---
title: "Volatility context"
description: "One card that reads the chart's own option chain the way a volatility desk opens its day, three verdicts in one line first (\"Contango, puts rich, IV over…"
order: 118
section: "cookbook"
---

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

# Volatility context

One card that reads the chart's own option chain the way a volatility desk opens its day, three verdicts in one line first ("Contango, puts rich, IV over realized"), then the numbers behind each. The term structure: the 30-day and the 90-day at-the-money IV at constant maturity and their slope, contango when the 90-day sits more than 1% over the 30-day, backwardation when it sits more than 1% under, flat between. The skew: the 25 delta put IV minus the 25 delta call IV at the expiry nearest 30 days, over the 30-day IV, puts rich above 0.06, calls rich below 0, flat between, with the risk reversal in vol points beside it. IV against realized: the 30-day IV over the realized volatility of the last 21 daily returns. And the front expiry, the first after today, with its days to go and its implied move. The card sits at the bottom of the pane in the terminal look, titled by the front expiry.

The parts are a celled `options_chain.cells` input read on the live bar ([Data sources](../core-concepts/data-sources.md#options-chain)), the options kit's `IvTerm` for the at-the-money term, the 25 delta wings and the implied move, with `chainPricedAtMs` for the chain's clock and `optionsTradeDay` for the front expiry's date ([Options kit](../functions/options-kit.md#the-at-the-money-term)), a `candles` stream of the chart's own daily candles kept in a `CandleList` for the realized volatility ([Calendar periods and candle lists](../functions/higher-timeframe-kit.md#calendar-periods-and-candle-lists)), and a `render.hud` card with `look: "terminal"` over data-only outputs and five text slots ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards)). This is also the `vol-context` template: the **Volatility Context** card under **HUDs** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=vol-context
// Volatility Context: one card that reads the chart's own option chain the way a volatility desk opens its day. The
// term structure: the 30-day and 90-day at-the-money IV at constant maturity (interpolated in total variance, never
// anchored on an expiry that settles today) and their slope, contango when the 90-day sits more than 1% over the
// 30-day, backwardation when it sits more than 1% under, flat between. The skew: the 25 delta put IV minus the 25
// delta call IV at the expiry nearest 30 days, over the 30-day IV: puts rich above 0.06, calls rich below 0, flat
// between; the risk reversal beside it in vol points, call minus put. IV against realized: the 30-day IV over the
// realized volatility of the last 21 daily log returns of the chart's own daily candles (the sample standard
// deviation, annualized over 252 trading days a year, 365 on crypto, which trades every day). The front expiry: the
// first after today on the market's own calendar, its days to go and its implied move (0.85 of the at-the-money
// straddle). A card at the bottom of the pane, in the terminal look, leads with one plain line (Contango, puts rich,
// IV over realized). The chain rides the live bar only: history bars draw nothing.

section("Volatility");
param.int("rv_days", 21, { min: 5, max: 60, label: "Realized vol, days", description: "Daily log returns the realized volatility reads (21: about a month of trading days)" });
param.choice("rv_year", ["auto", "252", "365"], "auto", { label: "Realized vol year", description: "Days a year the daily returns annualize over: auto reads 365 on crypto, which trades every day, and 252 elsewhere" });
param.number("rate_pct", 4, { min: 0, max: 20, step: 0.25, label: "Rate, percent", description: "The rate that carries a US stock's spot to each expiry's forward; futures and crypto chains quote the forward already" });
input("close", ohlcv.close); // the chart's own close: spot where a contract reports no underlying
input("chain", options_chain.cells, { max_cells: 4000, venue: "auto", description: "The chart's own option chain: OPRA on a US stock, CME on futures, Deribit on BTC and ETH" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract
input("daily", candles.cells, { interval: "1d", bars: 64, description: "The newest 64 closed daily candles: the realized volatility" }); // [offset_ms, open, high, low, close, volume] per candle, oldest first
market.zone(); // the market's clock: 0 UTC (crypto), 1 New York (US stocks), 2 Chicago (CME): the calendar, the rate and the year
// The readings, data-only: the card's tiles read them and the Console can too. Each is NaN on history bars.
output("iv30", none, overlay, { description: "The 30-day constant-maturity at-the-money IV, percent; live bar only" });
output("iv90", none, overlay, { description: "The 90-day constant-maturity at-the-money IV, percent (the last expiry's past it)" });
output("term_slope", none, overlay, { description: "The 90-day IV over the 30-day IV, minus 1, percent" });
output("term_regime", none, overlay, { description: "The term: 0 backwardation (slope under -1%), 1 flat or no reading, 2 contango (slope over +1%)" });
output("rr25", none, overlay, { description: "The 25 delta risk reversal at the expiry nearest 30 days: call IV minus put IV, vol points" });
output("skew_scaled", none, overlay, { description: "The 25 delta put IV minus the call IV, over the 30-day IV: put minus call, the skew label's number" });
output("skew_regime", none, overlay, { description: "The skew: 0 calls rich (under 0), 1 flat or no reading, 2 puts rich (over 0.06)" });
output("skew_days", none, overlay, { description: "Days to the expiry the skew reads, the one nearest 30 days" });
output("rv21", none, overlay, { description: "Realized volatility of the last rv_days daily log returns, annualized, percent" });
output("iv_rv_ratio", none, overlay, { description: "The 30-day IV over the realized volatility" });
output("ivrv_regime", none, overlay, { description: "IV against realized: 0 under, 1 at it or no reading, 2 over" });
output("front_days", none, overlay, { description: "Days to the front expiry, the first after today on the market's calendar" });
output("front_move", none, overlay, { description: "The front expiry's implied move, price: 0.85 of its at-the-money straddle" });
output("front_move_pct", none, overlay, { description: "The front expiry's implied move, percent of its underlying" });
string("read", { max_bytes: 64 }); // the headline: the three verdicts in one line, or why there is no reading
string("title", { max_bytes: 64 }); // the card's title: the front expiry and its days to go
string("term_text", { max_bytes: 40 }); // the term tile: the word and the slope
string("skew_text", { max_bytes: 40 }); // the skew tile: the word and the scaled skew
string("ivrv_text", { max_bytes: 40 }); // the realized tile: the word and the ratio

// The card, in the terminal look: the headline's colour follows the term (red backwardation, slate flat, green
// contango), each verdict tile its own ladder, the numbers under them as numbered rows.
render.hud("vol_context", {
  position: "bottom_center",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Volatility", "read", { headline: true, color_by: "term_regime", colors: ["#ff003c", "#94a3b8", "#10b981"] }),
    tile.pill("Term, 90 over 30 days", "term_text", { color_by: "term_regime", colors: ["#ff003c", "#94a3b8", "#10b981"] }),
    tile.pill("25 delta skew", "skew_text", { color_by: "skew_regime", colors: ["#38bdf8", "#94a3b8", "#a78bfa"] }),
    tile.pill("IV against realized", "ivrv_text", { color_by: "ivrv_regime", colors: ["#38bdf8", "#94a3b8", "#f59e0b"] }),
    tile.value("Front implied move", "front_move", { format: "price" }),
    tile.rows([
      ["1) IV, 30 days", "iv30", "0.00"],
      ["2) IV, 90 days", "iv90", "0.00"],
      ["3) Term slope, %", "term_slope", "0.00"],
      ["4) Risk reversal, call minus put", "rr25", "0.00"],
      ["5) Skew over IV, put minus call", "skew_scaled", "0.000"],
      ["6) Realized vol", "rv21", "0.00"],
      ["7) IV over realized", "iv_rv_ratio", "0.00"],
      ["8) Front move, %", "front_move_pct", "0.00"],
    ]),
  ],
});

const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega
const MAX_DAYS = 64; // the daily candles kept: the stream's depth
const CONTANGO = 0.01; // the slope past which the term is contango (and under minus it, backwardation)
const PUT_RICH = 0.06; // the scaled skew past which puts are rich (under 0, calls are)
const MONTHS: String[] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const days = new CandleList(MAX_DAYS); // the newest closed daily candles, kept from the stream
const term = new IvTerm(64); // the chain's at-the-money IV per expiry and at constant maturities
const dateClock = new Clock("UTC"); // reads a date from its day number

let rvDays = 21; // settings, read in onStart()
let yearDays = 252.0;
let rate = 0.04;
let zone = 0;
let close: f64 = NaN; // this bar
let t: f64 = NaN;
let atm30: f64 = NaN; // the readings, measured on the live bar, as decimals
let atm90: f64 = NaN;
let slope: f64 = NaN;
let callWing: f64 = NaN;
let putWing: f64 = NaN;
let skewScaled: f64 = NaN;
let skewDays: f64 = NaN;
let realized: f64 = NaN;
let overRealized: f64 = NaN;
let front = -1;
let frontDays: f64 = NaN;
let frontMove: f64 = NaN;
let frontMovePct: f64 = NaN;

function onStart(): void {
  rvDays = i32(p_rv_days());
  zone = i32(p_market_zone());
  const year = i32(p_rv_year());
  yearDays = year == 1 ? 252.0 : year == 2 ? 365.0 : zone == 0 ? 365.0 : 252.0; // crypto trades every day of the year
  rate = zone == 1 ? p_rate_pct() / 100.0 : 0.0; // only a stock's spot needs carrying to each forward
}

// ── Realized volatility: the sample standard deviation of the last rvDays daily log returns, annualized ──
function realizedVol(): f64 {
  const held = days.count();
  if (held < rvDays + 1) return NaN; // rvDays returns need rvDays + 1 closes
  let sum = 0.0;
  for (let k = held - rvDays; k < held; k += 1) {
    const before = days.close(k - 1);
    const after = days.close(k);
    if (!(before > 0.0) || !(after > 0.0)) return NaN;
    sum += Math.log(after / before);
  }
  const mean = sum / f64(rvDays);
  let squares = 0.0;
  for (let k = held - rvDays; k < held; k += 1) {
    const r = Math.log(days.close(k) / days.close(k - 1)) - mean;
    squares += r * r;
  }
  return Math.sqrt(squares / f64(rvDays - 1)) * Math.sqrt(yearDays);
}

// ── The chain, measured on the live bar ──
function measure(n: i32): void {
  const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer
  const nowMs = chainPricedAtMs(cells, n, t * 1000.0); // when the chart priced the chain
  term.load(cells, n, nowMs, close, zone, rate);
  atm30 = term.iv(30.0);
  atm90 = term.iv(90.0);
  slope = atm30 > 0.0 && atm90 > 0.0 ? atm90 / atm30 - 1.0 : NaN;
  const e = term.nearest(30.0); // the anchor whose time to expiry is nearest 30 days
  callWing = term.deltaIv(e, 1.0, 0.25);
  putWing = term.deltaIv(e, -1.0, 0.25);
  skewScaled = atm30 > 0.0 ? (putWing - callWing) / atm30 : NaN; // put minus call over the 30-day IV, as the label reads it
  skewDays = term.days(e);
  realized = realizedVol();
  overRealized = realized > 0.0 && atm30 > 0.0 ? atm30 / realized : NaN;
  front = term.front(); // the first expiry after today, whatever its IV
  frontDays = term.days(front);
  frontMove = term.impliedMove(front);
  frontMovePct = (frontMove / term.underlying(front)) * 100.0;
}

// ── The card's words ──
function termCode(): f64 { // 0 backwardation, 1 flat, 2 contango; NaN with no slope
  if (isNaN(slope)) return NaN;
  return slope > CONTANGO ? 2.0 : slope < -CONTANGO ? 0.0 : 1.0;
}
function skewCode(): f64 { // 0 calls rich, 1 flat, 2 puts rich; NaN with no skew
  if (isNaN(skewScaled)) return NaN;
  return skewScaled > PUT_RICH ? 2.0 : skewScaled < 0.0 ? 0.0 : 1.0;
}
function ratioCode(): f64 { // 0 under realized, 1 at it, 2 over; NaN with no ratio
  if (isNaN(overRealized)) return NaN;
  return overRealized > 1.0 ? 2.0 : overRealized < 1.0 ? 0.0 : 1.0;
}
function sbSigned(v: f64): void { // +6.20 / -3.10, two decimals; a value that rounds to zero reads +0.00
  if (Math.abs(v) < 0.005) {
    sb_text("+0.00");
    return;
  }
  if (v > 0.0) sb_text("+");
  sb_f64(v, 2);
}
function writeWords(): void {
  const termAt = termCode();
  const skewAt = skewCode();
  const ratioAt = ratioCode();
  // The headline: the verdicts that have a reading, the first one capitalized.
  sb_clear();
  let parts = 0;
  if (!isNaN(termAt)) {
    sb_text(termAt == 2.0 ? "Contango" : termAt == 0.0 ? "Backwardation" : "Flat term");
    parts += 1;
  }
  if (!isNaN(skewAt)) {
    if (parts > 0) sb_text(", ");
    if (skewAt == 2.0) sb_text(parts > 0 ? "puts rich" : "Puts rich");
    else if (skewAt == 0.0) sb_text(parts > 0 ? "calls rich" : "Calls rich");
    else sb_text(parts > 0 ? "skew flat" : "Skew flat");
    parts += 1;
  }
  if (!isNaN(ratioAt)) {
    if (parts > 0) sb_text(", ");
    sb_text(ratioAt == 2.0 ? "IV over realized" : ratioAt == 0.0 ? "IV under realized" : "IV at realized");
    parts += 1;
  }
  if (parts == 0) sb_text("No implied volatility in the chain");
  str_read_sb();
  // The tiles.
  sb_clear();
  if (isNaN(termAt)) sb_text("No term");
  else {
    sb_text(termAt == 2.0 ? "Contango, " : termAt == 0.0 ? "Backwardation, " : "Flat, ");
    sbSigned(slope * 100.0);
    sb_text("%");
  }
  str_term_text_sb();
  sb_clear();
  if (isNaN(skewAt)) sb_text("No 25 delta wing");
  else {
    sb_text(skewAt == 2.0 ? "Puts rich, " : skewAt == 0.0 ? "Calls rich, " : "Flat, ");
    sb_f64(skewScaled, 3);
  }
  str_skew_text_sb();
  sb_clear();
  if (realized == 0.0) sb_text("Realized vol is zero");
  else if (isNaN(realized)) {
    sb_text("Needs ");
    sb_int(rvDays + 1);
    sb_text(" daily closes");
  } else if (isNaN(ratioAt)) sb_text("No 30-day IV");
  else {
    sb_text(ratioAt == 2.0 ? "IV over realized, " : ratioAt == 0.0 ? "IV under realized, " : "IV at realized, ");
    sb_f64(overRealized, 2);
    sb_text("x");
  }
  str_ivrv_text_sb();
  // The title: the front expiry and its days to go.
  sb_clear();
  sb_text("Volatility context, ");
  if (front < 0) sb_text("no expiry after today");
  else {
    dateClock.update(optionsTradeDay(term.expiry(front), zone) * 86400.0); // the expiry's trade date, read as a date
    sb_text("front ");
    if (dateClock.dayOfMonth() < 10) sb_text("0");
    sb_int(dateClock.dayOfMonth());
    sb_text(MONTHS[dateClock.month() - 1]);
    sb_text(" in ");
    sb_int(i64(frontDays));
    sb_text(frontDays == 1.0 ? " day" : " days");
  }
  str_title_sb();
}

// onBar() runs once per bar: every bar feeds its daily block to the list (an empty one too); the live bar measures the
// chain and writes the readings (NaN on history rows) and the card's words.
function onBar(): void {
  close = bar.close();
  t = bar.time();
  days.load(in_daily_view(), in_daily_cells(), t);
  let measured = false;
  let chainCells = -1;
  if (bar.isLast()) {
    chainCells = in_chain_cells();
    if (chainCells >= TUPLE && !isNaN(close) && !isNaN(t)) {
      measure(chainCells);
      measured = term.expiries() > 0;
    }
  }
  out_iv30(measured ? atm30 * 100.0 : NaN);
  out_iv90(measured ? atm90 * 100.0 : NaN);
  out_term_slope(measured ? slope * 100.0 : NaN);
  out_rr25(measured ? (callWing - putWing) * 100.0 : NaN);
  out_skew_scaled(measured ? skewScaled : NaN);
  out_skew_days(measured ? skewDays : NaN);
  out_rv21(measured ? realized * 100.0 : NaN);
  out_iv_rv_ratio(measured ? overRealized : NaN);
  out_front_days(measured ? frontDays : NaN);
  out_front_move(measured ? frontMove : NaN);
  out_front_move_pct(measured ? frontMovePct : NaN);
  const live = bar.isLast();
  out_term_regime(measured && !isNaN(termCode()) ? termCode() : live ? 1.0 : NaN);
  out_skew_regime(measured && !isNaN(skewCode()) ? skewCode() : live ? 1.0 : NaN);
  out_ivrv_regime(measured && !isNaN(ratioCode()) ? ratioCode() : live ? 1.0 : NaN);
  if (measured) writeWords();
  else if (live) {
    // The live bar carried nothing to measure: the headline says why, so the card is never a row of blanks.
    sb_clear();
    sb_text(chainCells < TUPLE ? "Waiting for the options chain" : "No live expiry in the chain");
    str_read_sb();
    sb_clear();
    sb_text("Volatility context, no chain read");
    str_title_sb();
  }
}
```

## How it works

**The at-the-money term.** `IvTerm` reads every live expiry's at-the-money IV: the mean of the call and put IV at the strike nearest the expiry's forward, among the strikes within 20% of its underlying. On a US stock the forward is spot carried at the Rate setting (4% by default); a futures or crypto chain quotes its forward already, so the rate there is 0. `iv(30)` and `iv(90)` interpolate between the expiries around each horizon in total variance (IV squared times years), and an expiry that settles today never anchors, since its IV swings with the hours left. Past the last listed expiry a horizon reads the last expiry's IV. `term_slope` is `iv90 / iv30 - 1` in percent: over +1% is contango (the further month richer, the calm shape), under -1% backwardation (the near month richer, the stressed shape), flat between.

**The 25 delta skew.** At the expiry whose time to expiry is nearest 30 days, `deltaIv()` reads the call IV and the put IV where each side's delta is 0.25, interpolated between the two listed strikes around it. `skew_scaled` is the put IV minus the call IV, over the 30-day IV: above 0.06 the puts are rich (protection costs more than upside), below 0 the calls are rich, flat between. `rr25` is the same pair the other way round, call IV minus put IV in vol points, the risk reversal as a desk quotes it: a negative `rr25` and a positive `skew_scaled` say the same thing.

**IV against realized.** The `daily` input is the chart's own daily candles, the newest 64 closed ones, kept in a `CandleList` that every bar feeds. On the live bar the realized volatility is the sample standard deviation of the last `rv_days` (21) daily log returns, annualized over 252 trading days a year, or over 365 on crypto, which trades every day (the Realized vol year setting picks either by hand). `iv_rv_ratio` is the 30-day IV over it: above 1 the options price more movement than the market delivered.

**The front expiry.** The first expiry after today on the market's own calendar (`optionsTradeDay()`: New York on a US stock, Chicago on CME, UTC on crypto), its days to go, and its implied move: 0.85 of its at-the-money straddle, in price and in percent of its underlying.

**The card reads the outputs.** The headline joins the three verdicts that have a reading and takes the term's colour (red backwardation, slate flat, green contango); the three tiles under it carry each verdict with its number (`Contango, +6.20%`, `Puts rich, 0.083`, `IV over realized, 1.24x`), the front implied move in price, and eight numbered rows: the two IVs, the slope, the risk reversal, the scaled skew, the realized volatility, the ratio and the front move in percent. The title names the front expiry and its days to go (`Volatility context, front 16OCT in 1 day`).

## Where it runs

Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list): that is where the copy you run from the editor reads a chain. On a US stock chart `venue: "auto"` reads the chart's own OPRA chain, and on a CME futures chart the chain of its root; the chart serves both to OpenMarket's official wrun indicators and discloses the source in the legend. A copy you run from the editor or publish yourself does not read them yet. The daily candles are the chart's own market on every chart.

## When data is missing

The headline 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, and "No implied volatility in the chain" when no expiry has an at-the-money IV. Each tile says what it lacks: "No term", "No 25 delta wing" when the expiry nearest 30 days lists no strike on one side of the 25 delta point (a chain of near-the-money strikes only), "Needs 22 daily closes" while the stream holds fewer closes than the returns need, and "Realized vol is zero" when every daily return was the same. The rows then read `-` where a number has no reading.

## Customize it

- **A shorter or longer realized window.** Set `rv_days` to 10 for the last two weeks or 60 for a quarter; the stream holds 64 closed days.
- **Another year.** Set Realized vol year to `365` on a market that trades every day, or `252` to compare a crypto chart with a stock's numbers.
- **Another rate.** Change Rate, percent when short rates move; it only moves a US stock's forwards, so the at-the-money strike and the wings' deltas.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volatility Context** under **HUDs**.
2. Press **Run** on a BTC chart: the card appears at the bottom centre once the chain arrives, titled by the front expiry.
3. At the editor's Console prompt, type `iv30`, `term_slope` and `iv_rv_ratio` to read the term and the realized ratio on the live bar.

## Concepts used

- [Data sources](../core-concepts/data-sources.md#options-chain) for the `options_chain` celled class, its tuple and the `venue` word
- [Options kit](../functions/options-kit.md#the-at-the-money-term) for `IvTerm`, `chainPricedAtMs` and `optionsTradeDay`
- [Calendar periods and candle lists](../functions/higher-timeframe-kit.md#calendar-periods-and-candle-lists) for the `candles` stream and `CandleList`
- [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) and [Looks](../presentation/hud-and-hover-cards.md#looks) for the card, its pills and rows and the slot in the title
