Files
options-pricer/frontend/surface.html
ojy fc57aecd98 Vol Surface: cache-first load + auto-pick next-month 3rd Friday
On page load and on every symbol Lookup, target the 3rd Friday of
the next calendar month (the standard US monthly options expiry).
If that exact date isn't listed (e.g., June 19, 2026 is Juneteenth
so SPY's monthly is the Thursday 06-18), fall back to the nearest
available expiration.

Data flow:
  1. Init reads {symbol, expirations} from ViewState.
  2. Computes target expiry (3rd Fri next month).
  3. Hits a new per-symbol cache at
     localStorage['optionsPricer:surfaceCache'][symbol:expiry].
     - Hit AND cache.date === today → render instantly, no network.
     - Hit but stale (cache.date !== today) → refetch.
     - Miss → fetch expirations + load surface.
  4. fetchExpirations() now auto-selects the target expiry and
     triggers _loadForTargetExpiry (cache-aware) — entering a new
     symbol now produces a rendered surface with one Enter press.
  5. Successful loadSurface writes the response into the cache
     under today's date; cache is pruned to 50 entries.

Analytics is no longer stuffed into ViewState (only the lightweight
symbol/expirations/expiry pointer is), so the per-symbol cache is
the single source of truth for surface data.

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

1180 lines
49 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.0">
<title>Vol Surface — Options Pricer</title>
<link rel="stylesheet" href="/assets/tabler.min.css">
<link rel="stylesheet" href="/assets/tabler-vendors.min.css">
<script src="/assets/apexcharts.min.js"></script>
<script src="/assets/alpine.min.js" defer></script>
<script src="/assets/tabler.min.js" defer></script>
<style>
.navbar-vertical {
width: 15rem;
}
.navbar-vertical .navbar-brand {
padding: 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.chart-card {
background: #1e2030;
border: 1px solid #2d3045;
border-radius: 0.5rem;
}
.chart-card .card-header {
background: transparent;
border-bottom: 1px solid #2d3045;
}
#skewChart,
#termChart {
background: #1e2030;
border-radius: 0 0 0.5rem 0.5rem;
}
.badge-metric {
font-size: 0.9rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
}
.stat-inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
}
.stat-inline .stat-label {
color: #8b95a7;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stat-inline .stat-value {
font-size: 1.05rem;
font-weight: 700;
color: #ffffff;
}
.stat-inline .stat-value.positive {
color: #51cf66;
}
.stat-inline .stat-value.negative {
color: #ff6b6b;
}
.stat-inline .stat-value.amber {
color: #ffd43b;
}
/* HV vs IV comparison card (top-right of page header) */
.hviv-card {
background: #161824;
border: 1px solid #2d3045;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
min-width: 280px;
}
.hviv-card .hviv-head {
display:flex; align-items:center; justify-content:space-between;
border-bottom: 1px solid #2d3045;
padding-bottom: 0.35rem;
margin-bottom: 0.4rem;
}
.hviv-card .hviv-title {
color: #8b95a7;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hviv-card .hviv-badge {
font-size: 0.7rem;
font-weight: 800;
padding: 0.15rem 0.5rem;
border-radius: 0.25rem;
letter-spacing: 0.04em;
}
.hviv-card .hviv-badge.rich { background:#ff6b6b; color:#1a1c2e; }
.hviv-card .hviv-badge.cheap { background:#51cf66; color:#1a1c2e; }
.hviv-card .hviv-badge.fair { background:#374151; color:#cbd3df; }
.hviv-card .hviv-row {
display: grid;
grid-template-columns: 60px 1fr auto;
gap: 0.5rem;
align-items: baseline;
padding: 0.15rem 0;
font-size: 0.85rem;
}
.hviv-card .hviv-row .label {
color: #8b95a7;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.hviv-card .hviv-row .val {
color: #fff;
font-weight: 700;
font-family: 'JetBrains Mono','Fira Code',monospace;
}
.hviv-card .hviv-row .spread {
font-family: 'JetBrains Mono','Fira Code',monospace;
font-size: 0.78rem;
font-weight: 600;
}
.hviv-card .hviv-row .spread.positive { color: #ff6b6b; }
.hviv-card .hviv-row .spread.negative { color: #51cf66; }
.hviv-card .hviv-row.atm .val { color: #ffd43b; }
.hviv-card .hviv-foot {
color: #6c757d;
font-size: 0.7rem;
margin-top: 0.25rem;
border-top: 1px dashed #2d3045;
padding-top: 0.3rem;
}
[x-cloak] {
display: none !important;
}
.page-wrapper {
padding-top: 0;
}
.apexcharts-tooltip {
background: #1e2030 !important;
border: 1px solid #2d3045 !important;
color: #ffffff !important;
}
.apexcharts-tooltip-title {
background: #2d3045 !important;
border-bottom: 1px solid #3a3f5a !important;
}
.skew-table th {
background: #1a1c2e;
color: #8b95a7;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid #2d3045;
}
.skew-table td {
border-bottom: 1px solid #1e2030;
color: #d0d5e0;
font-size: 0.875rem;
}
.skew-table tbody tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
.skew-table .mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.82rem;
}
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="surfaceApp()" x-init="init()">
<!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#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 ">
<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 active">
<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>
<!-- Page content -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Volatility Surface</h2>
<div class="text-secondary mt-1">IV skew analysis and term structure</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- Top section: toolbar + skew badges on the left, HV/IV card (rowspan-2 style) on the right -->
<div class="row g-3 mb-4 align-items-stretch">
<div class="col-12 col-lg">
<!-- Toolbar -->
<div class="card mb-3" style="background:#161824; border:1px solid #2d3045;">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-sm-auto">
<label class="form-label text-secondary" for="symbolInput">Symbol</label>
<div class="input-group">
<input
id="symbolInput"
type="text"
class="form-control"
style="background:#1e2030; border-color:#2d3045; color:#fff; width:100px; text-transform:uppercase;"
placeholder="SPY"
x-model="symbol"
@keydown.enter="fetchExpirations()"
@input="symbol = symbol.toUpperCase()"
:disabled="loading"
aria-label="Ticker symbol"
>
<button
class="btn btn-secondary"
@click="fetchExpirations()"
:disabled="lookingUp || !symbol"
aria-label="Look up expirations for symbol"
>
<span x-show="lookingUp" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span x-text="lookingUp ? '…' : 'Lookup'"></span>
</button>
</div>
</div>
<div class="col-12 col-sm-auto">
<label class="form-label text-secondary" for="expirySelect">Expiry</label>
<select
id="expirySelect"
class="form-select"
style="background:#1e2030; border-color:#2d3045; color:#fff; min-width:160px;"
x-model="expiry"
:disabled="loading || expirations.length === 0"
aria-label="Select expiry date"
>
<option value="" disabled>Select expiry…</option>
<template x-for="exp in expirations" :key="exp">
<option :value="exp" x-text="exp"></option>
</template>
</select>
</div>
<div class="col-12 col-sm-auto">
<button
class="btn btn-primary"
@click="loadSurface()"
:disabled="loading || !symbol || !expiry"
aria-label="Load volatility surface"
>
<span x-show="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span x-text="loading ? 'Loading…' : 'Load Surface'"></span>
</button>
</div>
<div class="col-12 col-sm-auto ms-sm-auto" x-show="errorMsg" x-cloak>
<div class="alert alert-danger mb-0 py-2 px-3" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" class="me-1">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span x-text="errorMsg"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Skew metric badges -->
<div x-show="hasData" x-cloak>
<div class="d-flex flex-wrap gap-3">
<div class="stat-inline">
<span class="stat-label">ATM IV</span>
<span class="stat-value" x-text="formatPct(currentMetrics.atmIV)"></span>
</div>
<div class="stat-inline">
<span class="stat-label">RR25</span>
<span
class="stat-value"
:class="{
'positive': currentMetrics.rr25 > 0.005,
'negative': currentMetrics.rr25 < -0.005
}"
x-text="formatPctSigned(currentMetrics.rr25)"
></span>
</div>
<div class="stat-inline">
<span class="stat-label">Fly25</span>
<span
class="stat-value"
:class="{ 'amber': Math.abs(currentMetrics.fly25) > 0.002 }"
x-text="formatPctSigned(currentMetrics.fly25)"
></span>
</div>
</div>
</div>
</div>
<!-- HV vs IV card — spans full height of left column (toolbar + skew badges) -->
<div class="col-12 col-lg-auto" x-show="hasData" x-cloak>
<div class="hviv-card h-100 d-flex flex-column">
<div class="hviv-head">
<span class="hviv-title">HV vs IV</span>
<span class="hviv-badge" :class="hvIvVerdict.cls" x-text="hvIvVerdict.label"></span>
</div>
<div class="hviv-row atm">
<span class="label">ATM IV</span>
<span class="val" x-text="formatPct(currentMetrics.atmIV)"></span>
<span class="spread">&nbsp;</span>
</div>
<template x-for="w in hvRows" :key="w.key">
<div class="hviv-row">
<span class="label" x-text="w.label"></span>
<span class="val" x-text="w.value > 0 ? formatPct(w.value) : '—'"></span>
<span class="spread"
:class="w.value > 0 ? (w.spread >= 0 ? 'positive' : 'negative') : ''"
x-text="w.value > 0 ? ((w.spread >= 0 ? '+' : '') + (w.spread * 100).toFixed(1) + ' pts') : ''"></span>
</div>
</template>
<div class="hviv-foot mt-auto" x-text="hvIvFooter"></div>
</div>
</div>
</div>
<!-- Greeks card -->
<div class="card mb-4" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
<div class="card-header" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">Greeks</h3>
<div class="card-options">
<span class="text-secondary small">ATM &amp; nearest ITM · <span x-text="expiry"></span></span>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-vcenter skew-table mb-0">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col" class="text-end">Strike</th>
<th scope="col" class="text-end">IV</th>
<th scope="col" class="text-end">Mid</th>
<th scope="col" class="text-end">Delta</th>
<th scope="col" class="text-end">Gamma</th>
<th scope="col" class="text-end">Theta</th>
<th scope="col" class="text-end">Vega</th>
</tr>
</thead>
<tbody>
<template x-if="greeks.atmCall">
<tr>
<td><span class="badge bg-blue-lt text-blue">ATM Call</span></td>
<td class="mono text-end" x-text="greeks.atmCall.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.atmCall.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.atmCall.midPrice)"></td>
<td class="mono text-end text-success" x-text="fmtGreek(greeks.atmCall.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.atmCall.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmCall.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmCall.vega)"></td>
</tr>
</template>
<template x-if="greeks.atmPut">
<tr>
<td><span class="badge bg-orange-lt text-orange">ATM Put</span></td>
<td class="mono text-end" x-text="greeks.atmPut.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.atmPut.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.atmPut.midPrice)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.atmPut.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmPut.vega)"></td>
</tr>
</template>
<template x-if="greeks.itmCall">
<tr style="border-top:1px solid #2d3045;">
<td><span class="badge bg-blue-lt" style="color:#8ec8ff;">ITM Call</span></td>
<td class="mono text-end" x-text="greeks.itmCall.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.itmCall.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.itmCall.midPrice)"></td>
<td class="mono text-end text-success" x-text="fmtGreek(greeks.itmCall.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.itmCall.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmCall.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmCall.vega)"></td>
</tr>
</template>
<template x-if="greeks.itmPut">
<tr>
<td><span class="badge bg-orange-lt" style="color:#ffb347;">ITM Put</span></td>
<td class="mono text-end" x-text="greeks.itmPut.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.itmPut.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.itmPut.midPrice)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.itmPut.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmPut.vega)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Charts row -->
<div class="row g-4 mb-4" x-show="hasData" x-cloak>
<!-- IV Skew chart -->
<div class="col-12 col-xl-7">
<div class="chart-card">
<div class="card-header">
<h3 class="card-title text-white mb-0">
IV Skew —
<span x-text="symbol"></span>
<span class="text-secondary ms-1" x-text="expiry"></span>
</h3>
</div>
<div class="card-body p-0">
<div id="skewChart" style="min-height:320px;" role="img" aria-label="IV skew line chart showing calls and puts by strike price"></div>
</div>
</div>
</div>
<!-- Term structure chart -->
<div class="col-12 col-xl-5">
<div class="chart-card">
<div class="card-header">
<h3 class="card-title text-white mb-0">ATM IV Term Structure</h3>
</div>
<div class="card-body p-0">
<div id="termChart" style="min-height:320px;" role="img" aria-label="ATM IV bar chart across expiry dates"></div>
</div>
</div>
</div>
</div>
<!-- Skew table -->
<div class="card" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
<div class="card-header" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">Per-Expiry Skew Metrics</h3>
<div class="card-options">
<span class="text-secondary small" x-text="`${skewTable.length} expiries`"></span>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter skew-table mb-0" aria-label="Per-expiry skew metrics table">
<thead>
<tr>
<th scope="col">Expiry</th>
<th scope="col" class="text-end">ATM IV</th>
<th scope="col" class="text-end">RR25</th>
<th scope="col" class="text-end">RR10</th>
<th scope="col" class="text-end">Fly25</th>
<th scope="col" class="text-center">Skew Direction</th>
</tr>
</thead>
<tbody>
<template x-for="row in skewTable" :key="row.expiry">
<tr>
<td class="mono" x-text="row.expiry"></td>
<td class="mono text-end" x-text="formatPct(row.atmIV)"></td>
<td
class="mono text-end"
:class="{
'text-success': row.rr25 > 0.005,
'text-danger': row.rr25 < -0.005
}"
x-text="formatPctSigned(row.rr25)"
></td>
<td
class="mono text-end"
:class="{
'text-success': row.rr10 > 0.005,
'text-danger': row.rr10 < -0.005
}"
x-text="formatPctSigned(row.rr10)"
></td>
<td
class="mono text-end"
:class="{ 'text-warning': Math.abs(row.fly25) > 0.002 }"
x-text="formatPctSigned(row.fly25)"
></td>
<td class="text-center">
<span
class="badge"
:class="{
'bg-danger-lt text-danger': row.rr25 < -0.005,
'bg-success-lt text-success': row.rr25 > 0.005,
'bg-secondary-lt text-secondary': row.rr25 >= -0.005 && row.rr25 <= 0.005
}"
x-text="row.rr25 < -0.005 ? 'Put Skew' : row.rr25 > 0.005 ? 'Call Skew' : 'Flat'"
></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Empty state -->
<div class="card text-center py-5" style="background:#161824; border:1px solid #2d3045;" x-show="!hasData && !loading" x-cloak>
<div class="card-body">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" class="text-secondary mb-3">
<path d="M3 3v18h18"></path>
<path d="M7 16l4-8 4 4 4-4"></path>
</svg>
<h3 class="text-secondary">No surface loaded</h3>
<p class="text-muted">Enter a symbol and click Lookup, then select an expiry and click Load Surface.</p>
</div>
</div>
<!-- Loading skeleton -->
<div x-show="loading" x-cloak>
<div class="row g-4 mb-4">
<div class="col-12 col-xl-7">
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
<div class="card-body d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading skew chart…</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
<div class="card-body d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading term structure chart…</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/assets/viewstate-store.js"></script>
<script>
const CHART_BG = '#1e2030';
const CHART_GRID = '#2d3045';
const CHART_LABEL = '#8b95a7';
const CHART_TOOLTIP_BG = '#1a1c2e';
const COLOR_CALL = '#4d9ef7'; // blue
const COLOR_PUT = '#ff8c42'; // orange
const BASE_CHART_OPTS = {
chart: {
background: CHART_BG,
foreColor: CHART_LABEL,
toolbar: { show: false },
fontFamily: 'inherit',
animations: { enabled: true, speed: 400 }
},
grid: {
borderColor: CHART_GRID,
strokeDashArray: 3
},
tooltip: {
theme: 'dark',
style: { fontSize: '12px' }
},
dataLabels: { enabled: false },
legend: {
labels: { colors: '#d0d5e0' }
}
};
function surfaceApp() {
return {
symbol: 'SPY',
expiry: '',
expirations: [],
analytics: {},
loading: false,
lookingUp: false,
hasData: false,
errorMsg: '',
skewTable: [],
currentMetrics: { atmIV: 0, rr25: 0, fly25: 0 },
hvWindows: { hv20: 0, hv30: 0, hv60: 0, samples: { hv20: 0, hv30: 0, hv60: 0 } },
greeks: { atmCall: null, atmPut: null, itmCall: null, itmPut: null },
skewChartInstance: null,
termChartInstance: null,
async init() {
// Restore last symbol + expirations list (survives navigating away & back).
// Analytics itself comes from the per-symbol cache below, not ViewState.
const vs = ViewState.load('surface');
if (vs) {
this.symbol = vs.symbol ?? this.symbol;
this.expirations = vs.expirations ?? [];
}
if (!this.symbol) return;
// Always target the 3rd Friday of next calendar month.
const target = this._nextMonthThirdFriday();
// Cache hit for *today* — show instantly, skip the network.
const cached = this._getCached(this.symbol, target);
if (cached) {
this.expiry = target;
this.analytics = cached;
this._processAnalytics(cached);
this._persist();
return;
}
// Cache miss or stale — fetch expirations and load the target expiry.
await this.fetchExpirations();
if (this.expiry) await this._loadForTargetExpiry();
},
_persist() {
ViewState.save('surface', {
symbol: this.symbol, expirations: this.expirations, expiry: this.expiry,
});
},
// ---- Per-symbol surface cache (localStorage, today-only) ----
_todayStr() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
},
_nextMonthThirdFriday() {
const now = new Date();
const first = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const daysToFri = (5 - first.getDay() + 7) % 7;
const day = 1 + daysToFri + 14; // 3rd Friday = 1st Friday + 2 weeks
return `${first.getFullYear()}-${String(first.getMonth()+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
},
_pickTargetExpiry(expirations) {
if (!expirations || expirations.length === 0) return '';
const target = this._nextMonthThirdFriday();
if (expirations.includes(target)) return target;
const tMs = new Date(target + 'T00:00:00').getTime();
return expirations.reduce((best, e) =>
Math.abs(new Date(e + 'T00:00:00').getTime() - tMs) <
Math.abs(new Date(best + 'T00:00:00').getTime() - tMs) ? e : best
);
},
_readCacheStore() {
try { return JSON.parse(localStorage.getItem('optionsPricer:surfaceCache') || '{}'); }
catch { return {}; }
},
_getCached(symbol, expiry) {
const store = this._readCacheStore();
const entry = store[`${symbol}:${expiry}`];
if (!entry || entry.date !== this._todayStr()) return null;
return entry.analytics;
},
_writeCache(symbol, expiry, analytics) {
const store = this._readCacheStore();
store[`${symbol}:${expiry}`] = { date: this._todayStr(), analytics };
// Keep storage bounded — drop oldest beyond 50 entries
const keys = Object.keys(store);
if (keys.length > 50) {
const ordered = keys
.map(k => [k, store[k].date || ''])
.sort((a, b) => b[1].localeCompare(a[1]))
.slice(0, 50)
.map(([k]) => k);
const trimmed = {};
for (const k of ordered) trimmed[k] = store[k];
try { localStorage.setItem('optionsPricer:surfaceCache', JSON.stringify(trimmed)); } catch {}
} else {
try { localStorage.setItem('optionsPricer:surfaceCache', JSON.stringify(store)); } catch {}
}
},
// Use cache if it's fresh (today), otherwise hit the API. Assumes symbol+expiry are set.
async _loadForTargetExpiry() {
if (!this.symbol || !this.expiry) return;
const cached = this._getCached(this.symbol, this.expiry);
if (cached) {
this.analytics = cached;
this._processAnalytics(cached);
this._persist();
return;
}
await this.loadSurface();
},
async fetchExpirations() {
if (!this.symbol) return;
this.errorMsg = '';
this.lookingUp = true;
this.expirations = [];
this.expiry = '';
this.hasData = false;
try {
const sym = this.symbol.toUpperCase().trim();
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const env = await res.json();
const data = env.data ?? env;
this.expirations = data.expirations || (Array.isArray(data) ? data : []);
// Auto-select the 3rd Friday of next calendar month (nearest available match).
this.expiry = this._pickTargetExpiry(this.expirations);
this._persist();
// Auto-load the surface for the target expiry (cache-aware).
if (this.expiry) await this._loadForTargetExpiry();
} catch (err) {
this.errorMsg = 'Failed to look up symbol: ' + err.message;
} finally {
this.lookingUp = false;
}
},
async loadSurface() {
if (!this.symbol || !this.expiry) return;
this.loading = true;
this.errorMsg = '';
try {
const res = await fetch(
`/api/analytics?symbol=${encodeURIComponent(this.symbol)}&expiry=${encodeURIComponent(this.expiry)}`
);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const env = await res.json();
const data = env.data ?? env;
this.analytics = data;
this._processAnalytics(data);
this._writeCache(this.symbol, this.expiry, data);
this._persist();
} catch (err) {
this.errorMsg = 'Failed to load surface: ' + err.message;
} finally {
this.loading = false;
}
},
_processAnalytics(data) {
const volSurface = data.volSurface || {};
// skewMetrics is an object keyed by expiry string
const skewMetrics = data.skewMetrics || {};
// Current expiry metrics (field name is atmIv, lowercase v)
const currentSkew = skewMetrics[this.expiry] || {};
this.currentMetrics = {
atmIV: currentSkew.atmIv ?? 0,
rr25: currentSkew.rr25 ?? 0,
fly25: currentSkew.fly25 ?? 0
};
// Build skew table from all expiries
const allExpiries = volSurface.expiries || Object.keys(skewMetrics);
this.skewTable = allExpiries.map(exp => {
const m = skewMetrics[exp] || {};
return {
expiry: exp,
atmIV: m.atmIv ?? 0,
rr25: m.rr25 ?? 0,
rr10: m.rr10 ?? 0,
fly25: m.fly25 ?? 0
};
});
this.greeks = data.greeks || { atmCall: null, atmPut: null, itmCall: null, itmPut: null };
this.hvWindows = data.hvWindows || { hv20: 0, hv30: 0, hv60: 0, samples: { hv20: 0, hv30: 0, hv60: 0 } };
this.hasData = true;
// Wait a tick for x-show to render the divs
this.$nextTick(() => {
this._renderSkewChart(data);
this._renderTermChart(data);
});
},
_renderSkewChart(data) {
const strikes = data.strikes || [];
const callIVs = data.callIVs || [];
const putIVs = data.putIVs || [];
const spotPrice = data.spot ?? null;
const callSeries = callIVs.map(v => v != null ? parseFloat((v * 100).toFixed(2)) : null);
const putSeries = putIVs.map(v => v != null ? parseFloat((v * 100).toFixed(2)) : null);
const annotations = spotPrice != null ? {
xaxis: [{
x: spotPrice,
borderColor: '#ffd43b',
strokeDashArray: 5,
label: {
text: `Spot ${spotPrice}`,
style: { color: '#ffd43b', background: '#1a1c2e', fontSize: '11px' }
}
}]
} : {};
const opts = {
...BASE_CHART_OPTS,
chart: {
...BASE_CHART_OPTS.chart,
type: 'line',
height: 320
},
series: [
{ name: 'Calls', data: callSeries, color: COLOR_CALL },
{ name: 'Puts', data: putSeries, color: COLOR_PUT }
],
xaxis: {
categories: strikes,
title: { text: 'Strike', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
rotate: -30,
formatter: v => Number(v).toFixed(0)
},
axisBorder: { color: CHART_GRID },
axisTicks: { color: CHART_GRID }
},
yaxis: {
title: { text: 'Implied Volatility (%)', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
formatter: v => v != null ? `${v.toFixed(1)}%` : ''
}
},
stroke: { width: 2, curve: 'smooth' },
markers: { size: 0 },
annotations
};
if (this.skewChartInstance) {
this.skewChartInstance.updateOptions(opts, true, true);
} else {
this.skewChartInstance = new ApexCharts(document.getElementById('skewChart'), opts);
this.skewChartInstance.render();
}
},
_renderTermChart(data) {
const skewMetrics = data.skewMetrics || {};
const volSurface = data.volSurface || {};
const allExpiries = volSurface.expiries || Object.keys(skewMetrics) || [];
const atmValues = allExpiries.map(exp => {
const m = skewMetrics[exp] || {};
return m.atmIv != null ? parseFloat((m.atmIv * 100).toFixed(2)) : 0;
});
// Color gradient: blue (near) to green (far)
const barColors = allExpiries.map((_, i) => {
const ratio = allExpiries.length > 1 ? i / (allExpiries.length - 1) : 0;
const r = Math.round((1 - ratio) * 0x4d + ratio * 0x40);
const g = Math.round((1 - ratio) * 0x9e + ratio * 0xc0);
const b = Math.round((1 - ratio) * 0xf7 + ratio * 0x60);
return `rgb(${r},${g},${b})`;
});
const opts = {
...BASE_CHART_OPTS,
chart: {
...BASE_CHART_OPTS.chart,
type: 'bar',
height: 320
},
series: [{
name: 'ATM IV',
data: atmValues
}],
colors: barColors,
plotOptions: {
bar: {
distributed: true,
borderRadius: 4,
columnWidth: '60%'
}
},
xaxis: {
categories: allExpiries,
title: { text: 'Expiry', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
rotate: -40,
rotateAlways: allExpiries.length > 6
},
axisBorder: { color: CHART_GRID },
axisTicks: { color: CHART_GRID }
},
yaxis: {
title: { text: 'ATM IV (%)', style: { color: CHART_LABEL } },
labels: {
style: { colors: CHART_LABEL },
formatter: v => `${v.toFixed(1)}%`
}
},
legend: { show: false },
tooltip: {
...BASE_CHART_OPTS.tooltip,
y: {
formatter: v => `${v.toFixed(2)}%`
}
}
};
if (this.termChartInstance) {
this.termChartInstance.updateOptions(opts, true, true);
} else {
this.termChartInstance = new ApexCharts(document.getElementById('termChart'), opts);
this.termChartInstance.render();
}
},
// Greeks formatting helpers
fmtPct(v) { return v == null || isNaN(v) ? '—' : `${(v*100).toFixed(2)}%`; },
fmtPrice(v) { return v == null || isNaN(v) ? '—' : `$${v.toFixed(2)}`; },
fmtGreek(v) { return v == null || isNaN(v) ? '—' : v.toFixed(4); },
fmtGreek4(v) { return v == null || isNaN(v) ? '—' : v.toFixed(5); },
// Formatting helpers
formatPct(v) {
if (v == null || isNaN(v)) return '—';
return `${(v * 100).toFixed(2)}%`;
},
formatPctSigned(v) {
if (v == null || isNaN(v)) return '—';
const pct = (v * 100).toFixed(2);
return v >= 0 ? `+${pct}%` : `${pct}%`;
},
// HV vs IV comparison — rows for HV20/30/60 with IV-minus-HV spread (in vol points)
get hvRows() {
const iv = this.currentMetrics.atmIV || 0;
const w = this.hvWindows || {};
return [
{ key: 'hv20', label: 'HV20', value: w.hv20 || 0, spread: iv - (w.hv20 || 0) },
{ key: 'hv30', label: 'HV30', value: w.hv30 || 0, spread: iv - (w.hv30 || 0) },
{ key: 'hv60', label: 'HV60', value: w.hv60 || 0, spread: iv - (w.hv60 || 0) },
];
},
// Verdict: ATM IV vs HV30 — IV >20% above HV30 = RICH, >20% below = CHEAP, else FAIR.
get hvIvVerdict() {
const iv = this.currentMetrics.atmIV || 0;
const hv = this.hvWindows?.hv30 || 0;
if (!iv || !hv) return { label: 'N/A', cls: 'fair' };
const ratio = iv / hv;
if (ratio >= 1.20) return { label: 'RICH', cls: 'rich' };
if (ratio <= 0.80) return { label: 'CHEAP', cls: 'cheap' };
return { label: 'FAIR', cls: 'fair' };
},
get hvIvFooter() {
const iv = this.currentMetrics.atmIV || 0;
const hv = this.hvWindows?.hv30 || 0;
if (!iv || !hv) return 'IV/HV — n/a';
const ratio = (iv / hv).toFixed(2);
return `IV/HV ratio ${ratio}× · HV30 over ${this.hvWindows?.samples?.hv30 ?? 0}d`;
},
// Demo data for development / when API is unavailable
_demoExpirations() {
return [
'2025-05-16', '2025-05-23', '2025-06-20',
'2025-07-18', '2025-09-19', '2025-12-19', '2026-01-16'
];
},
_demoAnalytics() {
const expiries = this._demoExpirations();
const strikes = [];
const callIVs = [];
const putIVs = [];
const spot = 540;
for (let k = spot - 60; k <= spot + 60; k += 5) {
strikes.push(k);
const moneyness = (k - spot) / spot;
// Put skew: OTM puts have higher IV
const callIV = 0.14 + moneyness * 0.05 + Math.random() * 0.005;
const putIV = 0.14 - moneyness * 0.08 + Math.random() * 0.005;
callIVs.push(Math.max(0.05, callIV));
putIVs.push(Math.max(0.05, putIV));
}
const skewMetrics = {};
expiries.forEach((exp, i) => {
const dte = (i + 1) * 30;
const baseVol = 0.12 + 0.04 * Math.sqrt(dte / 30);
skewMetrics[exp] = {
atmIv: baseVol + (Math.random() - 0.5) * 0.01,
rr25: -0.02 - i * 0.003 + (Math.random() - 0.5) * 0.004,
rr10: -0.035 - i * 0.004,
fly25: 0.005 + i * 0.001
};
});
return {
spot,
strikes,
callIVs,
putIVs,
volSurface: { expiries },
skewMetrics
};
}
};
}
</script>
</body>
</html>