Panes & Dashboards

Foreign-Domain Panes

Offchart panes that ignore the time axis: categorical grids (plotMatrix), numeric-X curves (plotCurve), and KPI tile dashboards (plotTiles), plus the shared structural rules and caps.

Every plot on the Plotting page draws against the chart's shared time axis. The foreign-pane builtins are different: each one emits a whole offchart pane with a pane-private X domain (a category grid, a numeric axis, a card layout) instead of a time series. The chart renders these panes exempt from time-axis pan/zoom and the crosshair, and hides the legend's per-timestamp value readout by default, because there is no "value at this timestamp" for non-time rows.

FunctionDescription
plotMatrixCategorical grid: rows × columns of values (returns tables, correlations, seasonality)
plotCurveFunction pane over a numeric X axis (distributions, curves over price or horizon)
plotTilesKPI card dashboard (snapshot stats)

For a complete worked recipe, start with the Historical Returns Matrix cookbook page: it builds a month-by-year returns grid end to end and explains each decision.

How foreign panes behave

The three builtins share the same ground rules:

  • The script must be offchart. A foreign-pane script's define uses position: "offchart"; every example below does.
  • One foreign pane per script, and nothing beside it. A second pane call, or any time-domain plot in the same script, is a build error, not a merge: see Structural rules.
  • Call the builtin once, with the finished dataset, from an isLastBar block. The idiomatic shape is: accumulate per-bar state in static arrays as the bars replay, then bucket, derive, and plot in the finalization block. Each run re-emits the whole pane.
  • Rows are delivered verbatim. No timestamps are prefixed to your rows, and there is no lazyload/backfill for foreign rows; the output's data is exactly the array you passed (minus dropped na rows, see below).
  • gridHeight is shared. All three accept gridHeight (0.05 to 0.9, the pane's height as a fraction of the chart), and the pane relayouts when the emitted value changes. Both endpoints are legal: the probes below run at 0.05 and at 0.9.
  • Write these scripts with //@version=3. The object-literal kwargs (summary, highlight) and the lambda-heavy examples are v3 surface; a v3-headed script runs lambdas (map/filter/reduce), object literals, and the foreign-pane builtins through the full build ladder with no downgrade.
  • No label/desc. Unlike the series plots, foreign-pane builtins take no label arrays; the strict build passes without them (the pane's identity is the script itself).

Structural rules

A foreign pane is not one plot among many: it owns the script's whole output, and the build enforces that shape before the script ever runs. Each rejection below stops the strict build and the compile; every message is quoted verbatim from engine 3.0.67, reproduced by a probe, including the line:column position the editor anchors the diagnostic at.

You wroteExact error
A pane call with no define() in the scriptScript must start with a define(title, position, axis) or strategy(title) statement at 5:1
A pane call inside a strategy() scriptplotMatrix requires define(title, "offchart", ...): foreign-domain panes own a dedicated offchart pane at 12:3
define("...", "onchart", true) plus a pane callplotMatrix requires define(position="offchart"): foreign-domain panes ignore the shared time axis (got "onchart") at 5:1
plotMatrix(...) and then plotCurve(...)Only one foreign-domain pane output per script: plotCurve conflicts with plotMatrix (line 10) at 15:3
plotLine(...) in the same script as plotMatrix(...)plotLine cannot be combined with plotMatrix: a foreign-domain pane owns the script's whole output at 9:1

How to read them:

  • A script with no define() at all never reaches the pane-specific message: the general script-start rule fires first. The pane-specific missing-define text (requires define(title, "offchart", ...)) is what you get when the script's header exists but is not a define (the probe uses a strategy() header), and it anchors at the pane call itself.
  • Wrong position anchors at the define statement, and the message quotes the position it found (got "onchart").
  • Two panes: the message names the second pane call as conflicting with the first, (line 10) is the line of the first pane call, and the diagnostic anchors at the second call (15:3). The rule spans kinds (the probe mixes plotMatrix and plotCurve).
  • Pane plus time-domain plot: the message names the time-domain plot and anchors at its call (plotLine at 9:1 in the probe, where the plotLine sits above the pane call). There is no way to keep a "small" line plot next to a pane; give it its own script.

When the shape is legal, the script emits exactly one main output: its plot kind is the literal matrix / curve / tiles, the emitted define.position is "offchart", and settings carries only the keys you passed, verbatim. The happy-path probes for all three kinds confirm the round-trip, down to a curve marker's literal x: "spot" surviving untouched.

Pane inputs are never style-only

On time-domain plots, a color input bound directly to a style argument is classified style-only: input("lineColor", "color", "#38bdf8") fed to plotLine(colors=[lineColor], ...) reports styleOnly: true, settingsTier: "redraw", and a style binding to the plot's color property, so changing it restyles the plot without re-running the script. Bind the same kind of input to a foreign-pane style argument (plotCurve(color = payoffColor, ...)) and the classification flips: styleOnly: false, affectsCompute: true, settingsTier: "recompute", no style binding. Pane style arguments are deliberately absent from the style-only analysis, so any settings change on a foreign-pane script re-runs the script. Treat pane inputs as recompute triggers, not live style patches.

Caps

One shared table of limits covers all three builtins. Every cap is enforced at the source with a named error that quotes the offending value (never a silent truncation or clamp), and the values mirror the chart engine's frozen pane-contract constants, so nothing gets re-clamped downstream either. Most violations that are literals in your source stop the strict build; a few argument checks (for example plotTiles.columns) report when the script runs. Bounds are inclusive: probes run clean with gridHeight at exactly 0.05 (curve) and exactly 0.9 (tiles), a 24-character label, and a fully-covered 64 × 64 matrix.

LimitValueApplies to
Label length24 charsmatrix rowLabels / colLabels entries, curve series[].label, tile label
Summary label length12 charsmatrix summary[].label
Marker label length16 charscurve markers[].label
Tile text length32 charstile text, computed texts[].text
Caption length48 charstile caption
Color string length32 charsevery pane color setting (curve color, series[].color, markers[].color)
Matrix cells20,000 data rowsplotMatrix data
Matrix axis labels64 entries eachrowLabels, colLabels
Matrix summary rows5summary
Curve series6data columns beyond x, series entries
Curve points50,000 data rowsplotCurve data
Curve markers8markers
Tiles24tiles entries (also bounds the tileIndex a data row may address)
Tile columns1 to 8columns
gridHeight0.05 to 0.9all three builtins

Cross-kind demonstrations (the per-builtin sections below carry the full per-message tables):

  • Emit curve rows carrying seven series columns and the build stops with plotCurve supports at most 6 series, data[0] carries 7 at 10:13: a named error, not a truncation to six.
  • A 25-character matrix axis label stops the build with plotMatrix.rowLabels[0] exceeds 24 characters (got 25: "ABCDEFGHIJKLMNOP…") at 11:18.
  • columns = 9 on plotTiles compiles, then the run reports plotTiles.columns must be an integer in 1..8, got 9 at 9:3.
  • An out-of-range gridHeight stops the build on any kind, with the message prefixed by the builtin that got it: plotMatrix.gridHeight must be in 0.05..0.9, got 0.04 at 13:18 and plotCurve.gridHeight must be in 0.05..0.9, got 0.91 at 11:18.

plotMatrix

Renders a categorical grid: rowLabels × colLabels cells, each holding one number. Use it for month-by-year returns tables, correlation matrices, weekday/month seasonality grids, or any rows-by-columns summary your loaded history can compute.

plotMatrix(data, rowLabels, colLabels, format?, summary?, highlight?, gridHeight?, hideLegendValues?)

ParameterTypeDescription
dataarray[rowIdx, colIdx, value] rows. rowIdx/colIdx are 0-based indices into the label arrays; value is a finite number. Up to 20,000 rows; [] is legal (cells nobody covers render empty).
rowLabelsstring[]Required. One label per grid row: 1 to 64 entries, each up to 24 characters.
colLabelsstring[]Required. One label per grid column; same limits as rowLabels.
formatstring"number" (compact, e.g. 62.5K) or "percent" (0.047 renders as +4.7%). When omitted, no format key is emitted and the engine renders its "number" default.
summaryobject[]Up to 5 pinned full-width rows under the grid, each { label, values }: label up to 12 characters, values column-aligned (exactly one entry per column), na renders as a blank.
highlightobject{ row?: r, col?: c }: emphasizes a row and/or column (e.g. the forming month).
gridHeightnumberPane height as a fraction of the chart, 0.05 to 0.9.
hideLegendValuesbooleanForeign panes hide the legend value readout by default; pass false to keep it.

Minimal call

The smallest viable call is data + rowLabels + colLabels:

happy-minimal.ks
//@version=3
// plotMatrix happy path, smallest viable call: data + rowLabels + colLabels
// only. format defaults to "number"; summary/highlight/gridHeight omitted.
// One cell reads the live close so the source stays wired.
define("PM Happy Minimal", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotMatrix(
    data = [[0, 0, 0.5], [0, 1, -0.25], [1, 2, d.close]],
    rowLabels = ["A", "B"],
    colLabels = ["X", "Y", "Z"]
  )
}

What lands in the output is exactly what you passed: the output's plot type is matrix, data is your three rows verbatim, and settings carries only rowLabels and colLabels. Nothing is injected for you: omit format and no format key exists in the emitted settings (the engine then renders its compact-number default).

Every parameter

happy-full-kwargs.ks
//@version=3
// plotMatrix with every kwarg: percent format, one summary row whose values
// include na (renders blank / null on the wire), highlight row+col,
// gridHeight, hideLegendValues=false.
define("PM Full Kwargs", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotMatrix(
    data = [[0, 0, 0.047], [0, 1, -0.012], [1, 0, 0.003], [1, 2, 0.101]],
    rowLabels = ["2023", "2024"],
    colLabels = ["Jan", "Feb", "Mar"],
    format = "percent",
    summary = [
      { label: "AVG", values: [0.025, -0.012, na] }
    ],
    highlight = { row: 1, col: 2 },
    gridHeight = 0.3,
    hideLegendValues = false
  )
}

The observed output for this script carries format: "percent", the summary row with values [0.025, -0.012, null], highlight: { row: 1, col: 2 }, gridHeight: 0.3, and hideLegendValues: false. Note the na inside summary[].values: it is emitted as null and renders as a blank column in the pinned row, so a summary row can leave columns open.

A data row whose value is na is dropped before any other validation:

na-value-drops-cell.ks
//@version=3
// A data row whose value is na is DROPPED before any index validation:
// [1, 1, na] emits no cell, and even the wildly out-of-range [9, 9, na]
// does not error (the value short-circuit runs before the rowIdx/colIdx
// range checks). Only the two real cells reach the wire. gridHeight at the
// 0.05 minimum.
define("PM NA Drops Cell", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotMatrix(
    data = [[0, 0, 1.5], [1, 1, na], [9, 9, na], [1, 0, 2.5]],
    rowLabels = ["A", "B"],
    colLabels = ["X", "Y"],
    gridHeight = 0.05
  )
}

Only the two real cells reach the output (dataLength: 2 observed). The na check short-circuits ahead of the index range checks, which is why even the wildly out-of-range [9, 9, na] does not error. No data means no cell, never an exception.

data = [] is legal too: there is no minimum row count, and uncovered cells simply render empty (probe empty-data-ok.ks passes the full ladder with an empty array). Labels are different: an empty rowLabels is rejected, see the error table below.

Validation: caps and exact errors

Every limit is enforced with a named error that quotes the offending value and points at your source with a line:column suffix; nothing is silently truncated. When the offending value is a literal in your source (an over-long label string, six literal summary rows), the strict build flags it before the script ever runs; computed violations (loop-built arrays, cross-argument checks like column alignment or index ranges) surface when the script runs. The messages below are quoted from engine 3.0.67, each reproduced by a probe:

What was emittedExact error
20,001 data rowsplotMatrix.data must have at most 20000 rows, got 20001 at 17:3
rowIdx 2 with only 2 row labelsplotMatrix.data[1] rowIdx must be an integer in 0..1, got 2 at 9:3
The cell (1, 1) twiceplotMatrix.data has duplicate cell (1, 1) at 8:3
65 column labelsplotMatrix.colLabels must have at most 64 entries, got 65 at 12:3
rowLabels = []plotMatrix.rowLabels must not be empty at 8:3
A 25-character axis labelplotMatrix.rowLabels[1] exceeds 24 characters (got 25: "ABCDEFGHIJKLMNOP…") at 11:24
6 summary rowsplotMatrix.summary must have at most 5 rows, got 6 at 12:15
A 13-character summary labelplotMatrix.summary[0].label exceeds 12 characters (got 13: "THIRTEENCHAR!…") at 14:16
2 summary values under 3 columnsplotMatrix.summary[0].values must have one entry per column (3), got 2 at 9:3

Three behaviors worth knowing:

  • The row-count cap fires on the raw array length, before per-row checks. The 20,001-row probe is 20,001 identical duplicate rows, and it reports the cap error, never the duplicate-cell error.
  • You can only reach the row cap by over-emitting. With rowLabels/colLabels capped at 64 entries each, the densest legal grid is 64 × 64 = 4,096 distinct cells.
  • Every cap is inclusive. A probe sitting at every limit simultaneously (64 × 64 labels, all 4,096 cells covered, a 24-character axis label, 5 summary rows with 12-character labels and 64 column-aligned values each, highlight = { row: 63, col: 63 }, gridHeight = 0.9) builds and runs clean.

Full example: seasonality grid

The chart-source accumulation pattern: bucket each bar's close-over-close return into static weekday × month sums as the bars replay, then emit averages once on the last bar. Buckets with no observations stay blank, the summary row is the per-month average across weekdays, and the highlight tracks the latest bar's cell.

SEASONALITY_GRID.ks
//@version=3
// Weekday x calendar-month seasonality: each cell is the average single-bar
// close-over-close return for that (weekday, month) bucket across the loaded
// history. Buckets with no observations stay blank; the summary row is the
// per-month average across all weekdays. Highlight tracks the latest bar.
define("Seasonality Grid", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

static sums = []
static counts = []
static prevBox = []
static lastCell = []

if (sums.length == 0) {
  for (var i = 0; i < 84; i = i + 1) {
    sums.push(0)
    counts.push(0)
  }
  lastCell.push(0)
  lastCell.push(0)
}

var dayNames = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
var row = dayNames.indexOf(dayOfWeek())
var col = month() - 1
if (isnum(d.close) && row >= 0) {
  // The highlight tracks the latest NUMERIC bar even when its return is
  // skipped (first bar after a gap has no valid baseline).
  lastCell[0] = row
  lastCell[1] = col
  if (d.close > 0 && prevBox.length > 0 && isnum(prevBox[0]) && prevBox[0] > 0) {
    var idx = row * 12 + col
    sums[idx] = sums[idx] + (d.close / prevBox[0] - 1)
    counts[idx] = counts[idx] + 1
  }
}
if (prevBox.length == 0) {
  prevBox.push(d.close)
} else {
  prevBox[0] = d.close
}

if (isLastBar) {
  var cells = []
  for (var r = 0; r < 7; r = r + 1) {
    for (var c = 0; c < 12; c = c + 1) {
      var k = r * 12 + c
      if (counts[k] > 0) {
        cells.push([r, c, sums[k] / counts[k]])
      }
    }
  }
  var avgRow = []
  for (var c = 0; c < 12; c = c + 1) {
    var s = 0
    var n = 0
    for (var r = 0; r < 7; r = r + 1) {
      var k = r * 12 + c
      s = s + sums[k]
      n = n + counts[k]
    }
    if (n > 0) {
      avgRow.push(s / n)
    } else {
      avgRow.push(na)
    }
  }
  if (cells.length > 0) {
    plotMatrix(
      data = cells,
      rowLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
      colLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
      format = "percent",
      summary = [
        { label: "AVG", values: avgRow }
      ],
      highlight = { row: lastCell[0], col: lastCell[1] },
      gridHeight = 0.3
    )
  }
}

Full example: monthly returns, interval-independent

Accumulating from the chart source makes a pane depend on the chart's interval and loaded depth: fine for a distribution, wrong for a calendar table. The fix is to compute from native daily history fetched with requestBars() and anchor: "latest", so the grid is identical on every chart interval and stable across historical pans. One consequence to expect: a month-over-month return needs a predecessor month, so the grid needs at least two calendar months with a valid daily close before its first cell exists; with less history the script plots nothing rather than guessing.

MONTHLY_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
    )
  }
}

Full example: cross-symbol correlation grid

Cross-symbol sourcing works inside a foreign-pane script: source("ohlcv", "ETHUSDT", currentExchange) fetches a literal symbol, equal-interval sources align per bar, and each extra symbol consumes a data-source slot. This grid computes pairwise Pearson correlations of single-bar returns for five majors, with format = "number" and a single AVG summary row. It also shows the storage idiom for parallel series: one flat static array with stride indexing, because kScript arrays are homogeneous and nested static arrays cannot receive method calls.

CORRELATION_GRID.ks
//@version=3
// Cross-symbol correlation matrix: pairwise Pearson correlation of single-bar
// returns for five majors on the chart's exchange, over the loaded shared
// history (capped at the most recent 365 aligned bars). Equal-interval
// sources align per bar, so the accumulation is a plain parallel pass: a bar
// contributes only when every symbol has a numeric close. Returns are stored
// in ONE flat array with stride 5 (kScript arrays are homogeneous and nested
// element methods are not analyzable, so flat + index math is the idiom).
// The pane ignores the time axis; each extra symbol consumes a source slot.
define("Correlation Grid", "offchart", true)

timeseries sBTC = source("ohlcv", "BTCUSDT", currentExchange)
timeseries sETH = source("ohlcv", "ETHUSDT", currentExchange)
timeseries sSOL = source("ohlcv", "SOLUSDT", currentExchange)
timeseries sXRP = source("ohlcv", "XRPUSDT", currentExchange)
timeseries sBNB = source("ohlcv", "BNBUSDT", currentExchange)

static prevRow = []
static rets = []

var closesNow = [sBTC.close, sETH.close, sSOL.close, sXRP.close, sBNB.close]
var allNum = true
for (var i = 0; i < 5; i = i + 1) {
  if (isnum(closesNow[i]) == false || closesNow[i] <= 0) {
    allNum = false
  }
}
if (allNum) {
  if (prevRow.length == 5) {
    // Rolling window: only the newest 366 strides are ever kept, so the
    // buffer stays far under the runtime's collection ceiling on long
    // intraday histories (the analysis window is 365 anyway).
    if (rets.length >= 5 * 366) {
      for (var i = 0; i < 5; i = i + 1) {
        rets.removeAt(0)
      }
    }
    for (var i = 0; i < 5; i = i + 1) {
      rets.push(closesNow[i] / prevRow[i] - 1)
    }
  }
  if (prevRow.length == 0) {
    for (var i = 0; i < 5; i = i + 1) {
      prevRow.push(closesNow[i])
    }
  } else {
    for (var i = 0; i < 5; i = i + 1) {
      prevRow[i] = closesNow[i]
    }
  }
} else {
  // A gap bar invalidates the baseline: without this, the next complete bar
  // would compute a multi-bar "return" against the pre-gap close.
  prevRow.clear()
}

if (isLastBar && rets.length >= 55) {
  var m = math.floor(rets.length / 5)
  var startBar = m > 365 ? m - 365 : 0
  var count = m - startBar

  // Per-symbol means over the window.
  var means = []
  for (var s = 0; s < 5; s = s + 1) {
    var sum = 0
    for (var k = startBar; k < m; k = k + 1) {
      sum = sum + rets[k * 5 + s]
    }
    means.push(sum / count)
  }

  var cells = []
  var colSums = []
  var colCounts = []
  for (var c = 0; c < 5; c = c + 1) {
    colSums.push(0)
    colCounts.push(0)
  }
  for (var a = 0; a < 5; a = a + 1) {
    for (var b = 0; b < 5; b = b + 1) {
      if (a == b) {
        cells.push([a, b, 1])
      } else {
        var cov = 0
        var varA = 0
        var varB = 0
        for (var k = startBar; k < m; k = k + 1) {
          var dx = rets[k * 5 + a] - means[a]
          var dy = rets[k * 5 + b] - means[b]
          cov = cov + dx * dy
          varA = varA + dx * dx
          varB = varB + dy * dy
        }
        if (varA > 0 && varB > 0) {
          var corr = cov / math.sqrt(varA * varB)
          cells.push([a, b, corr])
          colSums[b] = colSums[b] + corr
          colCounts[b] = colCounts[b] + 1
        }
      }
    }
  }

  var avgRow = []
  for (var c = 0; c < 5; c = c + 1) {
    if (colCounts[c] > 0) {
      avgRow.push(colSums[c] / colCounts[c])
    } else {
      avgRow.push(na)
    }
  }

  plotMatrix(
    data = cells,
    rowLabels = ["BTC", "ETH", "SOL", "XRP", "BNB"],
    colLabels = ["BTC", "ETH", "SOL", "XRP", "BNB"],
    format = "number",
    summary = [
      { label: "AVG", values: avgRow }
    ],
    gridHeight = 0.3
  )
}

plotCurve

Renders a function pane over a numeric X axis: each data row is one X coordinate plus up to six Y values, drawn as lines or bars with optional vertical reference markers. Use it for return distributions, volatility cones over horizon, gamma profiles over hypothetical spot, or any y = f(x) dataset whose X is a price, a horizon, or a bucket rather than time.

plotCurve(data, series?, style?, color?, fill?, markers?, valueFormat?, gridHeight?, hideLegendValues?)

ParameterTypeDescription
dataarrayRequired. [x, v1, ...vn] rows: one leading X plus one value column per series (series i reads column i+1). x must be a finite number, strictly ascending row to row; every row carries the same column count. Up to 50,000 rows and 6 value columns. Value cells may be na (a gap, see below).
seriesobject[]Per-series styling, index-aligned with the value columns: { label?, color?, fill?, style? }. Up to 6 entries; label up to 24 characters; a per-series style ("line" or "bars") wins over the pane default.
stylestring"line" or "bars": the whole-pane default render style.
colorstringSeries-0 color shorthand (single-series ergonomics).
fillbooleanSeries-0 area-fill shorthand.
markersobject[]Up to 8 vertical reference lines: { x, label?, color? }. x is a finite number or the literal "spot", which tracks the main series' last close live; label up to 16 characters.
valueFormatstring"number", "currency", "percent", or "compact": one formatter drives both the pane's Y axis and its hover tooltips, so they can never disagree. Values render as-is in the chosen unit ("percent" appends % without multiplying, so emit 39.7, not 0.397). When omitted, no key is emitted.
gridHeightnumberPane height as a fraction of the chart, 0.05 to 0.9.
hideLegendValuesbooleanForeign panes hide the legend value readout by default; the boolean you pass lands verbatim in the emitted settings.

Minimal call

data alone is a complete call; every other kwarg is optional:

ok-minimal-data-only.ks
//@version=3
// MINIMAL CALL: data only, every other kwarg omitted (no series, no style,
// no gridHeight). Proves which kwargs are truly optional.
define("Curve Minimal Data Only", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotCurve(
    data = [
      [1, 10],
      [2, 12],
      [3, 11]
    ]
  )
}

The observed output is plot type curve with the three rows verbatim and settings: {}: nothing you did not pass is injected, and every omitted kwarg emits no key at all.

na gaps a series; x must be real

Only the value columns may be na. An na cell is a gap, not a zero: it lands as null on the wire and the series breaks at that X:

ok-na-gap.ks
//@version=3
// na series values are GAPS, not zeros: the line breaks at x=2 (series 1) and
// x=3 (series 2). Verifies na is accepted in value columns (only x must be a
// real ascending number) and lands as null on the wire.
define("Curve NA Gap", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotCurve(
    data = [
      [1, 10, 20],
      [2, na, 21],
      [3, 12, na],
      [4, 13, 23],
      [5, 11, 22]
    ],
    series = [
      { label: "A", color: "#43d3c3" },
      { label: "B", color: "#7a8cff" }
    ],
    gridHeight = 0.2
  )
}

The observed rows are [2, null, 21] and [3, 12, null]: x stays numeric, the na cells become null, and each series simply skips its gap. The leading x gets no such tolerance; an na there is a named error (see the table below).

Styles: pane default, per-series override, series-0 shorthands

style sets the default for every series; a series[i].style overrides it for that series alone. The call-level color and fill are shorthands that style series 0:

ok-mixed-styles-hidelegend.ks
//@version=3
// Per-series style overrides the whole-pane default: pane style "line", but
// series 2 renders as bars. Also exercises hideLegendValues = true and the
// series-0 shorthands (color + fill at call level).
define("Curve Mixed Styles", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotCurve(
    data = [
      [0, 5, 2],
      [1, 7, 4],
      [2, 6, 3],
      [3, 9, 5],
      [4, 8, 1]
    ],
    style = "line",
    color = "#5b8def",
    fill = true,
    series = [
      { label: "Curve" },
      { label: "Histogram", color: "#f5c518", style: "bars" }
    ],
    hideLegendValues = true,
    gridHeight = 0.25
  )
}

The emitted settings carry all of it verbatim: style: "line", color: "#5b8def", fill: true, hideLegendValues: true, and the second series' style: "bars" override.

Markers and the 'spot' literal

markers draws up to 8 vertical reference lines. Each marker's x is a finite number, or the literal string "spot", which tracks the main series' last close live (use it when the curve's X domain is price). Both caps here are inclusive: exactly 8 markers, one of them carrying a 16-character label, build and run clean:

ok-markers-8-label16.ks
//@version=3
// CAP BOUNDARY (pass side): exactly 8 markers, first one with a label of
// exactly 16 chars ("ABCDEFGHIJKLMNOP"), plus the 'spot' literal tracker.
define("Curve Markers Max", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotCurve(
    data = [
      [0, 10],
      [10, 12],
      [20, 11],
      [30, 14]
    ],
    series = [
      { label: "S0" }
    ],
    markers = [
      { x: 1, label: "ABCDEFGHIJKLMNOP", color: "#f5c518" },
      { x: "spot", label: "Spot" },
      { x: 3, label: "M3" },
      { x: 4, label: "M4" },
      { x: 5, label: "M5" },
      { x: 6, label: "M6" },
      { x: 7, label: "M7" },
      { x: 8, label: "M8" }
    ],
    gridHeight = 0.2
  )
}

The "spot" string survives verbatim in the emitted settings ({ "x": "spot", "label": "Spot" } observed). It is the only string x accepts; any other string is rejected when the call executes:

plotCurve.markers[0].x must be a finite number, got string at 9:3

valueFormat

valueFormat accepts exactly four values: "number", "currency", "percent", and "compact"; each landed verbatim in the emitted settings in its own probe. The one to internalize is "percent": values render as-is with a % suffix, nothing is multiplied by 100, so the script emits percent-unit values:

ok-valueformat-percent.ks
//@version=3
// valueFormat "percent": values render AS-IS with a % suffix (no x100), so the
// script emits percent-unit values like 32.5 (not 0.325).
define("Curve ValueFormat Percent", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotCurve(
    data = [
      [5, 62.4],
      [10, 55.1],
      [20, 48.9],
      [30, 44.2],
      [60, 39.7]
    ],
    series = [
      { label: "Ann. Vol" }
    ],
    valueFormat = "percent",
    gridHeight = 0.2
  )
}

Anything else is a named enum error:

Invalid plotCurve.valueFormat 'scientific'. Expected one of: 'number', 'currency', 'percent', 'compact' at 9:3

Validation: caps and exact errors

Every limit is a named error quoting the offending value with a line:column suffix; nothing is silently sorted, truncated, or dropped (na value cells are preserved as null gaps, never removed). In the probes behind this table, the violations visible in literals (an over-long label, a ninth marker, a seventh series entry, a seventh value column, an out-of-range gridHeight, an onchart define) were flagged by the strict build before the script ever ran, while the data-shape rules (strictly ascending x, uniform row width, finite x), the row cap, the marker x type, and the valueFormat enum surfaced when the call executed. The messages below are quoted from engine 3.0.67, each reproduced by a probe:

What was emittedExact error
x going backwards (1, 3, 2)plotCurve.data must be strictly ascending in x: data[2] x=2 follows 3 at 9:3
The same x twiceplotCurve.data must be strictly ascending in x: data[2] x=2 follows 2 at 9:3
Rows with different column countsplotCurve.data rows must all carry the same series count (2), data[1] carries 1 at 9:3
x = naplotCurve.data[1][0] (x) must be a finite number, got na at 9:3
50,001 data rowsplotCurve.data must have at most 50000 rows, got 50001 at 13:3
7 value columns after xplotCurve supports at most 6 series, data[0] carries 7 at 11:7
7 series entriesplotCurve.series must have at most 6 entries, got 7 at 15:14
A 25-character series labelplotCurve.series[0].label exceeds 24 characters (got 25: "ABCDEFGHIJKLMNOP…") at 16:16
9 markersplotCurve.markers must have at most 8 entries, got 9 at 17:15
A 17-character marker labelplotCurve.markers[0].label exceeds 16 characters (got 17: "ABCDEFGHIJKLMNOP…") at 19:22
A marker x of "flip"plotCurve.markers[0].x must be a finite number, got string at 9:3
valueFormat = "scientific"Invalid plotCurve.valueFormat 'scientific'. Expected one of: 'number', 'currency', 'percent', 'compact' at 9:3
gridHeight = 0.95plotCurve.gridHeight must be in 0.05..0.9, got 0.95 at 17:18
define(..., "onchart", ...)plotCurve requires define(position="offchart"): foreign-domain panes ignore the shared time axis (got "onchart") at 5:1

The pass side of every cap is probed too: exactly 6 value columns with 6 series entries, exactly 8 markers (one of them "spot", one with a 16-character label), and exactly 50,000 rows all build, run, and emit in full. The 50,000-row probe's last wire row is [49999, …]: nothing was truncated.

Full example: gamma profile over spot

A two-series GEX curve whose X domain is hypothetical spot price: net GEX filled (crossing zero at the gamma flip), absolute GEX unfilled, a live "spot" tracker marker plus a fixed flip-level marker. Note where the call sits: at the top level, with no isLastBar gate. That is legal; the probe passes the full ladder and the run ends with one curve output carrying the 8 rows and both markers. Gate the call on isLastBar when the dataset derives from accumulated state, as the next two examples do.

corpus-gamma-profile.ks
//@version=3
// ============================================================================
//  GAMMA PROFILE (STATIC)
//  Dealer gamma exposure over hypothetical spot as a foreign-domain CURVE
//  pane: net GEX (filled, crossing zero at the gamma flip) plus absolute
//  GEX, a live 'spot' tracker marker, and the flip level marked. X is
//  hypothetical spot, so the pane ignores the chart's time axis entirely.
//
//  The rows below are an illustrative options-desk snapshot; the script
//  exists to prove the surface until a real options-chain source lands.
//  Swap `data` for computed [spot, netGex, absGex] rows when it does.
// ============================================================================
define("Gamma Profile", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

plotCurve(
  data = [
    [52000, -3.1, 3.4],
    [56000, -2.4, 2.9],
    [60000, -1.2, 2.1],
    [64000, -0.2, 1.8],
    [65500, 0.4, 1.9],
    [68000, 1.3, 2.4],
    [72000, 2.6, 3.1],
    [76000, 3.8, 4.0]
  ],
  markers = [
    { x: "spot", label: "Spot" },
    { x: 64800, label: "Gamma flip", color: "#f7525f" }
  ],
  series = [
    { label: "Net GEX ($B/1%)", color: "#43d3c3", fill: true },
    { label: "Abs GEX ($B/1%)", color: "#7a8cff", fill: false }
  ],
  style = "line",
  gridHeight = 0.22
)

Full example: return distribution histogram

The accumulate-then-finalize idiom with style = "bars": bucket single-bar returns into 1% bins as the bars replay, then plot the histogram once on the last bar, with the mean and +/-1 SD pinned as computed markers (marker x values are expressions, not just literals). On the 300-bar probe run this emitted all 21 bucket rows with the three computed markers in settings.

corpus-return-distribution.ks
//@version=3
// Histogram of single-bar percentage returns in 1% buckets clamped to +/-10%,
// rendered as a bars-style curve over the RETURN axis (not time). Reference
// markers pin the mean and +/-1 standard deviation of the distribution.
define("Return Distribution", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

static rets = []
static prevBox = []

if (isnum(d.close) && d.close > 0 && prevBox.length > 0 && isnum(prevBox[0]) && prevBox[0] > 0) {
  rets.push(d.close / prevBox[0] - 1)
}
if (prevBox.length == 0) {
  prevBox.push(d.close)
} else {
  prevBox[0] = d.close
}

if (isLastBar && rets.length > 1) {
  var counts = []
  for (var i = 0; i < 21; i = i + 1) {
    counts.push(0)
  }
  for (var i = 0; i < rets.length; i = i + 1) {
    var b = math.floor(rets[i] * 100 + 0.5) + 10
    if (b < 0) {
      b = 0
    }
    if (b > 20) {
      b = 20
    }
    counts[b] = counts[b] + 1
  }
  var rows = []
  for (var i = 0; i < 21; i = i + 1) {
    rows.push([i - 10, counts[i]])
  }
  var mean = rets.avg()
  var sd = rets.stdev()
  plotCurve(
    data = rows,
    style = "bars",
    color = "#5b8def",
    series = [
      { label: "Bars" }
    ],
    markers = [
      { x: mean * 100, label: "Mean", color: "#f5c518" },
      { x: (mean - sd) * 100, label: "-1 SD", color: "#8a8f98" },
      { x: (mean + sd) * 100, label: "+1 SD", color: "#8a8f98" }
    ],
    gridHeight = 0.28
  )
}

Full example: volatility cone

Four series over a horizon axis with valueFormat = "percent": for each horizon, the max / median / min annualized volatility across every rolling window of that length in the loaded history, plus the current window. The script multiplies into percent units (annFactor carries the * 100) precisely because "percent" renders values as-is. On the 300-bar probe run, all seven horizon rows landed on the wire, each carrying [h, max, median, min, current].

corpus-volatility-cone.ks
//@version=3
// Realized-volatility cone: for each horizon (in bars), the max / median / min
// annualized volatility across every rolling window of that length in the
// loaded history, plus the CURRENT (most recent) window. X is the horizon
// length, so the pane ignores the time axis entirely. Annualization assumes
// daily bars (sqrt(365)); on intraday charts read the values as relative.
define("Volatility Cone", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

static logRets = []
static prevBox = []

if (prevBox.length > 0 && isnum(prevBox[0]) && prevBox[0] > 0 && d.close > 0) {
  logRets.push(math.log(d.close / prevBox[0]))
}
if (prevBox.length == 0) {
  prevBox.push(d.close)
} else {
  prevBox[0] = d.close
}

if (isLastBar && logRets.length >= 10) {
  var horizons = [5, 10, 20, 30, 60, 90, 120]
  var ps = [0]
  var ps2 = [0]
  for (var i = 0; i < logRets.length; i = i + 1) {
    ps.push(ps[i] + logRets[i])
    ps2.push(ps2[i] + logRets[i] * logRets[i])
  }
  // Percent units (x100): valueFormat 'percent' renders values as-is with a
  // % suffix on both the Y axis and the hover card.
  var annFactor = math.sqrt(365) * 100
  var rows = []
  for (var hIdx = 0; hIdx < horizons.length; hIdx = hIdx + 1) {
    var h = horizons[hIdx]
    if (h <= logRets.length) {
      var vols = []
      for (var s = 0; s + h <= logRets.length; s = s + 1) {
        var mean = (ps[s + h] - ps[s]) / h
        var v = (ps2[s + h] - ps2[s]) / h - mean * mean
        if (v < 0) {
          v = 0
        }
        vols.push(math.sqrt(v) * annFactor)
      }
      rows.push([h, vols.max(), vols.median(), vols.min(), vols[vols.length - 1]])
    }
  }
  if (rows.length > 1) {
    plotCurve(
      data = rows,
      series = [
        { label: "Max", color: "#e5534b" },
        { label: "Median", color: "#8a8f98" },
        { label: "Min", color: "#3fb27f" },
        { label: "Current", color: "#f5c518" }
      ],
      valueFormat = "percent",
      gridHeight = 0.3
    )
  }
}

plotTiles

Renders a KPI card dashboard: up to 24 stat cards, each with a label, a live formatted value, an optional caption, and an accent color. The split that makes it tick: card structure (labels, captions, formats, accents) is static settings, while card values arrive as [tileIndex, value] data rows, so values refresh through the normal data path while the layout stays fixed.

plotTiles(tiles, data?, texts?, columns?, gridHeight?, hideLegendValues?)

ParameterTypeDescription
tilestile[]Required, 1 to 24 cards; see the tile keys below. Empty and over-cap are named errors (table below).
dataarray[tileIndex, value] rows, up to 24. tileIndex is a 0-based index into tiles; value is a finite number. A tile no row targets renders as awaiting data; data itself is omittable.
textsobject[]{ tile, text } entries: computed display strings for format: "text" tiles (see below).
columnsnumberColumn count, 1 to 8. When omitted, no columns key is emitted and the pane packs cards by its width.
gridHeightnumberPane height as a fraction of the chart, 0.05 to 0.9.
hideLegendValuesbooleanForeign panes hide the legend value readout by default; pass false to keep it. Emitted only when you set it.

Each tile is an object with a closed key set; an unknown key is a named error listing the allowed ones (label, caption, format, accent, text).

Tile keyTypeDescription
labelstringRequired, up to 24 characters. Omitting it is a type error; 25 characters are rejected at save.
captionstringOptional small print under the value, up to 48 characters.
formatstringOptional: "number" | "percent" | "currency" | "text". A label-only tile is valid; an omitted format is simply absent from the emitted settings (no default is injected).
accentstringOptional: "auto" | "pos" | "neg" | "neutral". "auto" colors by the value's sign; the other three force that accent.
textstringThe static display string of a format: "text" tile, up to 32 characters. Required exactly when format is "text", and only valid there.

Both enums are closed: a wrong format or accent is a named error listing the valid choices, quoted in the table below. ("compact" exists elsewhere as a plotCurve value format and a plotTable summary format; it is not a tile format.) The numeric formats render as: number compact (62.5K), percent a fraction in and a signed percentage out (0.047 displays as +4.7%), currency compact with a currency symbol ($64.5K).

tiles-happy-minimal.ks
//@version=3
// Minimal plotTiles happy path: all four formats, accents, computed text via
// the texts kwarg overriding the static fallback, columns + gridHeight +
// hideLegendValues.
define("Tiles Minimal Probe", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotTiles(
    tiles = [
      { label: "Last", format: "currency" },
      { label: "Change", caption: "one bar", format: "percent", accent: "auto" },
      { label: "Count", format: "number", accent: "pos" },
      { label: "Regime", format: "text", text: "AWAITING", accent: "neutral" }
    ],
    data = [
      [0, d.close],
      [1, d.close / d.open - 1],
      [2, barIndex]
    ],
    texts = [
      { tile: 3, text: d.close >= d.open ? "UP BAR" : "DOWN BAR" }
    ],
    columns = 2,
    gridHeight = 0.2,
    hideLegendValues = true
  )
}

On the wire, this emits one tiles output whose settings carry the four tile objects plus columns, gridHeight, and hideLegendValues, and whose data is [[0, <close>], [1, <change>], [2, 299], [3, "UP BAR"]]: the computed Regime string travels as a data row while settings.tiles[3].text keeps the static "AWAITING" fallback.

Values, na, and duplicates

  • Every data row must address a distinct, in-range tile: duplicates and out-of-range indices are named errors, and the range error names the actual range (0..0 on a 1-tile dashboard).
  • An na value drops the row before any validation runs. data = [[0, close], [0, na], [99, na]] on a 2-tile dashboard runs clean: no duplicate error for the second index-0 row, no out-of-range error for index 99, and only [0, close] reaches the chart. The dropped tile renders as awaiting data. The same rule applies to a texts entry whose text is na.
  • The 24-row cap is counted before na rows are dropped: 25 rows are rejected at save even when 24 of them are na.
  • A numeric row must target a numeric tile: pointing one at a format: "text" tile is a named error.
tiles-na-drop-before-validation.ks
//@version=3
// na drops a row BEFORE any validation, for both data rows and texts
// entries: tile index 99 is far out of range for a 2-tile dashboard, and
// [0, na] duplicates the finite [0, ...] row, yet the script runs clean
// because the na value/text removes the row first (no index error, no
// duplicate error).
define("Tiles NA Drop Probe", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotTiles(
    tiles = [
      { label: "Last", format: "currency" },
      { label: "Regime", format: "text", text: "AWAITING" }
    ],
    data = [
      [0, d.close],
      [0, na],
      [99, na]
    ],
    texts = [
      { tile: 99, text: na }
    ],
    gridHeight = 0.15
  )
}

Computed text: the texts kwarg

A format: "text" tile displays a string, not a number. Its static text is the required fallback; the texts kwarg sets the displayed string at run time:

texts = [{ tile: 6, text: last >= meanWindow ? "UP TREND" : "DOWN TREND" }]

Why a separate kwarg instead of a string row inside data? Because kScript array literals are homogeneous: data = [[0, "UP TREND"]] fails at save with Mixed array types not supported at 13:13, so the mixed [tileIndex, string] tuple cannot be authored statically. On the wire a computed text becomes exactly that mixed data row; texts is just the way to write one.

The rules:

  • The target must be a format: "text" tile, and the text tile still needs its static text even when a texts entry targets it (the static string is what shows until a run emits a computed one).
  • Computed text is capped at 32 characters.
  • A texts entry without a text key is a type error, not a silent drop; an entry whose text is na is dropped before index validation, exactly like an na data value, and the tile keeps its static fallback.
  • Two entries for the same tile are a duplicate. (A data row and a texts entry can never collide on one tile: numeric rows are rejected on text tiles, and texts is rejected on non-text tiles.)
  • data is fully omittable: a text-only dashboard driven entirely by texts runs fine (probe tiles-no-data-kwarg.ks).

The fallback behavior, demonstrated: this script's only texts entry always carries na, so no computed string is emitted and the Regime tile keeps showing AWAITING.

tiles-texts-static-fallback.ks
//@version=3
// A format:'text' tile with a static text and NO texts emission: the tile
// keeps its static fallback ("AWAITING"). The texts kwarg is present but its
// only entry carries na text, which drops the entry (before index validation),
// so no [tileIndex, string] wire row is emitted for tile 1.
define("Tiles Static Fallback Probe", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

if (isLastBar) {
  plotTiles(
    tiles = [
      { label: "Last", format: "currency" },
      { label: "Regime", format: "text", text: "AWAITING", accent: "neutral" }
    ],
    data = [
      [0, d.close]
    ],
    texts = [
      { tile: 1, text: barIndex < 0 ? "NEVER" : na }
    ],
    columns = 2,
    gridHeight = 0.15
  )
}

Validation: caps and exact errors

Same enforcement style as plotMatrix: named errors quoting the offending value, with a line:column suffix pointing at your source, never silent truncation. Caps on literals you typed (tile count, label/caption length, gridHeight, the data row count, the mixed-tuple rejection) fail at save; value-dependent checks (columns, duplicates, index bounds, format cross-checks, everything about texts) surface when the script runs. The messages below are quoted from engine 3.0.67, each reproduced by a probe:

What was emittedStageExact error
25 tilessaveplotTiles.tiles must have at most 24 entries, got 25 at 9:13
25 data rows (24 of them na)saveplotTiles.data must have at most 24 rows, got 25 at 11:12
A 25-character labelsaveplotTiles.tiles[0].label exceeds 24 characters (got 25: "ABCDEFGHIJKLMNOP…") at 9:23
A 49-character captionsaveplotTiles.tiles[0].caption exceeds 48 characters (got 49: "ABCDEFGHIJKLMNOP…") at 9:40
gridHeight = 0.95saveplotTiles.gridHeight must be in 0.05..0.9, got 0.95 at 11:18
A string literal inside datasaveMixed array types not supported at 13:13
tiles = []runplotTiles.tiles must not be empty at 8:3
columns = 0 (9 fails alike)runplotTiles.columns must be an integer in 1..8, got 0 at 8:3
A tile without labelrunType mismatch for 'plotTiles.tiles[0].label': expected string, got undefined at 8:3
color: key on a tilerunUnknown key 'color' in plotTiles.tiles[0] (allowed: 'label', 'caption', 'format', 'accent', 'text') at 9:3
format: "compact"runInvalid plotTiles.tiles[0].format 'compact'. Expected one of: 'number', 'percent', 'currency', 'text' at 9:3
accent: "sparkly"runInvalid plotTiles.tiles[0].accent 'sparkly'. Expected one of: 'auto', 'pos', 'neg', 'neutral' at 9:3
A finite value at index 5, 1 tilerunplotTiles.data[0] tileIndex must be an integer in 0..0, got 5 at 9:3
Two data rows for tile 0runplotTiles.data has duplicate tileIndex 0 at 9:3
Two texts entries for tile 1runplotTiles.texts has duplicate tileIndex 1 at 11:3
A numeric row at a text tilerunplotTiles.data[0] targets text tile 0 ("Regime"): text tiles take a string value, not a number at 9:3
texts at a non-text tilerunplotTiles.texts[0] targets tile 0 ("Last") which is not format "text" at 9:3
A text tile without static textrunplotTiles.tiles[0].text is required when format is "text" at 10:3
Static text on a number tilerunplotTiles.tiles[0].text is only valid with format "text" at 8:3
A 33-character computed textrunplotTiles.texts[0].text exceeds 32 chars at 9:3
A texts entry with no text keyrunplotTiles.texts[0].text must be a string at 9:3

Full example: market snapshot

Seven live KPIs computed from the loaded history, including a computed Regime text tile. This is the runnable reference shipped with the engine (corpus/builtins/MARKET_SNAPSHOT.ks), verbatim:

tiles-market-snapshot.ks
//@version=3
// Live KPI tiles computed from the loaded history: last close, one-bar change,
// 30-bar annualized volatility, drawdown from the loaded-range high, position
// inside the loaded range, the current run of consecutive up/down closes, and
// a COMPUTED text regime tile (texts kwarg, wire v1.2). All values arrive as
// data rows, so they refresh with the chart; tile structure is static
// settings. Annualization assumes daily bars.
define("Market Snapshot", "offchart", true)

timeseries d = source("ohlcv", currentSymbol, currentExchange)

static closes = []
static gapBox = []
static logRets = []
static prevBox = []

// Gap-filled bars can carry na closes: keep the series valid-only and record
// the boundary so streaks and the one-bar change never bridge a gap.
if (isnum(d.close)) {
  closes.push(d.close)
  // Single-bar log returns accumulate at SOURCE time, so a return can never
  // span a gap (the compacted closes array cannot guarantee adjacency).
  // Rolling 30-entry buffer: exactly the vol window, bounded forever.
  if (prevBox.length > 0 && isnum(prevBox[0]) && prevBox[0] > 0 && d.close > 0) {
    if (logRets.length >= 30) {
      logRets.removeAt(0)
    }
    logRets.push(math.log(d.close / prevBox[0]))
  }
  if (prevBox.length == 0) {
    prevBox.push(d.close)
  } else {
    prevBox[0] = d.close
  }
} else {
  if (prevBox.length > 0) {
    prevBox[0] = na
  }
  if (gapBox.length == 0) {
    gapBox.push(closes.length)
  } else {
    gapBox[0] = closes.length
  }
}

if (isLastBar && closes.length > 1) {
  var n = closes.length
  var last = closes[n - 1]
  var gapAt = gapBox.length > 0 ? gapBox[0] : -1
  // gapAt == n means the newest bar(s) carry na closes: the live edge has no
  // close yet, so there is no current one-bar change and no active streak.
  // Ratio rows also require strictly POSITIVE endpoints (shared corpus rule).
  var trailingGap = gapAt == n
  var change = trailingGap == false && gapAt != n - 1 && closes[n - 2] > 0 && last > 0 ? last / closes[n - 2] - 1 : na
  var hi = closes.max()
  var lo = closes.min()
  var annVol = logRets.length > 1 ? logRets.stdev() * math.sqrt(365) : na
  var trendWindow = n > 50 ? 50 : n
  var meanWindow = closes.slice(n - trendWindow, n).avg()
  var streak = 0
  var broken = trailingGap
  for (var i = n - 1; i > 0; i = i - 1) {
    if (broken == false) {
      if (i == gapAt) {
        // The pair (i, i-1) spans a data gap: the run ends here.
        broken = true
      } else {
      var delta = closes[i] - closes[i - 1]
      if (delta == 0) {
        // A flat close ends the run: it is neither an up nor a down bar.
        broken = true
      } else {
        var up = delta > 0
        if (streak == 0) {
          streak = up ? 1 : -1
        } else if ((streak > 0 && up) || (streak < 0 && up == false)) {
          streak = streak > 0 ? streak + 1 : streak - 1
        } else {
          broken = true
        }
      }
      }
    }
  }
  plotTiles(
    tiles = [
      { label: "Last", format: "currency" },
      { label: "1-Bar Change", format: "percent", accent: "auto" },
      { label: "Ann. Vol (30b)", caption: "log-return stdev, annualized", format: "percent" },
      { label: "Off High", caption: "from loaded-range high", format: "percent", accent: "auto" },
      { label: "Range Position", caption: "inside loaded range", format: "percent" },
      { label: "Bar Streak", caption: "consecutive up/down closes", format: "number", accent: "auto" },
      { label: "Regime", caption: "close vs 50-bar mean", format: "text", text: "AWAITING", accent: "neutral" }
    ],
    data = [
      [0, last],
      [1, change],
      [2, annVol],
      [3, hi > 0 && last > 0 ? last / hi - 1 : na],
      [4, hi != lo ? (last - lo) / (hi - lo) : na],
      [5, streak]
    ],
    texts = [
      { tile: 6, text: last >= meanWindow ? "UP TREND" : "DOWN TREND" }
    ],
    columns = 4,
    gridHeight = 0.2
  )
}