Editable expiry per leg — fetches the new chain on demand

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 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 05:13:25 +00:00
parent d5d347e0fb
commit dbf7c8e9d2

View File

@@ -173,7 +173,12 @@
</select>
<span x-show="!hasStrikeOpts(lv)" x-text="lv.strike"></span>
</td>
<td class="mono small" x-text="lv.expiry"></td>
<td class="mono small" style="width:8.5rem">
<select x-show="hasExpiryOpts(lv)" class="form-select form-select-sm" :value="lv.expiry" @change="changeExpiry(lv.id, $event.target.value)" title="Change expiry — strike, entry, IV & mark update from the new chain">
<template x-for="e in expiryOpts(lv)" :key="e"><option :value="e" x-text="e"></option></template>
</select>
<span x-show="!hasExpiryOpts(lv)" x-text="lv.expiry"></span>
</td>
<td style="width:8.5rem">
<div class="input-group input-group-sm flex-nowrap">
<input type="number" min="0" step="0.01" class="form-control form-control-sm text-end" :class="lv.locked ? 'opacity-75' : ''" :value="lv.entryPrice" :disabled="lv.locked" @change="updateLeg(lv.id, { entryPrice: Math.max(0, +$event.target.value||0) })">
@@ -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 }