- New strategy.html: thinkorswim-style P/L diagram (expiration + T+N curves via Black-Scholes, days-to-expiry slider, net debit/credit, max profit/loss with unbounded detection, breakevens, net Greeks, auto-detected strategy name) - chain.html: per-row Buy/Sell buttons add legs to a localStorage basket; basket badge in toolbar; auto-scroll to ATM row on load - Persist per-page view state (symbol, expiry, loaded data, charts) across navigation via viewstate-store.js for chain/surface/tracker/dashboard - New assets: blackscholes.js (frontend BS port), strategy-store.js, viewstate-store.js - Strategy P/L nav link added to all sidebars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
649 lines
28 KiB
HTML
649 lines
28 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.0" />
|
|
<title>Options Chain — Options Pricer</title>
|
|
<link rel="stylesheet" href="/assets/tabler.min.css" />
|
|
<link rel="stylesheet" href="/assets/tabler-vendors.min.css" />
|
|
<style>
|
|
/* Sticky table header */
|
|
.table-sticky thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
background-color: var(--tblr-bg-surface);
|
|
}
|
|
|
|
/* Dense scrollable table wrappers */
|
|
.chain-scroll {
|
|
max-height: 75vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* ATM highlight overrides */
|
|
.row-atm {
|
|
background-color: rgba(var(--tblr-info-rgb), 0.12) !important;
|
|
outline: 1px solid rgba(var(--tblr-info-rgb), 0.4);
|
|
}
|
|
|
|
/* Theta always red */
|
|
.text-theta {
|
|
color: var(--tblr-danger) !important;
|
|
}
|
|
|
|
/* IV color helpers already covered by Tabler text-yellow / text-orange */
|
|
|
|
/* Toolbar badge for spot price */
|
|
.spot-badge {
|
|
font-size: 0.95rem;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="antialiased">
|
|
<div class="wrapper" x-data="chain()" 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="text-decoration-none d-flex align-items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chart-candle" width="28"
|
|
height="28" 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="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" class="icon icon-tabler icon-tabler-dashboard" 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="M5 12l-2 0l9 -9l9 9l-2 0" />
|
|
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" />
|
|
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
|
|
</svg>
|
|
</span>
|
|
<span class="nav-link-title">Dashboard</span>
|
|
</a>
|
|
</li>
|
|
|
|
<li class="nav-item active">
|
|
<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" class="icon icon-tabler icon-tabler-table" 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" class="icon icon-tabler icon-tabler-wave-sine" 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="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="tracker.html">
|
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-radar" 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>
|
|
</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">Options Chain</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page-body">
|
|
<div class="container-xl">
|
|
|
|
<!-- ===== Toolbar ===== -->
|
|
<div class="card mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2 align-items-center">
|
|
|
|
<!-- Symbol input + Lookup -->
|
|
<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">Symbol</span>
|
|
<input
|
|
id="input-symbol"
|
|
type="text"
|
|
class="form-control text-uppercase fw-bold"
|
|
style="width: 6rem;"
|
|
x-model="symbol"
|
|
placeholder="SPY"
|
|
@keydown.enter="fetchExpirations()"
|
|
aria-label="Underlying symbol"
|
|
/>
|
|
<button
|
|
class="btn btn-secondary btn-sm"
|
|
@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>
|
|
|
|
<!-- Expiry select -->
|
|
<div class="col-auto">
|
|
<label class="form-label visually-hidden" for="select-expiry">Expiry</label>
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">Expiry</span>
|
|
<select
|
|
id="select-expiry"
|
|
class="form-select"
|
|
x-model="expiry"
|
|
:disabled="expirations.length === 0"
|
|
aria-label="Expiration date"
|
|
>
|
|
<option value="" disabled>-- select --</option>
|
|
<template x-for="exp in expirations" :key="exp">
|
|
<option :value="exp" x-text="exp"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Type selector -->
|
|
<div class="col-auto">
|
|
<label class="form-label visually-hidden" for="select-type">Type</label>
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text">Type</span>
|
|
<select
|
|
id="select-type"
|
|
class="form-select"
|
|
x-model="optionType"
|
|
@change="_persist()"
|
|
aria-label="Option type filter"
|
|
>
|
|
<option value="all">All</option>
|
|
<option value="call">Calls</option>
|
|
<option value="put">Puts</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load button -->
|
|
<div class="col-auto">
|
|
<button
|
|
class="btn btn-primary btn-sm"
|
|
@click="loadChain()"
|
|
:disabled="loading || !expiry"
|
|
aria-label="Load options chain"
|
|
>
|
|
<span
|
|
x-show="loading"
|
|
class="spinner-border spinner-border-sm me-1"
|
|
role="status"
|
|
aria-hidden="true"
|
|
></span>
|
|
<span x-text="loading ? 'Loading…' : 'Load'"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Strategy basket -->
|
|
<div class="col-auto ms-auto">
|
|
<a href="strategy.html" class="btn btn-sm" :class="basketCount > 0 ? 'btn-purple' : 'btn-outline-secondary'" aria-label="Open strategy P/L">
|
|
🧺 Strategy<span x-show="basketCount > 0" class="badge bg-white text-dark ms-2" x-text="basketCount"></span>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Spot price badge -->
|
|
<div class="col-auto" x-show="spot > 0">
|
|
<span class="badge bg-blue-lt spot-badge fs-6 px-3 py-2">
|
|
<span class="text-muted me-1">Spot</span>
|
|
<strong x-text="'$' + spot.toFixed(2)"></strong>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Error message -->
|
|
<div class="col-12" x-show="error">
|
|
<div class="alert alert-danger alert-dismissible py-1 mb-0" role="alert">
|
|
<span x-text="error"></span>
|
|
<button type="button" class="btn-close" @click="error = ''" aria-label="Dismiss"></button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== Chain tables ===== -->
|
|
<div class="row g-3">
|
|
|
|
<!-- CALLS -->
|
|
<div :class="optionType === 'all' ? 'col-12 col-xxl-6' : 'col-12'" x-show="optionType === 'all' || optionType === 'call'">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title d-flex align-items-center gap-2">
|
|
<span class="badge bg-success me-1">C</span>
|
|
Calls
|
|
<span
|
|
class="badge bg-secondary ms-2"
|
|
x-show="calls.length > 0"
|
|
x-text="calls.length + ' strikes'"
|
|
></span>
|
|
</h3>
|
|
</div>
|
|
|
|
<!-- Empty / loading state -->
|
|
<div class="card-body text-center py-4" x-show="calls.length === 0 && !loading">
|
|
<div class="text-muted">No data — select a symbol and expiry, then click Load.</div>
|
|
</div>
|
|
<div class="card-body text-center py-4" x-show="loading">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading…</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chain-scroll" x-show="calls.length > 0 && !loading">
|
|
<table
|
|
class="table table-vcenter table-hover table-sm table-sticky mb-0"
|
|
aria-label="Calls options chain"
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" style="width:4.5rem">Add</th>
|
|
<th scope="col">Strike</th>
|
|
<th scope="col">Bid</th>
|
|
<th scope="col">Ask</th>
|
|
<th scope="col">Mid</th>
|
|
<th scope="col">IV%</th>
|
|
<th scope="col">Delta</th>
|
|
<th scope="col">Gamma</th>
|
|
<th scope="col">Theta/d</th>
|
|
<th scope="col">Vega/1%</th>
|
|
<th scope="col">Volume</th>
|
|
<th scope="col">OI</th>
|
|
<th scope="col">BS Price</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="row in calls" :key="row.strike">
|
|
<tr :class="rowClass(row, 'call')">
|
|
<td class="text-nowrap">
|
|
<button class="btn btn-success btn-icon btn-sm py-0 px-1" style="--tblr-btn-size:1.1rem" @click="addToStrategy(row, 'call', 'long')" :title="'Buy 1 ' + row.strike + 'C'">B</button>
|
|
<button class="btn btn-danger btn-icon btn-sm py-0 px-1 ms-1" style="--tblr-btn-size:1.1rem" @click="addToStrategy(row, 'call', 'short')" :title="'Sell 1 ' + row.strike + 'C'">S</button>
|
|
</td>
|
|
<td class="fw-semibold" x-text="row.strike"></td>
|
|
<td x-text="fmt2(row.bid)"></td>
|
|
<td x-text="fmt2(row.ask)"></td>
|
|
<td x-text="fmt2(row.midPrice)"></td>
|
|
<td :class="ivClass(row.iv)" x-text="fmtIV(row.iv)"></td>
|
|
<td x-text="fmt4(row.delta)"></td>
|
|
<td x-text="fmt4(row.gamma)"></td>
|
|
<td class="text-theta" x-text="fmt2(row.theta)"></td>
|
|
<td x-text="fmt2(row.vega)"></td>
|
|
<td x-text="fmtInt(row.volume)"></td>
|
|
<td x-text="fmtInt(row.openInterest)"></td>
|
|
<td x-text="fmt2(row.bsPrice)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PUTS -->
|
|
<div :class="optionType === 'all' ? 'col-12 col-xxl-6' : 'col-12'" x-show="optionType === 'all' || optionType === 'put'">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title d-flex align-items-center gap-2">
|
|
<span class="badge bg-danger me-1">P</span>
|
|
Puts
|
|
<span
|
|
class="badge bg-secondary ms-2"
|
|
x-show="puts.length > 0"
|
|
x-text="puts.length + ' strikes'"
|
|
></span>
|
|
</h3>
|
|
</div>
|
|
|
|
<!-- Empty / loading state -->
|
|
<div class="card-body text-center py-4" x-show="puts.length === 0 && !loading">
|
|
<div class="text-muted">No data — select a symbol and expiry, then click Load.</div>
|
|
</div>
|
|
<div class="card-body text-center py-4" x-show="loading">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading…</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chain-scroll" x-show="puts.length > 0 && !loading">
|
|
<table
|
|
class="table table-vcenter table-hover table-sm table-sticky mb-0"
|
|
aria-label="Puts options chain"
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" style="width:4.5rem">Add</th>
|
|
<th scope="col">Strike</th>
|
|
<th scope="col">Bid</th>
|
|
<th scope="col">Ask</th>
|
|
<th scope="col">Mid</th>
|
|
<th scope="col">IV%</th>
|
|
<th scope="col">Delta</th>
|
|
<th scope="col">Gamma</th>
|
|
<th scope="col">Theta/d</th>
|
|
<th scope="col">Vega/1%</th>
|
|
<th scope="col">Volume</th>
|
|
<th scope="col">OI</th>
|
|
<th scope="col">BS Price</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="row in puts" :key="row.strike">
|
|
<tr :class="rowClass(row, 'put')">
|
|
<td class="text-nowrap">
|
|
<button class="btn btn-success btn-icon btn-sm py-0 px-1" style="--tblr-btn-size:1.1rem" @click="addToStrategy(row, 'put', 'long')" :title="'Buy 1 ' + row.strike + 'P'">B</button>
|
|
<button class="btn btn-danger btn-icon btn-sm py-0 px-1 ms-1" style="--tblr-btn-size:1.1rem" @click="addToStrategy(row, 'put', 'short')" :title="'Sell 1 ' + row.strike + 'P'">S</button>
|
|
</td>
|
|
<td class="fw-semibold" x-text="row.strike"></td>
|
|
<td x-text="fmt2(row.bid)"></td>
|
|
<td x-text="fmt2(row.ask)"></td>
|
|
<td x-text="fmt2(row.midPrice)"></td>
|
|
<td :class="ivClass(row.iv)" x-text="fmtIV(row.iv)"></td>
|
|
<td x-text="fmt4(row.delta)"></td>
|
|
<td x-text="fmt4(row.gamma)"></td>
|
|
<td class="text-theta" x-text="fmt2(row.theta)"></td>
|
|
<td x-text="fmt2(row.vega)"></td>
|
|
<td x-text="fmtInt(row.volume)"></td>
|
|
<td x-text="fmtInt(row.openInterest)"></td>
|
|
<td x-text="fmt2(row.bsPrice)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /row -->
|
|
</div><!-- /container-xl -->
|
|
</div><!-- /page-body -->
|
|
|
|
<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">
|
|
Options Pricer © <span x-text="new Date().getFullYear()"></span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</div><!-- /page-wrapper -->
|
|
<!-- Toast -->
|
|
<div x-show="toast" x-transition x-cloak style="position:fixed;bottom:1rem;right:1rem;z-index:1080;">
|
|
<div class="alert alert-success py-2 px-3 mb-0" x-text="toast"></div>
|
|
</div>
|
|
</div><!-- /wrapper -->
|
|
|
|
<script src="/assets/tabler.min.js" defer></script>
|
|
<script src="/assets/strategy-store.js"></script>
|
|
<script src="/assets/viewstate-store.js"></script>
|
|
<script src="/assets/alpine.min.js" defer></script>
|
|
|
|
<script>
|
|
function chain() {
|
|
return {
|
|
// ── state ──────────────────────────────────────────────
|
|
symbol: 'SPY',
|
|
expiry: '',
|
|
optionType: 'all',
|
|
expirations: [],
|
|
calls: [],
|
|
puts: [],
|
|
spot: 0,
|
|
loading: false,
|
|
lookingUp: false,
|
|
error: '',
|
|
basketCount: 0,
|
|
toast: '',
|
|
|
|
// ── lifecycle ──────────────────────────────────────────
|
|
async init() {
|
|
// restore last loaded chain (survives navigating away & back)
|
|
const vs = ViewState.load('chain');
|
|
if (vs) {
|
|
this.symbol = vs.symbol ?? this.symbol;
|
|
this.expirations = vs.expirations ?? [];
|
|
this.expiry = vs.expiry ?? '';
|
|
this.optionType = vs.optionType ?? 'all';
|
|
this.calls = vs.calls ?? [];
|
|
this.puts = vs.puts ?? [];
|
|
this.spot = vs.spot ?? 0;
|
|
if (this.calls.length || this.puts.length) this.$nextTick(() => this._scrollToATM());
|
|
}
|
|
this.basketCount = StrategyStore.count();
|
|
window.addEventListener('storage', (e) => {
|
|
if (e.key === StrategyStore.KEY) this.basketCount = StrategyStore.count();
|
|
});
|
|
},
|
|
|
|
_persist() {
|
|
ViewState.save('chain', {
|
|
symbol: this.symbol, expirations: this.expirations, expiry: this.expiry,
|
|
optionType: this.optionType, calls: this.calls, puts: this.puts, spot: this.spot,
|
|
});
|
|
},
|
|
|
|
_scrollToATM() {
|
|
setTimeout(() => {
|
|
document.querySelectorAll('.chain-scroll .row-atm').forEach((el) =>
|
|
el.scrollIntoView({ block: 'center' }));
|
|
}, 60);
|
|
},
|
|
|
|
// ── strategy basket ────────────────────────────────────
|
|
addToStrategy(row, type, side) {
|
|
const sym = (this.symbol || 'SPY').toUpperCase().trim();
|
|
const other = StrategyStore.mismatch(sym);
|
|
if (other && !confirm(`Strategy basket has ${other} legs. Clear it and start a ${sym} strategy?`)) return;
|
|
const entry = row.midPrice || row.bsPrice || row.ask || row.bid || 0;
|
|
StrategyStore.addLeg({
|
|
symbol: sym,
|
|
expiry: row.expiry || this.expiry,
|
|
type,
|
|
strike: row.strike,
|
|
side, qty: 1,
|
|
entryPrice: entry,
|
|
iv: row.iv || 0,
|
|
spotSnapshot: this.spot || 0,
|
|
});
|
|
this.basketCount = StrategyStore.count();
|
|
this.flash(`${side === 'long' ? 'Bought' : 'Sold'} 1 ${sym} ${row.strike}${type === 'call' ? 'C' : 'P'} @ $${entry.toFixed(2)}`);
|
|
},
|
|
flash(msg) { this.toast = msg; clearTimeout(this._t); this._t = setTimeout(() => { this.toast = ''; }, 2500); },
|
|
|
|
// ── data fetching ──────────────────────────────────────
|
|
async fetchExpirations() {
|
|
if (!this.symbol) return;
|
|
this.error = '';
|
|
this.lookingUp = true;
|
|
this.expirations = [];
|
|
this.expiry = '';
|
|
this.calls = [];
|
|
this.puts = [];
|
|
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}: ${res.statusText}`);
|
|
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();
|
|
} catch (err) {
|
|
this.error = 'Failed to look up symbol: ' + err.message;
|
|
} finally {
|
|
this.lookingUp = false;
|
|
}
|
|
},
|
|
|
|
async loadChain() {
|
|
if (!this.expiry) return;
|
|
this.error = '';
|
|
this.loading = true;
|
|
this.calls = [];
|
|
this.puts = [];
|
|
|
|
try {
|
|
const sym = (this.symbol || 'SPY').toUpperCase().trim();
|
|
const url = `/api/chain?symbol=${encodeURIComponent(sym)}&expiry=${encodeURIComponent(this.expiry)}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
const env = await res.json();
|
|
// API wraps in { ok, data: { snapshots: [{ spot, chain }] } }
|
|
const snap = env.data?.snapshots?.[0] ?? env.data ?? {};
|
|
this.spot = snap.spot ?? 0;
|
|
const chain = snap.chain ?? [];
|
|
|
|
if (false) {
|
|
// placeholder to keep else structure
|
|
} else if (Array.isArray(chain)) {
|
|
this.calls = this._sortByStrike(
|
|
chain.filter(r => (r.optionType || r.type || '').toLowerCase() === 'call')
|
|
);
|
|
this.puts = this._sortByStrike(
|
|
chain.filter(r => (r.optionType || r.type || '').toLowerCase() === 'put')
|
|
);
|
|
this._persist();
|
|
this.$nextTick(() => this._scrollToATM());
|
|
} else {
|
|
throw new Error('Unexpected API response shape.');
|
|
}
|
|
} catch (err) {
|
|
this.error = 'Failed to load chain: ' + err.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
// ── helpers ────────────────────────────────────────────
|
|
_sortByStrike(arr) {
|
|
return [...arr].sort((a, b) => a.strike - b.strike);
|
|
},
|
|
|
|
// ATM strike: the strike (across all rows) closest to spot
|
|
get atmStrike() {
|
|
const all = this.calls.concat(this.puts);
|
|
if (all.length === 0 || this.spot === 0) return null;
|
|
return all.reduce((best, row) => {
|
|
return Math.abs(row.strike - this.spot) < Math.abs(best.strike - this.spot)
|
|
? row
|
|
: best;
|
|
}).strike;
|
|
},
|
|
|
|
// Row background class
|
|
rowClass(row, side) {
|
|
const s = row.strike;
|
|
if (s === this.atmStrike) return 'row-atm';
|
|
if (side === 'call' && s < this.spot) return 'bg-success-lt'; // ITM call
|
|
if (side === 'put' && s > this.spot) return 'bg-danger-lt'; // ITM put
|
|
return '';
|
|
},
|
|
|
|
// IV colour class
|
|
ivClass(iv) {
|
|
const pct = (iv ?? 0) * 100;
|
|
if (pct < 20) return '';
|
|
if (pct < 40) return 'text-yellow fw-semibold';
|
|
return 'text-orange fw-semibold';
|
|
},
|
|
|
|
// ── formatters ─────────────────────────────────────────
|
|
fmt2(v) { return v == null ? '—' : Number(v).toFixed(2); },
|
|
fmt4(v) { return v == null ? '—' : Number(v).toFixed(4); },
|
|
fmtIV(iv) {
|
|
if (iv == null) return '—';
|
|
return (Number(iv) * 100).toFixed(1) + '%';
|
|
},
|
|
fmtInt(v) {
|
|
if (v == null) return '—';
|
|
return Number(v).toLocaleString('en-US', { maximumFractionDigits: 0 });
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|