Spot exposures

View as MarkdownOpen the editor

The dealers' exposure in the chart's own options measured bar by bar: net gamma, delta or vega as a line, the walls on price and a heat map by strike, from the strike rows the data plane folds every minute (the option_strikes source: each bar's newest minute of strike rows for the expiries within seven days, about seven days back).

A line under the chart follows the lens's net exposure (gamma per 1% move of the underlying by default, delta held, or vega per IV point, in dollars), coloured by its sign; three step lines on the price chart follow the zero-gamma strike and the call and put walls; and a heat map behind the candles paints the lens strike by strike on every bar, so where exposure built or unwound, and when, reads at a glance. Map values switches the map from each strike's level to its change from the previous bar with strike rows. A card at the bottom of the pane, in the terminal look, reads the newest strike rows: the gamma regime in one plain line, then the three nets, the change in net gamma since the session's first strike rows, the levels and the put/call open interest.

The parts are a celled option_strikes.cells input read on every bar (Data sources) through the options kit's OptionStrikes (Options kit), a split line in the lower pane and three step lines on price (Plotting), an out.grid heat map with a row step and a bottom edge of its own on every bar (Price canvases), the bar's time.trade_date for the session (The market's own calendar) and a render.hud card with look: "terminal" (HUD cards). This is also the spot-exposures template: the Spot Exposures card under HUDs in the editor's starter list, and it compiles as written.

The wrun indicator

// Spot Exposures: the dealers' exposure in the chart's own options, measured bar by bar from the strike rows the data
// plane folds every minute (the option_strikes source: each bar carries the newest minute of strike rows inside it, for
// the expiries within seven days, about seven days back). A line under the chart follows the lens's net exposure (gamma
// per 1% move by default, delta held, or vega per IV point, in dollars), three step lines on the price chart follow the
// zero-gamma strike and the call and put walls, and a heat map behind the candles paints the lens strike by strike on
// every bar with strike rows (Map values shows each strike's level, or its change from the previous bar with strike
// rows), so where exposure built or unwound, and when, reads at a glance. A card in the terminal look reads the newest
// strike rows: the gamma regime in one plain line, then the three nets, the change in net gamma since the session's
// first strike rows, the levels and the put/call open interest. The strike rows' convention: dealers long the calls and
// short the puts (calls add, puts subtract), delta in each option's own sign. The lines and the card carry the newest
// strike rows at or before each bar (Rows age counts the bars since); only a bar with strike rows paints the map.

section("Exposure");
param.choice("lens", ["gamma", "delta", "vega"], "gamma", { label: "Lens", description: "What the line and the map follow, in dollars: gamma per 1% move of the underlying, delta held, or vega per IV point" });
param.choice("map_mode", ["level", "change"], "level", { label: "Map values", description: "level: each strike's exposure on the bar; change: its change from the previous bar that carried strike rows" });
param.int("nearest", 0, { min: 0, max: 7, label: "Nearest expiries", description: "The N soonest expiries each bar's rows list; 0 counts every expiry the rows carry (those within seven days)" });
param.number("window_pct", 3, { min: 0.5, max: 20, step: 0.5, label: "Map window, percent", description: "Strikes on the map: percent around the underlying, each side, at most 48 strike steps each way" });
input("close", ohlcv.close); // the chart's own close: the map's centre where the rows report no underlying
input("strikes", option_strikes.cells, { max_cells: 4000, venue: "auto", description: "The chart's own strike rows: every (expiry, strike) row of the newest minute inside each bar, expiries within seven days" }); // [strike, expiry_ms, net_gex, call_gex, put_gex, net_dex, net_vex, call_oi, put_oi, call_iv, put_iv, underlying] per row
input("trade_date", time.trade_date); // the bar's trade date: the change in net gamma counts from the session's first strike rows
market.zone(); // the market's clock: the trade date where the chart writes none
output("exposure", line, lower, { width: 2, split: "level", up_color: "theme.up", down_color: "theme.down", split_fill: true, format: "usd", label: "Net exposure", description: "The lens's net exposure over the counted expiries, dollars: gamma per 1% move, delta held or vega per IV point" });
output("zero_gamma", line, overlay, { color: "theme.text", width: 1, step: true, line_style: "dashed", format: "price", label: "Zero gamma", description: "The first strike, walking up from the lowest, where net gamma summed up the strikes changes sign" });
output("call_wall", line, overlay, { color: "theme.up", width: 1, step: true, format: "price", label: "Call wall", description: "The strike holding the most call dollar gamma" });
output("put_wall", line, overlay, { color: "theme.down", width: 1, step: true, format: "price", label: "Put wall", description: "The strike holding the most put dollar gamma" });
// The readings, data-only: the card's tiles read them and the Console can too. Like the lines, each carries the newest
// strike rows at or before its bar.
output("net_gex", none, overlay, { description: "Net gamma exposure of the counted expiries, USD per 1% move of the underlying" });
output("net_dex", none, overlay, { description: "Net delta exposure, USD held, each option in its own sign" });
output("net_vex", none, overlay, { description: "Net vega exposure, USD per IV point" });
output("call_gex", none, overlay, { description: "The calls' dollar gamma, USD per 1% move" });
output("put_gex", none, overlay, { description: "The puts' dollar gamma, USD per 1% move, stored positive" });
output("gex_change", none, overlay, { description: "Net gamma minus net gamma at the session's first strike rows, USD per 1% move" });
output("max_pain", none, overlay, { description: "The listed strike where the open interest's payout at expiry is least" });
output("put_call_oi", none, overlay, { description: "Put open interest over call open interest" });
output("rows_age", none, overlay, { description: "Bars since the newest bar that carried strike rows: 0 on such a bar, NaN before the first" });
output("tone", none, overlay, { description: "The headline's colour: 0 short gamma, 1 no reading, 2 long gamma" });
output("map_low", none, overlay, { description: "The bottom edge of the map's lowest row on this bar" }); // the heat map reads it
output("map_step", none, overlay, { description: "The map's row height in price on this bar: the finest strike step inside the window" });
out.grid("map", { rows: 96 }); // 96 data-only outputs map_0..map_95, one per strike step, written with out_map(row, value)
plot.heatmap({ name: "exposure_map", grid: "map", price_low: "map_low", price_step: "map_step", palette: ["theme.down", "theme.bg", "theme.up"], center: 0, scale: "sqrt", auto_quantile: 0.98, opacity: 0.7, behind_candles: true, label: "Exposure by strike", tooltip: "{{value:usd}} at {{price}}" }); // the chart's down colour where puts carry the strike, its up colour where calls do
string("read", { max_bytes: 64 }); // the headline: the gamma regime in one plain line, or why there is none
string("title", { max_bytes: 64 }); // the card's title: the lens and the expiries counted

// The card, in the terminal look: the gamma regime leads as the headline, coloured by the sign of net gamma (violet
// short, slate for no reading, sky long), then the three nets and the levels of the newest strike rows.
render.hud("spot", {
  position: "bottom_center",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Dealers", "read", { headline: true, color_by: "tone", colors: ["#a78bfa", "#94a3b8", "#38bdf8"] }),
    tile.value("Net gamma", "net_gex", { format: "usd" }),
    tile.value("Since the session's first rows", "gex_change", { format: "usd" }),
    tile.value("Net delta", "net_dex", { format: "usd" }),
    tile.value("Net vega", "net_vex", { format: "usd" }),
    tile.rows([
      ["1) Zero gamma", "zero_gamma", "price"],
      ["2) Call wall", "call_wall", "price"],
      ["3) Put wall", "put_wall", "price"],
      ["4) Max pain", "max_pain", "price"],
      ["5) Put/call OI", "put_call_oi", "0.00"],
      ["6) Rows age, bars", "rows_age", "int"],
    ]),
  ],
});

const ROWS = 96; // the map's rows, the grid's declared count
const HALF = 48; // rows each side of the strike nearest the underlying
const GAMMA = 0;
const DELTA = 1;
const LENS_WORDS: String[] = ["gamma", "delta", "vega"];
const rowValue = new StaticArray<f64>(ROWS); // this bar's map, folded from the strike table
const prevStrike = new StaticArray<f64>(in_strikes_max_cells); // the previous strike rows' table: its strikes, ascending
const prevValue = new StaticArray<f64>(in_strikes_max_cells); // and the lens at each, for the change map
let table = new OptionStrikes(1); // one bar's strike rows measured by strike; sized in onStart()
let lens = GAMMA; // settings, read in onStart()
let changeMap = false;
let nearest = 0;
let windowPct = 0.03;
let zone = 0;
let prevCount = -1; // strikes in the previous table; -1 before the first bar with strike rows
let sessionKey: f64 = NaN; // the trade date the bars are in, days since 1970-01-01
let sessionFirstGex: f64 = NaN; // net gamma at the session's first strike rows
let age = -1; // bars since the newest bar with strike rows; -1 before the first
// The newest strike rows' readings, carried to the bars after them until the next rows arrive.
let exposure: f64 = NaN;
let zeroGamma: f64 = NaN;
let callWall: f64 = NaN;
let putWall: f64 = NaN;
let netGex: f64 = NaN;
let netDex: f64 = NaN;
let netVex: f64 = NaN;
let callGex: f64 = NaN;
let putGex: f64 = NaN;
let gexChange: f64 = NaN;
let maxPain: f64 = NaN;
let putCallOi: f64 = NaN;

function onStart(): void {
  table = new OptionStrikes(in_strikes_max_cells); // a block holds at most max_cells rows, so a load is never refused
  lens = i32(p_lens());
  changeMap = p_map_mode() == 1.0;
  nearest = i32(p_nearest());
  windowPct = p_window_pct() / 100.0;
  zone = i32(p_market_zone());
}

// ── The session: net gamma's change counts from the first strike rows of the bar's trade date ──
function trackSession(t: f64): void {
  const stamp = in_trade_date(); // epoch seconds of the trade date, as the chart writes it
  const key = isFinite(stamp) ? Math.floor(stamp / 86400.0) : optionsTradeDay(t * 1000.0, zone);
  if (isNaN(key)) return;
  if (isNaN(sessionKey) || key > sessionKey) {
    sessionKey = key;
    sessionFirstGex = NaN; // a new session: its first strike rows set the base
  }
}

// ── The lens, strike by strike, and the previous rows' value at a strike ──
function lensAt(k: i32): f64 { // the lens at the table's k-th strike (ascending)
  return lens == GAMMA ? table.netGex(k) : lens == DELTA ? table.netDex(k) : table.netVex(k);
}
function lensTotal(): f64 { // the lens over every strike of the table
  return lens == GAMMA ? table.totalNetGex() : lens == DELTA ? table.totalNetDex() : table.totalNetVex();
}
function previousAt(strike: f64): f64 { // the previous rows' lens at this strike; 0 where they did not list it
  let lo = 0;
  let hi = prevCount;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (prevStrike[mid] < strike) lo = mid + 1;
    else hi = mid;
  }
  return lo < prevCount && prevStrike[lo] == strike ? prevValue[lo] : 0.0;
}
function keepTable(): void { // this bar's table becomes the previous rows
  const count = table.strikeCount();
  for (let k = 0; k < count; k += 1) {
    prevStrike[k] = table.strike(k);
    prevValue[k] = lensAt(k);
  }
  prevCount = count;
}

// ── The map: one row per strike step, centred on the strike nearest the underlying ──
function clearMap(): void { // no map on this bar: every cell empty
  for (let r = 0; r < ROWS; r += 1) out_map(r, NaN);
  out_map_low(NaN);
  out_map_step(NaN);
}
function writeMap(spot: f64): void {
  const count = table.strikeCount();
  const lo = spot * (1.0 - windowPct);
  const hi = spot * (1.0 + windowPct);
  let first = 0; // the window's strikes: [first, last)
  while (first < count && table.strike(first) < lo) first += 1;
  let last = first;
  while (last < count && table.strike(last) <= hi) last += 1;
  if (last <= first || (changeMap && prevCount < 0)) { // no strike inside the window, or no earlier rows to change from
    clearMap();
    return;
  }
  let step = Infinity; // the finest strike step inside the window: one row per step
  for (let k = first + 1; k < last; k += 1) {
    const gap = table.strike(k) - table.strike(k - 1);
    if (gap > 0.0 && gap < step) step = gap;
  }
  if (step == Infinity) { // one strike in the window: its step to a neighbour, else a tenth of a percent of spot
    if (first > 0) step = table.strike(first) - table.strike(first - 1);
    else if (first + 1 < count) step = table.strike(first + 1) - table.strike(first);
    else step = spot * 0.001;
  }
  let centre = first; // the strike nearest the underlying, the lower one on a tie
  for (let k = first + 1; k < last; k += 1) {
    if (Math.abs(table.strike(k) - spot) < Math.abs(table.strike(centre) - spot)) centre = k;
  }
  const anchor = table.strike(centre);
  for (let r = 0; r < ROWS; r += 1) rowValue[r] = NaN;
  for (let k = first; k < last; k += 1) {
    const strike = table.strike(k);
    const row = HALF + i32(Math.round((strike - anchor) / step));
    if (row < 0 || row >= ROWS) continue; // more than 48 steps from the centre
    const v = changeMap ? lensAt(k) - previousAt(strike) : lensAt(k);
    rowValue[row] = isNaN(rowValue[row]) ? v : rowValue[row] + v; // a strike off the step's grid shares the nearest row
  }
  for (let r = 0; r < ROWS; r += 1) out_map(r, rowValue[r]);
  out_map_low(anchor - (f64(HALF) + 0.5) * step); // each row spans its strike plus and minus half a step
  out_map_step(step);
}

// ── The card's words ──
function writeWords(): void {
  sb_clear();
  if (age < 0) sb_text("No strike rows on this chart yet");
  else if (isNaN(netGex) || netGex == 0.0) sb_text("Flat gamma: no net dealer gamma");
  else sb_text(netGex > 0.0 ? "Long gamma: dealer hedging dampens moves" : "Short gamma: dealer hedging amplifies moves");
  if (age > 0) { // the newest rows are on an earlier bar
    sb_text(" (");
    sb_int(age);
    sb_text(age == 1 ? " bar ago)" : " bars ago)");
  }
  str_read_sb();
  sb_clear();
  sb_text("Spot exposures, ");
  sb_text(LENS_WORDS[lens]);
  if (nearest == 0) sb_text(", every expiry within 7 days");
  else {
    sb_text(", nearest ");
    sb_int(nearest);
    sb_text(nearest == 1 ? " expiry" : " expiries");
  }
  str_title_sb();
}

// onBar() runs once per bar: a bar with strike rows measures them (the lines, the levels, the readings and the map); a
// bar without keeps the newest readings and paints no map; the live bar writes the card's words.
function onBar(): void {
  const close = bar.close();
  const t = bar.time();
  trackSession(t);
  const n = in_strikes_cells();
  const kept = n >= OPTION_STRIKES_TUPLE ? table.load(in_strikes_view(), n, nearest) : 0;
  if (kept > 0) {
    netGex = table.totalNetGex();
    netDex = table.totalNetDex();
    netVex = table.totalNetVex();
    callGex = table.totalCallGex();
    putGex = table.totalPutGex();
    maxPain = table.maxPain();
    putCallOi = table.putCallRatio();
    zeroGamma = table.zeroGamma();
    callWall = table.callWall();
    putWall = table.putWall();
    exposure = lensTotal();
    if (isNaN(sessionFirstGex)) sessionFirstGex = netGex;
    gexChange = netGex - sessionFirstGex;
    age = 0;
    const under = table.underlying();
    const spot = under > 0.0 ? under : close;
    if (spot > 0.0) writeMap(spot);
    else clearMap();
    keepTable();
  } else {
    if (age >= 0) age += 1;
    clearMap();
  }
  out_exposure(exposure);
  out_zero_gamma(zeroGamma);
  out_call_wall(callWall);
  out_put_wall(putWall);
  out_net_gex(netGex);
  out_net_dex(netDex);
  out_net_vex(netVex);
  out_call_gex(callGex);
  out_put_gex(putGex);
  out_gex_change(gexChange);
  out_max_pain(maxPain);
  out_put_call_oi(putCallOi);
  out_rows_age(age >= 0 ? f64(age) : NaN);
  out_tone(age < 0 || isNaN(netGex) ? 1.0 : netGex > 0.0 ? 2.0 : netGex < 0.0 ? 0.0 : 1.0);
  if (bar.isLast()) writeWords();
}

How it works

One snapshot a bar. input("strikes", option_strikes.cells, { max_cells: 4000, venue: "auto" }) hands every bar the newest minute of strike rows inside it, one [strike, expiry_ms, net_gex, call_gex, put_gex, net_dex, net_vex, call_oi, put_oi, call_iv, put_iv, underlying] tuple per expiry and strike. OptionStrikes.load(cells, count, nearest) folds them into one table by strike: a strike listed under two expiries is one row, its exposures summed; a side the strike does not list (NaN) counts as 0. Nearest expiries keeps the N soonest expiries each bar lists before the table fills (0 keeps every expiry the rows carry, those within seven days). The table is constructed with the input's in_strikes_max_cells, so a load is never refused for size.

The convention. The strike rows put the dealers long the calls and short the puts: net_gex is the calls' dollar gamma minus the puts', per 1% move of the underlying, net_vex the calls' vega dollars minus the puts', and net_dex the delta dollars of both sides with each option in its own sign (puts negative). The lens's line is the table's total: totalNetGex(), totalNetDex() or totalNetVex().

The levels. On every bar with strike rows the kit reads the zero-gamma strike (zeroGamma(): walking up from the lowest strike, the first where the running sum of net gamma reaches zero or changes sign, a strike, never a point between two), the call wall and the put wall (callWall() and putWall(): the strikes holding the most call and put dollar gamma) and max pain (maxPain(): the listed strike where the open interest's payout at expiry is least). The three step lines draw the first three; max pain reads in the card.

The map. On a bar with strike rows the module writes 96 rows centred on the strike nearest the underlying (the rows' own underlying, else the chart's close), one row per strike step: the finest gap between neighbouring strikes inside Map window (3% each side by default), so a ladder of $1 strikes near the money and $5 strikes further out puts each strike on its own row and leaves the rows between the $5 strikes empty. A strike off the step's grid shares the nearest row. map_low is the bottom edge of the lowest row and map_step the row height, so each strike's row spans its price plus and minus half a step; plot.heatmap({ grid: "map", palette: ["theme.down", "theme.bg", "theme.up"], center: 0 }) paints the chart's down colour where puts carry the strike and its up colour where calls do. With Map values on change, each strike reads its lens minus the previous strike rows' (0 where they did not list it), and the first bar with strike rows paints nothing.

Carried readings. The lines and the card carry the newest strike rows at or before each bar, so a minute the rows missed does not break them; rows_age counts the bars since (0 on a bar that carries its own) and the headline says "(3 bars ago)" when the newest rows are not the live bar's. Only a bar with strike rows paints the map.

The session's change. gex_change is net gamma minus net gamma at the first strike rows of the bar's trade date (time.trade_date, else the trade date on the market's clock), so it starts at 0 each session and reads how much dealer gamma the day added or removed.

Where it runs

US stock and ETF charts with US listed options: the strike rows exist for OPRA chains, and the chart serves them to OpenMarket's official wrun indicators under the US options rule (Limitations); a draft or a community copy is refused by name. Any other chart reads empty blocks: the card says there are no strike rows yet, and nothing draws.

When data is missing

A bar without strike rows (outside the session, a minute the rows missed) paints no map and keeps the newest readings; before the first strike rows every output reads NaN and the headline reads "No strike rows on this chart yet". A strike that lists one side reads that side alone; a side without an IV reads nothing in the IV weighting. The rows reach back about seven days, so on a chart that loads further back the older bars draw nothing.

Customize it

  • The front expiry alone. Set Nearest expiries to 1 for the exposure that settles first; the walls and the map follow it.
  • Where it changed. Set Map values to change: green rows are strikes where dealer gamma grew since the previous bar, red where it fell.
  • A wider map. Raise Map window; a fine ladder stops at 48 strike steps each side.
  • Delta or vega. Set Lens to delta or vega: the line and the map follow it, the walls and the regime stay gamma's.

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick Spot Exposures under HUDs.
  2. Press Run on a US stock or ETF chart on a 1m or 5m interval: the line, the walls and the map fill bar by bar once the strike rows arrive.
  3. At the editor's Console prompt, type net_gex, zero_gamma and gex_change to read them on the live bar.

Concepts used