Volatility surface

View as MarkdownOpen the editor

What the options market charges for the chart's coin, laid out as one picture under the candles.

Implied volatility surface under BTC candles: six ATM tiles by expiry, the expiry by moneyness heatmap with the ATM row outlined, and a glass card reading the nearest expiry's ATM IV

First a row of tiles, one per expiry, nearest first, each with its date and its at-the-money implied volatility (its days to expiry sit in the tile's hover card, and on the tile once the panel is maximized): read left to right they are the term structure. Under the tiles, the surface: implied volatility across the six nearest Deribit expiries as columns and nine moneyness buckets as rows, from +20% of spot at the top down to -20% at the bottom, the ATM row outlined. The cells run from the chart's own background through sky to violet, so the darkest band is the ATM row (the cheapest options), the wings brighten as the smile lifts, and the nearest expiry's wings are the brightest of all: a short-dated smile is the steepest. A cell past the strikes its expiry lists stays empty, so a short-dated expiry that lists only part of the range shows a gap at its wings instead of copies of its edge strike. Rest the pointer on a cell for its exact IV. The caption beside the surface's title names the term structure, "Contango: 1w 31.4%, 3m 36.6%": contango while the three-month ATM sits above the one-week ATM, backwardation while it sits below. A card at the top left under the legend, in the glass look, answers one question first: the nearest expiry's ATM implied volatility as the headline (21.50% in the picture), then two rows with dotted leaders, the 25-delta skew of that expiry (put IV minus call IV in IV points, positive while puts carry the premium) and the one-week ATM against the three-month ATM. The legend carries the term word, "term Contango", after the indicator's name. On a coin with no chain, one slate sentence at the top right says so.

The parts are an options_chain.cells input pinned to Deribit (Options kit, Data sources), two frames feeding a panel.tiles and a panel.heatmap with a highlighted ATM row and a caption written per run (Cards, frames and panels), a render.hud card in the glass look (HUD cards, Looks), a render.label with a corner position for the one sentence, and a render.legend entry carrying the term word (Legend). This is also the iv-surface template: the Volatility Surface card under Beyond the time axis in the editor's starter list, and it compiles as written.

The wrun indicator

// Volatility Surface: the chart coin's live Deribit options chain as implied volatility across the nearest expiries
// (columns, nearest first) and nine moneyness buckets (rows, +20% of spot at the top down to -20%), a heatmap below
// the candles: the darkest band is the at-the-money row, the wings brighten as the smile lifts, and the nearest
// expiry's wings are the brightest of all (a short-dated smile is the steepest); a cell past the strikes its expiry
// lists stays empty, never a copy of the edge strike's IV. Under it, one tile per expiry with
// its ATM implied volatility and days to expiry: the term structure read left to right. The card at the top left
// answers one question first: what does the nearest expiry's ATM option cost, as implied volatility; then the
// 25-delta skew of that expiry (put IV minus call IV: positive while puts carry the premium) and the one-week ATM
// against the three-month ATM (contango while the far point sits above the near one). The chain is live only:
// history bars draw nothing and cost nothing; everything is measured on the last bar, from the chain's own clock.

section("Surface");
param.int("expiries", 6, { min: 2, max: 6, label: "Expiries", description: "Expiries on the surface and the tiles: the N nearest listed expiries at least 12 h away" });
param.number("range_pct", 20, { min: 5, max: 40, label: "Moneyness range, percent", description: "Moneyness range: percent of spot each side, split into nine buckets" });
input("close", ohlcv.close); // the chart's own close: spot for the moneyness grid and the out-of-the-money split
input("chain", options_chain.cells, { max_cells: 2000, venue: "deribit", description: "Deribit's live options chain of the chart's coin" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract; a BTC chain is about 1,550 contracts
// The readings, data-only: the card's tiles read them and the Console can too. Each is NaN on history bars.
output("atm_near", none, overlay, { format: "%", description: "ATM implied volatility of the nearest expiry, percent: interpolated between the two listed strikes around spot; live bar only" });
output("skew_25d", none, overlay, { description: "25-delta skew of the nearest expiry, IV points: the put nearest 25 delta minus the call nearest 25 delta" });
output("atm_1w", none, overlay, { description: "ATM implied volatility of the listed expiry nearest one week away, percent" });
output("atm_3m", none, overlay, { description: "ATM implied volatility of the listed expiry nearest three months away, percent" });
output("term_spread", none, overlay, { description: "Three-month ATM minus one-week ATM, IV points: above 0 is contango, below 0 backwardation" });
string("skew_text", { max_bytes: 32 }); // the card's skew row: "+4.2 pts, puts bid"
string("term_text", { max_bytes: 32 }); // the card's term row: "33.0% to 41.2%"
string("term_word", { max_bytes: 16 }); // the legend entry: Contango or Backwardation
string("notice", { max_bytes: 64 }); // the one sentence at the top right when the market has no chain
render.legend("term", { text: "term_word", color: "theme.muted" }); // the legend reads "term Contango"
render.label("notice_tag", { position: "top_right", text: "notice", color: "#94a3b8", style: "plain", offset: [12, 8] });
const heat = frame("heat", { max_bytes: 8192 }); // the surface: one cell per expiry and moneyness bucket
const termRows = frame("term_rows", { max_bytes: 2048 }); // the tiles: one per expiry
// The term structure first, right under the candles: six tiles in one row, label the expiry, value its ATM IV,
// caption the days to expiry (the caption shows in the tile's hover card, and on the tile itself once the panel is
// maximized; the chart's own floating button sits at the bottom left of the cell, so the tiles stay above the
// surface). Together the two panels take 0.35 of the chart.
panel.tiles({ name: "term_tiles", title: "ATM implied volatility by expiry", x: "category", place: "below", frame: termRows, columns: 6, accent: "neutral", hover_card: true, maximize: true, format: "0.0", unit: "%", height_frac: 0.12 });
// The surface: expiry on x, moneyness on y, IV percent in the cell, a sequential palette from the chart's background
// (the cheapest options, at the money) through sky to violet (the dearest wings); the ATM row outlined, the term word
// as the caption written per run, and a hover card on every cell titled by the cell's own two labels, "-5% · 7 Oct",
// over its IV. No axis nouns: a row or column title only prefixes that card title, and the short one fits the card.
panel.heatmap({ name: "surface", title: "Implied volatility, Deribit", x: "category", place: "below", frame: heat, scale: "palette", palette: ["theme.bg", "#38bdf8", "#a78bfa"], highlight: { row: "ATM" }, hover_card: true, maximize: true, format: "0.0", unit: "%", height_frac: 0.23 });
// The card, in the glass look, at the top left under the legend (the top right keeps the notice): the nearest ATM IV
// leads as the headline, then the two rows with dotted leaders.
render.hud("surface_card", {
  position: "top_left",
  look: "glass",
  title: "Vol surface, Deribit",
  columns: 1,
  width: 236,
  safe_area: true,
  tiles: [
    tile.value("ATM IV, nearest expiry", "atm_near", { format: "%", headline: true, hint: "Implied volatility of an at-the-money option on the nearest listed expiry at least 12 h away, interpolated between the two strikes around spot" }),
    tile.rows([
      ["25d skew", "skew_text"],
      ["1w vs 3m", "term_text"],
    ], { leader: "dots" }),
  ],
});

const MAX_EXPIRIES = 64; // distinct live expiries a chain lists
const MAX_SURFACE = 6; // expiries on the surface (the setting's cap)
const BUCKETS = 9; // moneyness buckets, -range .. +range in eight steps
const MAX_STRIKES = 256; // out-of-the-money strikes one expiry lists
const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega
const DAY_MS = 86400000.0;
const MIN_AHEAD_MS = 43200000.0; // an expiry inside 12 h is settling, not a point on the surface
const MONTHS: StaticArray<string> = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the chain's live expiries, sorted ascending
const curveStrike = new StaticArray<f64>(MAX_STRIKES); // one expiry's out-of-the-money points, sorted by strike: rebuilt per expiry
const curveIv = new StaticArray<f64>(MAX_STRIKES); // percent
const curveOi = new StaticArray<f64>(MAX_STRIKES); // open interest, to settle a strike listed twice
const bucketPct = new StaticArray<f64>(BUCKETS); // the moneyness grid, percent of spot, -range first
const surfIv = new StaticArray<f64>(MAX_SURFACE * BUCKETS); // the surface: IV percent per expiry and bucket, NaN where the expiry lists no strike on both sides
const surfAtm = new StaticArray<f64>(MAX_SURFACE); // ATM IV per surface expiry
const surfDays = new StaticArray<f64>(MAX_SURFACE); // days from the chain's clock to each surface expiry
const surfDay = new StaticArray<i32>(MAX_SURFACE); // the expiry's UTC day of month and month (1..12), for the labels
const surfMonth = new StaticArray<i32>(MAX_SURFACE);

let expiries = 6; // settings, read in onStart()
let rangePct = 20.0;
let expiryCount = 0; // live expiries seen
let surfaceCount = 0; // expiries on the surface this run
let curveCount = 0; // points in the scratch curve
let ivScale = 1.0; // 100 when the chain serves IV as a fraction (0.45 for 45%), 1 when it serves percent
let nearIdx = -1; // the headline's expiry: the nearest surface expiry with a curve
let skew25: f64 = NaN; // the readings, measured on the live bar
let atm1w: f64 = NaN;
let atm3m: f64 = NaN;
let oneExpiry = false; // the one-week and three-month picks are the same listed expiry: no term word
let civilDay = 0;
let civilMonth = 0;

function onStart(): void {
  expiries = i32(p_expiries());
  if (expiries > MAX_SURFACE) expiries = MAX_SURFACE;
  rangePct = p_range_pct();
  for (let k = 0; k < BUCKETS; k += 1) bucketPct[k] = -rangePct + (f64(k) * 2.0 * rangePct) / f64(BUCKETS - 1);
}

// ── The expiry list: sorted insertion of a distinct live expiry ──
function noteExpiry(ms: f64): void {
  let lo = 0;
  let hi = expiryCount;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (expiryMs[mid] < ms) lo = mid + 1;
    else hi = mid;
  }
  if (lo < expiryCount && expiryMs[lo] == ms) return;
  if (expiryCount >= MAX_EXPIRIES) return;
  for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1];
  expiryMs[lo] = ms;
  expiryCount += 1;
}
function nearestExpiry(targetMs: f64): i32 { // the live expiry closest to a target instant
  let best = 0;
  for (let i = 1; i < expiryCount; i += 1) if (Math.abs(expiryMs[i] - targetMs) < Math.abs(expiryMs[best] - targetMs)) best = i;
  return best;
}

// ── One expiry's out-of-the-money curve: puts below spot, calls at or above, sorted by strike ──
function insertPoint(strike: f64, iv: f64, oi: f64): void {
  let lo = 0;
  let hi = curveCount;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (curveStrike[mid] < strike) lo = mid + 1;
    else hi = mid;
  }
  if (lo < curveCount && curveStrike[lo] == strike) { // listed twice: the contract with more open interest speaks
    if (oi > curveOi[lo]) {
      curveIv[lo] = iv;
      curveOi[lo] = oi;
    }
    return;
  }
  if (curveCount >= MAX_STRIKES) return;
  for (let i = curveCount; i > lo; i -= 1) {
    curveStrike[i] = curveStrike[i - 1];
    curveIv[i] = curveIv[i - 1];
    curveOi[i] = curveOi[i - 1];
  }
  curveStrike[lo] = strike;
  curveIv[lo] = iv;
  curveOi[lo] = oi;
  curveCount += 1;
}
function buildCurve(cells: StaticArray<f64>, n: i32, expiry: f64, spot: f64): void {
  curveCount = 0;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    if (cells[i + 1] != expiry) continue;
    const strike = cells[i];
    const iv = cells[i + 6] * ivScale;
    if (!(strike > 0.0) || !(iv > 0.0)) continue;
    const otm = cells[i + 2] < 0.0 ? strike < spot : strike >= spot;
    if (!otm) continue;
    insertPoint(strike, iv, cells[i + 3]);
  }
}
function ivAt(price: f64): f64 { // linear interpolation between the two listed strikes around the price; NaN past the listed ends (no copy of the edge strike)
  if (curveCount == 0 || isNaN(price)) return NaN;
  if (price < curveStrike[0] || price > curveStrike[curveCount - 1]) return NaN;
  let lo = 0;
  let hi = curveCount - 1;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (curveStrike[mid] <= price) lo = mid;
    else hi = mid;
  }
  const k0 = curveStrike[lo];
  const k1 = curveStrike[hi];
  if (price == k0 || k1 == k0) return curveIv[lo];
  return curveIv[lo] + ((curveIv[hi] - curveIv[lo]) * (price - k0)) / (k1 - k0);
}
function skewAt(cells: StaticArray<f64>, n: i32, expiry: f64): f64 { // put IV minus call IV at the contracts nearest 25 delta, IV points
  let putIv: f64 = NaN;
  let callIv: f64 = NaN;
  let putGap = Infinity;
  let callGap = Infinity;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    if (cells[i + 1] != expiry) continue;
    const iv = cells[i + 6] * ivScale;
    const delta = cells[i + 5];
    if (!(iv > 0.0) || isNaN(delta)) continue;
    const gap = Math.abs(Math.abs(delta) - 0.25);
    if (cells[i + 2] < 0.0) {
      if (gap < putGap) {
        putGap = gap;
        putIv = iv;
      }
    } else if (gap < callGap) {
      callGap = gap;
      callIv = iv;
    }
  }
  return putIv - callIv;
}
function civilFromDays(days: i64): void { // days since 1970-01-01 -> month and day of month (proleptic Gregorian)
  const z = days + 719468;
  const era = (z >= 0 ? z : z - 146096) / 146097;
  const doe = z - era * 146097;
  const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
  const doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  const mp = (5 * doy + 2) / 153;
  civilDay = i32(doy - (153 * mp + 2) / 5 + 1);
  civilMonth = i32(mp < 10 ? mp + 3 : mp - 9);
}

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

// ── The chain, measured on the live bar ──
function measure(cells: StaticArray<f64>, n: i32, spot: f64, barOpenMs: f64): bool {
  const nowMs = chainClockMs(cells, n, barOpenMs); // the chain's clock: a coarse bar's open would stretch every day count
  expiryCount = 0;
  surfaceCount = 0;
  nearIdx = -1;
  skew25 = NaN;
  atm1w = NaN;
  atm3m = NaN;
  oneExpiry = false;
  let maxIv = 0.0;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
    if (cells[i + 1] > nowMs + MIN_AHEAD_MS) noteExpiry(cells[i + 1]);
    if (cells[i + 6] > maxIv) maxIv = cells[i + 6];
  }
  if (expiryCount == 0) return false;
  ivScale = maxIv > 0.0 && maxIv <= 3.0 ? 100.0 : 1.0; // a chain whose largest IV is under 3 serves fractions (0.45 for 45%)
  surfaceCount = expiries < expiryCount ? expiries : expiryCount;
  for (let e = 0; e < surfaceCount; e += 1) {
    buildCurve(cells, n, expiryMs[e], spot);
    for (let k = 0; k < BUCKETS; k += 1) surfIv[e * BUCKETS + k] = curveCount >= 2 ? ivAt(spot * (1.0 + bucketPct[k] / 100.0)) : NaN;
    surfAtm[e] = curveCount >= 2 ? ivAt(spot) : NaN;
    surfDays[e] = (expiryMs[e] - nowMs) / DAY_MS;
    civilFromDays(i64(Math.floor(expiryMs[e] / DAY_MS)));
    surfDay[e] = civilDay;
    surfMonth[e] = civilMonth;
    if (nearIdx < 0 && !isNaN(surfAtm[e])) nearIdx = e;
  }
  if (nearIdx < 0) return false;
  skew25 = skewAt(cells, n, expiryMs[nearIdx]);
  const w = nearestExpiry(nowMs + 7.0 * DAY_MS);
  const m = nearestExpiry(nowMs + 90.0 * DAY_MS);
  oneExpiry = w == m;
  buildCurve(cells, n, expiryMs[w], spot);
  atm1w = curveCount >= 2 ? ivAt(spot) : NaN;
  buildCurve(cells, n, expiryMs[m], spot);
  atm3m = curveCount >= 2 ? ivAt(spot) : NaN;
  return true;
}

// ── The frames: the surface and the tiles, written on the live bar ──
function fbMoneyness(pct: f64): void { // "+20%", "-7.5%", "ATM"
  fb_text("\"");
  if (pct == 0.0) fb_text("ATM");
  else {
    fb_text(pct > 0.0 ? "+" : "-");
    const a = Math.abs(pct);
    fb_f64(a, a == Math.floor(a) ? 0 : 1);
    fb_text("%");
  }
  fb_text("\"");
}
function fbExpiryLabel(e: i32): void { // "10 Oct": the expiry's UTC day and month
  fb_text("\"");
  fb_int(surfDay[e]);
  fb_text(" ");
  fb_text(MONTHS[surfMonth[e] - 1]);
  fb_text("\"");
}
function writeSurface(): void {
  fb_clear();
  fb_text("{\"rows\":[");
  let first = true;
  for (let k = BUCKETS - 1; k >= 0; k -= 1) { // the top row is the highest strike bucket, like the price axis
    for (let e = 0; e < surfaceCount; e += 1) {
      if (!first) fb_text(",");
      first = false;
      fb_text("[");
      fbExpiryLabel(e);
      fb_text(",");
      fbMoneyness(bucketPct[k]);
      fb_text(",");
      fb_f64(surfIv[e * BUCKETS + k], 1); // null where the expiry lists nothing
      fb_text("]");
    }
  }
  fb_text("],\"caption\":\"");
  if (isNaN(atm1w) || isNaN(atm3m)) fb_text("Term structure: no reading");
  else if (oneExpiry) fb_text("Term structure: one expiry listed");
  else { // "Contango: 1w 31.1%, 3m 36.6%" (the title row shows about 30 characters of caption)
    fb_text(atm3m > atm1w ? "Contango: 1w " : "Backwardation: 1w ");
    fb_f64(atm1w, 1);
    fb_text("%, 3m ");
    fb_f64(atm3m, 1);
    fb_text("%");
  }
  fb_text("\"}");
  writeFrameBuffer(FRAME_HEAT);
}
function writeTiles(): void {
  fb_clear();
  fb_text("{\"rows\":[");
  for (let e = 0; e < surfaceCount; e += 1) {
    if (e > 0) fb_text(",");
    fb_text("[");
    fbExpiryLabel(e);
    fb_text(",");
    fb_f64(surfAtm[e], 1);
    fb_text(",\"");
    fb_f64(surfDays[e], 1);
    fb_text(" days\"]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_TERM_ROWS);
}

// onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the chain,
// writes the readings as numbers (NaN on history rows), the two frames and the card's words.
function onBar(): void {
  const close = bar.close();
  let read = false; // the live bar carried a chain and a surface expiry has a curve
  let chainCells = -1; // f64 values the live bar carried; -1 on history bars
  if (bar.isLast() && !isNaN(close)) {
    chainCells = in_chain_cells();
    if (chainCells >= TUPLE) read = measure(in_chain_view(), chainCells, close, bar.time() * 1000.0); // the bar's open, the clock's stand-in
  }
  out_atm_near(read ? surfAtm[nearIdx] : NaN);
  out_skew_25d(read ? skew25 : NaN);
  out_atm_1w(read ? atm1w : NaN);
  out_atm_3m(read ? atm3m : NaN);
  out_term_spread(read ? atm3m - atm1w : NaN);
  if (!bar.isLast()) return;
  if (read) {
    writeSurface();
    writeTiles();
    sb_clear();
    if (isNaN(skew25)) sb_text("no delta in the chain");
    else {
      sb_signed(skew25, 1);
      sb_text(skew25 >= 0.0 ? " pts, puts bid" : " pts, calls bid");
    }
    str_skew_text_sb();
    sb_clear();
    if (isNaN(atm1w) || isNaN(atm3m)) sb_text("no reading");
    else if (oneExpiry) sb_text("one expiry listed");
    else {
      sb_f64(atm1w, 1);
      sb_text("% to ");
      sb_f64(atm3m, 1);
      sb_text("%");
    }
    str_term_text_sb();
    sb_clear();
    if (!isNaN(atm1w) && !isNaN(atm3m) && !oneExpiry) sb_text(atm3m > atm1w ? "Contango" : "Backwardation");
    str_term_word_sb();
    sb_clear();
    str_notice_sb(); // no notice: the chain was read
  } else {
    // No chain to measure: the one sentence at the top right, the card's rows say why, nothing else is drawn.
    sb_clear();
    sb_text("No option chain for this coin");
    str_notice_sb();
    sb_clear();
    sb_text("no chain");
    str_skew_text_sb();
    sb_clear();
    sb_text("no chain");
    str_term_text_sb();
    sb_clear();
    str_term_word_sb();
  }
}

How it works

The chain is one input. input("chain", options_chain.cells, { max_cells: 2000, venue: "deribit" }) carries Deribit's live options chain of the chart's coin as cells of ten values per contract, [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]; a BTC chain is about 1,550 contracts. The chain is the live row's only: history bars carry an empty block and cost one read, so everything is measured under bar.isLast(), and the five data-only readings (atm_near, skew_25d, atm_1w, atm_3m, term_spread) are NaN on history bars. The chart's own close is spot, for the moneyness grid and the out-of-the-money split.

The surface is read off curves. On the live bar the chain's expiries at least 12 h away are sorted and the nearest expiries make the surface. Every distance runs from the chain's clock, the moment the chart priced the chain, never the live bar's open (which on a 1w chart can sit days back): the chart prices each contract's gamma by Black-Scholes from its mark IV and underlying when it reads the chain, so chainClockMs() solves the gamma of the contract whose delta sits nearest 0.5 for the time to expiry it implies, and the bar's open stands in only where a venue serves its own greeks; a tile's days to expiry count from it too. Per expiry the out-of-the-money contracts (puts below spot, calls at or above) form an IV curve by strike, a strike listed twice settled by the contract with more open interest. Each bucket's price (spot times 1 plus the bucket's percent) is read off that curve by linear interpolation between the two listed strikes around it, and the ATM value is the same read at spot. Past the expiry's first or last listed strike there is nothing to interpolate, so ivAt() reads NaN and the cell is written null: the heatmap leaves it empty rather than paint the edge strike's IV there as if it were measured. IV arrives as a fraction or as percent depending on the venue: a chain whose largest IV is at or under 3 serves fractions (0.45 for 45%) and is scaled by 100, otherwise the values are already percent.

Skew and term are two more reads. The 25-delta skew takes the put and the call of the nearest surface expiry whose delta sits nearest 0.25 in size, put IV minus call IV in IV points. The one-week and three-month points are the listed expiries nearest 7 and 90 days away over the whole chain, not only the surface's expiries, so the term word holds even when the six nearest expiries all sit inside a month; term_spread is the three-month ATM minus the one-week ATM, above 0 contango. When the two picks are the same listed expiry there is no term word.

Two frames feed two panels. frame("term_rows") carries one [label, value, caption] row per expiry (the expiry's UTC day and month, its ATM IV, its days to expiry) and feeds panel.tiles with columns: 6, accent: "neutral" (IV has no sign, so the text keeps its ink) and format: "0.0" with unit: "%", so a tile reads 27.1%. frame("heat") carries one [expiry, moneyness, value] cell per expiry and bucket, written from +20% down so the top row is the highest strike bucket, like the price axis; it feeds panel.heatmap with scale: "palette" and a palette from theme.bg through sky to violet, highlight: { row: "ATM" } outlining the middle row, hover_card: true printing each cell's exact value under the pointer under a title of the cell's own two labels ("-5% · 7 Oct"), and the frame's caption written per run (the title row shows about 30 characters of it). The heatmap declares no row_title or col_title: a row or column title only prefixes that hover card's title ("Moneyness -5% · Expiry 7 Oct"), so leaving them out keeps the card short enough to read whole. Both frames are written with the fb_* builders and writeFrameBuffer, and the two panels take 0.35 of the chart together.

The card reads an output and two slots. render.hud("surface_card", { position: "top_left", look: "glass", columns: 1, width: 236, safe_area: true, tiles: [...] }) names the anchor, the look and two tiles. tile.value("ATM IV, nearest expiry", "atm_near", { format: "%", headline: true, hint: "..." }) leads as the headline and reads the data-only output, its hint saying how the number is read; tile.rows with leader: "dots" reads two string slots written on the live bar, skew_text ("+0.3 pts, puts bid", from sb_signed) and term_text ("31.4% to 36.6%"). safe_area: true keeps the card under the legend, and the top right stays free for the notice.

The term word rides the legend, the notice the corner. render.legend("term", { text: "term_word", color: "theme.muted" }) prints the slot's words after the indicator's name as "term Contango", in the theme's muted ink. render.label("notice_tag", { position: "top_right", text: "notice", color: "#94a3b8", style: "plain", offset: [12, 8] }) is a label placed by a corner position rather than by x and y: it prints the notice slot in slate at the top right, and the slot is empty while the chain was read.

Where it runs

BTC and ETH charts on any venue: the chain is Deribit's for the chart's coin, so the candles' venue and interval only set the close the moneyness grid takes as spot. The chain is read on the live bar only and history bars cost nothing. Below the candles the tiles mount first and the surface under them, in declaration order; the chart's own floating round button sits at the bottom left of the cell, so the tiles stay above it and the surface's row labels sit left of it. With the default settings the surface is six expiries by nine buckets, 54 cells, over a row of six tiles.

When data is missing

A coin Deribit does not list shows the one sentence No option chain for this coin at the top right and draws nothing else: the card's skew and term rows read "no chain", the headline has no number behind it, the legend carries no term word, and the five readings are NaN. History bars read NaN on every bar but the live one, so the surface has no history. A chain with no delta column makes the skew row read "no delta in the chain"; when the one-week or three-month pick has no curve the caption reads "Term structure: no reading" and the term row "no reading"; when both picks are the same listed expiry the caption reads "Term structure: one expiry listed", the term row says so and the legend carries no term word. An expiry whose curve has fewer than two out-of-the-money strikes leaves its column's cells empty, and a bucket past an expiry's listed strikes leaves that one cell empty.

Customize it

  • Fewer expiries. expiries (6, from 2 to 6) sets how many of the nearest listed expiries at least 12 h away make the surface and the tiles; the one-week and three-month picks still read the whole chain.
  • A wider or narrower smile. range_pct (20, from 5 to 40) is the moneyness range each side of spot, split into nine buckets; the row labels follow it ("+10%", "-7.5%").
  • Another wing. The skew reads the put and the call nearest 0.25 delta in skewAt; change the number to read a 10-delta wing.
  • Another corner. position on the card takes any of the nine anchors, top_left to bottom_right; the top right holds the notice sentence, so keep the two apart.
  • Change the look. The card declares look: "glass"; the Look row on the indicator's Style page switches it to any of the twelve shipped looks without code, "As made" first (The Style page). This example's second look is cockpit, one pick away on that row.

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick Volatility Surface under Beyond the time axis.
  2. Press Run on a BTC or ETH chart on any venue (BTCUSDT on Binance Futures at 15m, say): the six tiles and the surface mount below the candles, the glass card at the top left under the legend, and the legend reads the term word. Rest the pointer on a cell for its exact IV.
  3. At the editor's Console prompt, type last 20 atm_near to read the nearest expiry's ATM implied volatility on the last 20 bars: only the live bar carries a number, since the chain is live only.

Concepts used

  • Options kit for options_chain.cells, the ten-value contract tuple and why the chain is the live row's only
  • Cards, frames and panels for frames, panel.tiles, panel.heatmap, highlight, captions and writeFrameBuffer
  • HUD cards for render.hud, its anchors, width and safe_area, and a render.label with a position as a corner readout
  • Looks for look: "glass" and what it draws
  • Blocks and tiles for the headline tile.value, its hint and tile.rows with leader: "dots"
  • The Style page for the Look row that switches the look without code
  • Legend for render.legend and a string slot's words in the legend
  • Text formatting for the sb_* and fb_* builders and sb_signed