From 712f71be4eb5ad9324587e7bc8f9e333c354dd07 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Wed, 6 May 2026 22:10:58 +0800 Subject: [PATCH] Drop duplicate P&L cards, rename section to Premium Economics (closes #40) Hero band is the canonical Realised/Unrealised/Total surface (per ADR 0004). Remove the duplicated cards from the Total tab and rename the section to reflect what it uniquely answers: premium economics. Co-Authored-By: Claude Opus 4.7 --- src/html/body.html | 2 +- src/js/07-render-charts.js | 59 ---------- test/integration/pnl-tiles.test.js | 183 +++++------------------------ 3 files changed, 31 insertions(+), 213 deletions(-) diff --git a/src/html/body.html b/src/html/body.html index 76d33a5..166958a 100644 --- a/src/html/body.html +++ b/src/html/body.html @@ -97,7 +97,7 @@
-
Premium P&L
+
Premium Economics
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) => {