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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user