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
14 changes: 8 additions & 6 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ tr.lot-child.la-sol:hover > td{background:rgba(153,69,255,.08)}
.holdings-sec{margin-bottom:0}
.holdings-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:12px}
.holdings-merges{margin-top:12px;display:flex;gap:8px;justify-content:flex-end}
.hcard{background:var(--s2);border:1px solid var(--bd2);position:relative;overflow:hidden}
.hcard{background:var(--s2);border:1px solid var(--bd2);position:relative}
.hcard-btc{border-left:3px solid var(--btc)}
.hcard-eth{border-left:3px solid var(--eth)}
.hcard-hype{border-left:3px solid var(--hype)}
Expand Down Expand Up @@ -288,12 +288,14 @@ tr.lot-child.la-sol:hover > td{background:rgba(153,69,255,.08)}
.ppnl-tab.active{border-color:var(--green);color:var(--green)}
.ppnl-cards{padding:16px 20px 20px;display:grid;grid-template-columns:repeat(3,minmax(0,340px));gap:12px;justify-content:center}
.ppnl-card{background:var(--s2);border:1px solid var(--bd);border-radius:10px;padding:14px 16px;position:relative}
.ppnl-card.has-tip{cursor:help}
.ppnl-tip-ico{color:var(--mu);font-size:.75rem;margin-left:4px;opacity:.7}

/* Generic styled-popover tooltip. Opt in by adding class="has-tip" + data-tip="…" */
.has-tip{position:relative;cursor:help}
.tip-ico,.ppnl-tip-ico{color:var(--mu);font-size:.75rem;margin-left:4px;opacity:.7}
.has-tip:hover .tip-ico,.has-tip:hover .ppnl-tip-ico{opacity:1;color:var(--text)}
.ppnl-card.has-tip:hover{border-color:var(--mu)}
.ppnl-card.has-tip:hover .ppnl-tip-ico{opacity:1;color:var(--text)}
.ppnl-card.has-tip::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);background:#0c1119;color:var(--text);border:1px solid var(--bd);border-radius:8px;padding:8px 10px;font-family:var(--mono);font-size:.7rem;line-height:1.45;width:max-content;max-width:300px;white-space:normal;text-align:left;box-shadow:0 8px 24px rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .12s ease;z-index:20}
.ppnl-card.has-tip:hover::after{opacity:1}
.has-tip::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);background:#0c1119;color:var(--text);border:1px solid var(--bd);border-radius:8px;padding:8px 10px;font-family:var(--mono);font-size:.7rem;line-height:1.45;width:max-content;max-width:300px;white-space:normal;text-align:left;box-shadow:0 8px 24px rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .12s ease;z-index:20}
.has-tip:hover::after{opacity:1}
.ppnl-lbl{font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:1.1px;color:var(--mu);font-family:var(--mono);margin-bottom:10px}
.ppnl-main{font-size:1.05rem;font-weight:700;color:var(--text);font-family:var(--mono);line-height:1.1}
.ppnl-sub{font-size:.65rem;color:var(--mu);font-family:var(--mono);margin-top:4px}
Expand Down
4 changes: 2 additions & 2 deletions src/js/06-render-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,8 @@ function rTable(displayRows, streams, lots) {
+ editBtn
+ '</div>'
+ '</div>'
+ '<div class="hcard-hero">'
+ '<div class="hcard-hero-lbl">Net Cost / ' + a + '</div>'
+ '<div class="hcard-hero has-tip" data-tip="Net Cost = costBasis − (lotPremiums / size). A premium-reduced entry-price lens — what you effectively paid per token after the wheel premiums worked for you. Different from Unrealised P&amp;L, which marks the lot to spot against raw costBasis (not netCost).">'
+ '<div class="hcard-hero-lbl">Net Cost / ' + a + ' <span class="tip-ico" aria-hidden="true">&#9432;</span></div>'
+ '<div class="hcard-hero-val">$' + fmt(nc) + '</div>'
+ '<div class="hcard-hero-sub">basis $' + fmt(lot.costBasis) + ' &mdash; saved <span>$' + fmt(reduction) + ' (' + reductionPct.toFixed(1) + '%)</span></div>'
+ '</div>'
Expand Down
16 changes: 9 additions & 7 deletions src/js/07-render-charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -530,9 +530,7 @@ function rCharts(displayRows, lots) {
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;') + '" title="' + tip.replace(/"/g, '&quot;') + '"'
: '';
const tipAttr = tip ? ' data-tip="' + tip.replace(/"/g, '&quot;') + '"' : '';
return '<div class="ppnl-card' + (tip ? ' has-tip' : '') + '"' + tipAttr + '>' +
'<div class="ppnl-lbl">' + label + (tip ? ' <span class="ppnl-tip-ico" aria-hidden="true">&#9432;</span>' : '') + '</div>' +
'<div class="ppnl-main">' + main + '</div>' +
Expand Down Expand Up @@ -589,7 +587,8 @@ function rCharts(displayRows, lots) {
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' : '') : ''),
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' : '',
Expand All @@ -604,13 +603,16 @@ function rCharts(displayRows, lots) {
totalTip),
card('Total Notional',
s.totalNotional > 0 ? '$' + fmt(s.totalNotional) : dash,
s.totalCount > 0 ? pos(s.totalCount) + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : ''),
s.totalCount > 0 ? pos(s.totalCount) + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : '',
'Total Notional = Σ strike × size across every option (settled and open). The capital you would tie up if every put were assigned at strike.'),
card('Portfolio APR',
s.portfolioAPR !== null ? s.portfolioAPR.toFixed(1) + '%' : dash,
s.settled > 0 ? 'notional-weighted · ' + s.settled + ' settled' : ''),
s.settled > 0 ? 'notional-weighted · ' + s.settled + ' settled' : '',
'Notional-weighted average APR of settled options. Per-option APR = (netPrem / collateral) / DTE × 365. Open options excluded.'),
card('Return Rate',
s.returnRate !== null ? s.returnRate.toFixed(1) + '%' : dash,
s.settled > 0 ? s.otmCount + ' / ' + s.settled + ' exp OTM' : ''),
s.settled > 0 ? s.otmCount + ' / ' + s.settled + ' exp OTM' : '',
'Share of settled options that expired OTM (premium kept, no assignment/call-away). Open options excluded.'),
].join('');

} else {
Expand Down
110 changes: 106 additions & 4 deletions test/integration/pnl-tiles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,63 @@ function findRealisedCard(window) { return findCard(window, /Realised P&L/i); }
function findUnrealisedCard(window) { return findCard(window, /Unrealised P&L/i); }
function findTotalCard(window) { return findCard(window, /^Total P&L/i); }

function assertHasTooltip(card, tipPattern) {
const tip = card.getAttribute('data-tip') || '';
if (!tipPattern.test(tip)) {
throw new Error('expected data-tip to match ' + tipPattern + ', got "' + tip + '"');
}
const lbl = card.querySelector('.ppnl-lbl');
const ico = lbl && lbl.querySelector('.ppnl-tip-ico');
if (!ico) throw new Error('expected ⓘ glyph (.ppnl-tip-ico) inside .ppnl-lbl');
}

test('Total Premium Collected tile has tooltip + ⓘ glyph', (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);

const card = findCard(window, /Total Premium Collected/i);
assert.ok(card, 'Total Premium Collected card should exist');
assertHasTooltip(card, /premium/i);
});

test('Total Notional tile has tooltip + ⓘ glyph', (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);
assertHasTooltip(findCard(window, /Total Notional/i), /notional|strike.*size/i);
});

test('Portfolio APR tile has tooltip + ⓘ glyph', (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);
assertHasTooltip(findCard(window, /Portfolio APR/i), /APR|annualised|annualized/i);
});

test('Return Rate tile has tooltip + ⓘ glyph', (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);
assertHasTooltip(findCard(window, /Return Rate/i), /OTM|expired/i);
});

test('Realised P&L tile renders settled premium total', (t) => {
const trades = [
// BTC PUT expired → +120
Expand All @@ -36,8 +93,10 @@ test('Realised P&L tile renders settled premium total', (t) => {
const main = card.querySelector('.ppnl-main').textContent;
assert.match(main, /\+\$200/, `expected +$200, got "${main}"`);

// Tooltip present.
assert.match(card.getAttribute('title') || '', /Realised P&L/);
// 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) => {
Expand Down Expand Up @@ -115,7 +174,8 @@ test('Unrealised P&L tile marks open lots to market against costBasis', (t) => {
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('title') || '', /Unrealised P&L/);
assert.match(card.getAttribute('data-tip') || '', /Unrealised P&L/);
assert.strictEqual(card.getAttribute('title'), null);
});

test('Total P&L tile = Realised + Unrealised', (t) => {
Expand All @@ -138,7 +198,8 @@ test('Total P&L tile = Realised + Unrealised', (t) => {
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('title') || '', /Total P&L/);
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) => {
Expand Down Expand Up @@ -200,3 +261,44 @@ test('Unrealised + Total tiles respect asset filter', (t) => {
// BTC: (52000-50000)*0.1 = 200
assert.match(main, /\+\$200/, `under BTC filter expected +$200, got "${main}"`);
});

test('Holdings card Net Cost hero has tooltip + ⓘ glyph (lens disambiguation)', (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);

// The Net Cost hero block — find by its label.
const heros = window.document.querySelectorAll('.hcard-hero');
let hero = null;
for (const h of heros) {
const lbl = h.querySelector('.hcard-hero-lbl');
if (lbl && /Net Cost/i.test(lbl.textContent)) { hero = h; break; }
}
assert.ok(hero, 'expected Net Cost hero block on the holdings card');

// Popover wired via the same data-tip + has-tip pattern as the PnL tiles,
// explaining the lens difference vs Unrealised P&L.
assert.ok(hero.classList.contains('has-tip'), 'hero should opt into has-tip styling');
const tip = hero.getAttribute('data-tip') || '';
assert.match(tip, /Unrealised|mark-to-market/i,
'tooltip should disambiguate Net Cost lens from Unrealised P&L lens');
// ⓘ glyph rendered inside the label, matching the PnL-tile affordance.
const ico = hero.querySelector('.tip-ico, .ppnl-tip-ico');
assert.ok(ico, 'expected ⓘ glyph next to the Net Cost label');
});

test('Holdings card does not clip its tooltip popover (no overflow:hidden on .hcard)', () => {
const fs = require('fs');
const path = require('path');
const css = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'css', 'styles.css'), 'utf8');
// Match the .hcard rule (not .hcard-foo) and check it doesn't set overflow:hidden,
// which would clip the .has-tip popover anchored to the .hcard-hero inside.
const m = css.match(/\.hcard\s*\{([^}]*)\}/);
assert.ok(m, '.hcard rule should exist');
assert.doesNotMatch(m[1], /overflow\s*:\s*hidden/i,
'.hcard must not set overflow:hidden — would clip Net Cost tooltip');
});
Loading