Scanner: spike = current ATM IV >= +30% above recent baseline

Replaces the IV/HV >= 1.5 heuristic with a baseline-relative jump:
a symbol is flagged SPIKE when current ATM IV is at least 30%
above its recent baseline (e.g., 10% -> 13%+). Baseline prefers
the 30-day average of saved snapshots; falls back to HV30 when
no scan history exists. Each scan persists a snapshot so the
baseline self-improves over time. BIG MOVE (|chg%| >= 3%) is now
shown as a separate badge instead of a spike requirement.

- snapshots.ts: add getAverageAtmIv(symbol, days)
- datafetch.ts: save snapshot per scan; compute baselineIv,
  baselineSrc, baselineN, ivJumpPct; spike from jump threshold
- options.ts: /api/scan returns new baseline + jump fields
- scanner.html: header copy, table columns (Baseline IV, IV Δ%),
  default sort by ivJumpPct desc, separate SPIKE/BIG MOVE badges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 07:39:24 +00:00
parent 2cd3d6ece8
commit 3ea5fd5209
4 changed files with 82 additions and 14 deletions

View File

@@ -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;
}

View File

@@ -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<ScanResult> {
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,
};
}

View File

@@ -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: "",
};
}
}));