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

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