Files
options-pricer/frontend/index.html

941 lines
36 KiB
HTML
Raw Normal View History

<!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">
<!-- 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>
<!-- Strategy P/L -->
<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="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 19l4 -6l4 2l4 -8l4 5"/><path d="M4 4v16h16"/>
</svg>
</span>
<span class="nav-link-title">Strategy P/L</span>
</a>
</li>
<!-- Positions -->
<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="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="M5 4h4v4h-4z"/><path d="M5 12h4v8h-4z"/><path d="M13 4h4v12h-4z"/><path d="M13 18h4v2h-4z"/>
</svg>
</span>
<span class="nav-link-title">Positions</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>
<!-- Settings -->
<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="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="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</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>