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 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 07:55:07 +00:00
parent 2e565fae4d
commit 52af71d7f4
3 changed files with 171 additions and 19 deletions

View File

@@ -19,44 +19,70 @@ const yf = new YahooFinance({ suppressNotices: ["yahooSurvey"] });
const RISK_FREE_RATE = 0.05; 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. * Pull ~90 calendar days of daily closes once and compute HV20 / HV30 / HV60
* Used as the ATM IV baseline when options markets are closed / bid-ask are stale. * (annualized log-return stdev for the last N trading days).
*/ */
async function fetchHistoricalVol(symbol: string): Promise<number> { export async function fetchHistoricalVolWindows(symbol: string): Promise<HvWindows> {
const empty: HvWindows = { hv20: 0, hv30: 0, hv60: 0, samples: { hv20: 0, hv30: 0, hv60: 0 } };
try { try {
const end = new Date(); const end = new Date();
const start = new Date(); const start = new Date();
start.setDate(start.getDate() - 45); // fetch 45 days to ensure 30 trading days start.setDate(start.getDate() - 95); // ~ 60 trading days + headroom
const rows = await yf.historical(symbol, {
period1: start,
period2: end,
interval: "1d",
});
const rows = await yf.historical(symbol, { period1: start, period2: end, interval: "1d" });
const closes = rows const closes = rows
.map((r) => r.adjClose ?? r.close) .map((r) => r.adjClose ?? r.close)
.filter((v): v is number => v != null && v > 0); .filter((v): v is number => v != null && v > 0);
if (closes.length < 5) return 0; if (closes.length < 5) return empty;
const logReturns: number[] = []; const logReturns: number[] = [];
for (let i = 1; i < closes.length; i++) { for (let i = 1; i < closes.length; i++) {
logReturns.push(Math.log(closes[i] / closes[i - 1])); logReturns.push(Math.log(closes[i] / closes[i - 1]));
} }
const mean = logReturns.reduce((a, b) => a + b, 0) / logReturns.length; return {
const variance = hv20: annualizedVolWindow(logReturns, 20),
logReturns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / hv30: annualizedVolWindow(logReturns, 30),
(logReturns.length - 1); hv60: annualizedVolWindow(logReturns, 60),
samples: {
return Math.sqrt(variance * 252); // annualize hv20: Math.min(20, logReturns.length),
hv30: Math.min(30, logReturns.length),
hv60: Math.min(60, logReturns.length),
},
};
} catch { } 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<number> {
const w = await fetchHistoricalVolWindows(symbol);
return w.hv30;
}
function timeToExpiry(expiryDateStr: string): number { function timeToExpiry(expiryDateStr: string): number {
const daysRemaining = (new Date(expiryDateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24); const daysRemaining = (new Date(expiryDateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return Math.max(daysRemaining, 0) / 365; return Math.max(daysRemaining, 0) / 365;

View File

@@ -7,7 +7,7 @@
*/ */
import { Hono } from "hono"; 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). */ /** 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 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( return c.json(
ok({ ok({
symbol, symbol,
@@ -338,6 +341,7 @@ optionsRouter.get("/analytics", async (c) => {
callIVs, callIVs,
putIVs, putIVs,
greeks, greeks,
hvWindows,
}) })
); );
} catch (err) { } catch (err) {

View File

@@ -79,6 +79,73 @@
color: #ffd43b; 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] { [x-cloak] {
display: none !important; display: none !important;
} }
@@ -279,6 +346,29 @@
<h2 class="page-title">Volatility Surface</h2> <h2 class="page-title">Volatility Surface</h2>
<div class="text-secondary mt-1">IV skew analysis and term structure</div> <div class="text-secondary mt-1">IV skew analysis and term structure</div>
</div> </div>
<div class="col-auto" x-show="hasData" x-cloak>
<div class="hviv-card">
<div class="hviv-head">
<span class="hviv-title">HV vs IV</span>
<span class="hviv-badge" :class="hvIvVerdict.cls" x-text="hvIvVerdict.label"></span>
</div>
<div class="hviv-row atm">
<span class="label">ATM IV</span>
<span class="val" x-text="formatPct(currentMetrics.atmIV)"></span>
<span class="spread">&nbsp;</span>
</div>
<template x-for="w in hvRows" :key="w.key">
<div class="hviv-row">
<span class="label" x-text="w.label"></span>
<span class="val" x-text="w.value > 0 ? formatPct(w.value) : '—'"></span>
<span class="spread"
:class="w.value > 0 ? (w.spread >= 0 ? 'positive' : 'negative') : ''"
x-text="w.value > 0 ? ((w.spread >= 0 ? '+' : '') + (w.spread * 100).toFixed(1) + ' pts') : ''"></span>
</div>
</template>
<div class="hviv-foot" x-text="hvIvFooter"></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -655,6 +745,7 @@
errorMsg: '', errorMsg: '',
skewTable: [], skewTable: [],
currentMetrics: { atmIV: 0, rr25: 0, fly25: 0 }, 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 }, greeks: { atmCall: null, atmPut: null, itmCall: null, itmPut: null },
skewChartInstance: null, skewChartInstance: null,
termChartInstance: null, termChartInstance: null,
@@ -753,6 +844,7 @@
}); });
this.greeks = data.greeks || { atmCall: null, atmPut: null, itmCall: null, itmPut: null }; 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; this.hasData = true;
// Wait a tick for x-show to render the divs // Wait a tick for x-show to render the divs
@@ -916,6 +1008,36 @@
return v >= 0 ? `+${pct}%` : `${pct}%`; 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 // Demo data for development / when API is unavailable
_demoExpirations() { _demoExpirations() {
return [ return [