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 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 09:40:10 +00:00
parent 0a6e357a78
commit e2eca5ef66
4 changed files with 128 additions and 11 deletions

View File

@@ -112,6 +112,71 @@ export function getAverageAtmIv(symbol: string, days = 30): { avg: number; n: nu
return { avg: row.avg_iv, n: row.n }; 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 { export function getDb(): Database.Database {
return db; return db;
} }

View File

@@ -3,7 +3,7 @@ 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 { computeSkewMetrics } from "./analytics.js";
import { saveSnapshot, getAverageAtmIv } from "../db/snapshots.js"; import { saveSnapshot, getAverageAtmIv, getIvRange, getIvPercentile } from "../db/snapshots.js";
import { import {
fmpEnabled, fmpEnabled,
fmpExpirations, fmpExpirations,
@@ -333,6 +333,13 @@ export type ScanResult = {
ivHv: number; // atmIv / hv30 (informational; pre-baseline metric) ivHv: number; // atmIv / hv30 (informational; pre-baseline metric)
spike: boolean; // ivJumpPct ≥ 30% spike: boolean; // ivJumpPct ≥ 30%
bigMove: boolean; // |today Δ| ≥ 3% 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; expiry: string;
error?: string; error?: string;
}; };
@@ -422,10 +429,29 @@ export async function scanSymbol(symbol: string): Promise<ScanResult> {
const spike = ivJumpPct >= SPIKE_JUMP_PCT; const spike = ivJumpPct >= SPIKE_JUMP_PCT;
const bigMove = Math.abs(changePct) >= SPIKE_BIG_MOVE_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 { return {
symbol, spot, change, changePct, atmIv, hv30, symbol, spot, change, changePct, atmIv, hv30,
baselineIv, baselineSrc, baselineN, baselineIv, baselineSrc, baselineN,
ivJumpPct, ivHv, spike, bigMove, expiry, ivJumpPct, ivHv, spike, bigMove,
ivRank, ivPercentile, ivRankN, ivRankSpanDays,
expiry,
}; };
} }

View File

@@ -546,7 +546,9 @@ optionsRouter.get("/scan", async (c) => {
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, spot: 0, change: 0, changePct: 0, atmIv: 0, hv30: 0,
baselineIv: 0, baselineSrc: "none" as const, baselineN: 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: "",
}; };
} }
})); }));

View File

@@ -18,6 +18,12 @@
.iv-cell-mid { color: #ffd43b; } .iv-cell-mid { color: #ffd43b; }
.iv-cell-high { color: #ff8c42; } .iv-cell-high { color: #ff8c42; }
.iv-cell-vhigh{ color: #ff6b6b; } .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; }
</style> </style>
</head> </head>
<body class="antialiased"> <body class="antialiased">
@@ -67,10 +73,11 @@
<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">
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 current ATM IV is at least <span class="mono">+30%</span>
above the symbol's recent baseline — the average ATM IV of the last 30 days from scan history, above the symbol's recent baseline (avg ATM IV of the last 30 days, falling back to HV30).
falling back to 30-day realized vol when no history exists. Each scan saves a snapshot so the <strong>IV Rank</strong> (0-100) shows where current IV sits in its 1-year (min, max) range
baseline gets better the more you run it. The yellow <strong>BIG MOVE</strong> badge is a from saved snapshots — <span style="color:#ffd43b">≥60 = expensive (sell premium)</span>,
separate flag for |today Δ| ≥ 3%. <span style="color:#51cf66">≤30 = cheap (buy premium)</span>. The yellow
<strong>BIG MOVE</strong> badge flags |today Δ| ≥ 3%.
</div> </div>
</div> </div>
</div> </div>
@@ -109,10 +116,11 @@
<!-- Spike summary cards --> <!-- Spike summary cards -->
<div class="row g-2 mb-3" x-show="results.length > 0" x-cloak> <div class="row g-2 mb-3" x-show="results.length > 0" x-cloak>
<div class="col-6 col-md-3"><div class="card" style="background:#1e2030;border:1px solid #2d3045;padding:.75rem 1rem;"><div class="text-secondary small">Scanned</div><div class="fs-4 fw-bold" x-text="results.length"></div></div></div> <div class="col-6 col-md"><div class="card" style="background:#1e2030;border:1px solid #2d3045;padding:.75rem 1rem;"><div class="text-secondary small">Scanned</div><div class="fs-4 fw-bold" x-text="results.length"></div></div></div>
<div class="col-6 col-md-3"><div class="card" style="background:#1e2030;border:1px solid #ffd43b66;padding:.75rem 1rem;"><div class="small" style="color:#ffd43b;">Spikes</div><div class="fs-4 fw-bold" style="color:#ffd43b;" x-text="results.filter(r => r.spike).length"></div></div></div> <div class="col-6 col-md"><div class="card" style="background:#1e2030;border:1px solid #ffd43b66;padding:.75rem 1rem;"><div class="small" style="color:#ffd43b;">Spikes</div><div class="fs-4 fw-bold" style="color:#ffd43b;" x-text="results.filter(r => r.spike).length"></div></div></div>
<div class="col-6 col-md-3"><div class="card" style="background:#1e2030;border:1px solid #2d3045;padding:.75rem 1rem;"><div class="text-secondary small">Biggest mover</div><div class="fs-5 fw-bold mono" x-text="biggestMover"></div></div></div> <div class="col-6 col-md"><div class="card" style="background:#1e2030;border:1px solid #2d3045;padding:.75rem 1rem;"><div class="text-secondary small">High IV Rank (≥60)</div><div class="fs-4 fw-bold" style="color:#ffd43b;" x-text="results.filter(r => r.ivRankN >= 5 && r.ivRank >= 0.60).length"></div></div></div>
<div class="col-6 col-md-3"><div class="card" style="background:#1e2030;border:1px solid #2d3045;padding:.75rem 1rem;"><div class="text-secondary small">Highest IV/HV</div><div class="fs-5 fw-bold mono" x-text="highestRatio"></div></div></div> <div class="col-6 col-md"><div class="card" style="background:#1e2030;border:1px solid #2d3045;padding:.75rem 1rem;"><div class="text-secondary small">Biggest mover</div><div class="fs-5 fw-bold mono" x-text="biggestMover"></div></div></div>
<div class="col-6 col-md"><div class="card" style="background:#1e2030;border:1px solid #2d3045;padding:.75rem 1rem;"><div class="text-secondary small">Highest IV Δ</div><div class="fs-5 fw-bold mono" x-text="highestRatio"></div></div></div>
</div> </div>
<!-- Results table --> <!-- Results table -->
@@ -131,6 +139,7 @@
<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('baselineIv')" class="text-end">Baseline IV</th>
<th @click="setSort('ivJumpPct')" class="text-end">IV Δ%</th> <th @click="setSort('ivJumpPct')" class="text-end">IV Δ%</th>
<th @click="setSort('ivRank')" class="text-end" title="IV Rank: where current ATM IV sits in its 1-year (min, max) range. ≥60 = expensive (sell premium); ≤30 = cheap (buy premium).">IV Rank</th>
<th @click="setSort('hv30')" class="text-end">HV30</th> <th @click="setSort('hv30')" class="text-end">HV30</th>
<th class="text-center">Flags</th> <th class="text-center">Flags</th>
<th>Expiry</th> <th>Expiry</th>
@@ -152,6 +161,12 @@
<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> <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>
<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 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">
<span class="ivrank-cell" :class="ivRankClass(r)"
:title="r.ivRankN >= 5 ? ('IV Rank ' + Math.round(r.ivRank*100) + ' · percentile ' + Math.round(r.ivPercentile*100) + '% · n=' + r.ivRankN + ' snapshots over ' + r.ivRankSpanDays + 'd') : ('not enough history yet · n=' + (r.ivRankN || 0))">
<span x-text="r.ivRankN >= 5 ? Math.round(r.ivRank*100) : 'n/a'"></span>
</span>
</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-center"> <td class="text-center">
<span x-show="r.spike" class="badge me-1" style="background:#ffd43b;color:#1a1c2e;font-weight:700;">SPIKE</span> <span x-show="r.spike" class="badge me-1" style="background:#ffd43b;color:#1a1c2e;font-weight:700;">SPIKE</span>
@@ -270,6 +285,15 @@
return 'iv-cell-vhigh'; 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'; }, 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'; }, goSurface(sym){ try { const v = ViewState.load('surface') || {}; v.symbol = sym; ViewState.save('surface', v); } catch {} window.location.href = '/surface.html'; },
}; };