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:
@@ -173,7 +173,12 @@
|
|||||||
</select>
|
</select>
|
||||||
<span x-show="!hasStrikeOpts(lv)" x-text="lv.strike"></span>
|
<span x-show="!hasStrikeOpts(lv)" x-text="lv.strike"></span>
|
||||||
</td>
|
</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">
|
<td style="width:8.5rem">
|
||||||
<div class="input-group input-group-sm flex-nowrap">
|
<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) })">
|
<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 },
|
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
|
||||||
chart: null, _renderTimer: null,
|
chart: null, _renderTimer: null,
|
||||||
_chainCache: {}, // "SYMBOL@EXPIRY" -> { "strike@type": optionRow }
|
_chainCache: {}, // "SYMBOL@EXPIRY" -> { "strike@type": optionRow }
|
||||||
|
_expiryCache: {}, // "SYMBOL" -> ["2026-06-20", ...]
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.reload();
|
this.reload();
|
||||||
this._chainCache = this._seedChainCache();
|
this._chainCache = this._seedChainCache();
|
||||||
|
if (this.symbol) this._ensureExpiries(this.symbol);
|
||||||
// pull live spot / marks / IVs (and per-expiry chains) on open
|
// pull live spot / marks / IVs (and per-expiry chains) on open
|
||||||
if (this.legs.length > 0 && this.symbol) this.reloadMarket(false);
|
if (this.legs.length > 0 && this.symbol) this.reloadMarket(false);
|
||||||
// re-sync if another tab changed the basket
|
// re-sync if another tab changed the basket
|
||||||
@@ -374,6 +381,84 @@
|
|||||||
this.updateLeg(id, patch);
|
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() {
|
reload() {
|
||||||
const st = StrategyStore.load();
|
const st = StrategyStore.load();
|
||||||
this.symbol = st.symbol || '';
|
this.symbol = st.symbol || '';
|
||||||
@@ -389,6 +474,7 @@
|
|||||||
StrategyStore.setActive(sym);
|
StrategyStore.setActive(sym);
|
||||||
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
|
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
|
||||||
this.reload();
|
this.reload();
|
||||||
|
this._ensureExpiries(sym);
|
||||||
if (this.legs.length > 0) this.reloadMarket(false);
|
if (this.legs.length > 0) this.reloadMarket(false);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -525,6 +611,7 @@
|
|||||||
async reloadMarket(repriceUnlocked = true) {
|
async reloadMarket(repriceUnlocked = true) {
|
||||||
if (!this.symbol || this.legs.length === 0) return;
|
if (!this.symbol || this.legs.length === 0) return;
|
||||||
this.refreshing = true;
|
this.refreshing = true;
|
||||||
|
this._ensureExpiries(this.symbol); // fire-and-forget; fills the expiry dropdown
|
||||||
try {
|
try {
|
||||||
const expiries = [...new Set(this.legs.map(l => l.expiry).filter(Boolean))];
|
const expiries = [...new Set(this.legs.map(l => l.expiry).filter(Boolean))];
|
||||||
const byExpiry = {}; // expiry -> { strike@type -> option }
|
const byExpiry = {}; // expiry -> { strike@type -> option }
|
||||||
|
|||||||
Reference in New Issue
Block a user