---
title: Historical Returns Matrix
description: >-
  Month-by-year percentage returns as a foreign-domain matrix pane: rows are
  years, columns are months, with AVG / MED / WIN% summary rows and the
  forming month highlighted. Identical on every chart interval.
verified:
  engine: 3.0.67
  probes:
    - scripts/probes/cookbook/monthly_returns.ks
---

<div class="flex gap-3 mb-6">
  <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 text-blue-600 text-sm font-medium">
    Intermediate
  </span>
  <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-sm font-medium">
    6 min read
  </span>
</div>

# Historical Returns Matrix

The classic seasonality table: every month's percentage return, laid out as a
grid with one row per year and one column per month, plus three summary rows
(average, median, and win rate per calendar month) and the still-forming month
highlighted. It renders in a [foreign-domain pane](/kscript/functions/foreign-panes),
so the grid ignores time pan and zoom entirely and looks identical whether the
chart is on 1m or 1w.

This recipe is also the shortest path to understanding the foreign-pane
workflow end to end: pull a dataset that does not depend on the chart window,
reduce it to categories, and hand the finished grid to
[`plotMatrix`](/kscript/functions/foreign-panes#plotmatrix) once.

```javascript title="historical_returns.ks" lines wrap
//@version=3
// ============================================================================
//  MONTHLY RETURNS
//  Month × year percentage returns rendered as a foreign-domain MATRIX pane
//  (rows = years, columns = months) with AVG / MED / WIN% summary rows per
//  month and the latest data month highlighted.
//
//  Computed from ONE source of truth: native daily history pinned to the
//  newest rows (requestBars + anchor "latest"), which the host keeps live
//  with a dedicated tail refresh. The grid is therefore identical on every
//  chart interval, stable across historical pans, and free of chart-window
//  freshness races; the live month's cell updates at the tail-refresh
//  cadence. Return for month M = close(M) / close(M-1) - 1, where close(M)
//  is the month's last daily close.
// ============================================================================
define("Monthly Returns", "offchart", true)

// The chart source only drives the bar timeline (isLastBar + re-runs); all
// returns math reads the anchored daily history below.
timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  // Three years of native daily bars, independent of the chart interval,
  // loaded depth, and historical pans.
  var rows = requestBars(currentSymbol, "1d", { bars: 1095, anchor: "latest" })

  // A venue with no native daily data makes requestBars fall back to
  // chart-interval rows. Detect that by the SMALLEST consecutive spacing
  // (real dailies have ~1-day minimum steps even when weekends or halts
  // leave larger gaps): multi-day fallback rows would mislabel months (a
  // bar's close can land in the month after its open), so treat them as
  // unusable rather than silently wrong. Intraday fallback rows bucket
  // correctly by timestamp, just over shallower history.
  if (rows.length > 1) {
    var minStep = na
    for (var i = 1; i < rows.length; i = i + 1) {
      var step = rows[i][0] - rows[i - 1][0]
      if (isnum(minStep) == false || step < minStep) {
        minStep = step
      }
    }
    if (isnum(minStep) && minStep > 129600000) {
      rows = []
    }
  }

  // Last daily close per calendar month, chronological: key = year*100+month.
  var monthKeys = []
  var monthCloses = []
  for (var i = 0; i < rows.length; i = i + 1) {
    var c = rows[i][4]
    if (isnum(c) && c > 0) {
      var key = year(rows[i][0]) * 100 + month(rows[i][0])
      if (monthKeys.length == 0 || monthKeys[monthKeys.length - 1] != key) {
        monthKeys.push(key)
        monthCloses.push(c)
      } else {
        monthCloses[monthCloses.length - 1] = c
      }
    }
  }

  if (monthKeys.length > 0) {
    // Distinct years, chronological (months arrive in order).
    var years = []
    for (var i = 0; i < monthKeys.length; i = i + 1) {
      var y = math.floor(monthKeys[i] / 100)
      if (years.length == 0 || years[years.length - 1] != y) {
        years.push(y)
      }
    }

    // Month-over-month % returns placed at [yearRow, monthCol]. Flat parallel
    // arrays (value + calendar month) so the summary pass below can re-bucket.
    // Ratio rows require strictly POSITIVE endpoints (the valid-only close
    // scan above guarantees that).
    var cells = []
    var retVals = []
    var retCols = []
    for (var i = 1; i < monthKeys.length; i = i + 1) {
      var key = monthKeys[i]
      var prevKey = monthKeys[i - 1]
      // Calendar adjacency: a month with no valid close must not turn the
      // next month's cell into a mislabeled multi-month move.
      var expected = prevKey % 100 == 12 ? (math.floor(prevKey / 100) + 1) * 100 + 1 : prevKey + 1
      if (key == expected) {
        var ret = monthCloses[i] / monthCloses[i - 1] - 1
        var row = years.indexOf(math.floor(key / 100))
        var col = key % 100 - 1
        cells.push([row, col, ret])
        retVals.push(ret)
        retCols.push(col)
      }
    }

    // Summary rows: AVG / MED / WIN% of each calendar month's returns across
    // years; months with no observations stay blank (na).
    var avgRow = []
    var medRow = []
    var winRow = []
    for (var m = 0; m < 12; m = m + 1) {
      var monthRets = []
      for (var i = 0; i < retVals.length; i = i + 1) {
        if (retCols[i] == m) {
          monthRets.push(retVals[i])
        }
      }
      if (monthRets.length == 0) {
        avgRow.push(na)
        medRow.push(na)
        winRow.push(na)
      } else {
        var sum = monthRets.reduce((acc, r) => acc + r, 0)
        var wins = monthRets.filter((r) => r > 0)
        var sorted = monthRets.slice(0, monthRets.length).sort()
        var mid = math.floor(sorted.length / 2)
        var med = sorted.length % 2 == 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
        avgRow.push(sum / monthRets.length)
        medRow.push(med)
        winRow.push(wins.length / monthRets.length)
      }
    }

    // Highlight the latest DATA month: the last daily bar's month is the one
    // still forming.
    var lastKey = monthKeys[monthKeys.length - 1]
    plotMatrix(
      data = cells,
      rowLabels = years.map((y) => "".concat(y)),
      colLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
      format = "percent",
      summary = [
        { label: "AVG", values: avgRow },
        { label: "MED", values: medRow },
        { label: "WIN%", values: winRow }
      ],
      highlight = { row: years.indexOf(math.floor(lastKey / 100)), col: lastKey % 100 - 1 },
      gridHeight = 0.3
    )
  }
}
```

## How it works

**One source of truth for the data.** The grid is computed from
[`requestBars`](/kscript/core-concepts/multi-source) with `"1d"` and
`anchor: "latest"`: three years of native daily bars pinned to the newest data,
independent of the chart's interval, its loaded depth, and wherever the user
has panned. That anchor is the whole trick. Without it, the request follows the
chart window, and panning into history would silently rewrite last year's
returns. The chart source at the top only drives the bar timeline; nothing is
computed from it.

**Guard against fallback rows.** A venue with no native daily history makes
`requestBars` fall back to chart-interval rows. Multi-day rows would mislabel
months (a bar's close can land in the month after its open), so the script
scans for the smallest consecutive bar spacing and discards the dataset when
that step is wider than 1.5 days. An empty dataset simply renders an empty
pane, which is honest; a silently mislabeled grid is not.

**Reduce to month closes, then to returns.** One pass keeps the last valid
daily close per calendar month (key = `year * 100 + month`). The return for a
month is its close over the previous month's close, minus one, and only
calendar-adjacent months produce a cell: if a month has no valid close, the
gap stays a gap instead of turning the next cell into a mislabeled two-month
move. Each return lands at `[yearRow, monthCol, value]`, which is exactly the
cell shape `plotMatrix` wants.

**Summary rows and the highlight.** The three `summary` rows re-bucket the
same returns by calendar month across years: mean, median (via the
[`sort()`](/kscript/core-concepts/collections) comparator-free numeric sort),
and share of positive months. Months with no observations push `na`, which
renders as a blank summary cell. `highlight` pins the eye to the forming
month: the last daily bar's year row and month column.

**Emit once, from the last bar.** Foreign panes take the finished dataset in
one call, so everything runs inside `if (isLastBar)` and the script ends with
a single `plotMatrix(...)` carrying data, labels, `format = "percent"`,
summaries, the highlight, and a `gridHeight` of 0.3.

## Customize it

- **Depth.** `bars: 1095` is three years. Stretch it to five (`1825`) and the
  grid simply grows more year rows; the caps are far away (64 rows).
- **Weekly seasonality.** Re-key the bucketing on
  [`weekOfYear`](/kscript/core-concepts/time-and-sessions) instead of month
  and swap the column labels for week numbers.
- **Win rate only.** Drop the AVG and MED rows and keep WIN%: the `summary`
  argument takes one to five rows.
- **Different formatting.** `format = "percent"` renders `0.047` as `+4.7%`.
  Use `"number"` for raw ratios, and see the
  [foreign-pane reference](/kscript/functions/foreign-panes#plotmatrix) for
  the full argument surface, caps, and error messages.
- **Pane height.** `gridHeight = 0.3` gives the pane 30% of the chart. Any
  value in 0.1 to 0.8 works.

The same accumulate-then-emit pattern drives the other two pane kinds:
[`plotCurve`](/kscript/functions/foreign-panes#plotcurve) for numeric-X
function panes (volatility cones, return distributions) and
[`plotTiles`](/kscript/functions/foreign-panes#plottiles) for KPI dashboards.
