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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ScanResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
@@ -67,10 +73,11 @@
|
||||
<h2 class="page-title">IV Spike Scanner</h2>
|
||||
<div class="text-secondary mt-1">
|
||||
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,
|
||||
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%.
|
||||
above the symbol's recent baseline (avg ATM IV of the last 30 days, falling back to HV30).
|
||||
<strong>IV Rank</strong> (0-100) shows where current IV sits in its 1-year (min, max) range
|
||||
from saved snapshots — <span style="color:#ffd43b">≥60 = expensive (sell premium)</span>,
|
||||
<span style="color:#51cf66">≤30 = cheap (buy premium)</span>. The yellow
|
||||
<strong>BIG MOVE</strong> badge flags |today Δ| ≥ 3%.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,10 +116,11 @@
|
||||
|
||||
<!-- Spike summary cards -->
|
||||
<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-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-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-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">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 #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 #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"><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>
|
||||
|
||||
<!-- Results table -->
|
||||
@@ -131,6 +139,7 @@
|
||||
<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('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 class="text-center">Flags</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>
|
||||
</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-center">
|
||||
<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';
|
||||
},
|
||||
|
||||
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'; },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user