Files
options-pricer/frontend/surface.html
ojy f8aa3cdaae Vol Surface: move HV-vs-IV card into the toolbar row
User wanted the comparison sitting alongside the Lookup / Expiry /
Load Surface controls instead of in the page header. Now it lives
on the right side of the toolbar row (ms-sm-auto), still hidden
until data is loaded.

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

1093 lines
45 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">
<!-- Toolbar -->
<div class="card mb-4" style="background:#161824; border:1px solid #2d3045;">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-sm-auto">
<label class="form-label text-secondary" for="symbolInput">Symbol</label>
<div class="input-group">
<input
id="symbolInput"
type="text"
class="form-control"
style="background:#1e2030; border-color:#2d3045; color:#fff; width:100px; text-transform:uppercase;"
placeholder="SPY"
x-model="symbol"
@keydown.enter="fetchExpirations()"
@input="symbol = symbol.toUpperCase()"
:disabled="loading"
aria-label="Ticker symbol"
>
<button
class="btn btn-secondary"
@click="fetchExpirations()"
:disabled="lookingUp || !symbol"
aria-label="Look up expirations for symbol"
>
<span x-show="lookingUp" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span x-text="lookingUp ? '…' : 'Lookup'"></span>
</button>
</div>
</div>
<div class="col-12 col-sm-auto">
<label class="form-label text-secondary" for="expirySelect">Expiry</label>
<select
id="expirySelect"
class="form-select"
style="background:#1e2030; border-color:#2d3045; color:#fff; min-width:160px;"
x-model="expiry"
:disabled="loading || expirations.length === 0"
aria-label="Select expiry date"
>
<option value="" disabled>Select expiry…</option>
<template x-for="exp in expirations" :key="exp">
<option :value="exp" x-text="exp"></option>
</template>
</select>
</div>
<div class="col-12 col-sm-auto">
<button
class="btn btn-primary"
@click="loadSurface()"
:disabled="loading || !symbol || !expiry"
aria-label="Load volatility surface"
>
<span x-show="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span x-text="loading ? 'Loading…' : 'Load Surface'"></span>
</button>
</div>
<div class="col-12 col-sm-auto" 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 class="col-12 col-sm-auto ms-sm-auto" x-show="hasData" x-cloak>
<div class="hviv-card">
<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" x-text="hvIvFooter"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Skew metric badges -->
<div class="mb-4" x-show="hasData" x-cloak>
<div class="d-flex flex-wrap gap-3">
<div class="stat-inline">
<span class="stat-label">ATM IV</span>
<span class="stat-value" x-text="formatPct(currentMetrics.atmIV)"></span>
</div>
<div class="stat-inline">
<span class="stat-label">RR25</span>
<span
class="stat-value"
:class="{
'positive': currentMetrics.rr25 > 0.005,
'negative': currentMetrics.rr25 < -0.005
}"
x-text="formatPctSigned(currentMetrics.rr25)"
></span>
</div>
<div class="stat-inline">
<span class="stat-label">Fly25</span>
<span
class="stat-value"
:class="{ 'amber': Math.abs(currentMetrics.fly25) > 0.002 }"
x-text="formatPctSigned(currentMetrics.fly25)"
></span>
</div>
</div>
</div>
<!-- Greeks card -->
<div class="card mb-4" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
<div class="card-header" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">Greeks</h3>
<div class="card-options">
<span class="text-secondary small">ATM &amp; nearest ITM · <span x-text="expiry"></span></span>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-vcenter skew-table mb-0">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col" class="text-end">Strike</th>
<th scope="col" class="text-end">IV</th>
<th scope="col" class="text-end">Mid</th>
<th scope="col" class="text-end">Delta</th>
<th scope="col" class="text-end">Gamma</th>
<th scope="col" class="text-end">Theta</th>
<th scope="col" class="text-end">Vega</th>
</tr>
</thead>
<tbody>
<template x-if="greeks.atmCall">
<tr>
<td><span class="badge bg-blue-lt text-blue">ATM Call</span></td>
<td class="mono text-end" x-text="greeks.atmCall.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.atmCall.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.atmCall.midPrice)"></td>
<td class="mono text-end text-success" x-text="fmtGreek(greeks.atmCall.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.atmCall.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmCall.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmCall.vega)"></td>
</tr>
</template>
<template x-if="greeks.atmPut">
<tr>
<td><span class="badge bg-orange-lt text-orange">ATM Put</span></td>
<td class="mono text-end" x-text="greeks.atmPut.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.atmPut.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.atmPut.midPrice)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.atmPut.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmPut.vega)"></td>
</tr>
</template>
<template x-if="greeks.itmCall">
<tr style="border-top:1px solid #2d3045;">
<td><span class="badge bg-blue-lt" style="color:#8ec8ff;">ITM Call</span></td>
<td class="mono text-end" x-text="greeks.itmCall.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.itmCall.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.itmCall.midPrice)"></td>
<td class="mono text-end text-success" x-text="fmtGreek(greeks.itmCall.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.itmCall.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmCall.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmCall.vega)"></td>
</tr>
</template>
<template x-if="greeks.itmPut">
<tr>
<td><span class="badge bg-orange-lt" style="color:#ffb347;">ITM Put</span></td>
<td class="mono text-end" x-text="greeks.itmPut.strike"></td>
<td class="mono text-end" x-text="fmtPct(greeks.itmPut.iv)"></td>
<td class="mono text-end" x-text="fmtPrice(greeks.itmPut.midPrice)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.delta)"></td>
<td class="mono text-end" x-text="fmtGreek4(greeks.itmPut.gamma)"></td>
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.theta)"></td>
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmPut.vega)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Charts row -->
<div class="row g-4 mb-4" x-show="hasData" x-cloak>
<!-- IV Skew chart -->
<div class="col-12 col-xl-7">
<div class="chart-card">
<div class="card-header">
<h3 class="card-title text-white mb-0">
IV Skew —
<span x-text="symbol"></span>
<span class="text-secondary ms-1" x-text="expiry"></span>
</h3>
</div>
<div class="card-body p-0">
<div id="skewChart" style="min-height:320px;" role="img" aria-label="IV skew line chart showing calls and puts by strike price"></div>
</div>
</div>
</div>
<!-- Term structure chart -->
<div class="col-12 col-xl-5">
<div class="chart-card">
<div class="card-header">
<h3 class="card-title text-white mb-0">ATM IV Term Structure</h3>
</div>
<div class="card-body p-0">
<div id="termChart" style="min-height:320px;" role="img" aria-label="ATM IV bar chart across expiry dates"></div>
</div>
</div>
</div>
</div>
<!-- Skew table -->
<div class="card" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
<div class="card-header" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">Per-Expiry Skew Metrics</h3>
<div class="card-options">
<span class="text-secondary small" x-text="`${skewTable.length} expiries`"></span>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter skew-table mb-0" aria-label="Per-expiry skew metrics table">
<thead>
<tr>
<th scope="col">Expiry</th>
<th scope="col" class="text-end">ATM IV</th>
<th scope="col" class="text-end">RR25</th>
<th scope="col" class="text-end">RR10</th>
<th scope="col" class="text-end">Fly25</th>
<th scope="col" class="text-center">Skew Direction</th>
</tr>
</thead>
<tbody>
<template x-for="row in skewTable" :key="row.expiry">
<tr>
<td class="mono" x-text="row.expiry"></td>
<td class="mono text-end" x-text="formatPct(row.atmIV)"></td>
<td
class="mono text-end"
:class="{
'text-success': row.rr25 > 0.005,
'text-danger': row.rr25 < -0.005
}"
x-text="formatPctSigned(row.rr25)"
></td>
<td
class="mono text-end"
:class="{
'text-success': row.rr10 > 0.005,
'text-danger': row.rr10 < -0.005
}"
x-text="formatPctSigned(row.rr10)"
></td>
<td
class="mono text-end"
:class="{ 'text-warning': Math.abs(row.fly25) > 0.002 }"
x-text="formatPctSigned(row.fly25)"
></td>
<td class="text-center">
<span
class="badge"
:class="{
'bg-danger-lt text-danger': row.rr25 < -0.005,
'bg-success-lt text-success': row.rr25 > 0.005,
'bg-secondary-lt text-secondary': row.rr25 >= -0.005 && row.rr25 <= 0.005
}"
x-text="row.rr25 < -0.005 ? 'Put Skew' : row.rr25 > 0.005 ? 'Call Skew' : 'Flat'"
></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Empty state -->
<div class="card text-center py-5" style="background:#161824; border:1px solid #2d3045;" x-show="!hasData && !loading" x-cloak>
<div class="card-body">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" class="text-secondary mb-3">
<path d="M3 3v18h18"></path>
<path d="M7 16l4-8 4 4 4-4"></path>
</svg>
<h3 class="text-secondary">No surface loaded</h3>
<p class="text-muted">Enter a symbol and click Lookup, then select an expiry and click Load Surface.</p>
</div>
</div>
<!-- Loading skeleton -->
<div x-show="loading" x-cloak>
<div class="row g-4 mb-4">
<div class="col-12 col-xl-7">
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
<div class="card-body d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading skew chart…</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
<div class="card-body d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading term structure chart…</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script 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 loaded surface (survives navigating away & back)
const vs = ViewState.load('surface');
if (vs) {
this.symbol = vs.symbol ?? this.symbol;
this.expirations = vs.expirations ?? [];
this.expiry = vs.expiry ?? '';
if (vs.analytics) {
this.analytics = vs.analytics;
this._processAnalytics(vs.analytics);
}
}
},
_persist(includeAnalytics) {
ViewState.save('surface', {
symbol: this.symbol, expirations: this.expirations, expiry: this.expiry,
analytics: includeAnalytics ? this.analytics : null,
});
},
async fetchExpirations() {
if (!this.symbol) return;
this.errorMsg = '';
this.lookingUp = true;
this.expirations = [];
this.expiry = '';
this.hasData = false;
try {
const sym = this.symbol.toUpperCase().trim();
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const env = await res.json();
const data = env.data ?? env;
this.expirations = data.expirations || (Array.isArray(data) ? data : []);
if (this.expirations.length > 0) this.expiry = this.expirations[0];
this._persist(false);
} 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._persist(true);
} 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>