Volatility regime

View as MarkdownOpen the editor

Whether the chart's underlying's implied volatility is high, normal or low for that name, and how unsettled it is, from the daily options statistics.

The regime score is a z-score: the session's 30-day IV against the mean and standard deviation of the window's earlier sessions, high over +0.5, low under -0.5, neutral between, drawn as columns in a pane under the chart with guides at both edges. The stress class bins the IV's percentile among the window's earlier sessions: calm under the 55th, transitional from the 55th to the 80th, stressed from the 80th up, and the candles take its colour (teal, amber, red) when Tint candles is on. The vol of vol is how much the IV itself moves: the exponentially weighted standard deviation of its daily log changes over the last 30 sessions, the newest weighing most, annualized over 252 days, in percent, with its own percentile. A card at the bottom right reads the regime, the stress class and the numbers behind them.

The parts are a scalar option_daily input, two DayHistory rings from the options kit (the IV and the vol of vol, Options kit), regime-coloured columns and guides, a render.barcolor tint (Plotting), and a render.hud card with a sparkline tile. This is also the vol-regime template: the Volatility Regime card under Beyond the time axis in the editor's starter list, and it compiles as written.

The wrun indicator

// Volatility Regime: whether the chart's underlying's implied volatility is high, normal or low for that name, and
// how unsettled it is, from the daily options statistics (option_daily, the 30-day at-the-money IV per session). The
// regime score is a z-score: the session's IV against the mean and standard deviation of the window's earlier
// sessions, high over +0.5, low under -0.5, neutral between, drawn as columns in a pane under the chart with guides at
// both edges. The stress class bins the IV's percentile among the window's earlier sessions: calm under the 55th,
// transitional from the 55th to the 80th, stressed from the 80th up, and the candles take its colour (teal, amber,
// red) when Tint candles is on. The vol of vol is how much the IV itself moves: the exponentially weighted standard
// deviation of its daily log changes over the last Vol of vol sessions (recent changes weigh more), annualized over
// 252 days, in percent, with its own percentile. A card at the bottom right reads the regime, the stress class and the
// numbers behind them. Each session's reading is the statistics job's close of that session, held on an intraday
// chart until the next one lands. The statistics are short today (they start when the data plane switches the daily
// job on): every reading needs Minimum history (60 sessions), and until then the card says how many there are. US
// stock charts, daily or finer.

param.int("lookback", 252, { min: 20, max: 520, label: "Window, sessions", description: "Sessions the score, the percentiles and the stress class read: 252 is about a year" });
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.int("vov_days", 30, { min: 5, max: 90, label: "Vol of vol, sessions", description: "Daily IV changes the vol of vol weighs, the newest weighing most" });
param.bool("tint_candles", true, { label: "Tint candles", description: "Colour the candles by the stress class: teal calm, amber transitional, red stressed" });
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("score", histogram, lower, { color_by: "regime", colors: ["#2dd4bf", "#94a3b8", "#ef4444"], label: "Regime score", format: "0.00", description: "The session's 30-day IV against the window's earlier sessions, in standard deviations; empty below Minimum history" });
output("high_guide", line, lower, { role: "guide", color: "#ef444480", line_style: "dashed", label: "High", align: "right", description: "+0.5: over it the regime is high" });
output("low_guide", line, lower, { role: "guide", color: "#2dd4bf80", line_style: "dashed", label: "Low", align: "right", description: "-0.5: under it the regime is low" });
output("regime", none, lower, { description: "0 low (score under -0.5), 1 neutral, 2 high (over +0.5); 1 while there is no score" });
output("stress", none, lower, { description: "0 calm (IV percentile under 55), 1 transitional (55 to 80), 2 stressed (80 and up); NaN while there is no percentile" });
output("tint_on", none, lower, { description: "1 on a bar the candles take the stress colour: the tint's gate" });
output("iv", none, lower, { description: "The newest session's 30-day at-the-money IV, percent" });
output("iv_pct", none, lower, { description: "The newest IV's percentile among the window's earlier sessions, 0 to 100" });
output("vov", none, lower, { description: "The vol of vol: the weighted deviation of the daily IV changes, annualized, percent" });
output("vov_pct", none, lower, { description: "The newest vol of vol's percentile among the window's earlier sessions, 0 to 100" });
output("sessions", none, lower, { description: "Sessions of statistics the window holds" });
output("status", none, lower, { description: "0 not enough history yet, 1 the regime reads, 2 a chart coarser than a day" });
string("read", { max_bytes: 64 }); // the headline
string("stress_text", { max_bytes: 32 }); // "Stressed, 86th percentile"
string("title", { max_bytes: 64 });
render.barcolor("stress_tint", { where: "tint_on", color_by: "stress", colors: ["#2dd4bf", "#f59e0b", "#ef4444"] }); // the candles in the stress colour

render.hud("regime_card", {
  position: "bottom_right",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Regime", "read", { headline: true, color_by: "regime", colors: ["#2dd4bf", "#94a3b8", "#ef4444"] }),
    tile.pill("Stress", "stress_text", { color_by: "stress", colors: ["#2dd4bf", "#f59e0b", "#ef4444"] }),
    tile.spark("Vol of vol", "vov", { bars: 64 }),
    tile.rows([
      ["1) IV, 30 days", "iv", "0.00"],
      ["2) Regime score", "score", "0.00"],
      ["3) IV percentile", "iv_pct", "0"],
      ["4) Vol of vol", "vov", "0.0"],
      ["5) Vol of vol percentile", "vov_pct", "0"],
      ["6) Sessions read", "sessions", "int"],
    ]),
  ],
});

const DAY = 86400.0;
const YEAR_DAYS = 252.0;
const HIGH = 0.5; // the score's edges
const LOW = -0.5;
const TRANSITIONAL = 55.0; // the stress class's percentile edges
const STRESSED = 80.0;
const CAPACITY = 520;
const MONTHS: String[] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const ivs = new DayHistory(CAPACITY); // one 30-day IV per session
const vovs = new DayHistory(CAPACITY); // one vol of vol per session it could be measured
const dateClock = new Clock("UTC");

let lookback = 252; // settings, read in onStart()
let need = 60;
let vovDays = 30;
let tint = true;
let prevT: f64 = NaN; // the bar spacing
let barSec: f64 = NaN;
let scoreNow: f64 = NaN; // the session readings, held
let ivPctNow: f64 = NaN;
let vovNow: f64 = NaN;
let vovPctNow: f64 = NaN;

function onStart(): void {
  lookback = i32(p_lookback());
  need = i32(p_min_sessions());
  if (need > lookback) need = lookback;
  vovDays = i32(p_vov_days());
  tint = p_tint_candles() > 0.5;
}

/** The vol of vol over the newest vovDays daily log changes of the IV, each weighing (1 - a)^k with k sessions back
 *  and a = 2 / (vovDays + 1), annualized, percent; NaN until vovDays + 1 sessions are held. */
function volOfVol(): f64 {
  if (ivs.count() < vovDays + 1) return NaN;
  const keep = 1.0 - 2.0 / f64(vovDays + 1);
  let weight = 1.0;
  let sumW = 0.0;
  let sumSq = 0.0;
  for (let k = 0; k < vovDays; k += 1) {
    const r = Math.log(ivs.value(k) / ivs.value(k + 1));
    sumSq += weight * r * r;
    sumW += weight;
    weight *= keep;
  }
  return Math.sqrt((sumSq / sumW) * YEAR_DAYS) * 100.0;
}

function measure(day: f64): void {
  const v = volOfVol();
  if (!isNaN(v)) vovs.push(day, v);
  vovNow = v;
  const ready = ivs.span(lookback) >= need;
  scoreNow = ready ? ivs.zscore(lookback) : NaN;
  ivPctNow = ready ? ivs.percentile(lookback) : NaN;
  vovPctNow = ready && !isNaN(v) && vovs.span(lookback) >= 2 ? vovs.percentile(lookback) : NaN;
}

function regimeOf(): f64 {
  if (isNaN(scoreNow)) return 1.0;
  return scoreNow > HIGH ? 2.0 : scoreNow < LOW ? 0.0 : 1.0;
}

function stressOf(): f64 {
  if (isNaN(ivPctNow)) return NaN;
  return ivPctNow >= STRESSED ? 2.0 : ivPctNow >= TRANSITIONAL ? 1.0 : 0.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 writeWords(coarse: bool): void {
  const held = ivs.span(lookback);
  sb_clear();
  if (coarse) sb_text("Needs a daily or finer chart");
  else if (ivs.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(scoreNow)) sb_text("No spread: the IV has not moved in the window");
  else {
    const r = regimeOf();
    sb_text(r == 2.0 ? "High regime, score " : r == 0.0 ? "Low regime, score " : "Neutral regime, score ");
    if (scoreNow >= 0.0) sb_text("+");
    sb_f64(scoreNow, 2);
  }
  str_read_sb();
  sb_clear();
  const s = stressOf();
  if (coarse || isNaN(s)) sb_text("No stress class");
  else {
    sb_text(s == 2.0 ? "Stressed, " : s == 1.0 ? "Transitional, " : "Calm, ");
    sb_f64(ivPctNow, 0);
    sb_text(" percentile");
  }
  str_stress_text_sb();
  sb_clear();
  sb_text("Volatility regime, 30-day IV");
  if (!coarse && ivs.count() > 0) {
    sb_text(", as of ");
    sbDay(ivs.day(0));
  }
  str_title_sb();
}

// onBar() runs once per bar: the spacing, then the session's IV when it lands (measured once, held after); every bar
// writes the held readings, and the candle takes the stress colour.
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;
  const iv = in_iv30();
  if (!coarse && !isNaN(iv) && iv > 0.0) {
    const day = Math.floor(t / DAY);
    if (ivs.push(day, iv)) measure(day);
  }
  out_high_guide(HIGH);
  out_low_guide(LOW);
  out_score(coarse ? NaN : scoreNow);
  out_regime(coarse ? 1.0 : regimeOf());
  const s = coarse ? NaN : stressOf();
  out_stress(s);
  out_tint_on(tint && !isNaN(s) ? 1.0 : 0.0);
  out_iv(coarse || ivs.count() == 0 ? NaN : ivs.value(0));
  out_iv_pct(coarse ? NaN : ivPctNow);
  out_vov(coarse ? NaN : vovNow);
  out_vov_pct(coarse ? NaN : vovPctNow);
  out_sessions(coarse ? NaN : f64(ivs.span(lookback)));
  out_status(coarse ? 2.0 : ivs.span(lookback) >= need ? 1.0 : 0.0);
  if (bar.isLast()) writeWords(coarse);
}

How it works

The score. zscore() reads the newest IV against the window's earlier sessions, so today never dampens its own score. The edges are fixed at plus and minus 0.5 standard deviations.

The stress class. percentile() is the share of the window's earlier sessions with a lower IV; the class edges at the 55th and 80th percentiles colour the candles through a gate output, so turning Tint candles off leaves the chart's own colours.

The vol of vol. Over the newest vov_days daily log changes of the IV, each weighing (1 - a) to the power of its age in sessions with a = 2 / (vov_days + 1): recent changes count most. It is measured once per session and joins its own DayHistory for its percentile.

Where it runs

US stock charts, through OpenMarket's official package: option_daily is served by the chart's browser lane for the chart's own underlying (Limitations). The class is declared ahead of its lane, 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.

When data is missing

The statistics start the day the job is switched on. The score, the percentile and the stress tint need Minimum history (60 sessions); until then the columns stay empty, the candles keep their colours and the card reads "Not enough history yet: 12 of 60 sessions". The vol of vol needs 31 sessions of its own. A window whose IV never moved has no spread and says so; 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 year. lookback sets how many sessions the score and the percentiles read.
  • A faster or slower vol of vol. vov_days of 10 reacts within two weeks, 90 over a quarter.
  • Keep the candles. Turn Tint candles off to read the regime in the pane alone.

Run it

  1. In the editor's Explorer, press the Templates icon and pick Volatility Regime under Beyond the time axis.
  2. Press Run on a US stock's daily chart.
  3. At the editor's Console prompt, type score, iv_pct and vov.

Concepts used