Master expiry control — link all legs to one date

The Expiry column header now has a master expiry selector with a 🔓/🔒
toggle. Locking it snaps every leg to the most common expiry, then any
change to the master propagates to all legs (each re-priced from its new
expiry's chain). Per-leg expiry selects are disabled while locked.
Lock resets when switching symbols or clearing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 06:46:49 +00:00
parent 0b546ebf41
commit 58f898b47d

View File

@@ -153,7 +153,19 @@
<thead>
<tr>
<th class="text-center" style="width:3rem">Show</th>
<th>Side</th><th>Qty</th><th>Type</th><th class="text-end">Strike</th><th>Expiry</th>
<th>Side</th><th>Qty</th><th>Type</th><th class="text-end">Strike</th>
<th style="min-width:13rem">
<div class="d-flex align-items-center gap-1 flex-nowrap" style="text-transform:none; letter-spacing:0;">
<span style="text-transform:uppercase; letter-spacing:.05em; font-size:.7rem;">Expiry</span>
<select class="form-select form-select-sm ms-1" :disabled="!expiryLocked" :value="masterExpiry" @change="onMasterExpiry($event.target.value)"
:title="expiryLocked ? 'All legs share this expiry — pick to change them all' : 'Click 🔒 to link all legs to one expiry'">
<template x-for="e in masterExpiryOpts" :key="e"><option :value="e" x-text="e"></option></template>
</select>
<button class="btn btn-sm btn-outline-secondary px-1" @click="toggleExpiryLock()"
:title="expiryLocked ? 'Unlock — each leg can have its own expiry' : 'Lock all legs to one expiry'"
x-text="expiryLocked ? '🔒' : '🔓'"></button>
</div>
</th>
<th class="text-end">Entry $</th><th class="text-end">Mark</th><th class="text-end">IV</th>
<th class="text-end">Cost</th><th class="text-end">Δ</th><th class="text-end">Θ/d</th><th></th>
</tr>
@@ -177,7 +189,7 @@
</td>
<td class="mono small" style="width:11rem">
<div class="d-flex align-items-center gap-1 flex-nowrap">
<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">
<select x-show="hasExpiryOpts(lv)" class="form-select form-select-sm" :value="lv.expiry" :disabled="expiryLocked" @change="changeExpiry(lv.id, $event.target.value)" :title="expiryLocked ? 'Use the master Expiry at the top to change all legs together' : '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>
@@ -328,6 +340,7 @@
symbol: '', symbols: [], spot: 0, legs: [],
dteOffset: 0,
xZoom: 1, xPan: 0, lastHalfPct: 0,
expiryLocked: false, masterExpiry: '',
refreshing: false, showManual: false, toast: '',
manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null },
chart: null, _renderTimer: null,
@@ -431,6 +444,37 @@
} catch { return null; }
},
// ---- master expiry (lock-all-legs-to-one-date) -------------------
get masterExpiryOpts() {
const set = new Set();
for (const l of this.legs) if (l.expiry) set.add(l.expiry);
const cache = this._expiryCache[this.symbol] || [];
for (const e of cache) set.add(e);
return [...set].sort();
},
toggleExpiryLock() {
if (this.expiryLocked) { this.expiryLocked = false; return; }
// turning on — pick the most common leg expiry as the master, then snap
const counts = {};
for (const l of this.legs) counts[l.expiry] = (counts[l.expiry] || 0) + 1;
let best = '', bestN = -1;
for (const e of Object.keys(counts)) if (counts[e] > bestN) { best = e; bestN = counts[e]; }
this.masterExpiry = best || (this.legs[0] && this.legs[0].expiry) || '';
this.expiryLocked = true;
if (this.masterExpiry) this.changeAllExpiry(this.masterExpiry);
},
async onMasterExpiry(newExp) {
if (!this.expiryLocked || !newExp) return;
this.masterExpiry = newExp;
await this.changeAllExpiry(newExp);
},
async changeAllExpiry(newExp) {
const ids = this.legs.filter(l => l.expiry !== newExp).map(l => l.id);
for (const id of ids) {
await this.changeExpiry(id, newExp);
}
},
async changeExpiry(id, newExpiry) {
const leg = this.legs.find(l => l.id === id);
if (!leg || !newExpiry || newExpiry === leg.expiry) return;
@@ -470,6 +514,9 @@
this.symbols = st.symbols || [];
this.spot = st.spotSnapshot || 0;
this.legs = st.legs || [];
if (this.legs.length && (!this.masterExpiry || !this.legs.some(l => l.expiry === this.masterExpiry))) {
this.masterExpiry = this.legs[0].expiry || '';
}
if (this.dteOffset > this.maxDTE) this.dteOffset = this.maxDTE;
if (this.legs.length > 0) this.$nextTick(() => this.renderChart()); else if (this.chart) this.chart.updateSeries([{name:'P/L',data:[]},{name:'P/L',data:[]}]);
},
@@ -478,6 +525,7 @@
if (!sym || sym === this.symbol) return;
StrategyStore.setActive(sym);
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
this.expiryLocked = false; this.masterExpiry = '';
this.reload();
this._ensureExpiries(sym);
if (this.legs.length > 0) this.reloadMarket(false);
@@ -617,6 +665,7 @@
if (!confirm('Clear all ' + (this.symbol || '') + ' legs?')) return;
StrategyStore.clear();
this.dteOffset = 0; this.xPan = 0; this.xZoom = 1;
this.expiryLocked = false; this.masterExpiry = '';
if (this.chart) { this.chart.destroy(); this.chart = null; }
this.reload(); // re-renders the chart if another symbol's basket is now active
},