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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
8 changes: 6 additions & 2 deletions src/js/05b-pnl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
});
});

Expand All @@ -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) {
Expand Down
11 changes: 7 additions & 4 deletions src/js/07-render-charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
? '<span style="color:' + mnColor + '">' + (s.netPnl >= 0 ? '+$' : '-$') + fmt(Math.abs(s.netPnl)) + '</span>'
const realisedM = realisedByMonth[ym];
const hasRealised = realisedM !== undefined;
const mnColor = hasRealised && realisedM < 0 ? 'var(--red)' : 'var(--green)';
const mnStr = hasRealised
? '<span style="color:' + mnColor + '">' + (realisedM >= 0 ? '+$' : '-$') + fmt(Math.abs(realisedM)) + '</span>'
: dash;
return '<tr>' +
'<td>' + fmtMonth(ym) + '</td>' +
Expand All @@ -654,7 +657,7 @@ function rCharts(displayRows, lots) {
'<thead><tr>' +
'<th>Month</th>' +
'<th>Premium</th>' +
'<th>Net P&amp;L</th>' +
'<th>Realised P&amp;L</th>' +
'<th>Portfolio APR</th>' +
'<th>Return Rate</th>' +
'</tr></thead>' +
Expand Down
47 changes: 47 additions & 0 deletions test/integration/pnl-tiles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
35 changes: 35 additions & 0 deletions test/unit/pnl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading