diff --git a/CLAUDE.md b/CLAUDE.md index d294b7a..aeb6b98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ globals, and 17-boot.js runs an IIFE last to bootstrap the app. | `04b-lot-engine.js` | 140 | `lotNetCost(costBasis, lotPremiums, size)` and `lotEngine(assetTrades)` → `{lots, portfolioPnl, portfolioPremiums, putOnlyPnl, tradeAccounting}`. **Single source of truth** for wheel invariants (see Lot model below). Pure; dual-exported for Node. **Key invariant:** assigned-PUT premium IS credited to the new lot's `lotPremiums` | | `05-compute.js` | 89 | `compute(assetFilter)` → `{streams, lots, allRows, displayRows}`. Cross-asset orchestrator: per-asset grouping, calls `lotEngine`, applies asset filter, sorts, assigns idx, derives display fields (`returnPct`, `monthly`, `annual`, `lotPnl`). Dual-exports `compute` for Node tests (reads `trades`/`lotEngine` from globals — set them before `require`) | | `05a-merge-open-lots.js` | 113 | `mergeOpenLots(trades, asset)` → `trades'`. Pure helper that merges all open lots for one asset (size-weighted `costBasis`, summed `lotPremiums`, earliest opener kept, CALL `lotNum` cleared). Prefers `lotEngine`, falls back to `compute` or a HOLDING/ASSIGNED heuristic for Node tests | -| `05b-pnl.js` | 90 | `computePnl(trades, assetFilter, livePrices)` → `{ realised, unrealised, total, missingSpotAssets, realisedSeries }`. Cash-flow-lens P&L calculator. Realised: `Σ settled netPrem + Σ (strike − costBasis) × calledSize` (open contributions are zero). Unrealised: `Σ over open lots of (spot − costBasis) × size`, marked against raw `costBasis` (never `netCost`); assets missing spot are excluded from the sum and reported in `missingSpotAssets`. Total = Realised + Unrealised. HOLDING- and ASSIGNED-originated lots are treated symmetrically in both paths. Pure; dual-exported. ADR: `docs/adr/0003-pnl-cash-flow-lens.md` | +| `05b-pnl.js` | 90 | `computePnl(trades, assetFilter, livePrices)` → `{ realised, unrealised, total, missingSpotAssets, realisedSeries, realisedByMonth }`. Cash-flow-lens P&L calculator. Realised: `Σ settled netPrem + Σ (strike − costBasis) × calledSize` (open contributions are zero). Unrealised: `Σ over open lots of (spot − costBasis) × size`, marked against raw `costBasis` (never `netCost`); assets missing spot are excluded from the sum and reported in `missingSpotAssets`. Total = Realised + Unrealised. HOLDING- and ASSIGNED-originated lots are treated symmetrically in both paths. Pure; dual-exported. ADR: `docs/adr/0003-pnl-cash-flow-lens.md` | | `06-render-table.js` | 452 | `sortOpen/sortHist`, `renderExpiryTable` (today badge + mobile cards), `fetchExpiryPrices` (CoinGecko, calls full `render()` on success), `rTable` (holdings cards, open & history tables, history filter application), `rStats` (just delegates to `renderExpiryTable`), `exportHistoryCSV` (downloads filtered history as CSV) | | `07-render-charts.js` | 640 | `setCpnlPeriod` (1M/3M/ALL), `rCpnlChart` (cumulative Realised P&L hero — sources `realisedSeries` from `computePnl` — plus secondary Realised sparkline), `rCharts` (Premium P&L total/monthly tabs — Total tab consumes `computePnl` for the Realised tile), `cOpts` (Chart.js options factory) | | `08-render.js` | 7 | `render()` — orchestrator: `compute → rStats → rTable → rCharts` | diff --git a/src/js/05b-pnl.js b/src/js/05b-pnl.js index 4272765..eb2017a 100644 --- a/src/js/05b-pnl.js +++ b/src/js/05b-pnl.js @@ -26,6 +26,7 @@ function computePnl(trades, assetFilter, livePrices) { let unrealised = 0; const missingSpotAssets = []; const events = []; + const realisedByMonth = {}; Object.keys(byAsset).forEach(asset => { const assetTrades = byAsset[asset]; @@ -58,7 +59,10 @@ function computePnl(trades, assetFilter, livePrices) { delta += gain; } } - events.push({ date: t.expiry || t.date, delta }); + const evDate = t.expiry || t.date; + events.push({ date: evDate, delta }); + const ym = (evDate || '').slice(0, 7); + if (ym) realisedByMonth[ym] = (realisedByMonth[ym] || 0) + delta; }); }); @@ -73,7 +77,7 @@ function computePnl(trades, assetFilter, livePrices) { }); const total = realised + unrealised; - return { realised, unrealised, total, missingSpotAssets, realisedSeries }; + return { realised, unrealised, total, missingSpotAssets, realisedSeries, realisedByMonth }; } if (typeof module !== 'undefined' && module.exports) { diff --git a/src/js/07-render-charts.js b/src/js/07-render-charts.js index ec892f2..ec86680 100644 --- a/src/js/07-render-charts.js +++ b/src/js/07-render-charts.js @@ -627,6 +627,7 @@ function rCharts(displayRows, lots) { monthMap[ym].push(r); }); const months = Object.keys(monthMap).sort().reverse(); + const { realisedByMonth } = computePnl(trades, sFilter, livePrices); el.className = 'ppnl-mtbl-wrap'; if (!months.length) { @@ -637,9 +638,11 @@ function rCharts(displayRows, lots) { const rows = months.map(ym => { const s = calcStats(monthMap[ym]); const rateClass = s.returnRate === null ? '' : s.returnRate >= 70 ? ' class="rate-hi"' : s.returnRate < 50 ? ' class="rate-lo"' : ''; - const mnColor = s.netPnl >= 0 ? 'var(--green)' : 'var(--red)'; - const mnStr = s.totalCount > 0 - ? '' + (s.netPnl >= 0 ? '+$' : '-$') + fmt(Math.abs(s.netPnl)) + '' + const realisedM = realisedByMonth[ym]; + const hasRealised = realisedM !== undefined; + const mnColor = hasRealised && realisedM < 0 ? 'var(--red)' : 'var(--green)'; + const mnStr = hasRealised + ? '' + (realisedM >= 0 ? '+$' : '-$') + fmt(Math.abs(realisedM)) + '' : dash; return '' + '' + fmtMonth(ym) + '' + @@ -654,7 +657,7 @@ function rCharts(displayRows, lots) { '' + 'Month' + 'Premium' + - 'Net P&L' + + 'Realised P&L' + 'Portfolio APR' + 'Return Rate' + '' + diff --git a/test/integration/pnl-tiles.test.js b/test/integration/pnl-tiles.test.js index 1a31ad6..09ecda4 100644 --- a/test/integration/pnl-tiles.test.js +++ b/test/integration/pnl-tiles.test.js @@ -291,6 +291,53 @@ test('Holdings card Net Cost hero has tooltip + ⓘ glyph (lens disambiguation)' assert.ok(ico, 'expected ⓘ glyph next to the Net Cost label'); }); +test('Monthly tab header reads "Realised P&L" (renamed from Net P&L)', (t) => { + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + dte: 14, strike: 50000, size: 0.1, premium: 120, outcome: 'EXPIRED', + closeCost: 0, platform: 'RYSK' }, + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + + window.setPpnlTab('monthly'); + + const headers = Array.from(window.document.querySelectorAll('.ppnl-mtbl thead th')) + .map(th => th.textContent.trim()); + assert.ok(headers.some(h => /Realised P&L/i.test(h)), + `expected "Realised P&L" header, got ${JSON.stringify(headers)}`); + assert.ok(!headers.some(h => /^Net P&L$/i.test(h)), + `"Net P&L" header should be gone, got ${JSON.stringify(headers)}`); +}); + +test('Monthly tab Realised value comes from computePnl (HOLDING + CALLED → premium + cap gain)', (t) => { + // HOLDING ETH at 3000 size 1 (Jan), then CALL at 3500 size 1 premium 50, called Feb. + // Cash-flow Realised for Feb = 50 + (3500-3000)*1 = 550. Old netPnl formula would + // include +call-away notional ($3500), giving a very different number. + 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-02-01', expiry: '2026-02-15', + dte: 14, strike: 3500, size: 1, premium: 50, outcome: 'CALLED', + closeCost: 0, platform: 'RYSK' }, + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + + window.setPpnlTab('monthly'); + + // Find the Feb row by month label. + const rows = Array.from(window.document.querySelectorAll('.ppnl-mtbl tbody tr')); + const febRow = rows.find(r => /Feb/i.test(r.children[0].textContent)); + assert.ok(febRow, 'expected a Feb row in the monthly table'); + + // Realised P&L is the 3rd column (Month | Premium | Realised P&L | APR | Rate). + const realisedCell = febRow.children[2].textContent; + assert.match(realisedCell, /\+\$550/, + `expected +$550 (cash-flow Realised) in Feb row, got "${realisedCell}"`); +}); + test('Holdings card does not clip its tooltip popover (no overflow:hidden on .hcard)', () => { const fs = require('fs'); const path = require('path'); diff --git a/test/unit/pnl.test.js b/test/unit/pnl.test.js index 3579c21..dd0e34e 100644 --- a/test/unit/pnl.test.js +++ b/test/unit/pnl.test.js @@ -192,6 +192,41 @@ test('asset filter scopes unrealised + total', () => { assert.strictEqual(computePnl(trades, 'ALL', lp).unrealised, 700); }); +test('realisedByMonth: buckets settled events by expiry YYYY-MM', () => { + // Jan: PUT EXPIRED +120. Feb: HOLDING + CALL CALLED → premium 50 + cap gain 500 = 550. + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + strike: 50000, size: 0.1, premium: 120, outcome: 'EXPIRED', closeCost: 0 }, + { id: 2, asset: 'ETH', type: 'HOLDING', date: '2026-01-01', expiry: '', + strike: 3000, size: 1, premium: 0, outcome: 'OPEN', closeCost: 0 }, + { id: 3, asset: 'ETH', type: 'CALL', date: '2026-02-01', expiry: '2026-02-15', + strike: 3500, size: 1, premium: 50, outcome: 'CALLED', closeCost: 0 }, + ]; + const { realisedByMonth } = computePnl(trades); + assert.strictEqual(realisedByMonth['2026-01'], 120); + assert.strictEqual(realisedByMonth['2026-02'], 550); +}); + +test('realisedByMonth: empty when no settled events', () => { + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + strike: 50000, size: 0.1, premium: 120, outcome: 'OPEN', closeCost: 0 }, + ]; + const { realisedByMonth } = computePnl(trades); + assert.deepStrictEqual(realisedByMonth, {}); +}); + +test('realisedByMonth: respects asset filter', () => { + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + strike: 50000, size: 0.1, premium: 100, outcome: 'EXPIRED', closeCost: 0 }, + { id: 2, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + strike: 3000, size: 1, premium: 50, outcome: 'EXPIRED', closeCost: 0 }, + ]; + assert.deepStrictEqual(computePnl(trades, 'BTC').realisedByMonth, { '2026-01': 100 }); + assert.deepStrictEqual(computePnl(trades, 'ETH').realisedByMonth, { '2026-01': 50 }); +}); + test('realisedSeries: CALLED event contributes premium AND capital gain at expiry date', () => { // HOLDING at 3000 size 1, then CALL at 3500 premium 50 called on 2026-02-15. // Series should have a point on 2026-02-15 with cumulative realised = 50 + 500 = 550.