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.
//@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: 1095is 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
weekOfYearinstead of month and swap the column labels for week numbers. - Win rate only. Drop the AVG and MED rows and keep WIN%: the
summaryargument takes one to five rows. - Different formatting.
format = "percent"renders0.047as+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.3gives 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.