IV rank

View as MarkdownOpen the editor

Where the chart's underlying's implied volatility sits against its own past, from the daily options statistics.

The IV rank is where the session's 30-day at-the-money IV sits between the window's low (0) and its high (100); the IV percentile is the share of the window's earlier sessions whose IV was lower. A rank of 90 with a percentile of 60 says one spike stretched the range; a rank of 40 with a percentile of 85 says the year was mostly calm and today is not. Both are drawn as step lines in a pane under the chart, the rank coloured teal at or under the low band and amber at or over the high band, with dashed guides at the two bands, and a card at the bottom right reads the verdict, the IV, both readings, the window's low and high and the sessions behind them.

The parts are a scalar option_daily input declared with missing: "nan", so each session's row lands on one bar (Data sources), the options kit's DayHistory for the window's readings (Options kit), step lines coloured per bar by a band output and two guides (Plotting), and a render.hud card in the terminal look (HUD cards). This is also the iv-rank template: the IV Rank card under Beyond the time axis in the editor's starter list, and it compiles as written.

The wrun indicator

// IV Rank: where the chart's underlying's implied volatility sits against its own past, from the daily options
// statistics (option_daily). The IV rank is where the session's 30-day at-the-money IV sits between the window's low
// (0) and high (100); the IV percentile is the share of the window's earlier sessions whose IV was lower. Both are
// drawn as step lines in a pane under the chart, coloured by band (teal at or under the low band, amber at or over the
// high band), with dashed guides at the two bands, and a card at the bottom right reads the verdict, the IV, both
// readings, the window's low and high and the sessions behind them. Each session's reading is the statistics job's
// close of that session: it lands on its own bar on a daily chart and on the first bar of its day on an intraday
// chart, and the lines hold it until the next session's lands (the live session's row arrives after its close). The
// window is 252 sessions, about a year, and the history is short until the data plane has kept the statistics that
// long: below Minimum history (60 sessions) the card says how many sessions there are and the lines stay empty, so a
// few days are never ranked as a year. Between the minimum and a full window the card names the sessions it read.
// A chart coarser than a day reads one line saying so.

param.int("lookback", 252, { min: 20, max: 520, label: "Window, sessions", description: "Sessions the rank and the percentile read: 252 is about a year of trading days" });
param.int("min_sessions", 60, { min: 10, max: 520, label: "Minimum history, sessions", description: "Sessions of statistics before a reading shows; below it the card says how many there are" });
param.number("high_band", 80, { min: 50, max: 100, step: 1, label: "High band", description: "A rank at or over it is high: amber" });
param.number("low_band", 20, { min: 0, max: 50, step: 1, label: "Low band", description: "A rank at or under it is low: teal" });
input("close", ohlcv.close); // the chart's own close: the grid
input("iv30", option_daily.atm_iv_30d, { missing: "nan", description: "The underlying's 30-day constant-maturity at-the-money IV, percent, one row per session (the daily options statistics)" }); // lands on the first bar of its day, NaN elsewhere
output("iv_rank", line, lower, { color: "#f59e0b", width: 2, step: true, color_by: "band", colors: ["#2dd4bf", "#cbd5e1", "#f59e0b"], label: "IV rank", format: "0.0", description: "Where the 30-day IV sits between the window's low (0) and high (100); empty below Minimum history" });
output("iv_pct", line, lower, { color: "#818cf8", width: 1, step: true, line_style: "dotted", label: "IV percentile", format: "0.0", description: "The share of the window's earlier sessions with a lower 30-day IV, percent; empty below Minimum history" });
output("high_guide", line, lower, { role: "guide", color: "#f59e0b80", line_style: "dashed", label: "High band", align: "right", description: "The high band" });
output("low_guide", line, lower, { role: "guide", color: "#2dd4bf80", line_style: "dashed", label: "Low band", align: "right", description: "The low band" });
output("band", none, lower, { description: "0 low (rank at or under Low band), 1 between, 2 high (at or over High band); 1 while there is no rank" });
output("iv", none, lower, { description: "The newest session's 30-day at-the-money IV, percent" });
output("iv_low", none, lower, { description: "The lowest 30-day IV in the window, percent" });
output("iv_high", none, lower, { description: "The highest 30-day IV in the window, percent" });
output("sessions", none, lower, { description: "Sessions of statistics the window holds" });
output("status", none, lower, { description: "0 not enough history yet, 1 the rank reads, 2 a chart coarser than a day; NaN before the first session" });
string("read", { max_bytes: 64 }); // the headline: the verdict, or why there is none
string("title", { max_bytes: 64 }); // the card's title: the session the readings are as of

// The card, in the terminal look: the headline coloured by band, then the IV and the two readings, and the window as
// numbered rows.
render.hud("iv_rank_card", {
  position: "bottom_right",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("IV rank", "read", { headline: true, color_by: "band", colors: ["#2dd4bf", "#94a3b8", "#f59e0b"] }),
    tile.value("30-day IV", "iv", { format: "0.00" }),
    tile.value("IV rank", "iv_rank", { format: "0.0" }),
    tile.rows([
      ["1) IV percentile", "iv_pct", "0.0"],
      ["2) Window low", "iv_low", "0.00"],
      ["3) Window high", "iv_high", "0.00"],
      ["4) Sessions read", "sessions", "int"],
    ]),
  ],
});

const DAY = 86400.0;
const CAPACITY = 520; // the most sessions the history keeps: the window's largest setting
const MONTHS: String[] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const history = new DayHistory(CAPACITY); // one 30-day IV per session
const dateClock = new Clock("UTC"); // reads a date from its day number

let lookback = 252; // settings, read in onStart()
let need = 60;
let highBand = 80.0;
let lowBand = 20.0;
let prevT: f64 = NaN; // the bar spacing: the smallest gap seen between two bars
let barSec: f64 = NaN;
let held = 0; // the readings, measured when a session lands and held until the next one
let ivNow: f64 = NaN;
let ivLow: f64 = NaN;
let ivHigh: f64 = NaN;
let rank: f64 = NaN;
let pct: f64 = NaN;

function onStart(): void {
  lookback = i32(p_lookback());
  need = i32(p_min_sessions());
  if (need > lookback) need = lookback; // the window can never hold more than itself
  highBand = p_high_band();
  lowBand = p_low_band();
}

// ── The readings, measured on the bar a session lands ──
function measure(): void {
  held = history.span(lookback);
  ivNow = history.value(0);
  ivLow = history.min(lookback);
  ivHigh = history.max(lookback);
  const ready = held >= need;
  rank = ready ? history.rank(lookback) : NaN;
  pct = ready ? history.percentile(lookback) : NaN;
}

function bandOf(r: f64): f64 { // 0 low, 1 between, 2 high; 1 with no rank
  if (isNaN(r)) return 1.0;
  return r >= highBand ? 2.0 : r <= lowBand ? 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]);
}

// ── The card's words, on the live bar ──
function writeWords(coarse: bool): void {
  sb_clear();
  if (coarse) sb_text("Needs a daily or finer chart");
  else if (history.count() == 0) sb_text("Waiting for the daily options statistics");
  else if (held < need) {
    sb_text("Not enough history yet: ");
    sb_int(held);
    sb_text(" of ");
    sb_int(need);
    sb_text(" sessions");
  } else if (isNaN(rank)) sb_text("No range: the IV has not moved in the window");
  else {
    const b = bandOf(rank);
    sb_text(b == 2.0 ? "High IV: rank " : b == 0.0 ? "Low IV: rank " : "IV rank ");
    sb_f64(rank, 0);
    sb_text(", percentile ");
    sb_f64(pct, 0);
  }
  str_read_sb();
  sb_clear();
  sb_text("IV rank, 30-day IV");
  if (!coarse && history.count() > 0) {
    sb_text(", as of ");
    sbDay(history.day(0));
    if (held >= need && held < lookback) {
      sb_text(", ");
      sb_int(held);
      sb_text(" sessions");
    }
  }
  str_title_sb();
}

// onBar() runs once per bar: the bar spacing, then the session's IV when it lands (measured once, held after); every
// bar writes the held readings, so an intraday chart draws each session as a step.
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; // a weekly bar would read one session a week as a day
  const iv = in_iv30();
  if (!coarse && !isNaN(iv) && iv > 0.0 && history.push(Math.floor(t / DAY), iv)) measure();
  out_high_guide(highBand);
  out_low_guide(lowBand);
  const ready = !coarse && held >= need;
  out_iv_rank(ready ? rank : NaN);
  out_iv_pct(ready ? pct : NaN);
  out_band(ready ? bandOf(rank) : 1.0);
  out_iv(coarse ? NaN : ivNow);
  out_iv_low(coarse ? NaN : ivLow);
  out_iv_high(coarse ? NaN : ivHigh);
  out_sessions(coarse ? NaN : f64(held));
  out_status(coarse ? 2.0 : history.count() == 0 ? NaN : ready ? 1.0 : 0.0);
  if (bar.isLast()) writeWords(coarse);
}

How it works

One reading per session. The daily options statistics carry one row per session, the statistics job's reading at that session's close. The chart lands it on the session's own bar on a daily chart and on the first bar of its day on an intraday chart; every other bar reads NaN under missing: "nan". DayHistory.push(day, iv) keeps it under the bar's UTC day, so an intraday chart and a daily chart hold the same sessions, and a revised row for the same day replaces the reading instead of adding one.

The rank and the percentile. Over the newest lookback sessions (252 by default, about a year), rank() is (IV - low) / (high - low) x 100 and percentile() is the share of the earlier sessions strictly below today's, times 100. A window whose IV never moved has no range: the rank reads NaN and the card says so.

The lines hold. The readings are measured once, on the bar a session lands, and every later bar writes them again, so an intraday chart draws each session as a step until the next one lands. The live session's own row arrives after its close, so during the session the lines read the previous session.

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, under the US options access rule (Limitations). The class is declared ahead of the lanes that serve it, so the template builds today and the chart shows the lane's refusal until the data plane serves the statistics. A daily or finer chart: a weekly bar would count one session a week as a day.

When data is missing

The history is short until the data plane has kept the statistics for a year: they start the day the statistics job is switched on. Below Minimum history (60 sessions) the lines stay empty and the card reads "Not enough history yet: 12 of 60 sessions", so a few days are never ranked as if they were a year. Between the minimum and a full window the rank reads over the sessions there are, and the title names how many. Before the first session the card reads "Waiting for the daily options statistics"; a session whose row carries no IV is skipped, never counted; a chart coarser than a day reads "Needs a daily or finer chart".

Customize it

  • A shorter or longer year. Set lookback to 126 for half a year or 504 for two.
  • Rank sooner or later. Lower Minimum history to read a rank over fewer sessions, knowing what it is; raise it to wait for a full window.
  • Other bands. Move High band and Low band to colour the extremes where you trade them.

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick IV Rank under Beyond the time axis.
  2. Press Run on a US stock's daily chart: the pane fills from the first session the statistics hold, and the card says how many there are.
  3. At the editor's Console prompt, type iv_rank, iv_pct and sessions to read the newest readings.

Concepts used