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;
/** 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<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 {
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<number> {
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;

View File

@@ -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) {