OI changes

View as MarkdownOpen the editor

Where open interest built and unwound since its prior observation, contract by contract, from the chart's own option chain summary on the newest bar: each contract's open interest and the prior observation of it, the day's volume and the quote (the option_contract_state source, its oi and oi_prev).

A board docked on the price axis reads the change by strike and expiry (one row per strike inside a window around the close, one column per expiry, the nearest first), a table under the chart lists the five largest increases and the five largest decreases, and a card at the bottom of the pane, in the terminal look, leads with the net change and its two sides. The Lens setting turns the board into the change in percent of the prior open interest, the open interest itself, the day's volume, or the net volume (calls minus puts), so the one board answers "where did positions open", "where do they sit" and "where did today trade".

The parts are a celled option_contract_state.cells input read on the live bar (Data sources), its present bits read with hasBit() and isPartial() and its option roots spelled from their symbol codes (The present mask and partial bars), three plot.matrix boards over three frames, one per kind of number, of which the lens writes one (Price canvases), a panel.table over a frame of styled cells (Cards, frames and panels) and a render.hud card with look: "terminal" (HUD cards). This is also the oi-changes template: the OI Changes card under HUDs in the editor's starter list, and it compiles as written.

The wrun indicator

// OI Changes: where open interest built and unwound since its prior observation, contract by contract, from the chart's
// own option chain summary (the option_contract_state source: each contract's open interest and the prior observation of
// it, the day's volume and the quote, on the newest bar). A board docked on the price axis reads the change by strike
// and expiry (or, by the Lens setting, the change in percent of the prior open interest, the open interest itself, the
// day's volume, or the net volume, calls minus puts), a table under the chart lists the largest increases and decreases
// (by contracts, or by percent of a prior open interest large enough to mean something), and a card in the terminal look
// reads the totals: the calls' and the puts' change, the strikes that built and unwound the most, the open interest and
// the day's volume. A contract without both observations of open interest counts in no change, and the card says how
// many; a summary the data plane marked partial says so in the title. The summary is a snapshot of now: history bars
// draw nothing, and everything is measured on the last bar.

section("Board");
param.choice("lens", ["oi_change", "oi_change_pct", "oi", "volume", "net_volume"], "oi_change", { label: "Lens", description: "oi_change: contracts added since the prior observation; oi_change_pct: in percent of the prior OI; oi; volume: the day's; net_volume: calls minus puts" });
param.choice("side", ["both", "calls", "puts"], "both", { label: "Side", description: "The contracts the board, the table and the strike totals count; net volume always reads calls minus puts" });
param.int("columns", 6, { min: 1, max: 12, label: "Expiry columns", description: "Expiries on the board, nearest first" });
param.number("window_pct", 5, { min: 1, max: 10, step: 0.5, label: "Strike window, percent", description: "Strikes on the board: percent around the close, each side" });
section("Leaders");
param.choice("rank", ["contracts", "percent"], "contracts", { label: "Rank by", description: "The table's order: the change in contracts, or in percent of the prior open interest" });
param.int("min_prior_oi", 100, { min: 1, max: 1000000, label: "Smallest prior OI for percent", description: "A contract ranks by percent only with at least this much prior open interest" });
input("close", ohlcv.close); // the chart's own close: the board's centre
input("state", option_contract_state.cells, { max_cells: 24000, expiries: "nearest:24", strike_window_pct: 10, description: "The chart's own option chain summary on the newest bar: the 24 nearest expiries, strikes within 10% of the underlying" }); // [root_code, expiry_ms, side_cp, strike, bid, ask, last, prev_close, theta, volume_day, oi, oi_prev, present] per contract
market.zone(); // the market's clock: each expiry's trade date
chart.interval_sec(); // the bar's length: a bar longer than a day names no single day, so no column reads 0DTE
// The readings, data-only: the card's tiles read them and the Console can too. Each is NaN on history bars.
output("call_change", none, overlay, { description: "The calls' open interest change since the prior observation, contracts; live bar only" });
output("put_change", none, overlay, { description: "The puts' open interest change, contracts" });
output("net_change", none, overlay, { description: "Calls' and puts' change together, contracts" });
output("total_oi", none, overlay, { description: "Open interest of every contract the summary lists, contracts" });
output("day_volume", none, overlay, { description: "The day's volume of every contract the summary lists, contracts" });
output("build_strike", none, overlay, { description: "The strike whose open interest grew most, every expiry together, on the Side setting" });
output("build_change", none, overlay, { description: "That strike's change, contracts" });
output("unwind_strike", none, overlay, { description: "The strike whose open interest fell most" });
output("unwind_change", none, overlay, { description: "That strike's change, contracts" });
output("counted", none, overlay, { description: "Contracts with both observations of open interest" });
output("missing", none, overlay, { description: "Contracts without both observations, left out of every change" });
output("partial", none, overlay, { description: "1 when the data plane marked the summary partial, else 0" });
output("tone", none, overlay, { description: "The headline's colour: 0 net change below zero, 1 none, 2 above zero; live bar only" });
string("read", { max_bytes: 64 }); // the headline: the net change and its two sides, or why there is none
string("title", { max_bytes: 96 }); // the card's title: what the summary held
// The board, one matrix per number kind; only the one the lens picks is written, so the other two stay absent.
const change_board = frame("change_board", { max_bytes: 32768 }); // signed counts: the OI change, the net volume
const pct_board = frame("pct_board", { max_bytes: 32768 }); // the OI change in percent
const size_board = frame("size_board", { max_bytes: 32768 }); // the open interest, the day's volume
const leaders = frame("leaders", { max_bytes: 8192 }); // the table: the largest increases, then the largest decreases
plot.matrix({ name: "change_matrix", frame: change_board, dock: "right", column_width: 62, row_max_px: 22, price_column: true, header: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", opacity: 0.95, format: "si", 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: "Change by expiry", tooltip: "{{column}}: {{value:si}} at {{price}}" });
plot.matrix({ name: "pct_matrix", frame: pct_board, dock: "right", column_width: 62, row_max_px: 22, price_column: true, header: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", opacity: 0.95, format: "%", signed: true, decimals: 0, 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: "Change % by expiry", tooltip: "{{column}}: {{value}}% at {{price}}" });
plot.matrix({ name: "size_matrix", frame: size_board, dock: "right", column_width: 62, row_max_px: 22, price_column: true, header: true, palette: ["theme.bg", "theme.up"], scale: "sqrt", opacity: 0.95, format: "si", 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: "Contracts by expiry", tooltip: "{{column}}: {{value:si}} at {{price}}" });
panel.table({ name: "oi_leaders", title: "Largest open interest changes", x: "category", place: "below", frame: leaders, height_frac: 0.22, header_column: true, header_text_color: "theme.muted", grid_lines: "rows", grid_color: "theme.grid", grid_width: 1, valign: "middle", cell_padding: 4, font_size: 11, series: [{ name: "Contract" }, { name: "Prior OI", align: "right", format: "int" }, { name: "OI", align: "right", format: "int" }, { name: "Change", align: "right", format: "int", signed: true }, { name: "Change %", align: "right", format: "0.0", unit: "%" }, { name: "Volume", align: "right", format: "int" }] });

// The card, in the terminal look: the net change leads as the headline, coloured by its sign (violet below zero, slate
// for none, sky above), then the two sides and the strikes that moved most.
render.hud("oi_changes", {
  position: "bottom_center",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Open interest", "read", { headline: true, color_by: "tone", colors: ["#a78bfa", "#94a3b8", "#38bdf8"] }),
    tile.value("Calls", "call_change", { format: "si" }),
    tile.value("Puts", "put_change", { format: "si" }),
    tile.rows([
      ["1) Built most at", "build_strike", "price"],
      ["2) Its change", "build_change", "si"],
      ["3) Unwound most at", "unwind_strike", "price"],
      ["4) Its change", "unwind_change", "si"],
      ["5) Open interest", "total_oi", "si"],
      ["6) Day volume", "day_volume", "si"],
      ["7) Without both readings", "missing", "int"],
    ]),
  ],
});

const TUPLE = 13; // f64s per contract: root_code, expiry_ms, side_cp, strike, bid, ask, last, prev_close, theta, volume_day, oi, oi_prev, present
const BIT_VOLUME = 9; // present bits: volume_day, oi, oi_prev
const BIT_OI = 10;
const BIT_OI_PREV = 11;
const MAX_ROWS = 128; // strikes on the board (a matrix's row cap)
const MAX_COLS = 12; // expiries on the board (the setting's cap)
const MAX_EXPIRIES = 64; // distinct expiries the summary may list
const MAX_STRIKES = 4096; // distinct strikes kept for the per-strike totals, the nearest the close when more
const TOP = 5; // rows each side of the leaders table
const OI_CHANGE = 0;
const OI_CHANGE_PCT = 1;
const OI = 2;
const VOLUME = 3;
const NET_VOLUME = 4;
const LENS_TITLES: String[] = ["OI change by expiry", "OI change % by expiry", "Open interest by expiry", "Volume by expiry", "Net volume by expiry"];
const MONTHS: String[] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const SYMBOL_CHARS: String[] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "."]; // a symbol code's characters, value 1 to 37
const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the listed expiries, ascending
const strikePrice = new StaticArray<f64>(MAX_ROWS); // the board's strikes, ascending
const cellA = new StaticArray<f64>(MAX_ROWS * MAX_COLS); // per cell: the lens's sum (the change, the OI, the volume)
const cellB = new StaticArray<f64>(MAX_ROWS * MAX_COLS); // per cell: the prior open interest, the percent lens's base
const cellSeen = new StaticArray<i32>(MAX_ROWS * MAX_COLS); // 1 where a contract with the lens's readings landed
const allStrike = new StaticArray<f64>(MAX_STRIKES); // every strike with a change, ascending
const allChange = new StaticArray<f64>(MAX_STRIKES); // and its change over every expiry
const upAt = new StaticArray<i32>(TOP); // the largest increases: each contract's offset in the block, and its key
const upKey = new StaticArray<f64>(TOP);
const downAt = new StaticArray<i32>(TOP); // the largest decreases, the most negative first
const downKey = new StaticArray<f64>(TOP);
const rootDigits = new StaticArray<i32>(10); // a root's characters, the last first, while it is spelled
const dateClock = new Clock("UTC"); // reads a date from its day number

let lens = OI_CHANGE; // settings, read in onStart()
let side = 0; // 0 both, 1 calls, 2 puts
let columns = 6;
let windowPct = 0.05;
let rankPercent = false;
let minPrior = 100.0;
let zone = 0;
let longBar = false;
let close: f64 = NaN; // this bar
let today: f64 = NaN; // the live bar's trade date, days since 1970-01-01; NaN on a bar longer than a day
let expiryCount = 0;
let rowCount = 0;
let strikeCount = 0;
let upCount = 0;
let downCount = 0;
let counted = 0; // contracts with both observations
let missing = 0; // contracts without them
let partial = false;
let callChange = 0.0;
let putChange = 0.0;
let totalOi = 0.0;
let dayVolume = 0.0;

function onStart(): void {
  lens = i32(p_lens());
  side = i32(p_side());
  columns = i32(p_columns());
  if (columns > MAX_COLS) columns = MAX_COLS;
  windowPct = p_window_pct() / 100.0;
  rankPercent = p_rank() == 1.0;
  minPrior = p_min_prior_oi();
  zone = i32(p_market_zone());
  longBar = p_chart_interval_sec() > 86400.0;
}

// ── The tables: sorted insertion ──
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) {
    if (lo >= expiryCount) return; // later than every kept expiry
    expiryCount -= 1; // the latest makes room
  }
  for (let k = expiryCount; k > lo; k -= 1) expiryMs[k] = expiryMs[k - 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;
}
function clearRow(row: i32): void {
  for (let c = 0; c < MAX_COLS; c += 1) {
    cellA[row * MAX_COLS + c] = 0.0;
    cellB[row * MAX_COLS + c] = 0.0;
    cellSeen[row * MAX_COLS + c] = 0;
  }
}
function moveRow(from: i32, to: i32): void {
  strikePrice[to] = strikePrice[from];
  for (let c = 0; c < MAX_COLS; c += 1) {
    cellA[to * MAX_COLS + c] = cellA[from * MAX_COLS + c];
    cellB[to * MAX_COLS + c] = cellB[from * MAX_COLS + c];
    cellSeen[to * MAX_COLS + c] = cellSeen[from * MAX_COLS + c];
  }
}
function rowSlot(strike: f64): i32 { // the board row of this strike, inserted when new; -1 when the board is full and it is the farthest from the close
  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 the close gives way, 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 addStrikeChange(strike: f64, change: f64): void { // a contract's change into its strike's total over every expiry
  let lo = 0;
  let hi = strikeCount;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (allStrike[mid] < strike) lo = mid + 1;
    else hi = mid;
  }
  if (lo < strikeCount && allStrike[lo] == strike) {
    allChange[lo] += change;
    return;
  }
  if (strikeCount >= MAX_STRIKES) return; // past the cap: a strike this far down the list is left out of the totals
  for (let k = strikeCount; k > lo; k -= 1) {
    allStrike[k] = allStrike[k - 1];
    allChange[k] = allChange[k - 1];
  }
  allStrike[lo] = strike;
  allChange[lo] = change;
  strikeCount += 1;
}

// ── The leaders: the TOP largest increases and decreases, ties kept in the summary's order ──
function offer(at: i32, key: f64): void {
  if (key > 0.0) {
    let k = upCount < TOP ? upCount : TOP;
    if (k == TOP && key <= upKey[TOP - 1]) return;
    if (k == TOP) k = TOP - 1;
    while (k > 0 && upKey[k - 1] < key) {
      upKey[k] = upKey[k - 1];
      upAt[k] = upAt[k - 1];
      k -= 1;
    }
    upKey[k] = key;
    upAt[k] = at;
    if (upCount < TOP) upCount += 1;
  } else if (key < 0.0) {
    let k = downCount < TOP ? downCount : TOP;
    if (k == TOP && key >= downKey[TOP - 1]) return;
    if (k == TOP) k = TOP - 1;
    while (k > 0 && downKey[k - 1] > key) {
      downKey[k] = downKey[k - 1];
      downAt[k] = downAt[k - 1];
      k -= 1;
    }
    downKey[k] = key;
    downAt[k] = at;
    if (downCount < TOP) downCount += 1;
  }
}

// ── The summary, measured on the live bar ──
function sideKept(isCall: bool): bool {
  return side == 0 || (side == 1) == isCall;
}
function measure(n: i32): void { // n = f64 cells in the live block
  const cells = in_state_view(); // the live summary in place: the first n values of the build's own buffer
  expiryCount = 0;
  rowCount = 0;
  strikeCount = 0;
  upCount = 0;
  downCount = 0;
  counted = 0;
  missing = 0;
  partial = false;
  callChange = 0.0;
  putChange = 0.0;
  totalOi = 0.0;
  dayVolume = 0.0;
  // The first pass: the expiries, the totals, the per-strike changes and the leaders, over every contract.
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    const present = cells[i + 12];
    if (isPartial(present)) partial = true;
    const strike = cells[i + 3];
    if (!(strike > 0.0) || !isFinite(cells[i + 1])) continue;
    noteExpiry(cells[i + 1]);
    if (hasBit(present, BIT_VOLUME)) dayVolume += cells[i + 9];
    if (hasBit(present, BIT_OI)) totalOi += cells[i + 10];
    if (!hasBit(present, BIT_OI) || !hasBit(present, BIT_OI_PREV)) {
      missing += 1; // an absent reading is no change
      continue;
    }
    const prior = cells[i + 11];
    const change = cells[i + 10] - prior;
    const isCall = cells[i + 2] > 0.0;
    counted += 1;
    if (isCall) callChange += change;
    else putChange += change;
    if (!sideKept(isCall)) continue;
    addStrikeChange(strike, change);
    if (!rankPercent) offer(i, change);
    else if (prior >= minPrior) offer(i, (100.0 * change) / prior);
  }
  // The second pass: the board, the nearest `columns` expiries inside the strike window.
  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 + 3];
    if (!(strike > 0.0) || strike < lo || strike > hi) continue;
    const col = columnOf(cells[i + 1]);
    if (col < 0) continue;
    const present = cells[i + 12];
    const isCall = cells[i + 2] > 0.0;
    let a: f64 = NaN; // this contract's part of the cell, NaN when it carries no reading for the lens
    let b = 0.0;
    if (lens == NET_VOLUME) {
      if (hasBit(present, BIT_VOLUME)) a = isCall ? cells[i + 9] : -cells[i + 9];
    } else if (sideKept(isCall)) {
      if (lens == OI) {
        if (hasBit(present, BIT_OI)) a = cells[i + 10];
      } else if (lens == VOLUME) {
        if (hasBit(present, BIT_VOLUME)) a = cells[i + 9];
      } else if (hasBit(present, BIT_OI) && hasBit(present, BIT_OI_PREV)) {
        a = cells[i + 10] - cells[i + 11];
        b = cells[i + 11];
      }
    }
    if (isNaN(a)) continue;
    const row = rowSlot(strike);
    if (row < 0) continue;
    cellA[row * MAX_COLS + col] += a;
    cellB[row * MAX_COLS + col] += b;
    cellSeen[row * MAX_COLS + col] = 1;
  }
}

// ── Text helpers, written into the frame buffer and the line buffer without allocating ──
function fbRoot(code: f64): bool { // the option root a symbol code names; false (nothing written) when it names none
  if (!isFinite(code) || code < 1.0 || code > 6278211847988223.0 || code != Math.floor(code)) return false;
  let rest = u64(code);
  let count = 0;
  while (rest > 0) {
    const value = i32(rest % 38);
    if (value == 0 || count >= 10) return false; // no text encodes to a zero digit
    rootDigits[count] = value;
    count += 1;
    rest = rest / 38;
  }
  for (let k = count - 1; k >= 0; k -= 1) fb_text(SYMBOL_CHARS[rootDigits[k] - 1]);
  return true;
}
function fbDate(ms: f64): void { // "17OCT": an expiry's trade date
  dateClock.update(optionsTradeDay(ms, zone) * 86400.0);
  if (dateClock.dayOfMonth() < 10) fb_text("0");
  fb_int(dateClock.dayOfMonth());
  fb_text(MONTHS[dateClock.month() - 1]);
}
function fbStrike(v: f64): void { // a strike as it is quoted: 660, 662.5, 0.25
  if (v == Math.floor(v)) fb_int(i64(v));
  else if (v * 10.0 == Math.floor(v * 10.0)) fb_f64(v, 1);
  else fb_f64(v, 2);
}
function fbContract(cells: StaticArray<f64>, i: i32): void { // "\"SPY 17OCT 660C\"", a root that spells nothing as "?"
  fb_text("\"");
  if (!fbRoot(cells[i])) fb_text("?");
  fb_text(" ");
  fbDate(cells[i + 1]);
  fb_text(" ");
  fbStrike(cells[i + 3]);
  fb_text(cells[i + 2] > 0.0 ? "C\"" : "P\"");
}
function sbCount(v: f64): void { // "+12.3K", "-850", "0"
  if (v > 0.0) sb_text("+");
  else if (v < 0.0) sb_text("-");
  const a = Math.abs(v);
  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);
}

// ── Frames: the board and the leaders, appended as JSON text without allocating ──
function writeBoard(): void { // strikes ascending, one cell per expiry column, the row nearest the close highlighted
  if (rowCount == 0) return;
  const cols = columns < expiryCount ? columns : expiryCount;
  let atm = 0; // the row nearest the close
  for (let r = 1; r < rowCount; r += 1) if (Math.abs(strikePrice[r] - close) < Math.abs(strikePrice[atm] - close)) atm = r;
  fb_clear();
  fb_text("{\"prices\":[");
  for (let r = 0; r < rowCount; r += 1) {
    if (r > 0) fb_text(",");
    fb_num(strikePrice[r]);
  }
  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(",");
      const at = r * MAX_COLS + c;
      if (cellSeen[at] == 0) fb_text("null"); // no contract with the lens's readings in this cell: no fill, no text
      else if (lens == OI_CHANGE_PCT) fb_num(cellB[at] > 0.0 ? (100.0 * cellA[at]) / cellB[at] : NaN); // a cell of new contracts only has no base: null
      else fb_num(cellA[at]);
    }
    fb_text("]");
  }
  fb_text("],\"cols\":[");
  for (let c = 0; c < cols; c += 1) {
    if (c > 0) fb_text(",");
    fb_text("\"");
    if (c == 0 && !isNaN(today) && optionsTradeDay(expiryMs[c], zone) <= today) fb_text("0DTE");
    else fbDate(expiryMs[c]);
    fb_text("\"");
  }
  fb_text("],\"title\":\"");
  fb_text(LENS_TITLES[lens]);
  fb_text("\",\"highlight\":{\"price\":");
  fb_num(strikePrice[atm]);
  fb_text("}}");
  if (lens == OI_CHANGE_PCT) writeFrameBuffer(pct_board);
  else if (lens == OI || lens == VOLUME) writeFrameBuffer(size_board);
  else writeFrameBuffer(change_board);
}
function fbLeader(cells: StaticArray<f64>, i: i32): void { // one table row: the contract, the prior OI, the OI, the change, the change in percent, the day's volume
  const prior = cells[i + 11];
  const change = cells[i + 10] - prior;
  fb_text("[");
  fbContract(cells, i);
  fb_text(",");
  fb_num(prior);
  fb_text(",");
  fb_num(cells[i + 10]);
  fb_text(",{\"value\":");
  fb_num(change);
  fb_text(change >= 0.0 ? ",\"text_color\":\"theme.up\"}," : ",\"text_color\":\"theme.down\"},");
  if (prior > 0.0) fb_num((100.0 * change) / prior);
  else fb_text("\"new\""); // no prior open interest: every contract is new
  fb_text(",");
  if (hasBit(cells[i + 12], BIT_VOLUME)) fb_num(cells[i + 9]);
  else fb_text("null");
  fb_text("]");
}
function writeLeaders(): void {
  const cells = in_state_view();
  fb_clear();
  fb_text("{\"caption\":\"");
  if (upCount + downCount == 0) fb_text("No change in open interest to rank");
  else {
    fb_text("The largest increases, then the largest decreases, by ");
    fb_text(rankPercent ? "percent of the prior open interest" : "contracts");
  }
  fb_text("\",\"rows\":[");
  for (let k = 0; k < upCount; k += 1) {
    if (k > 0) fb_text(",");
    fbLeader(cells, upAt[k]);
  }
  for (let k = 0; k < downCount; k += 1) {
    if (upCount + k > 0) fb_text(",");
    fbLeader(cells, downAt[k]);
  }
  fb_text("]}");
  writeFrameBuffer(leaders);
}

// ── The card's words ──
function writeWords(): void {
  sb_clear();
  if (counted == 0) sb_text("No contract carries both open interest readings");
  else {
    sb_text("Net ");
    sbCount(callChange + putChange);
    sb_text(": calls ");
    sbCount(callChange);
    sb_text(", puts ");
    sbCount(putChange);
  }
  str_read_sb();
  sb_clear();
  sb_text("OI since the prior reading, ");
  sb_int(expiryCount);
  sb_text(expiryCount == 1 ? " expiry, " : " expiries, ");
  sb_int(counted);
  sb_text(" contracts");
  if (partial) sb_text(", partial summary");
  str_title_sb();
}

// onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the summary,
// writes the readings (NaN on history rows), the board, the table and the card's words.
function onBar(): void {
  close = bar.close();
  const t = bar.time();
  let measured = false; // the live bar carried a summary with a contract in it
  let stateCells = -1;
  if (bar.isLast() && !isNaN(close) && !isNaN(t)) {
    stateCells = in_state_cells();
    if (stateCells >= TUPLE) {
      today = longBar ? NaN : optionsTradeDay(t * 1000.0, zone);
      measure(stateCells);
      measured = expiryCount > 0;
    }
  }
  const reading = measured && counted > 0;
  const net = callChange + putChange;
  let build = -1; // the strikes whose change, every expiry together, rose and fell most
  let unwind = -1;
  if (reading) {
    for (let k = 0; k < strikeCount; k += 1) {
      if (allChange[k] > 0.0 && (build < 0 || allChange[k] > allChange[build])) build = k;
      if (allChange[k] < 0.0 && (unwind < 0 || allChange[k] < allChange[unwind])) unwind = k;
    }
  }
  out_call_change(reading ? callChange : NaN);
  out_put_change(reading ? putChange : NaN);
  out_net_change(reading ? net : NaN);
  out_total_oi(measured ? totalOi : NaN);
  out_day_volume(measured ? dayVolume : NaN);
  out_build_strike(build >= 0 ? allStrike[build] : NaN);
  out_build_change(build >= 0 ? allChange[build] : NaN);
  out_unwind_strike(unwind >= 0 ? allStrike[unwind] : NaN);
  out_unwind_change(unwind >= 0 ? allChange[unwind] : NaN);
  out_counted(measured ? f64(counted) : NaN);
  out_missing(measured ? f64(missing) : NaN);
  out_partial(measured ? (partial ? 1.0 : 0.0) : NaN);
  out_tone(reading ? (net > 0.0 ? 2.0 : net < 0.0 ? 0.0 : 1.0) : bar.isLast() ? 1.0 : NaN);
  if (measured) {
    writeBoard();
    writeLeaders();
    writeWords();
  } else if (bar.isLast()) {
    // The live bar carried nothing to measure: the headline says why, so the card is never a row of blanks.
    sb_clear();
    sb_text(stateCells < TUPLE ? "Waiting for the option chain summary" : "No contract in the summary");
    str_read_sb();
    sb_clear();
    sb_text("OI since the prior reading, no summary read");
    str_title_sb();
  }
}

How it works

The summary is a snapshot of now. input("state", option_contract_state.cells, { max_cells: 24000, expiries: "nearest:24", strike_window_pct: 10 }) delivers one 13-value tuple per contract of the 24 nearest expiries with a strike within 10% of the underlying, on the live row; every history bar carries an empty block, so everything is measured on the last bar and the card, the board and the table are rewritten as the summary refreshes. The knobs are part of the fetch, so the sheet names them; the 24,000 cells leave room for an index's ladder of strikes.

A change needs both readings. A contract counts in a change only when its present mask carries bit 10 (oi) and bit 11 (oi_prev): the change is oi - oi_prev, in contracts. A contract missing either reading is counted in the card's last row ("Without both readings") and left out of every change, never read as 0. A contract with a prior open interest of 0 is new: its change counts, and the table prints new in its percent column. When any tuple carries bit 14 the data plane cut the summary short, and the title says "partial summary".

The board. The nearest Expiry columns expiries (6 by default) and the strikes within Strike window of the close (5% each side, at most 128 rows, the farthest from the close giving way first) make the board. Each cell sums its contracts on the lens: the change (oi_change), the change over the cell's prior open interest in percent (oi_change_pct; a cell of new contracts alone has no base and prints nothing), the open interest (oi), the day's volume (volume) or the call volume minus the put volume (net_volume). Side keeps the calls or the puts alone on every lens but the net volume. A cell with no contract that carries the lens's readings prints nothing and takes no tint. The front column reads 0DTE when its expiry's trade date is today's on the market's clock (on a chart of a day or shorter; a longer bar names no single day). Signed counts ride a board with a diverging palette centred on zero, percents a board formatted %, and the open interest and the volume a board with one ramp, so each prints its own unit.

The leaders. Every contract with both readings on the Side setting is offered to two lists of five: the largest increases, then the largest decreases (the most negative first), by contracts, or by percent of the prior open interest when Rank by says so, among the contracts holding at least Smallest prior OI for percent (100 by default: a change from 3 to 57,154 contracts is not a percent worth ranking). Ties keep the summary's order. Each row names the contract (SPY 17OCT 660C: the root from its symbol code, ? when the code spells nothing, the expiry's trade date, the strike, C or P), the prior open interest, the open interest, the change in the up or down colour, the change in percent and the day's volume.

The card. The headline is the net change with its two sides (Net +12.3K: calls +9.9K, puts +2.5K), coloured by its sign; then the calls' and the puts' change, the strikes whose change summed over every expiry rose and fell most, with their changes, the open interest and the day's volume of every listed contract, and the count without both readings. The title says how many expiries and contracts the summary held.

Where it runs

US stock and ETF charts with US listed options, through OpenMarket's official wrun indicator, under the US options rule (Limitations). The option_contract_state source is upcoming: the kit declares it ahead of the chart lane that will serve it, so the package builds, and the chart shows its refusal until that lane ships.

When data is missing

The headline says why instead of the card going blank: "Waiting for the option chain summary" while the live bar carries none, "No contract in the summary" for an empty one, and "No contract carries both open interest readings" when no prior open interest has been observed. A board cell with no reading prints nothing; a lens with no cell writes no board.

Customize it

  • Today's trading instead. Set Lens to volume, or net volume for calls against puts by strike and expiry.
  • The puts alone. Set Side to puts: the board, the table and the strike totals count the puts.
  • Rank by percent. Set Rank by to percent and raise Smallest prior OI for percent to keep thin contracts out.
  • Further out. Change expiries: "nearest:24" and strike_window_pct: 10 in the input and Run again; size max_cells for the contracts they keep.

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick OI Changes under HUDs.
  2. Press Run on a US stock or ETF chart: the board docks at the price axis once the summary arrives, the table opens under the chart and the card at the bottom centre.
  3. At the editor's Console prompt, type net_change, build_strike and missing to read them on the live bar.

Concepts used