diff --git a/backend/src/db/snapshots.ts b/backend/src/db/snapshots.ts index 62f2fbb..06b987b 100644 --- a/backend/src/db/snapshots.ts +++ b/backend/src/db/snapshots.ts @@ -94,6 +94,24 @@ export function getLatestSnapshot(symbol: string, expiry: string): SnapshotRow | return (stmtGetLatest.get(symbol, expiry) as SnapshotRow | undefined) ?? null; } +const stmtAvgAtmIv = db.prepare(` + SELECT AVG(atm_iv) AS avg_iv, COUNT(*) AS n + FROM snapshots + WHERE symbol = ? + AND atm_iv IS NOT NULL AND atm_iv > 0 + AND timestamp >= datetime('now', ?) +`); + +/** + * Average ATM IV for a symbol over the last `days` days (across all expiries). + * Returns null if there are fewer than 2 qualifying snapshots. + */ +export function getAverageAtmIv(symbol: string, days = 30): { avg: number; n: number } | null { + const row = stmtAvgAtmIv.get(symbol, `-${Math.max(1, Math.round(days))} days`) as { avg_iv: number | null; n: number } | undefined; + if (!row || !row.avg_iv || row.n < 2) return null; + return { avg: row.avg_iv, n: row.n }; +} + export function getDb(): Database.Database { return db; } diff --git a/backend/src/lib/datafetch.ts b/backend/src/lib/datafetch.ts index c93f0f9..bce8aed 100644 --- a/backend/src/lib/datafetch.ts +++ b/backend/src/lib/datafetch.ts @@ -2,6 +2,8 @@ import YahooFinance from "yahoo-finance2"; import type { OptionsResult, CallOrPut } from "yahoo-finance2/modules/options"; import { bsPrice, bsGreeks, impliedVol } from "./blackscholes.js"; import type { OptionQuote, ChainSnapshot } from "./analytics.js"; +import { computeSkewMetrics } from "./analytics.js"; +import { saveSnapshot, getAverageAtmIv } from "../db/snapshots.js"; import { fmpEnabled, fmpExpirations, @@ -9,6 +11,10 @@ import { fmpQuote, } from "./fmp.js"; +/** Spike threshold — current ATM IV must be at least this much above the baseline. */ +const SPIKE_JUMP_PCT = 0.30; // +30% +const SPIKE_BIG_MOVE_PCT = 3; // |today Δ| ≥ 3% + const yf = new YahooFinance({ suppressNotices: ["yahooSurvey"] }); const RISK_FREE_RATE = 0.05; @@ -294,8 +300,13 @@ export type ScanResult = { changePct: number; atmIv: number; hv30: number; - ivHv: number; // atmIv / hv30 - spike: boolean; // true if atmIv/hv30 > 1.5 or |changePct| >= 3 + baselineIv: number; // recent-30d avg ATM IV from snapshots, else HV30 + baselineSrc: "history" | "hv30" | "none"; + baselineN: number; // # of snapshot rows feeding the baseline (0 = HV30 fallback) + ivJumpPct: number; // (atmIv - baselineIv) / baselineIv + ivHv: number; // atmIv / hv30 (informational; pre-baseline metric) + spike: boolean; // ivJumpPct ≥ 30% + bigMove: boolean; // |today Δ| ≥ 3% expiry: string; error?: string; }; @@ -361,7 +372,33 @@ export async function scanSymbol(symbol: string): Promise { 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 }; + // Save this scan as a snapshot so the baseline grows over time. + // Use the snapshot's chain so per-strike IV is recorded too. + try { + if (atmIv > 0 && spot > 0) { + const snap = { + symbol, expiry, spot, spotIv: hv30, + timestamp: new Date().toISOString(), + chain, + }; + saveSnapshot(symbol, expiry, spot, computeSkewMetrics(snap), chain); + } + } catch { /* non-fatal */ } + + // Baseline IV: prefer 30-day avg from snapshots; fall back to HV30. + const hist = getAverageAtmIv(symbol, 30); + let baselineIv = 0, baselineSrc: ScanResult["baselineSrc"] = "none", baselineN = 0; + if (hist && hist.avg > 0) { baselineIv = hist.avg; baselineSrc = "history"; baselineN = hist.n; } + else if (hv30 > 0) { baselineIv = hv30; baselineSrc = "hv30"; baselineN = 0; } + + const ivJumpPct = baselineIv > 0 ? (atmIv - baselineIv) / baselineIv : 0; + const spike = ivJumpPct >= SPIKE_JUMP_PCT; + const bigMove = Math.abs(changePct) >= SPIKE_BIG_MOVE_PCT; + + return { + symbol, spot, change, changePct, atmIv, hv30, + baselineIv, baselineSrc, baselineN, + ivJumpPct, ivHv, spike, bigMove, expiry, + }; } diff --git a/backend/src/routes/options.ts b/backend/src/routes/options.ts index ff31c43..2f83d3b 100644 --- a/backend/src/routes/options.ts +++ b/backend/src/routes/options.ts @@ -540,7 +540,9 @@ optionsRouter.get("/scan", async (c) => { } 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: "", + spot: 0, change: 0, changePct: 0, atmIv: 0, hv30: 0, + baselineIv: 0, baselineSrc: "none" as const, baselineN: 0, + ivJumpPct: 0, ivHv: 0, spike: false, bigMove: false, expiry: "", }; } })); diff --git a/frontend/scanner.html b/frontend/scanner.html index 562b3cc..5d89889 100644 --- a/frontend/scanner.html +++ b/frontend/scanner.html @@ -66,8 +66,11 @@

IV Spike Scanner

- Find symbols where IV / HV ratio is unusually high or price is moving big today. - A spike is flagged when IV / HV ≥ 1.5 or |today Δ| ≥ 3%. + A spike is flagged when current ATM IV is at least +30% + above the symbol's recent baseline — the average ATM IV of the last 30 days from scan history, + falling back to 30-day realized vol when no history exists. Each scan saves a snapshot so the + baseline gets better the more you run it. The yellow BIG MOVE badge is a + separate flag for |today Δ| ≥ 3%.
@@ -126,9 +129,10 @@ Spot Δ Today ATM IV + Baseline IV + IV Δ% HV30 - IV / HV - Spike + Flags Expiry @@ -143,9 +147,16 @@ + + + + + - - SPIKE + + SPIKE + BIG MOVE + Chain @@ -185,7 +196,7 @@ watchlist: [], loading: false, error: '', - sortBy: 'ivHv', + sortBy: 'ivJumpPct', sortDesc: true, async init() { @@ -246,8 +257,8 @@ get highestRatio() { if (this.results.length === 0) return '—'; - const r = [...this.results].sort((a, b) => b.ivHv - a.ivHv)[0]; - return r.symbol + ' ' + r.ivHv.toFixed(2) + '×'; + const r = [...this.results].sort((a, b) => b.ivJumpPct - a.ivJumpPct)[0]; + return r.symbol + ' ' + (r.ivJumpPct >= 0 ? '+' : '') + (r.ivJumpPct*100).toFixed(0) + '%'; }, ivClass(iv) {