Tracker watchlist: summary cards + auto-load first symbol

Each watched symbol now appears as a small card showing live spot, ATM IV
(color-coded by level), and RR25 — fetched from /api/analytics. Click a
card to load that symbol's full IV/RR/Fly history charts; the active card
gets a blue border.

On page open: if there's a watchlist but no history loaded yet, auto-open
the first watched symbol so the page isn't empty. Refresh button re-pulls
all watchlist metrics. Empty state hints at adding symbols from the
Strategy page's "Save to Tracker" button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 07:14:06 +00:00
parent d03a90312c
commit 65cb6cc5f2

View File

@@ -278,15 +278,41 @@
<div class="page-body"> <div class="page-body">
<div class="container-xl"> <div class="container-xl">
<!-- Watchlist (symbols saved from Strategy page) --> <!-- Watchlist summary cards (symbols saved from Strategy page) -->
<div class="mb-3 d-flex align-items-center flex-wrap gap-2" x-show="watchlist.length > 0" x-cloak> <div class="mb-3" x-show="watchlist.length > 0" x-cloak>
<span class="text-secondary small me-1">Watchlist:</span> <div class="d-flex align-items-center justify-content-between mb-2">
<h3 class="text-white mb-0" style="font-size:1rem;">Watchlist <span class="text-secondary small fw-normal" x-text="'(' + watchlist.length + ')'"></span></h3>
<button class="btn btn-sm btn-outline-secondary" @click="_loadWatchlistData()" :disabled="watchlistLoading">
<span x-show="watchlistLoading" class="spinner-border spinner-border-sm me-1"></span>Refresh
</button>
</div>
<div class="row g-2">
<template x-for="s in watchlist" :key="s"> <template x-for="s in watchlist" :key="s">
<span class="badge bg-blue-lt" style="cursor:pointer; padding:.45rem .6rem;"> <div class="col-6 col-md-4 col-lg-3 col-xl-2">
<span @click="loadSymbol(s)" x-text="s" :class="s === symbol.toUpperCase().trim() ? 'fw-bold' : ''"></span> <div class="watch-card" style="background:#1e2030; border:1px solid #2d3045; border-radius:.5rem; padding:.6rem .75rem; cursor:pointer;"
<span class="text-danger ms-1" style="cursor:pointer;" @click="removeWatch(s)" :title="'Remove ' + s + ' from watchlist'"></span> :class="s === symbol.toUpperCase().trim() ? 'border-primary' : ''"
</span> :style="s === symbol.toUpperCase().trim() ? 'background:#1e2030; border:1px solid #4d9ef7; border-radius:.5rem; padding:.6rem .75rem; cursor:pointer;' : 'background:#1e2030; border:1px solid #2d3045; border-radius:.5rem; padding:.6rem .75rem; cursor:pointer;'"
@click="loadSymbol(s)">
<div class="d-flex align-items-center justify-content-between">
<span class="fw-bold text-white" style="font-family:'JetBrains Mono',monospace;" x-text="s"></span>
<span class="text-danger small" style="cursor:pointer;" @click.stop="removeWatch(s)" :title="'Remove ' + s"></span>
</div>
<div class="mt-1" style="font-size:.78rem; color:#cbd3df;">
<template x-if="watchlistData[s]">
<div>
<div>Spot <strong class="mono" x-text="watchlistData[s].spot ? '$' + watchlistData[s].spot.toFixed(2) : '—'"></strong></div>
<div>ATM IV <strong class="mono" :style="watchlistData[s].atmIv > 0.4 ? 'color:#ff8c42' : watchlistData[s].atmIv > 0.2 ? 'color:#ffd43b' : 'color:#51cf66'" x-text="watchlistData[s].atmIv ? (watchlistData[s].atmIv*100).toFixed(1) + '%' : '—'"></strong></div>
<div :style="watchlistData[s].rr25 > 0.005 ? 'color:#51cf66' : watchlistData[s].rr25 < -0.005 ? 'color:#ff6b6b' : 'color:#8b95a7'">
RR25 <span class="mono" x-text="watchlistData[s].rr25 != null ? ((watchlistData[s].rr25 >= 0 ? '+' : '') + (watchlistData[s].rr25 * 100).toFixed(2) + '%') : '—'"></span>
</div>
</div>
</template> </template>
<template x-if="!watchlistData[s]"><div class="text-secondary">loading…</div></template>
</div>
</div>
</div>
</template>
</div>
</div> </div>
<!-- Empty state --> <!-- Empty state -->
@@ -297,7 +323,11 @@
<path d="M21 21l-1.5-1.5M16.5 16.5L12 12M12 12L7.5 7.5M7.5 7.5L3 3"></path> <path d="M21 21l-1.5-1.5M16.5 16.5L12 12M12 12L7.5 7.5M7.5 7.5L3 3"></path>
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
</svg> </svg>
<p class="text-muted">No snapshot data. Enter a symbol and click Load History.</p> <p class="text-muted">
No snapshot data yet. <span x-show="watchlist.length > 0">Click a watchlist card above, or </span>enter a symbol and click <strong>Load History</strong>.
<br>
<span class="small">Tip: add symbols to the watchlist from the <a href="strategy.html">Strategy</a> page's <em>Save to Tracker</em> button.</span>
</p>
</div> </div>
<!-- Charts row --> <!-- Charts row -->
@@ -647,6 +677,8 @@
error: '', error: '',
watchlist: [], watchlist: [],
watchlistData: {},
watchlistLoading: false,
_charts: { atmIv: null, rr25: null, fly25: null }, _charts: { atmIv: null, rr25: null, fly25: null },
@@ -661,7 +693,13 @@
if (this.snapshots.length) this.$nextTick(() => this.renderCharts()); if (this.snapshots.length) this.$nextTick(() => this.renderCharts());
} }
this._loadWatchlist(); this._loadWatchlist();
window.addEventListener('storage', (e) => { if (e.key === 'optionsPricer:watchlist') this._loadWatchlist(); }); window.addEventListener('storage', (e) => { if (e.key === 'optionsPricer:watchlist') { this._loadWatchlist(); this._loadWatchlistData(); } });
// populate the watchlist cards
if (this.watchlist.length) this._loadWatchlistData();
// if we have nothing loaded yet but a watchlist exists, auto-open the first
if (this.snapshots.length === 0 && this.watchlist.length > 0) {
this.loadSymbol(this.watchlist[0]);
}
}, },
_loadWatchlist() { _loadWatchlist() {
@@ -669,6 +707,38 @@
catch { this.watchlist = []; } catch { this.watchlist = []; }
}, },
async _loadWatchlistData() {
if (this.watchlist.length === 0) return;
this.watchlistLoading = true;
try {
const data = { ...this.watchlistData };
// fetch each symbol's nearest analytics in parallel
await Promise.all(this.watchlist.map(async (sym) => {
try {
const r = await fetch('/api/analytics?symbol=' + encodeURIComponent(sym));
if (!r.ok) return;
const e = await r.json();
const d = e.data ?? e;
// skewMetrics is keyed by expiry — pick the nearest
const expiries = (d.volSurface?.expiries || Object.keys(d.skewMetrics || {})).sort();
const front = expiries[0];
const m = (d.skewMetrics || {})[front] || {};
data[sym] = {
spot: d.spot ?? null,
atmIv: m.atmIv ?? d.atmIv ?? null,
rr25: m.rr25 ?? null,
fly25: m.fly25 ?? null,
expiry: front || null,
ts: new Date().toISOString(),
};
} catch {}
}));
this.watchlistData = data;
} finally {
this.watchlistLoading = false;
}
},
loadSymbol(s) { loadSymbol(s) {
this.symbol = s; this.symbol = s;
this.fetchExpirations(); this.fetchExpirations();
@@ -678,6 +748,9 @@
removeWatch(s) { removeWatch(s) {
this.watchlist = this.watchlist.filter(x => x !== s); this.watchlist = this.watchlist.filter(x => x !== s);
try { localStorage.setItem('optionsPricer:watchlist', JSON.stringify(this.watchlist)); } catch {} try { localStorage.setItem('optionsPricer:watchlist', JSON.stringify(this.watchlist)); } catch {}
delete this.watchlistData[s];
// keep reactivity by reassigning
this.watchlistData = { ...this.watchlistData };
}, },
_persist() { _persist() {