New /movers page surfaces Yahoo Finance's predefined screeners (day_gainers, day_losers, most_actives, most_shorted_stocks) filtered to common equities with market cap >= $2B, so every listed name has a deep options chain. Per-row actions jump straight into Chain / Vol Surface / IV Spike Scanner, or pin the symbol to the Tracker watchlist. - datafetch.ts: fetchMovers(category, count) using yf.screener, post-filtered to quoteType=EQUITY and marketCap >= $2B - options.ts: GET /api/movers?category=&count= - movers.html: Tabler page with 4-tab segmented control, sortable table, summary cards, volume-vs-avg ratio highlighting hot names - All page sidebars: insert "Movers" link between Vol Surface and Scanner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
966 lines
39 KiB
HTML
966 lines
39 KiB
HTML
<!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="movers.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 17l6 -6l4 4l8 -8"/>
|
||
<path d="M14 7l7 0l0 7"/>
|
||
</svg>
|
||
</span>
|
||
<span class="nav-link-title">Movers</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 & 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Δ</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Δ)
|
||
</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Δ 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 © <span x-text="new Date().getFullYear()"></span>
|
||
</li>
|
||
<li class="list-inline-item text-secondary small">
|
||
Powered by Tabler & 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>
|