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
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ globals, and 17-boot.js runs an IIFE last to bootstrap the app.
| `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, 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` |
| `05c-outcome-distribution.js` | 32 | `outcomeDistribution(trades, assetFilter)` → `[{outcome, count, premium}]`. Pure helper for the Position History outcome treemap. Excludes OPEN, orders EXPIRED/ASSIGNED/CALLED/CLOSED, nets `closeCost` from CLOSED premium (cash-flow lens). Dual-exported |
| `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) |
| `06a-render-outcome-chart.js` | 75 | `rOutcomeChart()` — renders a horizontal treemap of `outcomeDistribution` into `#hist-outchart` when ≥10 settled trades; otherwise hides itself and shows `#hist-pills`. Each cell click toggles `setHistOutcome`. Cells coloured via CSS vars (EXPIRED/ASSIGNED/CALLED/CLOSED → green/red/orange/blue) |
| `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` |
| `08-render.js` | 8 | `render()` — orchestrator: `compute → rStats → rTable → rOutcomeChart → rCharts` |
| `09-drawer-modal.js` | 15 | `openTradeDrawer`, `closeTradeDrawer`, `focusForm` |
| `10-reset-modal.js` | 4 | `showReset`, `closeReset`, `doReset` (wipes `trades`) |
| `11-wallet-popup.js` | 34 | `showWalletPopup`, `hideWalletPopup`, `submitWalletPopup` (first-visit wallet entry) |
Expand Down Expand Up @@ -235,6 +237,10 @@ CSS vars so adding themes later remains cheap.

## Recently added (May 2026)

- **Position History outcome treemap** (`06a-render-outcome-chart.js` +
`05c-outcome-distribution.js`): full-width treemap replaces outcome pills at
≥10 settled trades. Cells sized by count, colored per outcome, click to
filter via existing `setHistOutcome`. Pills remain below threshold.
- **Holdings cards** (`06-render-table.js`): live spot, unrealized P&L vs net
cost, "next call ≥ $X to stay above net cost" hint
- **Expiring This Week**: today badge in section header, quick-action buttons
Expand Down
14 changes: 14 additions & 0 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -754,3 +754,17 @@ td.td-act { width:1px; white-space:nowrap; padding-right:12px; }
.hist-clear-btn:hover{color:var(--red);border-color:var(--rb)}
.hist-export-btn{font-family:var(--mono);font-size:.6rem;letter-spacing:1px;text-transform:uppercase;padding:5px 10px;background:transparent;color:var(--mu);border:1px solid var(--bd);cursor:pointer}
.hist-export-btn:hover{color:var(--green);border-color:var(--gb)}

/* ── HISTORY OUTCOME CHART (TREEMAP) ── */
.hist-outchart{padding:12px 0 14px;border-bottom:1px solid var(--bd)}
.outchart-meta{display:flex;justify-content:space-between;align-items:baseline;font-size:.55rem;letter-spacing:1.2px;color:var(--mu);text-transform:uppercase;margin-bottom:8px}
.outchart-meta b{color:var(--text);font-size:.85rem;font-weight:700;letter-spacing:0;text-transform:none;font-variant-numeric:tabular-nums}
.outchart-treemap{display:flex;width:100%;height:140px;border:1px solid var(--bd2);background:var(--s2);overflow:hidden;gap:2px}
.outchart-cell{display:flex;flex-direction:column;justify-content:center;align-items:center;cursor:pointer;color:var(--bg);font-weight:700;transition:filter .15s,opacity .15s;padding:8px;overflow:hidden;text-align:center;min-width:0}
.outchart-cell:hover{filter:brightness(1.15)}
.outchart-cell.active{outline:2px solid var(--text);outline-offset:-2px}
.outchart-cell.dim{opacity:.45}
.outchart-out{font-size:.62rem;letter-spacing:1.5px;text-transform:uppercase;opacity:.85;line-height:1;font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.outchart-cnt{font-size:1.6rem;font-weight:800;letter-spacing:-1px;line-height:1.1;margin:4px 0;font-variant-numeric:tabular-nums}
.outchart-prem{font-size:.62rem;letter-spacing:.5px;opacity:.9;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.outchart-tip{position:fixed;pointer-events:none;background:var(--s3);border:1px solid var(--bd2);padding:6px 10px;font-size:.62rem;color:var(--text);letter-spacing:.5px;z-index:1000;white-space:nowrap;font-family:var(--mono);display:none}
3 changes: 2 additions & 1 deletion src/html/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@
</div>
<!-- Position History -->
<div class="tlog-sub-hd" style="margin-top:24px"><span>Position History</span><span id="hcnt" class="tlog-cnt"></span></div>
<div class="hist-outchart" id="hist-outchart" style="display:none"></div>
<div class="hist-filters">
<div class="hist-filter-group">
<div class="hist-filter-group" id="hist-pills">
<span class="hist-filter-lbl">Outcome</span>
<button class="hist-out-btn active" id="ho-ALL" onclick="setHistOutcome('ALL')">All</button>
<button class="hist-out-btn" id="ho-EXPIRED" onclick="setHistOutcome('EXPIRED')">Expired</button>
Expand Down
31 changes: 31 additions & 0 deletions src/js/05c-outcome-distribution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// ── OUTCOME DISTRIBUTION ──────────────────────────────────────
// Pure helper: aggregates settled trades by outcome for the
// Position History donut. Excludes OPEN. Cash-flow lens: CLOSED
// premium is netted by closeCost.
//
// Returns [{outcome, count, premium}] in canonical order
// EXPIRED, ASSIGNED, CALLED, CLOSED. Outcomes with zero trades
// are omitted.

const _OUTCOME_ORDER = ['EXPIRED', 'ASSIGNED', 'CALLED', 'CLOSED'];

function outcomeDistribution(trades, assetFilter) {
const filtered = (assetFilter && assetFilter !== 'ALL')
? trades.filter(t => t.asset === assetFilter)
: trades;

const acc = {};
filtered.forEach(t => {
if (!_OUTCOME_ORDER.includes(t.outcome)) return;
const netPrem = (t.premium || 0) - (t.closeCost || 0);
const slot = acc[t.outcome] || (acc[t.outcome] = { outcome: t.outcome, count: 0, premium: 0 });
slot.count += 1;
slot.premium += netPrem;
});

return _OUTCOME_ORDER.filter(o => acc[o]).map(o => acc[o]);
}

if (typeof module !== 'undefined' && module.exports) {
module.exports = { outcomeDistribution };
}
73 changes: 73 additions & 0 deletions src/js/06a-render-outcome-chart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// ── HISTORY OUTCOME CHART (TREEMAP) ───────────────────────────
// Renders a horizontal treemap of settled-outcome distribution
// above the Position History table when there are >= 10 settled
// trades (excluding OPEN). Below threshold the existing pills
// remain. Each cell toggles the outcome filter via setHistOutcome.

const _OUTCHART_THRESHOLD = 10;
const _OUTCHART_COLORS = {
EXPIRED: 'var(--green)',
ASSIGNED: 'var(--red)',
CALLED: 'var(--orange)',
CLOSED: 'var(--blue)',
};

function rOutcomeChart() {
const wrap = document.getElementById('hist-outchart');
const pills = document.getElementById('hist-pills');
if (!wrap || !pills) return;

const dist = outcomeDistribution(trades, sFilter);
const total = dist.reduce((s, d) => s + d.count, 0);

if (total < _OUTCHART_THRESHOLD) {
wrap.style.display = 'none';
wrap.innerHTML = '';
pills.style.display = '';
return;
}
pills.style.display = 'none';
wrap.style.display = '';

const active = sHistOutcome && sHistOutcome !== 'ALL' ? sHistOutcome : null;

let cellsHtml = '';
dist.forEach(d => {
let cls = 'outchart-cell';
if (active === d.outcome) cls += ' active';
else if (active) cls += ' dim';
const premStr = '$' + fmt(d.premium);
cellsHtml += '<div class="' + cls + '"'
+ ' data-outcome="' + d.outcome + '"'
+ ' style="flex:' + d.count + ';background:' + _OUTCHART_COLORS[d.outcome] + '">'
+ '<span class="outchart-out">' + d.outcome + '</span>'
+ '<span class="outchart-cnt">' + d.count + '</span>'
+ '<span class="outchart-prem">' + premStr + '</span>'
+ '</div>';
});

wrap.innerHTML = '<div class="outchart-meta">'
+ '<span>Outcome distribution</span>'
+ '<span><b>' + total + '</b> settled · click a cell to filter</span>'
+ '</div>'
+ '<div class="outchart-treemap">' + cellsHtml + '</div>'
+ '<div class="outchart-tip" id="outchart-tip"></div>';

const tip = document.getElementById('outchart-tip');
wrap.querySelectorAll('.outchart-cell').forEach(el => {
const o = el.getAttribute('data-outcome');
const d = dist.find(x => x.outcome === o);
el.addEventListener('click', () => {
setHistOutcome(sHistOutcome === o ? 'ALL' : o);
});
el.addEventListener('mousemove', e => {
if (!d || !tip) return;
tip.style.display = 'block';
tip.style.left = (e.clientX + 12) + 'px';
tip.style.top = (e.clientY + 12) + 'px';
tip.textContent = o + ' — ' + d.count + ' trade' + (d.count === 1 ? '' : 's')
+ ', $' + fmt(d.premium) + ' total premium';
});
el.addEventListener('mouseleave', () => { if (tip) tip.style.display = 'none'; });
});
}
1 change: 1 addition & 0 deletions src/js/08-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ function render() {
const { streams, lots, allRows, displayRows } = compute(sFilter);
rStats(streams, lots, displayRows);
rTable(displayRows, streams, lots);
rOutcomeChart();
rCharts(displayRows, lots);
}
111 changes: 111 additions & 0 deletions test/integration/outcome-chart-filter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert');
const { setupJsdom } = require('../helpers/setupJsdom');

function settled(id, outcome, asset = 'BTC') {
return {
id,
asset,
type: outcome === 'CALLED' || outcome === 'CLOSED' ? 'CALL' : 'PUT',
date: '2026-01-' + String((id % 27) + 1).padStart(2, '0'),
expiry: '2026-02-' + String((id % 27) + 1).padStart(2, '0'),
dte: 21,
strike: 50000,
size: 0.05,
premium: 100,
outcome,
closeCost: 0,
platform: 'RYSK',
};
}

function makeSettledSet(n, outcome = 'EXPIRED', asset = 'BTC') {
const out = [];
for (let i = 0; i < n; i++) out.push(settled(1000 + i, outcome, asset));
return out;
}

test('chart hidden + pills shown when < 10 settled trades', (t) => {
const trades = makeSettledSet(5, 'EXPIRED');
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);

const chart = window.document.getElementById('hist-outchart');
const pills = window.document.getElementById('hist-pills');
assert.strictEqual(chart.style.display, 'none', 'chart should be hidden');
assert.notStrictEqual(pills.style.display, 'none', 'pills should be visible');
assert.strictEqual(chart.querySelectorAll('.outchart-cell').length, 0);
});

test('chart shown + pills hidden when >= 10 settled trades', (t) => {
const trades = [
...makeSettledSet(7, 'EXPIRED'),
...makeSettledSet(3, 'CALLED'),
];
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);

const chart = window.document.getElementById('hist-outchart');
const pills = window.document.getElementById('hist-pills');
assert.notStrictEqual(chart.style.display, 'none', 'chart should be visible');
assert.strictEqual(pills.style.display, 'none', 'pills should be hidden');
const cells = chart.querySelectorAll('.outchart-cell');
assert.strictEqual(cells.length, 2, 'two outcomes → two cells');
});

test('clicking a cell filters the history table to that outcome', (t) => {
const trades = [
...makeSettledSet(7, 'EXPIRED'),
...makeSettledSet(3, 'CALLED'),
];
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);

const cells = window.document.querySelectorAll('#hist-outchart .outchart-cell');
const expiredCell = [...cells].find(c => c.getAttribute('data-outcome') === 'EXPIRED');
expiredCell.dispatchEvent(new window.Event('click', { bubbles: true }));

assert.ok(window.document.getElementById('ho-EXPIRED').classList.contains('active'));
const histRows = window.document.querySelectorAll('#ttbody-hist tr');
assert.strictEqual(histRows.length, 7);
});

test('clicking the active cell clears the filter (idempotent toggle)', (t) => {
const trades = [
...makeSettledSet(7, 'EXPIRED'),
...makeSettledSet(3, 'CALLED'),
];
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);

const getExpired = () => [...window.document.querySelectorAll('#hist-outchart .outchart-cell')]
.find(c => c.getAttribute('data-outcome') === 'EXPIRED');

getExpired().dispatchEvent(new window.Event('click', { bubbles: true }));
assert.ok(window.document.getElementById('ho-EXPIRED').classList.contains('active'));

getExpired().dispatchEvent(new window.Event('click', { bubbles: true }));
assert.ok(window.document.getElementById('ho-ALL').classList.contains('active'));
const histRows = window.document.querySelectorAll('#ttbody-hist tr');
assert.strictEqual(histRows.length, 10, 'all 10 settled rows back in history');
});

test('asset filter chip changes the chart data', (t) => {
const trades = [
...makeSettledSet(7, 'EXPIRED', 'BTC'),
...makeSettledSet(5, 'ASSIGNED', 'ETH'),
];
const { window, teardown } = setupJsdom({ trades });
t.after(teardown);

let cells = window.document.querySelectorAll('#hist-outchart .outchart-cell');
assert.strictEqual(cells.length, 2);

window.setFilter('BTC');
let chart = window.document.getElementById('hist-outchart');
assert.strictEqual(chart.style.display, 'none', 'BTC alone is < 10 settled');

window.setFilter('ETH');
chart = window.document.getElementById('hist-outchart');
assert.strictEqual(chart.style.display, 'none', 'ETH alone is < 10 settled');
});
80 changes: 80 additions & 0 deletions test/unit/outcome-distribution.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const test = require('node:test');
const assert = require('node:assert');
const { outcomeDistribution } = require('../../src/js/05c-outcome-distribution.js');

test('empty trades → empty array', () => {
assert.deepStrictEqual(outcomeDistribution([]), []);
});

test('OPEN-only trades → empty array', () => {
const trades = [
{ id: 1, asset: 'BTC', type: 'PUT', outcome: 'OPEN', premium: 100, closeCost: 0 },
{ id: 2, asset: 'ETH', type: 'CALL', outcome: 'OPEN', premium: 50, closeCost: 0 },
];
assert.deepStrictEqual(outcomeDistribution(trades), []);
});

test('one trade per outcome → all four entries in canonical order', () => {
const trades = [
{ id: 4, asset: 'BTC', type: 'CALL', outcome: 'CLOSED', premium: 100, closeCost: 30 },
{ id: 3, asset: 'BTC', type: 'CALL', outcome: 'CALLED', premium: 80, closeCost: 0 },
{ id: 1, asset: 'BTC', type: 'PUT', outcome: 'EXPIRED', premium: 50, closeCost: 0 },
{ id: 2, asset: 'BTC', type: 'PUT', outcome: 'ASSIGNED',premium: 60, closeCost: 0 },
];
const dist = outcomeDistribution(trades);
assert.deepStrictEqual(dist.map(d => d.outcome), ['EXPIRED', 'ASSIGNED', 'CALLED', 'CLOSED']);
assert.deepStrictEqual(dist.map(d => d.count), [1, 1, 1, 1]);
assert.deepStrictEqual(dist.map(d => d.premium), [50, 60, 80, 70]); // CLOSED: 100 - 30
});

test('multiple trades per outcome → counts and premium summed', () => {
const trades = [
{ id: 1, asset: 'BTC', type: 'PUT', outcome: 'EXPIRED', premium: 50, closeCost: 0 },
{ id: 2, asset: 'BTC', type: 'PUT', outcome: 'EXPIRED', premium: 70, closeCost: 0 },
{ id: 3, asset: 'BTC', type: 'CALL', outcome: 'CALLED', premium: 80, closeCost: 0 },
];
const dist = outcomeDistribution(trades);
assert.strictEqual(dist.find(d => d.outcome === 'EXPIRED').count, 2);
assert.strictEqual(dist.find(d => d.outcome === 'EXPIRED').premium, 120);
assert.strictEqual(dist.find(d => d.outcome === 'CALLED').count, 1);
});

test('asset filter applied → only matching asset trades counted', () => {
const trades = [
{ id: 1, asset: 'BTC', type: 'PUT', outcome: 'EXPIRED', premium: 100, closeCost: 0 },
{ id: 2, asset: 'ETH', type: 'PUT', outcome: 'EXPIRED', premium: 200, closeCost: 0 },
{ id: 3, asset: 'ETH', type: 'PUT', outcome: 'ASSIGNED', premium: 50, closeCost: 0 },
];
const dist = outcomeDistribution(trades, 'ETH');
assert.strictEqual(dist.find(d => d.outcome === 'EXPIRED').premium, 200);
assert.strictEqual(dist.find(d => d.outcome === 'EXPIRED').count, 1);
assert.strictEqual(dist.find(d => d.outcome === 'ASSIGNED').count, 1);
});

test('CLOSED outcome subtracts closeCost from premium', () => {
const trades = [
{ id: 1, asset: 'BTC', type: 'CALL', outcome: 'CLOSED', premium: 200, closeCost: 75 },
{ id: 2, asset: 'BTC', type: 'CALL', outcome: 'CLOSED', premium: 100, closeCost: 120 },
];
const dist = outcomeDistribution(trades);
const closed = dist.find(d => d.outcome === 'CLOSED');
assert.strictEqual(closed.count, 2);
assert.strictEqual(closed.premium, 200 - 75 + 100 - 120); // 105
});

test('outcomes with zero count are omitted from result', () => {
const trades = [
{ id: 1, asset: 'BTC', type: 'PUT', outcome: 'EXPIRED', premium: 50, closeCost: 0 },
];
const dist = outcomeDistribution(trades);
assert.strictEqual(dist.length, 1);
assert.strictEqual(dist[0].outcome, 'EXPIRED');
});

test('asset filter "ALL" treated same as no filter', () => {
const trades = [
{ id: 1, asset: 'BTC', type: 'PUT', outcome: 'EXPIRED', premium: 50, closeCost: 0 },
{ id: 2, asset: 'ETH', type: 'PUT', outcome: 'EXPIRED', premium: 70, closeCost: 0 },
];
assert.strictEqual(outcomeDistribution(trades, 'ALL').find(d => d.outcome === 'EXPIRED').count, 2);
});
Loading