---
title: "Volatility smile"
description: "Implied volatility by strike for three expiries of the chart coin's Deribit option chain, drawn under the chart as three smooth curves over a dollar strike…"
order: 107
section: "cookbook"
---

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

# Volatility smile

![Volatility smile pane under a BTC chart: three implied volatility curves over a dollar strike axis with a dashed spot marker, at-the-money callouts and a put-skew chip, and the broadsheet card at the top right of the price pane](/wrun/images/vol-smile.png)

Implied volatility by strike for three expiries of the chart coin's Deribit option chain, drawn under the chart as three smooth curves over a dollar strike axis, named by role so the words stay true when a horizon setting moves: near, about a week out by default (teal, the widest stroke), mid, about a month (sky) and far, about three months (violet). Out-of-the-money contracts only, the way the market prices the wings: puts below spot, calls at or above it. A dashed marker stands at spot, a callout pins each curve at the money with its expiry's date and IV ("9 Oct 34.1%"), the caption names each horizon's listed expiry ("near 9 Oct, mid 30 Oct, far 25 Dec"), and a chip on the pane's title row names the lean of the nearest curve: "Put skew 4.5 pts (10%)" means the strike 10% below spot trades 4.5 IV points over the strike 10% above it, so puts carry the premium. Rose for put skew, the chart's accent for call skew, slate when the two wings sit within half a point. At the top left of the price pane, under the legend and clear of the newest candles, a card in the broadsheet look (newsprint, serif type, small caps, dotted leaders) answers one number first: the near expiry's implied volatility at the money, in the curve's teal. Under it, two rows: the mid horizon's 25-delta skew (put IV minus call IV at the listed strikes whose |delta| sits nearest 0.25, the desk's usual quote; positive is put skew) and the term, the mid against the far IV at the money (a higher nearer reading means the market pays more for the next month than for the quarter: event risk priced in).

The parts are an `options_chain.cells` input pinned to Deribit ([Options kit](../functions/options-kit.md)), a frame feeding a `panel.line` on a number axis with three series, a declared spot marker and a badge that the frame replaces on every run with the at-the-money callouts, the chip and the caption ([Cards, frames and panels](../presentation/cards-frames-panels.md)), a `render.hud` card of a headline `tile.value` and a `tile.rows` in the broadsheet look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Looks](../presentation/hud-and-hover-cards.md#looks)), and a `render.label` pinned to the top right for the one sentence when the coin has no chain ([Plotting](../presentation/plotting.md)). This is also the `vol-smile` template: the **Volatility Smile** card under **Beyond the time axis** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=vol-smile
// Volatility Smile: implied volatility by strike for three expiries of the chart coin's Deribit chain (near, mid and
// far: about a week, a month and three months out by default), out-of-the-money contracts only (puts below spot, calls at or above, the way
// the market prices the wings), drawn under the chart as three smooth curves over a dollar strike axis: a dashed
// marker at spot, a callout on each curve's at-the-money point and a chip naming the lean of the nearest curve (IV
// at the put wing minus IV at the call wing, both the same percent from spot). The trader sees whether puts or calls
// carry the premium (the left wing higher means put skew), how much steeper the near smile is than the far one
// (event risk sits in the near curve), and on the card the nearest expiry's IV at the money, the mid horizon's
// 25-delta skew (the desk's standard quote, read at the strikes nearest 25 delta) and the mid against far term. Each
// curve is named by its role and called out with its expiry's date, so the words stay true when a horizon setting
// moves. 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("Expiries and strikes");
param.int("near_days", 7, { min: 1, max: 30, label: "Near horizon, days", description: "Days to the first horizon: the listed expiry closest to this many days away (at least 12 h out)" });
param.int("mid_days", 30, { min: 7, max: 120, label: "Mid horizon, days", description: "Days to the second horizon; a later expiry than the first" });
param.int("far_days", 90, { min: 30, max: 365, label: "Far horizon, days", description: "Days to the third horizon; a later expiry than the second" });
param.number("window_pct", 25, { min: 5, max: 60, label: "Strike grid, percent", description: "Strike grid: percent around spot, each side" });
param.int("points", 41, { min: 21, max: 81, label: "Grid strikes", description: "Strikes on the common grid" });
param.int("wing_pct", 10, { min: 3, max: 30, label: "Skew wing, percent", description: "The chip's skew: IV this percent below spot minus IV this percent above it, on the nearest drawn expiry" });
input("close", ohlcv.close); // the chart's own close: spot for the grid, the out-of-the-money split and the at-the-money read
input("chain", options_chain.cells, { max_cells: 4000, venue: "deribit", description: "Deribit's 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
output("atm_iv_1w", none, overlay, { description: "Implied volatility at the money, near horizon (about a week by default), percent; live bar only" }); // data-only: the card reads them and the Console can too
output("atm_iv_1m", none, overlay, { description: "Implied volatility at the money, mid horizon (about a month by default), percent; live bar only" });
output("atm_iv_3m", none, overlay, { description: "Implied volatility at the money, far horizon (about three months by default), percent; live bar only" });
output("skew_25d", none, overlay, { description: "25-delta skew of the mid horizon (the nearest drawn one when it lists nothing): put IV minus call IV at the strikes nearest |delta| 0.25, IV points; above 0 is put skew" });
output("wing_skew", none, overlay, { description: "The chip's skew: IV at the put wing minus IV at the call wing (wing_pct from spot) on the nearest drawn expiry, IV points; above 0 is put skew" });
output("term_1m_3m", none, overlay, { description: "Mid horizon ATM IV minus far horizon ATM IV, IV points; above 0 the nearer term is the dearer" });
output("expiry_days_1w", none, overlay, { description: "Days from the chain's read time to the near horizon's expiry" });
string("skew_word", { max_bytes: 24 }); // the card's skew row: "+2.7 pts"
string("term_word", { max_bytes: 32 }); // the card's term row: "32.8% vs 36.5%"
string("notice", { max_bytes: 48 }); // the one sentence when the coin has no chain
const smile_rows = frame("smile_rows", { max_bytes: 12288 }); // one row per grid strike: [strike, iv per horizon], null where the horizon lists nothing

// The smile pane: three smooth curves over a dollar strike axis (the nearest expiry the widest stroke, every curve
// with a halo), the value axis in percent and pinned per run so a short curve's gaps never squash the picture and its
// peak keeps clear of the chips, a dashed spot marker, a callout pinned to each curve at the money with its expiry's
// date and the skew chip on the title row; the marker list, the chip, the caption and the axis range are written per
// run into the frame. The series are named by role (near, mid, far), true whatever the horizon settings read.
panel.line({
  name: "smile",
  title: "Vol smile, Deribit",
  x: "number",
  place: "below",
  frame: smile_rows,
  height_frac: 0.32,
  chrome: "grid",
  smooth: true,
  glow: true,
  hover_card: true,
  legend_style: "chips",
  x_format: "usd",
  x_decimals: 1,
  x_title: "Strike",
  y_title: "Implied volatility",
  format: "%",
  decimals: 1,
  badge: { text: "Skew", color: "#94a3b8" },
  markers: [{ x: "spot", label: "Spot", line_style: "dashed", badge: true }],
  series: [
    { name: "Near", color: "#2dd4bf", width: 2.5 },
    { name: "Mid", color: "#38bdf8", width: 1.5 },
    { name: "Far", color: "#a78bfa", width: 1.5 },
  ],
});

// The card, in the broadsheet look (newsprint, serif type, small caps, dotted leaders): the nearest expiry's IV at
// the money leads as the headline in the curve's teal, then the mid horizon's 25-delta skew and the mid against far
// term as two rows. Every tile reads the newest row, the live bar's. It sits at the top left under the legend, clear
// of the newest candles and of the one-sentence notice at the top right. The look is a literal (the kit refuses a
// setting here); the dialog's Style page carries the Look row, phosphor among its twelve, for the viewer's switch.
render.hud("smile_card", {
  position: "top_left",
  safe_area: true, // under the legend
  look: "broadsheet",
  title: "Implied volatility, Deribit",
  columns: 1,
  width: 300,
  tiles: [
    tile.value("ATM IV, near expiry", "atm_iv_1w", { format: "%", headline: true, color: "#2dd4bf", hint: "Implied volatility at the money for the listed expiry nearest the near horizon (a week out by default): what the market pays for a move over that horizon" }),
    tile.rows([
      ["25d skew, mid", "skew_word"],
      ["Term, mid vs far", "term_word"],
    ]),
  ],
});
render.label("notice", { position: "top_right", text: "notice", offset: [12, 8], style: "knockout", color: "#94a3b8", size: 12 }); // the one sentence, written on the live bar only when the coin has no chain

const CURVES = 3; // the horizons, in the panel's series order
const CURVE_NAMES: string[] = ["Near", "Mid", "Far"]; // the series, by role
const CAPTION_NAMES: string[] = ["near ", "mid ", "far "];
const CURVE_INK: string[] = ["#2dd4bf", "#38bdf8", "#a78bfa"]; // teal, sky, violet: the series colours, for the callouts
const MAX_STRIKES = 256; // out-of-the-money strikes one expiry lists
const MAX_EXPIRIES = 64; // distinct live expiries a chain lists
const TUPLE = 10; // f64s per contract
const DAY_MS = 86400000.0;
const MIN_AHEAD_MS = 43200000.0; // an expiry inside 12 h is settling, not a horizon
const Z_25D = 0.6744897501960817; // the standard normal quantile at 0.75: the Black-Scholes 25-delta strikes when the chain serves no deltas
const MONTHS: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

// Per horizon: the out-of-the-money points at its expiry, sorted by strike, and the readings taken from them.
const cStrike = new StaticArray<f64>(CURVES * MAX_STRIKES);
const cIv = new StaticArray<f64>(CURVES * MAX_STRIKES); // percent
const cDelta = new StaticArray<f64>(CURVES * MAX_STRIKES); // |delta| as the venue serves it, 0 when it serves none
const cOi = new StaticArray<f64>(CURVES * MAX_STRIKES); // open interest, to settle a strike listed twice
const cCount = new StaticArray<i32>(CURVES);
const cExpiry = new StaticArray<f64>(CURVES); // the expiry the horizon shows, ms; NaN when none is listed
const cDays = new StaticArray<f64>(CURVES); // days from the chain's clock to that expiry
const cAtm = new StaticArray<f64>(CURVES); // IV at spot, percent; NaN when the curve does not cover spot
const cKept = new StaticArray<i32>(CURVES); // 1 while the horizon's curve is drawn
const cDay = new StaticArray<i32>(CURVES); // the expiry's UTC calendar day and month (1..12)
const cMonth = new StaticArray<i32>(CURVES);
const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the chain's live expiries, sorted ascending
let expiryCount = 0;

let targetDays: f64[] = [7.0, 30.0, 90.0]; // settings, read in onStart()
let windowPct = 0.25;
let points = 41;
let wingPct = 0.1;
let measured = false; // the live bar carried a usable chain: at least one horizon drawn
let skew: f64 = NaN; // the 25-delta skew of the mid horizon (the nearest drawn one when it lists nothing), IV points
let wingSkew: f64 = NaN; // the chip: the nearest drawn curve's put wing minus its call wing, IV points
let nearest = -1; // the nearest drawn horizon (0 while the first is listed)

// The chain's live expiries: sorted insertion of each distinct one
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 pickExpiry(targetMs: f64, after: i32): i32 { // the listed expiry closest to the target; one later than the previous horizon's when the two meet; -1 when none is left
  let best = -1;
  let bestGap = Infinity;
  for (let i = 0; i < expiryCount; i += 1) {
    const gap = Math.abs(expiryMs[i] - targetMs);
    if (gap < bestGap) {
      bestGap = gap;
      best = i;
    }
  }
  if (best <= after) best = after + 1;
  return best < expiryCount ? best : -1;
}

// One horizon's points: sorted insertion by strike; a strike listed twice (two settlement families) keeps the heavier open interest
function insertPoint(c: i32, strike: f64, iv: f64, delta: f64, oi: f64): void {
  const base = c * MAX_STRIKES;
  let lo = 0;
  let hi = cCount[c];
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (cStrike[base + mid] < strike) lo = mid + 1;
    else hi = mid;
  }
  if (lo < cCount[c] && cStrike[base + lo] == strike) {
    if (oi > cOi[base + lo]) {
      cIv[base + lo] = iv;
      cDelta[base + lo] = delta;
      cOi[base + lo] = oi;
    }
    return;
  }
  if (cCount[c] >= MAX_STRIKES) return;
  for (let i = cCount[c]; i > lo; i -= 1) {
    cStrike[base + i] = cStrike[base + i - 1];
    cIv[base + i] = cIv[base + i - 1];
    cDelta[base + i] = cDelta[base + i - 1];
    cOi[base + i] = cOi[base + i - 1];
  }
  cStrike[base + lo] = strike;
  cIv[base + lo] = iv;
  cDelta[base + lo] = delta;
  cOi[base + lo] = oi;
  cCount[c] += 1;
}

// The out-of-the-money points of one expiry: puts below spot, calls at or above; IV normalized to percent (a chain
// whose largest IV at the expiry is under 3 serves fractions, 0.45 for 45%); IV at or below 0, or NaN, skipped.
function readPoints(c: i32, cells: StaticArray<f64>, n: i32, expiry: f64, spot: f64): void {
  cCount[c] = 0;
  let maxIv = 0.0;
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] == expiry && cells[i + 6] > maxIv) maxIv = cells[i + 6];
  const scale = maxIv > 0.0 && maxIv <= 3.0 ? 100.0 : 1.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] * scale;
    if (!(strike > 0.0) || !(iv > 0.0)) continue;
    const otm = cells[i + 2] < 0.0 ? strike < spot : strike >= spot;
    if (!otm) continue;
    insertPoint(c, strike, iv, Math.abs(cells[i + 5]), cells[i + 3]);
  }
}

function ivAt(c: i32, price: f64): f64 { // linear interpolation between the horizon's two neighbouring listed strikes; NaN outside its listed range
  const count = cCount[c];
  const base = c * MAX_STRIKES;
  if (count < 2 || isNaN(price)) return NaN;
  if (price < cStrike[base] || price > cStrike[base + count - 1]) return NaN;
  let lo = 0;
  let hi = count - 1;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (cStrike[base + mid] <= price) lo = mid;
    else hi = mid;
  }
  const k0 = cStrike[base + lo];
  const k1 = cStrike[base + hi];
  if (price == k0 || k1 == k0) return cIv[base + lo];
  return cIv[base + lo] + ((cIv[base + hi] - cIv[base + lo]) * (price - k0)) / (k1 - k0);
}

// The 25-delta skew of one horizon: the out-of-the-money put whose |delta| sits nearest 0.25 against the call whose
// delta does, put IV minus call IV. The mid horizon (a month by default) is the one quoted (a week out, the 25-delta
// strikes sit two or three percent from spot and miss the wings the eye reads). A chain that serves no deltas falls back to the
// Black-Scholes 25-delta strikes from the horizon's own ATM vol and time to expiry, read off the interpolated curve.
function skew25(c: i32, spot: f64): f64 {
  const base = c * MAX_STRIKES;
  let put = -1;
  let call = -1;
  let putGap = Infinity;
  let callGap = Infinity;
  for (let i = 0; i < cCount[c]; i += 1) {
    const d = cDelta[base + i];
    if (!(d > 0.0) || d >= 1.0) continue;
    const gap = Math.abs(d - 0.25);
    if (cStrike[base + i] < spot) {
      if (gap < putGap) {
        putGap = gap;
        put = i;
      }
    } else if (gap < callGap) {
      callGap = gap;
      call = i;
    }
  }
  if (put >= 0 && call >= 0) return cIv[base + put] - cIv[base + call];
  const sigma = cAtm[c] / 100.0;
  const years = cDays[c] / 365.0;
  if (!(sigma > 0.0) || !(years > 0.0)) return NaN;
  const drift = 0.5 * sigma * sigma * years;
  const spread = Z_25D * sigma * Math.sqrt(years);
  return ivAt(c, spot * Math.exp(drift - spread)) - ivAt(c, spot * Math.exp(drift + spread));
}

function civilDate(c: i32, ms: f64): void { // UTC calendar day and month of an epoch-ms instant (days since 1970 to civil, no Date)
  const z = <i64>Math.floor(ms / DAY_MS) + 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;
  cDay[c] = <i32>(doy - (153 * mp + 2) / 5 + 1);
  cMonth[c] = <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: three horizons of the one chain, each a later expiry than the last
function measure(spot: f64, barOpenMs: f64): bool {
  for (let c = 0; c < CURVES; c += 1) {
    cKept[c] = 0;
    cCount[c] = 0;
    cExpiry[c] = NaN;
    cDays[c] = NaN;
    cAtm[c] = NaN;
  }
  expiryCount = 0;
  skew = NaN;
  wingSkew = NaN;
  nearest = -1;
  const n = in_chain_cells();
  if (n < TUPLE || isNaN(spot)) return false;
  const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer
  const nowMs = chainClockMs(cells, n, barOpenMs); // the chain's clock: a coarse bar's open would shift the horizons
  for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs + MIN_AHEAD_MS) noteExpiry(cells[i + 1]);
  if (expiryCount == 0) return false;
  let kept = 0;
  let after = -1; // the last horizon's expiry index: the next horizon takes a later one
  for (let c = 0; c < CURVES; c += 1) {
    const idx = pickExpiry(nowMs + targetDays[c] * DAY_MS, after);
    if (idx < 0) continue;
    after = idx;
    readPoints(c, cells, n, expiryMs[idx], spot);
    if (cCount[c] < 2) continue;
    cExpiry[c] = expiryMs[idx];
    cDays[c] = (expiryMs[idx] - nowMs) / DAY_MS;
    cAtm[c] = ivAt(c, spot);
    civilDate(c, expiryMs[idx]);
    cKept[c] = 1;
    if (nearest < 0) nearest = c;
    kept += 1;
  }
  if (cKept[1] == 1) skew = skew25(1, spot);
  else if (nearest >= 0) skew = skew25(nearest, spot);
  for (let c = 0; c < CURVES; c += 1) { // the chip reads the nearest curve that lists both wings
    if (cKept[c] == 0) continue;
    const lean = ivAt(c, spot * (1.0 - wingPct)) - ivAt(c, spot * (1.0 + wingPct));
    if (!isFinite(lean)) continue;
    wingSkew = lean;
    break;
  }
  return kept > 0;
}

// The panel frame: the common strike grid centred on spot (one row sits exactly at spot, where the callouts pin),
// each drawn horizon interpolated onto it and null outside its listed strikes; the value axis pinned to the curves
// with a margin, the caption naming the expiries, the skew chip, the spot marker and the at-the-money callouts.
function fbDate(c: i32): void { // 30 Oct
  fb_int(<i64>cDay[c]);
  fb_text(" ");
  fb_text(MONTHS[cMonth[c] - 1]);
}
function writeSmile(spot: f64): void {
  const decimals = spot >= 1000.0 ? 1 : spot >= 10.0 ? 2 : 5;
  const mid = (points - 1) / 2;
  const step = (2.0 * windowPct * spot) / f64(points - 1);
  fb_clear();
  fb_text("{\"rows\":[");
  let yLo = Infinity;
  let yHi = -Infinity;
  let written = 0;
  for (let r = 0; r < points; r += 1) {
    const x = spot + f64(r - mid) * step;
    if (x <= 0.0) continue;
    if (written > 0) fb_text(",");
    fb_text("[");
    fb_f64(x, decimals);
    for (let c = 0; c < CURVES; c += 1) {
      fb_text(",");
      const iv = cKept[c] == 1 ? ivAt(c, x) : NaN;
      if (isFinite(iv)) {
        if (iv < yLo) yLo = iv;
        if (iv > yHi) yHi = iv;
      }
      fb_f64(iv, 1); // null (JSON's gap) where the horizon lists nothing at this strike
    }
    fb_text("]");
    written += 1;
  }
  fb_text("]");
  if (isFinite(yLo) && isFinite(yHi)) { // the value axis follows the curves, not the gaps; room under the floor for the three callouts
    const range = yHi - yLo;
    let above = range * 0.25; // headroom over the highest wing, so it never runs under the series chips
    if (above < 3.0) above = 3.0;
    let below = range * 0.3;
    if (below < 3.0) below = 3.0;
    const lo = yLo - below;
    fb_text(",\"y_min\":");
    fb_f64(lo < 0.0 ? 0.0 : lo, 1);
    fb_text(",\"y_max\":");
    fb_f64(yHi + above, 1);
  }
  fb_text(",\"caption\":\"");
  let named = 0;
  for (let c = 0; c < CURVES; c += 1) { // each drawn horizon and its expiry's date, in the series' order: "near 9 Oct"
    if (cKept[c] == 0) continue;
    if (named > 0) fb_text(", ");
    fb_text(CAPTION_NAMES[c]);
    fbDate(c);
    named += 1;
  }
  fb_text("\",\"badge\":{\"text\":\""); // the chip: the nearest curve's lean at the wings, rose for puts, the accent for calls
  if (isNaN(wingSkew)) fb_text("Skew n/a\",\"color\":\"#94a3b8\"}");
  else {
    const flat = Math.abs(wingSkew) < 0.5;
    fb_text(flat ? "Flat skew " : wingSkew > 0.0 ? "Put skew " : "Call skew ");
    fb_f64(Math.abs(wingSkew), 1);
    fb_text(" pts (");
    fb_int(<i64>Math.round(wingPct * 100.0));
    fb_text("%)\"");
    if (flat) fb_text(",\"color\":\"#94a3b8\"");
    else if (wingSkew > 0.0) fb_text(",\"color\":\"#fb7185\"");
    fb_text("}");
  }
  fb_text(",\"markers\":[{\"x\":\"spot\",\"label\":\"Spot\",\"line_style\":\"dashed\",\"badge\":true}");
  for (let c = 0; c < CURVES; c += 1) {
    if (cKept[c] == 0 || !isFinite(cAtm[c])) continue;
    fb_text(",{\"x\":");
    fb_f64(spot, decimals);
    fb_text(",\"valign\":\"point\",\"series\":");
    fb_str(CURVE_NAMES[c]);
    fb_text(",\"label\":\"");
    fbDate(c); // the callout names its curve's expiry: "9 Oct 34.1%"
    fb_text(" ");
    fb_f64(cAtm[c], 1);
    fb_text("%\",\"color\":\"");
    fb_text(CURVE_INK[c]);
    fb_text("\",\"badge\":true}");
  }
  fb_text("]}");
  writeFrameBuffer(smile_rows);
}

function onStart(): void {
  targetDays[0] = p_near_days();
  targetDays[1] = p_mid_days();
  targetDays[2] = p_far_days();
  windowPct = p_window_pct() / 100.0;
  points = i32(p_points());
  wingPct = p_wing_pct() / 100.0;
}

// onBar() runs once per bar: history rows carry an empty block and cost one read each; the live bar measures the
// chain, writes the panel frame and the card's words, or the one notice when the coin has no chain.
function onBar(): void {
  const close = bar.close();
  const t = bar.time();
  if (bar.isLast()) measured = measure(close, t * 1000.0); // the bar's open, the clock's stand-in
  if (isNaN(close)) return;
  const live = bar.isLast() && measured;
  out_atm_iv_1w(live ? cAtm[0] : NaN);
  out_atm_iv_1m(live ? cAtm[1] : NaN);
  out_atm_iv_3m(live ? cAtm[2] : NaN);
  out_skew_25d(live ? skew : NaN);
  out_wing_skew(live ? wingSkew : NaN);
  out_term_1m_3m(live && cKept[1] == 1 && cKept[2] == 1 ? cAtm[1] - cAtm[2] : NaN);
  out_expiry_days_1w(live ? cDays[0] : NaN);
  if (!bar.isLast()) return;
  if (measured) {
    writeSmile(close);
    sb_clear(); // the skew row: signed IV points (a reading inside a twentieth of a point prints as 0.0, unsigned)
    if (isNaN(skew)) sb_text("n/a");
    else {
      const tenths = Math.round(skew * 10.0) / 10.0;
      if (tenths > 0.0) sb_text("+");
      else if (tenths < 0.0) sb_text("-");
      sb_f64(Math.abs(tenths), 1);
      sb_text(" pts");
    }
    str_skew_word_sb();
    sb_clear(); // the term row: the one-month and three-month readings at the money
    if (cKept[1] == 1 && isFinite(cAtm[1]) && cKept[2] == 1 && isFinite(cAtm[2])) {
      sb_f64(cAtm[1], 1);
      sb_text("% vs ");
      sb_f64(cAtm[2], 1);
      sb_text("%");
    } else sb_text("n/a");
    str_term_word_sb();
  } else {
    sb_clear();
    sb_text("No option chain for this coin");
    str_notice_sb();
    sb_clear();
    sb_text("n/a");
    str_skew_word_sb();
    sb_clear();
    sb_text("n/a");
    str_term_word_sb();
  }
}
```

## How it works

**The chain is one input, read on the live bar.** `input("chain", options_chain.cells, { max_cells: 4000, venue: "deribit" })` serves the chart coin's Deribit chain, ten numbers per contract (`[strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega]`, a BTC chain about 1,550 contracts); `in_chain_cells()` is the count and `in_chain_view()` the block in place. The chain fills the newest row only, so `measure()` runs under `bar.isLast()` and history bars draw nothing and cost nothing. The chart's own `close` is spot: the grid's centre, the out-of-the-money split and the at-the-money read.

**Three horizons of one chain.** 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. `measure()` collects the chain's live expiries (one inside 12 hours is settling, not a horizon), then picks for `near_days`, `mid_days` and `far_days` the listed expiry closest to each target, each later than the previous one. `readPoints()` keeps one expiry's out-of-the-money contracts (puts below spot, calls at or above), IV normalized to percent, sorted by strike, a strike listed twice keeping the heavier open interest. `ivAt()` interpolates between the two neighbouring listed strikes and reads NaN outside the listed range, which is how a short near curve ends where its listings end.

**The frame is the pane.** `writeSmile()` builds the `smile_rows` frame with the `fb_*` builders: `points` rows on a grid `window_pct` each side of spot, one row exactly at spot, each row `[strike, iv per horizon]` with `null` where a horizon lists nothing at that strike. Beside the rows it writes `y_min` and `y_max` (the value axis pinned to the curves, floored at 0, with room under them for the callouts and a quarter of their range over the highest wing, so it never runs under the series chips), the `caption` naming each drawn horizon's expiry (`near 9 Oct, mid 30 Oct, far 25 Dec`), the `badge` (the chip's word and colour) and the `markers` list: the dashed spot line plus one `valign: "point"` callout per drawn curve, pinned to the named series at the spot row, inked in that series' colour and labelled with its expiry's date and IV. `panel.line` draws it below the chart on a number axis with `x_format: "usd"`, `format: "%"` with one decimal, `chrome: "grid"`, `smooth`, `glow`, `hover_card` and `legend_style: "chips"`, the nearest expiry the widest stroke.

**Two skews, two readers.** `wing_skew` is the chip: on the nearest drawn curve, IV `wing_pct` below spot minus IV the same percent above it, so the chip names the lean the picture shows; within half a point it reads "Flat skew" in slate, else "Put skew" in rose or "Call skew" in the chart's accent, the percent from spot in the text. `skew_25d` is the card's row: on the mid horizon (the nearest drawn one when it lists nothing), the out-of-the-money put whose |delta| sits nearest 0.25 against the call whose delta does, put IV minus call IV from the chain's own deltas; a chain that serves no deltas falls back to the Black-Scholes 25-delta strikes from the horizon's own ATM vol and time to expiry, read off the interpolated curve. A week out, the 25-delta strikes sit two or three percent from spot and miss the wings the eye reads, which is why the mid horizon, a month by default, is the quoted one and the chip reads the wings.

**The card reads outputs and slots.** `atm_iv_1w` feeds the headline `tile.value`, "ATM IV, near expiry", with `format: "%"` (two decimals, "31.37%", where the callout prints one, "9 Oct 31.4%"), in the curve's teal, with a `hint` that says what the number means; `atm_iv_1m`, `atm_iv_3m`, `term_1m_3m` and `expiry_days_1w` are data-only outputs beside it (the names keep the default horizons: `_1w` is the near one, `_1m` the mid, `_3m` the far), every one written on the live bar and NaN on every history bar. The two rows read string slots built with the `sb_*` builders on the live bar: `skew_word` ("+2.7 pts", rounded to tenths first and signed only when a tenth is nonzero) and `term_word` ("32.8% vs 36.5%"). The row labels name the horizons by role ("25d skew, mid", "Term, mid vs far"). `look: "broadsheet"` is a literal, `columns: 1` and `width: 300` shape the card, and `position: "top_left"` with `safe_area: true` seats it under the legend, over the oldest candles on screen instead of the newest.

**The notice is a label.** `render.label("notice", { position: "top_right", text: "notice", offset: [12, 8], style: "knockout" })` is pinned to the top right over the `notice` slot, written on the live bar only when the coin has no chain, so no history bar carries text.

## Where it runs

Any market whose coin Deribit lists options for (BTC, ETH, SOL and the rest of Deribit's list), on any interval: the chain is a live snapshot served by the chart, so a BTC perpetual at 15m and at 1h draw the same curves and the same numbers, and an ETH spot chart reads ETH's chain. The strike axis prints dollar strikes ("$65.0K" to "$105.0K" on a BTC chart near 85K) with the percent from spot under each tick. A coin Deribit does not list refuses the run by name before any fetch ("Deribit lists no options for coin 'X'").

## When data is missing

A served coin whose chain carries no usable expiry shows the one sentence "No option chain for this coin" at the top right and draws nothing else: the frame is not written, so no empty pane mounts, the card's headline prints a dash and its two rows read "n/a". Every history bar draws nothing (the chain is the live bar's only), so the outputs carry a number on the last bar alone. Inside a drawn chain, a horizon that lists nothing at a grid strike writes `null` there and its curve stops at its listed range, so a near expiry that lists a narrower range than the quarter draws shorter; a horizon with no later expiry left is not drawn and the caption names only the drawn ones; the term row reads "n/a" unless both the mid and the far horizons are drawn and cover spot; the chip reads "Skew n/a" when no curve lists both wings.

## Customize it

- **Other horizons.** `near_days` (7, 1 to 30), `mid_days` (30, 7 to 120) and `far_days` (90, 30 to 365) each pick the listed expiry closest to that many days out, at least 12 hours away and each later than the previous one; the chips keep their role names and the callouts and the caption follow the expiries read.
- **A wider or finer grid.** `window_pct` (25, 5 to 60) sets the strike window as a percent of spot on each side and `points` (41, 21 to 81) the strikes on it; one row always sits exactly at spot, where the callouts pin.
- **The chip's wings.** `wing_pct` (10, 3 to 30) sets how far from spot the chip reads its two wings; the percent prints in the chip's text.
- **The chip's number on the card.** `wing_skew` is already a data-only output: add `["Wing skew", "wing_skew", "0.0"]` as a third row of `tile.rows` and the card quotes the picture too.
- **Change the look.** The card's look is the literal `look: "broadsheet"` (no setting binds it); the Look row on the indicator's Style page switches it without code, "As made" first, and `phosphor` (green mono type on near black, with scanlines) is the second look this example was shot in ([The Style page](../settings/style-page.md#the-look-row), [Looks](../presentation/hud-and-hover-cards.md#looks)).

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volatility Smile** under **Beyond the time axis**.
2. Press **Run** on a chart of a coin Deribit lists, such as BTCUSDT at 15m: the smile pane mounts under the chart with the three curves, the spot marker, the callouts and the chip, and the broadsheet card appears at the top left of the price pane, under the legend.
3. At the editor's Console prompt, type `last 20 atm_iv_1w` to read the nearest expiry's at-the-money IV on the last 20 bars: only the last bar carries a number, the chain being live only.

## Concepts used

- [Options kit](../functions/options-kit.md) for `options_chain.cells`, the ten-number contract tuple, `venue`, `max_cells`, `in_chain_cells()` and `in_chain_view()`
- [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, the `fb_*` builders and `writeFrameBuffer`, `panel.line` on a number axis, markers, `valign: "point"` callouts, badges and captions
- [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) for `render.hud`, its anchors, `offset`, `columns` and `width`
- [Looks](../presentation/hud-and-hover-cards.md#looks) for `broadsheet` and `phosphor` and what each draws
- [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the headline `tile.value`, its `hint` and `format`, and `tile.rows`
- [The Style page](../settings/style-page.md#the-look-row) for the Look row that switches the look without code
- [Plotting](../presentation/plotting.md) for `render.label` pinned by `position`, the fixed-position text
- [Strings and text](../functions/text-formatting.md) for the `sb_*` builders and `str_<slot>_sb()`
