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:
89
frontend/assets/blackscholes.js
Normal file
89
frontend/assets/blackscholes.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Black-Scholes pricing & Greeks — vanilla JS port of backend/src/lib/blackscholes.ts.
|
||||
* Exposed as window.BS. No modules, no deps.
|
||||
*
|
||||
* Conventions (match backend):
|
||||
* - theta returned PER CALENDAR DAY (annual / 365)
|
||||
* - vega returned PER 1% VOL MOVE (raw / 100)
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function normalPDF(x) {
|
||||
return Math.exp(-0.5 * x * x) / Math.sqrt(2.0 * Math.PI);
|
||||
}
|
||||
|
||||
// Abramowitz & Stegun 26.2.17, max error ~7.5e-8
|
||||
function normalCDF(x) {
|
||||
const sign = x >= 0 ? 1 : -1;
|
||||
const absX = Math.abs(x);
|
||||
const a1 = 0.319381530, a2 = -0.356563782, a3 = 1.781477937,
|
||||
a4 = -1.821255978, a5 = 1.330274429, p = 0.2316419;
|
||||
const t = 1.0 / (1.0 + p * absX);
|
||||
const poly = t * (a1 + t * (a2 + t * (a3 + t * (a4 + t * a5))));
|
||||
const approx = 1.0 - normalPDF(absX) * poly;
|
||||
return sign === 1 ? approx : 1.0 - approx;
|
||||
}
|
||||
|
||||
function d1d2(S, K, T, r, sigma) {
|
||||
const sqrtT = Math.sqrt(T);
|
||||
const d1 = (Math.log(S / K) + (r + 0.5 * sigma * sigma) * T) / (sigma * sqrtT);
|
||||
return { d1, d2: d1 - sigma * sqrtT, sqrtT };
|
||||
}
|
||||
|
||||
/** Intrinsic payoff per share at expiry. */
|
||||
function intrinsic(S, K, type) {
|
||||
return type === "call" ? Math.max(S - K, 0) : Math.max(K - S, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Black-Scholes theoretical price (per share).
|
||||
* If T <= 0 (or sigma <= 0), returns intrinsic value.
|
||||
*/
|
||||
function bsPrice(S, K, T, r, sigma, type) {
|
||||
if (T <= 0 || sigma <= 0) return intrinsic(S, K, type);
|
||||
const { d1, d2 } = d1d2(S, K, T, r, sigma);
|
||||
const disc = Math.exp(-r * T);
|
||||
return type === "call"
|
||||
? S * normalCDF(d1) - K * disc * normalCDF(d2)
|
||||
: K * disc * normalCDF(-d2) - S * normalCDF(-d1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Black-Scholes Greeks (per share). theta per day, vega per 1% vol.
|
||||
* If T <= 0, returns degenerate Greeks (delta ±1/0, rest 0).
|
||||
*/
|
||||
function bsGreeks(S, K, T, r, sigma, type) {
|
||||
if (T <= 0 || sigma <= 0) {
|
||||
const itm = intrinsic(S, K, type) > 0;
|
||||
return {
|
||||
delta: type === "call" ? (itm ? 1 : 0) : (itm ? -1 : 0),
|
||||
gamma: 0, theta: 0, vega: 0, rho: 0,
|
||||
};
|
||||
}
|
||||
const { d1, d2, sqrtT } = d1d2(S, K, T, r, sigma);
|
||||
const nd1 = normalPDF(d1);
|
||||
const disc = Math.exp(-r * T);
|
||||
|
||||
const delta = type === "call" ? normalCDF(d1) : normalCDF(d1) - 1;
|
||||
const gamma = nd1 / (S * sigma * sqrtT);
|
||||
|
||||
let thetaAnnual;
|
||||
if (type === "call") {
|
||||
thetaAnnual = -(S * nd1 * sigma) / (2 * sqrtT) - r * K * disc * normalCDF(d2);
|
||||
} else {
|
||||
thetaAnnual = -(S * nd1 * sigma) / (2 * sqrtT) + r * K * disc * normalCDF(-d2);
|
||||
}
|
||||
const theta = thetaAnnual / 365;
|
||||
const vega = (S * nd1 * sqrtT) / 100;
|
||||
|
||||
let rhoRaw;
|
||||
if (type === "call") rhoRaw = K * T * disc * normalCDF(d2);
|
||||
else rhoRaw = -K * T * disc * normalCDF(-d2);
|
||||
const rho = rhoRaw / 100;
|
||||
|
||||
return { delta, gamma, theta, vega, rho };
|
||||
}
|
||||
|
||||
window.BS = { normalCDF, normalPDF, bsPrice, bsGreeks, intrinsic };
|
||||
})();
|
||||
129
frontend/assets/strategy-store.js
Normal file
129
frontend/assets/strategy-store.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Strategy basket persistence — localStorage-backed.
|
||||
* Exposed as window.StrategyStore. No modules, no deps.
|
||||
*
|
||||
* Stored shape (key "optionsPricer:strategy"):
|
||||
* {
|
||||
* v: 1,
|
||||
* symbol: "SPY",
|
||||
* spotSnapshot: 738.18, // spot when last leg was added (reference only)
|
||||
* updatedAt: "2026-05-13T...Z",
|
||||
* legs: [
|
||||
* { id, symbol, expiry:"YYYY-MM-DD", type:"call"|"put",
|
||||
* strike, side:"long"|"short", qty, entryPrice, iv }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const KEY = "optionsPricer:strategy";
|
||||
const VERSION = 1;
|
||||
const MULTIPLIER = 100; // standard equity option contract size
|
||||
|
||||
function empty() {
|
||||
return { v: VERSION, symbol: "", spotSnapshot: 0, updatedAt: null, legs: [] };
|
||||
}
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return empty();
|
||||
const obj = JSON.parse(raw);
|
||||
if (!obj || obj.v !== VERSION || !Array.isArray(obj.legs)) return empty();
|
||||
// sanitize: drop legs with no usable strike / type
|
||||
obj.legs = obj.legs.filter(
|
||||
(l) => l && Number.isFinite(Number(l.strike)) && (l.type === "call" || l.type === "put")
|
||||
);
|
||||
return obj;
|
||||
} catch {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
|
||||
function save(state) {
|
||||
const s = state || empty();
|
||||
s.v = VERSION;
|
||||
s.updatedAt = new Date().toISOString();
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(s));
|
||||
} catch (e) {
|
||||
console.warn("[StrategyStore] save failed:", e);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { localStorage.removeItem(KEY); } catch {}
|
||||
return empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a leg. `leg` should provide: symbol, expiry, type, strike, side, qty, entryPrice, iv.
|
||||
* Returns { state, replacedSymbol } — replacedSymbol is the old symbol if the basket
|
||||
* was wiped due to a symbol mismatch (caller should have confirmed first via mismatch()).
|
||||
*/
|
||||
function addLeg(leg) {
|
||||
const strike = Number(leg && leg.strike);
|
||||
if (!Number.isFinite(strike) || strike <= 0) {
|
||||
console.warn("[StrategyStore] addLeg: invalid strike", leg);
|
||||
return { state: load(), replacedSymbol: null };
|
||||
}
|
||||
let state = load();
|
||||
let replacedSymbol = null;
|
||||
if (state.legs.length > 0 && state.symbol && leg.symbol && state.symbol !== leg.symbol) {
|
||||
replacedSymbol = state.symbol;
|
||||
state = empty();
|
||||
}
|
||||
state.symbol = leg.symbol || state.symbol || "";
|
||||
if (typeof leg.spotSnapshot === "number" && leg.spotSnapshot > 0) {
|
||||
state.spotSnapshot = leg.spotSnapshot;
|
||||
}
|
||||
state.legs.push({
|
||||
id: ((typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : "id-" + Date.now() + "-" + Math.random().toString(36).slice(2)),
|
||||
symbol: leg.symbol || state.symbol,
|
||||
expiry: leg.expiry,
|
||||
type: leg.type,
|
||||
strike: strike,
|
||||
side: leg.side === "short" ? "short" : "long",
|
||||
qty: Math.max(1, Math.round(Number(leg.qty) || 1)),
|
||||
entryPrice: Number(leg.entryPrice) || 0,
|
||||
iv: Number(leg.iv) || 0,
|
||||
});
|
||||
save(state);
|
||||
return { state, replacedSymbol };
|
||||
}
|
||||
|
||||
function removeLeg(id) {
|
||||
const state = load();
|
||||
state.legs = state.legs.filter((l) => l.id !== id);
|
||||
if (state.legs.length === 0) { return clear(); }
|
||||
save(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function updateLeg(id, patch) {
|
||||
const state = load();
|
||||
const leg = state.legs.find((l) => l.id === id);
|
||||
if (leg) Object.assign(leg, patch);
|
||||
save(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** True if adding a leg of `symbol` would wipe an existing different-symbol basket. */
|
||||
function mismatch(symbol) {
|
||||
const state = load();
|
||||
return state.legs.length > 0 && state.symbol && symbol && state.symbol !== symbol
|
||||
? state.symbol
|
||||
: null;
|
||||
}
|
||||
|
||||
function count() {
|
||||
return load().legs.length;
|
||||
}
|
||||
|
||||
window.StrategyStore = {
|
||||
KEY, VERSION, MULTIPLIER,
|
||||
empty, load, save, clear, addLeg, removeLeg, updateLeg, mismatch, count,
|
||||
};
|
||||
})();
|
||||
28
frontend/assets/viewstate-store.js
Normal file
28
frontend/assets/viewstate-store.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Per-page view-state persistence — survives navigation within the app.
|
||||
* Exposed as window.ViewState. No modules, no deps.
|
||||
*
|
||||
* Each page stores its last-loaded inputs + data under
|
||||
* localStorage["optionsPricer:view:<page>"] so navigating away and back
|
||||
* restores the page exactly as it was.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
const PREFIX = "optionsPricer:view:";
|
||||
window.ViewState = {
|
||||
PREFIX,
|
||||
load(page) {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + page);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
},
|
||||
save(page, data) {
|
||||
try { localStorage.setItem(PREFIX + page, JSON.stringify(data)); }
|
||||
catch (e) { console.warn("[ViewState] save failed:", e); }
|
||||
},
|
||||
clear(page) {
|
||||
try { localStorage.removeItem(PREFIX + page); } catch {}
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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/viewstate-store.js"></script>
|
||||
<script src="/assets/tabler.min.js" defer></script>
|
||||
<script src="/assets/alpine.min.js" defer></script>
|
||||
|
||||
@@ -200,6 +201,18 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Strategy P/L -->
|
||||
<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="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<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>
|
||||
|
||||
<!-- Tracker -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="tracker.html">
|
||||
@@ -741,7 +754,16 @@
|
||||
|
||||
// ---- lifecycle ----
|
||||
async init() {
|
||||
// restore last symbol/expiry so navigating back keeps your selection
|
||||
const vs = ViewState.load('dashboard');
|
||||
if (vs) {
|
||||
this.symbol = vs.symbol ?? this.symbol;
|
||||
this.expiry = vs.expiry ?? '';
|
||||
}
|
||||
await this.fetchExpirations();
|
||||
if (this.expiry && !this.expirations.includes(this.expiry)) {
|
||||
this.expiry = this.expirations[0] || '';
|
||||
}
|
||||
if (this.expiry) await this.loadData();
|
||||
},
|
||||
|
||||
@@ -783,6 +805,7 @@
|
||||
this.snapshots = snapshotEnv.data?.snapshots ?? snapshotEnv.snapshots ?? [];
|
||||
|
||||
this.lastUpdated = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
ViewState.save('dashboard', { symbol: this.symbol, expiry: this.expiry });
|
||||
|
||||
// Wait a tick for DOM, then render charts
|
||||
await this.$nextTick();
|
||||
|
||||
526
frontend/strategy.html
Normal file
526
frontend/strategy.html
Normal file
@@ -0,0 +1,526 @@
|
||||
<!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">
|
||||
<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-text="symbol"></span> ·
|
||||
<span x-text="legs.length + ' leg' + (legs.length===1?'':'s')"></span> ·
|
||||
Spot <strong class="mono" x-text="spot > 0 ? '$'+spot.toFixed(2) : '—'"></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto" x-show="legs.length > 0" x-cloak>
|
||||
<button class="btn btn-outline-secondary btn-sm me-1" @click="refreshSpot()" :disabled="refreshing">
|
||||
<span x-show="refreshing" class="spinner-border spinner-border-sm me-1" role="status"></span>Refresh spot
|
||||
</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>
|
||||
|
||||
<!-- Legs table -->
|
||||
<div class="card mb-3" 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</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>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">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>
|
||||
<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" x-text="lv.strike"></td>
|
||||
<td class="mono small" x-text="lv.expiry"></td>
|
||||
<td style="width:6.5rem"><input type="number" min="0" step="0.01" class="form-control form-control-sm text-end" :value="lv.entryPrice" @change="updateLeg(lv.id, { entryPrice: Math.max(0, +$event.target.value||0) })"></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><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><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>
|
||||
|
||||
<!-- 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-2">
|
||||
<h3 class="card-title text-white mb-0">Profit / Loss vs. Underlying Price</h3>
|
||||
<div class="d-flex align-items-center gap-2" style="min-width:320px;">
|
||||
<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:160px;">
|
||||
<span class="text-secondary small text-nowrap">Exp</span>
|
||||
<span class="badge bg-blue-lt text-nowrap" x-text="dteLabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="plChart" style="min-height:380px;" role="img" aria-label="Profit and loss diagram"></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>
|
||||
|
||||
</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: '', spot: 0, legs: [],
|
||||
dteOffset: 0,
|
||||
refreshing: false, showManual: false, toast: '',
|
||||
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
|
||||
chart: null, _renderTimer: null,
|
||||
|
||||
init() {
|
||||
this.reload();
|
||||
// re-sync if another tab changed the basket
|
||||
window.addEventListener('storage', (e) => { if (e.key === StrategyStore.KEY) this.reload(); });
|
||||
},
|
||||
|
||||
reload() {
|
||||
const st = StrategyStore.load();
|
||||
this.symbol = st.symbol || '';
|
||||
this.spot = st.spotSnapshot || 0;
|
||||
this.legs = st.legs || [];
|
||||
this.dteOffset = 0;
|
||||
if (this.legs.length > 0) this.$nextTick(() => this.renderChart());
|
||||
},
|
||||
|
||||
// ── derived ───────────────────────────────────────────
|
||||
get netCost() { return this.legs.reduce((s,l)=> s + legCost(l), 0); },
|
||||
get strategyName() { return detectStrategy(this.legs); },
|
||||
get maxDTE() { return Math.max(1, Math.ceil(Math.max(0, ...this.legs.map(legDTE)))); },
|
||||
get minDTE() { return Math.max(0, Math.floor(Math.min(...this.legs.map(legDTE)))); },
|
||||
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.legs, net = this.netCost, spot = this.spot;
|
||||
// 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 within sample
|
||||
let maxY = -Infinity, minY = Infinity;
|
||||
for (const y of ys) { 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();
|
||||
},
|
||||
removeLeg(id) {
|
||||
StrategyStore.removeLeg(id);
|
||||
this.reload();
|
||||
},
|
||||
clearAll() {
|
||||
if (!confirm('Clear all legs?')) return;
|
||||
StrategyStore.clear();
|
||||
this.reload();
|
||||
if (this.chart) { this.chart.destroy(); this.chart = null; }
|
||||
},
|
||||
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');
|
||||
},
|
||||
async refreshSpot() {
|
||||
if (!this.symbol) return;
|
||||
this.refreshing = true;
|
||||
try {
|
||||
// grab the nearest expiry to read current spot
|
||||
const er = await fetch('/api/expirations?symbol=' + encodeURIComponent(this.symbol));
|
||||
const ee = await er.json(); const ed = ee.data ?? ee;
|
||||
const exps = ed.expirations ?? (Array.isArray(ed) ? ed : []);
|
||||
const exp = exps[0];
|
||||
if (!exp) throw new Error('no expirations');
|
||||
const cr = await fetch('/api/chain?symbol=' + encodeURIComponent(this.symbol) + '&expiry=' + encodeURIComponent(exp));
|
||||
const ce = await cr.json(); const snap = ce.data?.snapshots?.[0] ?? {};
|
||||
if (snap.spot > 0) {
|
||||
this.spot = snap.spot;
|
||||
const st = StrategyStore.load(); st.spotSnapshot = snap.spot; StrategyStore.save(st);
|
||||
this.renderChart();
|
||||
this.flash('Spot updated: $' + snap.spot.toFixed(2));
|
||||
}
|
||||
} catch (e) {
|
||||
this.flash('Refresh 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);
|
||||
},
|
||||
|
||||
renderChart() {
|
||||
if (this.legs.length === 0) return;
|
||||
const legs = this.legs, net = this.netCost;
|
||||
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 half = Math.max(spot*0.30, span*1.4, (maxK-spot)*1.3, (spot-minK)*1.3, 5);
|
||||
const lo = Math.max(0.01, spot - half), hi = spot + half;
|
||||
const N = 141;
|
||||
const expData = [], tnData = [];
|
||||
let yMin = Infinity, yMax = -Infinity;
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const x = lo + (hi-lo)*i/N;
|
||||
const ye = plAt(legs, net, x, this.minDTE);
|
||||
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);
|
||||
}
|
||||
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 <= N; 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 xAnnos = [{
|
||||
x: spot, borderColor: '#ffd43b', strokeDashArray: 4,
|
||||
label: { text: 'Spot $'+spot.toFixed(2), borderColor:'#ffd43b', style:{ color:'#1a1c2e', background:'#ffd43b', fontSize:'11px' }, position:'top' }
|
||||
}].concat(bes.map(x => ({ x, borderColor:'#ffd43b66', strokeDashArray:2,
|
||||
label:{ text:'BE $'+x.toFixed(2), borderColor:'transparent', style:{ color:'#ffd43b', background:'#1a1c2e', fontSize:'10px' }, position:'bottom' } })));
|
||||
|
||||
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:'#9aa4b2', strokeDashArray:0, label:{ text:'Break-even', borderColor:'transparent', style:{color:'#9aa4b2', background:'transparent', fontSize:'10px'}, position:'left' } },
|
||||
],
|
||||
xaxis: xAnnos,
|
||||
},
|
||||
};
|
||||
|
||||
if (this.chart) {
|
||||
this.chart.updateOptions(opts, true, false);
|
||||
} else {
|
||||
this.chart = new ApexCharts(document.getElementById('plChart'), opts);
|
||||
this.chart.render();
|
||||
}
|
||||
},
|
||||
|
||||
fmtMoney,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -197,6 +197,17 @@
|
||||
</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="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19l4 -6l4 2l4 -8l4 5"></path><path d="M4 4v16h16"></path>
|
||||
</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">
|
||||
@@ -557,6 +568,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/viewstate-store.js"></script>
|
||||
<script>
|
||||
const CHART_BG = '#1e2030';
|
||||
const CHART_GRID = '#2d3045';
|
||||
@@ -604,7 +616,24 @@
|
||||
termChartInstance: null,
|
||||
|
||||
async init() {
|
||||
// no auto-load — user must click Lookup first
|
||||
// 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() {
|
||||
@@ -622,6 +651,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(false);
|
||||
} catch (err) {
|
||||
this.errorMsg = 'Failed to look up symbol: ' + err.message;
|
||||
} finally {
|
||||
@@ -644,6 +674,7 @@
|
||||
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 {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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/viewstate-store.js"></script>
|
||||
<script src="/assets/alpine.min.js" defer></script>
|
||||
<script src="/assets/tabler.min.js" defer></script>
|
||||
|
||||
@@ -156,6 +157,17 @@
|
||||
</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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19l4 -6l4 2l4 -8l4 5"></path><path d="M4 4v16h16"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-link-title">Strategy P/L</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active-page" href="tracker.html">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
@@ -603,7 +615,22 @@
|
||||
_charts: { atmIv: null, rr25: null, fly25: null },
|
||||
|
||||
async init() {
|
||||
// expirations are built from snapshot data after loadHistory()
|
||||
// restore last loaded history (survives navigating away & back)
|
||||
const vs = ViewState.load('tracker');
|
||||
if (vs) {
|
||||
this.symbol = vs.symbol ?? this.symbol;
|
||||
this.expirations = vs.expirations ?? [];
|
||||
this.expiry = vs.expiry ?? '';
|
||||
this.snapshots = vs.snapshots ?? [];
|
||||
if (this.snapshots.length) this.$nextTick(() => this.renderCharts());
|
||||
}
|
||||
},
|
||||
|
||||
_persist() {
|
||||
ViewState.save('tracker', {
|
||||
symbol: this.symbol, expirations: this.expirations,
|
||||
expiry: this.expiry, snapshots: this.snapshots,
|
||||
});
|
||||
},
|
||||
|
||||
async fetchExpirations() {
|
||||
@@ -615,6 +642,7 @@
|
||||
const env = await res.json();
|
||||
const json = env.data ?? env;
|
||||
this.expirations = Array.isArray(json) ? json : (json.expirations || []);
|
||||
this._persist();
|
||||
} catch (e) {
|
||||
this.expirations = [];
|
||||
}
|
||||
@@ -640,6 +668,7 @@
|
||||
const expSet = new Set(this.snapshots.map(s => s.expiry).filter(Boolean));
|
||||
this.expirations = [...expSet].sort();
|
||||
}
|
||||
this._persist();
|
||||
this.$nextTick(() => this.renderCharts());
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
|
||||
Reference in New Issue
Block a user