---
title: Foreign-Domain Panes
description: >-
  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.
verified:
  engine: 3.0.67
  probes:
    - scripts/probes/foreign-panes/cap-curve-7-series.ks
    - scripts/probes/foreign-panes/cap-label-overlong.ks
    - scripts/probes/foreign-panes/cap-tiles-columns-9.ks
    - scripts/probes/foreign-panes/err-gridheight-high.ks
    - scripts/probes/foreign-panes/err-gridheight-low.ks
    - scripts/probes/foreign-panes/err-missing-define.ks
    - scripts/probes/foreign-panes/err-onchart-define.ks
    - scripts/probes/foreign-panes/err-pane-plus-plot.ks
    - scripts/probes/foreign-panes/err-strategy-pane.ks
    - scripts/probes/foreign-panes/err-two-panes.ks
    - scripts/probes/foreign-panes/happy-curve.ks
    - scripts/probes/foreign-panes/happy-matrix.ks
    - scripts/probes/foreign-panes/happy-tiles.ks
    - scripts/probes/foreign-panes/na-cell-drop.ks
    - scripts/probes/foreign-panes/style-input-pane.ks
    - scripts/probes/foreign-panes/style-input-plotline-ref.ks
    - scripts/probes/foreign-panes/tiles-accent-neg-happy.ks
    - scripts/probes/foreign-panes/tiles-format-optional.ks
    - scripts/probes/foreign-panes/tiles-happy-minimal.ks
    - scripts/probes/foreign-panes/tiles-market-snapshot.ks
    - scripts/probes/foreign-panes/tiles-na-drop-before-validation.ks
    - scripts/probes/foreign-panes/tiles-no-data-kwarg.ks
    - scripts/probes/foreign-panes/tiles-reject-bad-accent.ks
    - scripts/probes/foreign-panes/tiles-reject-bad-format.ks
    - scripts/probes/foreign-panes/tiles-reject-caption-49.ks
    - scripts/probes/foreign-panes/tiles-reject-columns-0.ks
    - scripts/probes/foreign-panes/tiles-reject-columns-9.ks
    - scripts/probes/foreign-panes/tiles-reject-data-25-rows.ks
    - scripts/probes/foreign-panes/tiles-reject-duplicate-data-rows.ks
    - scripts/probes/foreign-panes/tiles-reject-duplicate-data-texts.ks
    - scripts/probes/foreign-panes/tiles-reject-gridheight-oob.ks
    - scripts/probes/foreign-panes/tiles-reject-label-25.ks
    - scripts/probes/foreign-panes/tiles-reject-missing-label.ks
    - scripts/probes/foreign-panes/tiles-reject-numeric-to-text-tile.ks
    - scripts/probes/foreign-panes/tiles-reject-static-text-on-number-tile.ks
    - scripts/probes/foreign-panes/tiles-reject-string-in-data-literal.ks
    - scripts/probes/foreign-panes/tiles-reject-text-tile-missing-static.ks
    - scripts/probes/foreign-panes/tiles-reject-texts-33-chars.ks
    - scripts/probes/foreign-panes/tiles-reject-texts-missing-text-key.ks
    - scripts/probes/foreign-panes/tiles-reject-texts-target-nontext.ks
    - scripts/probes/foreign-panes/tiles-reject-tileindex-oob.ks
    - scripts/probes/foreign-panes/tiles-reject-tiles-25.ks
    - scripts/probes/foreign-panes/tiles-reject-tiles-empty.ks
    - scripts/probes/foreign-panes/tiles-reject-unknown-tile-key.ks
    - scripts/probes/foreign-panes/tiles-texts-static-fallback.ks
---

Every plot on the [Plotting](./plotting.md) 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.

| Function                    | Description                                                                       |
| --------------------------- | --------------------------------------------------------------------------------- |
| [`plotMatrix`](#plotmatrix) | Categorical grid: rows × columns of values (returns tables, correlations, seasonality) |
| [`plotCurve`](#plotcurve)   | Function pane over a numeric X axis (distributions, curves over price or horizon) |
| [`plotTiles`](#plottiles)   | KPI card dashboard (snapshot stats)                                               |

For a complete worked recipe, start with the
[Historical Returns Matrix](../cookbook/historical-returns.md) 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](#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).

<a id="structural-rules"></a>
## 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 wrote                                             | Exact error                                                                                                        |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| A pane call with no `define()` in the script          | `Script must start with a define(title, position, axis) or strategy(title) statement at 5:1`                       |
| A pane call inside a `strategy()` script              | `plotMatrix requires define(title, "offchart", ...): foreign-domain panes own a dedicated offchart pane at 12:3`   |
| `define("...", "onchart", true)` plus a pane call     | `plotMatrix 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.

<a id="pane-inputs-recompute"></a>
## 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.

<a id="caps"></a>
## 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.

| Limit                | Value            | Applies to                                                                     |
| -------------------- | ---------------- | ------------------------------------------------------------------------------ |
| Label length         | 24 chars         | matrix `rowLabels` / `colLabels` entries, curve `series[].label`, tile `label` |
| Summary label length | 12 chars         | matrix `summary[].label`                                                       |
| Marker label length  | 16 chars         | curve `markers[].label`                                                        |
| Tile text length     | 32 chars         | tile `text`, computed `texts[].text`                                           |
| Caption length       | 48 chars         | tile `caption`                                                                 |
| Color string length  | 32 chars         | every pane color setting (curve `color`, `series[].color`, `markers[].color`)  |
| Matrix cells         | 20,000 data rows | `plotMatrix` `data`                                                            |
| Matrix axis labels   | 64 entries each  | `rowLabels`, `colLabels`                                                       |
| Matrix summary rows  | 5                | `summary`                                                                      |
| Curve series         | 6                | data columns beyond `x`, `series` entries                                      |
| Curve points         | 50,000 data rows | `plotCurve` `data`                                                             |
| Curve markers        | 8                | `markers`                                                                      |
| Tiles                | 24               | `tiles` entries (also bounds the `tileIndex` a data row may address)           |
| Tile columns         | 1 to 8           | `columns`                                                                      |
| `gridHeight`         | 0.05 to 0.9      | all 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`.

<a id="plotmatrix"></a>
## 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?)`

| Parameter          | Type     | Description                                                                                                                                                                                                  |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `data`             | array    | `[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).             |
| `rowLabels`        | string[] | Required. One label per grid row: 1 to **64** entries, each up to **24** characters.                                                                                                                          |
| `colLabels`        | string[] | Required. One label per grid column; same limits as `rowLabels`.                                                                                                                                              |
| `format`           | string   | `"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.                                       |
| `summary`          | object[] | 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.                |
| `highlight`        | object   | `{ row?: r, col?: c }`: emphasizes a row and/or column (e.g. the forming month).                                                                                                                              |
| `gridHeight`       | number   | Pane height as a fraction of the chart, 0.05 to 0.9.                                                                                                                                                          |
| `hideLegendValues` | boolean  | Foreign panes hide the legend value readout by default; pass `false` to keep it.                                                                                                                              |

### Minimal call

The smallest viable call is `data` + `rowLabels` + `colLabels`:

```javascript title="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

```javascript title="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.

### `na` drops the cell; empty data is legal

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

```javascript title="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 emitted                             | Exact error                                                                                  |
| -------------------------------------------- | -------------------------------------------------------------------------------------------- |
| 20,001 data rows                             | `plotMatrix.data must have at most 20000 rows, got 20001 at 17:3`                            |
| `rowIdx` 2 with only 2 row labels            | `plotMatrix.data[1] rowIdx must be an integer in 0..1, got 2 at 9:3`                         |
| The cell `(1, 1)` twice                      | `plotMatrix.data has duplicate cell (1, 1) at 8:3`                                           |
| 65 column labels                             | `plotMatrix.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 label                    | `plotMatrix.rowLabels[1] exceeds 24 characters (got 25: "ABCDEFGHIJKLMNOP…") at 11:24`       |
| 6 summary rows                               | `plotMatrix.summary must have at most 5 rows, got 6 at 12:15`                                |
| A 13-character summary label                 | `plotMatrix.summary[0].label exceeds 12 characters (got 13: "THIRTEENCHAR!…") at 14:16`      |
| 2 summary values under 3 columns             | `plotMatrix.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.

```javascript title="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()`](/kscript/core-concepts/multi-source#requestbars-raw-bars-for-drawing) 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.

```javascript title="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.

```javascript title="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
  )
}
```

<a id="plotcurve"></a>
## 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?)`

| Parameter          | Type     | Description                                                                                                                                                                                                                                                                                                       |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `data`             | array    | Required. `[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). |
| `series`           | object[] | 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.                                                                                        |
| `style`            | string   | `"line"` or `"bars"`: the whole-pane default render style.                                                                                                                                                                                                                                                          |
| `color`            | string   | Series-0 color shorthand (single-series ergonomics).                                                                                                                                                                                                                                                                |
| `fill`             | boolean  | Series-0 area-fill shorthand.                                                                                                                                                                                                                                                                                       |
| `markers`          | object[] | 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.                                                                                                                      |
| `valueFormat`      | string   | `"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.       |
| `gridHeight`       | number   | Pane height as a fraction of the chart, 0.05 to 0.9.                                                                                                                                                                                                                                                                |
| `hideLegendValues` | boolean  | Foreign 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:

```javascript title="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:

```javascript title="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:

```javascript title="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:

```javascript title="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:

```javascript title="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 emitted                  | Exact 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` twice                | `plotCurve.data must be strictly ascending in x: data[2] x=2 follows 2 at 9:3`                                           |
| Rows with different column counts | `plotCurve.data rows must all carry the same series count (2), data[1] carries 1 at 9:3`                                 |
| `x = na`                          | `plotCurve.data[1][0] (x) must be a finite number, got na at 9:3`                                                        |
| 50,001 data rows                  | `plotCurve.data must have at most 50000 rows, got 50001 at 13:3`                                                         |
| 7 value columns after `x`         | `plotCurve supports at most 6 series, data[0] carries 7 at 11:7`                                                         |
| 7 `series` entries                | `plotCurve.series must have at most 6 entries, got 7 at 15:14`                                                           |
| A 25-character series label       | `plotCurve.series[0].label exceeds 24 characters (got 25: "ABCDEFGHIJKLMNOP…") at 16:16`                                 |
| 9 markers                         | `plotCurve.markers must have at most 8 entries, got 9 at 17:15`                                                          |
| A 17-character marker label       | `plotCurve.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.95`               | `plotCurve.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.

```javascript title="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.

```javascript title="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]`.

```javascript title="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
    )
  }
}
```

<a id="plottiles"></a>
## 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?)`

| Parameter          | Type                   | Description                                                                                                                                                                              |
| ------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tiles`            | tile[]                 | Required, 1 to **24** cards; see the tile keys below. Empty and over-cap are named errors (table below).                                                                                    |
| `data`             | array                  | `[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. |
| `texts`            | object[]               | `{ tile, text }` entries: computed display strings for `format: "text"` tiles (see below).                                                                                                 |
| `columns`          | number                 | Column count, **1** to **8**. When omitted, no `columns` key is emitted and the pane packs cards by its width.                                                                              |
| `gridHeight`       | number                 | Pane height as a fraction of the chart, 0.05 to 0.9.                                                                                                                                       |
| `hideLegendValues` | boolean                | Foreign 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 key  | Type   | Description                                                                                                                                                                            |
| --------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `label`   | string | Required, up to **24** characters. Omitting it is a type error; 25 characters are rejected at save.                                                                                       |
| `caption` | string | Optional small print under the value, up to **48** characters.                                                                                                                            |
| `format`  | string | Optional: `"number"` \| `"percent"` \| `"currency"` \| `"text"`. A label-only tile is valid; an omitted `format` is simply absent from the emitted settings (no default is injected).      |
| `accent`  | string | Optional: `"auto"` \| `"pos"` \| `"neg"` \| `"neutral"`. `"auto"` colors by the value's sign; the other three force that accent.                                                          |
| `text`    | string | The 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`).

```javascript title="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.

```javascript title="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`.

```javascript title="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 emitted                    | Stage | Exact error                                                                                             |
| ----------------------------------- | ----- | ------------------------------------------------------------------------------------------------------- |
| 25 tiles                            | save  | `plotTiles.tiles must have at most 24 entries, got 25 at 9:13`                                          |
| 25 data rows (24 of them `na`)      | save  | `plotTiles.data must have at most 24 rows, got 25 at 11:12`                                             |
| A 25-character label                | save  | `plotTiles.tiles[0].label exceeds 24 characters (got 25: "ABCDEFGHIJKLMNOP…") at 9:23`                  |
| A 49-character caption              | save  | `plotTiles.tiles[0].caption exceeds 48 characters (got 49: "ABCDEFGHIJKLMNOP…") at 9:40`                |
| `gridHeight = 0.95`                 | save  | `plotTiles.gridHeight must be in 0.05..0.9, got 0.95 at 11:18`                                          |
| A string literal inside `data`      | save  | `Mixed array types not supported at 13:13`                                                              |
| `tiles = []`                        | run   | `plotTiles.tiles must not be empty at 8:3`                                                              |
| `columns = 0` (`9` fails alike)     | run   | `plotTiles.columns must be an integer in 1..8, got 0 at 8:3`                                            |
| A tile without `label`              | run   | `Type mismatch for 'plotTiles.tiles[0].label': expected string, got undefined at 8:3`                   |
| `color:` key on a tile              | run   | `Unknown key 'color' in plotTiles.tiles[0] (allowed: 'label', 'caption', 'format', 'accent', 'text') at 9:3` |
| `format: "compact"`                 | run   | `Invalid plotTiles.tiles[0].format 'compact'. Expected one of: 'number', 'percent', 'currency', 'text' at 9:3` |
| `accent: "sparkly"`                 | run   | `Invalid plotTiles.tiles[0].accent 'sparkly'. Expected one of: 'auto', 'pos', 'neg', 'neutral' at 9:3`  |
| A finite value at index 5, 1 tile   | run   | `plotTiles.data[0] tileIndex must be an integer in 0..0, got 5 at 9:3`                                  |
| Two data rows for tile 0            | run   | `plotTiles.data has duplicate tileIndex 0 at 9:3`                                                       |
| Two `texts` entries for tile 1      | run   | `plotTiles.texts has duplicate tileIndex 1 at 11:3`                                                     |
| A numeric row at a text tile        | run   | `plotTiles.data[0] targets text tile 0 ("Regime"): text tiles take a string value, not a number at 9:3` |
| `texts` at a non-text tile          | run   | `plotTiles.texts[0] targets tile 0 ("Last") which is not format "text" at 9:3`                          |
| A text tile without static `text`   | run   | `plotTiles.tiles[0].text is required when format is "text" at 10:3`                                     |
| Static `text` on a number tile      | run   | `plotTiles.tiles[0].text is only valid with format "text" at 8:3`                                       |
| A 33-character computed text        | run   | `plotTiles.texts[0].text exceeds 32 chars at 9:3`                                                       |
| A `texts` entry with no `text` key  | run   | `plotTiles.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:

```javascript title="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
  )
}
```
