diff --git a/CONTEXT.md b/CONTEXT.md index ba3577a..a757966 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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: ` 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 diff --git a/docs/adr/0004-hero-realised-line-total-tile.md b/docs/adr/0004-hero-realised-line-total-tile.md new file mode 100644 index 0000000..9f5b95f --- /dev/null +++ b/docs/adr/0004-hero-realised-line-total-tile.md @@ -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: `. 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. diff --git a/src/css/styles.css b/src/css/styles.css index bf53c2e..303c6a1 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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} diff --git a/src/html/body.html b/src/html/body.html index 5289e8a..7408aa8 100644 --- a/src/html/body.html +++ b/src/html/body.html @@ -48,13 +48,6 @@
-
-
- Realised P&L - -
-
-
diff --git a/src/js/07-render-charts.js b/src/js/07-render-charts.js index ec86680..79e6660 100644 --- a/src/js/07-render-charts.js +++ b/src/js/07-render-charts.js @@ -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 = '
No settled trades yet — Realised P&L starts at $0
'; - } else if (nArea && netDisp.length >= 2) { - const nW = nArea.clientWidth || W; - const nH = 88; - nArea.innerHTML = ''; - 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) { diff --git a/test/integration/pnl-tiles.test.js b/test/integration/pnl-tiles.test.js index 09ecda4..ff4582b 100644 --- a/test/integration/pnl-tiles.test.js +++ b/test/integration/pnl-tiles.test.js @@ -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).