diff --git a/backend/src/lib/datafetch.ts b/backend/src/lib/datafetch.ts index ddfe560..c93f0f9 100644 --- a/backend/src/lib/datafetch.ts +++ b/backend/src/lib/datafetch.ts @@ -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 { + // 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 }; +} diff --git a/backend/src/routes/options.ts b/backend/src/routes/options.ts index d1083e6..ff31c43 100644 --- a/backend/src/routes/options.ts +++ b/backend/src/routes/options.ts @@ -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")); diff --git a/frontend/chain.html b/frontend/chain.html index a8c1c4b..13837e2 100644 --- a/frontend/chain.html +++ b/frontend/chain.html @@ -104,6 +104,21 @@ Vol Surface + + + + + + + + + + + + + +