Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,22 @@ Tags are created automatically by a GitHub Action on every merge to `main`
the deploy can resolve them. The footer always reflects current shipped code
without manual bookkeeping.

### Hero
The top-of-page chart band. Composition is fixed:

- **One** Realised P&L cumulative line (the time series), with the big number =
current Realised total and a 1M / 3M / ALL period toggle.
- A **Total P&L tile** sitting beside the line (not a second chart): two
stacked numbers — `Unrealised` and `Total` (= Realised + Unrealised). When
spot is missing for any open-lot asset, the affected number renders with a
muted sub-line `spot unavailable: <ASSET>` rather than an asterisk; full
miss renders as `—`. Realised is unaffected by missing spot.

The hero deliberately does **not** plot a Total P&L time series. Total
requires historical spot to plot honestly; we don't store it. Realised is the
only series we can draw without backfilling. Total lives as a "right now"
snapshot beside the chart, not behind it. ADR: `docs/adr/0004-hero-realised-line-total-tile.md`.

### Trade accounting snapshot
A per-trade record of the lot state **at the moment that trade was processed**
by the engine: `{ lotNum, lotSize, lotPremiums, lotCostBasis }`. Captured
Expand Down
110 changes: 110 additions & 0 deletions docs/adr/0004-hero-realised-line-total-tile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# ADR 0004: Hero is a Realised cumulative line plus a static Total P&L tile

- **Status:** Accepted
- **Date:** 2026-05-06
- **Supersedes:** none
- **Related:** ADR 0003 (cash-flow lens)

## Context

ADR 0003 established the cash-flow lens (Realised / Unrealised / Total) and
specified that the cumulative-P&L hero sparkline plots Realised only, because
Unrealised is a snapshot — it can't be back-projected without historical spot.

The implementation that landed has two issues the user surfaced on
2026-05-06:

1. The hero band currently renders **two** sparklines, both plotting Realised
cumulative P&L over the same window. The second is a near-duplicate of the
first, adds no information, and dilutes the salience of the headline.
2. Total P&L — which `CONTEXT.md` canonises as *"the single defensible 'where
do I stand' figure"* — is not visible above the fold at all. It exists in
`computePnl` but only surfaces in the Premium P&L tabs further down the
page.

A future contributor will inevitably ask *"why isn't the hero a Total P&L
chart? CONTEXT.md says Total is the headline."* This ADR pins the answer down
so it doesn't need to be relitigated.

## Decision

The hero band is composed of exactly:

- **One** cumulative Realised P&L line, full-width left, with the big number
showing current Realised and a 1M / 3M / ALL period toggle.
- A **Total P&L tile** placed beside the line (right side of the band). Two
numbers stacked: `Unrealised` (smaller weight) and `Total` (peer weight to
the hero's Realised number). No second chart.

The duplicate Realised sparkline is removed.

Missing-spot rendering on the tile:

- **Partial miss** (some open-lot assets have spot, others don't): tile shows
the partial Total computed from the assets we have, with a muted sub-line
`spot unavailable: <ASSET[, ASSET]>`. No asterisk on the number — an
asterisked headline reads as "this number is suspect" and undermines trust.
- **Full miss** (no spot for any open-lot asset): `Unrealised` and `Total`
both render as `—`, sub-line `spot unavailable`. Realised is unaffected.
- **No retry button, no "loading" state.** Spot fetches on boot; if it hasn't
arrived, treat it as missing.

## Alternatives considered

### Total P&L as the hero time series

Plot cumulative Total P&L (Realised + Unrealised) as the line.

- ✓ Matches the `CONTEXT.md` framing of Total as the headline figure.
- ✗ Requires historical spot to draw honestly. We don't store it.
- ✗ Faking it (marking past lots against today's spot, or holding Unrealised
flat until "now") produces a chart that looks authoritative but lies. Worse
than not plotting it at all.
- ✗ Even if we backfilled spot from CoinGecko, Unrealised would dominate the
series — a 30% spot move drowns out months of premium income, and the chart
stops answering "is the wheel paying me?"

### Two charts side by side (Realised line + Unrealised line)

Keep two charts but make the second informative.

- ✓ Symmetric with the cash-flow lens decomposition.
- ✗ Same historical-spot problem for the Unrealised line.
- ✗ Two charts of equal weight competes for attention; the page already has
Premium P&L tabs and an Expiring This Week table — the hero shouldn't be a
dashboard, it should be a headline.

### Realised line + composition donut (per-asset)

Replace the duplicate sparkline with a donut showing Realised contribution by
asset.

- ✓ Decision-relevant (which asset is paying me).
- ✗ Asset-filter chips below the hero already let the user slice the line by
asset on demand. A donut would be ambient eye-candy rather than answering a
question that isn't already answerable.
- ✗ Spends top-of-page real estate on something that can live further down.

### Realised line + static Total tile (chosen)

- ✓ Headline answers two distinct questions: *"how did I get here?"* (the
line) and *"where do I stand right now?"* (the tile).
- ✓ Honest — both halves are computable from data we actually have.
- ✓ Total tile preserves the `CONTEXT.md` headline framing without forcing a
dishonest time series.
- ✗ Tile is static — no historical context for Total. Acceptable: the line
already provides historical context for the flow component, and Unrealised
history isn't recoverable anyway.

## Consequences

- The duplicate sparkline render path in `rCpnlChart` (`src/js/07-render-charts.js`,
the `nToX` / `nToY` block) is removed.
- A new Total P&L tile component lives in the hero band, sourcing
`unrealised`, `total`, and `missingSpotAssets` from `computePnl`
(`src/js/05b-pnl.js`) — already exported, no engine changes required.
- The asset-filter chips continue to apply to both the line and the tile.
- If the team later decides to store historical spot (e.g. snapshot
CoinGecko at boot into a rolling localStorage series), this ADR is the
natural place to revisit — Total-as-a-line becomes possible and this
decision can be superseded.
6 changes: 0 additions & 6 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -675,12 +675,6 @@ td.td-act { width:1px; white-space:nowrap; padding-right:12px; }
}
.cpnl-tt-date { color: var(--mu); }
.cpnl-tt-val { font-weight: 600; }
.npnl-spark { border-top: 1px solid var(--bd); margin-top: 4px; padding-top: 10px; }
.npnl-spark-hd { display: flex; justify-content: space-between; align-items: center; padding: 0 24px; margin-bottom: 4px; }
.npnl-spark-lbl { font-size: .6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1.1px; color: var(--mu); font-family: var(--mono); }
.npnl-spark-lbl::before { content: '// '; opacity: .7; }
.npnl-spark-val { font-size: .9rem; font-weight: 700; font-family: var(--mono); color: var(--text); }
.npnl-no-asgn { font-size: .65rem; font-family: var(--mono); color: var(--mu); padding: 10px 24px 14px; }

/* ── MOBILE FAB (Add Trade) ── */
.mobile-fab{position:fixed;bottom:72px;right:16px;z-index:150;font-family:var(--mono);font-size:.82rem;font-weight:700;letter-spacing:1px;width:52px;height:52px;background:var(--gd);color:var(--green);border:1px solid var(--gb);cursor:pointer;touch-action:manipulation;transition:all .15s;display:none;align-items:center;justify-content:center}
Expand Down
7 changes: 0 additions & 7 deletions src/html/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,6 @@
</div>
</div>
<div id="cpnl-chart-area"></div>
<div class="npnl-spark">
<div class="npnl-spark-hd">
<span class="npnl-spark-lbl">Realised P&amp;L</span>
<span class="npnl-spark-val" id="npnl-val">—</span>
</div>
<div id="npnl-chart-area"></div>
</div>
</div>

<!-- ASSET FILTER TABS -->
Expand Down
124 changes: 0 additions & 124 deletions src/js/07-render-charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,130 +356,6 @@ function rCpnlChart() {
if (dotEl) dotEl.style.display = 'block';
if (ttEl) ttEl.style.display = 'none';
});

// ── REALISED P&L SPARKLINE ────────────────────────────────────
const { lots: cpnlLotsByAsset } = compute('ALL');
const lotByTradeId = {};
Object.keys(cpnlLotsByAsset).forEach(a => {
cpnlLotsByAsset[a].forEach(lot => {
lot.tradeIds.forEach(tid => { lotByTradeId[tid] = lot; });
});
});

const netEvts = [];
trades.forEach(t => {
if (t.type === 'HOLDING') return;
if (t.outcome === 'OPEN') return;
const ed = t.expiry || t.date;
if (!ed) return;
let delta = (t.premium || 0) - (t.closeCost || 0);
if (t.type === 'CALL' && t.outcome === 'CALLED') {
const lot = lotByTradeId[t.id];
if (lot) delta += (t.strike - lot.costBasis) * t.size;
}
netEvts.push({ date: ed, delta });
});
netEvts.sort((a, b) => a.date.localeCompare(b.date));

let netRun = 0;
const netAllSeries = netEvts.length ? [{ date: netEvts[0].date, val: 0 }] : [];
netEvts.forEach(e => {
netRun += e.delta;
const nlast = netAllSeries[netAllSeries.length - 1];
if (nlast.date === e.date) nlast.val = netRun;
else netAllSeries.push({ date: e.date, val: netRun });
});

const nValEl = document.getElementById('npnl-val');
if (nValEl) {
nValEl.style.color = netRun >= 0 ? greenColor : redColor;
nValEl.textContent = netEvts.length ? (netRun >= 0 ? '+$' : '-$') + fmt(Math.abs(netRun)) : '—';
}

let netDisp;
if (!cutoff || !netAllSeries.length) {
netDisp = netAllSeries;
} else {
const cutStr = cutoff.toISOString().slice(0, 10);
const lastBefore = netAllSeries.filter(p => p.date < cutStr);
const baseline = lastBefore.length ? lastBefore[lastBefore.length - 1].val : 0;
const inPeriod = netAllSeries.filter(p => p.date >= cutStr);
netDisp = inPeriod.length
? [{ date: cutStr, val: baseline }, ...inPeriod]
: [{ date: cutStr, val: netRun }, { date: todayStr, val: netRun }];
}
if (netDisp.length && netDisp[netDisp.length - 1].date < todayStr) {
netDisp = [...netDisp, { date: todayStr, val: netDisp[netDisp.length - 1].val }];
}

const nArea = document.getElementById('npnl-chart-area');
if (nArea && !netEvts.length) {
nArea.innerHTML = '<div class="npnl-no-asgn">No settled trades yet &mdash; Realised P&amp;L starts at $0</div>';
} else if (nArea && netDisp.length >= 2) {
const nW = nArea.clientWidth || W;
const nH = 88;
nArea.innerHTML = '<canvas id="npnl-canvas"></canvas>';
const nCanvas = document.getElementById('npnl-canvas');
nCanvas.width = nW * DPR; nCanvas.height = nH * DPR;
nCanvas.style.width = nW + 'px'; nCanvas.style.height = nH + 'px';
const nCtx = nCanvas.getContext('2d');
nCtx.scale(DPR, DPR);

const nPad = { top: 8, right: 24, bottom: 8, left: 56 };
const ncW = nW - nPad.left - nPad.right;
const ncH = nH - nPad.top - nPad.bottom;
const nVals = netDisp.map(p => p.val);
const nMinV = Math.min(0, ...nVals);
const nMaxV = Math.max(0, ...nVals);
const nSpread = nMaxV - nMinV || 1;
const nDates = netDisp.map(p => new Date(p.date + 'T12:00:00').getTime());
const nMinD = nDates[0], nMaxD = nDates[nDates.length - 1];
const nDateSpan = nMaxD - nMinD || 1;

function nToX(d) { return nPad.left + ((d - nMinD) / nDateSpan) * ncW; }
function nToY(v) { return nPad.top + (1 - (v - nMinV) / nSpread) * ncH; }

const nPts = netDisp.map((p, i) => ({ x: nToX(nDates[i]), y: nToY(p.val) }));
const nZeroY = nToY(0);
const nLineColor = netDisp[netDisp.length - 1].val >= netDisp[0].val ? greenColor : redColor;
const nGrad = nCtx.createLinearGradient(0, nPad.top, 0, nPad.top + ncH);
nGrad.addColorStop(0, toRgba(nLineColor, 0.18));
nGrad.addColorStop(1, toRgba(nLineColor, 0));

// Zero line
nCtx.save();
nCtx.strokeStyle = bdColor; nCtx.lineWidth = 1;
nCtx.setLineDash([3, 3]); nCtx.globalAlpha = 0.4;
nCtx.beginPath(); nCtx.moveTo(nPad.left, nZeroY); nCtx.lineTo(nPad.left + ncW, nZeroY);
nCtx.stroke(); nCtx.setLineDash([]); nCtx.globalAlpha = 1; nCtx.restore();

// Gradient fill
nCtx.save();
nCtx.beginPath(); nCtx.moveTo(nPts[0].x, nPts[0].y);
nPts.slice(1).forEach(pt => nCtx.lineTo(pt.x, pt.y));
nCtx.lineTo(nPts[nPts.length - 1].x, Math.min(nZeroY, nPad.top + ncH));
nCtx.lineTo(nPts[0].x, Math.min(nZeroY, nPad.top + ncH));
nCtx.closePath(); nCtx.fillStyle = nGrad; nCtx.fill(); nCtx.restore();

// Line
nCtx.save();
nCtx.strokeStyle = nLineColor; nCtx.lineWidth = 1.5;
nCtx.lineJoin = 'round'; nCtx.lineCap = 'round';
nCtx.beginPath(); nCtx.moveTo(nPts[0].x, nPts[0].y);
nPts.slice(1).forEach(pt => nCtx.lineTo(pt.x, pt.y));
nCtx.stroke(); nCtx.restore();

// Y labels (min and max)
nCtx.save();
nCtx.fillStyle = muColor; nCtx.font = '10px monospace'; nCtx.textAlign = 'right';
[nMinV, nMaxV].forEach(v => {
const lbl = (v >= 0 ? '' : '-') + '$' + (Math.abs(v) >= 1000 ? (Math.abs(v)/1000).toFixed(1) + 'k' : Math.abs(v).toFixed(0));
nCtx.fillText(lbl, nPad.left - 6, nToY(v) + 3.5);
});
nCtx.restore();
} else if (nArea) {
nArea.innerHTML = '';
}
}

function rCharts(displayRows, lots) {
Expand Down
22 changes: 22 additions & 0 deletions test/integration/pnl-tiles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,28 @@ test('Realised P&L tile respects asset filter (sFilter)', (t) => {
assert.match(main, /\+\$120/, `under BTC filter expected +$120, got "${main}"`);
});

test('Hero band has no duplicate Realised sparkline (#npnl-* removed)', (t) => {
const trades = [
{ id: 1, asset: 'ETH', type: 'HOLDING', date: '2026-01-01', expiry: '',
dte: null, strike: 3000, size: 1, premium: 0, outcome: 'OPEN',
closeCost: 0, platform: 'SPOT' },
{ id: 2, asset: 'ETH', type: 'CALL', date: '2026-01-15', expiry: '2026-01-29',
dte: 14, strike: 3500, size: 1, premium: 50, outcome: 'CALLED',
closeCost: 0, platform: 'RYSK' },
];
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);

assert.strictEqual(window.document.getElementById('npnl-val'), null,
'#npnl-val (duplicate sparkline header) should be removed');
assert.strictEqual(window.document.getElementById('npnl-chart-area'), null,
'#npnl-chart-area (duplicate sparkline canvas host) should be removed');
assert.strictEqual(window.document.getElementById('npnl-canvas'), null,
'#npnl-canvas (duplicate sparkline canvas) should be removed');
assert.strictEqual(window.document.querySelector('.npnl-spark'), null,
'.npnl-spark wrapper should be removed');
});

test('Cumulative-hero sparkline header shows Realised P&L (premium + capital gain)', (t) => {
// HOLDING + CALLED → realised = 50 + 500 = 550. Hero header (#cpnl-val) should
// show +$550, proving capital gain feeds the cumulative series (not premium-only).
Expand Down
Loading