From dbf7c8e9d29dee2bb32cc795d206b9b1b80c16ce Mon Sep 17 00:00:00 2001 From: ojy Date: Wed, 13 May 2026 05:13:25 +0000 Subject: [PATCH] =?UTF-8?q?Editable=20expiry=20per=20leg=20=E2=80=94=20fet?= =?UTF-8?q?ches=20the=20new=20chain=20on=20demand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Expiry cell is now a dropdown of available expiries for that leg's symbol (fetched from /api/expirations on page open / symbol switch / Reload). Picking a different expiry pulls that expiry's chain on-demand (cached), finds the same strike (or the closest available) for the leg's type, and updates entry price, IV and mark. Lock clears since it's a new contract. Co-Authored-By: Claude Sonnet 4.6 --- frontend/strategy.html | 89 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/frontend/strategy.html b/frontend/strategy.html index 5b49076..46f5ada 100644 --- a/frontend/strategy.html +++ b/frontend/strategy.html @@ -173,7 +173,12 @@ - + + + +
@@ -322,10 +327,12 @@ manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null }, chart: null, _renderTimer: null, _chainCache: {}, // "SYMBOL@EXPIRY" -> { "strike@type": optionRow } + _expiryCache: {}, // "SYMBOL" -> ["2026-06-20", ...] init() { this.reload(); this._chainCache = this._seedChainCache(); + if (this.symbol) this._ensureExpiries(this.symbol); // pull live spot / marks / IVs (and per-expiry chains) on open if (this.legs.length > 0 && this.symbol) this.reloadMarket(false); // re-sync if another tab changed the basket @@ -374,6 +381,84 @@ this.updateLeg(id, patch); }, + // ---- expiry picker ----------------------------------------------- + hasExpiryOpts(lv) { return Array.isArray(this._expiryCache[lv.symbol]) && this._expiryCache[lv.symbol].length > 0; }, + expiryOpts(lv) { + const list = (this._expiryCache[lv.symbol] || []).slice(); + if (!list.includes(lv.expiry)) list.push(lv.expiry); + return list.sort(); + }, + + async _ensureExpiries(sym) { + if (!sym) return; + const have = this._expiryCache[sym]; + if (Array.isArray(have) && have.length > 0) return; + try { + const r = await fetch('/api/expirations?symbol=' + encodeURIComponent(sym)); + if (!r.ok) return; + const e = await r.json(); + const data = e.data ?? e; + const list = data.expirations || (Array.isArray(data) ? data : []); + if (list.length) this._expiryCache = { ...this._expiryCache, [sym]: list }; + } catch {} + }, + + async _ensureChain(sym, exp) { + const key = sym + '@' + exp; + if (this._chainCache[key]) return this._chainCache[key]; + try { + const r = await fetch('/api/chain?symbol=' + encodeURIComponent(sym) + '&expiry=' + encodeURIComponent(exp)); + if (!r.ok) return null; + const e = await r.json(); + const snap = e.data?.snapshots?.[0]; + if (!snap) return null; + const map = {}; + for (const o of (snap.chain || [])) { + const t = (o.type || o.optionType || '').toLowerCase(); + map[Number(o.strike) + '@' + t] = o; + } + this._chainCache = { ...this._chainCache, [key]: map }; + if (snap.spot > 0) { + this.spot = snap.spot; + const st = StrategyStore.load(); st.spotSnapshot = snap.spot; StrategyStore.save(st); + } + return map; + } catch { return null; } + }, + + async changeExpiry(id, newExpiry) { + const leg = this.legs.find(l => l.id === id); + if (!leg || !newExpiry || newExpiry === leg.expiry) return; + this.refreshing = true; + try { + const map = await this._ensureChain(leg.symbol, newExpiry); + if (!map) { this.flash('No chain available for ' + newExpiry); return; } + // exact strike if it exists for this type, else closest + let opt = map[Number(leg.strike) + '@' + leg.type]; + let pickedStrike = leg.strike; + if (!opt) { + let bestD = Infinity; + for (const k of Object.keys(map)) { + if (!k.endsWith('@' + leg.type)) continue; + const s = parseFloat(k); + const d = Math.abs(s - leg.strike); + if (d < bestD) { bestD = d; opt = map[k]; pickedStrike = s; } + } + } + const patch = { expiry: newExpiry, strike: pickedStrike, locked: false }; + if (opt) { + const mid = Math.round(((opt.midPrice ?? opt.mid ?? opt.bsPrice ?? leg.entryPrice) || 0) * 100) / 100; + patch.entryPrice = mid; + patch.currentMark = mid; + if (opt.iv > 0) patch.iv = opt.iv; + } + this.updateLeg(id, patch); + this.flash('Expiry → ' + newExpiry + (pickedStrike !== leg.strike ? ' (strike ' + leg.strike + ' → ' + pickedStrike + ')' : '')); + } finally { + this.refreshing = false; + } + }, + reload() { const st = StrategyStore.load(); this.symbol = st.symbol || ''; @@ -389,6 +474,7 @@ StrategyStore.setActive(sym); this.dteOffset = 0; this.xPan = 0; this.xZoom = 1; this.reload(); + this._ensureExpiries(sym); if (this.legs.length > 0) this.reloadMarket(false); }, @@ -525,6 +611,7 @@ async reloadMarket(repriceUnlocked = true) { if (!this.symbol || this.legs.length === 0) return; this.refreshing = true; + this._ensureExpiries(this.symbol); // fire-and-forget; fills the expiry dropdown try { const expiries = [...new Set(this.legs.map(l => l.expiry).filter(Boolean))]; const byExpiry = {}; // expiry -> { strike@type -> option }