Initial commit — options pricing dashboard

Full-stack options analytics app: IV surface, Greeks, skew metrics,
vol term structure. Yahoo Finance data with Black-Scholes IV computation
and historical vol fallback for after-hours data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 03:22:23 +00:00
commit d08c2230a8
20 changed files with 6112 additions and 0 deletions

5
frontend/assets/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5
frontend/assets/apexcharts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

9
frontend/assets/tabler.min.css vendored Normal file

File diff suppressed because one or more lines are too long

13
frontend/assets/tabler.min.js vendored Normal file

File diff suppressed because one or more lines are too long

557
frontend/chain.html Normal file
View File

@@ -0,0 +1,557 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options Chain — Options Pricer</title>
<link rel="stylesheet" href="/assets/tabler.min.css" />
<link rel="stylesheet" href="/assets/tabler-vendors.min.css" />
<style>
/* Sticky table header */
.table-sticky thead th {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--tblr-bg-surface);
}
/* Dense scrollable table wrappers */
.chain-scroll {
max-height: 75vh;
overflow-y: auto;
}
/* ATM highlight overrides */
.row-atm {
background-color: rgba(var(--tblr-info-rgb), 0.12) !important;
outline: 1px solid rgba(var(--tblr-info-rgb), 0.4);
}
/* Theta always red */
.text-theta {
color: var(--tblr-danger) !important;
}
/* IV color helpers already covered by Tabler text-yellow / text-orange */
/* Toolbar badge for spot price */
.spot-badge {
font-size: 0.95rem;
letter-spacing: 0.03em;
}
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="chain()" x-init="init()">
<!-- ===== Sidebar ===== -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu"
aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark">
<a href="index.html" class="text-decoration-none d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chart-candle" width="28"
height="28" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="8" width="4" height="8" rx="1" />
<line x1="6" y1="4" x2="6" y2="8" />
<line x1="6" y1="16" x2="6" y2="20" />
<rect x="16" y="6" width="4" height="10" rx="1" />
<line x1="18" y1="2" x2="18" y2="6" />
<line x1="18" y1="16" x2="18" y2="22" />
</svg>
<span class="fw-bold">Options Pricer</span>
</a>
</h1>
<div class="collapse navbar-collapse" id="sidebar-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item">
<a class="nav-link" href="index.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-dashboard" width="24"
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12l-2 0l9 -9l9 9l-2 0" />
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" />
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
</svg>
</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="chain.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-table" width="24"
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3 10l18 0" />
<path d="M10 5v14" />
</svg>
</span>
<span class="nav-link-title">Options Chain</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="surface.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-wave-sine" width="24"
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 12c1.333 -4.667 2.667 -7 4 -7s2.667 2.333 4 7s2.667 7 4 7s2.667 -2.333 4 -7" />
</svg>
</span>
<span class="nav-link-title">Vol Surface</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-radar" width="24"
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 12m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M15 12l-3 -3" />
</svg>
</span>
<span class="nav-link-title">Tracker</span>
</a>
</li>
</ul>
</div>
</div>
</aside>
<!-- ===== Main content ===== -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Options Chain</h2>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- ===== Toolbar ===== -->
<div class="card mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-center">
<!-- Symbol input + Lookup -->
<div class="col-auto">
<label class="form-label visually-hidden" for="input-symbol">Symbol</label>
<div class="input-group input-group-sm">
<span class="input-group-text">Symbol</span>
<input
id="input-symbol"
type="text"
class="form-control text-uppercase fw-bold"
style="width: 6rem;"
x-model="symbol"
placeholder="SPY"
@keydown.enter="fetchExpirations()"
aria-label="Underlying symbol"
/>
<button
class="btn btn-secondary btn-sm"
@click="fetchExpirations()"
:disabled="lookingUp || !symbol"
aria-label="Look up expirations for symbol"
>
<span x-show="lookingUp" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span x-text="lookingUp ? '…' : 'Lookup'"></span>
</button>
</div>
</div>
<!-- Expiry select -->
<div class="col-auto">
<label class="form-label visually-hidden" for="select-expiry">Expiry</label>
<div class="input-group input-group-sm">
<span class="input-group-text">Expiry</span>
<select
id="select-expiry"
class="form-select"
x-model="expiry"
:disabled="expirations.length === 0"
aria-label="Expiration date"
>
<option value="" disabled>-- select --</option>
<template x-for="exp in expirations" :key="exp">
<option :value="exp" x-text="exp"></option>
</template>
</select>
</div>
</div>
<!-- Type selector -->
<div class="col-auto">
<label class="form-label visually-hidden" for="select-type">Type</label>
<div class="input-group input-group-sm">
<span class="input-group-text">Type</span>
<select
id="select-type"
class="form-select"
x-model="optionType"
aria-label="Option type filter"
>
<option value="all">All</option>
<option value="call">Calls</option>
<option value="put">Puts</option>
</select>
</div>
</div>
<!-- Load button -->
<div class="col-auto">
<button
class="btn btn-primary btn-sm"
@click="loadChain()"
:disabled="loading || !expiry"
aria-label="Load options chain"
>
<span
x-show="loading"
class="spinner-border spinner-border-sm me-1"
role="status"
aria-hidden="true"
></span>
<span x-text="loading ? 'Loading…' : 'Load'"></span>
</button>
</div>
<!-- Spot price badge -->
<div class="col-auto ms-auto" x-show="spot > 0">
<span class="badge bg-blue-lt spot-badge fs-6 px-3 py-2">
<span class="text-muted me-1">Spot</span>
<strong x-text="'$' + spot.toFixed(2)"></strong>
</span>
</div>
<!-- Error message -->
<div class="col-12" x-show="error">
<div class="alert alert-danger alert-dismissible py-1 mb-0" role="alert">
<span x-text="error"></span>
<button type="button" class="btn-close" @click="error = ''" aria-label="Dismiss"></button>
</div>
</div>
</div>
</div>
</div>
<!-- ===== Chain tables ===== -->
<div class="row g-3">
<!-- CALLS -->
<div :class="optionType === 'all' ? 'col-12 col-xxl-6' : 'col-12'" x-show="optionType === 'all' || optionType === 'call'">
<div class="card">
<div class="card-header">
<h3 class="card-title d-flex align-items-center gap-2">
<span class="badge bg-success me-1">C</span>
Calls
<span
class="badge bg-secondary ms-2"
x-show="calls.length > 0"
x-text="calls.length + ' strikes'"
></span>
</h3>
</div>
<!-- Empty / loading state -->
<div class="card-body text-center py-4" x-show="calls.length === 0 && !loading">
<div class="text-muted">No data — select a symbol and expiry, then click Load.</div>
</div>
<div class="card-body text-center py-4" x-show="loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading…</span>
</div>
</div>
<div class="chain-scroll" x-show="calls.length > 0 && !loading">
<table
class="table table-vcenter table-hover table-sm table-sticky mb-0"
aria-label="Calls options chain"
>
<thead>
<tr>
<th scope="col">Strike</th>
<th scope="col">Bid</th>
<th scope="col">Ask</th>
<th scope="col">Mid</th>
<th scope="col">IV%</th>
<th scope="col">Delta</th>
<th scope="col">Gamma</th>
<th scope="col">Theta/d</th>
<th scope="col">Vega/1%</th>
<th scope="col">Volume</th>
<th scope="col">OI</th>
<th scope="col">BS Price</th>
</tr>
</thead>
<tbody>
<template x-for="row in calls" :key="row.strike">
<tr :class="rowClass(row, 'call')">
<td class="fw-semibold" x-text="row.strike"></td>
<td x-text="fmt2(row.bid)"></td>
<td x-text="fmt2(row.ask)"></td>
<td x-text="fmt2(row.midPrice)"></td>
<td :class="ivClass(row.iv)" x-text="fmtIV(row.iv)"></td>
<td x-text="fmt4(row.delta)"></td>
<td x-text="fmt4(row.gamma)"></td>
<td class="text-theta" x-text="fmt2(row.theta)"></td>
<td x-text="fmt2(row.vega)"></td>
<td x-text="fmtInt(row.volume)"></td>
<td x-text="fmtInt(row.openInterest)"></td>
<td x-text="fmt2(row.bsPrice)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- PUTS -->
<div :class="optionType === 'all' ? 'col-12 col-xxl-6' : 'col-12'" x-show="optionType === 'all' || optionType === 'put'">
<div class="card">
<div class="card-header">
<h3 class="card-title d-flex align-items-center gap-2">
<span class="badge bg-danger me-1">P</span>
Puts
<span
class="badge bg-secondary ms-2"
x-show="puts.length > 0"
x-text="puts.length + ' strikes'"
></span>
</h3>
</div>
<!-- Empty / loading state -->
<div class="card-body text-center py-4" x-show="puts.length === 0 && !loading">
<div class="text-muted">No data — select a symbol and expiry, then click Load.</div>
</div>
<div class="card-body text-center py-4" x-show="loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading…</span>
</div>
</div>
<div class="chain-scroll" x-show="puts.length > 0 && !loading">
<table
class="table table-vcenter table-hover table-sm table-sticky mb-0"
aria-label="Puts options chain"
>
<thead>
<tr>
<th scope="col">Strike</th>
<th scope="col">Bid</th>
<th scope="col">Ask</th>
<th scope="col">Mid</th>
<th scope="col">IV%</th>
<th scope="col">Delta</th>
<th scope="col">Gamma</th>
<th scope="col">Theta/d</th>
<th scope="col">Vega/1%</th>
<th scope="col">Volume</th>
<th scope="col">OI</th>
<th scope="col">BS Price</th>
</tr>
</thead>
<tbody>
<template x-for="row in puts" :key="row.strike">
<tr :class="rowClass(row, 'put')">
<td class="fw-semibold" x-text="row.strike"></td>
<td x-text="fmt2(row.bid)"></td>
<td x-text="fmt2(row.ask)"></td>
<td x-text="fmt2(row.midPrice)"></td>
<td :class="ivClass(row.iv)" x-text="fmtIV(row.iv)"></td>
<td x-text="fmt4(row.delta)"></td>
<td x-text="fmt4(row.gamma)"></td>
<td class="text-theta" x-text="fmt2(row.theta)"></td>
<td x-text="fmt2(row.vega)"></td>
<td x-text="fmtInt(row.volume)"></td>
<td x-text="fmtInt(row.openInterest)"></td>
<td x-text="fmt2(row.bsPrice)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /row -->
</div><!-- /container-xl -->
</div><!-- /page-body -->
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
Options Pricer &copy; <span x-text="new Date().getFullYear()"></span>
</li>
</ul>
</div>
</div>
</div>
</footer>
</div><!-- /page-wrapper -->
</div><!-- /wrapper -->
<script src="/assets/tabler.min.js" defer></script>
<script src="/assets/alpine.min.js" defer></script>
<script>
function chain() {
return {
// ── state ──────────────────────────────────────────────
symbol: 'SPY',
expiry: '',
optionType: 'all',
expirations: [],
calls: [],
puts: [],
spot: 0,
loading: false,
lookingUp: false,
error: '',
// ── lifecycle ──────────────────────────────────────────
async init() {
// no auto-load — user must click Lookup first
},
// ── data fetching ──────────────────────────────────────
async fetchExpirations() {
if (!this.symbol) return;
this.error = '';
this.lookingUp = true;
this.expirations = [];
this.expiry = '';
this.calls = [];
this.puts = [];
try {
const sym = this.symbol.toUpperCase().trim();
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const env = await res.json();
const data = env.data ?? env;
this.expirations = data.expirations ?? (Array.isArray(data) ? data : []);
if (this.expirations.length > 0) this.expiry = this.expirations[0];
} catch (err) {
this.error = 'Failed to look up symbol: ' + err.message;
} finally {
this.lookingUp = false;
}
},
async loadChain() {
if (!this.expiry) return;
this.error = '';
this.loading = true;
this.calls = [];
this.puts = [];
try {
const sym = (this.symbol || 'SPY').toUpperCase().trim();
const url = `/api/chain?symbol=${encodeURIComponent(sym)}&expiry=${encodeURIComponent(this.expiry)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const env = await res.json();
// API wraps in { ok, data: { snapshots: [{ spot, chain }] } }
const snap = env.data?.snapshots?.[0] ?? env.data ?? {};
this.spot = snap.spot ?? 0;
const chain = snap.chain ?? [];
if (false) {
// placeholder to keep else structure
} else if (Array.isArray(chain)) {
this.calls = this._sortByStrike(
chain.filter(r => (r.optionType || r.type || '').toLowerCase() === 'call')
);
this.puts = this._sortByStrike(
chain.filter(r => (r.optionType || r.type || '').toLowerCase() === 'put')
);
} else {
throw new Error('Unexpected API response shape.');
}
} catch (err) {
this.error = 'Failed to load chain: ' + err.message;
} finally {
this.loading = false;
}
},
// ── helpers ────────────────────────────────────────────
_sortByStrike(arr) {
return [...arr].sort((a, b) => a.strike - b.strike);
},
// ATM strike: the strike (across all rows) closest to spot
get atmStrike() {
const all = this.calls.concat(this.puts);
if (all.length === 0 || this.spot === 0) return null;
return all.reduce((best, row) => {
return Math.abs(row.strike - this.spot) < Math.abs(best.strike - this.spot)
? row
: best;
}).strike;
},
// Row background class
rowClass(row, side) {
const s = row.strike;
if (s === this.atmStrike) return 'row-atm';
if (side === 'call' && s < this.spot) return 'bg-success-lt'; // ITM call
if (side === 'put' && s > this.spot) return 'bg-danger-lt'; // ITM put
return '';
},
// IV colour class
ivClass(iv) {
const pct = (iv ?? 0) * 100;
if (pct < 20) return '';
if (pct < 40) return 'text-yellow fw-semibold';
return 'text-orange fw-semibold';
},
// ── formatters ─────────────────────────────────────────
fmt2(v) { return v == null ? '—' : Number(v).toFixed(2); },
fmt4(v) { return v == null ? '—' : Number(v).toFixed(4); },
fmtIV(iv) {
if (iv == null) return '—';
return (Number(iv) * 100).toFixed(1) + '%';
},
fmtInt(v) {
if (v == null) return '—';
return Number(v).toLocaleString('en-US', { maximumFractionDigits: 0 });
},
};
}
</script>
</body>
</html>

892
frontend/index.html Normal file
View File

@@ -0,0 +1,892 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<title>Options Pricer — Dashboard</title>
<link rel="stylesheet" href="/assets/tabler.min.css">
<link rel="stylesheet" href="/assets/tabler-vendors.min.css">
<script src="/assets/apexcharts.min.js"></script>
<script src="/assets/tabler.min.js" defer></script>
<script src="/assets/alpine.min.js" defer></script>
<style>
:root {
--op-bg: #1e2030;
--op-surface: #252840;
--op-grid: #2d3045;
--op-cyan: #00adb5;
--op-amber: #f59f00;
--op-muted: #6c757d;
}
body {
background-color: var(--op-bg);
}
.navbar-vertical {
background-color: var(--op-surface) !important;
border-right: 1px solid var(--op-grid) !important;
}
.page-wrapper {
background-color: var(--op-bg);
}
.card {
background-color: var(--op-surface);
border: 1px solid var(--op-grid);
}
.card-header {
border-bottom: 1px solid var(--op-grid);
}
/* Stat card value sizing */
.stat-value {
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
}
/* Chart container */
.chart-container {
min-height: 280px;
position: relative;
}
/* Loading overlay */
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(30, 32, 48, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: inherit;
backdrop-filter: blur(2px);
}
/* Toolbar bar */
.toolbar-card {
background-color: var(--op-surface);
border: 1px solid var(--op-grid);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
/* Sidebar active state override */
.navbar-nav .nav-link.active,
.navbar-nav .nav-item.active > .nav-link {
color: var(--op-cyan) !important;
background-color: rgba(0, 173, 181, 0.12) !important;
}
.navbar-nav .nav-link.active svg,
.navbar-nav .nav-item.active > .nav-link svg {
color: var(--op-cyan) !important;
}
/* Badge last-updated */
.badge-updated {
font-size: 0.7rem;
background-color: rgba(255, 255, 255, 0.07);
color: #adb5bd;
border: 1px solid var(--op-grid);
}
/* Color utilities */
.text-positive { color: #2fb344 !important; }
.text-negative { color: #d63939 !important; }
.text-amber { color: var(--op-amber) !important; }
.text-cyan { color: var(--op-cyan) !important; }
/* Stat subtitle */
.stat-subtitle {
color: #6c757d;
font-size: 0.78rem;
margin-top: 0.25rem;
}
/* Spinner color override */
.spinner-border.text-cyan {
color: var(--op-cyan) !important;
}
/* ApexCharts dark text override */
.apexcharts-text, .apexcharts-legend-text {
fill: #adb5bd !important;
color: #adb5bd !important;
}
.apexcharts-tooltip {
background: var(--op-surface) !important;
border: 1px solid var(--op-grid) !important;
color: #e9ecef !important;
}
.apexcharts-tooltip-title {
background: var(--op-grid) !important;
border-bottom: 1px solid var(--op-grid) !important;
color: #e9ecef !important;
}
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="dashboard">
<!-- ===================== SIDEBAR ===================== -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<!-- Mobile toggle -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Brand -->
<h1 class="navbar-brand navbar-brand-autodark">
<a href="index.html" class="d-flex align-items-center gap-2 text-decoration-none">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-cyan" aria-hidden="true">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>
<span class="fw-bold fs-5 text-white">Options Pricer</span>
</a>
</h1>
<!-- Sidebar nav -->
<div class="collapse navbar-collapse" id="sidebar-menu">
<ul class="navbar-nav pt-lg-3">
<!-- Dashboard -->
<li class="nav-item active">
<a class="nav-link active" href="index.html" aria-current="page">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<!-- Options Chain -->
<li class="nav-item">
<a class="nav-link" href="chain.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
</span>
<span class="nav-link-title">Options Chain</span>
</a>
</li>
<!-- Vol Surface -->
<li class="nav-item">
<a class="nav-link" href="surface.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 20h20M5 20V10l7-7 7 7v10"/>
<path d="M9 20v-5h6v5"/>
</svg>
</span>
<span class="nav-link-title">Vol Surface</span>
</a>
</li>
<!-- Tracker -->
<li class="nav-item">
<a class="nav-link" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</span>
<span class="nav-link-title">Tracker</span>
</a>
</li>
</ul>
</div>
</div>
</aside>
<!-- ===================== MAIN CONTENT ===================== -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Dashboard</h2>
<div class="text-secondary small mt-1">Implied volatility analytics &amp; skew metrics</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- ======== TOOLBAR ======== -->
<div class="toolbar-card mb-3" role="search" aria-label="Symbol controls">
<div class="row g-2 align-items-center">
<!-- Symbol input -->
<div class="col-auto">
<label class="form-label visually-hidden" for="input-symbol">Symbol</label>
<div class="input-group input-group-sm">
<span class="input-group-text" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
</span>
<input
id="input-symbol"
type="text"
class="form-control form-control-sm"
placeholder="Symbol"
x-model="symbol"
@keydown.enter="fetchExpirations()"
style="width: 100px; text-transform: uppercase;"
aria-label="Ticker symbol"
/>
</div>
</div>
<!-- Expiry select -->
<div class="col-auto">
<label class="form-label visually-hidden" for="select-expiry">Expiry</label>
<select
id="select-expiry"
class="form-select form-select-sm"
x-model="expiry"
@change="loadData()"
aria-label="Expiration date"
style="min-width: 140px;"
>
<option value="" disabled>Select expiry…</option>
<template x-for="exp in expirations" :key="exp">
<option :value="exp" x-text="exp"></option>
</template>
</select>
</div>
<!-- Refresh button -->
<div class="col-auto">
<button
class="btn btn-sm btn-primary d-inline-flex align-items-center gap-1"
type="button"
@click="refresh()"
:disabled="loading"
aria-label="Refresh data"
>
<span x-show="loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<svg x-show="!loading" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh
</button>
</div>
<!-- Last updated badge -->
<div class="col-auto ms-auto d-flex align-items-center gap-2">
<template x-if="lastUpdated">
<span class="badge badge-updated d-inline-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
Updated <span x-text="lastUpdated"></span>
</span>
</template>
</div>
</div>
</div>
<!-- ======== ERROR ALERT ======== -->
<template x-if="error">
<div class="alert alert-danger alert-dismissible mb-3" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="alert-icon me-2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span x-text="error"></span>
<button type="button" class="btn-close" @click="error = null" aria-label="Close"></button>
</div>
</template>
<!-- ======== STAT CARDS ======== -->
<div class="row row-cards mb-3" aria-label="Key metrics">
<!-- ATM IV -->
<div class="col-sm-6 col-lg-3">
<div class="card h-100" style="position: relative;">
<!-- Loading overlay -->
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
</div>
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-3">
<div class="subheader text-secondary fw-medium small">ATM IV</div>
<span class="badge text-bg-secondary" style="font-size: 0.65rem;">LIVE</span>
</div>
<div class="d-flex align-items-baseline gap-2">
<div class="stat-value text-cyan" x-text="atmIv" aria-live="polite"></div>
</div>
<div class="stat-subtitle">At-the-money implied vol</div>
</div>
</div>
</div>
<!-- 25d Risk Reversal -->
<div class="col-sm-6 col-lg-3">
<div class="card h-100" style="position: relative;">
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
</div>
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-3">
<div class="subheader text-secondary fw-medium small">25d Risk Reversal</div>
<span
class="badge"
:class="rr25 >= 0 ? 'text-bg-success' : 'text-bg-danger'"
aria-hidden="true"
x-text="rr25 >= 0 ? 'CALL SKEW' : 'PUT SKEW'"
></span>
</div>
<div class="d-flex align-items-baseline gap-2">
<div
class="stat-value"
:class="rr25 >= 0 ? 'text-positive' : 'text-negative'"
x-text="formatPct(rr25)"
aria-live="polite"
></div>
</div>
<div class="stat-subtitle">Call IV Put IV at 25&Delta;</div>
</div>
</div>
</div>
<!-- 25d Butterfly -->
<div class="col-sm-6 col-lg-3">
<div class="card h-100" style="position: relative;">
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
</div>
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-3">
<div class="subheader text-secondary fw-medium small">25d Butterfly</div>
<span
class="badge"
:class="fly25 > 0 ? 'text-bg-warning' : 'text-bg-secondary'"
aria-hidden="true"
x-text="fly25 > 0 ? 'CONVEX' : 'FLAT'"
></span>
</div>
<div class="d-flex align-items-baseline gap-2">
<div
class="stat-value"
:class="fly25 > 0 ? 'text-amber' : 'text-secondary'"
x-text="formatPct(fly25)"
aria-live="polite"
></div>
</div>
<div class="stat-subtitle">Wing IV ATM IV</div>
</div>
</div>
</div>
<!-- Underlying Price -->
<div class="col-sm-6 col-lg-3">
<div class="card h-100" style="position: relative;">
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
</div>
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-3">
<div class="subheader text-secondary fw-medium small">Underlying Price</div>
<span class="badge text-bg-secondary" style="font-size: 0.65rem;" x-text="symbol.toUpperCase()"></span>
</div>
<div class="d-flex align-items-baseline gap-2">
<div class="stat-value text-white" x-text="formatSpot(spot)" aria-live="polite"></div>
</div>
<div class="stat-subtitle" x-text="symbol.toUpperCase() + ' last price'"></div>
</div>
</div>
</div>
</div>
<!-- /stat cards -->
<!-- ======== CHARTS ROW ======== -->
<div class="row row-cards">
<!-- RR25 Trend Chart -->
<div class="col-lg-6">
<div class="card" style="position: relative;">
<div class="loading-overlay" x-show="loading && snapshots.length === 0" aria-label="Loading chart" role="status">
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
</div>
<div class="card-header">
<h3 class="card-title d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#00adb5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>
Risk Reversal Trend (25&Delta;)
</h3>
<div class="card-options">
<span class="text-secondary small">Last 30 snapshots</span>
</div>
</div>
<div class="card-body chart-container p-2">
<div id="chart-rr25" style="min-height: 260px;" aria-label="Risk Reversal 25 delta trend chart" role="img"></div>
</div>
</div>
</div>
<!-- Fly25 Trend Chart -->
<div class="col-lg-6">
<div class="card" style="position: relative;">
<div class="loading-overlay" x-show="loading && snapshots.length === 0" aria-label="Loading chart" role="status">
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
</div>
<div class="card-header">
<h3 class="card-title d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="12" width="4" height="8" rx="1"/>
<rect x="10" y="7" width="4" height="13" rx="1"/>
<rect x="17" y="4" width="4" height="16" rx="1"/>
</svg>
Butterfly Trend (25&Delta; Fly)
</h3>
<div class="card-options">
<span class="text-secondary small">Last 30 snapshots</span>
</div>
</div>
<div class="card-body chart-container p-2">
<div id="chart-fly25" style="min-height: 260px;" aria-label="Butterfly 25 delta trend chart" role="img"></div>
</div>
</div>
</div>
</div>
<!-- /charts row -->
</div>
<!-- /container-xl -->
</div>
<!-- /page-body -->
<!-- Footer -->
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item text-secondary small">
Options Pricer &copy; <span x-text="new Date().getFullYear()"></span>
</li>
<li class="list-inline-item text-secondary small">
Powered by Tabler &amp; ApexCharts
</li>
</ul>
</div>
</div>
</div>
</footer>
</div>
<!-- /page-wrapper -->
</div>
<!-- /wrapper -->
<script>
// =========================================================
// Chart instances (module-level so we can destroy/re-render)
// =========================================================
let chartRR25 = null;
let chartFly25 = null;
// =========================================================
// Utility: format a timestamp (ISO or epoch ms) → HH:mm MM/DD
// =========================================================
function fmtTimestamp(ts) {
const d = new Date(ts);
if (isNaN(d.getTime())) return String(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const mo = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${hh}:${mm} ${mo}/${dd}`;
}
// =========================================================
// Base chart options (dark theme)
// =========================================================
function baseChartOptions(categories) {
return {
chart: {
background: '#252840',
foreColor: '#adb5bd',
toolbar: { show: false },
animations: { enabled: true, speed: 400 },
fontFamily: 'inherit',
},
theme: { mode: 'dark' },
grid: {
borderColor: '#2d3045',
strokeDashArray: 3,
xaxis: { lines: { show: false } },
yaxis: { lines: { show: true } },
padding: { top: 0, right: 16, bottom: 0, left: 8 },
},
xaxis: {
categories: categories,
labels: {
style: { colors: '#adb5bd', fontSize: '11px' },
rotate: -30,
maxHeight: 64,
},
axisBorder: { color: '#2d3045' },
axisTicks: { color: '#2d3045' },
tooltip: { enabled: false },
},
yaxis: {
labels: {
style: { colors: '#adb5bd', fontSize: '11px' },
formatter: (val) => val.toFixed(4),
},
},
tooltip: {
theme: 'dark',
y: { formatter: (val) => val.toFixed(4) },
x: { formatter: (val) => val },
},
dataLabels: { enabled: false },
legend: { show: false },
};
}
// =========================================================
// Render / re-render RR25 line chart
// =========================================================
function renderRR25Chart(snapshots) {
const el = document.getElementById('chart-rr25');
if (!el) return;
const categories = snapshots.map(s => fmtTimestamp(s.timestamp ?? s.createdAt ?? s.ts));
const seriesData = snapshots.map(s => {
const val = s.rr25 ?? s.skewMetrics?.[0]?.rr25 ?? null;
return val !== null ? parseFloat(val) : null;
});
if (chartRR25) {
chartRR25.destroy();
chartRR25 = null;
}
const opts = {
...baseChartOptions(categories),
chart: {
...baseChartOptions(categories).chart,
type: 'line',
height: 260,
},
series: [{
name: 'RR25',
data: seriesData,
}],
stroke: {
curve: 'smooth',
width: 2.5,
colors: ['#00adb5'],
},
markers: {
size: 3,
colors: ['#00adb5'],
strokeColors: '#252840',
strokeWidth: 2,
hover: { size: 5 },
},
fill: {
type: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
shadeIntensity: 0.4,
gradientToColors: ['#1e2030'],
stops: [0, 100],
opacityFrom: 0.3,
opacityTo: 0.02,
},
},
colors: ['#00adb5'],
yaxis: {
...baseChartOptions(categories).yaxis,
title: {
text: 'RR25 (decimal)',
style: { color: '#6c757d', fontSize: '11px' },
},
},
annotations: {
yaxis: [{
y: 0,
borderColor: '#6c757d',
strokeDashArray: 4,
label: {
text: '0',
style: { color: '#6c757d', background: 'transparent', fontSize: '10px' },
},
}],
},
};
chartRR25 = new ApexCharts(el, opts);
chartRR25.render();
}
// =========================================================
// Render / re-render Fly25 bar chart
// =========================================================
function renderFly25Chart(snapshots) {
const el = document.getElementById('chart-fly25');
if (!el) return;
const categories = snapshots.map(s => fmtTimestamp(s.timestamp ?? s.createdAt ?? s.ts));
const rawData = snapshots.map(s => {
const val = s.fly25 ?? s.skewMetrics?.[0]?.fly25 ?? null;
return val !== null ? parseFloat(val) : 0;
});
// Distributed colors: amber for positive, muted grey for non-positive
const barColors = rawData.map(v => v > 0 ? '#f59f00' : '#6c757d');
if (chartFly25) {
chartFly25.destroy();
chartFly25 = null;
}
const opts = {
...baseChartOptions(categories),
chart: {
...baseChartOptions(categories).chart,
type: 'bar',
height: 260,
},
series: [{
name: 'Fly25',
data: rawData,
}],
plotOptions: {
bar: {
distributed: true,
borderRadius: 3,
columnWidth: '60%',
dataLabels: { position: 'top' },
},
},
colors: barColors,
yaxis: {
...baseChartOptions(categories).yaxis,
title: {
text: 'Fly25 (decimal)',
style: { color: '#6c757d', fontSize: '11px' },
},
},
annotations: {
yaxis: [{
y: 0,
borderColor: '#6c757d',
strokeDashArray: 4,
label: {
text: '0',
style: { color: '#6c757d', background: 'transparent', fontSize: '10px' },
},
}],
},
tooltip: {
theme: 'dark',
y: { formatter: (val) => val.toFixed(4) },
x: { formatter: (val) => val },
},
};
chartFly25 = new ApexCharts(el, opts);
chartFly25.render();
}
// =========================================================
// Alpine.js component
// =========================================================
document.addEventListener('alpine:init', () => {
Alpine.data('dashboard', () => ({
// ---- state ----
symbol: 'SPY',
expiry: '',
expirations: [],
analytics: {},
snapshots: [],
loading: false,
lastUpdated: '',
error: null,
// ---- lifecycle ----
async init() {
await this.fetchExpirations();
if (this.expiry) await this.loadData();
},
// ---- data fetching ----
async fetchExpirations() {
try {
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(this.symbol)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const env = await res.json();
const data = env.data ?? env;
this.expirations = Array.isArray(data) ? data : (data.expirations ?? []);
if (this.expirations.length > 0 && !this.expiry) {
this.expiry = this.expirations[0];
}
} catch (e) {
this.error = 'Failed to load expirations: ' + e.message;
}
},
async loadData() {
this.loading = true;
this.error = null;
try {
const sym = encodeURIComponent(this.symbol);
const exp = encodeURIComponent(this.expiry);
const [analyticsRes, snapshotsRes] = await Promise.all([
fetch(`/api/analytics?symbol=${sym}&expiry=${exp}`),
fetch(`/api/snapshots?symbol=${sym}&limit=30`),
]);
if (!analyticsRes.ok) throw new Error(`Analytics API: HTTP ${analyticsRes.status}`);
if (!snapshotsRes.ok) throw new Error(`Snapshots API: HTTP ${snapshotsRes.status}`);
const analyticsEnv = await analyticsRes.json();
this.analytics = analyticsEnv.data ?? analyticsEnv;
const snapshotEnv = await snapshotsRes.json();
this.snapshots = snapshotEnv.data?.snapshots ?? snapshotEnv.snapshots ?? [];
this.lastUpdated = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Wait a tick for DOM, then render charts
await this.$nextTick();
renderRR25Chart(this.snapshots);
renderFly25Chart(this.snapshots);
} catch (e) {
console.error('loadData failed:', e);
this.error = `Failed to load data: ${e.message}`;
// In dev/demo, render placeholder charts with mock data
const mockSnaps = this._mockSnapshots();
this.snapshots = mockSnaps;
await this.$nextTick();
renderRR25Chart(mockSnaps);
renderFly25Chart(mockSnaps);
} finally {
this.loading = false;
}
},
async refresh() {
this.loading = true;
this.error = null;
try {
const res = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol: this.symbol, expiry: this.expiry }),
});
if (!res.ok) throw new Error(`Refresh API: HTTP ${res.status}`);
} catch (e) {
console.warn('refresh failed:', e.message);
// Non-fatal — proceed to reload data anyway
}
await this.fetchExpirations();
await this.loadData();
},
// ---- computed getters ----
get atmIv() {
const v = this.analytics.atmIv;
if (v == null || isNaN(Number(v))) return '—';
return (Number(v) * 100).toFixed(2) + '%';
},
get rr25() {
const sk = this.analytics.skewMetrics;
if (sk && !Array.isArray(sk)) return sk[this.expiry]?.rr25 ?? 0;
if (Array.isArray(sk) && sk.length > 0) return sk[0].rr25 ?? 0;
return 0;
},
get fly25() {
const sk = this.analytics.skewMetrics;
if (sk && !Array.isArray(sk)) return sk[this.expiry]?.fly25 ?? 0;
if (Array.isArray(sk) && sk.length > 0) return sk[0].fly25 ?? 0;
return 0;
},
get spot() {
return this.analytics.spot ?? this.analytics.underlyingPrice ?? 0;
},
// ---- format helpers ----
formatSigned(val, dp = 4) {
if (val == null || isNaN(Number(val))) return '—';
const n = Number(val);
const sign = n > 0 ? '+' : '';
return sign + n.toFixed(dp);
},
formatPct(val) {
if (val == null || isNaN(Number(val))) return '—';
const n = Number(val) * 100;
const sign = n > 0 ? '+' : '';
return sign + n.toFixed(2) + '%';
},
formatSpot(val) {
if (val == null || isNaN(Number(val))) return '—';
return '$' + Number(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
// ---- dev/demo mock data ----
_mockSnapshots() {
const now = Date.now();
const interval = 5 * 60 * 1000; // 5 min
return Array.from({ length: 30 }, (_, i) => {
const t = now - (29 - i) * interval;
const base = 0.002;
const rr = base + (Math.random() - 0.5) * 0.006;
const fly = Math.abs(Math.random() * 0.004) - 0.001;
return {
timestamp: new Date(t).toISOString(),
rr25: parseFloat(rr.toFixed(5)),
fly25: parseFloat(fly.toFixed(5)),
};
});
},
})); // end Alpine.data('dashboard')
}); // end alpine:init
</script>
</body>
</html>

894
frontend/surface.html Normal file
View File

@@ -0,0 +1,894 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vol Surface — Options Pricer</title>
<link rel="stylesheet" href="/assets/tabler.min.css">
<link rel="stylesheet" href="/assets/tabler-vendors.min.css">
<script src="/assets/apexcharts.min.js"></script>
<script src="/assets/alpine.min.js" defer></script>
<script src="/assets/tabler.min.js" defer></script>
<style>
.navbar-vertical {
width: 15rem;
}
.navbar-vertical .navbar-brand {
padding: 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.chart-card {
background: #1e2030;
border: 1px solid #2d3045;
border-radius: 0.5rem;
}
.chart-card .card-header {
background: transparent;
border-bottom: 1px solid #2d3045;
}
#skewChart,
#termChart {
background: #1e2030;
border-radius: 0 0 0.5rem 0.5rem;
}
.badge-metric {
font-size: 0.9rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
}
.stat-inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
}
.stat-inline .stat-label {
color: #8b95a7;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stat-inline .stat-value {
font-size: 1.05rem;
font-weight: 700;
color: #ffffff;
}
.stat-inline .stat-value.positive {
color: #51cf66;
}
.stat-inline .stat-value.negative {
color: #ff6b6b;
}
.stat-inline .stat-value.amber {
color: #ffd43b;
}
[x-cloak] {
display: none !important;
}
.page-wrapper {
padding-top: 0;
}
.apexcharts-tooltip {
background: #1e2030 !important;
border: 1px solid #2d3045 !important;
color: #ffffff !important;
}
.apexcharts-tooltip-title {
background: #2d3045 !important;
border-bottom: 1px solid #3a3f5a !important;
}
.skew-table th {
background: #1a1c2e;
color: #8b95a7;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid #2d3045;
}
.skew-table td {
border-bottom: 1px solid #1e2030;
color: #d0d5e0;
font-size: 0.875rem;
}
.skew-table tbody tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
.skew-table .mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.82rem;
}
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="surfaceApp()" x-init="init()">
<!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu"
aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-brand">
<a href="index.html" class="d-flex align-items-center gap-2 text-decoration-none">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="text-primary">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline>
<polyline points="16 7 22 7 22 13"></polyline>
</svg>
<span class="fw-bold text-white fs-5">Options Pricer</span>
</a>
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item">
<a class="nav-link" href="index.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="chain.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</span>
<span class="nav-link-title">Options Chain</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="surface.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M3 3v18h18"></path>
<path d="M7 16l4-8 4 4 4-4"></path>
</svg>
</span>
<span class="nav-link-title">Vol Surface</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</span>
<span class="nav-link-title">Tracker</span>
</a>
</li>
</ul>
</div>
</div>
</aside>
<!-- Page content -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Volatility Surface</h2>
<div class="text-secondary mt-1">IV skew analysis and term structure</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- Toolbar -->
<div class="card mb-4" style="background:#161824; border:1px solid #2d3045;">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-sm-auto">
<label class="form-label text-secondary" for="symbolInput">Symbol</label>
<div class="input-group">
<input
id="symbolInput"
type="text"
class="form-control"
style="background:#1e2030; border-color:#2d3045; color:#fff; width:100px; text-transform:uppercase;"
placeholder="SPY"
x-model="symbol"
@keydown.enter="fetchExpirations()"
@input="symbol = symbol.toUpperCase()"
:disabled="loading"
aria-label="Ticker symbol"
>
<button
class="btn btn-secondary"
@click="fetchExpirations()"
:disabled="lookingUp || !symbol"
aria-label="Look up expirations for symbol"
>
<span x-show="lookingUp" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span x-text="lookingUp ? '…' : 'Lookup'"></span>
</button>
</div>
</div>
<div class="col-12 col-sm-auto">
<label class="form-label text-secondary" for="expirySelect">Expiry</label>
<select
id="expirySelect"
class="form-select"
style="background:#1e2030; border-color:#2d3045; color:#fff; min-width:160px;"
x-model="expiry"
:disabled="loading || expirations.length === 0"
aria-label="Select expiry date"
>
<option value="" disabled>Select expiry…</option>
<template x-for="exp in expirations" :key="exp">
<option :value="exp" x-text="exp"></option>
</template>
</select>
</div>
<div class="col-12 col-sm-auto">
<button
class="btn btn-primary"
@click="loadSurface()"
:disabled="loading || !symbol || !expiry"
aria-label="Load volatility surface"
>
<span x-show="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span x-text="loading ? 'Loading…' : 'Load Surface'"></span>
</button>
</div>
<div class="col-12 col-sm-auto ms-sm-auto" x-show="errorMsg" x-cloak>
<div class="alert alert-danger mb-0 py-2 px-3" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" class="me-1">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span x-text="errorMsg"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Skew metric badges -->
<div class="mb-4" x-show="hasData" x-cloak>
<div class="d-flex flex-wrap gap-3">
<div class="stat-inline">
<span class="stat-label">ATM IV</span>
<span class="stat-value" x-text="formatPct(currentMetrics.atmIV)"></span>
</div>
<div class="stat-inline">
<span class="stat-label">RR25</span>
<span
class="stat-value"
:class="{
'positive': currentMetrics.rr25 > 0.005,
'negative': currentMetrics.rr25 < -0.005
}"
x-text="formatPctSigned(currentMetrics.rr25)"
></span>
</div>
<div class="stat-inline">
<span class="stat-label">Fly25</span>
<span
class="stat-value"
:class="{ 'amber': Math.abs(currentMetrics.fly25) > 0.002 }"
x-text="formatPctSigned(currentMetrics.fly25)"
></span>
</div>
</div>
</div>
<!-- Greeks card -->
<div class="card mb-4" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
<div class="card-header" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">Greeks</h3>
<div class="card-options">
<span class="text-secondary small">ATM &amp; nearest ITM · <span x-text="expiry"></span></span>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-vcenter skew-table mb-0">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col" class="text-end">Strike</th>
<th scope="col" class="text-end">IV</th>
<th scope="col" class="text-end">Mid</th>
<th scope="col" class="text-end">Delta</th>
<th scope="col" class="text-end">Gamma</th>
<th scope="col" class="text-end">Theta</th>
<th scope="col" class="text-end">Vega</th>
</tr>
</thead>
<tbody>
<template x-if="greeks.atmCall">
<tr>
<td><span class="badge bg-blue-lt text-blue">ATM Call</span></td>
<td class="mono text-end" x-text="greeks.atmCall.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.atmCall.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.atmCall.midPrice)"></td>
<td class="mono text-end text-success" x-text="fmtGreek(greeks.atmCall.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.atmCall.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmCall.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmCall.vega)"></td>
</tr>
</template>
<template x-if="greeks.atmPut">
<tr>
<td><span class="badge bg-orange-lt text-orange">ATM Put</span></td>
<td class="mono text-end" x-text="greeks.atmPut.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.atmPut.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.atmPut.midPrice)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.atmPut.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmPut.vega)"></td>
</tr>
</template>
<template x-if="greeks.itmCall">
<tr style="border-top:1px solid #2d3045;">
<td><span class="badge bg-blue-lt" style="color:#8ec8ff;">ITM Call</span></td>
<td class="mono text-end" x-text="greeks.itmCall.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.itmCall.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.itmCall.midPrice)"></td>
<td class="mono text-end text-success" x-text="fmtGreek(greeks.itmCall.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.itmCall.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmCall.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmCall.vega)"></td>
</tr>
</template>
<template x-if="greeks.itmPut">
<tr>
<td><span class="badge bg-orange-lt" style="color:#ffb347;">ITM Put</span></td>
<td class="mono text-end" x-text="greeks.itmPut.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.itmPut.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.itmPut.midPrice)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.itmPut.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmPut.vega)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Charts row -->
<div class="row g-4 mb-4" x-show="hasData" x-cloak>
<!-- IV Skew chart -->
<div class="col-12 col-xl-7">
<div class="chart-card">
<div class="card-header">
<h3 class="card-title text-white mb-0">
IV Skew —
<span x-text="symbol"></span>
<span class="text-secondary ms-1" x-text="expiry"></span>
</h3>
</div>
<div class="card-body p-0">
<div id="skewChart" style="min-height:320px;" role="img" aria-label="IV skew line chart showing calls and puts by strike price"></div>
</div>
</div>
</div>
<!-- Term structure chart -->
<div class="col-12 col-xl-5">
<div class="chart-card">
<div class="card-header">
<h3 class="card-title text-white mb-0">ATM IV Term Structure</h3>
</div>
<div class="card-body p-0">
<div id="termChart" style="min-height:320px;" role="img" aria-label="ATM IV bar chart across expiry dates"></div>
</div>
</div>
</div>
</div>
<!-- Skew table -->
<div class="card" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
<div class="card-header" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">Per-Expiry Skew Metrics</h3>
<div class="card-options">
<span class="text-secondary small" x-text="`${skewTable.length} expiries`"></span>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter skew-table mb-0" aria-label="Per-expiry skew metrics table">
<thead>
<tr>
<th scope="col">Expiry</th>
<th scope="col" class="text-end">ATM IV</th>
<th scope="col" class="text-end">RR25</th>
<th scope="col" class="text-end">RR10</th>
<th scope="col" class="text-end">Fly25</th>
<th scope="col" class="text-center">Skew Direction</th>
</tr>
</thead>
<tbody>
<template x-for="row in skewTable" :key="row.expiry">
<tr>
<td class="mono" x-text="row.expiry"></td>
<td class="mono text-end" x-text="formatPct(row.atmIV)"></td>
<td
class="mono text-end"
:class="{
'text-success': row.rr25 > 0.005,
'text-danger': row.rr25 < -0.005
}"
x-text="formatPctSigned(row.rr25)"
></td>
<td
class="mono text-end"
:class="{
'text-success': row.rr10 > 0.005,
'text-danger': row.rr10 < -0.005
}"
x-text="formatPctSigned(row.rr10)"
></td>
<td
class="mono text-end"
:class="{ 'text-warning': Math.abs(row.fly25) > 0.002 }"
x-text="formatPctSigned(row.fly25)"
></td>
<td class="text-center">
<span
class="badge"
:class="{
'bg-danger-lt text-danger': row.rr25 < -0.005,
'bg-success-lt text-success': row.rr25 > 0.005,
'bg-secondary-lt text-secondary': row.rr25 >= -0.005 && row.rr25 <= 0.005
}"
x-text="row.rr25 < -0.005 ? 'Put Skew' : row.rr25 > 0.005 ? 'Call Skew' : 'Flat'"
></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Empty state -->
<div class="card text-center py-5" style="background:#161824; border:1px solid #2d3045;" x-show="!hasData && !loading" x-cloak>
<div class="card-body">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" class="text-secondary mb-3">
<path d="M3 3v18h18"></path>
<path d="M7 16l4-8 4 4 4-4"></path>
</svg>
<h3 class="text-secondary">No surface loaded</h3>
<p class="text-muted">Enter a symbol and click Lookup, then select an expiry and click Load Surface.</p>
</div>
</div>
<!-- Loading skeleton -->
<div x-show="loading" x-cloak>
<div class="row g-4 mb-4">
<div class="col-12 col-xl-7">
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
<div class="card-body d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading skew chart…</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
<div class="card-body d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading term structure chart…</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const CHART_BG = '#1e2030';
const CHART_GRID = '#2d3045';
const CHART_LABEL = '#8b95a7';
const CHART_TOOLTIP_BG = '#1a1c2e';
const COLOR_CALL = '#4d9ef7'; // blue
const COLOR_PUT = '#ff8c42'; // orange
const BASE_CHART_OPTS = {
chart: {
background: CHART_BG,
foreColor: CHART_LABEL,
toolbar: { show: false },
fontFamily: 'inherit',
animations: { enabled: true, speed: 400 }
},
grid: {
borderColor: CHART_GRID,
strokeDashArray: 3
},
tooltip: {
theme: 'dark',
style: { fontSize: '12px' }
},
dataLabels: { enabled: false },
legend: {
labels: { colors: '#d0d5e0' }
}
};
function surfaceApp() {
return {
symbol: 'SPY',
expiry: '',
expirations: [],
analytics: {},
loading: false,
lookingUp: false,
hasData: false,
errorMsg: '',
skewTable: [],
currentMetrics: { atmIV: 0, rr25: 0, fly25: 0 },
greeks: { atmCall: null, atmPut: null, itmCall: null, itmPut: null },
skewChartInstance: null,
termChartInstance: null,
async init() {
// no auto-load — user must click Lookup first
},
async fetchExpirations() {
if (!this.symbol) return;
this.errorMsg = '';
this.lookingUp = true;
this.expirations = [];
this.expiry = '';
this.hasData = false;
try {
const sym = this.symbol.toUpperCase().trim();
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const env = await res.json();
const data = env.data ?? env;
this.expirations = data.expirations || (Array.isArray(data) ? data : []);
if (this.expirations.length > 0) this.expiry = this.expirations[0];
} catch (err) {
this.errorMsg = 'Failed to look up symbol: ' + err.message;
} finally {
this.lookingUp = false;
}
},
async loadSurface() {
if (!this.symbol || !this.expiry) return;
this.loading = true;
this.errorMsg = '';
try {
const res = await fetch(
`/api/analytics?symbol=${encodeURIComponent(this.symbol)}&expiry=${encodeURIComponent(this.expiry)}`
);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const env = await res.json();
const data = env.data ?? env;
this.analytics = data;
this._processAnalytics(data);
} catch (err) {
this.errorMsg = 'Failed to load surface: ' + err.message;
} finally {
this.loading = false;
}
},
_processAnalytics(data) {
const volSurface = data.volSurface || {};
// skewMetrics is an object keyed by expiry string
const skewMetrics = data.skewMetrics || {};
// Current expiry metrics (field name is atmIv, lowercase v)
const currentSkew = skewMetrics[this.expiry] || {};
this.currentMetrics = {
atmIV: currentSkew.atmIv ?? 0,
rr25: currentSkew.rr25 ?? 0,
fly25: currentSkew.fly25 ?? 0
};
// Build skew table from all expiries
const allExpiries = volSurface.expiries || Object.keys(skewMetrics);
this.skewTable = allExpiries.map(exp => {
const m = skewMetrics[exp] || {};
return {
expiry: exp,
atmIV: m.atmIv ?? 0,
rr25: m.rr25 ?? 0,
rr10: m.rr10 ?? 0,
fly25: m.fly25 ?? 0
};
});
this.greeks = data.greeks || { atmCall: null, atmPut: null, itmCall: null, itmPut: null };
this.hasData = true;
// Wait a tick for x-show to render the divs
this.$nextTick(() => {
this._renderSkewChart(data);
this._renderTermChart(data);
});
},
_renderSkewChart(data) {
const strikes = data.strikes || [];
const callIVs = data.callIVs || [];
const putIVs = data.putIVs || [];
const spotPrice = data.spot ?? null;
const callSeries = callIVs.map(v => v != null ? parseFloat((v * 100).toFixed(2)) : null);
const putSeries = putIVs.map(v => v != null ? parseFloat((v * 100).toFixed(2)) : null);
const annotations = spotPrice != null ? {
xaxis: [{
x: spotPrice,
borderColor: '#ffd43b',
strokeDashArray: 5,
label: {
text: `Spot ${spotPrice}`,
style: { color: '#ffd43b', background: '#1a1c2e', fontSize: '11px' }
}
}]
} : {};
const opts = {
...BASE_CHART_OPTS,
chart: {
...BASE_CHART_OPTS.chart,
type: 'line',
height: 320
},
series: [
{ name: 'Calls', data: callSeries, color: COLOR_CALL },
{ name: 'Puts', data: putSeries, color: COLOR_PUT }
],
xaxis: {
categories: strikes,
title: { text: 'Strike', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
rotate: -30,
formatter: v => Number(v).toFixed(0)
},
axisBorder: { color: CHART_GRID },
axisTicks: { color: CHART_GRID }
},
yaxis: {
title: { text: 'Implied Volatility (%)', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
formatter: v => v != null ? `${v.toFixed(1)}%` : ''
}
},
stroke: { width: 2, curve: 'smooth' },
markers: { size: 0 },
annotations
};
if (this.skewChartInstance) {
this.skewChartInstance.updateOptions(opts, true, true);
} else {
this.skewChartInstance = new ApexCharts(document.getElementById('skewChart'), opts);
this.skewChartInstance.render();
}
},
_renderTermChart(data) {
const skewMetrics = data.skewMetrics || {};
const volSurface = data.volSurface || {};
const allExpiries = volSurface.expiries || Object.keys(skewMetrics) || [];
const atmValues = allExpiries.map(exp => {
const m = skewMetrics[exp] || {};
return m.atmIv != null ? parseFloat((m.atmIv * 100).toFixed(2)) : 0;
});
// Color gradient: blue (near) to green (far)
const barColors = allExpiries.map((_, i) => {
const ratio = allExpiries.length > 1 ? i / (allExpiries.length - 1) : 0;
const r = Math.round((1 - ratio) * 0x4d + ratio * 0x40);
const g = Math.round((1 - ratio) * 0x9e + ratio * 0xc0);
const b = Math.round((1 - ratio) * 0xf7 + ratio * 0x60);
return `rgb(${r},${g},${b})`;
});
const opts = {
...BASE_CHART_OPTS,
chart: {
...BASE_CHART_OPTS.chart,
type: 'bar',
height: 320
},
series: [{
name: 'ATM IV',
data: atmValues
}],
colors: barColors,
plotOptions: {
bar: {
distributed: true,
borderRadius: 4,
columnWidth: '60%'
}
},
xaxis: {
categories: allExpiries,
title: { text: 'Expiry', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
rotate: -40,
rotateAlways: allExpiries.length > 6
},
axisBorder: { color: CHART_GRID },
axisTicks: { color: CHART_GRID }
},
yaxis: {
title: { text: 'ATM IV (%)', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
formatter: v => `${v.toFixed(1)}%`
}
},
legend: { show: false },
tooltip: {
...BASE_CHART_OPTS.tooltip,
y: {
formatter: v => `${v.toFixed(2)}%`
}
}
};
if (this.termChartInstance) {
this.termChartInstance.updateOptions(opts, true, true);
} else {
this.termChartInstance = new ApexCharts(document.getElementById('termChart'), opts);
this.termChartInstance.render();
}
},
// Greeks formatting helpers
fmtPct(v) { return v == null || isNaN(v) ? '—' : `${(v*100).toFixed(2)}%`; },
fmtPrice(v) { return v == null || isNaN(v) ? '—' : `$${v.toFixed(2)}`; },
fmtGreek(v) { return v == null || isNaN(v) ? '—' : v.toFixed(4); },
fmtGreek4(v) { return v == null || isNaN(v) ? '—' : v.toFixed(5); },
// Formatting helpers
formatPct(v) {
if (v == null || isNaN(v)) return '—';
return `${(v * 100).toFixed(2)}%`;
},
formatPctSigned(v) {
if (v == null || isNaN(v)) return '—';
const pct = (v * 100).toFixed(2);
return v >= 0 ? `+${pct}%` : `${pct}%`;
},
// Demo data for development / when API is unavailable
_demoExpirations() {
return [
'2025-05-16', '2025-05-23', '2025-06-20',
'2025-07-18', '2025-09-19', '2025-12-19', '2026-01-16'
];
},
_demoAnalytics() {
const expiries = this._demoExpirations();
const strikes = [];
const callIVs = [];
const putIVs = [];
const spot = 540;
for (let k = spot - 60; k <= spot + 60; k += 5) {
strikes.push(k);
const moneyness = (k - spot) / spot;
// Put skew: OTM puts have higher IV
const callIV = 0.14 + moneyness * 0.05 + Math.random() * 0.005;
const putIV = 0.14 - moneyness * 0.08 + Math.random() * 0.005;
callIVs.push(Math.max(0.05, callIV));
putIVs.push(Math.max(0.05, putIV));
}
const skewMetrics = {};
expiries.forEach((exp, i) => {
const dte = (i + 1) * 30;
const baseVol = 0.12 + 0.04 * Math.sqrt(dte / 30);
skewMetrics[exp] = {
atmIv: baseVol + (Math.random() - 0.5) * 0.01,
rr25: -0.02 - i * 0.003 + (Math.random() - 0.5) * 0.004,
rr10: -0.035 - i * 0.004,
fly25: 0.005 + i * 0.001
};
});
return {
spot,
strikes,
callIVs,
putIVs,
volSurface: { expiries },
skewMetrics
};
}
};
}
</script>
</body>
</html>

714
frontend/tracker.html Normal file
View File

@@ -0,0 +1,714 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<title>Options Metrics Tracker</title>
<link rel="stylesheet" href="/assets/tabler.min.css"/>
<link rel="stylesheet" href="/assets/tabler-vendors.min.css"/>
<script src="/assets/apexcharts.min.js"></script>
<script src="/assets/alpine.min.js" defer></script>
<script src="/assets/tabler.min.js" defer></script>
<style>
:root {
--chart-bg: #1a1c23;
--chart-grid: rgba(255, 255, 255, 0.06);
--chart-label: #8b95a1;
}
body {
background-color: #1a1c23;
}
.navbar-vertical.navbar-dark {
background-color: #14161c;
border-right: 1px solid rgba(255, 255, 255, 0.07);
}
.chart-card {
background-color: var(--chart-bg);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px;
}
.apexcharts-canvas {
background: transparent !important;
}
.table-snapshots th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--chart-label);
font-weight: 600;
}
.toolbar-bar {
background-color: #14161c;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.nav-link.active-page {
color: #4fc3f7 !important;
background-color: rgba(79, 195, 247, 0.1) !important;
border-radius: 6px;
}
.badge-fat-tails {
background-color: #f59f00;
color: #1a1c23;
}
.badge-thin-tails {
background-color: #d63939;
color: #fff;
}
.badge-normal {
background-color: rgba(255, 255, 255, 0.1);
color: #8b95a1;
}
.snapshot-count-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 12px;
background-color: rgba(79, 195, 247, 0.15);
color: #4fc3f7;
border: 1px solid rgba(79, 195, 247, 0.3);
font-weight: 600;
white-space: nowrap;
}
[x-cloak] { display: none !important; }
</style>
</head>
<body class="antialiased" x-data="trackerApp()" x-init="init()">
<div class="wrapper">
<!-- Vertical Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<!-- Toggle for mobile -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Logo -->
<h1 class="navbar-brand navbar-brand-autodark">
<a href="index.html" class="text-decoration-none d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none"
stroke="#4fc3f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline>
<polyline points="16 7 22 7 22 13"></polyline>
</svg>
<span class="fw-bold" style="color:#4fc3f7; font-size:1rem; letter-spacing:0.04em;">OPTIONS PRICER</span>
</a>
</h1>
<!-- Nav links -->
<div class="collapse navbar-collapse" id="sidebar-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item">
<a class="nav-link" href="index.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect>
</svg>
</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="chain.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</span>
<span class="nav-link-title">Options Chain</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="surface.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3l7 7 4-4 7 7"></path>
<path d="M21 17v4h-4"></path>
</svg>
</span>
<span class="nav-link-title">Vol Surface</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link active-page" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</span>
<span class="nav-link-title">Tracker</span>
</a>
</li>
</ul>
</div>
</div>
</aside>
<!-- Main content -->
<div class="page-wrapper">
<!-- Toolbar -->
<div class="toolbar-bar py-2 px-3 mb-3">
<div class="container-xl">
<div class="d-flex flex-wrap align-items-center gap-3">
<!-- Symbol input -->
<div class="input-group input-group-sm" style="max-width:140px;">
<span class="input-group-text bg-dark border-secondary text-muted">Symbol</span>
<input
type="text"
class="form-control bg-dark border-secondary text-white"
placeholder="SPY"
x-model="symbol"
@keydown.enter="fetchExpirations()"
style="text-transform:uppercase;"
/>
</div>
<!-- Expiry select -->
<div class="input-group input-group-sm" style="max-width:200px;">
<span class="input-group-text bg-dark border-secondary text-muted">Expiry</span>
<select
class="form-select bg-dark border-secondary text-white"
x-model="expiry"
@change="renderCharts()"
>
<option value="">All expirations</option>
<template x-for="exp in expirations" :key="exp">
<option :value="exp" x-text="exp"></option>
</template>
</select>
</div>
<!-- Load button -->
<button
class="btn btn-sm btn-primary"
@click="loadHistory()"
:disabled="loading"
>
<span x-show="loading" class="spinner-border spinner-border-sm me-1" role="status"></span>
<span x-show="!loading">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
class="me-1" style="vertical-align:-2px;">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 .49-3.51"></path>
</svg>
Load History
</span>
<span x-show="loading">Loading…</span>
</button>
<!-- Snapshot count badge -->
<span class="snapshot-count-badge" x-text="filteredSnapshots.length + ' snapshots'"></span>
<!-- Error message -->
<span x-show="error" x-cloak class="text-danger small ms-2" x-text="error"></span>
</div>
</div>
</div>
<!-- Page body -->
<div class="page-body">
<div class="container-xl">
<!-- Empty state -->
<div x-show="!loading && filteredSnapshots.length === 0" x-cloak class="text-center py-5">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
fill="none" stroke="#8b95a1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="mb-3">
<path d="M3 3l18 18"></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>
</svg>
<p class="text-muted">No snapshot data. Enter a symbol and click Load History.</p>
</div>
<!-- Charts row -->
<div x-show="filteredSnapshots.length > 0" x-cloak>
<div class="row g-3 mb-3">
<!-- Chart 1: ATM IV -->
<div class="col-12">
<div class="chart-card p-3">
<div class="d-flex align-items-center mb-2 gap-2">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#00d4ff;"></span>
<span class="fw-semibold text-white" style="font-size:0.9rem;">ATM Implied Volatility History</span>
</div>
<div id="chart-atm-iv" style="min-height:220px;"></div>
</div>
</div>
<!-- Chart 2: RR25 + Chart 3: Fly25 side by side on wider screens -->
<div class="col-12 col-lg-6">
<div class="chart-card p-3">
<div class="d-flex align-items-center mb-2 gap-2">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2fb344;"></span>
<span class="fw-semibold text-white" style="font-size:0.9rem;">25&#916; Risk Reversal History</span>
</div>
<div id="chart-rr25" style="min-height:220px;"></div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="chart-card p-3">
<div class="d-flex align-items-center mb-2 gap-2">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f59f00;"></span>
<span class="fw-semibold text-white" style="font-size:0.9rem;">25&#916; Butterfly History</span>
</div>
<div id="chart-fly25" style="min-height:220px;"></div>
</div>
</div>
</div>
<!-- History table -->
<div class="card" style="background-color:#14161c; border:1px solid rgba(255,255,255,0.07);">
<div class="card-header d-flex align-items-center justify-content-between"
style="border-bottom:1px solid rgba(255,255,255,0.07);">
<h3 class="card-title text-white mb-0" style="font-size:0.9rem; font-weight:600;">
Snapshot History
<span class="ms-2 text-muted" style="font-size:0.75rem; font-weight:400;">
(most recent first, showing up to 20)
</span>
</h3>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover table-snapshots mb-0"
style="color:#c8d3e0;">
<thead>
<tr>
<th>Timestamp</th>
<th>Expiry</th>
<th>Spot</th>
<th>ATM IV</th>
<th>RR25</th>
<th>RR10</th>
<th>Fly25</th>
<th>Butterfly Signal</th>
</tr>
</thead>
<tbody>
<template x-for="(snap, idx) in tableRows" :key="snap.timestamp + idx">
<tr>
<td class="text-muted" style="white-space:nowrap; font-size:0.8rem;"
x-text="formatTimestamp(snap.timestamp)"></td>
<td x-text="snap.expiry || '—'"></td>
<td x-text="snap.spot ? snap.spot.toFixed(2) : '—'"></td>
<td>
<span x-text="snap.atmIv ? (snap.atmIv * 100).toFixed(2) + '%' : '—'"
style="color:#4fc3f7; font-variant-numeric:tabular-nums;"></span>
</td>
<td>
<span
:style="{ color: snap.rr25 > 0 ? '#2fb344' : snap.rr25 < 0 ? '#d63939' : '#8b95a1' }"
style="font-variant-numeric:tabular-nums;"
x-text="snap.rr25 != null ? snap.rr25.toFixed(4) : '—'">
</span>
</td>
<td>
<span
:style="{ color: snap.rr10 > 0 ? '#2fb344' : snap.rr10 < 0 ? '#d63939' : '#8b95a1' }"
style="font-variant-numeric:tabular-nums;"
x-text="snap.rr10 != null ? snap.rr10.toFixed(4) : '—'">
</span>
</td>
<td style="font-variant-numeric:tabular-nums;"
x-text="snap.fly25 != null ? snap.fly25.toFixed(4) : '—'"></td>
<td>
<template x-if="snap.fly25 > 0.002">
<span class="badge badge-fat-tails">Fat Tails</span>
</template>
<template x-if="snap.fly25 <= 0.002 && snap.fly25 < -0.002">
<span class="badge badge-thin-tails">Thin Tails</span>
</template>
<template x-if="snap.fly25 != null && snap.fly25 >= -0.002 && snap.fly25 <= 0.002">
<span class="badge badge-normal">Normal</span>
</template>
<template x-if="snap.fly25 == null">
<span class="text-muted"></span>
</template>
</td>
</tr>
</template>
<tr x-show="tableRows.length === 0">
<td colspan="8" class="text-center text-muted py-3">No data available</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Alpine + ApexCharts logic -->
<script>
const CHART_DEFAULTS = {
background: 'transparent',
foreColor: '#8b95a1',
fontFamily: 'inherit',
};
const AXIS_STYLE = {
borderColor: 'rgba(255,255,255,0.06)',
labels: {
style: { colors: '#8b95a1', fontSize: '11px' }
},
axisBorder: { show: false },
axisTicks: { show: false },
};
const TOOLTIP_STYLE = {
theme: 'dark',
};
const GRID_STYLE = {
borderColor: 'rgba(255,255,255,0.06)',
strokeDashArray: 4,
};
// ── ATM IV chart ──────────────────────────────────────────────────────────
function buildAtmIvChart(data) {
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const series = sorted.map(s => ({
x: new Date(s.timestamp).getTime(),
y: s.atmIv != null ? parseFloat((s.atmIv * 100).toFixed(4)) : null,
})).filter(p => p.y !== null);
const options = {
chart: {
type: 'area',
height: 220,
toolbar: { show: false },
...CHART_DEFAULTS,
animations: { enabled: true, speed: 400 },
zoom: { enabled: false },
},
series: [{ name: 'ATM IV', data: series }],
stroke: { curve: 'smooth', width: 2, colors: ['#00d4ff'] },
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.2,
opacityTo: 0.0,
stops: [0, 100],
colorStops: [{
offset: 0,
color: '#00d4ff',
opacity: 0.2,
}, {
offset: 100,
color: '#00d4ff',
opacity: 0,
}],
},
},
colors: ['#00d4ff'],
xaxis: {
type: 'datetime',
...AXIS_STYLE,
},
yaxis: {
...AXIS_STYLE,
labels: {
style: { colors: '#8b95a1', fontSize: '11px' },
formatter: v => v != null ? v.toFixed(1) + '%' : '',
},
title: { text: 'IV (%)', style: { color: '#8b95a1', fontSize: '11px', fontWeight: 400 } },
},
grid: GRID_STYLE,
tooltip: {
...TOOLTIP_STYLE,
x: { format: 'dd MMM yy HH:mm' },
y: { formatter: v => v != null ? v.toFixed(2) + '%' : 'N/A' },
},
markers: { size: series.length <= 15 ? 4 : 0, colors: ['#00d4ff'], strokeColors: '#1a1c23', strokeWidth: 2 },
dataLabels: { enabled: false },
};
return options;
}
// ── RR25 chart ────────────────────────────────────────────────────────────
function buildRr25Chart(data) {
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const series = sorted.map(s => ({
x: new Date(s.timestamp).getTime(),
y: s.rr25 != null ? parseFloat(s.rr25.toFixed(6)) : null,
})).filter(p => p.y !== null);
const options = {
chart: {
type: 'line',
height: 220,
toolbar: { show: false },
...CHART_DEFAULTS,
animations: { enabled: true, speed: 400 },
zoom: { enabled: false },
},
series: [{ name: 'RR25', data: series }],
stroke: { curve: 'smooth', width: 2, colors: ['#2fb344'] },
colors: ['#2fb344'],
xaxis: {
type: 'datetime',
...AXIS_STYLE,
},
yaxis: {
...AXIS_STYLE,
labels: {
style: { colors: '#8b95a1', fontSize: '11px' },
formatter: v => v != null ? v.toFixed(4) : '',
},
title: { text: 'RR25', style: { color: '#8b95a1', fontSize: '11px', fontWeight: 400 } },
},
grid: GRID_STYLE,
annotations: {
yaxis: [{
y: 0,
borderColor: 'rgba(255, 255, 255, 0.25)',
strokeDashArray: 5,
label: {
text: 'Zero',
style: {
color: '#8b95a1',
background: 'transparent',
fontSize: '10px',
},
position: 'right',
},
}],
},
tooltip: {
...TOOLTIP_STYLE,
x: { format: 'dd MMM yy HH:mm' },
y: { formatter: v => v != null ? v.toFixed(4) : 'N/A' },
},
markers: { size: series.length <= 15 ? 4 : 0, colors: ['#2fb344'], strokeColors: '#1a1c23', strokeWidth: 2 },
dataLabels: { enabled: false },
};
return options;
}
// ── Fly25 chart ───────────────────────────────────────────────────────────
function buildFly25Chart(data) {
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const filtered = sorted.filter(s => s.fly25 != null);
const values = filtered.map(s => parseFloat(s.fly25.toFixed(6)));
const timestamps = filtered.map(s => new Date(s.timestamp).getTime());
const barColors = values.map(v => v > 0 ? '#f59f00' : '#6c757d');
const options = {
chart: {
type: 'bar',
height: 220,
toolbar: { show: false },
...CHART_DEFAULTS,
animations: { enabled: true, speed: 400 },
zoom: { enabled: false },
},
series: [{
name: 'Fly25',
data: filtered.map((s, i) => ({
x: timestamps[i],
y: values[i],
})),
}],
plotOptions: {
bar: {
distributed: true,
borderRadius: 2,
columnWidth: filtered.length > 30 ? '90%' : '60%',
},
},
colors: barColors,
legend: { show: false },
xaxis: {
type: 'datetime',
...AXIS_STYLE,
},
yaxis: {
...AXIS_STYLE,
labels: {
style: { colors: '#8b95a1', fontSize: '11px' },
formatter: v => v != null ? v.toFixed(4) : '',
},
title: { text: 'Fly25', style: { color: '#8b95a1', fontSize: '11px', fontWeight: 400 } },
},
grid: GRID_STYLE,
annotations: {
yaxis: [{
y: 0,
borderColor: 'rgba(255, 255, 255, 0.2)',
strokeDashArray: 4,
}],
},
tooltip: {
...TOOLTIP_STYLE,
x: { format: 'dd MMM yy HH:mm' },
y: { formatter: v => v != null ? v.toFixed(4) : 'N/A' },
},
dataLabels: { enabled: false },
};
return options;
}
// ── Alpine component ──────────────────────────────────────────────────────
function trackerApp() {
return {
symbol: 'SPY',
expiry: '',
expirations: [],
snapshots: [],
loading: false,
error: '',
_charts: { atmIv: null, rr25: null, fly25: null },
async init() {
// expirations are built from snapshot data after loadHistory()
},
async fetchExpirations() {
if (!this.symbol.trim()) return;
try {
const sym = this.symbol.trim().toUpperCase();
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
if (!res.ok) throw new Error('Failed to fetch expirations');
const env = await res.json();
const json = env.data ?? env;
this.expirations = Array.isArray(json) ? json : (json.expirations || []);
} catch (e) {
this.expirations = [];
}
},
async loadHistory() {
if (!this.symbol.trim()) return;
this.loading = true;
this.error = '';
try {
const sym = this.symbol.trim().toUpperCase();
const url = `/api/snapshots?symbol=${encodeURIComponent(sym)}&limit=100`;
const res = await fetch(url);
if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`);
const env = await res.json();
const json = env.data ?? env;
// Accept array or { snapshots: [...] }
this.snapshots = json.snapshots ?? (Array.isArray(json) ? json : []);
// Sort descending (most recent first)
this.snapshots.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
// Rebuild expiration list from data if API didn't provide it
if (this.expirations.length === 0) {
const expSet = new Set(this.snapshots.map(s => s.expiry).filter(Boolean));
this.expirations = [...expSet].sort();
}
this.$nextTick(() => this.renderCharts());
} catch (e) {
this.error = e.message;
this.snapshots = [];
} finally {
this.loading = false;
}
},
get filteredSnapshots() {
if (!this.expiry) return this.snapshots;
return this.snapshots.filter(s => s.expiry === this.expiry);
},
get tableRows() {
// Most recent first, limited to 20
return this.filteredSnapshots.slice(0, 20);
},
formatTimestamp(ts) {
if (!ts) return '—';
try {
const d = new Date(ts);
return d.toLocaleString('en-US', {
month: 'short', day: '2-digit',
hour: '2-digit', minute: '2-digit',
hour12: false,
});
} catch {
return ts;
}
},
renderCharts() {
const data = this.filteredSnapshots;
// Destroy existing instances
Object.keys(this._charts).forEach(k => {
if (this._charts[k]) {
try { this._charts[k].destroy(); } catch (_) {}
this._charts[k] = null;
}
});
if (data.length === 0) return;
this._charts.atmIv = new ApexCharts(
document.querySelector('#chart-atm-iv'),
buildAtmIvChart(data)
);
this._charts.atmIv.render();
this._charts.rr25 = new ApexCharts(
document.querySelector('#chart-rr25'),
buildRr25Chart(data)
);
this._charts.rr25.render();
this._charts.fly25 = new ApexCharts(
document.querySelector('#chart-fly25'),
buildFly25Chart(data)
);
this._charts.fly25.render();
},
// Re-render charts when expiry filter changes
// (triggered via @change on the select, which calls renderCharts() directly)
};
}
</script>
</body>
</html>