Multi-symbol strategy baskets + symbol picker

The store now keeps one basket per symbol (v2 schema, auto-migrates v1).
Adding a leg from a different symbol no longer wipes the basket — it creates
that symbol's basket and makes it active. The Strategy page shows a symbol
picker at the top (when >1 symbol is saved) to switch between them; switching
auto-fetches that symbol's live marks. "Clear all" clears only the current
symbol's legs; new StrategyStore.clearAll() wipes everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 04:47:16 +00:00
parent e0cbd798b6
commit 703c305cf1
2 changed files with 195 additions and 85 deletions

View File

@@ -1,24 +1,28 @@
/** /**
* Strategy basket persistence — localStorage-backed. * Strategy basket persistence — localStorage-backed, multi-symbol.
* Exposed as window.StrategyStore. No modules, no deps. * Exposed as window.StrategyStore. No modules, no deps.
* *
* Stored shape (key "optionsPricer:strategy"): * Stored shape (key "optionsPricer:strategy", v2):
* { * {
* v: 1, * v: 2,
* symbol: "SPY", * active: "SPY", // currently-shown symbol
* spotSnapshot: 738.18, // spot when last leg was added (reference only)
* updatedAt: "2026-05-13T...Z", * updatedAt: "2026-05-13T...Z",
* legs: [ * baskets: {
* { id, symbol, expiry:"YYYY-MM-DD", type:"call"|"put", * "SPY": { spotSnapshot: 738.18, legs: [ ...legs ] },
* strike, side:"long"|"short", qty, entryPrice, iv } * "AAPL": { spotSnapshot: 0, legs: [ ...legs ] }
* ]
* } * }
* }
* leg = { id, symbol, expiry:"YYYY-MM-DD", type:"call"|"put", strike, side:"long"|"short",
* qty, entryPrice, iv, locked:bool, currentMark:number|null }
*
* `load()` returns the *active* basket in a flat back-compat shape, plus a
* `symbols` array of all symbols that currently have legs.
*/ */
(function () { (function () {
"use strict"; "use strict";
const KEY = "optionsPricer:strategy"; const KEY = "optionsPricer:strategy";
const VERSION = 1; const VERSION = 2;
const MULTIPLIER = 100; // standard equity option contract size const MULTIPLIER = 100; // standard equity option contract size
/** Round a price to 2 decimals (kills float artifacts like 15.000000001). */ /** Round a price to 2 decimals (kills float artifacts like 15.000000001). */
@@ -27,72 +31,145 @@
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0; return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
} }
function empty() { function emptyDoc() { return { v: VERSION, active: "", updatedAt: null, baskets: {} }; }
return { v: VERSION, symbol: "", spotSnapshot: 0, updatedAt: null, legs: [] };
}
function load() { function sanitizeLegs(legs) {
try { if (!Array.isArray(legs)) return [];
const raw = localStorage.getItem(KEY); return legs
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; round prices to 2dp (self-heal)
obj.legs = obj.legs
.filter((l) => l && Number.isFinite(Number(l.strike)) && (l.type === "call" || l.type === "put")) .filter((l) => l && Number.isFinite(Number(l.strike)) && (l.type === "call" || l.type === "put"))
.map((l) => ({ .map((l) => ({
...l, ...l,
strike: round2(l.strike), strike: round2(l.strike),
entryPrice: round2(l.entryPrice), entryPrice: round2(l.entryPrice),
currentMark: (typeof l.currentMark === "number" ? round2(l.currentMark) : (l.currentMark ?? null)), currentMark: typeof l.currentMark === "number" ? round2(l.currentMark) : (l.currentMark ?? null),
locked: l.locked === true,
})); }));
return obj;
} catch {
return empty();
}
} }
function save(state) { function loadDoc() {
const s = state || empty();
s.v = VERSION;
s.updatedAt = new Date().toISOString();
try { try {
localStorage.setItem(KEY, JSON.stringify(s)); const raw = localStorage.getItem(KEY);
} catch (e) { if (!raw) return emptyDoc();
console.warn("[StrategyStore] save failed:", e); const obj = JSON.parse(raw);
if (!obj || typeof obj !== "object") return emptyDoc();
// migrate v1 (single basket) -> v2
if (obj.v === 1 && Array.isArray(obj.legs)) {
const doc = emptyDoc();
const sym = obj.symbol || "";
const legs = sanitizeLegs(obj.legs);
if (sym && legs.length) {
doc.baskets[sym] = { spotSnapshot: Number(obj.spotSnapshot) || 0, legs };
doc.active = sym;
} }
return s; return doc;
} }
if (obj.v !== VERSION || !obj.baskets || typeof obj.baskets !== "object") return emptyDoc();
const doc = emptyDoc();
for (const sym of Object.keys(obj.baskets)) {
const b = obj.baskets[sym] || {};
const legs = sanitizeLegs(b.legs);
if (legs.length) doc.baskets[sym] = { spotSnapshot: Number(b.spotSnapshot) || 0, legs };
}
doc.active = obj.active && doc.baskets[obj.active] ? obj.active : (Object.keys(doc.baskets)[0] || "");
doc.updatedAt = obj.updatedAt || null;
return doc;
} catch {
return emptyDoc();
}
}
function saveDoc(doc) {
doc.v = VERSION;
// drop empty baskets
for (const sym of Object.keys(doc.baskets)) {
const b = doc.baskets[sym];
if (!b || !Array.isArray(b.legs) || b.legs.length === 0) delete doc.baskets[sym];
}
if (!doc.baskets[doc.active]) doc.active = Object.keys(doc.baskets)[0] || "";
doc.updatedAt = new Date().toISOString();
try { localStorage.setItem(KEY, JSON.stringify(doc)); }
catch (e) { console.warn("[StrategyStore] save failed:", e); }
}
// ---- active-basket view (flat, back-compat) -----------------------------
function load() {
const doc = loadDoc();
const sym = doc.active;
const b = (sym && doc.baskets[sym]) || { spotSnapshot: 0, legs: [] };
return {
v: VERSION,
symbol: sym || "",
spotSnapshot: b.spotSnapshot || 0,
updatedAt: doc.updatedAt,
legs: (b.legs || []).map((l) => ({ ...l })),
symbols: Object.keys(doc.baskets),
};
}
/** Write a flat {symbol, spotSnapshot, legs} state back into that symbol's basket. */
function save(state) {
const doc = loadDoc();
const sym = state && state.symbol;
if (sym) {
const legs = sanitizeLegs(state.legs);
if (legs.length) {
doc.baskets[sym] = { spotSnapshot: Number(state.spotSnapshot) || 0, legs };
doc.active = sym;
} else {
delete doc.baskets[sym];
if (doc.active === sym) doc.active = Object.keys(doc.baskets)[0] || "";
}
}
saveDoc(doc);
return load();
}
/** Clear the ACTIVE basket only. */
function clear() { function clear() {
try { localStorage.removeItem(KEY); } catch {} const doc = loadDoc();
return empty(); if (doc.active) {
delete doc.baskets[doc.active];
doc.active = Object.keys(doc.baskets)[0] || "";
}
saveDoc(doc);
return load();
}
/** Wipe every basket. */
function clearAll() {
try { localStorage.removeItem(KEY); } catch {}
return load();
}
function listSymbols() { return Object.keys(loadDoc().baskets); }
function setActive(symbol) {
const doc = loadDoc();
if (!symbol) return load();
if (!doc.baskets[symbol]) doc.baskets[symbol] = { spotSnapshot: 0, legs: [] };
doc.active = symbol;
saveDoc(doc);
return load();
} }
/**
* 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) { function addLeg(leg) {
const strike = Number(leg && leg.strike); const strike = Number(leg && leg.strike);
if (!Number.isFinite(strike) || strike <= 0) { if (!Number.isFinite(strike) || strike <= 0) {
console.warn("[StrategyStore] addLeg: invalid strike", leg); console.warn("[StrategyStore] addLeg: invalid strike", leg);
return { state: load(), replacedSymbol: null }; return { state: load() };
} }
let state = load(); const doc = loadDoc();
let replacedSymbol = null; const sym = leg.symbol || doc.active || "MANUAL";
if (state.legs.length > 0 && state.symbol && leg.symbol && state.symbol !== leg.symbol) { if (!doc.baskets[sym]) doc.baskets[sym] = { spotSnapshot: 0, legs: [] };
replacedSymbol = state.symbol;
state = empty();
}
state.symbol = leg.symbol || state.symbol || "";
if (typeof leg.spotSnapshot === "number" && leg.spotSnapshot > 0) { if (typeof leg.spotSnapshot === "number" && leg.spotSnapshot > 0) {
state.spotSnapshot = leg.spotSnapshot; doc.baskets[sym].spotSnapshot = leg.spotSnapshot;
} }
state.legs.push({ doc.baskets[sym].legs.push({
id: ((typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : "id-" + Date.now() + "-" + Math.random().toString(36).slice(2)), id: (typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : "id-" + Date.now() + "-" + Math.random().toString(36).slice(2),
symbol: leg.symbol || state.symbol, symbol: sym,
expiry: leg.expiry, expiry: leg.expiry,
type: leg.type, type: leg.type,
strike: round2(strike), strike: round2(strike),
@@ -101,23 +178,39 @@
entryPrice: round2(leg.entryPrice), entryPrice: round2(leg.entryPrice),
iv: Number(leg.iv) || 0, iv: Number(leg.iv) || 0,
locked: leg.locked === true, locked: leg.locked === true,
currentMark: (typeof leg.currentMark === "number" ? round2(leg.currentMark) : null), currentMark: typeof leg.currentMark === "number" ? round2(leg.currentMark) : null,
}); });
save(state); doc.active = sym;
return { state, replacedSymbol }; saveDoc(doc);
return { state: load() };
}
function basketOf(doc, id) {
for (const sym of Object.keys(doc.baskets)) {
if (doc.baskets[sym].legs.some((l) => l.id === id)) return sym;
}
return null;
} }
function removeLeg(id) { function removeLeg(id) {
const state = load(); const doc = loadDoc();
state.legs = state.legs.filter((l) => l.id !== id); const sym = basketOf(doc, id);
if (state.legs.length === 0) { return clear(); } if (sym) {
save(state); doc.baskets[sym].legs = doc.baskets[sym].legs.filter((l) => l.id !== id);
return state; if (doc.baskets[sym].legs.length === 0) {
delete doc.baskets[sym];
if (doc.active === sym) doc.active = Object.keys(doc.baskets)[0] || "";
}
}
saveDoc(doc);
return load();
} }
function updateLeg(id, patch) { function updateLeg(id, patch) {
const state = load(); const doc = loadDoc();
const leg = state.legs.find((l) => l.id === id); const sym = basketOf(doc, id);
if (sym) {
const leg = doc.baskets[sym].legs.find((l) => l.id === id);
if (leg) { if (leg) {
const p = { ...patch }; const p = { ...patch };
if ("entryPrice" in p) p.entryPrice = round2(p.entryPrice); if ("entryPrice" in p) p.entryPrice = round2(p.entryPrice);
@@ -125,24 +218,24 @@
if ("strike" in p) p.strike = round2(p.strike); if ("strike" in p) p.strike = round2(p.strike);
Object.assign(leg, p); Object.assign(leg, p);
} }
save(state); }
return state; saveDoc(doc);
return load();
} }
/** True if adding a leg of `symbol` would wipe an existing different-symbol basket. */ /** multi-basket: adding a different symbol no longer wipes anything. */
function mismatch(symbol) { function mismatch() { return null; }
const state = load();
return state.legs.length > 0 && state.symbol && symbol && state.symbol !== symbol
? state.symbol
: null;
}
/** legs in the active basket. */
function count() { function count() {
return load().legs.length; const doc = loadDoc();
const b = doc.baskets[doc.active];
return b ? b.legs.length : 0;
} }
window.StrategyStore = { window.StrategyStore = {
KEY, VERSION, MULTIPLIER, KEY, VERSION, MULTIPLIER,
empty, load, save, clear, addLeg, removeLeg, updateLeg, mismatch, count, empty: () => ({ v: VERSION, symbol: "", spotSnapshot: 0, updatedAt: null, legs: [], symbols: [] }),
load, save, clear, clearAll, addLeg, removeLeg, updateLeg, mismatch, count, listSymbols, setActive,
}; };
})(); })();

View File

@@ -55,13 +55,20 @@
<div class="page-header d-print-none"> <div class="page-header d-print-none">
<div class="container-xl"> <div class="container-xl">
<div class="row g-2 align-items-center"> <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"> <div class="col">
<h2 class="page-title">Strategy P/L Analyzer</h2> <h2 class="page-title">Strategy P/L Analyzer</h2>
<div class="text-secondary mt-1" x-show="legs.length > 0" x-cloak> <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 class="badge bg-purple-lt fs-6 me-2" x-text="strategyName"></span>
<span x-text="symbol"></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> · <span x-text="legs.length + ' leg' + (legs.length===1?'':'s')"></span> ·
Spot <strong class="mono" x-text="spot > 0 ? '$'+spot.toFixed(2) : '—'"></strong> 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> </div>
<div class="col-auto" x-show="legs.length > 0" x-cloak> <div class="col-auto" x-show="legs.length > 0" x-cloak>
@@ -291,7 +298,7 @@
function strategyApp() { function strategyApp() {
return { return {
// state // state
symbol: '', spot: 0, legs: [], symbol: '', symbols: [], spot: 0, legs: [],
dteOffset: 0, dteOffset: 0,
refreshing: false, showManual: false, toast: '', refreshing: false, showManual: false, toast: '',
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null }, manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
@@ -308,10 +315,19 @@
reload() { reload() {
const st = StrategyStore.load(); const st = StrategyStore.load();
this.symbol = st.symbol || ''; this.symbol = st.symbol || '';
this.symbols = st.symbols || [];
this.spot = st.spotSnapshot || 0; this.spot = st.spotSnapshot || 0;
this.legs = st.legs || []; this.legs = st.legs || [];
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE; if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
if (this.legs.length > 0) this.$nextTick(() => this.renderChart()); 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.reload();
if (this.legs.length > 0) this.reloadMarket(false);
}, },
// ── derived ─────────────────────────────────────────── // ── derived ───────────────────────────────────────────
@@ -421,10 +437,11 @@
this.reload(); this.reload();
}, },
clearAll() { clearAll() {
if (!confirm('Clear all legs?')) return; if (!confirm('Clear all ' + (this.symbol || '') + ' legs?')) return;
StrategyStore.clear(); StrategyStore.clear();
this.reload(); this.dteOffset = 0;
if (this.chart) { this.chart.destroy(); this.chart = null; } if (this.chart) { this.chart.destroy(); this.chart = null; }
this.reload(); // re-renders the chart if another symbol's basket is now active
}, },
addManualLeg() { addManualLeg() {
const m = this.manual; const m = this.manual;