From e2eca5ef669750934fee686c83193493506d393b Mon Sep 17 00:00:00 2001 From: ojy Date: Wed, 13 May 2026 09:40:10 +0000 Subject: [PATCH] Scanner: add IV Rank + IV Percentile columns IV Rank shows where current ATM IV sits in its 1-year (min, max) range from saved snapshot history. The industry-standard entry metric: >=60 = expensive (sell premium), <=30 = cheap (buy premium). Also exposes IV Percentile (share of past snapshots with strictly lower IV) via the tooltip. - snapshots.ts: new getIvRange + getIvPercentile queries with a min-samples gate so the metric is hidden until n>=5 - datafetch.ts: ScanResult gains ivRank, ivPercentile, ivRankN, ivRankSpanDays - options.ts: error stub updated with new fields - scanner.html: new sortable IV Rank column with chip-styled color coding (green/grey/yellow/red); summary row gains a "High IV Rank (>=60)" count card; header text explains the new metric and the >=60 / <=30 entry rule of thumb Live INTC scan: IV Rank 100 (1-year peak) confirms the position's short-premium structure was entered into expensive vol - mean-reversion tailwind is the edge. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db/snapshots.ts | 65 +++++++++++++++++++++++++++++++++++ backend/src/lib/datafetch.ts | 30 ++++++++++++++-- backend/src/routes/options.ts | 4 ++- frontend/scanner.html | 40 ++++++++++++++++----- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/backend/src/db/snapshots.ts b/backend/src/db/snapshots.ts index 06b987b..dafcf5a 100644 --- a/backend/src/db/snapshots.ts +++ b/backend/src/db/snapshots.ts @@ -112,6 +112,71 @@ export function getAverageAtmIv(symbol: string, days = 30): { avg: number; n: nu return { avg: row.avg_iv, n: row.n }; } +const stmtIvRange = db.prepare(` + SELECT + MIN(atm_iv) AS min_iv, + MAX(atm_iv) AS max_iv, + COUNT(*) AS n, + MIN(timestamp) AS first_ts, + MAX(timestamp) AS last_ts + FROM snapshots + WHERE symbol = ? + AND atm_iv IS NOT NULL AND atm_iv > 0 + AND timestamp >= datetime('now', ?) +`); + +const stmtIvPercentileBelow = db.prepare(` + SELECT COUNT(*) AS below + FROM snapshots + WHERE symbol = ? + AND atm_iv IS NOT NULL AND atm_iv > 0 + AND atm_iv < ? + AND timestamp >= datetime('now', ?) +`); + +/** + * Min / max ATM IV for a symbol over the last `days` days (across all expiries), + * plus the count of qualifying snapshots and the first/last timestamps used. + * Returns null if there are fewer than `minSamples` snapshots — the IV-Rank + * metric isn't meaningful without enough history to span a real range. + */ +export function getIvRange( + symbol: string, + days = 365, + minSamples = 5, +): { min: number; max: number; n: number; first: string; last: string } | null { + const row = stmtIvRange.get(symbol, `-${Math.max(1, Math.round(days))} days`) as + | { min_iv: number | null; max_iv: number | null; n: number; first_ts: string | null; last_ts: string | null } + | undefined; + if (!row || !row.min_iv || !row.max_iv || row.n < minSamples) return null; + if (row.max_iv <= row.min_iv) return null; + return { + min: row.min_iv, + max: row.max_iv, + n: row.n, + first: row.first_ts ?? "", + last: row.last_ts ?? "", + }; +} + +/** + * IV Percentile — share of snapshots in the lookback window whose ATM IV was + * strictly below `currentIv`. Returns 0..1. Falls back to null if not enough data. + */ +export function getIvPercentile( + symbol: string, + currentIv: number, + days = 365, + minSamples = 5, +): { pct: number; n: number } | null { + const range = getIvRange(symbol, days, minSamples); + if (!range) return null; + const row = stmtIvPercentileBelow.get(symbol, currentIv, `-${Math.max(1, Math.round(days))} days`) as + | { below: number } | undefined; + if (!row) return null; + return { pct: row.below / range.n, n: range.n }; +} + export function getDb(): Database.Database { return db; } diff --git a/backend/src/lib/datafetch.ts b/backend/src/lib/datafetch.ts index 36b5ada..b85ad74 100644 --- a/backend/src/lib/datafetch.ts +++ b/backend/src/lib/datafetch.ts @@ -3,7 +3,7 @@ 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 { saveSnapshot, getAverageAtmIv, getIvRange, getIvPercentile } from "../db/snapshots.js"; import { fmpEnabled, fmpExpirations, @@ -333,6 +333,13 @@ export type ScanResult = { ivHv: number; // atmIv / hv30 (informational; pre-baseline metric) spike: boolean; // ivJumpPct ≥ 30% bigMove: boolean; // |today Δ| ≥ 3% + + // IV Rank / Percentile (1-year window over saved snapshots) + ivRank: number; // 0..1 — where current sits in (1y min, 1y max). Null when n/a. + ivPercentile: number; // 0..1 — share of past snapshots with IV strictly below current + ivRankN: number; // # of snapshots backing the rank/percentile (0 = no rank yet) + ivRankSpanDays: number; // calendar days between earliest and latest snapshot in the window + expiry: string; error?: string; }; @@ -422,10 +429,29 @@ export async function scanSymbol(symbol: string): Promise { const spike = ivJumpPct >= SPIKE_JUMP_PCT; const bigMove = Math.abs(changePct) >= SPIKE_BIG_MOVE_PCT; + // IV Rank: position of current ATM IV within the 1-year (min, max) range from snapshots. + // Returns null until we have ≥ 5 snapshots; until then the metric isn't trustworthy. + let ivRank = 0, ivPercentile = 0, ivRankN = 0, ivRankSpanDays = 0; + if (atmIv > 0) { + const range = getIvRange(symbol, 365); + if (range) { + ivRank = Math.min(1, Math.max(0, (atmIv - range.min) / (range.max - range.min))); + ivRankN = range.n; + if (range.first && range.last) { + const ms = new Date(range.last).getTime() - new Date(range.first).getTime(); + ivRankSpanDays = Math.max(0, Math.round(ms / 86400000)); + } + const pct = getIvPercentile(symbol, atmIv, 365); + if (pct) ivPercentile = pct.pct; + } + } + return { symbol, spot, change, changePct, atmIv, hv30, baselineIv, baselineSrc, baselineN, - ivJumpPct, ivHv, spike, bigMove, expiry, + ivJumpPct, ivHv, spike, bigMove, + ivRank, ivPercentile, ivRankN, ivRankSpanDays, + expiry, }; } diff --git a/backend/src/routes/options.ts b/backend/src/routes/options.ts index b78d324..9125ece 100644 --- a/backend/src/routes/options.ts +++ b/backend/src/routes/options.ts @@ -546,7 +546,9 @@ optionsRouter.get("/scan", async (c) => { symbol: sym, error: err instanceof Error ? err.message : String(err), 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: "", + ivJumpPct: 0, ivHv: 0, spike: false, bigMove: false, + ivRank: 0, ivPercentile: 0, ivRankN: 0, ivRankSpanDays: 0, + expiry: "", }; } })); diff --git a/frontend/scanner.html b/frontend/scanner.html index d99a75c..f452483 100644 --- a/frontend/scanner.html +++ b/frontend/scanner.html @@ -18,6 +18,12 @@ .iv-cell-mid { color: #ffd43b; } .iv-cell-high { color: #ff8c42; } .iv-cell-vhigh{ color: #ff6b6b; } + .ivrank-cell { display:inline-block; min-width:48px; padding:.1rem .4rem; border-radius:.3rem; font-weight:700; text-align:center; } + .ivrank-low { background: rgba(81, 207, 102, 0.18); color: #51cf66; } + .ivrank-mid { background: rgba(173, 181, 191, 0.12); color: #cbd3df; } + .ivrank-high { background: rgba(255, 212, 59, 0.18); color: #ffd43b; } + .ivrank-vhigh{ background: rgba(255, 107, 107, 0.18); color: #ff6b6b; } + .ivrank-na { color: #6c757d; } @@ -67,10 +73,11 @@

IV Spike Scanner

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%. + above the symbol's recent baseline (avg ATM IV of the last 30 days, falling back to HV30). + IV Rank (0-100) shows where current IV sits in its 1-year (min, max) range + from saved snapshots — ≥60 = expensive (sell premium), + ≤30 = cheap (buy premium). The yellow + BIG MOVE badge flags |today Δ| ≥ 3%.
@@ -109,10 +116,11 @@
-
Scanned
-
Spikes
-
Biggest mover
-
Highest IV/HV
+
Scanned
+
Spikes
+
High IV Rank (≥60)
+
Biggest mover
+
Highest IV Δ
@@ -131,6 +139,7 @@ ATM IV Baseline IV IV Δ% + IV Rank HV30 Flags Expiry @@ -152,6 +161,12 @@ + + + + + SPIKE @@ -270,6 +285,15 @@ return 'iv-cell-vhigh'; }, + ivRankClass(r) { + if (!r || r.ivRankN < 5) return 'ivrank-na'; + const rank = r.ivRank * 100; + if (rank >= 80) return 'ivrank-vhigh'; + if (rank >= 60) return 'ivrank-high'; + if (rank >= 30) return 'ivrank-mid'; + return 'ivrank-low'; + }, + goChain(sym) { try { const v = ViewState.load('chain') || {}; v.symbol = sym; ViewState.save('chain', v); } catch {} window.location.href = '/chain.html'; }, goSurface(sym){ try { const v = ViewState.load('surface') || {}; v.symbol = sym; ViewState.save('surface', v); } catch {} window.location.href = '/surface.html'; }, };