Files
options-pricer/frontend/strategy.html
ojy 03afae3d04 Drag-to-pan the P/L chart price range
Hold the left mouse button on the chart and drag left/right to slide the
underlying-price window; the curve is re-sampled as you drag so it always
extends to the edges. Fit / switching symbols / clear-all re-centre and
reset zoom.

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

727 lines
44 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>Strategy P/L — 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>
<style>
.chart-card { background:#1e2030; border:1px solid #2d3045; border-radius:.5rem; }
.chart-card .card-header { background:transparent; border-bottom:1px solid #2d3045; }
#plChart { background:#1e2030; border-radius:0 0 .5rem .5rem; }
[x-cloak] { display:none !important; }
.leg-table th { background:#1a1c2e; color:#8b95a7; font-size:.7rem; text-transform:uppercase; letter-spacing:.05em; border-bottom:1px solid #2d3045; }
.leg-table td { border-bottom:1px solid #1e2030; color:#d0d5e0; font-size:.85rem; vertical-align:middle; }
.leg-table input, .leg-table select { background:#1e2030; border-color:#2d3045; color:#fff; }
.stat-box { background:#1e2030; border:1px solid #2d3045; border-radius:.5rem; padding:.75rem 1rem; }
.stat-box .lbl { color:#8b95a7; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
.stat-box .val { font-size:1.15rem; font-weight:700; color:#fff; }
.val.pos { color:#51cf66; } .val.neg { color:#ff6b6b; } .val.amber { color:#ffd43b; }
.mono { font-family:'JetBrains Mono','Fira Code',monospace; }
.apexcharts-tooltip { background:#1e2030 !important; border:1px solid #2d3045 !important; color:#fff !important; }
.apexcharts-tooltip-title { background:#2d3045 !important; border-bottom:1px solid #3a3f5a !important; }
.toast-mini { position:fixed; bottom:1rem; right:1rem; z-index:1080; }
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="strategyApp()" 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="#sb-menu" 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" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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="sb-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" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"><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" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10l18 0"/><path d="M10 5v14"/></svg></span><span class="nav-link-title">Options Chain</span></a></li>
<li class="nav-item"><a class="nav-link" href="surface.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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 active"><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" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>
<!-- Page -->
<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-auto" x-show="symbols.length > 1" x-cloak>
<label class="form-label text-secondary mb-1" for="symPicker">Strategy</label>
<select id="symPicker" class="form-select form-select-sm fw-bold" style="min-width:7rem" :value="symbol" @change="switchSymbol($event.target.value)" aria-label="Pick strategy symbol">
<template x-for="s in symbols" :key="s"><option :value="s" x-text="s"></option></template>
</select>
</div>
<div class="col">
<h2 class="page-title">Strategy P/L Analyzer</h2>
<div class="text-secondary mt-1" x-show="legs.length > 0" x-cloak>
<span class="badge bg-purple-lt fs-6 me-2" x-text="strategyName"></span>
<span x-show="symbols.length <= 1" x-text="symbol"></span><span x-show="symbols.length <= 1"> · </span>
<span x-text="legs.length + ' leg' + (legs.length===1?'':'s')"></span> ·
Spot <strong class="mono" x-text="spot > 0 ? '$'+spot.toFixed(2) : '—'"></strong>
<span x-show="symbols.length > 1" class="ms-2 text-secondary" x-text="'(' + symbols.length + ' symbols saved)'"></span>
</div>
</div>
<div class="col-auto" x-show="legs.length > 0" x-cloak>
<button class="btn btn-outline-primary btn-sm me-1" @click="reloadMarket()" :disabled="refreshing" title="Re-fetch spot, marks &amp; IVs; refresh entry price on unlocked legs">
<span x-show="refreshing" class="spinner-border spinner-border-sm me-1" role="status"></span>Reload
</button>
<button class="btn btn-outline-danger btn-sm" @click="clearAll()">Clear all</button>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- Empty state -->
<div class="card text-center py-5" x-show="legs.length === 0" 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 stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l4 -6l4 2l4 -8l4 5"/><path d="M4 4v16h16"/></svg>
<h3 class="text-secondary">No strategy built yet</h3>
<p class="text-muted">Go to the <a href="chain.html">Options Chain</a>, then click <span class="badge bg-success">B</span> (buy) or <span class="badge bg-danger">S</span> (sell) on options to add legs — or add one manually below.</p>
<button class="btn btn-primary btn-sm mt-2" @click="showManual = !showManual">+ Add leg manually</button>
</div>
</div>
<!-- P/L chart -->
<div class="chart-card mb-3" x-show="legs.length > 0" x-cloak>
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-3">
<h3 class="card-title text-white mb-0">Profit / Loss vs. Underlying Price</h3>
<div class="d-flex align-items-center flex-wrap gap-3">
<!-- price-range zoom -->
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom price range">
<button class="btn btn-outline-secondary" @click="zoomOut()" title="Zoom out — wider price range"></button>
<button class="btn btn-outline-secondary" disabled style="min-width:5rem" x-text="'±' + lastHalfPct + '%'"></button>
<button class="btn btn-outline-secondary" @click="zoomIn()" title="Zoom in — narrower price range">+</button>
<button class="btn btn-outline-secondary" @click="zoomFit()" title="Reset zoom">Fit</button>
</div>
<!-- time slider -->
<div class="d-flex align-items-center gap-2" style="min-width:300px;">
<span class="text-secondary small text-nowrap">Now</span>
<input type="range" class="form-range" min="0" :max="maxDTE" step="1" x-model.number="dteOffset" @input="scheduleRender()" style="min-width:150px;">
<span class="text-secondary small text-nowrap">Exp</span>
<span class="badge bg-blue-lt text-nowrap" x-text="dteLabel"></span>
</div>
</div>
</div>
<div class="card-body p-0">
<div id="plChart" :style="'min-height:380px;user-select:none;cursor:' + (_drag ? 'grabbing' : 'grab')"
@mousedown="startPan($event)" @mousemove.window="onPan($event)" @mouseup.window="endPan()" @mouseleave.window="endPan()"
role="img" aria-label="Profit and loss diagram (drag left/right to pan the price range)"></div>
</div>
</div>
<!-- Stats -->
<div class="row g-2 mb-4" x-show="legs.length > 0" x-cloak>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net</div><div class="val" :class="netCost>=0?'neg':'pos'"><span x-text="netCost>=0?'Debit ':'Credit '"></span><span x-text="fmtMoney(Math.abs(netCost))"></span></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Max Profit</div><div class="val pos" x-text="stats.maxProfit"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Max Loss</div><div class="val neg" x-text="stats.maxLoss"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Break-even(s)</div><div class="val amber mono" style="font-size:.95rem" x-text="stats.breakevens"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Δ (shares)</div><div class="val mono" :class="stats.delta>=0?'pos':'neg'" x-text="stats.delta.toFixed(1)"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Γ</div><div class="val mono" x-text="stats.gamma.toFixed(2)"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Θ / day</div><div class="val mono" :class="stats.theta>=0?'pos':'neg'" x-text="fmtMoney(stats.theta)"></div></div></div>
<div class="col-6 col-md-3 col-xl"><div class="stat-box"><div class="lbl">Net Vega / 1%</div><div class="val mono" :class="stats.vega>=0?'pos':'neg'" x-text="fmtMoney(stats.vega)"></div></div></div>
</div>
<!-- Legs table (at the bottom) -->
<div class="card mb-4" x-show="legs.length > 0 || showManual" x-cloak>
<div class="card-header d-flex align-items-center justify-content-between">
<h3 class="card-title mb-0">Legs <span class="text-secondary small fw-normal">— Mark = live mid · 🔒 locks entry price · uncheck to drop from chart</span></h3>
<div>
<span class="me-3" :class="netCost >= 0 ? 'text-danger' : 'text-success'">
Net <strong x-text="netCost >= 0 ? 'debit' : 'credit'"></strong>:
<strong class="mono" x-text="fmtMoney(Math.abs(netCost))"></strong>
</span>
<button class="btn btn-outline-primary btn-sm" @click="showManual = !showManual" x-text="showManual ? 'Hide manual entry' : '+ Add leg manually'"></button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm leg-table mb-0">
<thead>
<tr>
<th class="text-center" style="width:3rem">Show</th>
<th>Side</th><th>Qty</th><th>Type</th><th class="text-end">Strike</th><th>Expiry</th>
<th class="text-end">Entry $</th><th class="text-end">Mark</th><th class="text-end">IV</th>
<th class="text-end">Cost</th><th class="text-end">Δ</th><th class="text-end">Θ/d</th><th></th>
</tr>
</thead>
<tbody>
<template x-for="lv in legsView" :key="lv.id">
<tr :style="lv.enabled === false ? 'opacity:.4' : ''">
<td class="text-center"><input type="checkbox" class="form-check-input m-0" :checked="lv.enabled !== false" @change="toggleLeg(lv.id)" :aria-label="'Show leg '+lv.strike+lv.type"></td>
<td style="width:7rem">
<select class="form-select form-select-sm" :value="lv.side" @change="updateLeg(lv.id, { side: $event.target.value })">
<option value="long">Long</option><option value="short">Short</option>
</select>
</td>
<td style="width:5rem"><input type="number" min="1" step="1" class="form-control form-control-sm" :value="lv.qty" @change="updateLeg(lv.id, { qty: Math.max(1, Math.round(+$event.target.value||1)) })"></td>
<td><span class="badge" :class="lv.type==='call' ? 'bg-success-lt text-success' : 'bg-danger-lt text-danger'" x-text="lv.type"></span></td>
<td class="text-end mono" style="width:7rem">
<select x-show="hasStrikeOpts(lv)" class="form-select form-select-sm text-end" :value="lv.strike" @change="changeStrike(lv.id, +$event.target.value)" title="Change strike — entry price, IV & mark update from the loaded chain">
<template x-for="k in strikeOpts(lv)" :key="k"><option :value="k" x-text="k"></option></template>
</select>
<span x-show="!hasStrikeOpts(lv)" x-text="lv.strike"></span>
</td>
<td class="mono small" x-text="lv.expiry"></td>
<td style="width:8.5rem">
<div class="input-group input-group-sm flex-nowrap">
<input type="number" min="0" step="0.01" class="form-control form-control-sm text-end" :class="lv.locked ? 'opacity-75' : ''" :value="lv.entryPrice" :disabled="lv.locked" @change="updateLeg(lv.id, { entryPrice: Math.max(0, +$event.target.value||0) })">
<button class="btn btn-sm btn-outline-secondary px-1" @click="updateLeg(lv.id, { locked: !lv.locked })" :title="lv.locked ? 'Entry price locked — click to unlock (reload will update it)' : 'Click to lock entry price (reload won\'t change it)'" x-text="lv.locked ? '🔒' : '🔓'"></button>
</div>
</td>
<td class="text-end mono small">
<template x-if="lv.currentMark != null">
<span>$<span x-text="lv.currentMark.toFixed(2)"></span><span x-show="Math.abs(lv.currentMark - lv.entryPrice) > 0.005" :class="(lv.currentMark - lv.entryPrice) >= 0 ? 'text-success' : 'text-danger'" x-text="(lv.currentMark - lv.entryPrice >= 0 ? ' +' : ' ') + (lv.currentMark - lv.entryPrice).toFixed(2)"></span></span>
</template>
<template x-if="lv.currentMark == null"><span class="text-secondary"></span></template>
</td>
<td class="text-end mono small" x-text="lv.iv > 0 ? (lv.iv*100).toFixed(1)+'%' : '—'"></td>
<td class="text-end mono" :class="lv.cost >= 0 ? 'text-danger' : 'text-success'" x-text="fmtMoney(lv.cost)"></td>
<td class="text-end mono small" :class="lv.delta>=0?'text-success':'text-danger'" x-text="lv.delta.toFixed(1)"></td>
<td class="text-end mono small text-danger" x-text="lv.theta.toFixed(1)"></td>
<td class="text-end"><button class="btn btn-sm btn-ghost-danger" @click="removeLeg(lv.id)" aria-label="Remove leg"></button></td>
</tr>
</template>
<!-- Manual entry row -->
<template x-if="showManual">
<tr style="background:#161824;">
<td></td>
<td><select class="form-select form-select-sm" x-model="manual.side"><option value="long">Long</option><option value="short">Short</option></select></td>
<td><input type="number" min="1" step="1" class="form-control form-control-sm" x-model.number="manual.qty"></td>
<td><select class="form-select form-select-sm" x-model="manual.type"><option value="call">call</option><option value="put">put</option></select></td>
<td><input type="number" step="0.5" class="form-control form-control-sm text-end" placeholder="strike" x-model.number="manual.strike"></td>
<td><input type="date" class="form-control form-control-sm" x-model="manual.expiry"></td>
<td><input type="number" min="0" step="0.01" class="form-control form-control-sm text-end" placeholder="entry $" x-model.number="manual.entryPrice"></td>
<td></td>
<td><input type="number" min="0" step="0.5" class="form-control form-control-sm text-end" placeholder="IV %" x-model.number="manual.ivPct"></td>
<td colspan="4" class="text-end"><button class="btn btn-sm btn-primary" @click="addManualLeg()">Add leg</button></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast-mini" x-show="toast" x-transition x-cloak>
<div class="alert alert-success py-2 px-3 mb-0" x-text="toast"></div>
</div>
</div>
<script src="/assets/tabler.min.js" defer></script>
<script src="/assets/blackscholes.js"></script>
<script src="/assets/strategy-store.js"></script>
<script src="/assets/alpine.min.js" defer></script>
<script>
const R = 0.05; // risk-free rate (matches backend)
const MULT = 100; // contract multiplier
const DAY_MS = 86400000;
const CHART_BG='#1e2030', CHART_GRID='#2d3045', CHART_LABEL='#8b95a7';
const COLOR_EXP='#4dd4ac', COLOR_TN='#a98eda';
function legDTE(leg) {
// days from now to expiry; can be negative if expired
const t = Date.parse(leg.expiry + 'T00:00:00Z');
return (t - Date.now()) / DAY_MS;
}
function legSign(leg) { return leg.side === 'short' ? -1 : 1; }
function legCost(leg) { return legSign(leg) * leg.qty * MULT * (leg.entryPrice || 0); }
/** Value (per share) of a leg at underlying S, evaluated `offsetDays` from now. */
function legValueAt(leg, S, offsetDays) {
const remDays = legDTE(leg) - offsetDays;
if (remDays <= 0) return BS.intrinsic(S, leg.strike, leg.type);
const sigma = leg.iv > 0 ? leg.iv : 0.0001;
return BS.bsPrice(S, leg.strike, remDays / 365, R, sigma, leg.type);
}
/** Position P/L at underlying S, evaluated `offsetDays` from now. */
function plAt(legs, netCost, S, offsetDays) {
let v = 0;
for (const leg of legs) v += legSign(leg) * leg.qty * MULT * legValueAt(leg, S, offsetDays);
return v - netCost;
}
function detectStrategy(legs) {
const n = legs.length;
if (n === 0) return 'Empty';
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
const sw = s => s === 'long' ? 'Long' : 'Short';
if (n === 1) return `${sw(legs[0].side)} ${cap(legs[0].type)}`;
const ls = [...legs].sort((a,b)=> a.type.localeCompare(b.type) || a.strike-b.strike || a.expiry.localeCompare(b.expiry));
const allSameExp = ls.every(l => l.expiry === ls[0].expiry);
const calls = ls.filter(l=>l.type==='call'), puts = ls.filter(l=>l.type==='put');
if (n === 2) {
const [a,b] = ls;
if (a.type===b.type && a.expiry===b.expiry && a.side!==b.side && a.qty===b.qty) {
const L = a.side==='long'?a:b, Sh = a.side==='long'?b:a;
if (a.type==='call') return L.strike < Sh.strike ? 'Bull Call Spread (debit)' : 'Bear Call Spread (credit)';
return L.strike > Sh.strike ? 'Bear Put Spread (debit)' : 'Bull Put Spread (credit)';
}
if (calls.length===1 && puts.length===1 && a.side===b.side && a.expiry===b.expiry)
return calls[0].strike===puts[0].strike ? `${sw(a.side)} Straddle` : `${sw(a.side)} Strangle`;
if (a.type===b.type && a.strike===b.strike && a.expiry!==b.expiry && a.side!==b.side) return 'Calendar Spread';
if (a.type===b.type && a.strike!==b.strike && a.expiry!==b.expiry && a.side!==b.side) return 'Diagonal Spread';
return 'Custom (2 legs)';
}
if (n === 3) {
if (allSameExp && (calls.length===3 || puts.length===3)) {
const [lo,mid,hi] = ls;
if (lo.side===hi.side && lo.side!==mid.side && lo.qty===hi.qty && mid.qty===2*lo.qty)
return `${lo.side==='long'?'Long':'Short'} ${cap(ls[0].type)} Butterfly`;
}
return 'Custom (3 legs)';
}
if (n === 4) {
if (allSameExp && calls.length===2 && puts.length===2) {
const [pL,pH] = puts, [cL,cH] = calls;
const ic = pL.side==='long'&&pH.side==='short'&&cL.side==='short'&&cH.side==='long';
if (ic && pH.strike===cL.strike) return 'Iron Butterfly';
if (ic) return 'Iron Condor';
const ric = pL.side==='short'&&pH.side==='long'&&cL.side==='long'&&cH.side==='short';
if (ric) return 'Reverse Iron Condor';
}
if (allSameExp && calls.length===4) return 'Call Condor';
if (allSameExp && puts.length===4) return 'Put Condor';
return 'Custom (4 legs)';
}
return `Custom (${n} legs)`;
}
function fmtMoney(v) {
if (v == null || !isFinite(v)) return '—';
const sign = v < 0 ? '-' : '';
const a = Math.abs(v);
return sign + '$' + a.toLocaleString('en-US', { maximumFractionDigits: a < 100 ? 2 : 0 });
}
function strategyApp() {
return {
// state
symbol: '', symbols: [], spot: 0, legs: [],
dteOffset: 0,
xZoom: 1, xPan: 0, lastHalfPct: 0,
_drag: null,
refreshing: false, showManual: false, toast: '',
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
chart: null, _renderTimer: null,
_chainCache: {}, // "SYMBOL@EXPIRY" -> { "strike@type": optionRow }
init() {
this.reload();
this._chainCache = this._seedChainCache();
// pull live spot / marks / IVs (and per-expiry chains) on open
if (this.legs.length > 0 && this.symbol) this.reloadMarket(false);
// re-sync if another tab changed the basket
window.addEventListener('storage', (e) => { if (e.key === StrategyStore.KEY) this.reload(); });
},
// seed the strike picker from whatever chain was last loaded on chain.html
_seedChainCache() {
try {
const vs = (typeof ViewState !== 'undefined') ? ViewState.load('chain') : null;
if (!vs || !vs.symbol || !vs.expiry) return {};
const map = {};
for (const o of (vs.calls || [])) map[Number(o.strike) + '@call'] = o;
for (const o of (vs.puts || [])) map[Number(o.strike) + '@put'] = o;
return Object.keys(map).length ? { [vs.symbol + '@' + vs.expiry]: map } : {};
} catch { return {}; }
},
_legMap(lv) { return this._chainCache[lv.symbol + '@' + lv.expiry] || null; },
hasStrikeOpts(lv) {
const m = this._legMap(lv);
if (!m) return false;
for (const k of Object.keys(m)) if (k.endsWith('@' + lv.type)) return true;
return false;
},
strikeOpts(lv) {
const m = this._legMap(lv);
const out = [];
if (m) for (const k of Object.keys(m)) { if (k.endsWith('@' + lv.type)) out.push(parseFloat(k)); }
if (!out.includes(lv.strike)) out.push(lv.strike);
return out.sort((a, b) => a - b);
},
changeStrike(id, newStrike) {
const leg = this.legs.find(l => l.id === id);
if (!leg || !Number.isFinite(newStrike) || newStrike === leg.strike) return;
const m = this._legMap(leg);
const o = m && m[Number(newStrike) + '@' + leg.type];
const patch = { strike: newStrike };
if (o) {
const mid = Math.round(((o.midPrice ?? o.mid ?? o.bsPrice ?? leg.entryPrice) || 0) * 100) / 100;
patch.entryPrice = mid;
patch.currentMark = mid;
if (o.iv > 0) patch.iv = o.iv;
patch.locked = false; // it's a different contract now — start fresh
}
this.updateLeg(id, patch);
},
reload() {
const st = StrategyStore.load();
this.symbol = st.symbol || '';
this.symbols = st.symbols || [];
this.spot = st.spotSnapshot || 0;
this.legs = st.legs || [];
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
if (this.legs.length > 0) this.$nextTick(() => this.renderChart()); else if (this.chart) this.chart.updateSeries([{name:'P/L',data:[]},{name:'P/L',data:[]}]);
},
switchSymbol(sym) {
if (!sym || sym === this.symbol) return;
StrategyStore.setActive(sym);
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
this.reload();
if (this.legs.length > 0) this.reloadMarket(false);
},
// ── derived ───────────────────────────────────────────
// only legs with the "Show" checkbox ticked drive the chart / stats / Greeks
get activeLegs() { return this.legs.filter(l => l.enabled !== false); },
get netCost() { return this.activeLegs.reduce((s,l)=> s + legCost(l), 0); },
get strategyName() { return detectStrategy(this.activeLegs); },
get maxDTE() { const a = this.activeLegs; return a.length ? Math.max(1, Math.ceil(Math.max(0, ...a.map(legDTE)))) : 1; },
// exact (not floored) days to the earliest expiry — so the "expiration"
// curve uses true intrinsic value (sharp hockey stick), not a near-expiry BS approx
get minDTE() { const a = this.activeLegs; return a.length ? Math.max(0, Math.min(...a.map(legDTE))) : 0; },
get dteLabel() {
const d = new Date(Date.now() + this.dteOffset * DAY_MS);
const ds = d.toISOString().slice(0,10);
if (this.dteOffset === 0) return 'Today (' + ds + ')';
return 'T+' + this.dteOffset + 'd · ' + ds;
},
get legsView() {
return this.legs.map(l => {
const dteY = Math.max(legDTE(l), 0) / 365;
const sigma = l.iv > 0 ? l.iv : 0.0001;
const g = (this.spot > 0)
? BS.bsGreeks(this.spot, l.strike, dteY, R, sigma, l.type)
: { delta:0, gamma:0, theta:0, vega:0 };
const k = legSign(l) * l.qty * MULT;
return {
...l,
cost: legCost(l),
delta: g.delta * k,
gamma: g.gamma * k,
theta: g.theta * k,
vega: g.vega * k,
};
});
},
get stats() {
const legs = this.activeLegs, net = this.netCost, spot = this.spot;
if (legs.length === 0) return { maxProfit:'—', maxLoss:'—', breakevens:'—', delta:0, gamma:0, theta:0, vega:0 };
// dense expiration-curve sample for breakevens / extremes / unbounded
const lo0 = 0.01;
const hi0 = Math.max(spot * 2, ...legs.map(l=>l.strike)) * 1.5 + 10;
const Nd = 600;
const xs = [], ys = [];
for (let i = 0; i <= Nd; i++) {
const x = lo0 + (hi0 - lo0) * i / Nd;
xs.push(x); ys.push(plAt(legs, net, x, this.minDTE));
}
// breakevens (sign changes)
const bes = [];
for (let i = 1; i <= Nd; i++) {
const y0 = ys[i-1], y1 = ys[i];
if (y0 === 0) { bes.push(xs[i-1]); continue; }
if ((y0 < 0 && y1 > 0) || (y0 > 0 && y1 < 0)) {
const x = xs[i-1] + (0 - y0) * (xs[i]-xs[i-1]) / (y1 - y0);
bes.push(x);
}
}
const uniqBE = bes.filter((v,i)=> i===0 || Math.abs(v - bes[i-1]) > 1e-6);
// extremes — include the grid samples AND the exact kink points (strikes)
// plus the sample bounds, since piecewise payoffs peak/trough at strikes
let maxY = -Infinity, minY = Infinity;
for (const y of ys) { if (y > maxY) maxY = y; if (y < minY) minY = y; }
for (const k of new Set([...legs.map(l=>l.strike), lo0, hi0])) {
const y = plAt(legs, net, k, this.minDTE);
if (y > maxY) maxY = y; if (y < minY) minY = y;
}
// unbounded detection from the far-right slope of the expiration curve
// (downside is always bounded since the underlying can't go below 0)
const slopeR = (ys[Nd] - ys[Nd-1]) / (xs[Nd] - xs[Nd-1]);
const maxProfit = slopeR > 1e-2 ? 'Unlimited' : fmtMoney(maxY);
const maxLoss = slopeR < -1e-2 ? 'Unlimited' : fmtMoney(minY);
// net greeks now
let d=0,g=0,t=0,v=0;
for (const l of legs) {
const dteY = Math.max(legDTE(l), 0) / 365;
const sigma = l.iv > 0 ? l.iv : 0.0001;
const gr = (spot>0) ? BS.bsGreeks(spot, l.strike, dteY, R, sigma, l.type) : {delta:0,gamma:0,theta:0,vega:0};
const k = legSign(l) * l.qty * MULT;
d += gr.delta*k; g += gr.gamma*k; t += gr.theta*k; v += gr.vega*k;
}
return {
maxProfit, maxLoss,
breakevens: uniqBE.length ? uniqBE.map(x=>'$'+x.toFixed(2)).join(' / ') : '—',
delta:d, gamma:g, theta:t, vega:v,
};
},
// ── actions ───────────────────────────────────────────
updateLeg(id, patch) {
StrategyStore.updateLeg(id, patch);
this.reload();
},
toggleLeg(id) {
const leg = this.legs.find(l => l.id === id);
const cur = leg ? leg.enabled !== false : true;
StrategyStore.updateLeg(id, { enabled: !cur });
// refresh legs without resetting the date slider
const st = StrategyStore.load();
this.legs = st.legs || [];
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
this.$nextTick(() => this.renderChart());
},
removeLeg(id) {
StrategyStore.removeLeg(id);
this.reload();
},
clearAll() {
if (!confirm('Clear all ' + (this.symbol || '') + ' legs?')) return;
StrategyStore.clear();
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
if (this.chart) { this.chart.destroy(); this.chart = null; }
this.reload(); // re-renders the chart if another symbol's basket is now active
},
addManualLeg() {
const m = this.manual;
if (!m.strike || m.strike <= 0 || !m.expiry) { alert('Need a strike and expiry.'); return; }
StrategyStore.addLeg({
symbol: this.symbol || 'MANUAL',
expiry: m.expiry, type: m.type, strike: +m.strike,
side: m.side, qty: Math.max(1, Math.round(+m.qty||1)),
entryPrice: Math.max(0, +m.entryPrice||0),
iv: (+m.ivPct||0) / 100,
});
this.manual = { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null };
this.reload();
this.flash('Leg added');
},
// Re-fetch spot + each leg's current mark & IV.
// When repriceUnlocked (default — the Reload button), unlocked legs also
// get their entry price reset to the current mark; locked legs always keep it.
async reloadMarket(repriceUnlocked = true) {
if (!this.symbol || this.legs.length === 0) return;
this.refreshing = true;
try {
const expiries = [...new Set(this.legs.map(l => l.expiry).filter(Boolean))];
const byExpiry = {}; // expiry -> { strike@type -> option }
let spot = 0;
for (const exp of expiries) {
const r = await fetch('/api/chain?symbol=' + encodeURIComponent(this.symbol) + '&expiry=' + encodeURIComponent(exp));
if (!r.ok) continue;
const e = await r.json();
const snap = e.data?.snapshots?.[0];
if (!snap) continue;
if (snap.spot > 0) spot = snap.spot;
const map = {};
for (const o of (snap.chain || [])) {
const t = (o.type || o.optionType || '').toLowerCase();
map[Number(o.strike) + '@' + t] = o;
}
byExpiry[exp] = map;
}
// make these chains available to the strike picker
const cacheAdds = {};
for (const exp of Object.keys(byExpiry)) cacheAdds[this.symbol + '@' + exp] = byExpiry[exp];
this._chainCache = { ...this._chainCache, ...cacheAdds };
const st = StrategyStore.load();
let updated = 0, relinked = 0;
for (const leg of st.legs) {
const map = byExpiry[leg.expiry];
if (!map) continue;
const o = map[Number(leg.strike) + '@' + leg.type];
if (!o) continue;
const mark = Math.round((o.midPrice ?? o.mid ?? o.bsPrice ?? 0) * 100) / 100;
leg.currentMark = mark;
if (o.iv > 0) leg.iv = o.iv;
if (!leg.locked && repriceUnlocked && mark > 0) { leg.entryPrice = mark; relinked++; }
updated++;
}
if (spot > 0) st.spotSnapshot = spot;
StrategyStore.save(st);
this.legs = st.legs;
if (spot > 0) this.spot = spot;
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
this.$nextTick(() => this.renderChart());
if (repriceUnlocked) {
this.flash(updated > 0
? `Reloaded ${updated} leg${updated===1?'':'s'}${relinked?` (${relinked} re-priced)`:''} · spot $${(spot||this.spot).toFixed(2)}`
: 'Reloaded — no matching contracts found in the current chain');
}
} catch (e) {
if (repriceUnlocked) this.flash('Reload failed: ' + e.message);
} finally {
this.refreshing = false;
}
},
flash(msg) { this.toast = msg; setTimeout(()=>{ this.toast=''; }, 2500); },
scheduleRender() {
clearTimeout(this._renderTimer);
this._renderTimer = setTimeout(() => this.renderChart(), 40);
},
zoomIn() { this.xZoom = Math.max(0.2, +(this.xZoom / 1.4).toFixed(3)); this.renderChart(); },
zoomOut() { this.xZoom = Math.min(8, +(this.xZoom * 1.4).toFixed(3)); this.renderChart(); },
zoomFit() { this.xZoom = 1; this.xPan = 0; this.renderChart(); },
// click-and-drag to pan the price window
startPan(e) {
if (e.button !== 0) return;
const pw = (this._lastPlotW && this._lastPlotW > 50) ? this._lastPlotW
: ((document.getElementById('plChart')?.clientWidth || 600) - 70);
this._drag = { x: e.clientX, pan: this.xPan || 0, half: this._lastHalf || ((this.spot || 100) * 0.22), pw };
e.preventDefault();
},
onPan(e) {
if (!this._drag) return;
const dx = e.clientX - this._drag.x; // drag right -> show lower prices
this.xPan = this._drag.pan - (dx / this._drag.pw) * (2 * this._drag.half);
this.scheduleRender();
},
endPan() { if (this._drag) this._drag = null; },
renderChart() {
const legs = this.activeLegs, net = this.netCost;
if (legs.length === 0) {
if (this.chart) this.chart.updateSeries([{ name: 'P/L', data: [] }, { name: 'P/L', data: [] }]);
return;
}
const spot = this.spot > 0 ? this.spot : (legs.reduce((s,l)=>s+l.strike,0)/legs.length);
const strikes = legs.map(l=>l.strike);
const minK = Math.min(...strikes), maxK = Math.max(...strikes);
const span = maxK - minK;
const baseHalf = Math.max(spot*0.22, span*1.5, (maxK-spot)*1.4, (spot-minK)*1.4, 5);
const half = baseHalf * (this.xZoom || 1);
const center = spot + (this.xPan || 0);
const lo = Math.max(0.01, center - half), hi = center + half;
this.lastHalfPct = spot > 0 ? Math.round(half / spot * 100) : 0;
this._lastHalf = half; this._lastSpot = spot;
const expAt = this.minDTE;
const N = 221;
// x-grid = evenly spaced points + the exact strike kinks (so payoff
// vertices — butterfly peak, condor body, etc. — render sharply)
const xGrid = [];
for (let i = 0; i <= N; i++) xGrid.push(lo + (hi-lo)*i/N);
for (const k of strikes) if (k > lo && k < hi) xGrid.push(k);
xGrid.sort((a,b)=>a-b);
const expData = [], tnData = [];
let yMin = Infinity, yMax = -Infinity;
for (const x of xGrid) {
const ye = plAt(legs, net, x, expAt);
const yt = plAt(legs, net, x, this.dteOffset);
expData.push([x, +ye.toFixed(2)]);
tnData.push([x, +yt.toFixed(2)]);
yMin = Math.min(yMin, ye, yt); yMax = Math.max(yMax, ye, yt);
}
// Keep the chart readable when one tail is (near-)unbounded: don't let it
// dominate the y-axis so badly the rest of the curve is a flat sliver.
const aHi = Math.max(yMax, 0), aLo = Math.max(-yMin, 0);
if (aLo > 1 && aHi > 6 * aLo) yMax = 6 * aLo;
if (aHi > 1 && aLo > 6 * aHi) yMin = -6 * aHi;
const pad = Math.max((yMax - yMin) * 0.08, 1);
yMin -= pad; yMax += pad;
// breakevens for light vertical lines
const bes = [];
for (let i = 1; i < expData.length; i++) {
const y0 = expData[i-1][1], y1 = expData[i][1];
if ((y0 < 0 && y1 > 0) || (y0 > 0 && y1 < 0)) {
const x = expData[i-1][0] + (0 - y0) * (expData[i][0]-expData[i-1][0]) / (y1 - y0);
bes.push(x);
}
}
const labelPad = { left: 6, right: 6, top: 3, bottom: 3 };
const xAnnos = [{
x: spot, borderColor: '#ffd43b', strokeDashArray: 4,
label: { text: 'Spot $'+spot.toFixed(2), position:'top', orientation:'horizontal', offsetY: -2,
borderColor:'#ffd43b', borderWidth:1,
style:{ color:'#1b1d27', background:'#ffd43b', fontSize:'11px', fontWeight:700, padding: labelPad } }
}].concat(bes.map(x => ({ x, borderColor:'#5aa9ff', strokeDashArray:3,
label:{ text:'BE $'+x.toFixed(2), position:'bottom', orientation:'horizontal', offsetY: 2,
borderColor:'#2f6fd0', borderWidth:1,
style:{ color:'#ffffff', background:'#2f6fd0', fontSize:'10px', fontWeight:700, padding: labelPad } } })));
const opts = {
chart: { type:'line', height:380, background:CHART_BG, foreColor:CHART_LABEL, fontFamily:'inherit', toolbar:{show:false}, animations:{enabled:false}, zoom:{enabled:false} },
series: [
{ name: this.dteOffset===0 ? 'P/L Today' : 'P/L '+this.dteLabel, type:'line', data: tnData, color: COLOR_TN },
{ name: 'P/L at Expiration', type:'line', data: expData, color: COLOR_EXP },
],
stroke: { width:[2,3], curve:['smooth','straight'], dashArray:[5,0] },
markers: { size:0 },
grid: { borderColor:CHART_GRID, strokeDashArray:3 },
dataLabels: { enabled:false },
legend: { labels:{ colors:'#d0d5e0' }, position:'top', horizontalAlign:'right' },
xaxis: {
type:'numeric', tickAmount:10,
labels:{ style:{colors:CHART_LABEL}, formatter:v=>'$'+Number(v).toFixed(0) },
title:{ text:'Underlying price', style:{color:CHART_LABEL} },
axisBorder:{color:CHART_GRID}, axisTicks:{color:CHART_GRID},
},
yaxis: {
min:yMin, max:yMax,
labels:{ style:{colors:CHART_LABEL}, formatter:v=>fmtMoney(v) },
title:{ text:'Profit / Loss ($)', style:{color:CHART_LABEL} },
},
tooltip: {
theme:'dark', shared:true, intersect:false,
x:{ formatter:v=>'Underlying $'+Number(v).toFixed(2) },
y:{ formatter:v=>fmtMoney(v) },
},
annotations: {
yaxis: [
{ y:0, y2:yMax, fillColor:'#2fb344', opacity:0.05, borderColor:'transparent' },
{ y:yMin, y2:0, fillColor:'#d63939', opacity:0.05, borderColor:'transparent' },
{ y:0, borderColor:'#aeb6c4', strokeDashArray:0,
label:{ text:'P/L = 0', position:'left', borderColor:'#3a3f5a', borderWidth:1,
style:{ color:'#e8ebf2', background:'#2a2e42', fontSize:'10px', fontWeight:600, padding:labelPad } } },
],
xaxis: xAnnos,
},
};
if (this.chart) {
this.chart.updateOptions(opts, true, false);
} else {
this.chart = new ApexCharts(document.getElementById('plChart'), opts);
this.chart.render();
}
// remember the plot-area width for drag-to-pan math
try {
const gw = this.chart && this.chart.w && this.chart.w.globals && this.chart.w.globals.gridWidth;
this._lastPlotW = (gw && gw > 50) ? gw : ((document.getElementById('plChart')?.clientWidth || 600) - 70);
} catch {}
},
fmtMoney,
};
}
</script>
</body>
</html>