diff --git a/frontend/assets/strategy-store.js b/frontend/assets/strategy-store.js index cd59470..a6fa829 100644 --- a/frontend/assets/strategy-store.js +++ b/frontend/assets/strategy-store.js @@ -1,24 +1,28 @@ /** - * Strategy basket persistence — localStorage-backed. + * Strategy basket persistence — localStorage-backed, multi-symbol. * Exposed as window.StrategyStore. No modules, no deps. * - * Stored shape (key "optionsPricer:strategy"): + * Stored shape (key "optionsPricer:strategy", v2): * { - * v: 1, - * symbol: "SPY", - * spotSnapshot: 738.18, // spot when last leg was added (reference only) + * v: 2, + * active: "SPY", // currently-shown symbol * updatedAt: "2026-05-13T...Z", - * legs: [ - * { id, symbol, expiry:"YYYY-MM-DD", type:"call"|"put", - * strike, side:"long"|"short", qty, entryPrice, iv } - * ] + * 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 = 1; + const VERSION = 2; const MULTIPLIER = 100; // standard equity option contract size /** 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; } - function empty() { - return { v: VERSION, symbol: "", spotSnapshot: 0, updatedAt: null, legs: [] }; + 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 load() { + function loadDoc() { try { const raw = localStorage.getItem(KEY); - if (!raw) return empty(); + if (!raw) return emptyDoc(); 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")) - .map((l) => ({ - ...l, - strike: round2(l.strike), - entryPrice: round2(l.entryPrice), - currentMark: (typeof l.currentMark === "number" ? round2(l.currentMark) : (l.currentMark ?? null)), - })); - return obj; + 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 empty(); + 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 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); + 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] || ""; + } } - return s; + saveDoc(doc); + return load(); } + /** Clear the ACTIVE basket only. */ function clear() { - try { localStorage.removeItem(KEY); } catch {} - return empty(); + 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(); } - /** - * 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 }; + return { state: load() }; } - 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 || ""; + 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) { - state.spotSnapshot = leg.spotSnapshot; + doc.baskets[sym].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, + 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), @@ -101,48 +178,64 @@ entryPrice: round2(leg.entryPrice), iv: Number(leg.iv) || 0, locked: leg.locked === true, - currentMark: (typeof leg.currentMark === "number" ? round2(leg.currentMark) : null), + currentMark: typeof leg.currentMark === "number" ? round2(leg.currentMark) : null, }); - save(state); - return { state, replacedSymbol }; + 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 state = load(); - state.legs = state.legs.filter((l) => l.id !== id); - if (state.legs.length === 0) { return clear(); } - save(state); - return state; + 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 state = load(); - const leg = state.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); + 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); + } } - save(state); - return state; + saveDoc(doc); + return load(); } - /** 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; - } + /** multi-basket: adding a different symbol no longer wipes anything. */ + function mismatch() { return null; } + /** legs in the active basket. */ function count() { - return load().legs.length; + const doc = loadDoc(); + const b = doc.baskets[doc.active]; + return b ? b.legs.length : 0; } window.StrategyStore = { 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, }; })(); diff --git a/frontend/strategy.html b/frontend/strategy.html index 5d85ba8..98de95c 100644 --- a/frontend/strategy.html +++ b/frontend/strategy.html @@ -55,13 +55,20 @@