Panes & Dashboards

Historical Returns Matrix

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.

Intermediate 6 min read

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, 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 once.

historical_returns.ks
//@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 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() 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 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 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 for numeric-X function panes (volatility cones, return distributions) and plotTiles for KPI dashboards.