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 };
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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'; },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user