IV term structure
At-the-money implied volatility for every listed expiry of the chart's own option chain, drawn below the chart as one curve over days to expiry: the price options put on a move, horizon by horizon.
A step up from one expiry to the next is contango and keeps the curve teal; a step down is backwardation and the orange Inverted series draws over that stretch, so an event the market prices for one date (an earnings release, a Fed decision) shows as an orange drop after it. The 30 and 90 day points are constant maturities, interpolated between the listed expiries around them and pinned on the curve as callouts ("30d 14.9%"), and the chip on the title row names the regime with the slope between them ("Contango +8.7%"). The caption counts the expiries on the curve and reads the 30 day realized volatility of the chart's own daily closes. A card at the top left, in the glass look, names the regime first (contango, flat or backwardation), then the two constant maturities, the slope, and the 30 day IV against the 30 day realized volatility ("14.9% vs 11.2%, 1.33x"): above 1x, options price more movement than the market has been making.
The parts are a celled options_chain.cells input with venue: "auto", read on the live bar (Data sources): on a US stock or ETF chart the stock's OPRA chain, on a CME futures chart the CME chain of its root, on a coin's chart its Deribit chain; one candles stream of the chart's market at 1d for the realized volatility (Multi-timeframe); a param.int setting labelled Longest expiry, days (Setting kinds); one frame feeding a panel.line over a number axis with point callouts and a chip written per run (Cards, frames and panels); and a render.hud card of a headline tile.pill coloured by an output and a tile.rows with dotted leaders (HUD cards). This is also the iv-term-structure template: the IV Term Structure card under Beyond the time axis in the editor's starter list, and it compiles as written. The Volatility term structure recipe is a different thing: Deribit's own 1W, 1M and 3M IV series over time on BTC and ETH.
The wrun indicator
// IV Term Structure: the chart's own option chain as at-the-money implied volatility for every listed expiry, drawn
// below the chart as one curve over days to expiry. Each expiry's ATM IV is the average of the call and put IV at the
// two strikes around that expiry's own underlying, read between them at the underlying. A step up to the next expiry is
// contango and keeps the curve teal; a step down is backwardation and draws over it in orange, so an inverted stretch
// (near-dated options dearer than later ones, the market paying up for a move soon) stands out at a glance. The 30 and
// 90 day points are constant maturities, interpolated in total variance (IV squared times time) between the listed
// expiries around them, and pinned on the curve as callouts. The card at the top left names the regime first
// (contango, flat or backwardation, from the 90 day IV over the 30 day IV, flat within 1%), then the two constant
// maturities, the slope between them and the 30 day IV against the 30 day realized volatility of the chart's own daily
// closes. On a US stock or ETF chart the chain is OPRA's, on a CME futures chart the CME chain of its root, on a coin's
// chart its Deribit chain. The chain is live only: history bars draw nothing; everything is measured on the last bar,
// from the chain's own clock.
section("Expiries");
param.int("max_days", 365, { min: 30, max: 1100, label: "Longest expiry, days", description: "Expiries further out than this many days stay off the curve (the 30 and 90 day points still use them)" });
input("close", ohlcv.close); // the chart's own close: the fallback 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 a futures chart, Deribit on a coin" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract
input("days", candles.cells, { interval: "1d", bars: 60, description: "The newest 60 closed daily candles: the 30 day realized volatility" });
output("atm_iv_30d", none, overlay, { format: "%", description: "30 day constant-maturity ATM implied volatility, percent: total variance interpolated between the listed expiries around 30 days; live bar only" });
output("atm_iv_90d", none, overlay, { format: "%", description: "90 day constant-maturity ATM implied volatility, percent; live bar only" });
output("term_slope", none, overlay, { format: "%", description: "The 90 day ATM IV over the 30 day ATM IV, minus 1, in percent: above 0 contango, below 0 backwardation" });
output("term_code", none, overlay, { description: "0 backwardation (90 day IV more than 1% under the 30 day), 1 flat, 2 contango (more than 1% over)" });
output("front_iv", none, overlay, { format: "%", description: "ATM implied volatility of the nearest expiry at least 12 hours out, percent" });
output("expiry_count", none, overlay, { description: "Expiries on the curve" });
output("hv_30", none, overlay, { format: "%", description: "30 day realized volatility of the chart's daily closes: the annualized standard deviation of daily log returns, percent" });
output("iv_hv_ratio", none, overlay, { description: "The 30 day ATM IV over the 30 day realized volatility: above 1 options price more movement than the market has made" });
string("regime_word", { max_bytes: 16 }); // the card's headline: "Contango"
string("slope_text", { max_bytes: 16 }); // "+8.7%"
string("iv_hv_text", { max_bytes: 32 }); // "14.9% vs 11.2%, 1.33x"
string("notice", { max_bytes: 64 }); // the one sentence when the chain lists no usable expiry
const termRows = frame("term_rows", { max_bytes: 16384 }); // one row per expiry: [days, ATM IV, ATM IV where the step is down]
// The curve: days to expiry across, ATM IV up, the inverted steps drawn over the teal curve in orange; the value axis,
// the caption, the regime chip and the 30 and 90 day callouts are written per run into the frame.
panel.line({ name: "term", title: "IV term structure", x: "number", place: "below", frame: termRows, height_frac: 0.3, chrome: "grid", hover_card: true, legend_style: "chips", format: "%", decimals: 1, x_title: "Days to expiry", x_format: "auto", x_decimals: 0, x_unit: "d", y_title: "ATM implied volatility", badge: { text: "Term", color: "#94a3b8" }, series: [
{ name: "ATM IV", color: "#2dd4bf", width: 2, points: true },
{ name: "Inverted", color: "#f86800", width: 2.5 },
] });
// The card, in the glass look at the top left under the legend: the regime first (orange backwardation, slate flat,
// teal contango), then four rows with dotted leaders.
render.hud("term_card", {
position: "top_left",
safe_area: true,
look: "glass",
title: "IV term structure",
columns: 1,
width: 250,
tiles: [
tile.pill("Term structure", "regime_word", { headline: true, color_by: "term_code", colors: ["#f86800", "#94a3b8", "#2dd4bf"] }),
tile.rows([
["ATM IV, 30 days", "atm_iv_30d", "%"],
["ATM IV, 90 days", "atm_iv_90d", "%"],
["Slope, 90d over 30d", "slope_text"],
["IV vs HV, 30 days", "iv_hv_text"],
], { leader: "dots" }),
],
});
render.label("notice", { position: "top_right", text: "notice", offset: [12, 8], style: "knockout", color: "#94a3b8", size: 12 });
const TUPLE = 10; // f64s per contract
const MAX_EXPIRIES = 64; // distinct live expiries a chain lists
const MAX_STRIKES = 512; // strikes one expiry lists
const DAY_MS = 86400000.0;
const YEAR_DAYS = 365.0; // constant maturities are calendar days
const MIN_AHEAD_MS = 43200000.0; // an expiry inside 12 h is settling, not a point on the curve
const MIN_ANCHOR_DAYS = 1.0; // an expiry under a day out never anchors a constant maturity
const FLAT = 0.01; // the regime's flat band: the 90 day IV within 1% of the 30 day
const HV_DAYS = 30;
const DAY_SEC: f64 = 86400.0;
const REGIME_WORDS: StaticArray<string> = ["Backwardation", "Flat", "Contango"];
const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the chain's live expiries, sorted ascending
const termDays = new StaticArray<f64>(MAX_EXPIRIES); // per curve point: days to expiry and ATM IV, percent
const termIv = new StaticArray<f64>(MAX_EXPIRIES);
const kStrike = new StaticArray<f64>(MAX_STRIKES); // one expiry's strikes, sorted, with each side's IV and open interest
const kCall = new StaticArray<f64>(MAX_STRIKES);
const kPut = new StaticArray<f64>(MAX_STRIKES);
const kCallOi = new StaticArray<f64>(MAX_STRIKES);
const kPutOi = new StaticArray<f64>(MAX_STRIKES);
const dayClose = new StaticArray<f64>(HV_DAYS + 1); // the newest 31 daily closes, oldest first
const dayOpen = new StaticArray<f64>(HV_DAYS + 1);
let maxDays = 365.0; // the setting, read in onStart()
let expiryCount = 0;
let pointCount = 0;
let kCount = 0;
let closes = 0; // daily closes held, at most HV_DAYS + 1
let weekendDays = 0; // weekend days among every day seen: a market that trades them annualizes by 365
let seenDays = 0;
let fresh = false; // a day arrived since the realized volatility was last measured
let iv30: f64 = NaN;
let iv90: f64 = NaN;
let slope: f64 = NaN;
let code: f64 = NaN;
let front: f64 = NaN;
let hv30: f64 = NaN;
function onStart(): void {
maxDays = p_max_days();
}
// ── The daily closes: the newest 31, for the 30 day realized volatility ──
function foldDays(t: f64): void {
const block = in_days_view();
const cells = in_days_cells(); // six f64 per candle: [offset_ms, open, high, low, close, volume]; -1 or 0 carries none
const n = cells < block.length ? cells : block.length;
for (let i = 0; i + 6 <= n; i += 6) {
const close = block[i + 4];
if (!(close > 0.0)) continue;
const open = t + block[i] / 1000.0;
if (closes > 0 && open <= dayOpen[closes - 1]) continue;
if (closes == HV_DAYS + 1) {
for (let k = 1; k <= HV_DAYS; k += 1) {
dayClose[k - 1] = dayClose[k];
dayOpen[k - 1] = dayOpen[k];
}
closes = HV_DAYS;
}
dayClose[closes] = close;
dayOpen[closes] = open;
closes += 1;
seenDays += 1;
fresh = true;
if ((i64(Math.floor(open / DAY_SEC)) + 3) % 7 >= 5) weekendDays += 1; // 1970-01-01 was a Thursday: 5 and 6 are the weekend
}
}
// The sample standard deviation of the 30 newest daily log returns, annualized by the sessions a year, in percent.
function realizedVol(): f64 {
if (closes < HV_DAYS + 1) return NaN;
let mean = 0.0;
for (let k = 1; k <= HV_DAYS; k += 1) mean += Math.log(dayClose[k] / dayClose[k - 1]);
mean /= f64(HV_DAYS);
let v = 0.0;
for (let k = 1; k <= HV_DAYS; k += 1) {
const d = Math.log(dayClose[k] / dayClose[k - 1]) - mean;
v += d * d;
}
const annual = weekendDays * 10 > seenDays ? 365.0 : 252.0;
return Math.sqrt((v / f64(HV_DAYS - 1)) * annual) * 100.0;
}
// ── The chain's live expiries, sorted ──
function noteExpiry(ms: f64): void {
let lo = 0;
let hi = expiryCount;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (expiryMs[mid] < ms) lo = mid + 1;
else hi = mid;
}
if (lo < expiryCount && expiryMs[lo] == ms) return;
if (expiryCount >= MAX_EXPIRIES) return;
for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1];
expiryMs[lo] = ms;
expiryCount += 1;
}
// ── One expiry's strikes: sorted insertion; a side listed twice keeps the contract with more open interest ──
function noteStrike(strike: f64, call: bool, iv: f64, oi: f64): void {
let lo = 0;
let hi = kCount;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (kStrike[mid] < strike) lo = mid + 1;
else hi = mid;
}
if (lo == kCount || kStrike[lo] != strike) {
if (kCount >= MAX_STRIKES) return;
for (let i = kCount; i > lo; i -= 1) {
kStrike[i] = kStrike[i - 1];
kCall[i] = kCall[i - 1];
kPut[i] = kPut[i - 1];
kCallOi[i] = kCallOi[i - 1];
kPutOi[i] = kPutOi[i - 1];
}
kStrike[lo] = strike;
kCall[lo] = NaN;
kPut[lo] = NaN;
kCallOi[lo] = -1.0;
kPutOi[lo] = -1.0;
kCount += 1;
}
if (call) {
if (oi > kCallOi[lo]) {
kCall[lo] = iv;
kCallOi[lo] = oi;
}
} else if (oi > kPutOi[lo]) {
kPut[lo] = iv;
kPutOi[lo] = oi;
}
}
function strikeIv(i: i32): f64 { // the strike's call and put IV averaged, or the one side it lists
const c = kCall[i];
const p = kPut[i];
if (isNaN(c)) return p;
if (isNaN(p)) return c;
return 0.5 * (c + p);
}
// The expiry's ATM IV, percent: the strike IV read linearly at its underlying between the two strikes around it, the
// strike's own when it sits on one; NaN when the underlying is off the listed strikes. IV arrives in percentage points.
function atmIv(cells: StaticArray<f64>, n: i32, expiry: f64, fallback: f64): f64 {
kCount = 0;
let spot: f64 = NaN;
for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
if (cells[i + 1] != expiry) continue;
const strike = cells[i];
const iv = cells[i + 6];
if (!(strike > 0.0) || !(iv > 0.0)) continue;
if (isNaN(spot) && cells[i + 7] > 0.0) spot = cells[i + 7]; // the expiry's own underlying (a CME expiry has its own future)
noteStrike(strike, cells[i + 2] > 0.0, iv, cells[i + 3]);
}
if (isNaN(spot)) spot = fallback;
if (kCount == 0 || !(spot >= kStrike[0]) || !(spot <= kStrike[kCount - 1])) return NaN;
let lo = 0;
while (lo + 1 < kCount && kStrike[lo + 1] <= spot) lo += 1;
if (kStrike[lo] == spot || lo + 1 == kCount) return strikeIv(lo);
const a = strikeIv(lo);
const b = strikeIv(lo + 1);
return a + ((b - a) * (spot - kStrike[lo])) / (kStrike[lo + 1] - kStrike[lo]);
}
// The constant-maturity ATM IV at `days`, percent: total variance (IV squared times years) interpolated linearly in
// time between the curve points around it, each at least a day out (the kit's totalVarianceIv, decimals in and out);
// NaN outside them.
function constantMaturity(days: f64): f64 {
let lo = -1;
let hi = -1;
for (let i = 0; i < pointCount; i += 1) {
if (termDays[i] < MIN_ANCHOR_DAYS || isNaN(termIv[i])) continue;
if (termDays[i] <= days) lo = i;
if (termDays[i] >= days && hi < 0) hi = i;
}
if (lo < 0 || hi < 0) return NaN;
if (hi == lo) return termIv[lo];
const t1 = termDays[lo] / YEAR_DAYS;
const t2 = termDays[hi] / YEAR_DAYS;
return totalVarianceIv(t1, termIv[lo] / 100.0, t2, termIv[hi] / 100.0, days / YEAR_DAYS) * 100.0;
}
// ── The chain's clock: when its gammas were priced, not the bar's open ──
// The chart prices each contract's gamma by Black-Scholes from its mark IV and underlying at the moment it reads the
// chain, so the contract nearest the money (|delta| nearest 0.5), solved for the time to expiry its gamma implies,
// names that moment. A venue that serves its own greeks gives a reading near its own pricing time; anything that is not
// a plausible reading falls back to the bar's open.
function chainClockMs(cells: StaticArray<f64>, n: i32, barOpenMs: f64): f64 {
let best = -1;
let bestGap = 0.25; // |delta| within 0.25 of 0.5
let nearest = Infinity; // the nearest listed expiry: the chain lists none that has passed
for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
if (cells[i + 1] < nearest) nearest = cells[i + 1];
const gap = Math.abs(Math.abs(cells[i + 5]) - 0.5);
if (cells[i] > 0.0 && cells[i + 4] > 0.0 && cells[i + 6] > 0.0 && cells[i + 7] > 0.0 && gap < bestGap) {
bestGap = gap;
best = i;
}
}
if (best < 0) return barOpenMs;
const sigma = cells[best + 6] / 100.0; // mark IV rides the chain in percentage points a year
const m = Math.log(cells[best + 7] / cells[best]);
const q = cells[best + 4] * cells[best + 7]; // gamma times spot = pdf(d1) / u, u = sigma * sqrt(T), d1 = m / u + u / 2
let lo = Math.sqrt(2.0 * (Math.sqrt(1.0 + m * m) - 1.0)); // where pdf(d1) / u peaks: past it the gamma falls as T grows
let hi = 10.0;
for (let k = 0; k < 80; k += 1) { // bisection on the falling side
const u = 0.5 * (lo + hi);
const d1 = m / u + 0.5 * u;
if (Math.exp(-0.5 * d1 * d1) / (2.5066282746310002 * u) > q) lo = u;
else hi = u;
}
const u = 0.5 * (lo + hi);
const clock = cells[best + 1] - ((u * u) / (sigma * sigma)) * 31536000000.0;
return clock > barOpenMs - 86400000.0 && clock < nearest ? clock : barOpenMs; // anything else is no reading
}
// ── The chain, measured on the live bar ──
function measure(cells: StaticArray<f64>, n: i32, close: f64, barOpenMs: f64): void {
const nowMs = chainClockMs(cells, n, barOpenMs);
expiryCount = 0;
pointCount = 0;
for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs + MIN_AHEAD_MS) noteExpiry(cells[i + 1]);
for (let e = 0; e < expiryCount; e += 1) {
const iv = atmIv(cells, n, expiryMs[e], close);
if (isNaN(iv)) continue;
termDays[pointCount] = (expiryMs[e] - nowMs) / DAY_MS;
termIv[pointCount] = iv;
pointCount += 1;
}
iv30 = constantMaturity(30.0);
iv90 = constantMaturity(90.0);
slope = !isNaN(iv30) && !isNaN(iv90) && iv30 > 0.0 ? (iv90 / iv30 - 1.0) * 100.0 : NaN;
code = isNaN(slope) ? NaN : slope > FLAT * 100.0 ? 2.0 : slope < -FLAT * 100.0 ? 0.0 : 1.0;
front = pointCount > 0 ? termIv[0] : NaN;
}
// ── The frame: the curve, the inverted steps over it, the callouts and the chip ──
function inverted(i: i32): bool { return i > 0 && termIv[i] < termIv[i - 1]; } // the step into point i is down
function writeCurve(): void {
let shown = 0;
while (shown < pointCount && termDays[shown] <= maxDays) shown += 1;
let yLo = Infinity;
let yHi = -Infinity;
for (let i = 0; i < shown; i += 1) {
if (termIv[i] < yLo) yLo = termIv[i];
if (termIv[i] > yHi) yHi = termIv[i];
}
fb_clear();
fb_text("{\"rows\":[");
for (let i = 0; i < shown; i += 1) {
if (i > 0) fb_text(",");
// A point carries the inverted series when a step on either side of it is down; a rising step between two
// inverted ones gets a break row just past its first point, so the orange never bridges a teal step.
const onInverted = inverted(i) || (i + 1 < shown && inverted(i + 1));
fb_text("[");
fb_f64(termDays[i], 2);
fb_text(",");
fb_f64(termIv[i], 2);
fb_text(",");
if (onInverted) fb_f64(termIv[i], 2);
else fb_text("null");
fb_text("]");
if (i + 1 < shown && !inverted(i + 1) && inverted(i) && i + 2 < shown && inverted(i + 2)) {
const x = termDays[i] + 0.01;
const y = termIv[i] + ((termIv[i + 1] - termIv[i]) * 0.01) / (termDays[i + 1] - termDays[i]);
fb_text(",[");
fb_f64(x, 2);
fb_text(",");
fb_f64(y, 2);
fb_text(",null]");
}
}
fb_text("]");
if (shown > 0) {
const pad = (yHi - yLo) * 0.15 > 1.0 ? (yHi - yLo) * 0.15 : 1.0;
fb_text(",\"y_min\":");
fb_f64(yLo - pad < 0.0 ? 0.0 : yLo - pad, 1);
fb_text(",\"y_max\":");
fb_f64(yHi + pad, 1);
}
fb_text(",\"caption\":\"");
fb_int(shown);
fb_text(shown == 1 ? " expiry" : " expiries");
if (!isNaN(hv30)) {
fb_text(", HV 30d ");
fb_f64(hv30, 1);
fb_text("%");
}
fb_text("\",\"badge\":{\"text\":\"");
if (isNaN(code)) fb_text("Term n/a\",\"color\":\"#94a3b8\"}");
else {
fb_text(REGIME_WORDS[i32(code)]);
fb_text(" ");
if (slope >= 0.0) fb_text("+");
fb_f64(slope, 1);
fb_text(code == 2.0 ? "%\",\"color\":\"#2dd4bf\"}" : code == 0.0 ? "%\",\"color\":\"#f86800\"}" : "%\",\"color\":\"#94a3b8\"}");
}
fb_text(",\"markers\":[");
let marks = 0;
for (let m = 0; m < 2; m += 1) {
const days = m == 0 ? 30.0 : 90.0;
const iv = m == 0 ? iv30 : iv90;
if (isNaN(iv) || days > maxDays) continue;
if (marks > 0) fb_text(",");
fb_text("{\"x\":");
fb_int(<i64>days);
fb_text(",\"y\":");
fb_f64(iv, 2);
fb_text(",\"valign\":\"point\",\"label\":\"");
fb_int(<i64>days);
fb_text("d ");
fb_f64(iv, 1);
fb_text("%\",\"color\":\"theme.text\",\"badge\":true}");
marks += 1;
}
fb_text("]}");
writeFrameBuffer(FRAME_TERM_ROWS);
}
// onBar() runs once per bar: fold the closed days this bar carries; history rows carry an empty chain block and write
// NaN; the live bar measures the chain, writes the readings, the frame and the card's words.
function onBar(): void {
const close = bar.close();
foldDays(bar.time());
if (fresh) {
hv30 = realizedVol();
fresh = false;
}
let read = false;
if (bar.isLast() && !isNaN(close)) {
const n = in_chain_cells();
if (n >= TUPLE) {
measure(in_chain_view(), n, close, bar.time() * 1000.0);
read = pointCount > 0;
}
}
out_atm_iv_30d(read ? iv30 : NaN);
out_atm_iv_90d(read ? iv90 : NaN);
out_term_slope(read ? slope : NaN);
out_term_code(read ? code : NaN);
out_front_iv(read ? front : NaN);
out_expiry_count(read ? f64(pointCount) : NaN);
out_hv_30(hv30);
out_iv_hv_ratio(read && hv30 > 0.0 ? iv30 / hv30 : NaN);
if (!bar.isLast()) return;
sb_clear();
if (read && !isNaN(code)) sb_text(REGIME_WORDS[i32(code)]);
else sb_text("n/a");
str_regime_word_sb();
sb_clear();
if (read && !isNaN(slope)) {
if (slope >= 0.0) sb_text("+");
sb_f64(slope, 1);
sb_text("%");
} else sb_text("n/a");
str_slope_text_sb();
sb_clear();
if (read && !isNaN(iv30) && hv30 > 0.0) {
sb_f64(iv30, 1);
sb_text("% vs ");
sb_f64(hv30, 1);
sb_text("%, ");
sb_f64(iv30 / hv30, 2);
sb_text("x");
} else sb_text("n/a");
str_iv_hv_text_sb();
sb_clear();
if (read) writeCurve();
else sb_text("No listed expiry to read on this chain yet");
str_notice_sb();
}How it works
The chain's clock. The chain is the newest snapshot, on the live bar only, so measure() runs once there. Days to expiry count from the moment the chain was priced, not the bar's open: the contract nearest the money is solved for the time to expiry its gamma implies (a coarse bar's open would stretch every day count), with the bar's open as the fallback when no reading is plausible. Expiries inside 12 hours are settling and stay off the curve.
ATM IV per expiry. For each listed expiry atmIv() gathers every strike's call and put mark IV (a side listed twice keeps the contract with more open interest), averages the two sides where both list, and reads that average linearly at the expiry's own underlying between the two strikes around it. The underlying is the expiry's own (on CME each expiry tracks its own future), the chart's close when the chain carries none; an underlying off the listed strikes reads nothing for that expiry. Mark IV arrives in percentage points and stays in them.
Constant maturities. The 30 and 90 day points interpolate total variance, IV squared times years, linearly in time between the listed expiries around the target, then take the root back over the target's own years (the options kit's totalVarianceIv, the step IvTerm.iv takes between two anchors): the same horizon reads the same whichever expiries happen to list around it. An expiry under a day out never anchors a constant maturity, and a target outside the anchors reads NaN (n/a on the card). term_slope is the 90 day IV over the 30 day IV minus 1, in percent; the regime is contango above +1%, backwardation below -1%, flat between, and term_code carries it as 2, 0 or 1.
Contango and backwardation steps. The frame's rows are [days, ATM IV, inverted]: every point carries the curve, and a point beside a falling step carries the Inverted series too, so the orange line draws exactly the falling steps. Where a rising step sits between two falling ones, a break row just past its first point carries the curve and a null for the inverted series, so the orange never bridges a teal step. The value axis is pinned per run with a margin, the callouts carry their exact y, and the chip is the frame's badge.
Realized volatility. The daily stream keeps the newest 31 closes; hv_30 is the sample standard deviation of their 30 log returns, annualized by 365 on a market whose days include weekends and by 252 elsewhere, in percent, the same reading as the Volatility character's hv_30. iv_hv_ratio is the 30 day ATM IV over it.
The card reads outputs and slots. The headline pill reads regime_word and takes its colour from term_code through a colors ladder (orange, slate, teal); the rows read atm_iv_30d and atm_iv_90d in percent and the slope_text and iv_hv_text slots.
Where it runs
As OpenMarket's official package, every chart with an option chain: US stock and ETF charts read their OPRA chain (US listed options, delayed 15 minutes), CME futures charts the CME chain of the root, and a coin's chart its Deribit chain (BTC, ETH, SOL and the rest of its list) on any venue. A copy run from the editor reads the crypto chains only: the US stock and CME chains reach OpenMarket's own indicators only, and the official IV Term Structure package is not on the registry yet (it will be). A market with no chain is refused by name before any fetch. max_cells: 4000 holds a full Deribit chain (a BTC chain is about 1,550 contracts); on a governed chain (OPRA, CME) the chart serves the expiries that fit at 8 to 80 strikes each side of the money, which is all a term structure reads.
When data is missing
The chain is live only: history bars carry an empty block and write NaN for every chain reading, while hv_30 follows the daily closes on every bar. A chain with no expiry past 12 hours writes no frame and one sentence at the top right; an expiry whose strikes do not reach its underlying is left off the curve; a constant maturity outside the listed expiries reads n/a, and so do the slope and the regime. The realized volatility needs 31 closed days.
Customize it
- A shorter or longer curve. Longest expiry, days (365) keeps further expiries off the curve; the 30 and 90 day points still interpolate from them, and a callout past the limit is left off.
- Other constant maturities.
constantMaturity(30.0)andconstantMaturity(90.0)inmeasure()are the two horizons; any calendar day count works the same way. - Pin a venue.
venue: "auto"reads the chart's own chain; a venue's word ("deribit","cme") pins one.
Run it
- In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick IV Term Structure under Beyond the time axis.
- Press Run on BTCUSDT on Binance Futures: the curve of Deribit's expiries fills below the chart and the card names the regime. On SPY or an ES chart the editor's copy does not read the chain; the official package that does is not on the registry yet.
- At the editor's Console prompt, type
atm_iv_30dfor the 30 day point on the live bar, oroutputsfor every reading together.
Concepts used
- Data sources for the
options_chaintuple,venue,max_cellsand the live-bar-only block - Options kit for the chain's units: mark IV in percentage points, the multiplier
- Multi-timeframe for the daily
candlesstream - Cards, frames and panels for
panel.lineon anumberaxis, point markers, the per-run badge and value axis - HUD cards and Blocks and tiles for
tile.pillwithcolor_byandtile.rows