0DTE levels

View as MarkdownOpen the editor

The front expiry of the chart's own option chain as dealer levels on the price chart.

BTC and ETH list a Deribit expiry every day and SPY, QQQ and IWM one every weekday, so there the front expiry settles today: the 0DTE. The expiry's net gamma exposure by strike docks on the price axis as a profile, calls minus puts in USD per 1% move of spot: sky where calls carry the strike, violet where puts do. Five lines run across the chart with a price tag at the axis: the call wall (sky), the put wall (violet), the gamma flip (amber, dashed) and the two edges of the expected move (slate, dotted). A card at the bottom of the pane, in the terminal look, answers one question first, as the headline: are dealers long or short gamma where spot trades? Under the answer the card reads the expiry's net GEX, the expected move, charm into the close and six rows: 1) the flip, 2) the call wall, 3) the put wall, 4) max pain, 5) the pin and the hours left to the New York close. Its title bar names the expiry: 0DTE on the expiry day, else its date and the days to go (16OCT, 1 DTE), which is what a single stock with weekly expiries shows on most days.

The parts are a celled options_chain.cells input narrowed to the front expiry by expiries: "nearest:1" and read on the live bar (Data sources), two OptionsChain objects for the exposure, the flip, the walls and max pain (Options kit), Clock and the bar's trade date for the calendar (The market's own calendar), a frame feeding plot.levels docked on the price axis (Cards, frames and panels), line and label handles for the five levels and their tags (Drawing objects), and a render.hud card with look: "terminal" over data-only outputs and three text slots (HUD cards, Looks, Blocks and tiles). This is also the zero-dte-levels template: the 0DTE Levels card under HUDs in the editor's starter list, and it compiles as written.

The wrun indicator

// 0DTE Levels: the front expiry of the chart's own option chain as dealer levels on the price chart. BTC and ETH list
// a Deribit expiry every day and SPY, QQQ and IWM one every weekday, so there the front expiry settles today (0DTE); on
// a single stock it can be days away, and the card's title then names it (16OCT, 1 DTE). The expiry's net gamma
// exposure by strike docks on the price axis (calls minus puts, USD per 1% move of spot: sky where calls carry the
// strike, violet where puts do), and five lines run across the chart with price tags at the axis: the call wall
// (sky), the put wall (violet), the gamma flip (amber, dashed) and the two edges of the expected move (slate, dotted).
// A card at the bottom of the pane, in the terminal look, answers one question first: are dealers long or short
// gamma where spot trades? Under the answer: the expiry's net GEX, the expected move, the delta the dealers' hedges
// shed into the close (charm) and the five levels with the hours left to the New York close. The chain rides the
// live bar only: history bars draw nothing, and everything is measured on the last bar.

// The frame channel, declared here so the profile is written from a static byte buffer: the generated writeFrame
// takes a string, and a string built per bar allocates, which the sandbox refuses once the bars start.
@external("wrun", "wrun_output_frame")
declare function wrun_output_frame(slot: i32, ptr: i32, len: i32): void;

section("Strikes and walls");
param.number("window_pct", 3, { min: 0.5, max: 20, label: "Strike window, percent", description: "Strikes in the docked profile, the walls and the pin: percent around spot, each side" });
param.choice("wall_basis", ["gamma", "oi"], "gamma", { label: "Walls by", description: "gamma: the strike at or beyond spot holding the most call (put) dollar gamma; oi: the most call (put) open interest" });
input("close", ohlcv.close); // the chart's own close: spot where the chain reports none
input("chain", options_chain.cells, { max_cells: 800, venue: "auto", expiries: "nearest:1", description: "The front expiry of the chart's own option chain" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract
input("trade_date", time.trade_date); // the bar's trade date, the day the expiry is counted from: the UTC date on crypto, the New York date on a US stock
market.zone(); // the market's own clock, written by the chart: 0 UTC (crypto), 1 New York (US stocks), 2 Chicago (CME, whose trade day opens at 17:00)
// The readings, data-only: the card's tiles read them and the Console can too. Each is NaN on history bars.
output("net_gex", none, overlay, { description: "Net gamma exposure of the front expiry, USD per 1% move of spot (calls plus, puts minus); live bar only" });
output("gamma_flip", none, overlay, { description: "Gamma flip: where the expiry's cumulative net GEX, walked up the strikes, crosses zero, the crossing nearest spot when it crosses more than once; live bar only" });
output("call_wall", none, overlay, { color: "#38bdf8", description: "Call wall: the strike at or above spot, inside the window, holding the most call dollar gamma (or open interest, by the Walls by setting)" }); // the colour is the dot beside its row on the card, the line's own sky
output("put_wall", none, overlay, { color: "#a78bfa", description: "Put wall: the strike at or below spot, inside the window, holding the most put dollar gamma (or open interest, by the Walls by setting)" });
output("max_pain", none, overlay, { description: "Max pain: the strike where the expiry pays option holders the least; live bar only" });
output("pin", none, overlay, { description: "Pin: the strike inside the window holding the largest net gamma, either sign; live bar only" });
output("exp_move_pct", none, overlay, { description: "Expected move, percent of spot: at-the-money implied volatility over the time left to the New York close on the expiry day, over one calendar day before it, and to the expiry itself when that comes sooner" });
output("em_hi", none, overlay, { description: "Spot plus the expected move; live bar only" });
output("em_lo", none, overlay, { description: "Spot minus the expected move; live bar only" });
output("charm_close", none, overlay, { description: "Charm into the close, USD: the delta the dealers' hedges shed by the New York close, or by each contract's own expiry when that comes first, spot held, signed as net GEX is; live bar only" });
output("gamma_regime", none, overlay, { description: "Dealer gamma at spot, the first tile's colour: 0 short gamma (spot under the flip), 2 long gamma (spot at or above it), 1 no reading; live bar only" });
output("dte", none, overlay, { description: "Days to the front expiry: its trade date minus today's on the market's own clock (the calendar time.trade_date uses), 0 on the expiry day; live bar only" });
output("hours_to_close", none, overlay, { description: "Hours left to 16:00 New York today, 0 once it passed; NaN on a chart of a day or longer" });
string("tag", { max_bytes: 40 }); // one bounded slot the five price tags are written through, one at a time
string("dealer_gamma", { max_bytes: 32 }); // the card's first tile: Long gamma, Short gamma, or why there is no reading
string("expiry_label", { max_bytes: 48 }); // the card's title: 0DTE, or the expiry's date and its days to go
string("move_text", { max_bytes: 32 }); // the move tile: the percent and its horizon, to the close, to expiry or over one day
handles.line({ width: 1, extend: "both" }); // the walls, the flip and the move's edges, across the whole chart
handles.label({ anchor: "right", align: "right", size: 11, color: "#e2e8f0" }); // price tags: x in pixels from the right edge, y in price
const gex_rows = frame("gex_rows", { max_bytes: 16384 }); // the docked profile: one row per grid price
plot.levels({ name: "gex_by_strike", frame: gex_rows, dock: "right", width_frac: 0.12, labels: false, color: "#38bdf8", thickness_px: 12 }); // bars at most 12 px tall, so a zoomed-in chart reads as a ladder, never as slabs

// The card, in the terminal look (an amber title bar, monospace type, numbered rows): a HUD pinned at the bottom
// centre of the pane, clear of the docked profile and the tags on the right. Every tile reads the newest row, the
// live bar's. The regime leads as the headline, coloured by the regime at spot (bear red for short gamma, slate for no
// reading, bull green for long gamma); the title is the expiry ({{expiry_label}} reads the slot of that name). A tile's
// label is fixed text, so the move tile reads its percent and horizon from the move_text slot.
render.hud("zero_dte", {
  position: "bottom_center",
  look: "terminal",
  title: "{{expiry_label}}",
  columns: 2,
  tiles: [
    tile.pill("Dealer gamma", "dealer_gamma", { headline: true, color_by: "gamma_regime", colors: ["#ff003c", "#94a3b8", "#10b981"] }),
    tile.value("Net GEX per 1% move", "net_gex", { format: "si" }),
    tile.pill("Expected move", "move_text"),
    tile.value("Charm into close", "charm_close", { format: "si" }),
    tile.rows([
      ["1) Gamma flip", "gamma_flip", "price"],
      ["2) Call wall", "call_wall", "price"],
      ["3) Put wall", "put_wall", "price"],
      ["4) Max pain", "max_pain", "price"],
      ["5) Pin, largest |net gamma|", "pin", "price"],
      ["Hours to close", "hours_to_close", "0.0"],
    ]),
  ],
});

const MAX_STRIKES = 800; // a chain of 800 contracts holds at most 800 strikes
const MAX_ROWS = 200; // grid rows in the docked profile
const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega
const YEAR_MS = 31536000000.0; // 365 days
const MIN_T_MS = 900000.0; // 15 minutes: a contract closer to its expiry reads no time left, as the chart's greeks do
const DAY_MS = 86400000.0;
const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const rowGex = new StaticArray<f64>(MAX_ROWS); // the profile grid: signed net GEX per row
const jsonBytes = new StaticArray<u8>(16384); // the frame JSON, appended as UTF-8 bytes
const tagPrices = new StaticArray<f64>(5); // the tags placed so far on this bar, so a level near another steps inward
const tagSlots = new StaticArray<i32>(5); // the step inward each placed tag took
let jsonUsed = 0;
const SKY = rgba(56, 189, 248, 255);
const VIOLET = rgba(167, 139, 250, 255);
const AMBER = rgba(248, 192, 0, 255);
const SLATE = rgba(148, 163, 184, 255);
const PILL = rgba(15, 23, 42, 235); // the tags' backdrop
const lines: LineHandle[] = [draw.line(0), draw.line(1), draw.line(2), draw.line(3), draw.line(4)]; // flip, call wall, put wall, move high, move low; handle objects allocate once
const tags: LabelHandle[] = [draw.label(5), draw.label(6), draw.label(7), draw.label(8), draw.label(9)]; // ids are one space across kinds
const nyClock = new Clock("America/New_York"); // the bar's own clock: the chart's bar interval
const dateClock = new Clock("UTC"); // reads today's calendar day from its day number
let chain = new OptionsChain(1); // the front expiry, every strike: net GEX, the flip, max pain
let windowChain = new OptionsChain(1); // the strikes inside the window: the walls, the pin and the profile

let windowPct = 3.0; // settings, read in onStart()
let wallsByOi = false;
let marketZone = 0; // the market's clock, read in onStart()
let close: f64 = NaN; // this bar
let t: f64 = NaN;
let firstT: f64 = NaN; // the first bar's open time: where the lines start
let spot: f64 = NaN; // the chain's underlying, or the close where it reports none
let nowMs: f64 = NaN;
let front: f64 = Infinity; // the front expiry, epoch milliseconds
let frontDay = 0; // its trade date, days since 1970-01-01
let dte = 0;
let timed = true; // the bar carries a time of day (a chart finer than a day)
let closeSec: f64 = NaN; // 16:00 New York today
let netGex: f64 = NaN; // the readings, measured on the live bar
let gammaRead = false;
let flip: f64 = NaN;
let callWall: f64 = NaN;
let putWall: f64 = NaN;
let maxPain: f64 = NaN;
let pin: f64 = NaN;
let move: f64 = NaN;
let moveToExpiry = false; // the move runs to the front expiry, which comes before the close or the day ends
let charm: f64 = NaN;
let hoursToClose: f64 = NaN;

function onStart(): void {
  chain = new OptionsChain(MAX_STRIKES);
  windowChain = new OptionsChain(MAX_STRIKES);
  windowPct = p_window_pct();
  wallsByOi = p_wall_basis() == 1.0;
  marketZone = i32(p_market_zone());
}

// ── 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] / 100.0; // mark IV rides the chain in percentage points a year
  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 calendar: an expiry is today when its trade date is today's, never when it is under 24 hours away ──
function tradeDayOf(sec: f64): f64 { // an instant's trade date on the market's own clock, days since 1970-01-01: CME's trade day opens at 17:00 Chicago (zone 2), every other market's at midnight
  return sessionTradeDate(sec, marketZone, marketZone == 2 ? 1020.0 : 0.0);
}
function expiryDayKey(ms: f64): i32 { // the expiry's trade date on the market's own clock, days since 1970-01-01; a stamp at exactly 00:00 UTC is the date a venue stamped
  if (ms % 86400000.0 == 0.0) return i32(ms / 86400000.0);
  return i32(tradeDayOf(Math.floor(ms / 1000.0)));
}
function todayKey(tradeDate: f64, clockMs: f64, intervalSec: f64): i32 { // today on the same calendar: the bar's trade date, or the trade date of the chain's clock when that is later on a bar longer than a day (a 1W or 1M bar carries its first day's date)
  const clockDay = tradeDayOf(Math.floor(clockMs / 1000.0));
  if (!isFinite(tradeDate)) return isFinite(clockDay) ? i32(clockDay) : 0;
  const tradeDay = Math.floor(tradeDate / 86400.0);
  return i32(intervalSec > 86400.0 && clockDay > tradeDay ? clockDay : tradeDay);
}
function measureClock(clockMs: f64): i32 { // today's day key, and the close and time of day it implies
  const today = todayKey(in_trade_date(), clockMs, nyClock.intervalSec());
  dateClock.update(f64(today) * 86400.0);
  closeSec = nyClock.epochSec(dateClock.year(), dateClock.month(), dateClock.dayOfMonth(), 16, 0, 0);
  timed = !(nyClock.intervalSec() >= 86400.0); // a bar of a day or longer opens at 00:00 UTC of its date: it has no time of day
  hoursToClose = timed ? Math.max(closeSec - t, 0.0) / 3600.0 : NaN;
  return today;
}

// ── The chain, measured on the live bar ──
function chainSpot(cells: StaticArray<f64>, n: i32): f64 { // the underlying a front contract reports, else the chart's close
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] == front && cells[i + 7] > 0.0) return cells[i + 7];
  return close;
}
function sigmaOf(iv: f64): f64 { // mark IV rides the chain in percentage points a year (15.2 is 15.2%), whatever its size
  return iv / 100.0;
}
function atmSigma(cells: StaticArray<f64>, n: i32): f64 { // the mean of the call and put IV at the strike nearest spot, one side when the other is missing
  let atm: f64 = NaN;
  let gap = Infinity;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    const k = cells[i];
    if (cells[i + 1] != front || !(k > 0.0) || !(cells[i + 6] > 0.0)) continue;
    const d = Math.abs(k - spot);
    if (d < gap || (d == gap && k < atm)) {
      gap = d;
      atm = k;
    }
  }
  if (isNaN(atm)) return NaN;
  let callSum = 0.0;
  let callCount = 0;
  let putSum = 0.0;
  let putCount = 0;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    if (cells[i + 1] != front || cells[i] != atm || !(cells[i + 6] > 0.0)) continue;
    if (cells[i + 2] > 0.0) {
      callSum += sigmaOf(cells[i + 6]);
      callCount += 1;
    } else {
      putSum += sigmaOf(cells[i + 6]);
      putCount += 1;
    }
  }
  if (callCount > 0 && putCount > 0) return (callSum / f64(callCount) + putSum / f64(putCount)) * 0.5;
  return callCount > 0 ? callSum / f64(callCount) : putSum / f64(putCount);
}
function normalCdf(x: f64): f64 { // the standard normal CDF through Abramowitz-Stegun 7.1.26 (error under 2e-7), as the Options Odds Cloud reads it
  const z = Math.abs(x) / Math.SQRT2;
  const u = 1.0 / (1.0 + 0.3275911 * z);
  const poly = u * (0.254829592 + u * (-0.284496736 + u * (1.421413741 + u * (-1.453152027 + u * 1.061405429))));
  const erf = 1.0 - poly * Math.exp(-z * z);
  return x >= 0.0 ? 0.5 * (1.0 + erf) : 0.5 * (1.0 - erf);
}
function callDelta(k: f64, sigma: f64, years: f64): f64 { // Black-Scholes N(d1) with no rate and spot held; at expiry 1 in the money, 0 out of it, a half at the strike
  if (!(years > 0.0)) return spot > k ? 1.0 : spot < k ? 0.0 : 0.5;
  return normalCdf((Math.log(spot / k) + 0.5 * sigma * sigma * years) / (sigma * Math.sqrt(years)));
}
function charmToClose(cells: StaticArray<f64>, n: i32, tClose: f64): f64 { // the delta the expiry's open interest sheds by the close, or by each contract's own expiry when that comes first, spot held: USD, calls plus and puts minus
  let sum = 0.0;
  let counted = 0;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    const k = cells[i];
    const oi = cells[i + 3];
    const iv = cells[i + 6];
    if (cells[i + 1] != front || !(k > 0.0) || !(oi > 0.0) || !(iv > 0.0)) continue;
    counted += 1;
    const left = cells[i + 1] - nowMs;
    if (left < MIN_T_MS) continue; // no time left: nothing more to shed
    const years = left / YEAR_MS;
    const sigma = sigmaOf(iv);
    const shed = callDelta(k, sigma, years - Math.min(tClose, years)) - callDelta(k, sigma, years); // a call's and a put's delta move alike; past its expiry a contract holds its settled delta
    const multiplier = cells[i + 8] > 0.0 ? cells[i + 8] : 1.0;
    sum += (cells[i + 2] > 0.0 ? 1.0 : -1.0) * shed * oi * multiplier * spot;
  }
  return counted > 0 ? sum : NaN;
}
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 - spot) < Math.abs(best - spot))) 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 frontExpiry(cells: StaticArray<f64>, n: i32): f64 { // the soonest expiry still to come; Infinity when every listed one has passed
  let soonest = Infinity;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs && cells[i + 1] < soonest) soonest = cells[i + 1];
  return soonest;
}
function measureChain(n: i32): void { // n = f64 cells in the live block, whose front expiry is live
  const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer
  spot = chainSpot(cells, n);
  chain.load(cells, n, nowMs, spot, 1, 0.0);
  windowChain.load(cells, n, nowMs, spot, 1, windowPct);
  netGex = chain.totalNetGex();
  gammaRead = chain.strikes() > 0 && chain.totalCallGex() + chain.totalPutGex() > 0.0; // false on a chain that carries no gamma, NaN included
  flip = flipNearSpot();
  maxPain = chain.maxPain();
  // The walls and the pin among the strikes inside the window: the call wall at or above spot, the put wall at or
  // below it, by dollar gamma or by open interest; the pin the strike whose net gamma is largest either way.
  callWall = NaN;
  putWall = NaN;
  pin = NaN;
  if (wallsByOi) {
    callWall = windowChain.callWall();
    putWall = windowChain.putWall();
  }
  let bestCall = 0.0;
  let bestPut = 0.0;
  let bestPin = 0.0;
  for (let i = 0; i < windowChain.strikes(); i += 1) {
    const k = windowChain.strike(i);
    if (!wallsByOi && k >= spot && windowChain.callGex(i) > bestCall) {
      bestCall = windowChain.callGex(i);
      callWall = k;
    }
    if (!wallsByOi && k <= spot && windowChain.putGex(i) > bestPut) {
      bestPut = windowChain.putGex(i);
      putWall = k;
    }
    if (Math.abs(windowChain.netGex(i)) > bestPin) {
      bestPin = Math.abs(windowChain.netGex(i));
      pin = k;
    }
  }
  // The expected move: the expiry's at-the-money volatility over the time left to the close on its own day, over one
  // calendar day before it (and on a chart of a day or longer, which has no time of day), and to the expiry itself
  // when that comes sooner (a Deribit daily settles at 08:00 UTC, before the New York close); in calendar years, the
  // year the chain's IV and greeks are quoted on.
  const tClose = timed ? Math.max(closeSec - t, 0.0) / 31536000.0 : NaN;
  const frontLeft = front - nowMs;
  const limit = timed && dte == 0 ? Math.max(closeSec * 1000.0 - nowMs, 0.0) : DAY_MS;
  moveToExpiry = frontLeft < limit;
  const sigma = atmSigma(cells, n);
  move = sigma * spot * Math.sqrt((moveToExpiry ? frontLeft : limit) / YEAR_MS);
  charm = timed ? charmToClose(cells, n, tClose) : NaN;
}

// ── JSON bytes: append text and numbers without allocating ──
function jbByte(b: u32): void {
  if (jsonUsed < jsonBytes.length) {
    jsonBytes[jsonUsed] = <u8>b;
    jsonUsed += 1;
  }
}
function jbText(s: String): void {
  for (let i = 0; i < s.length; i += 1) jbByte(<u32>s.charCodeAt(i)); // ASCII only here
}
function jbUint(v: u64): void {
  if (v >= 10) jbUint(v / 10);
  jbByte(0x30 + <u32>(v % 10));
}
function jbNum(v: f64, decimals: i32): void { // a fixed-point number, or 0 when not finite
  if (!isFinite(v)) {
    jbByte(0x30);
    return;
  }
  let x = v;
  if (x < 0.0) {
    jbByte(0x2d);
    x = -x;
  }
  let scale = 1.0;
  for (let i = 0; i < decimals; i += 1) scale *= 10.0;
  const scaled = Math.round(x * scale);
  const whole = Math.floor(scaled / scale);
  jbUint(<u64>whole);
  if (decimals > 0) {
    jbByte(0x2e);
    const frac = <u64>(scaled - whole * scale);
    let digits = 1;
    let probe = frac;
    while (probe >= 10) {
      probe /= 10;
      digits += 1;
    }
    for (let i = digits; i < decimals; i += 1) jbByte(0x30);
    jbUint(frac);
  }
}
function jbFlush(slot: i32): void {
  wrun_output_frame(slot, i32(changetype<usize>(jsonBytes)), jsonUsed);
  jsonUsed = 0;
}
function priceDecimals(step: f64): i32 { // enough decimals that the grid's row prices stay evenly spaced when parsed (the docked grid is contiguous only within 0.1% of its step)
  const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10));
  return d < 2 ? 2 : d > 9 ? 9 : d;
}

// ── The docked profile: the window's strikes on a half-step grid, so each bar fills half its band ──
function commonGap(): f64 { // the gap most neighbouring strikes inside the window sit apart (ties: the smaller); NaN with fewer than two strikes
  const count = windowChain.strikes();
  let best: f64 = NaN;
  let bestCount = 0;
  for (let k = 1; k < count; k += 1) {
    const gap = windowChain.strike(k) - windowChain.strike(k - 1);
    let same = 0;
    for (let j = 1; j < count; j += 1) if (Math.abs(windowChain.strike(j) - windowChain.strike(j - 1) - gap) <= gap * 1e-6) same += 1; // how many neighbours sit this far apart
    if (same > bestCount || (same == bestCount && gap < best)) {
      bestCount = same;
      best = gap;
    }
  }
  return best;
}
function writeProfile(): void {
  const count = windowChain.strikes();
  const lo = spot * (1.0 - windowPct / 100.0);
  const hi = spot * (1.0 + windowPct / 100.0);
  let step = commonGap(); // the ladder's usual gap: the odd finer strike near the money joins its nearest row instead of shrinking every bar
  if (isNaN(step) || step <= 0.0) step = spot * 0.01; // one strike in the window: a percent of spot
  let half = step * 0.5;
  let gridLo = Math.floor(lo / half) * half;
  let rows = i32(Math.ceil((hi - gridLo) / half)) + 1;
  while (rows > MAX_ROWS) { // a very wide window on a fine ladder: coarsen the grid until it fits
    half *= 2.0;
    gridLo = Math.floor(lo / half) * half;
    rows = i32(Math.ceil((hi - gridLo) / half)) + 1;
  }
  if (rows < 3) return; // the engine needs three prices for a grid
  for (let r = 0; r < rows; r += 1) rowGex[r] = 0.0;
  for (let k = 0; k < count; k += 1) {
    const r = i32(Math.round((windowChain.strike(k) - gridLo) / half));
    if (r >= 0 && r < rows) rowGex[r] += windowChain.netGex(k);
  }
  const decimals = priceDecimals(half);
  jbText("{\"prices\":[");
  for (let r = 0; r < rows; r += 1) {
    if (r > 0) jbByte(0x2c);
    jbNum(gridLo + f64(r) * half, decimals);
  }
  jbText("],\"values\":[");
  for (let r = 0; r < rows; r += 1) {
    if (r > 0) jbByte(0x2c);
    jbNum(Math.abs(rowGex[r]), 0); // bar length: the size of the strike's net exposure
  }
  jbText("],\"colors\":[");
  for (let r = 0; r < rows; r += 1) {
    if (r > 0) jbByte(0x2c);
    jbText(rowGex[r] > 0.0 ? "\"#38bdf8\"" : rowGex[r] < 0.0 ? "\"#a78bfa\"" : "\"#94a3b8\""); // calls carry the strike: sky; puts: violet; an empty row draws nothing
  }
  jbText("]}");
  jbFlush(gex_rows);
}

// ── Text helpers ──
function sbGrouped(n: i64): void { // 84210 -> 84,210
  if (n >= 1000) {
    sbGrouped(n / 1000);
    sb_text(",");
    const r = n % 1000;
    if (r < 100) sb_text("0");
    if (r < 10) sb_text("0");
    sb_int(r);
  } else sb_int(n);
}
function sbPrice(v: f64): void { // decimals follow the price's size: 5,842 / 772.50 / 48.125
  if (v >= 1000.0) sbGrouped(i64(Math.round(v)));
  else if (v >= 100.0) sb_f64(v, 2);
  else sb_f64(v, 3);
}
function near(a: f64, b: f64): bool { // two levels within 0.3% of each other would share a tag row
  return !isNaN(a) && !isNaN(b) && Math.abs(a - b) <= Math.abs(b) * 0.003;
}
function stepTaken(which: i32, price: f64, step: i32): bool { // an earlier tag within 0.3% of this price already sits at this step
  for (let j = 0; j < which; j += 1) if (tagSlots[j] == step && near(tagPrices[j], price)) return true;
  return false;
}
function drawLevel(which: i32, name: String, price: f64, ink: i32, style: LineStyle): void { // a line across the chart at the level and its price tag at the axis
  if (isNaN(price)) {
    lines[which].delete(); // no-ops when never drawn
    tags[which].delete();
    tagPrices[which] = NaN;
    tagSlots[which] = -1;
    return;
  }
  lines[which].set(firstT, price, t, price).color(ink).width(style == LineStyle.Solid ? 1.5 : 1.0).style(style).extend(Extend.Both);
  let step = 0; // the first step inward that no earlier tag within 0.3% holds: at most `which` steps, each held by another tag
  while (stepTaken(which, price, step)) step += 1;
  tagPrices[which] = price;
  tagSlots[which] = step;
  const x = 56.0 + 112.0 * f64(step); // 56 px in from the right edge clears the axis tags; each step is 112 px further in
  sb_clear();
  sb_text(name);
  sb_text(" ");
  sbPrice(price);
  tags[which].set(x, price).text(str_tag_sb).color(ink).fill(PILL);
}
function writeLabel(): void { // the card's title: 0DTE on the expiry day, else the expiry's date and its days to go
  sb_clear();
  if (dte == 0) sb_text("0DTE");
  else {
    dateClock.update(f64(frontDay) * 86400.0); // a day number is its date at 00:00 UTC
    if (dateClock.dayOfMonth() < 10) sb_text("0");
    sb_int(dateClock.dayOfMonth());
    sb_text(MONTHS[dateClock.month() - 1]);
    sb_text(", ");
    sb_int(dte);
    sb_text(" DTE");
  }
  str_expiry_label_sb();
}

// onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the chain. Then the
// readings as numbers (NaN on history rows); the profile, the lines, the tags and the card's words on the live bar only.
function onBar(): void {
  close = bar.close();
  t = bar.time();
  if (isNaN(firstT) && !isNaN(t)) firstT = t;
  nyClock.update(t); // every bar, so the clock knows the chart's interval by the live bar
  let chainRead = false; // the live bar carried a chain with a live expiry and open interest, and it was measured
  let chainCells = -1; // f64 values the live bar carried; -1 on history bars
  if (bar.isLast() && !isNaN(t)) {
    chainCells = in_chain_cells();
    nowMs = t * 1000.0;
    const today = measureClock(chainCells >= TUPLE ? chainClockMs(in_chain_view(), chainCells, nowMs) : nowMs);
    front = chainCells >= TUPLE ? frontExpiry(in_chain_view(), chainCells) : Infinity;
    if (front != Infinity) {
      frontDay = expiryDayKey(front);
      const days = frontDay - today;
      dte = days > 0 ? days : 0;
      measureChain(chainCells);
      chainRead = chain.strikes() > 0;
    }
  }
  if (isNaN(close)) return;
  const regimeKnown = chainRead && gammaRead && !isNaN(flip);
  const longGamma = regimeKnown && spot >= flip;
  out_net_gex(chainRead && gammaRead ? netGex : NaN);
  out_gamma_flip(chainRead && gammaRead ? flip : NaN);
  out_call_wall(chainRead ? callWall : NaN);
  out_put_wall(chainRead ? putWall : NaN);
  out_max_pain(chainRead ? maxPain : NaN);
  out_pin(chainRead ? pin : NaN);
  out_exp_move_pct(chainRead ? (move / spot) * 100.0 : NaN);
  out_em_hi(chainRead ? spot + move : NaN);
  out_em_lo(chainRead ? spot - move : NaN);
  out_charm_close(chainRead ? charm : NaN);
  out_gamma_regime(regimeKnown ? (longGamma ? 2.0 : 0.0) : bar.isLast() ? 1.0 : NaN);
  out_dte(front != Infinity && bar.isLast() ? f64(dte) : NaN);
  out_hours_to_close(bar.isLast() ? hoursToClose : NaN);
  if (chainRead) {
    writeProfile();
    drawLevel(0, "Flip", gammaRead ? flip : NaN, AMBER, LineStyle.Dashed);
    drawLevel(1, "Call wall", callWall, SKY, LineStyle.Solid);
    drawLevel(2, "Put wall", putWall, VIOLET, LineStyle.Solid);
    drawLevel(3, "Move high", spot + move, SLATE, LineStyle.Dotted);
    drawLevel(4, "Move low", spot - move, SLATE, LineStyle.Dotted);
    // The card's words: the regime at spot leads, then the move's percent and horizon, then the expiry for the title.
    sb_clear();
    if (!gammaRead) sb_text("No gamma in the chain");
    else if (isNaN(flip)) sb_text("No flip in range");
    else sb_text(longGamma ? "Long gamma" : "Short gamma");
    str_dealer_gamma_sb();
    sb_clear();
    if (isNaN(move)) sb_text("No implied volatility");
    else {
      sb_f64((move / spot) * 100.0, 2);
      sb_text(moveToExpiry ? "% to expiry" : timed && dte == 0 ? "% to the close" : "% in 1 day");
    }
    str_move_text_sb();
    writeLabel();
  } else if (bar.isLast()) {
    // The live bar carried no chain to measure: the first tile says why, so the card is never a row of blanks.
    sb_clear();
    sb_text(chainCells < TUPLE ? "Waiting for the options chain" : front == Infinity ? "No live expiry in the chain" : "No open interest in the chain");
    str_dealer_gamma_sb();
    if (front != Infinity) writeLabel(); // the expiry is live but holds no open interest: the title still names it
    else {
      sb_clear();
      sb_text("no chain read");
      str_expiry_label_sb();
    }
  }
}

How it works

One expiry, the live bar only. input("chain", options_chain.cells, { max_cells: 800, venue: "auto", expiries: "nearest:1" }) delivers the front expiry's contracts as [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] tuples on the live row; history bars carry an empty block, and every new bar runs the file again from the first bar, so nothing carries over from one live bar to the next. Both chains load with nearest 1 as well, so a block that lists more than one expiry still counts the front one alone. Spot is the chain's underlying, the chart's close where the chain reports none.

GEX per contract. Gamma times open interest times the multiplier times spot squared times 0.01, calls plus and puts minus: the dealer-naive sign, dealers long the calls customers sold and short the puts they bought. On a US listed chain the multiplier is 100, the shares one contract covers. chain holds every strike of the expiry: its sum is net_gex, maxPain() is max pain, and the flip is where its running sum, walked up the strikes, crosses zero: the crossing nearest spot (flipNearSpot(), the Gamma Map's rule), because one expiry's far wings carry a few dollars that can flip the sum back and forth, and the first crossing would then land far below spot and read long gamma. windowChain holds the strikes inside window_pct (3) percent of spot: the walls, the pin and the docked profile read it.

The walls follow a setting. wall_basis picks how a wall is found among the window's strikes: gamma (the default) takes the strike at or above spot holding the most call dollar gamma, and the strike at or below it holding the most put dollar gamma; oi takes the heaviest open interest on each side, callWall() and putWall() of the windowed chain, the walls the Gamma Map draws. Near the money the two disagree often: a strike far out can hold the most contracts while a strike close to spot carries more gamma. The pin is the window's strike holding the largest net gamma of either sign, where the dealers' hedging is heaviest.

The expected move. At-the-money implied volatility (the call and put mark IV at the strike nearest spot, averaged; one side where the other has none; mark IV rides the chain in percentage points, so 2.4 is 2.4% a year, never a fraction) times spot times the square root of the time left, in calendar years (the year the chain's IV and greeks are quoted on): to 16:00 New York on the expiry day, one calendar day (1/365 of a year) before it, and to the expiry itself whenever that comes sooner, as a Deribit daily settling at 08:00 UTC does. A 0DTE move shrinks through the session toward zero at the close or the expiry; exp_move_pct is that move in percent of spot, and em_hi and em_lo are spot plus and minus it, drawn as the two dotted lines. The move tile reads 0.42% to the close on the expiry day, 0.31% to expiry when the expiry comes first, and 0.95% in 1 day before it: a tile's label is fixed text, so its words ride the move_text slot.

Charm into the close. Each contract sheds the delta it would lose by the earlier of the close and its own expiry, spot held: its Black-Scholes delta at that horizon (with no rate, and at expiry 1 in the money, 0 out of it) minus its delta now, the same for a call and a put. Times the open interest, the multiplier and spot, with net GEX's sign, the sum is charm_close: the dollar delta the dealers' hedges shed by 16:00 if spot stands still. The horizon stops at each contract's own expiry, so a contract can never shed more delta than it holds: a Deribit daily settles at 08:00 UTC, and the charm rate times the hours to the New York close would read a 0.5% out-of-the-money call at 07:40 UTC at about 60 times its delta. A contract less than 15 minutes from expiry reads no time left, as the chart's greeks do.

The calendar decides 0DTE, never the hours. An expiry is today when its trade date is today's, not when it is under 24 hours away: at 11:00 tomorrow's 10:00 expiry is 23 hours off and still 1 DTE. Both dates are read on the market's own clock, sessionTradeDate() on market.zone(), the calendar time.trade_date uses: the UTC date on crypto, New York's on a US stock, and on CME the trade date, which opens at 17:00 Chicago (tradeDayOf() passes that open for zone 2, no other fact needed). Today is the bar's trade date, and on a 1W or 1M bar, which carries its first day's date all period, the later of that and the trade date of the chain's clock (chainClockMs(), the moment the chart priced the chain's gammas), so a weekly BTC chart at 02:00 UTC reads that morning's 08:00 UTC expiry as 0DTE, as the 5m chart does. An expiry stamped at exactly 00:00 UTC is a date a venue stamped, so it reads as that date; every other stamp is read at its instant, so Binance's metals expiry at 21:00 UTC stays today's. dte is the difference; the title is 0DTE at 0, else the date and the count. The close is 16:00 New York today and hours_to_close counts down to it from the live bar's open, so on a 5m chart it runs at most 5 minutes behind. A chart of a day or longer stamps its bars at 00:00 UTC and carries no time of day: there hours_to_close and charm_close read -, and the move is the one-day move.

The word follows spot against the flip. The headline reads "Long gamma" while spot is at or above the flip, where the dealers' hedging leans against moves, and "Short gamma" under it, where their hedging chases them; gamma_regime carries it as a number (0 short, 1 no reading, 2 long) and colours the headline bear red, slate or bull green. Net GEX is a different number: it sums every strike, so the card can read "Short gamma" beside a positive net GEX.

The card reads the outputs. render.hud("zero_dte", { position: "bottom_center", look: "terminal", title: "{{expiry_label}}", columns: 2, tiles }) pins the card: a headline tile.pill over dealer_gamma, tile.value tiles for net_gex and charm_close in si (1.24B), the tile.pill over move_text, and a tile.rows of the five prices in price and the hours in 0.0, numbered 1) to 5) in their labels; the two walls' rows carry a dot in their line's colour, the colour declared on their outputs. look: "terminal" sets the black card with monospaced type and the title on an amber bar (Looks).

The profile and the tags. frame("gex_rows") carries one row per grid price on a half-step grid of the window's strikes and feeds plot.levels, whose thickness_px: 12 keeps every bar at most 12 px tall; its text is appended to a static byte buffer and sent through the frame channel the file declares itself, so the live bar allocates nothing. Each level is a line handle extended across the chart with a price tag written through the one bounded tag slot; each tag takes the first step inward, 112 px apart, that no tag within 0.3% of it already holds, so the flip, a wall and an edge of the move never print over each other.

Where it runs

Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list), whose dailies expire at 08:00 UTC, on a chart finer than 1D: that is where the copy you run from the editor reads a chain, and the clock readings need a time of day. On a US stock chart venue: "auto" reads the chart's own OPRA chain (SPY, QQQ and IWM list an expiry every weekday, a single stock usually weekly ones), which the chart serves to OpenMarket's official wrun indicators only and discloses in the legend, so a copy you run from the editor or publish yourself does not read it there, and a CME chart is the same. The official 0DTE Levels package for US stock and CME charts is not on the registry yet; it will be. An index chain carries no greeks yet, so there the first tile reads "No gamma in the chain". The close the card counts to is 16:00 New York on every market.

When data is missing

Other markets are refused by name before any fetch, so the card does not draw there: the run stops, the legend marks the indicator, and the Console names the input and the way out (wrun_options_chain_unavailable). On a chain it cannot use, the first tile says why instead of the card going blank: "Waiting for the options chain" while the live bar carries none, "No live expiry in the chain" when every listed expiry has passed, "No open interest in the chain" when the front expiry holds none, and "No gamma in the chain" when the venue serves no greeks, as on an index chain whose underlying price is not served yet; the title then reads "no chain read" or still names the expiry. Without implied volatility the move tile reads "No implied volatility" and the two dotted lines do not draw. A flip that never crosses zero turns the headline into "No flip in range" in slate and draws no flip line; a side with no strike inside the window leaves its wall's row reading "-".

Customize it

  • A wider window. Raise window_pct; the walls, the pin and the profile follow it, and the flip, max pain and net GEX always count every strike of the expiry.
  • Walls by open interest. Set wall_basis to oi for the heaviest open interest on each side of spot, the Gamma Map's walls.
  • Another place for the card. Change position to another of the nine anchors; keep the right edge free for the profile and the tags.
  • Change the look. look: takes any of the twelve shipped looks, and the Look row on the indicator's Style page switches it without code (The Style page).

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick 0DTE Levels under HUDs.
  2. Press Run on a BTC chart on a 5m interval: the profile docks on the price axis once the chain arrives, the five levels draw across the chart, and the terminal card appears at the bottom centre, titled by the front expiry.
  3. At the editor's Console prompt, type dte, exp_move_pct and charm_close to read the days to the expiry, the expected move and charm on the live bar.

Concepts used