---
title: "Strike Matrix"
description: "The chart coin's live options chain as a board docked to the price axis: one row per strike inside a window around spot, one column per expiry (the nearest…"
order: 79
section: "cookbook"
---

<!-- source: docs/indicators/cookbook/strike-matrix.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Strike Matrix

![The options desk on two chart cells, the left cell first: BTCUSDT 5m with the GEX by expiry board docked against the price axis, teal and violet cells with signed dollars, the at-the-money row outlined in amber, the per-strike net GEX bars in a narrow column left of the board, and the dashed level lines with their labels at the newest bar; on the right, the Options Dashboard's tiles, gamma curve and CVD pane](/wrun/images/style-anything/overview.png)

The chart coin's live options chain as a board docked to the price axis: one row per strike inside a window around spot, one column per expiry (the nearest first, four by default, the front expiry labelled `0DTE` while it expires inside the day), each cell that expiry's net gamma exposure at the strike, calls minus puts in USD per 1% move of spot. The cells are tinted on a diverging scale centred on zero, in the chart's up colour where calls carry the strike and its down colour where puts do, the tint deepening with the square root of the size, signed dollars printed in every cell (`+$84.3M`), the row at the money outlined in amber, the expiry dates as a header. Left of the board, one bar per strike reads the row's net GEX from a centre baseline, the up colour one way and the down colour the other, with a hover card naming the call and put halves. Four dashed levels run across the whole chart, each labelled just left of the bars, clear of the board: the strike inside the board's window holding the largest positive net GEX (in the up colour), the put wall inside the window (in the down colour), the gamma flip (in the text ink) and max pain (in the muted ink), the labels ranking the three levels by how much net GEX each carries. The legend names the board's window (the expiries and the percent around spot, read from the settings) and reads the live spot. The rows sit on their prices, so the board reads against the candles beside it; on a short timeframe the candles cover few strikes, so drag the price axis to zoom it out and more rows appear.

The parts are a celled `options_chain.cells` input read as a block on the live bar ([Data sources](../core-concepts/data-sources.md)), two frames written from the generated frame buffer, a `plot.matrix` over the first and a `plot.levels` over the second, both docked right and kept apart by an `offset` ([Price canvases](../presentation/price-canvases.md), [Docked profiles](../presentation/cards-frames-panels.md#docked-profiles)), the `OptionsChain` kit for the flip, max pain and the walls ([Options kit](../functions/options-kit.md)), and `draw.line` drawings placed from the newest bar with label handles pinned in pixels from the price axis ([Drawing objects](../presentation/drawing-objects.md)). It is the left cell of the options desk that [Style anything](../presentation/style-anything.md) walks through part by part; the [Options Dashboard](options-dashboard.md) recipe is its right cell. This is also the `strike-matrix` template: the **Strike Matrix** card under **On price** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=strike-matrix
// Strike Matrix: the chart coin's live options chain as a strike by expiry board docked on the price axis (net gamma
// exposure per cell, in the chart's up colour where calls carry the strike and its down colour where puts do, the tint
// deepening with size), a column of per-strike net GEX bars beside it, and four dashed level lines across the chart,
// each labelled just left of the bars: the strike inside the board's window holding the largest positive net GEX, the
// gamma flip, max pain and the put wall inside the window. The chain is live only: history bars cost one read and draw
// nothing; the boards, the levels and their labels are rewritten on the last bar as the chain moves.
param.number("window_pct", 7, { min: 1, max: 30, label: "Strike window, percent", description: "Strikes on the board: percent around spot, each side" });
param.int("expiries", 4, { min: 1, max: 12, label: "Expiry columns", description: "Expiries on the board, nearest first" });
input("close", ohlcv.close); // the chart's own close: the grid, and spot for the strike window
input("chain", options_chain.cells, { max_cells: 4000, venue: "auto", description: "The live options chain of the chart's coin (venue auto: the chart's own market when it lists options, else the coin's Deribit chain)" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract
output("spot", none, overlay, { description: "The chart's last close: the board's centre and the levels' spot" });
output("net_gex", none, overlay, { description: "Net gamma exposure of the board's cells, USD per 1% move of spot; live bar only" });
output("t_prev", none, overlay, { description: "The previous bar's open time, epoch seconds: the level lines' first point" });
output("t_last", none, overlay, { description: "This bar's open time, epoch seconds: the level lines' second point" });
output("wall_price", none, overlay, { description: "The strike inside the board's window holding the largest positive net GEX; live bar only" });
output("flip_price", none, overlay, { description: "The gamma flip: where cumulative net GEX over the whole chain crosses zero, the crossing nearest spot; live bar only" });
output("pain_price", none, overlay, { description: "Max pain: the strike of the whole chain that pays option holders the least; live bar only" });
output("put_wall_price", none, overlay, { description: "The put wall: the heaviest put strike at or below spot inside the board's window; live bar only" });
string("spot_text", { max_bytes: 32 }); // the legend entry: "live spot $84.6K"
string("wall_text", { max_bytes: 32 }); // the level labels, one slot each, written on the live bar
string("flip_text", { max_bytes: 32 });
string("pain_text", { max_bytes: 32 });
string("put_wall_text", { max_bytes: 32 });
legend({ title: "{{expiries}} expiries, ±{{window_pct}}%" }); // the words after the name in the legend: the board's window, read from the settings by name, short enough to show whole
render.legend("spot_entry", { text: "spot_text", color: "theme.muted" });
const board = frame("board", { max_bytes: 32768 }); // the matrix: one row per strike, one column per expiry
const gexRows = frame("gex_rows", { max_bytes: 16384 }); // the bars: net GEX per strike, the board's row sums
// The strike by expiry board against the price axis: a diverging palette centred on zero, puts in the chart's down
// colour and calls in its up colour, the tint by the square root of the cell's size, signed dollars in every cell, the
// at-the-money row outlined in amber.
plot.matrix({ name: "gex_board", frame: board, dock: "right", column_width: 66, row_max_px: 22, price_column: true, header: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", opacity: 0.95, format: "usd", signed: true, decimals: 1, font_size: 10, font_weight: "medium", align: "center", text_color: "theme.text", cell_padding: 3, grid_color: "theme.bg", grid_width: 2, grid_style: "solid", grid_lines: "all", highlight_color: "#f8c000", label: "GEX by expiry", tooltip: "{{column}}: {{value:usd}} net GEX at {{price}}" });
// The per-strike net GEX bars beside the board: zero mid-dock, positives one way and negatives the other, each row
// in its own colour from the frame, a hover card per row, pushed inward past the board (both dock right), 100 px wide.
plot.levels({ name: "gex_by_strike", frame: gexRows, dock: "right", width_px: 100, baseline: "center", labels: false, opacity: 0.95, thickness_px: 10, format: "usd", signed: true, hover: true, offset: [340, 0] }); // offset: the board's width (4 columns of 66 px plus the price column), so the bars sit left of it instead of under it
// Four dashed levels across the whole pane, placed from the newest bar: the largest positive GEX strike in the chart's
// up colour, the put wall in its down colour, the gamma flip in the text ink and max pain in the muted ink. Their
// knockout labels are label handles placed in pixels from the price axis (x) at the level's price (y), their right
// edge just left of the bars, so a label never covers a cell of the board or runs into the axis.
draw.line("wall_line", { x1: "t_prev", y1: "wall_price", x2: "t_last", y2: "wall_price", color: "theme.up", width: 1, line_style: "dashed", extend: "both" });
draw.line("flip_line", { x1: "t_prev", y1: "flip_price", x2: "t_last", y2: "flip_price", color: "theme.text", width: 1, line_style: "dashed", extend: "both" });
draw.line("pain_line", { x1: "t_prev", y1: "pain_price", x2: "t_last", y2: "pain_price", color: "theme.muted", width: 1, line_style: "dashed", extend: "both" });
draw.line("put_wall_line", { x1: "t_prev", y1: "put_wall_price", x2: "t_last", y2: "put_wall_price", color: "theme.down", width: 1, line_style: "dashed", extend: "both" });
handles.label({ anchor: "right", align: "right", style: "knockout", font_weight: "medium" });

const MAX_ROWS = 128; // strikes on the board (the frame's cap)
const MAX_COLS = 12; // expiries on the board (the setting's cap)
const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega
const MAX_EXPIRIES = 64; // distinct live expiries the chain may list
const DAY_MS = 86400000.0;
const strikePrice = new StaticArray<f64>(MAX_ROWS); // the strike table, sorted ascending, one row per strike inside the window
const cellGex = new StaticArray<f64>(MAX_ROWS * MAX_COLS); // net GEX per row and column
const cellSeen = new StaticArray<i32>(MAX_ROWS * MAX_COLS); // 1 where a contract landed in the cell
const rowCall = new StaticArray<f64>(MAX_ROWS); // the row's call GEX across the board's columns (the hover card)
const rowPut = new StaticArray<f64>(MAX_ROWS); // the row's put GEX across the board's columns, stored positive
const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the live expiries, sorted ascending
const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const tags: LabelHandle[] = [draw.label(0), draw.label(1), draw.label(2), draw.label(3)]; // the wall, the flip, max pain, the put wall; handle objects allocate once
let chain = new OptionsChain(1); // the whole chain measured by strike: the flip and max pain
let windowChain = new OptionsChain(1); // the strikes inside the board's window: the two walls, as Gamma Map reads them
let windowPct = 0.07; // settings, read in onStart()
let columns = 4;
let close: f64 = NaN; // this bar
let t: f64 = NaN;
let prevT: f64 = NaN;
let nowMs: f64 = 0.0; // the chain's clock, read on the live bar
let rowCount = 0; // strikes in the table
let expiryCount = 0; // live expiries seen
let netGex: f64 = NaN; // the board's net GEX, measured on the live bar
let wallPrice: f64 = NaN; // the levels, measured on the live bar
let wallGex: f64 = NaN;
let flipPrice: f64 = NaN;
let flipStrike: f64 = NaN;
let painPrice: f64 = NaN;
let putWallPrice: f64 = NaN;
let flipRank = 0;
let painRank = 0;
let putWallRank = 0;

function onStart(): void {
  chain = new OptionsChain(512);
  windowChain = new OptionsChain(512);
  windowPct = p_window_pct() / 100.0;
  columns = i32(p_expiries());
  if (columns > MAX_COLS) columns = MAX_COLS;
}

// ── The chain's clock: when its gammas were priced, not the bar's open ──
// The chart prices each contract's gamma by Black-Scholes from its mark IV and underlying at the moment it reads the
// chain, so the contract nearest the money (|delta| nearest 0.5), solved for the time to expiry its gamma implies,
// names that moment. A venue that serves its own greeks gives no such answer: the bar's open stands in.
function chainClockMs(cells: StaticArray<f64>, n: i32, barOpenMs: f64): f64 {
  let best = -1;
  let bestGap = 0.25; // |delta| within 0.25 of 0.5
  let nearest = Infinity; // the nearest listed expiry: the chain lists none that has passed
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    if (cells[i + 1] < nearest) nearest = cells[i + 1];
    const gap = Math.abs(Math.abs(cells[i + 5]) - 0.5);
    if (cells[i] > 0.0 && cells[i + 4] > 0.0 && cells[i + 6] > 0.0 && cells[i + 7] > 0.0 && gap < bestGap) {
      bestGap = gap;
      best = i;
    }
  }
  if (best < 0) return barOpenMs;
  const sigma = cells[best + 6] > 3.0 ? cells[best + 6] / 100.0 : cells[best + 6]; // percent a year, or a fraction
  const m = Math.log(cells[best + 7] / cells[best]);
  const q = cells[best + 4] * cells[best + 7]; // gamma times spot = pdf(d1) / u, u = sigma * sqrt(T), d1 = m / u + u / 2
  let lo = Math.sqrt(2.0 * (Math.sqrt(1.0 + m * m) - 1.0)); // where pdf(d1) / u peaks: past it the gamma falls as T grows
  let hi = 10.0;
  for (let k = 0; k < 80; k += 1) { // bisection on the falling side
    const u = 0.5 * (lo + hi);
    const d1 = m / u + 0.5 * u;
    if (Math.exp(-0.5 * d1 * d1) / (2.5066282746310002 * u) > q) lo = u;
    else hi = u;
  }
  const u = 0.5 * (lo + hi);
  const clock = cells[best + 1] - ((u * u) / (sigma * sigma)) * 31536000000.0;
  return clock > barOpenMs - 86400000.0 && clock < nearest ? clock : barOpenMs; // anything else is no reading
}

// ── The expiry list: sorted insertion of a distinct live expiry ──
function noteExpiry(ms: f64): void {
  let lo = 0;
  let hi = expiryCount;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (expiryMs[mid] < ms) lo = mid + 1;
    else hi = mid;
  }
  if (lo < expiryCount && expiryMs[lo] == ms) return;
  if (expiryCount >= MAX_EXPIRIES) return;
  for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1];
  expiryMs[lo] = ms;
  expiryCount += 1;
}
function columnOf(ms: f64): i32 { // the board column of an expiry: its rank among the nearest `columns`, or -1
  for (let c = 0; c < columns && c < expiryCount; c += 1) if (expiryMs[c] == ms) return c;
  return -1;
}

// ── The strike table: sorted insertion, one row per strike; when full, the end farther from spot gives way ──
function clearRow(row: i32): void {
  for (let c = 0; c < MAX_COLS; c += 1) {
    cellGex[row * MAX_COLS + c] = 0.0;
    cellSeen[row * MAX_COLS + c] = 0;
  }
  rowCall[row] = 0.0;
  rowPut[row] = 0.0;
}
function moveRow(from: i32, to: i32): void {
  strikePrice[to] = strikePrice[from];
  rowCall[to] = rowCall[from];
  rowPut[to] = rowPut[from];
  for (let c = 0; c < MAX_COLS; c += 1) {
    cellGex[to * MAX_COLS + c] = cellGex[from * MAX_COLS + c];
    cellSeen[to * MAX_COLS + c] = cellSeen[from * MAX_COLS + c];
  }
}
function strikeSlot(strike: f64): i32 { // the row of this strike, inserted when new; -1 when the table is full and this strike is the farthest from spot
  let lo = 0;
  let hi = rowCount;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (strikePrice[mid] < strike) lo = mid + 1;
    else hi = mid;
  }
  if (lo < rowCount && strikePrice[lo] == strike) return lo;
  if (rowCount >= MAX_ROWS) { // full: the end farther from spot is dropped, unless this strike is farther still
    const lowGap = Math.abs(strikePrice[0] - close);
    const highGap = Math.abs(strikePrice[rowCount - 1] - close);
    if (Math.abs(strike - close) >= Math.max(lowGap, highGap)) return -1;
    if (lowGap >= highGap) { // drop the lowest row: every row shifts down one
      for (let i = 0; i < rowCount - 1; i += 1) moveRow(i + 1, i);
      if (lo > 0) lo -= 1;
    }
    rowCount -= 1;
    if (lo > rowCount) lo = rowCount;
  }
  for (let i = rowCount; i > lo; i -= 1) moveRow(i - 1, i);
  strikePrice[lo] = strike;
  clearRow(lo);
  rowCount += 1;
  return lo;
}
function atmStrike(): f64 { // the strike nearest spot: the highlighted row
  let best = strikePrice[0];
  for (let r = 1; r < rowCount; r += 1) if (Math.abs(strikePrice[r] - close) < Math.abs(best - close)) best = strikePrice[r];
  return best;
}

// ── The chain, measured on the live bar ──
function measureChain(n: i32): void { // n = f64 cells in the live block
  const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer
  rowCount = 0;
  expiryCount = 0;
  netGex = 0.0;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs) noteExpiry(cells[i + 1]); // the live expiries, nearest first
  if (expiryCount == 0) return;
  const lo = close * (1.0 - windowPct);
  const hi = close * (1.0 + windowPct);
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    const strike = cells[i];
    const oi = cells[i + 3];
    if (!(strike > 0.0) || !(oi > 0.0) || strike < lo || strike > hi) continue;
    const col = columnOf(cells[i + 1]);
    if (col < 0) continue; // an expiry past the board's columns, or already expired
    const multiplier = cells[i + 8] > 0.0 ? cells[i + 8] : 1.0; // Deribit open interest is in coin units (multiplier 1); CME open interest is contracts times the point value
    const spot = cells[i + 7] > 0.0 ? cells[i + 7] : close;
    const gexUsd = cells[i + 4] * oi * multiplier * spot * spot * 0.01; // the USD delta change of the open interest for a 1% move of spot
    const isCall = cells[i + 2] > 0.0;
    const signed = isCall ? gexUsd : -gexUsd; // calls positive, puts negative: the dealer-naive sign convention
    const row = strikeSlot(strike);
    if (row < 0) continue;
    cellGex[row * MAX_COLS + col] += signed;
    cellSeen[row * MAX_COLS + col] = 1;
    if (isCall) rowCall[row] += gexUsd;
    else rowPut[row] += gexUsd;
    netGex += signed;
  }
}

// ── The levels through the options kit: the flip and max pain over the whole chain, the walls inside the window ──
function nearestStrikeIndex(price: f64): i32 { // the kit table's strike nearest a price, -1 on an empty table
  const n = chain.strikes();
  let best = -1;
  let bestGap = Infinity;
  for (let i = 0; i < n; i += 1) {
    const gap = Math.abs(chain.strike(i) - price);
    if (gap < bestGap) {
      bestGap = gap;
      best = i;
    }
  }
  return best;
}
function rankOf(value: f64, a: f64, b: f64): i32 { // 1 when this level carries the most |net GEX| of the three, 3 the least
  let rank = 1;
  if (a > value) rank += 1;
  if (b > value) rank += 1;
  return rank;
}
function flipNearSpot(): f64 { // cumulative net GEX walked up the strikes: its zero crossing nearest spot (the far wings' few dollars can flip the sum back and forth); a sum landing exactly on zero crosses at that strike and starts a new segment
  const count = chain.strikes();
  let best: f64 = NaN;
  let cum = 0.0;
  let prevCum = 0.0;
  let prevStrike: f64 = NaN;
  for (let i = 0; i < count; i += 1) {
    cum += chain.netGex(i);
    let cross: f64 = NaN;
    if (!isNaN(prevStrike) && prevCum != 0.0) {
      if (cum == 0.0) cross = chain.strike(i);
      else if ((cum > 0.0) != (prevCum > 0.0)) cross = prevStrike + (chain.strike(i) - prevStrike) * (Math.abs(prevCum) / (Math.abs(prevCum) + Math.abs(cum)));
    }
    if (!isNaN(cross) && (isNaN(best) || Math.abs(cross - close) < Math.abs(best - close))) best = cross;
    if (cum == 0.0) prevStrike = NaN; // a zero ends the segment: no later crossing is interpolated across it
    else {
      prevCum = cum;
      prevStrike = chain.strike(i);
    }
  }
  return best;
}
function measureLevels(n: i32): void {
  chain.load(in_chain_view(), n, nowMs, close, 0, 0.0); // every live expiry, every strike: the flip and max pain
  windowChain.load(in_chain_view(), n, nowMs, close, 0, windowPct * 100.0); // the board's window: the walls a chart of this range trades against
  const count = windowChain.strikes();
  wallPrice = NaN;
  wallGex = NaN;
  for (let i = 0; i < count; i += 1) {
    const g = windowChain.netGex(i);
    if (g > 0.0 && (isNaN(wallGex) || g > wallGex)) {
      wallGex = g;
      wallPrice = windowChain.strike(i);
    }
  }
  flipPrice = flipNearSpot();
  painPrice = chain.maxPain();
  putWallPrice = windowChain.putWall();
  const fi = isNaN(flipPrice) ? -1 : nearestStrikeIndex(flipPrice);
  const pi = isNaN(painPrice) ? -1 : nearestStrikeIndex(painPrice);
  const wi = isNaN(putWallPrice) ? -1 : nearestStrikeIndex(putWallPrice);
  flipStrike = fi < 0 ? NaN : chain.strike(fi);
  const fg = fi < 0 ? 0.0 : Math.abs(chain.netGex(fi));
  const pg = pi < 0 ? 0.0 : Math.abs(chain.netGex(pi));
  const wg = wi < 0 ? 0.0 : Math.abs(chain.netGex(wi));
  flipRank = rankOf(fg, pg, wg);
  painRank = rankOf(pg, fg, wg);
  putWallRank = rankOf(wg, fg, pg);
}

// ── Text: the legend entry and the level labels, built without allocating ──
function sbUsd(v: f64): void { // "+$84.3M", "-$4.5M", "+$950.0K"
  if (!isFinite(v)) {
    sb_text("n/a");
    return;
  }
  sb_text(v < 0.0 ? "-$" : "+$");
  const a = Math.abs(v);
  if (a >= 1.0e9) {
    sb_f64(a / 1.0e9, 2);
    sb_text("B");
  } else if (a >= 1.0e6) {
    sb_f64(a / 1.0e6, 1);
    sb_text("M");
  } else if (a >= 1.0e3) {
    sb_f64(a / 1.0e3, 1);
    sb_text("K");
  } else sb_f64(a, 0);
}
function sbStrike(p: f64): void { // "$86K", "$2.4K", "$0.42": the strike as a short price
  sb_text("$");
  if (p >= 10000.0) {
    sb_f64(p / 1000.0, 0);
    sb_text("K");
  } else if (p >= 1000.0) {
    sb_f64(p / 1000.0, 1);
    sb_text("K");
  } else sb_f64(p, 2);
}
function sbPrice(p: f64): void { // "$85.7K", "$2.44K", "$0.42"
  sb_text("$");
  if (p >= 1000.0) {
    sb_f64(p / 1000.0, 1);
    sb_text("K");
  } else sb_f64(p, 2);
}
function clearTags(): void {
  for (let k = 0; k < 4; k += 1) tags[k].delete(); // no-ops when never drawn
}
function writeTexts(): void { // the legend entry, then each label built in the line buffer and sent with its handle
  sb_clear();
  sb_text("live spot ");
  sbPrice(close);
  str_spot_text_sb();
  const cols = columns < expiryCount ? columns : expiryCount;
  const inset = Math.max(66.0 * f64(cols) + 76.0, 340.0) + 108.0; // past the board (66 px a column plus the price column, the bars' offset at least) and the 100 px of bars left of it
  if (isNaN(wallPrice)) tags[0].delete();
  else {
    sb_clear();
    sbUsd(wallGex);
    tags[0].set(inset, wallPrice).text(str_wall_text_sb).color(theme.UP);
  }
  if (isNaN(flipPrice)) tags[1].delete();
  else {
    sb_clear();
    sb_text("#");
    sb_int(flipRank);
    sb_text(" ");
    sbStrike(flipStrike);
    sb_text(" | GF ");
    sbPrice(flipPrice);
    tags[1].set(inset, flipPrice).text(str_flip_text_sb).color(theme.TEXT);
  }
  if (isNaN(painPrice)) tags[2].delete();
  else {
    sb_clear();
    sb_text("#");
    sb_int(painRank);
    sb_text(" ");
    sbStrike(painPrice);
    sb_text(" | MP ");
    sbPrice(painPrice);
    tags[2].set(inset, painPrice).text(str_pain_text_sb).color(theme.MUTED);
  }
  if (isNaN(putWallPrice)) tags[3].delete();
  else {
    sb_clear();
    sb_text("#");
    sb_int(putWallRank);
    sb_text(" ");
    sbStrike(putWallPrice);
    tags[3].set(inset, putWallPrice).text(str_put_wall_text_sb).color(theme.DOWN);
  }
}

// ── Frames: the board and the bars, appended as JSON text without allocating ──
let civilMonth = 0;
let civilDay = 0;
function civilFromDays(days: i64): void { // days since 1970-01-01 -> month and day of month (proleptic Gregorian)
  const z = days + 719468;
  const era = (z >= 0 ? z : z - 146096) / 146097;
  const doe = z - era * 146097;
  const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
  const doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  const mp = (5 * doy + 2) / 153;
  civilDay = i32(doy - (153 * mp + 2) / 5 + 1);
  civilMonth = i32(mp < 10 ? mp + 3 : mp - 9);
}
function fbExpiryLabel(ms: f64, nearest: bool): void { // "0DTE" for an expiry inside the day of the chain's clock, else "27JUN"
  fb_text("\"");
  if (nearest && ms - nowMs < DAY_MS) fb_text("0DTE");
  else {
    civilFromDays(i64(Math.floor(ms / DAY_MS)));
    if (civilDay < 10) fb_text("0");
    fb_int(civilDay);
    fb_text(MONTHS[civilMonth - 1]);
  }
  fb_text("\"");
}
function fbUsd(v: f64): void { // "+$84.3M" inside a frame string
  if (!isFinite(v)) {
    fb_text("n/a");
    return;
  }
  fb_text(v < 0.0 ? "-$" : "+$");
  const a = Math.abs(v);
  if (a >= 1.0e9) {
    fb_f64(a / 1.0e9, 2);
    fb_text("B");
  } else if (a >= 1.0e6) {
    fb_f64(a / 1.0e6, 1);
    fb_text("M");
  } else if (a >= 1.0e3) {
    fb_f64(a / 1.0e3, 1);
    fb_text("K");
  } else fb_f64(a, 0);
}
function priceDecimals(v: f64): i32 { // decimals follow the price's size: strikes at 84,000 / 2,437.5 / 12.5 / 0.42
  return v >= 1000.0 ? 1 : v >= 10.0 ? 2 : v >= 1.0 ? 3 : 5;
}
function writeBoard(): void { // strikes ascending, one cell per expiry column, the row of the strike nearest spot highlighted
  const cols = columns < expiryCount ? columns : expiryCount;
  const decimals = priceDecimals(close);
  fb_clear();
  fb_text("{\"prices\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_f64(strikePrice[r], decimals);
  }
  fb_text("],\"cells\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_text("[");
    for (let c = 0; c < cols; c += 1) {
      if (c > 0) fb_text(",");
      if (cellSeen[r * MAX_COLS + c] == 0) fb_text("null"); // no open interest in this cell: no fill, no text
      else fb_f64(cellGex[r * MAX_COLS + c], 0);
    }
    fb_text("]");
  }
  fb_text("],\"cols\":[");
  for (let c = 0; c < cols; c += 1) {
    if (c > 0) fb_text(",");
    fbExpiryLabel(expiryMs[c], c == 0);
  }
  fb_text("],\"title\":\"GEX by expiry\",\"highlight\":{\"price\":"); // the row alone: a col here would outline the whole column too
  fb_f64(atmStrike(), decimals);
  fb_text("}}");
  writeFrameBuffer(board);
}
function writeBars(): void { // the board's row sums as bars, positive in the chart's up colour and negative in its down colour, a hover hint per row
  const decimals = priceDecimals(close);
  fb_clear();
  fb_text("{\"prices\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_f64(strikePrice[r], decimals);
  }
  fb_text("],\"values\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_f64(rowCall[r] - rowPut[r], 0);
  }
  fb_text("],\"colors\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_text(rowCall[r] - rowPut[r] >= 0.0 ? "\"theme.up\"" : "\"theme.down\"");
  }
  fb_text("],\"tooltips\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_text("\"C ");
    fbUsd(rowCall[r]);
    fb_text(" | P ");
    fbUsd(-rowPut[r]);
    fb_text("\"");
  }
  fb_text("]}");
  writeFrameBuffer(gexRows);
}

// onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the chain,
// writes the readings, places the labels and writes the two boards.
function onBar(): void {
  prevT = t;
  t = bar.time();
  close = bar.close();
  out_spot(close);
  out_t_prev(prevT);
  out_t_last(t);
  if (!bar.isLast() || isNaN(close) || isNaN(t)) {
    if (bar.isLast()) clearTags();
    out_net_gex(NaN);
    out_wall_price(NaN);
    out_flip_price(NaN);
    out_pain_price(NaN);
    out_put_wall_price(NaN);
    return;
  }
  const n = in_chain_cells();
  if (n < TUPLE) {
    clearTags();
    out_net_gex(NaN);
    out_wall_price(NaN);
    out_flip_price(NaN);
    out_pain_price(NaN);
    out_put_wall_price(NaN);
    return;
  }
  nowMs = chainClockMs(in_chain_view(), n, t * 1000.0); // the chain's clock: the expiries still live and the 0DTE label
  measureChain(n);
  measureLevels(n);
  out_net_gex(rowCount > 0 ? netGex : NaN);
  out_wall_price(wallPrice);
  out_flip_price(flipPrice);
  out_pain_price(painPrice);
  out_put_wall_price(putWallPrice);
  writeTexts();
  if (rowCount > 0) {
    writeBoard();
    writeBars();
  }
}
```

## How it works

**The chain is live only.** `input("chain", options_chain.cells, { max_cells: 4000, venue: "auto" })` delivers the chart coin's listed contracts as `[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]` tuples on the live row (a BTC chain is about 1,550); history bars carry an empty block and cost one read, so the boards and the levels are measured on the last bar and rewritten as the chain moves. `venue: "auto"` reads the chart's own market when it lists options, else the coin's Deribit chain.

**GEX per cell.** Gamma times open interest times the multiplier times spot squared times 0.01 (Deribit open interest is in coin units with multiplier 1; CME open interest is contracts times the point value), calls positive and puts negative; each contract lands in the cell of its strike row and its expiry column. `window_pct` (7) keeps the strikes within that percent of spot each side and `expiries` (4) the nearest expiries; a strike table of 128 rows holds the window, and when a window is wider than that the strike farthest from spot gives way. Each row also keeps its call and put halves for the bars' hover card, and `net_gex` sums the board's cells as a data-only output, live bar only.

**The chain's clock.** Which expiries are still live, and which column reads `0DTE`, count from the moment the chart priced the chain, never from the live bar's open: the chart prices each contract's gamma by Black-Scholes from its mark IV and underlying when it reads the chain, so `chainClockMs()` solves the gamma of the contract whose delta sits nearest 0.5 for the time to expiry it implies. A venue that serves its own greeks gives no such answer, and the bar's open stands in there.

**The levels.** The chain goes through the `OptionsChain` kit twice. The whole chain, every live expiry and every strike, gives `maxPain()` and the gamma flip: cumulative net GEX from `netGex(i)` walked up the strikes, its zero crossing interpolated between two strikes, and when the far wings' few dollars flip the sum back and forth, the crossing nearest spot (the rule Gamma Map reads too); a sum landing exactly on zero crosses at that strike and the walk starts afresh after it, so no crossing is interpolated across the zero. A second load with the board's `window_pct` gives the walls a chart of this range trades against, read as Gamma Map reads them: `netGex(i)` finds the strike inside the window holding the largest positive net GEX and `putWall()` the heaviest put strike at or below spot inside it, so the two templates name the same put wall on the same chain. The labels rank the flip, max pain and the put wall 1 to 3 by the absolute net GEX at the strike nearest each (`#1 $86K | GF $85.7K`), so the heaviest level reads first. The level prices are data-only outputs, NaN on history bars, and `t_prev` and `t_last` carry the two newest bar times the lines are placed from.

**Two frames, two canvases.** The board frame carries `prices` (the strikes, ascending), `cells` (one row per price, one number per column, `null` where the expiry lists no open interest at the strike), `cols` (the expiry labels, `0DTE` for the front expiry inside its last day, else `27JUN`), a `title` and a `highlight` naming the price of the strike nearest spot, so the board outlines that one row. The bars frame carries the same `prices`, the row sums as `values`, a colour per row (`theme.up` for a positive sum, `theme.down` for a negative one) and a `tooltips` entry naming the call and put halves. Both are appended with `fb_text` and `fb_f64` into the generated frame buffer and sent with `writeFrameBuffer`, so the live bar allocates nothing. `plot.matrix({ frame: board, dock: "right" })` docks the board at the price axis, each row on its strike's price, the header sticky at the top; `palette: ["theme.down", "theme.bg", "theme.up"]` with `center: 0` and `scale: "sqrt"` tints the cells on a diverging ramp from the chart's down colour through its background to its up colour, `format: "usd"` with `signed: true` prints them as signed dollars, `grid_lines: "all"` rules every cell, `highlight_color: "#f8c000"` outlines the at-the-money row in amber and `tooltip` puts a readout under the cursor. `plot.levels({ frame: gexRows, dock: "right", baseline: "center" })` draws the bars from a zero line mid-dock, `width_px: 100` and `thickness_px: 10` size them, and `hover: true` opens the row's card. Two canvases docked on the same side overprint and no word stacks them: `offset: [340, 0]`, the board's width (four columns of 66 px plus the price column), pushes the bars inward so they sit left of the board instead of under it.

**The legend.** `legend({ title: "{{expiries}} expiries, ±{{window_pct}}%" })` puts the board's window after the indicator's name (`4 expiries, ±7%`, short enough to show whole), each `{{name}}` filled from the setting of that name, so the words stay true on every coin; `render.legend("spot_entry", { text: "spot_text" })` adds the live spot from a string slot written on the live bar (`live spot $86.5K`: the live bar's reading, which a crosshair on an older bar leaves as it is).

**The lines and their labels.** Each `draw.line` runs from `t_prev` to `t_last` at its level's price with `extend: "both"`, so the dashed line spans the whole pane. The labels are label handles declared once with `handles.label({ anchor: "right", align: "right", style: "knockout", font_weight: "medium" })`: `tags[k].set(inset, price)` puts a label's right edge `inset` pixels in from the price axis at its level's price, and `inset` is the board's width (66 px a column plus the 76 px price column, at least the bars' 340 px `offset`) plus the 100 px of bars and an 8 px gap, so a label never covers a cell or a bar or runs into the axis whatever `expiries` reads. Its text is built with `sb_text`, `sb_f64` and `sb_int` and sent with its own slot's sender (`.text(str_flip_text_sb)`), and `.color(theme.TEXT)` and its siblings ink it in the same theme words as its line. A level that is NaN deletes its label and draws no line.

## Where it runs

Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list), on any venue: Binance Futures BTCUSDT, Binance spot ETHUSDT and so on. The chart serves the newest chain on the live bar only, every history bar an empty block, and refreshes it with a snapshot about every 30 seconds. On a 1m or 5m chart the candles span a few strikes, so drag the price axis to zoom it out and the board shows more rows; the picture above is a 5m chart zoomed out that way. A CME futures chart has its own chain, but the editor's Run is paused on CME markets and a community indicator cannot read CME data; OpenMarket's official wrun indicators can.

## When data is missing

Other markets are refused by name before any fetch, naming the input and the way out: `Input 'chain' reads options_chain cells, but the chart market HYPERLIQUID_FUTURES/PURR has no option chain (wrun_options_chain_unavailable): coin 'PURR' is not listed on Deribit and the market is not an options venue; open a CME futures chart or a chart of a coin Deribit lists (BTC, ETH, SOL, ...)`. While the chain has not arrived yet the chart shows nothing but the candles; a chain with no live expiry writes no board and no bars; a cell with no open interest prints nothing and takes no tint; a level the kit cannot find (no zero crossing, no put strike at or below spot inside the window) draws no line and no label.

## Customize it

- **A narrower board.** Lower `window_pct` for the strikes nearest spot, or `expiries` to 2 for the front of the curve.
- **More columns.** Raise `expiries` (up to 12) and add 66 px to the `offset` of `plot.levels` for every added column, so the bars keep clear of the wider board; the labels move left with the board on their own.
- **Bars on the other side.** `dock: "left"` on `plot.levels` puts the bars at the left edge of the pane; drop the `offset` then.
- **Fewer levels.** Delete a `draw.line`, its label's block in `writeTexts()`, the output the line reads and the string slot the label sends; the rest keeps drawing.
- **Pin the venue.** Change `venue: "auto"` to `"deribit"` and Run again to read the coin's Deribit chain even where the chart's market lists options of its own; `"cme"` reads a CME futures chart's chain.
- **Your own colours.** The palette's two ends, the bars and the four levels are theme words (`theme.up`, `theme.down`, `theme.text`, `theme.muted`), so they follow the chart's theme and the user's up and down colours; a hex word in their place fixes a colour ([Style anything](../presentation/style-anything.md)).

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Strike Matrix** under **On price**.
2. Press **Run** on a BTC or ETH chart: the board docks at the price axis once the chain arrives, the bars beside it, the four dashed levels across the chart with their labels just left of the bars. Drag the price axis to zoom it out if the board shows only a few rows.
3. Hover a cell for its net GEX or a bar for the row's call and put halves; at the editor's Console prompt, type `net_gex` to read the board's sum, or `flip_price` for the gamma flip.

## Concepts used

- [Data sources](../core-concepts/data-sources.md) for the `options_chain` celled class, its tuple and the `venue` word
- [Price canvases](../presentation/price-canvases.md) for `plot.matrix`, the matrix frame, the colour scale and the hover readout
- [Docked profiles](../presentation/cards-frames-panels.md#docked-profiles) for `plot.levels` over a frame, `baseline`, `hover` and `offset`
- [Drawing objects](../presentation/drawing-objects.md) for `draw.line` placed from the newest bar and label handles pinned in pixels from the price axis
- [Options kit](../functions/options-kit.md) for `OptionsChain`, the flip, max pain and the walls
- [Style anything](../presentation/style-anything.md) for the whole desk, part by part
