Skew history

View as MarkdownOpen the editor

How the chart's underlying's options skew has moved, and where today's term structure sits in its own past.

Three reads. The 30-day 25 delta risk reversal, one per session from the daily options statistics (the call's IV minus the put's at the expiry nearest 30 days), drawn as a step line with its percentile and z-score against the window's earlier sessions. The risk reversal of one expiry, the one nearest the Expiry, days out setting, at 25 or 10 delta, read on every bar from the option surface, with each value checked against its present bit; a bar the data plane marks partial draws on a dotted line of its own and is counted, never mixed into the solid line. And the term structure now against its window: the at-the-money IV at seven constant maturities beside each one's median and 25th and 75th percentile over the window's sessions, in a panel below the chart. A card at the bottom right names the skew (puts rich when the put IV minus the call IV is over 0.06 of the 30-day IV, calls rich below 0, flat between) with the numbers behind it.

The parts are scalar option_daily inputs and the celled option_surface source (Option surface) read through hasBit and isPartial, the options kit's DayHistory for the percentile, the z-score and the quantiles (Options kit), a panel.line over category maturities (Line panels), and a render.hud card. This is also the skew-history template: the Skew History card under Beyond the time axis in the editor's starter list, and it compiles as written.

The wrun indicator

// Skew History: how the chart's underlying's options skew has moved, and where today's term structure sits in its own
// past. Three reads. The 30-day 25 delta risk reversal, one per session from the daily options statistics
// (option_daily: the call's IV minus the put's at the expiry nearest 30 days), drawn as a step line with its percentile
// and z-score against the window's earlier sessions. The risk reversal of one expiry, the one nearest the Expiry
// setting's days out, at 25 or 10 delta, read on every bar from the option surface (option_surface: one tuple per
// expiry with present bits per value; a bar the data plane marks partial, bit 14, draws on a dotted line of its own
// and is counted, never mixed into the solid line). And the term structure now against its window: the at-the-money
// IV at seven constant maturities (7 days to a year) beside each one's median and 25th and 75th percentile over the
// window's sessions, in a panel below the chart. A card at the bottom right names the skew (puts rich over 0.06 of
// the 30-day IV, calls rich below 0, flat between) with the numbers behind it. The statistics are short today (they
// start when the data plane switches the daily job on): the percentile, the z-score and the distribution need Minimum
// history (60 sessions), and until then the card says how many sessions there are. The surface's five-minute history
// is kept 30 days, so older intraday bars arrive partial. US stock charts, daily or finer.

param.int("days", 30, { min: 1, max: 365, label: "Expiry, days out", description: "The surface line reads the expiry nearest this many days out (never one that settles today)" });
param.choice("delta", ["25 delta", "10 delta"], "25 delta", { label: "Surface delta", description: "The wings the surface line compares: 25 delta, or 10 delta further out" });
param.int("lookback", 252, { min: 20, max: 520, label: "Window, sessions", description: "Sessions the percentile, the z-score and the term distribution read: 252 is about a year" });
param.int("min_sessions", 60, { min: 10, max: 520, label: "Minimum history, sessions", description: "Sessions of statistics before the percentile, the z-score and the distribution show" });
input("close", ohlcv.close); // the chart's own close: the grid
input("rr25", option_daily.rr25_30d, { missing: "nan", description: "The 30-day 25 delta risk reversal, call IV minus put IV, volatility points, one row per session" });
input("iv7", option_daily.atm_iv_7d, { missing: "nan", description: "The 7-day at-the-money IV, percent" });
input("iv14", option_daily.atm_iv_14d, { missing: "nan", description: "The 14-day at-the-money IV, percent" });
input("iv30", option_daily.atm_iv_30d, { missing: "nan", description: "The 30-day at-the-money IV, percent" });
input("iv60", option_daily.atm_iv_60d, { missing: "nan", description: "The 60-day at-the-money IV, percent" });
input("iv90", option_daily.atm_iv_90d, { missing: "nan", description: "The 90-day at-the-money IV, percent" });
input("iv180", option_daily.atm_iv_180d, { missing: "nan", description: "The 180-day at-the-money IV, percent" });
input("iv365", option_daily.atm_iv_365d, { missing: "nan", description: "The 365-day at-the-money IV, percent" });
input("surface", option_surface.cells, { max_cells: 1800, description: "The underlying's option surface on the bar: one tuple per expiry" }); // [expiry_ms, dte_days, forward, atm_iv, iv_p25, iv_c25, iv_p10, iv_c10, present] per expiry
output("rr30", line, lower, { color: "#38bdf8", width: 2, step: true, label: "RR 30d", format: "0.00", description: "The 30-day 25 delta risk reversal, call IV minus put IV, volatility points, per session" });
output("rr_expiry", line, lower, { color: "#f59e0b", width: 1, label: "RR expiry", format: "0.00", description: "The chosen expiry's risk reversal at the chosen delta, call IV minus put IV, volatility points, per bar" });
output("rr_partial", line, lower, { color: "#64748b", width: 1, line_style: "dotted", label: "RR partial", format: "0.00", description: "The same reading on a bar the data plane marks partial: a floor of the data, never the solid line" });
output("zero", line, lower, { role: "guide", color: "#475569", label: "0", description: "The zero line: below it puts are dearer than calls" });
output("rr_pct", none, lower, { description: "The newest 30-day risk reversal's percentile among the window's earlier sessions, 0 to 100" });
output("rr_z", none, lower, { description: "The newest 30-day risk reversal against the mean and deviation of the window's earlier sessions" });
output("scaled", none, lower, { description: "Put IV minus call IV over the 30-day IV (the risk reversal turned round and scaled): the skew word's number" });
output("skew_code", none, lower, { description: "0 calls rich (scaled under 0), 1 flat, 2 puts rich (scaled over 0.06); 1 with no reading" });
output("expiry_days", none, lower, { description: "Days to the expiry the surface line reads" });
output("partial_bars", none, lower, { description: "Bars so far whose surface the data plane marked partial" });
output("sessions", none, lower, { description: "Sessions of statistics the window holds" });
output("status", none, lower, { description: "0 not enough history yet, 1 the window reads, 2 a chart coarser than a day" });
string("read", { max_bytes: 64 }); // the headline
string("title", { max_bytes: 64 }); // the card's title
string("history_text", { max_bytes: 48 }); // "Percentile 18, z -1.42" or the wait
const termRows = frame("term_rows", { max_bytes: 4096 }); // one row per maturity: [label, now, median, 25th, 75th]
// The term structure now against its window: the maturities across, IV up; the frame carries the caption per run.
panel.line({ name: "term", title: "Term structure against its window", x: "category", place: "below", frame: termRows, height_frac: 0.24, chrome: "grid", hover_card: true, legend_style: "chips", format: "0.0", unit: "%", y_title: "At-the-money IV", series: [
  { name: "Now", color: "#e2e8f0", width: 2.5, points: true },
  { name: "Median", color: "#38bdf8", width: 1.5 },
  { name: "25th", color: "#64748b", width: 1, line_style: "dashed" },
  { name: "75th", color: "#64748b", width: 1, line_style: "dashed" },
] });

render.hud("skew_card", {
  position: "bottom_right",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Skew", "read", { headline: true, color_by: "skew_code", colors: ["#38bdf8", "#94a3b8", "#a78bfa"] }),
    tile.pill("Against its history", "history_text", { color_by: "status", colors: ["#94a3b8", "#e2e8f0", "#94a3b8"] }),
    tile.value("RR 30d", "rr30", { format: "0.00" }),
    tile.rows([
      ["1) RR, chosen expiry", "rr_expiry", "0.00"],
      ["2) Expiry, days out", "expiry_days", "int"],
      ["3) Skew over IV", "scaled", "0.000"],
      ["4) Percentile", "rr_pct", "0"],
      ["5) Z-score", "rr_z", "0.00"],
      ["6) Partial bars", "partial_bars", "int"],
    ]),
  ],
});

const DAY = 86400.0;
const TUPLE = 9; // option_surface: expiry_ms, dte_days, forward, atm_iv, iv_p25, iv_c25, iv_p10, iv_c10, present
const BIT_DTE = 1; // present bits: each value after expiry_ms, then the partial bar
const BIT_P25 = 4;
const BIT_C25 = 5;
const BIT_P10 = 6;
const BIT_C10 = 7;
const PUT_RICH = 0.06; // the scaled skew past which puts are rich (under 0, calls are)
const CAPACITY = 520;
const TENORS = 7;
const TENOR_LABELS: String[] = ["7d", "14d", "30d", "60d", "90d", "180d", "1y"];
const MONTHS: String[] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const rrs = new DayHistory(CAPACITY); // one 30-day risk reversal per session
const terms: DayHistory[] = []; // one history per maturity
for (let k = 0; k < TENORS; k += 1) terms.push(new DayHistory(CAPACITY));
const termNow = new StaticArray<f64>(TENORS); // the newest session's IV per maturity
const dateClock = new Clock("UTC");

let targetDays = 30.0; // settings, read in onStart()
let tenDelta = false;
let lookback = 252;
let need = 60;
let prevT: f64 = NaN; // the bar spacing
let barSec: f64 = NaN;
let rrNow: f64 = NaN; // the session readings, held
let pctNow: f64 = NaN;
let zNow: f64 = NaN;
let scaledNow: f64 = NaN;
let iv30Now: f64 = NaN;
let partialBars = 0;
let lastDays: f64 = NaN; // the surface line's expiry, held for the card

function onStart(): void {
  targetDays = p_days();
  tenDelta = i32(p_delta()) == 1;
  lookback = i32(p_lookback());
  need = i32(p_min_sessions());
  if (need > lookback) need = lookback;
  for (let k = 0; k < TENORS; k += 1) termNow[k] = NaN;
}

function tenorInput(k: i32): f64 {
  if (k == 0) return in_iv7();
  if (k == 1) return in_iv14();
  if (k == 2) return in_iv30();
  if (k == 3) return in_iv60();
  if (k == 4) return in_iv90();
  if (k == 5) return in_iv180();
  return in_iv365();
}

// ── The surface on this bar: the expiry nearest targetDays with both wings served, and whether the bar is partial ──
let surfaceRr: f64 = NaN;
let surfaceDays: f64 = NaN;
let surfacePartial = false;
function readSurface(): void {
  surfaceRr = NaN;
  surfaceDays = NaN;
  surfacePartial = false;
  const n = in_surface_cells();
  if (n < TUPLE) return; // an empty bar: no surface, a gap in the line
  const cells = in_surface_view();
  const putBit = tenDelta ? BIT_P10 : BIT_P25;
  const callBit = tenDelta ? BIT_C10 : BIT_C25;
  const putAt = tenDelta ? 6 : 4;
  const callAt = tenDelta ? 7 : 5;
  let bestGap: f64 = Infinity;
  let bestExpiry: f64 = Infinity;
  for (let i = 0; i + TUPLE <= n; i += TUPLE) {
    const present = cells[i + 8];
    if (isPartial(present)) surfacePartial = true;
    if (!hasBit(present, BIT_DTE) || !hasBit(present, putBit) || !hasBit(present, callBit)) continue;
    const dte = cells[i + 1];
    if (!(dte >= 1.0)) continue; // an expiry that settles today never stands for a tenor
    const gap = Math.abs(dte - targetDays);
    const expiry = cells[i];
    if (gap < bestGap || (gap == bestGap && expiry < bestExpiry)) {
      bestGap = gap;
      bestExpiry = expiry;
      surfaceDays = dte;
      surfaceRr = cells[i + callAt] - cells[i + putAt];
    }
  }
}

// ── The session readings, when a session lands ──
function measure(): void {
  rrNow = rrs.value(0);
  const ready = rrs.span(lookback) >= need;
  pctNow = ready ? rrs.percentile(lookback) : NaN;
  zNow = ready ? rrs.zscore(lookback) : NaN;
  scaledNow = iv30Now > 0.0 ? -rrNow / iv30Now : NaN;
}

function skewCode(): f64 {
  if (isNaN(scaledNow)) return 1.0;
  return scaledNow > PUT_RICH ? 2.0 : scaledNow < 0.0 ? 0.0 : 1.0;
}

function sbDay(day: f64): void { // "16OCT"
  dateClock.update(day * DAY);
  if (dateClock.dayOfMonth() < 10) sb_text("0");
  sb_int(dateClock.dayOfMonth());
  sb_text(MONTHS[dateClock.month() - 1]);
}

function sbSigned(v: f64): void { // "+1.20", "-4.05"
  if (v >= 0.0) sb_text("+");
  sb_f64(v, 2);
}

// ── The term panel: each maturity now, its median and quartiles over the window ──
function writeTerm(): void {
  fb_clear();
  fb_text("{\"rows\":[");
  for (let k = 0; k < TENORS; k += 1) {
    if (k > 0) fb_text(",");
    fb_text("[");
    fb_str(TENOR_LABELS[k]);
    const h = terms[k];
    const ready = h.span(lookback) >= need;
    const now = termNow[k];
    fb_text(",");
    if (isNaN(now)) fb_text("null");
    else fb_f64(now, 2);
    for (let q = 0; q < 3; q += 1) {
      fb_text(",");
      const v = ready ? h.quantile(lookback, q == 0 ? 0.5 : q == 1 ? 0.25 : 0.75) : NaN;
      if (isNaN(v)) fb_text("null");
      else fb_f64(v, 2);
    }
    fb_text("]");
  }
  fb_text("],\"caption\":\"");
  const held = terms[2].span(lookback);
  if (held < need) {
    fb_text("Not enough history yet: ");
    fb_int(held);
    fb_text(" of ");
    fb_int(need);
    fb_text(" sessions");
  } else {
    fb_text("Now against ");
    fb_int(held);
    fb_text(" sessions");
  }
  fb_text("\"}");
  writeFrameBuffer(FRAME_TERM_ROWS);
}

function writeWords(coarse: bool): void {
  const held = rrs.span(lookback);
  sb_clear();
  if (coarse) sb_text("Needs a daily or finer chart");
  else if (rrs.count() == 0) sb_text("Waiting for the daily options statistics");
  else {
    const code = skewCode();
    sb_text(code == 2.0 ? "Puts rich, RR " : code == 0.0 ? "Calls rich, RR " : "Skew flat, RR ");
    sbSigned(rrNow);
  }
  str_read_sb();
  sb_clear();
  if (coarse || rrs.count() == 0) sb_text("No history");
  else if (held < need) {
    sb_text("Not enough history yet: ");
    sb_int(held);
    sb_text(" of ");
    sb_int(need);
    sb_text(" sessions");
  } else {
    sb_text("Percentile ");
    sb_f64(pctNow, 0);
    if (!isNaN(zNow)) {
      sb_text(", z ");
      sbSigned(zNow);
    }
  }
  str_history_text_sb();
  sb_clear();
  sb_text("Skew history, 25 delta");
  if (!coarse && rrs.count() > 0) {
    sb_text(", as of ");
    sbDay(rrs.day(0));
  }
  str_title_sb();
}

// onBar() runs once per bar: the spacing; the surface's expiry on every bar; the session's statistics when they land
// (the risk reversal and each maturity's IV). Each bar writes the held session readings beside its own surface line.
function onBar(): void {
  const t = bar.time();
  if (!isNaN(prevT) && t > prevT && (isNaN(barSec) || t - prevT < barSec)) barSec = t - prevT;
  prevT = t;
  const coarse = !isNaN(barSec) && barSec > 1.5 * DAY;
  readSurface();
  if (surfacePartial) partialBars += 1;
  if (!isNaN(surfaceDays)) lastDays = surfaceDays;
  const day = Math.floor(t / DAY);
  if (!coarse) {
    const iv30 = in_iv30();
    if (!isNaN(iv30) && iv30 > 0.0) iv30Now = iv30;
    for (let k = 0; k < TENORS; k += 1) {
      const v = tenorInput(k);
      if (!isNaN(v) && v > 0.0 && terms[k].push(day, v)) termNow[k] = v;
    }
    const rr = in_rr25();
    if (!isNaN(rr) && rrs.push(day, rr)) measure();
  }
  out_zero(0.0);
  out_rr30(coarse ? NaN : rrNow);
  out_rr_expiry(coarse || surfacePartial ? NaN : surfaceRr);
  out_rr_partial(!coarse && surfacePartial ? surfaceRr : NaN);
  out_rr_pct(coarse ? NaN : pctNow);
  out_rr_z(coarse ? NaN : zNow);
  out_scaled(coarse ? NaN : scaledNow);
  out_skew_code(coarse ? 1.0 : skewCode());
  out_expiry_days(coarse ? NaN : lastDays);
  out_partial_bars(f64(partialBars));
  out_sessions(coarse ? NaN : f64(rrs.span(lookback)));
  out_status(coarse ? 2.0 : rrs.span(lookback) >= need ? 1.0 : 0.0);
  if (bar.isLast()) {
    writeWords(coarse);
    if (!coarse) writeTerm();
  }
}

How it works

The session line. rr25_30d is the statistics' 30-day risk reversal, call IV minus put IV in volatility points: below zero the puts are dearer, the usual shape on a stock. Each session's reading joins a DayHistory; percentile() and zscore() read it against the window's earlier sessions.

The surface line. Every bar's option_surface block holds one tuple per expiry, [expiry_ms, dte_days, forward, atm_iv, iv_p25, iv_c25, iv_p10, iv_c10, present]. The module keeps the expiry whose dte_days is nearest the setting among those whose days and both wings are present (bits 1, 4 and 5 at 25 delta, bits 1, 6 and 7 at 10 delta), never an expiry that settles that day, and draws its call wing minus its put wing. When the nearest expiry lacks a wing the next nearest stands in. A bar with bit 14 set on any tuple is partial (older than the five-minute history the data plane keeps, or cut short): its reading goes to the dotted RR partial line, a floor of the data rather than a value.

The term panel. Each maturity's IV joins its own DayHistory; on the live bar quantile() reads the median and the quartiles over the window, and the panel draws them beside the newest session's curve.

Where it runs

US stock charts, through OpenMarket's official package: the chart's browser lane serves option_daily for the chart's own underlying (Limitations). It is declared ahead of the lane that serves it, so the template builds today and the chart shows the lane's refusal until the data plane serves it. The surface line reads option_surface, which the chart's browser lane serves on crypto, CME and US stock charts (Option surface). A daily or finer chart: the daily surface has no retention limit, the five-minute one keeps 30 days.

When data is missing

The statistics start the day their job is switched on. The session line reads from the first session; the percentile, the z-score and the term distribution need Minimum history (60 sessions), and until then the pill and the panel's caption read "Not enough history yet: 12 of 60 sessions". A bar with no surface block draws nothing on either surface line; the card keeps the expiry it last read. Before the first session the card reads "Waiting for the daily options statistics"; a chart coarser than a day reads "Needs a daily or finer chart".

Customize it

  • Another expiry. Set Expiry, days out to 7 for the weekly or 90 for the quarter.
  • Further wings. Switch Surface delta to 10 delta to read the tails.
  • Another year. lookback sets the sessions the percentile, the z-score and the distribution read.

Run it

  1. In the editor's Explorer, press the Templates icon and pick Skew History under Beyond the time axis.
  2. Press Run on a US stock's chart: the session line and the surface line fill as the statistics allow.
  3. At the editor's Console prompt, type rr30, rr_pct and partial_bars.

Concepts used