Positions: add natural-mark P/L column + spread-cost tracker

The standard "current value" used bid/ask MID per leg — what the
broker shows. That's optimistic for multi-leg positions because
you can't actually close at mid; longs sell at the BID, shorts buy
back at the ASK. For a 4-leg LEAPS structure the bid-ask gap can
swing the mark by hundreds of dollars vs the real close-now value.

Now the table shows both side-by-side:
  - Current $ (mid)        - broker-style mark
  - Current $ (nat)        - realistic close-now value
  - Net P/L (mid)          - what the broker reports
  - Net P/L (close now)    - what you'd actually pocket today
  - Spread $               - difference (= bid-ask cost to close)

Summary cards row gains the same split: "Net P/L (mid)",
"Net P/L (close now)", "Spread cost". Tooltips on every header
explain the methodology.

Live test on the open INTC iron-fly: mid P/L shows +$235, natural
P/L shows -$38 — the $272 spread cost is exactly what makes the
day-1 paper P/L look better than reality. The natural column is
the honest decision-making number.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-14 03:10:45 +00:00
parent 74a727d0ae
commit 736978c91b

View File

@@ -195,10 +195,11 @@
<!-- Summary cards --> <!-- Summary cards -->
<div class="row g-2 mb-3" x-show="visible.length > 0" x-cloak> <div class="row g-2 mb-3" x-show="visible.length > 0" x-cloak>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Open positions</div><div class="val" x-text="openCount"></div></div></div> <div class="col-6 col-md"><div class="summary-card"><div class="lbl">Open positions</div><div class="val" x-text="openCount"></div></div></div>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Total entered</div><div class="val mono" x-text="fmtMoney(totals.entered)"></div></div></div> <div class="col-6 col-md"><div class="summary-card"><div class="lbl">Total entered</div><div class="val mono" x-text="fmtMoney(totals.entered)"></div></div></div>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Current value</div><div class="val mono" x-text="fmtMoney(totals.current)"></div></div></div> <div class="col-6 col-md"><div class="summary-card" title="Net P/L using bid/ask MID for each leg — what your broker shows. Optimistic; doesn't account for the bid-ask spread you'd pay to actually close."><div class="lbl">Net P/L (mid mark)</div><div class="val mono" :class="totals.netPL >= 0 ? 'pos' : 'neg'" x-text="fmtMoney(totals.netPL)"></div></div></div>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Net P/L (after commissions)</div><div class="val mono" :class="totals.netPL >= 0 ? 'pos' : 'neg'" x-text="fmtMoney(totals.netPL)"></div></div></div> <div class="col-6 col-md"><div class="summary-card" title="Net P/L if you closed RIGHT NOW: longs sell at the BID, shorts buy back at the ASK — the realistic close-now value. This is what you'd actually pocket today."><div class="lbl">Net P/L (close now)</div><div class="val mono" :class="totals.netPLNatural >= 0 ? 'pos' : 'neg'" x-text="fmtMoney(totals.netPLNatural)"></div></div></div>
<div class="col-6 col-md"><div class="summary-card" title="The bid-ask spread cost across all positions: how much you'd give up to actually close at market vs the optimistic mid-mark."><div class="lbl">Spread cost</div><div class="val mono text-warning" x-text="'-' + fmtMoney(Math.max(0, totals.netPL - totals.netPLNatural))"></div></div></div>
</div> </div>
<!-- Empty state --> <!-- Empty state -->
@@ -227,10 +228,12 @@
<tr> <tr>
<th>Sym</th><th>Strategy</th><th>Legs</th> <th>Sym</th><th>Strategy</th><th>Legs</th>
<th class="text-end">Entered $</th> <th class="text-end">Entered $</th>
<th class="text-end">Current $</th> <th class="text-end" title="Mark using bid/ask MID per leg — broker-style. Optimistic.">Current $ (mid)</th>
<th class="text-end" title="Realistic close-now value: longs at BID, shorts at ASK.">Current $ (nat)</th>
<th class="text-end">Comm. (RT)</th> <th class="text-end">Comm. (RT)</th>
<th class="text-end">Gross P/L</th> <th class="text-end" title="Net P/L using mid mark — what your broker shows.">Net P/L (mid)</th>
<th class="text-end">Net P/L</th> <th class="text-end" title="Net P/L if you closed right now at realistic fills: longs sell at BID, shorts buy back at ASK. This is what you'd actually pocket.">Net P/L (close now)</th>
<th class="text-end" title="Bid-ask spread cost: difference between optimistic mid P/L and realistic close-now P/L.">Spread $</th>
<th class="text-end">P/L %</th> <th class="text-end">P/L %</th>
<th>Opened</th> <th>Opened</th>
<th>Status</th> <th>Status</th>
@@ -245,10 +248,12 @@
<td class="mono small text-secondary" x-text="row.legsSummary"></td> <td class="mono small text-secondary" x-text="row.legsSummary"></td>
<td class="text-end mono" x-text="fmtMoney(row.entered)"></td> <td class="text-end mono" x-text="fmtMoney(row.entered)"></td>
<td class="text-end mono" x-text="fmtMoney(row.value)"></td> <td class="text-end mono" x-text="fmtMoney(row.value)"></td>
<td class="text-end mono text-secondary" x-text="fmtMoney(row.valueNatural)"></td>
<td class="text-end mono text-warning" x-text="'-' + fmtMoney(row.commission)"></td> <td class="text-end mono text-warning" x-text="'-' + fmtMoney(row.commission)"></td>
<td class="text-end mono" :class="row.grossPL >= 0 ? 'text-success' : 'text-danger'" x-text="fmtMoney(row.grossPL)"></td> <td class="text-end mono" :class="row.netPL >= 0 ? 'text-success' : 'text-danger'" x-text="fmtMoney(row.netPL)"></td>
<td class="text-end mono fw-bold" :class="row.netPL >= 0 ? 'text-success' : 'text-danger'" x-text="fmtMoney(row.netPL)"></td> <td class="text-end mono fw-bold" :class="row.netPLNatural >= 0 ? 'text-success' : 'text-danger'" x-text="fmtMoney(row.netPLNatural)"></td>
<td class="text-end mono small" :class="row.netPL >= 0 ? 'text-success' : 'text-danger'" x-text="row.plPct.toFixed(1) + '%'"></td> <td class="text-end mono small text-warning" x-text="'-' + fmtMoney(Math.max(0, row.netPL - row.netPLNatural))"></td>
<td class="text-end mono small" :class="row.netPLNatural >= 0 ? 'text-success' : 'text-danger'" x-text="row.plPctNatural.toFixed(1) + '%'"></td>
<td class="small text-secondary mono" x-text="(row.o.created_at||'').slice(0,10)"></td> <td class="small text-secondary mono" x-text="(row.o.created_at||'').slice(0,10)"></td>
<td><span class="badge" :class="row.o.status === 'open' ? 'bg-success-lt text-success' : 'bg-secondary-lt text-secondary'" x-text="row.o.status"></span></td> <td><span class="badge" :class="row.o.status === 'open' ? 'bg-success-lt text-success' : 'bg-secondary-lt text-secondary'" x-text="row.o.status"></span></td>
<td><button class="btn btn-sm btn-outline-primary" @click="openInStrategy(row.o)" title="Open this position in the Strategy P/L chart">P/L Chart</button></td> <td><button class="btn btn-sm btn-outline-primary" @click="openInStrategy(row.o)" title="Open this position in the Strategy P/L chart">P/L Chart</button></td>
@@ -333,7 +338,7 @@
}, },
rowFor(o) { rowFor(o) {
let entered = 0, value = 0; let entered = 0, value = 0, valueNatural = 0;
for (const l of (o.legs||[])) { for (const l of (o.legs||[])) {
const sign = l.side === 'short' ? -1 : 1; const sign = l.side === 'short' ? -1 : 1;
entered += sign * l.qty * 100 * (l.entryPrice || 0); entered += sign * l.qty * 100 * (l.entryPrice || 0);
@@ -341,15 +346,36 @@
const oo = m && m[Number(l.strike) + '@' + l.type]; const oo = m && m[Number(l.strike) + '@' + l.type];
const mid = oo ? Number(oo.midPrice ?? oo.mid ?? oo.bsPrice ?? 0) : 0; const mid = oo ? Number(oo.midPrice ?? oo.mid ?? oo.bsPrice ?? 0) : 0;
value += sign * l.qty * 100 * mid; value += sign * l.qty * 100 * mid;
// Natural mark = realistic close-now price:
// long legs sell at the BID (other side takes them off your hands)
// short legs buy back at the ASK (you pay to take them off)
// If quotes are missing (after-hours / illiquid), fall back to mid.
const bid = oo ? Number(oo.bid ?? 0) : 0;
const ask = oo ? Number(oo.ask ?? 0) : 0;
let natural;
if (l.side === 'long') natural = bid > 0 ? bid : mid;
else natural = ask > 0 ? ask : mid;
valueNatural += sign * l.qty * 100 * natural;
} }
const baseEntered = (o.entry_cost != null) ? Number(o.entry_cost) : entered; const baseEntered = (o.entry_cost != null) ? Number(o.entry_cost) : entered;
const grossPL = value - baseEntered; const grossPL = value - baseEntered;
const commission = SettingsStore.estimate(o.legs || []).roundTrip; const commission = SettingsStore.estimate(o.legs || []).roundTrip;
const netPL = grossPL - commission; const netPL = grossPL - commission;
// Natural-mark P/L: what you'd actually capture closing right now at
// the worst end of each leg's spread, after commissions.
const grossPLNatural = valueNatural - baseEntered;
const netPLNatural = grossPLNatural - commission;
const denom = Math.max(Math.abs(baseEntered), 1); const denom = Math.max(Math.abs(baseEntered), 1);
return { return {
o, entered: baseEntered, value, grossPL, commission, netPL, o, entered: baseEntered, value, valueNatural,
grossPL, commission, netPL,
grossPLNatural, netPLNatural,
plPct: netPL / denom * 100, plPct: netPL / denom * 100,
plPctNatural: netPLNatural / denom * 100,
spreadCost: netPL - netPLNatural,
legsSummary: (o.legs||[]).map(l => legsSummary: (o.legs||[]).map(l =>
(l.side === 'long' ? '+' : '-') + l.qty + ' ' + l.strike + (l.type === 'call' ? 'C' : 'P') + '·' + (l.expiry||'').slice(5) (l.side === 'long' ? '+' : '-') + l.qty + ' ' + l.strike + (l.type === 'call' ? 'C' : 'P') + '·' + (l.expiry||'').slice(5)
).join(' / '), ).join(' / '),
@@ -364,12 +390,20 @@
get openCount() { return this.visible.filter(r => r.o.status === 'open').length; }, get openCount() { return this.visible.filter(r => r.o.status === 'open').length; },
get totals() { get totals() {
let entered = 0, current = 0, comm = 0, gross = 0; let entered = 0, current = 0, currentNat = 0, comm = 0, gross = 0, grossNat = 0;
for (const r of this.visible) { for (const r of this.visible) {
if (r.o.status !== 'open') continue; if (r.o.status !== 'open') continue;
entered += r.entered; current += r.value; comm += r.commission; gross += r.grossPL; entered += r.entered;
current += r.value; currentNat += r.valueNatural;
comm += r.commission;
gross += r.grossPL; grossNat += r.grossPLNatural;
} }
return { entered, current, commission: comm, grossPL: gross, netPL: gross - comm }; return {
entered, current, currentNat,
commission: comm,
grossPL: gross, netPL: gross - comm,
grossPLNatural: grossNat, netPLNatural: grossNat - comm,
};
}, },
get commissionLabel() { get commissionLabel() {