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