Variance risk premium
The chart's underlying's implied volatility against the volatility its price then delivered, from the daily options statistics and the chart's own daily candles.
Two readings, both in volatility points. The spread is today's 30-day IV minus the realized volatility of the last 21 daily returns: known at once, but it compares a forecast with the past. The premium is the IV of 21 sessions ago minus the realized volatility of the 21 daily returns that followed it: the honest comparison, known only 21 sessions later, so it is drawn on the session it completes and never on the session it starts. The premium's rank is its percentile among the window's earlier completed premiums: at or over the sell rank with a positive premium the options have paid their sellers (the sell zone, green); at or under the stand-down rank, or with a negative premium, they have not (red). A pane under the chart draws the IV and the realized volatility as step lines, the spread as columns and the premium as a line coloured by zone; a card at the bottom right reads the zone, the ratio of IV to realized and the numbers behind them.
The parts are a scalar option_daily input and a candles stream of the chart's own daily candles kept in a CandleList (Calendar periods and candle lists), two DayHistory rings from the options kit, one of the IV and one of the completed premiums (Options kit), a line coloured per bar by a zone output, and a render.hud card in the terminal look. This is also the vrp template: the Variance Risk Premium card under Beyond the time axis in the editor's starter list, and it compiles as written.
The wrun indicator
// Variance Risk Premium: the chart's underlying's implied volatility against the volatility its price then delivered,
// from the daily options statistics (option_daily, the 30-day at-the-money IV per session) and the chart's own daily
// candles. Two readings, both in volatility points. The spread is today's 30-day IV minus the realized volatility of
// the last 21 daily returns: known at once, but it compares the forecast with the past. The premium is the IV of 21
// sessions ago minus the realized volatility of the 21 daily returns that followed it: the honest comparison, known
// only 21 sessions later, so it is drawn on the session it completes, never on the session it starts. The premium's
// rank is its percentile among the window's earlier completed premiums; at or over the sell rank with a positive
// premium the options have paid their sellers (the sell zone, green), at or under the stand-down rank or with a
// negative premium they have not (the stand-down zone, red). A pane under the chart draws the IV and the realized
// volatility as step lines, the spread as columns and the premium as a line coloured by zone; a card at the bottom
// right reads the zone, the ratio and the numbers behind them. The statistics are short today (they start when the
// data plane switches the daily job on): the spread needs one session, the premium 22 sessions, and its rank Minimum
// history (60) completed premiums; until then the card says how many there are. US stock charts, daily or finer.
param.int("rv_days", 21, { min: 5, max: 63, label: "Realized window, days", description: "Daily log returns each realized volatility reads: 21 is about the month of trading days the 30-day IV covers" });
param.int("lookback", 252, { min: 20, max: 520, label: "Rank window, sessions", description: "Completed premiums the rank reads: 252 is about a year" });
param.int("min_sessions", 60, { min: 10, max: 520, label: "Minimum history, sessions", description: "Completed premiums before a rank shows; below it the card says how many there are" });
param.number("sell_rank", 50, { min: 0, max: 100, step: 1, label: "Sell zone rank", description: "A premium ranked at or over it, and above zero, is the sell zone" });
param.number("stand_rank", 20, { min: 0, max: 100, step: 1, label: "Stand-down rank", description: "A premium ranked at or under it, or below zero, is the stand-down zone" });
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
input("daily", candles.cells, { interval: "1d", bars: 400, description: "The chart's own closed daily candles, the newest 400 before the window and each one as it closes: the realized volatility" }); // [offset_ms, open, high, low, close, volume] per candle, oldest first
output("iv", line, lower, { color: "#a78bfa", width: 2, step: true, label: "IV 30d", format: "0.00", unit: "%", description: "The newest session's 30-day at-the-money IV, percent" });
output("rv", line, lower, { color: "#94a3b8", width: 2, step: true, label: "Realized", format: "0.00", unit: "%", description: "The realized volatility of the rv_days daily returns through the session the spread pairs, annualized over 252 days, percent" });
output("spread", histogram, lower, { color_by: "spread_sign", colors: ["#ef444499", "#22c55e99"], label: "IV minus realized", format: "0.00", description: "The session's 30-day IV minus the realized volatility through its close, volatility points" });
output("premium", line, lower, { color: "#e2e8f0", width: 2, step: true, color_by: "zone", colors: ["#ef4444", "#cbd5e1", "#22c55e"], label: "Premium", format: "0.00", description: "The IV of rv_days sessions ago minus the realized volatility that followed it, volatility points, drawn when it completes" });
output("zero", line, lower, { role: "guide", color: "#475569", label: "0", description: "The zero line" });
output("spread_sign", none, lower, { description: "1 when the IV is over the realized volatility, 0 under: the columns' colour" });
output("zone", none, lower, { description: "0 stand down, 1 neutral, 2 sell zone; NaN until the premium has a rank" });
output("prem_rank", none, lower, { description: "The newest completed premium's percentile among the window's earlier ones, 0 to 100" });
output("ratio", none, lower, { description: "The 30-day IV over the realized volatility" });
output("completed", none, lower, { description: "Completed premiums the rank window holds" });
output("status", none, lower, { description: "0 waiting (history or candles), 1 the premium has a rank, 2 a chart coarser than a day" });
string("read", { max_bytes: 64 }); // the headline: the zone, or why there is none
string("title", { max_bytes: 64 }); // the card's title: the session the readings are as of
string("spread_text", { max_bytes: 48 }); // "IV over realized by 3.20 pts, 1.21x"
render.hud("vrp_card", {
position: "bottom_right",
look: "terminal",
title: "{{title}}",
columns: 2,
tiles: [
tile.pill("Premium", "read", { headline: true, color_by: "zone", colors: ["#ef4444", "#94a3b8", "#22c55e"] }),
tile.pill("Spread", "spread_text", { color_by: "spread_sign", colors: ["#ef4444", "#22c55e"] }),
tile.value("Premium rank", "prem_rank", { format: "0" }),
tile.rows([
["1) IV, 30 days", "iv", "0.00"],
["2) Realized vol", "rv", "0.00"],
["3) IV minus realized", "spread", "0.00"],
["4) Premium, completed", "premium", "0.00"],
["5) Premiums ranked", "completed", "int"],
]),
],
});
const DAY = 86400.0;
const YEAR_DAYS = 252.0; // US listed options: their underlyings trade 252 days a year
const CAPACITY = 520; // the most sessions each history keeps
const MAX_CANDLES = 420; // the daily candles kept: the stream's backlog and the window's days
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 premiums = new DayHistory(CAPACITY); // one completed premium per session, keyed by the session it starts
const days = new CandleList(MAX_CANDLES); // the chart's closed daily candles
const dateClock = new Clock("UTC");
let rvDays = 21; // settings, read in onStart()
let lookback = 252;
let need = 60;
let sellRank = 50.0;
let standRank = 20.0;
let prevT: f64 = NaN; // the bar spacing
let barSec: f64 = NaN;
let pairedDay: f64 = NaN; // the newest session the spread has paired with its close
let doneDay: f64 = NaN; // the newest session whose premium completed (or can never complete)
let lastCandleT: f64 = NaN; // the newest candle seen: new work only when it moves
let rvNow: f64 = NaN; // the readings, held until the next session moves them
let spreadNow: f64 = NaN;
let ratioNow: f64 = NaN;
let premiumNow: f64 = NaN;
let rankNow: f64 = NaN;
function onStart(): void {
rvDays = i32(p_rv_days());
lookback = i32(p_lookback());
need = i32(p_min_sessions());
if (need > lookback) need = lookback;
sellRank = p_sell_rank();
standRank = p_stand_rank();
}
function candleDay(i: i32): f64 {
return Math.floor(days.openSec(i) / DAY);
}
/** The candle of day d, newest first; -1 when the list holds none. */
function candleOf(d: f64): i32 {
for (let i = days.count() - 1; i >= 0; i -= 1) {
const cd = candleDay(i);
if (cd == d) return i;
if (cd < d) return -1;
}
return -1;
}
/** The realized volatility of the daily log returns from candle first's close to candle last's, annualized, percent:
* the sample standard deviation, NaN under two returns or on a close that is not above 0. */
function realized(first: i32, last: i32): f64 {
const n = last - first;
if (n < 2 || first < 0) return NaN;
let sum = 0.0;
for (let k = first + 1; k <= last; 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(n);
let squares = 0.0;
for (let k = first + 1; k <= last; k += 1) {
const r = Math.log(days.close(k) / days.close(k - 1)) - mean;
squares += r * r;
}
return Math.sqrt((squares / f64(n - 1)) * YEAR_DAYS) * 100.0;
}
// ── The spread: the newest session's IV against the realized volatility through its close ──
function pairSpread(): void {
if (ivs.count() == 0) return;
const d = ivs.day(0);
if (d == pairedDay) return;
const at = candleOf(d);
if (at < 0) return; // its candle has not closed yet: the previous pairing holds
const rv = realized(at - rvDays, at);
pairedDay = d;
rvNow = rv;
const iv = ivs.value(0);
spreadNow = isNaN(rv) ? NaN : iv - rv;
ratioNow = rv > 0.0 ? iv / rv : NaN;
}
// ── The premium: each session's IV against the realized volatility of the rvDays returns after it ──
function completePremiums(): void {
const newest = days.count() - 1;
if (newest < 0) return;
const oldestDay = candleDay(0);
let moved = false;
for (let ago = ivs.count() - 1; ago >= 0; ago -= 1) { // oldest session first
const d = ivs.day(ago);
if (!isNaN(doneDay) && d <= doneDay) continue;
const at = candleOf(d);
if (at < 0) {
if (d < oldestDay) {
doneDay = d; // older than every kept candle: it can never complete
continue;
}
if (d < candleDay(newest)) {
doneDay = d; // no candle that day (a holiday on the chart's own calendar): skipped
continue;
}
break; // its candle has not closed yet, nor any later one
}
if (at + rvDays > newest) break; // not enough returns after it yet, nor after any later session
const rv = realized(at, at + rvDays);
doneDay = d;
if (!isNaN(rv) && premiums.push(d, ivs.value(ago) - rv)) moved = true;
}
if (!moved) return;
premiumNow = premiums.value(0);
rankNow = premiums.span(lookback) >= need ? premiums.percentile(lookback) : NaN;
}
function zoneOf(): f64 { // 0 stand down, 1 neutral, 2 sell; NaN with no rank
if (isNaN(rankNow)) return NaN;
if (premiumNow < 0.0 || rankNow <= standRank) return 0.0;
return rankNow >= sellRank ? 2.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 writeWords(coarse: bool): void {
const held = premiums.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 (isNaN(rankNow)) {
sb_text("Not enough history yet: ");
sb_int(held);
sb_text(" of ");
sb_int(need);
sb_text(" premiums");
} else {
const z = zoneOf();
if (z == 2.0) sb_text("Sell zone: premium rank ");
else if (z == 0.0) sb_text(premiumNow < 0.0 ? "Stand down: negative premium, rank " : "Stand down: premium rank ");
else sb_text("Neutral: premium rank ");
sb_f64(rankNow, 0);
}
str_read_sb();
sb_clear();
if (coarse || ivs.count() == 0) sb_text("No spread");
else if (isNaN(spreadNow)) {
sb_text("Needs ");
sb_int(rvDays + 1);
sb_text(" daily closes");
} else {
sb_text(spreadNow >= 0.0 ? "IV over realized by " : "IV under realized by ");
sb_f64(Math.abs(spreadNow), 2);
sb_text(" pts");
if (!isNaN(ratioNow)) {
sb_text(", ");
sb_f64(ratioNow, 2);
sb_text("x");
}
}
str_spread_text_sb();
sb_clear();
sb_text("Variance risk premium");
if (!coarse && ivs.count() > 0) {
sb_text(", as of ");
sbDay(ivs.day(0));
}
str_title_sb();
}
// onBar() runs once per bar: the bar spacing; every closed daily candle the bar carries; the session's IV when it lands;
// then the spread when its close is in and every premium that completed. Each bar writes the held readings.
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;
days.load(in_daily_view(), in_daily_cells(), t);
const iv = in_iv30();
let landed = false;
if (!coarse && !isNaN(iv) && iv > 0.0) landed = ivs.push(Math.floor(t / DAY), iv);
const newestT = days.count() > 0 ? days.openSec(days.count() - 1) : NaN;
if (!coarse && (landed || newestT != lastCandleT)) {
lastCandleT = newestT;
pairSpread();
completePremiums();
}
out_zero(0.0);
out_iv(coarse || ivs.count() == 0 ? NaN : ivs.value(0));
out_rv(coarse ? NaN : rvNow);
out_spread(coarse ? NaN : spreadNow);
out_spread_sign(coarse || isNaN(spreadNow) ? NaN : spreadNow >= 0.0 ? 1.0 : 0.0);
out_premium(coarse ? NaN : premiumNow);
out_zone(coarse ? NaN : zoneOf());
out_prem_rank(coarse ? NaN : rankNow);
out_ratio(coarse ? NaN : ratioNow);
out_completed(coarse ? NaN : f64(premiums.span(lookback)));
out_status(coarse ? 2.0 : isNaN(rankNow) ? 0.0 : 1.0);
if (bar.isLast()) writeWords(coarse);
}How it works
Realized volatility. The sample standard deviation of rv_days (21) daily log returns of the chart's own closes, annualized over 252 days, in percent: the same unit as the IV.
The spread pairs each session with its own close. The IV row of a session is its closing reading, so the spread waits for that session's daily candle: on a daily chart they arrive on the same bar; on an intraday chart the candle closes later, and until it does the previous pairing holds.
The premium completes 21 sessions later. Each session's IV is paired with the 21 returns after its close. On the bar the 21st of those candles closes, the premium of that session joins its own DayHistory (keyed by the session it starts on) and the line moves. A session with no candle of its own on the chart's calendar is skipped and never blocks the later ones.
The rank and the zone. percentile() over the newest lookback completed premiums. The zone is stand down when the premium is negative or the rank is at or under Stand-down rank (20), sell when the rank is at or over Sell zone rank (50), neutral between.
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), and the daily candles are the chart's own. 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, so the history is short. The spread reads from the first session (the candles reach back 400 days); the premium needs 22 sessions; its rank needs Minimum history (60) completed premiums, about 81 sessions in all. Until then the zone is empty and the card reads "Not enough history yet: 12 of 60 premiums". With fewer than 22 daily closes the spread pill reads "Needs 22 daily closes"; 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 horizon. Set
rv_daysto 10 for two weeks of returns, or 63 for a quarter (the premium then completes that many sessions later). - Another year.
lookbacksets how many completed premiums the rank reads. - Other zones. Move Sell zone rank and Stand-down rank to the bands you act on.
Run it
- In the editor's Explorer, press the Templates icon and pick Variance Risk Premium under Beyond the time axis.
- Press Run on a US stock's daily chart: the lines fill from the first session the statistics hold.
- At the editor's Console prompt, type
spread,premiumandprem_rank.
Concepts used
- Data sources for
option_daily - Calendar periods and candle lists for the
candlesstream andCandleList - Options kit for
DayHistory - HUD cards for the card