diff --git a/src/js/07-render-charts.js b/src/js/07-render-charts.js
index 91c2503..f28bd3f 100644
--- a/src/js/07-render-charts.js
+++ b/src/js/07-render-charts.js
@@ -445,7 +445,6 @@ function rCharts(displayRows, lots) {
if (sPpnlTab === 'total') {
const s = calcStats(displayRows);
- const { realised, unrealised, total, missingSpotAssets } = computePnl(trades, sFilter, livePrices);
function card(label, main, sub, tip) {
const tipAttr = tip ? ' data-tip="' + tip.replace(/"/g, '"') + '"' : '';
return '
' +
@@ -454,70 +453,12 @@ function rCharts(displayRows, lots) {
(sub ? '
' + sub + '
' : '') +
'
';
}
- function signed(v) {
- const color = v >= 0 ? 'var(--green)' : 'var(--red)';
- return '
' + (v >= 0 ? '+$' : '-$') + fmt(Math.abs(v)) + '';
- }
-
- // Open-lot inventory under the current asset filter — drives whether
- // the Unrealised tile shows a value, a partial, or sits dormant.
- const openAssets = new Set();
- Object.keys(lots).forEach(a => {
- if (sFilter !== 'ALL' && a !== sFilter) return;
- lots[a].forEach(l => { if (!l.endDate && l.size > 0) openAssets.add(a); });
- });
- const openLotsCount = openAssets.size;
- const allMissing = openLotsCount > 0 && missingSpotAssets.length === openLotsCount;
-
- const realisedStr = s.totalCount > 0 ? signed(realised) : dash;
- const realisedTip = 'Realised P&L = net premiums of settled options + capital gains on called-away lots ((strike − costBasis) × size). Open positions contribute zero.';
-
- let unrealisedStr, unrealisedSub;
- if (openLotsCount === 0) {
- unrealisedStr = dash;
- unrealisedSub = '';
- } else if (allMissing) {
- unrealisedStr = dash;
- unrealisedSub = 'spot unavailable for ' + missingSpotAssets.join(', ');
- } else {
- unrealisedStr = signed(unrealised);
- unrealisedSub = missingSpotAssets.length
- ? 'spot unavailable for ' + missingSpotAssets.join(', ')
- : 'mark-to-market on open lots';
- }
- const unrealisedTip = 'Unrealised P&L = Σ over open lots of (spot − costBasis) × size. Marks to market against raw cost basis (not net cost). Updates whenever spot refreshes.';
-
- let totalStr, totalSub;
- if (s.totalCount === 0 && openLotsCount === 0) {
- totalStr = dash; totalSub = '';
- } else if (allMissing) {
- totalStr = dash;
- totalSub = 'spot unavailable for ' + missingSpotAssets.join(', ');
- } else {
- totalStr = signed(total);
- totalSub = missingSpotAssets.length
- ? 'partial — spot missing: ' + missingSpotAssets.join(', ')
- : 'realised + unrealised';
- }
- const totalTip = 'Total P&L = Realised + Unrealised. The full picture if every open lot were sold at current spot right now.';
el.className = 'ppnl-cards';
el.innerHTML = [
card('Total Premium Collected',
s.totalCount > 0 ? '$' + fmt(s.totalPrem) : dash,
s.totalCount > 0 ? pos(s.settled) + ' settled' + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : '',
'Sum of every option premium collected (gross of buy-to-close costs). Includes settled and open positions.'),
- card('Realised P&L',
- realisedStr,
- s.totalCount > 0 ? 'settled events only' : '',
- realisedTip),
- card('Unrealised P&L',
- unrealisedStr,
- unrealisedSub,
- unrealisedTip),
- card('Total P&L',
- totalStr,
- totalSub,
- totalTip),
card('Total Notional',
s.totalNotional > 0 ? '$' + fmt(s.totalNotional) : dash,
s.totalCount > 0 ? pos(s.totalCount) + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : '',
diff --git a/test/integration/pnl-tiles.test.js b/test/integration/pnl-tiles.test.js
index ff4582b..7476ef1 100644
--- a/test/integration/pnl-tiles.test.js
+++ b/test/integration/pnl-tiles.test.js
@@ -72,50 +72,35 @@ test('Return Rate tile has tooltip + ⓘ glyph', (t) => {
assertHasTooltip(findCard(window, /Return Rate/i), /OTM|expired/i);
});
-test('Realised P&L tile renders settled premium total', (t) => {
+test('Total tab no longer renders Realised/Unrealised/Total P&L cards (issue #40)', (t) => {
+ // Per #40, the hero band is the canonical Realised/Unrealised/Total surface.
+ // The Total tab must not duplicate them.
const trades = [
- // BTC PUT expired → +120
- { 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' },
- // ETH PUT expired → +80
- { id: 2, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15',
- dte: 14, strike: 3000, size: 1, premium: 80, outcome: 'EXPIRED',
+ { id: 1, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15',
+ dte: 14, strike: 2800, size: 1, premium: 100, outcome: 'EXPIRED',
closeCost: 0, platform: 'RYSK' },
+ { id: 2, asset: 'ETH', type: 'HOLDING', date: '2026-01-01', expiry: '',
+ dte: null, strike: 3000, size: 1, premium: 0, outcome: 'OPEN',
+ closeCost: 0, platform: 'SPOT' },
];
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);
- // Default sFilter is 'ALL' — both assets contribute.
- const card = findRealisedCard(window);
- assert.ok(card, 'Realised P&L card should exist on Total tab');
-
- const main = card.querySelector('.ppnl-main').textContent;
- assert.match(main, /\+\$200/, `expected +$200, got "${main}"`);
-
- // Styled popover present; native title= dropped (it produced a duplicate
- // slow browser tooltip on top of the styled one).
- assert.match(card.getAttribute('data-tip') || '', /Realised P&L/);
- assert.strictEqual(card.getAttribute('title'), null);
-});
-
-test('Realised P&L tile respects asset filter (sFilter)', (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' },
- { id: 2, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15',
- dte: 14, strike: 3000, size: 1, premium: 80, outcome: 'EXPIRED',
- closeCost: 0, platform: 'RYSK' },
- ];
- const { window, teardown } = setupJsdom({ trades });
- t.after(teardown);
+ window.livePrices = { ETH: 3500 };
+ window.render();
- window.setFilter('BTC');
+ assert.strictEqual(findRealisedCard(window), null,
+ 'Realised P&L card should be removed from Total tab');
+ assert.strictEqual(findUnrealisedCard(window), null,
+ 'Unrealised P&L card should be removed from Total tab');
+ assert.strictEqual(findTotalCard(window), null,
+ 'Total P&L card should be removed from Total tab');
- const card = findRealisedCard(window);
- const main = card.querySelector('.ppnl-main').textContent;
- assert.match(main, /\+\$120/, `under BTC filter expected +$120, got "${main}"`);
+ // The four kept cards remain.
+ assert.ok(findCard(window, /Total Premium Collected/i));
+ assert.ok(findCard(window, /Total Notional/i));
+ assert.ok(findCard(window, /Portfolio APR/i));
+ assert.ok(findCard(window, /Return Rate/i));
});
test('Hero band has no duplicate Realised sparkline (#npnl-* removed)', (t) => {
@@ -159,129 +144,21 @@ test('Cumulative-hero sparkline header shows Realised P&L (premium + capital gai
assert.match(heroVal.textContent, /\+\$550/, `expected +$550 in cumulative hero, got "${heroVal.textContent}"`);
});
-test('CALL CALLED on HOLDING lot contributes capital gain to Realised tile', (t) => {
- // HOLDING ETH at 3000 size 1, then CALL at 3500 size 1 premium 50, called.
- // Realised = 50 + (3500-3000)*1 = 550
+test('Premium P&L section is renamed to "Premium Economics" (issue #40)', (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);
-
- // Default sFilter='ALL' — single-asset trade set sums to expected total.
- const card = findRealisedCard(window);
- const main = card.querySelector('.ppnl-main').textContent;
- assert.match(main, /\+\$550/, `expected +$550, got "${main}"`);
-});
-
-test('Unrealised P&L tile marks open lots to market against costBasis', (t) => {
- // HOLDING ETH at 3000 size 1, spot 3500 → unrealised = 500.
- 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' },
- ];
- const { window, teardown } = setupJsdom({ trades });
- t.after(teardown);
-
- window.livePrices = { ETH: 3500 };
- window.render();
-
- const card = findUnrealisedCard(window);
- assert.ok(card, 'Unrealised P&L card should exist');
- const main = card.querySelector('.ppnl-main').textContent;
- assert.match(main, /\+\$500/, `expected +$500, got "${main}"`);
- assert.match(card.getAttribute('data-tip') || '', /Unrealised P&L/);
- assert.strictEqual(card.getAttribute('title'), null);
-});
-
-test('Total P&L tile = Realised + Unrealised', (t) => {
- // PUT EXPIRED netPrem 100 + HOLDING ETH 3000 size 1 spot 3500 → total = 100 + 500 = 600.
- const trades = [
- { id: 1, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15',
- dte: 14, strike: 2800, size: 1, premium: 100, outcome: 'EXPIRED',
+ { 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' },
- { id: 2, asset: 'ETH', type: 'HOLDING', date: '2026-01-01', expiry: '',
- dte: null, strike: 3000, size: 1, premium: 0, outcome: 'OPEN',
- closeCost: 0, platform: 'SPOT' },
- ];
- const { window, teardown } = setupJsdom({ trades });
- t.after(teardown);
-
- window.livePrices = { ETH: 3500 };
- window.render();
-
- const card = findTotalCard(window);
- assert.ok(card, 'Total P&L card should exist');
- const main = card.querySelector('.ppnl-main').textContent;
- assert.match(main, /\+\$600/, `expected +$600, got "${main}"`);
- assert.match(card.getAttribute('data-tip') || '', /Total P&L/);
- assert.strictEqual(card.getAttribute('title'), null);
-});
-
-test('Unrealised tile shows dash + spot-unavailable sub-line when spot missing', (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' },
- ];
- const { window, teardown } = setupJsdom({ trades });
- t.after(teardown);
-
- // livePrices stays {} (fetchExpiryPrices stubbed, never resolves).
- const card = findUnrealisedCard(window);
- const main = card.querySelector('.ppnl-main').textContent;
- const sub = card.querySelector('.ppnl-sub').textContent;
- assert.match(main, /—|—|-/, `expected dash main, got "${main}"`);
- assert.match(sub, /spot unavailable.*ETH/i, `expected spot-unavailable sub, got "${sub}"`);
-});
-
-test('Unrealised tile partial: sums available, sub-line lists missing', (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: 'BTC', type: 'HOLDING', date: '2026-01-01', expiry: '',
- dte: null, strike: 50000, size: 0.1, premium: 0, outcome: 'OPEN',
- closeCost: 0, platform: 'SPOT' },
];
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);
- window.livePrices = { ETH: 3500 }; // BTC missing
- window.render();
-
- const card = findUnrealisedCard(window);
- const main = card.querySelector('.ppnl-main').textContent;
- const sub = card.querySelector('.ppnl-sub').textContent;
- assert.match(main, /\+\$500/, `expected +$500 (ETH only), got "${main}"`);
- assert.match(sub, /BTC/, `expected BTC in sub-line, got "${sub}"`);
-});
-
-test('Unrealised + Total tiles respect asset filter', (t) => {
- const trades = [
- { id: 1, asset: 'BTC', type: 'HOLDING', date: '2026-01-01', expiry: '',
- dte: null, strike: 50000, size: 0.1, premium: 0, outcome: 'OPEN',
- closeCost: 0, platform: 'SPOT' },
- { id: 2, asset: 'ETH', type: 'HOLDING', date: '2026-01-01', expiry: '',
- dte: null, strike: 3000, size: 1, premium: 0, outcome: 'OPEN',
- closeCost: 0, platform: 'SPOT' },
- ];
- const { window, teardown } = setupJsdom({ trades });
- t.after(teardown);
-
- window.livePrices = { BTC: 52000, ETH: 3500 };
- window.setFilter('BTC');
-
- const card = findUnrealisedCard(window);
- const main = card.querySelector('.ppnl-main').textContent;
- // BTC: (52000-50000)*0.1 = 200
- assert.match(main, /\+\$200/, `under BTC filter expected +$200, got "${main}"`);
+ const titles = Array.from(window.document.querySelectorAll('.sec-ttl'))
+ .map(el => el.textContent.trim());
+ assert.ok(titles.some(t => /Premium Economics/i.test(t)),
+ `expected a section titled "Premium Economics", got ${JSON.stringify(titles)}`);
+ assert.ok(!titles.some(t => /^Premium P&L$/i.test(t)),
+ `"Premium P&L" section title should be gone, got ${JSON.stringify(titles)}`);
});
test('Holdings card Net Cost hero has tooltip + ⓘ glyph (lens disambiguation)', (t) => {