Scanner: IV/HV column + composite EDGE signal

User clarified the real entry signal: not just high IV, but IV
higher than HV — that's what makes premium "rich" relative to
realized vol and gives the mean-reversion edge for premium sellers.

Changes:
- New IV/HV column (was already computed server-side as r.ivHv but
  not displayed). Colored cheap/fair/rich/very-rich. Sortable.
- New EDGE composite badge in the Flags cell: lights green when
  IV Rank ≥ 60 AND IV/HV ≥ 1.20 — premium expensive in own history
  AND not justified by what stock is actually doing.
- Summary row gains an "EDGE setups" count card.
- Page header copy reworked to explain the three richness signals
  (IV Δ%, IV Rank, IV/HV) and why the composite EDGE flag matters.

Live test confirms the discrimination: INTC / AAPL / NVDA flag as
EDGE; TSLA (high IV/HV but low Rank — its normal regime) and SPY /
COIN (IV/HV high because realized vol is unusually quiet, not
because IV is rich) are correctly excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 09:53:16 +00:00
parent e2eca5ef66
commit d79b98baca

View File

@@ -24,6 +24,10 @@
.ivrank-high { background: rgba(255, 212, 59, 0.18); color: #ffd43b; } .ivrank-high { background: rgba(255, 212, 59, 0.18); color: #ffd43b; }
.ivrank-vhigh{ background: rgba(255, 107, 107, 0.18); color: #ff6b6b; } .ivrank-vhigh{ background: rgba(255, 107, 107, 0.18); color: #ff6b6b; }
.ivrank-na { color: #6c757d; } .ivrank-na { color: #6c757d; }
.ivhv-cheap { color: #51cf66; font-weight:600; }
.ivhv-fair { color: #cbd3df; font-weight:600; }
.ivhv-rich { color: #ffd43b; font-weight:700; }
.ivhv-vrich { color: #ff6b6b; font-weight:800; }
</style> </style>
</head> </head>
<body class="antialiased"> <body class="antialiased">
@@ -72,12 +76,15 @@
<div class="col"> <div class="col">
<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> Three richness signals — used together they tell you whether the market is paying you
above the symbol's recent baseline (avg ATM IV of the last 30 days, falling back to HV30). to sell vol that isn't being realized.
<strong>IV Rank</strong> (0-100) shows where current IV sits in its 1-year (min, max) range <ul class="mt-2 mb-0 small" style="list-style:none;padding-left:0;">
from saved snapshots — <span style="color:#ffd43b">≥60 = expensive (sell premium)</span>, <li>· <strong>IV Δ%</strong> — current ATM IV vs the symbol's recent 30-day baseline. <span class="mono">≥+30%</span> = SPIKE (sudden jump).</li>
<span style="color:#51cf66">≤30 = cheap (buy premium)</span>. The yellow <li>· <strong>IV Rank</strong> — where current IV sits in its 1-year (min, max) range. <span style="color:#ffd43b">≥60 = expensive in its own history</span>.</li>
<strong>BIG MOVE</strong> badge flags |today Δ| ≥ 3%. <li>· <strong>IV/HV</strong> — current IV divided by 30-day realized vol. <span style="color:#ffd43b">≥1.2</span> = options pricing more vol than the stock is actually moving. <strong>This is the real edge</strong> — high IV alone just means a volatile name; high IV <em>above</em> HV means the market is overpaying.</li>
</ul>
The green <strong>EDGE</strong> badge fires when IV Rank ≥ 60 <em>AND</em> IV/HV ≥ 1.2 —
that's the combined "sell-premium-now" signal. Blue <strong>BIG MOVE</strong> = |today Δ| ≥ 3%.
</div> </div>
</div> </div>
</div> </div>
@@ -117,8 +124,8 @@
<!-- 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"><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"><div class="card" style="background:#1e2030;border:1px solid #51cf6666;padding:.75rem 1rem;"><div class="small" style="color:#51cf66;">EDGE setups</div><div class="fs-4 fw-bold" style="color:#51cf66;" x-text="results.filter(r => hasEdge(r)).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 #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">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 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>
@@ -140,6 +147,7 @@
<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('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('ivHv')" class="text-end" title="IV/HV ratio: current ATM IV divided by 30-day realized vol. ≥1.5 = options pricing FAR more vol than stock is actually moving — premium-rich. <1.0 = options under-pricing realized risk — premium-cheap.">IV/HV</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>
@@ -167,8 +175,14 @@
<span x-text="r.ivRankN >= 5 ? Math.round(r.ivRank*100) : 'n/a'"></span> <span x-text="r.ivRankN >= 5 ? Math.round(r.ivRank*100) : 'n/a'"></span>
</span> </span>
</td> </td>
<td class="text-end mono" :class="ivHvClass(r.ivHv)"
:title="r.ivHv ? ('ATM IV ' + (r.atmIv*100).toFixed(1) + '% vs HV30 ' + (r.hv30*100).toFixed(1) + '% — ' + ivHvLabel(r.ivHv)) : 'HV30 unavailable'"
x-text="r.ivHv ? r.ivHv.toFixed(2) + 'x' : '—'"></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="hasEdge(r)" class="badge me-1"
style="background:linear-gradient(135deg,#51cf66,#00adb5);color:#0b1020;font-weight:800;letter-spacing:.04em;"
:title="'IV Rank ' + Math.round(r.ivRank*100) + ' AND IV/HV ' + r.ivHv.toFixed(2) + 'x — premium is rich AND not justified by realized vol'">EDGE</span>
<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>
<span x-show="r.bigMove" class="badge" style="background:#4d9ef7;color:#fff;font-weight:700;">BIG MOVE</span> <span x-show="r.bigMove" class="badge" style="background:#4d9ef7;color:#fff;font-weight:700;">BIG MOVE</span>
</td> </td>
@@ -294,6 +308,29 @@
return 'ivrank-low'; return 'ivrank-low';
}, },
ivHvClass(ratio) {
if (!ratio) return '';
if (ratio >= 1.50) return 'ivhv-vrich';
if (ratio >= 1.20) return 'ivhv-rich';
if (ratio >= 0.95) return 'ivhv-fair';
return 'ivhv-cheap';
},
ivHvLabel(ratio) {
if (!ratio) return '';
if (ratio >= 1.50) return 'VERY RICH (sell vol)';
if (ratio >= 1.20) return 'RICH (sell-vol candidate)';
if (ratio >= 0.95) return 'fair';
return 'CHEAP (buy-vol candidate)';
},
// EDGE: high IV Rank (premium expensive in own range) AND IV/HV >= 1.2
// (premium is actually pricing more vol than the stock is realizing) — the
// combined signal for "options market is paying you for risk that isn't there".
hasEdge(r) {
return r && r.ivRankN >= 5 && r.ivRank >= 0.60 && r.ivHv >= 1.20;
},
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'; },
}; };