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 {}
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user