Add strategy P/L analyzer + view-state persistence

- 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>
This commit is contained in:
ojy
2026-05-13 04:01:57 +00:00
parent d08c2230a8
commit 3109df842d
8 changed files with 950 additions and 4 deletions

View File

@@ -117,6 +117,18 @@
</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">
@@ -214,6 +226,7 @@
id="select-type"
class="form-select"
x-model="optionType"
@change="_persist()"
aria-label="Option type filter"
>
<option value="all">All</option>
@@ -241,8 +254,15 @@
</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 ms-auto" x-show="spot > 0">
<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>
@@ -296,6 +316,7 @@
>
<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>
@@ -313,6 +334,10 @@
<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>
@@ -365,6 +390,7 @@
>
<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>
@@ -382,6 +408,10 @@
<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>
@@ -420,9 +450,15 @@
</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>
@@ -439,12 +475,64 @@
loading: false,
lookingUp: false,
error: '',
basketCount: 0,
toast: '',
// ── lifecycle ──────────────────────────────────────────
async init() {
// no auto-load — user must click Lookup first
// 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;
@@ -462,6 +550,7 @@
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 {
@@ -496,6 +585,8 @@
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.');
}