---
title: "Volatility term structure"
description: "Implied volatility at one week (sky), one month (violet) and three months (teal) in their own pane. The gap between the one-week and three-month lines is…"
order: 99
section: "cookbook"
---

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

# Volatility term structure

![Implied volatility term structure pane, regime card and tenor curve](/wrun/images/vol-term-structure.png)

Implied volatility at one week (sky), one month (violet) and three months (teal) in their own pane. The gap between the one-week and three-month lines is tinted slate while the curve is normal and rose while it is inverted (one-week IV above three-month IV: options pricing near-term stress). On the price pane, a dot marks the bar where the curve flipped: rose when it inverted, slate when it turned normal again. Below the chart, a small curve of the three tenors now against a week ago. A card at the top right of the price pane names the regime (low, normal or high vol by the one-month IV's percentile rank over the lookback), the one-month IV and its rank, the curve's state and gap, the 25-delta one-month skew, a 48-bar sparkline of the one-month IV, and what the grey curve stands for.

The parts are three `implied_volatility` inputs at different tenors and a `skew` input ([Data sources](../core-concepts/data-sources.md)), a `range` between two outputs tinted by a data-only ladder ([Styling](../presentation/styling.md)), two `shape` outputs for the flip dots, a frame feeding a `panel.line` on a category axis, a `draw.card` with a sparkline ([Cards, frames and panels](../presentation/cards-frames-panels.md)) and a `render.label` for the one sentence on a coin without the series ([Plotting](../presentation/plotting.md)). This is also the `vol-term-structure` template: the **Volatility Term Structure** card under **Beyond the time axis** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=vol-term-structure
// Volatility Term Structure: implied volatility at one week, one month and three months in a pane, the gap between
// 1W and 3M shaded rose while the curve is inverted (near-term stress), a small curve chart of now against a week
// ago below the chart, and a card naming the regime, the IV rank and the 25-delta skew. The trader sees whether
// options price near-term stress or calm, and how that changed over the week. A coin without the series reads one
// sentence instead of an empty pane.

param.int("lookback", 180, { min: 20, max: 2000, label: "Rank lookback, bars", description: "Bars the IV rank is measured over" });
legend({ title: "({{lookback}})" }); // the words after the name in the legend: the lookback, read by name
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid the volatility series ride
input("one_week", implied_volatility.implied_volatility, { tenor: "ONE_W", description: "Implied volatility, one week" }); // carried between observations
input("one_month", implied_volatility.implied_volatility, { tenor: "ONE_M", description: "Implied volatility, one month" });
input("three_month", implied_volatility.implied_volatility, { tenor: "THREE_M", description: "Implied volatility, three months" });
input("skew_one_month", skew.skew, { tenor: "ONE_M", description: "25-delta skew, one month" });
const oneWeek = output("iv_1w", line, lower, { color: "#38bdf8", width: 2, label: "IV 1W", format: "0.0", description: "Implied volatility, one week" }); // the handle names it for the hover card
output("iv_1m", line, lower, { color: "#a78bfa", width: 2, label: "IV 1M", format: "0.0", description: "Implied volatility, one month" });
output("iv_3m", line, lower, { color: "#2dd4bf", width: 2, label: "IV 3M", format: "0.0", description: "Implied volatility, three months" });
output("inverted", none, lower, { description: "1 while one-week IV sits above three-month IV" }); // data-only: the shading's ladder and the card's tone
output("iv_rank", none, lower, { description: "Percentile rank of one-month IV over the lookback, 0 to 100" });
output("regime_tone", none, lower, { description: "0 low vol, 1 normal, 2 high vol" });
output("skew_25d", none, lower, { description: "25-delta skew, one month" });
hover(oneWeek, [block.value("IV 1W", "iv_1w", { format: "0.0" }), block.rows([["IV 1M", "iv_1m", "0.0"], ["IV 3M", "iv_3m", "0.0"], ["IV rank", "iv_rank", "int"], ["25d skew", "skew_25d", "0.00"]])]); // the card the one-week line opens under the cursor
output("stress_on", shape, overlay, { color: "#fb7185", width: 6, description: "The close of the bar where one-week IV crossed above three-month IV" }); // an event dot on price
output("stress_off", shape, overlay, { color: "#94a3b8", width: 6, description: "The close of the bar where the curve turned normal again" });
range("iv_1w", "iv_3m", { color_by: "inverted", colors: ["#94a3b81f", "#fb718538"] }); // the gap between 1W and 3M: a slate tint while normal, rose while inverted
string("regime_word", { max_bytes: 24 }); // "High vol"
string("curve_word", { max_bytes: 32 }); // "1W over 3M by 4.2"
string("compare_word", { max_bytes: 24 }); // what the grey curve is: a week ago, or the oldest bar loaded
string("notice", { max_bytes: 48 }); // the one sentence on a coin the volatility series do not cover, written on the live bar
render.label("notice_tag", { position: "top_right", text: "notice", color: "#94a3b8", style: "plain", offset: [12, 40] }); // under the pane's corner buttons
const curve_rows = frame("curve_rows", { max_bytes: 1024 }); // three tenor rows: [tenor, now, a week ago]
panel.line({ name: "term_curve", title: "Term structure: now against a week ago", x: "category", place: "below", frame: curve_rows, series: [{ name: "Now", color: "#38bdf8" }, { name: "A week ago", color: "#94a3b8" }] });
draw.card("vol_regime", {
  title: "Vol regime",
  anchor: "top_right",
  offset: [0, 40], // below the chart's own High tag, which always sits in the pane's top-right corner
  rows: [
    { label: "Regime", value: { text: "regime_word" }, color: { color_by: "regime_tone", colors: ["#94a3b8", "#38bdf8", "#fb7185"] } },
    { label: "1M IV", value: { output: "iv_1m" } },
    { label: "IV rank, 0 to 100", value: { output: "iv_rank", format: "int" } },
    { label: "Curve", value: { text: "curve_word" }, color: { color_by: "inverted", colors: ["#94a3b8", "#fb7185"] } },
    { label: "25d skew 1M", value: { output: "skew_25d" } },
    { label: "1M IV, 48 bars", spark: { output: "iv_1m", window: 48 } },
    { label: "Grey curve", value: { text: "compare_word" } },
  ],
});

const RING = 2048; const WEEK_SECONDS = 604800.0; // the rings hold the largest lookback and a week of 5m bars
const ringW = new StaticArray<f64>(RING); const ringM = new StaticArray<f64>(RING); const ringQ = new StaticArray<f64>(RING); // 1W, 1M, 3M per bar
let lookback = 180; let head = -1; let count = 0; // the ring: head is the newest bar
let t: f64 = NaN; let prevT: f64 = NaN; // this bar's open time and the previous one: together they give the bar width, so a week is counted in bars
let prevInverted: f64 = NaN; // the flip detector: the curve's state on the previous bar
let readingSeen = false; // some bar carried all three tenors: the market has the series

function round1(v: f64): f64 { return Math.round(v * 10.0) / 10.0; }

function onStart(): void { lookback = i32(p_lookback()); memory.grow(1); } // headroom for the live bar's frame string: the sandbox forbids growth after onStart()
// onBar() runs once per bar: read the three tenors and the skew, append to the rings, rank the one-month reading over the lookback;
// then the lines and the card numbers on every bar, the curve frame on the live bar only.
function onBar(): void {
  prevT = t; t = bar.time(); const close = bar.close();
  const ivW = in_one_week(); const ivM = in_one_month(); const ivQ = in_three_month(); const skew25 = in_skew_one_month(); // this bar's readings
  head = (head + 1) % RING; if (count < RING) count += 1;
  ringW[head] = ivW; ringM[head] = ivM; ringQ[head] = ivQ;
  if (isNaN(ivW) || isNaN(ivM) || isNaN(ivQ)) { // no reading on this bar: nothing is written; a coin never served one gets the sentence
    if (bar.isLast() && !readingSeen) { sb_clear(); sb_text("No implied volatility for this coin"); str_notice_sb(); }
    return;
  }
  readingSeen = true;
  let below = 0; let seen = 0; const span = lookback < count ? lookback : count;
  for (let k = 0; k < span; k += 1) { const v = ringM[(head + RING - k) % RING]; if (isNaN(v)) continue; seen += 1; if (v <= ivM) below += 1; }
  const rank = seen > 0 ? (100.0 * f64(below)) / f64(seen) : NaN; // the card's numbers: the rank, its tone and the curve's state
  const tone = isNaN(rank) ? 1.0 : rank < 20.0 ? 0.0 : rank > 80.0 ? 2.0 : 1.0;
  const inverted = ivW > ivQ ? 1.0 : 0.0;
  const stressOn = prevInverted == 0.0 && inverted == 1.0 ? close : NaN; // the event dots: the close on the bar the curve inverted on, NaN elsewhere
  const stressOff = prevInverted == 1.0 && inverted == 0.0 ? close : NaN; // the bar it turned normal on
  prevInverted = inverted;
  out_iv_1w(round1(ivW)); out_iv_1m(round1(ivM)); out_iv_3m(round1(ivQ)); out_inverted(inverted); out_iv_rank(rank); out_regime_tone(tone); out_skew_25d(isNaN(skew25) ? NaN : round1(skew25)); out_stress_on(stressOn); out_stress_off(stressOff);
  const width = isNaN(prevT) ? NaN : t - prevT; // the bar's width in seconds
  const weekBars = isNaN(width) || width <= 0.0 ? 0 : i32(Math.round(WEEK_SECONDS / width)); // bars in a week on this chart
  const back = weekBars > 0 && weekBars < count ? weekBars : count - 1; // a week back, or the oldest bar loaded
  const ago = (head + RING - back) % RING;
  sb_clear(); sb_text(tone == 0.0 ? "Low vol" : tone == 2.0 ? "High vol" : "Normal vol"); str_regime_word_sb();
  sb_clear(); sb_text(inverted == 1.0 ? "1W over 3M by " : "1W under 3M by "); sb_f64(Math.abs(ivW - ivQ), 1); str_curve_word_sb();
  sb_clear(); if (back == weekBars) sb_text("a week ago"); else { sb_text("oldest, "); sb_int(back); sb_text(" bars back"); } str_compare_word_sb();
  if (bar.isLast()) {
    writeFrame(FRAME_CURVE_ROWS, "{\"rows\":[[\"1W\"," + round1(ivW).toString() + "," + (isNaN(ringW[ago]) ? "null" : round1(ringW[ago]).toString()) +
      "],[\"1M\"," + round1(ivM).toString() + "," + (isNaN(ringM[ago]) ? "null" : round1(ringM[ago]).toString()) +
      "],[\"3M\"," + round1(ivQ).toString() + "," + (isNaN(ringQ[ago]) ? "null" : round1(ringQ[ago]).toString()) + "]]}");
  }
}
```

## How it works

**Tenors are inputs.** `input("one_week", implied_volatility.implied_volatility, { tenor: "ONE_W" })` and its ONE_M and THREE_M twins ride the chart's grid, carried between observations; `skew.skew` at ONE_M is the 25-delta skew. `iv_1w`, `iv_1m` and `iv_3m` plot in the lower pane; `skew_25d` is data-only.

**Inversion is a ladder.** `inverted` is 1 while one-week IV sits above three-month IV; `range("iv_1w", "iv_3m", { color_by: "inverted", colors: [slate, rose] })` tints the gap from it. `stress_on` carries the close on the bar the curve inverted (a rose dot on price) and `stress_off` the close where it turned normal again; both are NaN elsewhere.

**Rank is the regime.** `iv_rank` is the percentile rank of one-month IV over `lookback` (180) bars, 0 to 100; `regime_tone` reads 0 low vol, 1 normal, 2 high vol from it, and the card's title takes the tone. The card's value cells are text slots written on the live bar; the one-month IV feeds its 48-bar sparkline.

**A week ago is counted in bars.** The bar width from consecutive `bar.time()` readings turns a week into a bar count; `frame("curve_rows")` carries the three tenors now and a week ago and feeds `panel.line` on a category axis below the chart.

## Where it runs

BTC and ETH charts on any venue (the volatility series follow the chart's coin). On a Polymarket market the lanes answer with BTC's series (the chart has no coin, so the lane falls back to BTC): the pane then shows BTC's curve, not the market's.

## When data is missing

Other coins, FX and stocks have no volatility series: the run stops with the toast `Implied volatility data is unavailable for '<title>' (the deribit_implied_volatility source lane answered empty or was declined), so the indicator cannot compute.`, where `<title>` is the tab's title. If a chart of such a coin does run, no bar carries a reading and nothing is drawn: the live bar writes the one sentence `No implied volatility for this coin` at the top right of the pane, 40 px down so it clears the pane's corner buttons (`render.label("notice_tag", { position: "top_right", text: "notice", offset: [12, 40] })`), instead of leaving an empty pane. Bars before the first volatility observation draw nothing; when the loaded history is shorter than a week the grey curve is the oldest bar loaded and the card's "Grey curve" row says "oldest, N bars back".

## Customize it

- **Other tenors.** The chart serves five tenors, `ONE_W`, `ONE_M`, `TWO_M`, `THREE_M` and `SIX_M`; this example reads three of them, and `ONE_D`, `THREE_D` and `ONE_Y` are refused by name. For a two-tenor view, drop one input together with its line and its labels.
- **Another wing.** The card's skew is the one-month skew at 25 delta, the default; `delta: 5`, `15` or `35` on the `skew` input reads another.
- **A longer rank.** `lookback` up to 2000 bars.
- **A month ago.** Change the bar count behind the grey curve from a week to a month.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volatility Term Structure** under **Beyond the time axis**.
2. Press **Run** on a BTC or ETH chart: the three tenors draw in their pane, the curve below the chart and the card at the top right.
3. At the editor's Console prompt, type `last 20 iv_rank` to read the one-month IV's rank over the last 20 bars.

## Concepts used

- [Data sources](../core-concepts/data-sources.md) for the `implied_volatility` and `skew` sources and the `tenor` word
- [Styling](../presentation/styling.md) for `range` bands and colour ladders
- [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `panel.line`, `draw.card` and sparklines
- [Plotting](../presentation/plotting.md) for `render.label` pinned by `position`, the one sentence
