IV Spike Scanner

Backend: GET /api/scan?symbols=SYM1,SYM2,... — for each symbol fetches
the front-expiry options chain plus 30-day realized vol and returns
{ spot, change, changePct, atmIv, hv30, ivHv, spike, expiry }. Spike flag
is on when IV/HV ≥ 1.5 or |today's % change| ≥ 3. Defaults to ~15 popular
tickers when no list is given; cap of 30 symbols/scan.

Frontend: new scanner.html page — symbol input (with "Use defaults" / "Use
watchlist" shortcuts), summary cards (count · spikes · biggest mover ·
highest IV/HV), sortable results table with spike rows highlighted, and
shortcut buttons to open each symbol on Chain or Surface.

Scanner added to all sidebars between Vol Surface and Strategy P/L.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 07:32:14 +00:00
parent 46574c4f51
commit 2cd3d6ece8
10 changed files with 488 additions and 1 deletions

View File

@@ -282,3 +282,86 @@ export async function fetchOptionsChain(
return snapshots;
}
// ---------------------------------------------------------------------------
// Lightweight per-symbol scan — for the IV-spike scanner
// ---------------------------------------------------------------------------
export type ScanResult = {
symbol: string;
spot: number;
change: number;
changePct: number;
atmIv: number;
hv30: number;
ivHv: number; // atmIv / hv30
spike: boolean; // true if atmIv/hv30 > 1.5 or |changePct| >= 3
expiry: string;
error?: string;
};
/** Fetch just the front-expiry chain + HV30 for a single symbol — cheap enough to parallelize. */
export async function scanSymbol(symbol: string): Promise<ScanResult> {
// 1) list of expirations
const exps: string[] = await fetchExpirations(symbol);
const now = Date.now();
const upcoming = exps.filter((e) => new Date(e).getTime() > now);
if (upcoming.length === 0) throw new Error("No upcoming expirations");
const expiry = upcoming[0];
// 2) the front-expiry chain + spot
let spot = 0, prevClose = 0;
let chain: OptionQuote[] = [];
if (fmpEnabled()) {
try {
const [fmpChain, q] = await Promise.all([fmpOptionsChain(symbol, expiry), fmpQuote(symbol)]);
if (q?.price) spot = q.price;
const T = Math.max(0, (new Date(expiry).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) / 365;
chain = fmpChain.map((o) => ({
strike: o.strike, expiry, type: o.type,
bid: o.bid, ask: o.ask,
iv: o.impliedVolatility, delta: o.delta, gamma: o.gamma, theta: o.theta, vega: o.vega,
volume: o.volume, openInterest: o.openInterest,
bsPrice: spot > 0 ? bsPrice(spot, o.strike, T, RISK_FREE_RATE, o.impliedVolatility, o.type) : 0,
midPrice: o.mid,
}));
} catch { /* fall through to yahoo */ }
}
if (chain.length === 0) {
const result: OptionsResult = await yf.options(symbol, { date: new Date(expiry) });
spot = result.quote?.regularMarketPrice ?? 0;
prevClose = result.quote?.regularMarketPreviousClose ?? spot;
const optExp = result.options?.[0];
const rawCalls = optExp?.calls ?? [];
const rawPuts = optExp?.puts ?? [];
chain = [
...rawCalls.map((r) => enrichOption(r, "call", expiry, spot)),
...rawPuts.map((r) => enrichOption(r, "put", expiry, spot)),
].filter((q): q is OptionQuote => q !== null);
} else if (!prevClose) {
prevClose = spot;
}
const change = spot - prevClose;
const changePct = prevClose > 0 ? (change / prevClose) * 100 : 0;
// ATM IV — closest-strike option's IV (pick the lower-IV side to avoid wing inflation)
let atmIv = 0;
if (chain.length > 0 && spot > 0) {
const closest = chain.reduce((b, q) =>
Math.abs(q.strike - spot) < Math.abs(b.strike - spot) ? q : b
);
const sameStrike = chain.filter((q) => q.strike === closest.strike && q.iv > 0);
atmIv = sameStrike.length > 1
? sameStrike.reduce((s, q) => s + q.iv, 0) / sameStrike.length
: (closest.iv || 0);
}
const hv30 = await fetchHistoricalVol(symbol);
const ivHv = hv30 > 0 ? atmIv / hv30 : 0;
const spike = ivHv >= 1.5 || Math.abs(changePct) >= 3;
return { symbol, spot, change, changePct, atmIv, hv30, ivHv, spike, expiry };
}

View File

@@ -7,7 +7,7 @@
*/
import { Hono } from "hono";
import { fetchOptionsChain, fetchExpirations } from "../lib/datafetch.js";
import { fetchOptionsChain, fetchExpirations, scanSymbol } 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
@@ -517,6 +517,37 @@ optionsRouter.delete("/orders/:id", (c) => {
return removed ? c.json(ok({ id, removed: true })) : c.json(fail("Order not found"), 404);
});
// ---------------------------------------------------------------------------
// IV-spike scanner — sweep a symbol list and return spot, % change, ATM IV,
// HV30 and IV/HV ratio for each. Highlight spikes where IV >> HV.
// ---------------------------------------------------------------------------
const DEFAULT_SCAN_SYMBOLS = [
"SPY","QQQ","IWM","DIA",
"AAPL","MSFT","NVDA","GOOGL","META","AMZN",
"TSLA","AMD","NFLX","COIN","INTC",
];
optionsRouter.get("/scan", async (c) => {
const symsParam = c.req.query("symbols");
const symbols = (symsParam ? symsParam.split(",") : DEFAULT_SCAN_SYMBOLS)
.map((s) => s.trim().toUpperCase()).filter(Boolean).slice(0, 30);
if (symbols.length === 0) return c.json(fail("No symbols to scan"), 400);
const results = await Promise.all(symbols.map(async (sym) => {
try {
return await scanSymbol(sym);
} catch (err) {
return {
symbol: sym, error: err instanceof Error ? err.message : String(err),
spot: 0, change: 0, changePct: 0, atmIv: 0, hv30: 0, ivHv: 0, spike: false, expiry: "",
};
}
}));
return c.json(ok({ count: results.length, results }));
});
// PATCH /api/orders/:id { status:'open'|'closed', note?:string }
optionsRouter.patch("/orders/:id", async (c) => {
const id = Number(c.req.param("id"));