/** * Strategy basket persistence — localStorage-backed, multi-symbol. * Exposed as window.StrategyStore. No modules, no deps. * * Stored shape (key "optionsPricer:strategy", v2): * { * v: 2, * active: "SPY", // currently-shown symbol * updatedAt: "2026-05-13T...Z", * baskets: { * "SPY": { spotSnapshot: 738.18, legs: [ ...legs ] }, * "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 () { "use strict"; const KEY = "optionsPricer:strategy"; const VERSION = 2; const MULTIPLIER = 100; // standard equity option contract size /** Round a price to 2 decimals (kills float artifacts like 15.000000001). */ function round2(v) { const n = Number(v); return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0; } function emptyDoc() { return { v: VERSION, active: "", updatedAt: null, baskets: {} }; } function sanitizeLegs(legs) { if (!Array.isArray(legs)) return []; return legs .filter((l) => l && Number.isFinite(Number(l.strike)) && (l.type === "call" || l.type === "put")) .map((l) => ({ ...l, strike: round2(l.strike), entryPrice: round2(l.entryPrice), currentMark: typeof l.currentMark === "number" ? round2(l.currentMark) : (l.currentMark ?? null), locked: l.locked === true, })); } function loadDoc() { try { const raw = localStorage.getItem(KEY); if (!raw) return emptyDoc(); 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 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() { const doc = loadDoc(); 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(); } 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() }; } const doc = loadDoc(); const sym = leg.symbol || doc.active || "MANUAL"; if (!doc.baskets[sym]) doc.baskets[sym] = { spotSnapshot: 0, legs: [] }; if (typeof leg.spotSnapshot === "number" && leg.spotSnapshot > 0) { doc.baskets[sym].spotSnapshot = leg.spotSnapshot; } doc.baskets[sym].legs.push({ id: (typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : "id-" + Date.now() + "-" + Math.random().toString(36).slice(2), symbol: sym, expiry: leg.expiry, type: leg.type, strike: round2(strike), side: leg.side === "short" ? "short" : "long", qty: Math.max(1, Math.round(Number(leg.qty) || 1)), entryPrice: round2(leg.entryPrice), iv: Number(leg.iv) || 0, locked: leg.locked === true, currentMark: typeof leg.currentMark === "number" ? round2(leg.currentMark) : null, }); doc.active = sym; 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) { const doc = loadDoc(); const sym = basketOf(doc, id); if (sym) { doc.baskets[sym].legs = doc.baskets[sym].legs.filter((l) => l.id !== id); 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) { const doc = loadDoc(); const sym = basketOf(doc, id); if (sym) { const leg = doc.baskets[sym].legs.find((l) => l.id === id); if (leg) { const p = { ...patch }; if ("entryPrice" in p) p.entryPrice = round2(p.entryPrice); if ("currentMark" in p && p.currentMark != null) p.currentMark = round2(p.currentMark); if ("strike" in p) p.strike = round2(p.strike); Object.assign(leg, p); } } saveDoc(doc); return load(); } /** multi-basket: adding a different symbol no longer wipes anything. */ function mismatch() { return null; } /** legs in the active basket. */ function count() { const doc = loadDoc(); const b = doc.baskets[doc.active]; return b ? b.legs.length : 0; } window.StrategyStore = { KEY, VERSION, MULTIPLIER, empty: () => ({ v: VERSION, symbol: "", spotSnapshot: 0, updatedAt: null, legs: [], symbols: [] }), load, save, clear, clearAll, addLeg, removeLeg, updateLeg, mismatch, count, listSymbols, setActive, }; })();