Scanner: spike = current ATM IV >= +30% above recent baseline

Replaces the IV/HV >= 1.5 heuristic with a baseline-relative jump:
a symbol is flagged SPIKE when current ATM IV is at least 30%
above its recent baseline (e.g., 10% -> 13%+). Baseline prefers
the 30-day average of saved snapshots; falls back to HV30 when
no scan history exists. Each scan persists a snapshot so the
baseline self-improves over time. BIG MOVE (|chg%| >= 3%) is now
shown as a separate badge instead of a spike requirement.

- snapshots.ts: add getAverageAtmIv(symbol, days)
- datafetch.ts: save snapshot per scan; compute baselineIv,
  baselineSrc, baselineN, ivJumpPct; spike from jump threshold
- options.ts: /api/scan returns new baseline + jump fields
- scanner.html: header copy, table columns (Baseline IV, IV Δ%),
  default sort by ivJumpPct desc, separate SPIKE/BIG MOVE badges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 07:39:24 +00:00
parent 2cd3d6ece8
commit 3ea5fd5209
4 changed files with 82 additions and 14 deletions

View File

@@ -66,8 +66,11 @@
<div class="col">
<h2 class="page-title">IV Spike Scanner</h2>
<div class="text-secondary mt-1">
Find symbols where IV / HV ratio is unusually high or price is moving big today.
A <strong>spike</strong> is flagged when <span class="mono">IV / HV ≥ 1.5</span> or <span class="mono">|today Δ| ≥ 3%</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,
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%.
</div>
</div>
</div>
@@ -126,9 +129,10 @@
<th @click="setSort('spot')" class="text-end">Spot</th>
<th @click="setSort('changePct')" class="text-end">Δ Today</th>
<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('hv30')" class="text-end">HV30</th>
<th @click="setSort('ivHv')" class="text-end">IV / HV</th>
<th class="text-center">Spike</th>
<th class="text-center">Flags</th>
<th>Expiry</th>
<th></th>
</tr>
@@ -143,9 +147,16 @@
<td class="text-end mono" x-text="r.spot ? '$' + r.spot.toFixed(2) : '—'"></td>
<td class="text-end mono fw-semibold" :class="r.changePct >= 0 ? 'text-success' : 'text-danger'" x-text="r.spot ? (r.changePct >= 0 ? '+' : '') + r.changePct.toFixed(2) + '%' : '—'"></td>
<td class="text-end mono" :class="ivClass(r.atmIv)" x-text="r.atmIv ? (r.atmIv*100).toFixed(1) + '%' : '—'"></td>
<td class="text-end mono">
<span :class="ivClass(r.baselineIv)" x-text="r.baselineIv ? (r.baselineIv*100).toFixed(1) + '%' : '—'"></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 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" :class="ivClass(r.hv30)" x-text="r.hv30 ? (r.hv30*100).toFixed(1) + '%' : '—'"></td>
<td class="text-end mono fw-bold" :style="r.ivHv >= 1.5 ? 'color:#ffd43b' : 'color:#d0d5e0'" x-text="r.ivHv ? r.ivHv.toFixed(2) : '—'"></td>
<td class="text-center"><span x-show="r.spike" class="badge" style="background:#ffd43b;color:#1a1c2e;font-weight:700;">SPIKE</span></td>
<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.bigMove" class="badge" style="background:#4d9ef7;color:#fff;font-weight:700;">BIG MOVE</span>
</td>
<td class="mono small text-secondary" x-text="r.expiry"></td>
<td>
<a class="btn btn-sm btn-outline-secondary me-1" :href="'chain.html'" @click.prevent="goChain(r.symbol)" title="Open in Options Chain">Chain</a>
@@ -185,7 +196,7 @@
watchlist: [],
loading: false,
error: '',
sortBy: 'ivHv',
sortBy: 'ivJumpPct',
sortDesc: true,
async init() {
@@ -246,8 +257,8 @@
get highestRatio() {
if (this.results.length === 0) return '—';
const r = [...this.results].sort((a, b) => b.ivHv - a.ivHv)[0];
return r.symbol + ' ' + r.ivHv.toFixed(2) + '×';
const r = [...this.results].sort((a, b) => b.ivJumpPct - a.ivJumpPct)[0];
return r.symbol + ' ' + (r.ivJumpPct >= 0 ? '+' : '') + (r.ivJumpPct*100).toFixed(0) + '%';
},
ivClass(iv) {