diff --git a/CLAUDE.md b/CLAUDE.md index 3fd9ff1..631ac70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | @@ -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 diff --git a/src/css/styles.css b/src/css/styles.css index 81a03a8..2e257ed 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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} diff --git a/src/html/body.html b/src/html/body.html index 166958a..bee4b2e 100644 --- a/src/html/body.html +++ b/src/html/body.html @@ -168,8 +168,9 @@
Position History
+
-
+
Outcome diff --git a/src/js/05c-outcome-distribution.js b/src/js/05c-outcome-distribution.js new file mode 100644 index 0000000..70937fb --- /dev/null +++ b/src/js/05c-outcome-distribution.js @@ -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 }; +} diff --git a/src/js/06a-render-outcome-chart.js b/src/js/06a-render-outcome-chart.js new file mode 100644 index 0000000..7978b12 --- /dev/null +++ b/src/js/06a-render-outcome-chart.js @@ -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 += '
' + + '' + d.outcome + '' + + '' + d.count + '' + + '' + premStr + '' + + '
'; + }); + + wrap.innerHTML = '
' + + 'Outcome distribution' + + '' + total + ' settled · click a cell to filter' + + '
' + + '
' + cellsHtml + '
' + + '
'; + + 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'; }); + }); +} diff --git a/src/js/08-render.js b/src/js/08-render.js index ef29e8a..db53f4f 100644 --- a/src/js/08-render.js +++ b/src/js/08-render.js @@ -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); } diff --git a/test/integration/outcome-chart-filter.test.js b/test/integration/outcome-chart-filter.test.js new file mode 100644 index 0000000..7d30c23 --- /dev/null +++ b/test/integration/outcome-chart-filter.test.js @@ -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'); +}); diff --git a/test/unit/outcome-distribution.test.js b/test/unit/outcome-distribution.test.js new file mode 100644 index 0000000..903b9f5 --- /dev/null +++ b/test/unit/outcome-distribution.test.js @@ -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); +});