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 src/html/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
<!-- PREMIUM PNL -->
<div class="sec">
<div class="sec-hd">
<div class="sec-ttl"><span class="dot dg"></span>Premium P&amp;L</div>
<div class="sec-ttl"><span class="dot dg"></span>Premium Economics</div>
<div class="ppnl-tabs">
<button class="ppnl-tab active" id="ppnl-tab-total" onclick="setPpnlTab('total')">Total</button>
<button class="ppnl-tab" id="ppnl-tab-monthly" onclick="setPpnlTab('monthly')">Monthly</button>
Expand Down
59 changes: 0 additions & 59 deletions src/js/07-render-charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '&quot;') + '"' : '';
return '<div class="ppnl-card' + (tip ? ' has-tip' : '') + '"' + tipAttr + '>' +
Expand All @@ -454,70 +453,12 @@ function rCharts(displayRows, lots) {
(sub ? '<div class="ppnl-sub">' + sub + '</div>' : '') +
'</div>';
}
function signed(v) {
const color = v >= 0 ? 'var(--green)' : 'var(--red)';
return '<span style="color:' + color + '">' + (v >= 0 ? '+$' : '-$') + fmt(Math.abs(v)) + '</span>';
}

// 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&amp;L',
realisedStr,
s.totalCount > 0 ? 'settled events only' : '',
realisedTip),
card('Unrealised P&amp;L',
unrealisedStr,
unrealisedSub,
unrealisedTip),
card('Total P&amp;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' : '') : '',
Expand Down
183 changes: 30 additions & 153 deletions test/integration/pnl-tiles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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, /—|&mdash;|-/, `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) => {
Expand Down
Loading