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 };
}
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;
}

View File

@@ -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,
};
}

View File

@@ -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: "",
};
}
}));