From 52af71d7f45bd78910b8d4dc752c713718f40e26 Mon Sep 17 00:00:00 2001 From: ojy Date: Wed, 13 May 2026 07:55:07 +0000 Subject: [PATCH] Vol Surface: top-right HV-vs-IV comparison card Adds a compact card in the page header that shows ATM IV alongside realized vol over 20/30/60-day windows, the IV-minus-HV spread in vol points, and a RICH/FAIR/CHEAP verdict (driven by IV/HV30 ratio: >=1.20x = RICH, <=0.80x = CHEAP, otherwise FAIR). Lets you eyeball whether options are priced rich relative to recent realized vol the moment the surface loads. - datafetch.ts: extract annualizedVolWindow helper; new fetchHistoricalVolWindows() returns hv20/hv30/hv60 from one ~90-day Yahoo historical pull - options.ts: /api/analytics includes hvWindows in response - surface.html: top-right hviv-card with per-window rows + footer showing IV/HV ratio and sample size Co-Authored-By: Claude Sonnet 4.6 --- backend/src/lib/datafetch.ts | 62 ++++++++++++----- backend/src/routes/options.ts | 6 +- frontend/surface.html | 122 ++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 19 deletions(-) diff --git a/backend/src/lib/datafetch.ts b/backend/src/lib/datafetch.ts index 622226e..36b5ada 100644 --- a/backend/src/lib/datafetch.ts +++ b/backend/src/lib/datafetch.ts @@ -19,44 +19,70 @@ const yf = new YahooFinance({ suppressNotices: ["yahooSurvey"] }); const RISK_FREE_RATE = 0.05; +/** Annualized stdev of the last N log returns (N trading-day window). 0 if not enough data. */ +function annualizedVolWindow(logReturns: number[], window: number): number { + if (logReturns.length < window) return 0; + const slice = logReturns.slice(-window); + const mean = slice.reduce((a, b) => a + b, 0) / slice.length; + const variance = slice.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (slice.length - 1); + return Math.sqrt(variance * 252); +} + +export type HvWindows = { + hv20: number; + hv30: number; + hv60: number; + /** Trading days of data actually used in the longest window (capped at the window size). */ + samples: { hv20: number; hv30: number; hv60: number }; +}; + /** - * Compute 30-day annualized realized volatility from daily closing prices. - * Used as the ATM IV baseline when options markets are closed / bid-ask are stale. + * Pull ~90 calendar days of daily closes once and compute HV20 / HV30 / HV60 + * (annualized log-return stdev for the last N trading days). */ -async function fetchHistoricalVol(symbol: string): Promise { +export async function fetchHistoricalVolWindows(symbol: string): Promise { + const empty: HvWindows = { hv20: 0, hv30: 0, hv60: 0, samples: { hv20: 0, hv30: 0, hv60: 0 } }; try { const end = new Date(); const start = new Date(); - start.setDate(start.getDate() - 45); // fetch 45 days to ensure 30 trading days - - const rows = await yf.historical(symbol, { - period1: start, - period2: end, - interval: "1d", - }); + start.setDate(start.getDate() - 95); // ~ 60 trading days + headroom + const rows = await yf.historical(symbol, { period1: start, period2: end, interval: "1d" }); const closes = rows .map((r) => r.adjClose ?? r.close) .filter((v): v is number => v != null && v > 0); - if (closes.length < 5) return 0; + if (closes.length < 5) return empty; const logReturns: number[] = []; for (let i = 1; i < closes.length; i++) { logReturns.push(Math.log(closes[i] / closes[i - 1])); } - const mean = logReturns.reduce((a, b) => a + b, 0) / logReturns.length; - const variance = - logReturns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / - (logReturns.length - 1); - - return Math.sqrt(variance * 252); // annualize + return { + hv20: annualizedVolWindow(logReturns, 20), + hv30: annualizedVolWindow(logReturns, 30), + hv60: annualizedVolWindow(logReturns, 60), + samples: { + hv20: Math.min(20, logReturns.length), + hv30: Math.min(30, logReturns.length), + hv60: Math.min(60, logReturns.length), + }, + }; } catch { - return 0; + return empty; } } +/** + * Compute 30-day annualized realized volatility — thin wrapper around + * fetchHistoricalVolWindows for the scanner's existing call sites. + */ +async function fetchHistoricalVol(symbol: string): Promise { + const w = await fetchHistoricalVolWindows(symbol); + return w.hv30; +} + function timeToExpiry(expiryDateStr: string): number { const daysRemaining = (new Date(expiryDateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24); return Math.max(daysRemaining, 0) / 365; diff --git a/backend/src/routes/options.ts b/backend/src/routes/options.ts index d7280a4..b78d324 100644 --- a/backend/src/routes/options.ts +++ b/backend/src/routes/options.ts @@ -7,7 +7,7 @@ */ import { Hono } from "hono"; -import { fetchOptionsChain, fetchExpirations, scanSymbol, fetchMovers, type MoverCategory } from "../lib/datafetch.js"; +import { fetchOptionsChain, fetchExpirations, scanSymbol, fetchMovers, fetchHistoricalVolWindows, type MoverCategory } from "../lib/datafetch.js"; /** How old a snapshot can be and still count for the term structure (more lenient than primary TTL). */ const TERM_STRUCTURE_TTL_MS = 30 * 60 * 1000; // 30 minutes @@ -326,6 +326,9 @@ optionsRouter.get("/analytics", async (c) => { }; })(); + // Historical (realized) vol windows for HV-vs-IV comparison + const hvWindows = await fetchHistoricalVolWindows(symbol); + return c.json( ok({ symbol, @@ -338,6 +341,7 @@ optionsRouter.get("/analytics", async (c) => { callIVs, putIVs, greeks, + hvWindows, }) ); } catch (err) { diff --git a/frontend/surface.html b/frontend/surface.html index 6815315..7295d4d 100644 --- a/frontend/surface.html +++ b/frontend/surface.html @@ -79,6 +79,73 @@ color: #ffd43b; } + /* HV vs IV comparison card (top-right of page header) */ + .hviv-card { + background: #161824; + border: 1px solid #2d3045; + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + min-width: 280px; + } + .hviv-card .hviv-head { + display:flex; align-items:center; justify-content:space-between; + border-bottom: 1px solid #2d3045; + padding-bottom: 0.35rem; + margin-bottom: 0.4rem; + } + .hviv-card .hviv-title { + color: #8b95a7; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .hviv-card .hviv-badge { + font-size: 0.7rem; + font-weight: 800; + padding: 0.15rem 0.5rem; + border-radius: 0.25rem; + letter-spacing: 0.04em; + } + .hviv-card .hviv-badge.rich { background:#ff6b6b; color:#1a1c2e; } + .hviv-card .hviv-badge.cheap { background:#51cf66; color:#1a1c2e; } + .hviv-card .hviv-badge.fair { background:#374151; color:#cbd3df; } + .hviv-card .hviv-row { + display: grid; + grid-template-columns: 60px 1fr auto; + gap: 0.5rem; + align-items: baseline; + padding: 0.15rem 0; + font-size: 0.85rem; + } + .hviv-card .hviv-row .label { + color: #8b95a7; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .hviv-card .hviv-row .val { + color: #fff; + font-weight: 700; + font-family: 'JetBrains Mono','Fira Code',monospace; + } + .hviv-card .hviv-row .spread { + font-family: 'JetBrains Mono','Fira Code',monospace; + font-size: 0.78rem; + font-weight: 600; + } + .hviv-card .hviv-row .spread.positive { color: #ff6b6b; } + .hviv-card .hviv-row .spread.negative { color: #51cf66; } + .hviv-card .hviv-row.atm .val { color: #ffd43b; } + .hviv-card .hviv-foot { + color: #6c757d; + font-size: 0.7rem; + margin-top: 0.25rem; + border-top: 1px dashed #2d3045; + padding-top: 0.3rem; + } + [x-cloak] { display: none !important; } @@ -279,6 +346,29 @@

Volatility Surface

IV skew analysis and term structure
+
+
+
+ HV vs IV + +
+
+ ATM IV + +   +
+ +
+
+
@@ -655,6 +745,7 @@ errorMsg: '', skewTable: [], currentMetrics: { atmIV: 0, rr25: 0, fly25: 0 }, + hvWindows: { hv20: 0, hv30: 0, hv60: 0, samples: { hv20: 0, hv30: 0, hv60: 0 } }, greeks: { atmCall: null, atmPut: null, itmCall: null, itmPut: null }, skewChartInstance: null, termChartInstance: null, @@ -753,6 +844,7 @@ }); this.greeks = data.greeks || { atmCall: null, atmPut: null, itmCall: null, itmPut: null }; + this.hvWindows = data.hvWindows || { hv20: 0, hv30: 0, hv60: 0, samples: { hv20: 0, hv30: 0, hv60: 0 } }; this.hasData = true; // Wait a tick for x-show to render the divs @@ -916,6 +1008,36 @@ return v >= 0 ? `+${pct}%` : `${pct}%`; }, + // HV vs IV comparison — rows for HV20/30/60 with IV-minus-HV spread (in vol points) + get hvRows() { + const iv = this.currentMetrics.atmIV || 0; + const w = this.hvWindows || {}; + return [ + { key: 'hv20', label: 'HV20', value: w.hv20 || 0, spread: iv - (w.hv20 || 0) }, + { key: 'hv30', label: 'HV30', value: w.hv30 || 0, spread: iv - (w.hv30 || 0) }, + { key: 'hv60', label: 'HV60', value: w.hv60 || 0, spread: iv - (w.hv60 || 0) }, + ]; + }, + + // Verdict: ATM IV vs HV30 — IV >20% above HV30 = RICH, >20% below = CHEAP, else FAIR. + get hvIvVerdict() { + const iv = this.currentMetrics.atmIV || 0; + const hv = this.hvWindows?.hv30 || 0; + if (!iv || !hv) return { label: 'N/A', cls: 'fair' }; + const ratio = iv / hv; + if (ratio >= 1.20) return { label: 'RICH', cls: 'rich' }; + if (ratio <= 0.80) return { label: 'CHEAP', cls: 'cheap' }; + return { label: 'FAIR', cls: 'fair' }; + }, + + get hvIvFooter() { + const iv = this.currentMetrics.atmIV || 0; + const hv = this.hvWindows?.hv30 || 0; + if (!iv || !hv) return 'IV/HV — n/a'; + const ratio = (iv / hv).toFixed(2); + return `IV/HV ratio ${ratio}× · HV30 over ${this.hvWindows?.samples?.hv30 ?? 0}d`; + }, + // Demo data for development / when API is unavailable _demoExpirations() { return [