From 3109df842d90b452524a8ba3f28546363d4485a9 Mon Sep 17 00:00:00 2001 From: ojy Date: Wed, 13 May 2026 04:01:57 +0000 Subject: [PATCH] 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 --- frontend/assets/blackscholes.js | 89 +++++ frontend/assets/strategy-store.js | 129 +++++++ frontend/assets/viewstate-store.js | 28 ++ frontend/chain.html | 95 +++++- frontend/index.html | 23 ++ frontend/strategy.html | 526 +++++++++++++++++++++++++++++ frontend/surface.html | 33 +- frontend/tracker.html | 31 +- 8 files changed, 950 insertions(+), 4 deletions(-) create mode 100644 frontend/assets/blackscholes.js create mode 100644 frontend/assets/strategy-store.js create mode 100644 frontend/assets/viewstate-store.js create mode 100644 frontend/strategy.html diff --git a/frontend/assets/blackscholes.js b/frontend/assets/blackscholes.js new file mode 100644 index 0000000..d946ba4 --- /dev/null +++ b/frontend/assets/blackscholes.js @@ -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 }; +})(); diff --git a/frontend/assets/strategy-store.js b/frontend/assets/strategy-store.js new file mode 100644 index 0000000..22f9075 --- /dev/null +++ b/frontend/assets/strategy-store.js @@ -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, + }; +})(); diff --git a/frontend/assets/viewstate-store.js b/frontend/assets/viewstate-store.js new file mode 100644 index 0000000..ba0389a --- /dev/null +++ b/frontend/assets/viewstate-store.js @@ -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:"] 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 {} + }, + }; +})(); diff --git a/frontend/chain.html b/frontend/chain.html index 95f9609..dd76fe9 100644 --- a/frontend/chain.html +++ b/frontend/chain.html @@ -117,6 +117,18 @@ + +