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; 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 { export function getDb(): Database.Database {
return db; return db;
} }

View File

@@ -2,6 +2,8 @@ import YahooFinance from "yahoo-finance2";
import type { OptionsResult, CallOrPut } from "yahoo-finance2/modules/options"; import type { OptionsResult, CallOrPut } from "yahoo-finance2/modules/options";
import { bsPrice, bsGreeks, impliedVol } from "./blackscholes.js"; import { bsPrice, bsGreeks, impliedVol } from "./blackscholes.js";
import type { OptionQuote, ChainSnapshot } from "./analytics.js"; import type { OptionQuote, ChainSnapshot } from "./analytics.js";
import { computeSkewMetrics } from "./analytics.js";
import { saveSnapshot, getAverageAtmIv } from "../db/snapshots.js";
import { import {
fmpEnabled, fmpEnabled,
fmpExpirations, fmpExpirations,
@@ -9,6 +11,10 @@ import {
fmpQuote, fmpQuote,
} from "./fmp.js"; } 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 yf = new YahooFinance({ suppressNotices: ["yahooSurvey"] });
const RISK_FREE_RATE = 0.05; const RISK_FREE_RATE = 0.05;
@@ -294,8 +300,13 @@ export type ScanResult = {
changePct: number; changePct: number;
atmIv: number; atmIv: number;
hv30: number; hv30: number;
ivHv: number; // atmIv / hv30 baselineIv: number; // recent-30d avg ATM IV from snapshots, else HV30
spike: boolean; // true if atmIv/hv30 > 1.5 or |changePct| >= 3 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; expiry: string;
error?: string; error?: string;
}; };
@@ -361,7 +372,33 @@ export async function scanSymbol(symbol: string): Promise<ScanResult> {
const hv30 = await fetchHistoricalVol(symbol); const hv30 = await fetchHistoricalVol(symbol);
const ivHv = hv30 > 0 ? atmIv / hv30 : 0; 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) { } catch (err) {
return { return {
symbol: sym, error: err instanceof Error ? err.message : String(err), 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: "",
}; };
} }
})); }));

View File

@@ -66,8 +66,11 @@
<div class="col"> <div class="col">
<h2 class="page-title">IV Spike Scanner</h2> <h2 class="page-title">IV Spike Scanner</h2>
<div class="text-secondary mt-1"> <div class="text-secondary mt-1">
Find symbols where IV / HV ratio is unusually high or price is moving big today. A <strong>spike</strong> is flagged when current ATM IV is at least <span class="mono">+30%</span>
A <strong>spike</strong> is flagged when <span class="mono">IV / HV ≥ 1.5</span> or <span class="mono">|today Δ| ≥ 3%</span>. 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 <strong>BIG MOVE</strong> badge is a
separate flag for |today Δ| ≥ 3%.
</div> </div>
</div> </div>
</div> </div>
@@ -126,9 +129,10 @@
<th @click="setSort('spot')" class="text-end">Spot</th> <th @click="setSort('spot')" class="text-end">Spot</th>
<th @click="setSort('changePct')" class="text-end">Δ Today</th> <th @click="setSort('changePct')" class="text-end">Δ Today</th>
<th @click="setSort('atmIv')" class="text-end">ATM IV</th> <th @click="setSort('atmIv')" class="text-end">ATM IV</th>
<th @click="setSort('baselineIv')" class="text-end">Baseline IV</th>
<th @click="setSort('ivJumpPct')" class="text-end">IV Δ%</th>
<th @click="setSort('hv30')" class="text-end">HV30</th> <th @click="setSort('hv30')" class="text-end">HV30</th>
<th @click="setSort('ivHv')" class="text-end">IV / HV</th> <th class="text-center">Flags</th>
<th class="text-center">Spike</th>
<th>Expiry</th> <th>Expiry</th>
<th></th> <th></th>
</tr> </tr>
@@ -143,9 +147,16 @@
<td class="text-end mono" x-text="r.spot ? '$' + r.spot.toFixed(2) : '—'"></td> <td class="text-end mono" x-text="r.spot ? '$' + r.spot.toFixed(2) : '—'"></td>
<td class="text-end mono fw-semibold" :class="r.changePct >= 0 ? 'text-success' : 'text-danger'" x-text="r.spot ? (r.changePct >= 0 ? '+' : '') + r.changePct.toFixed(2) + '%' : '—'"></td> <td class="text-end mono fw-semibold" :class="r.changePct >= 0 ? 'text-success' : 'text-danger'" x-text="r.spot ? (r.changePct >= 0 ? '+' : '') + r.changePct.toFixed(2) + '%' : '—'"></td>
<td class="text-end mono" :class="ivClass(r.atmIv)" x-text="r.atmIv ? (r.atmIv*100).toFixed(1) + '%' : '—'"></td> <td class="text-end mono" :class="ivClass(r.atmIv)" x-text="r.atmIv ? (r.atmIv*100).toFixed(1) + '%' : '—'"></td>
<td class="text-end mono">
<span :class="ivClass(r.baselineIv)" x-text="r.baselineIv ? (r.baselineIv*100).toFixed(1) + '%' : '—'"></span>
<span class="text-secondary small ms-1" :title="r.baselineSrc === 'history' ? ('avg of ' + r.baselineN + ' scan snapshots in last 30d') : (r.baselineSrc === 'hv30' ? '30-day realized vol (no scan history yet)' : 'no baseline')" x-text="r.baselineSrc === 'history' ? ('n=' + r.baselineN) : (r.baselineSrc === 'hv30' ? 'hv' : '')"></span>
</td>
<td class="text-end mono fw-bold" :style="r.ivJumpPct >= 0.30 ? 'color:#ffd43b' : (r.ivJumpPct >= 0 ? 'color:#d0d5e0' : 'color:#8b95a7')" x-text="r.baselineIv ? ((r.ivJumpPct >= 0 ? '+' : '') + (r.ivJumpPct * 100).toFixed(1) + '%') : '—'"></td>
<td class="text-end mono" :class="ivClass(r.hv30)" x-text="r.hv30 ? (r.hv30*100).toFixed(1) + '%' : '—'"></td> <td class="text-end mono" :class="ivClass(r.hv30)" x-text="r.hv30 ? (r.hv30*100).toFixed(1) + '%' : '—'"></td>
<td class="text-end mono fw-bold" :style="r.ivHv >= 1.5 ? 'color:#ffd43b' : 'color:#d0d5e0'" x-text="r.ivHv ? r.ivHv.toFixed(2) : '—'"></td> <td class="text-center">
<td class="text-center"><span x-show="r.spike" class="badge" style="background:#ffd43b;color:#1a1c2e;font-weight:700;">SPIKE</span></td> <span x-show="r.spike" class="badge me-1" style="background:#ffd43b;color:#1a1c2e;font-weight:700;">SPIKE</span>
<span x-show="r.bigMove" class="badge" style="background:#4d9ef7;color:#fff;font-weight:700;">BIG MOVE</span>
</td>
<td class="mono small text-secondary" x-text="r.expiry"></td> <td class="mono small text-secondary" x-text="r.expiry"></td>
<td> <td>
<a class="btn btn-sm btn-outline-secondary me-1" :href="'chain.html'" @click.prevent="goChain(r.symbol)" title="Open in Options Chain">Chain</a> <a class="btn btn-sm btn-outline-secondary me-1" :href="'chain.html'" @click.prevent="goChain(r.symbol)" title="Open in Options Chain">Chain</a>
@@ -185,7 +196,7 @@
watchlist: [], watchlist: [],
loading: false, loading: false,
error: '', error: '',
sortBy: 'ivHv', sortBy: 'ivJumpPct',
sortDesc: true, sortDesc: true,
async init() { async init() {
@@ -246,8 +257,8 @@
get highestRatio() { get highestRatio() {
if (this.results.length === 0) return '—'; if (this.results.length === 0) return '—';
const r = [...this.results].sort((a, b) => b.ivHv - a.ivHv)[0]; const r = [...this.results].sort((a, b) => b.ivJumpPct - a.ivJumpPct)[0];
return r.symbol + ' ' + r.ivHv.toFixed(2) + '×'; return r.symbol + ' ' + (r.ivJumpPct >= 0 ? '+' : '') + (r.ivJumpPct*100).toFixed(0) + '%';
}, },
ivClass(iv) { ivClass(iv) {