Files
options-pricer/frontend/index.html
ojy 2cd3d6ece8 IV Spike Scanner
Backend: GET /api/scan?symbols=SYM1,SYM2,... — for each symbol fetches
the front-expiry options chain plus 30-day realized vol and returns
{ spot, change, changePct, atmIv, hv30, ivHv, spike, expiry }. Spike flag
is on when IV/HV ≥ 1.5 or |today's % change| ≥ 3. Defaults to ~15 popular
tickers when no list is given; cap of 30 symbols/scan.

Frontend: new scanner.html page — symbol input (with "Use defaults" / "Use
watchlist" shortcuts), summary cards (count · spikes · biggest mover ·
highest IV/HV), sortable results table with spike rows highlighted, and
shortcut buttons to open each symbol on Chain or Surface.

Scanner added to all sidebars between Vol Surface and Strategy P/L.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:32:14 +00:00

954 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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/viewstate-store.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">
<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="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="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-primary" 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 active">
<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="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="4" y="4" width="6" height="5" rx="2"/>
<rect x="4" y="13" width="6" height="7" rx="2"/>
<rect x="14" y="4" width="6" height="11" rx="2"/>
<rect x="14" y="19" width="6" height="1" rx=".5"/>
</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="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" 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="scanner.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" 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"/>
<circle cx="11" cy="11" r="7"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<path d="M11 8v6"/>
<path d="M8 11h6"/>
</svg>
</span>
<span class="nav-link-title">Scanner</span>
</a>
</li>
<li class="nav-item ">
<a class="nav-link" href="strategy.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" 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="M4 19l4 -6l4 2l4 -8l4 5"/>
<path d="M4 4v16h16"/>
</svg>
</span>
<span class="nav-link-title">Strategy P/L</span>
</a>
</li>
<li class="nav-item ">
<a class="nav-link" href="positions.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" 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="7" width="18" height="13" rx="2"/>
<path d="M8 7v-2a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v2"/>
<line x1="12" y1="12" x2="12" y2="12.01"/>
<path d="M3 13a20 20 0 0 0 18 0"/>
</svg>
</span>
<span class="nav-link-title">Positions</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="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>
<li class="nav-item ">
<a class="nav-link" href="settings.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" 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="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</span>
<span class="nav-link-title">Settings</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() {
// restore last symbol/expiry so navigating back keeps your selection
const vs = ViewState.load('dashboard');
if (vs) {
this.symbol = vs.symbol ?? this.symbol;
this.expiry = vs.expiry ?? '';
}
await this.fetchExpirations();
if (this.expiry && !this.expirations.includes(this.expiry)) {
this.expiry = this.expirations[0] || '';
}
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' });
ViewState.save('dashboard', { symbol: this.symbol, expiry: this.expiry });
// 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>