From e17912adca644ac52cabdcaae76033c8c31df54e Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Wed, 6 May 2026 23:27:55 +0800 Subject: [PATCH 1/3] Position History: outcome donut with click-to-filter (closes #37) Replaces the outcome pills with an SVG donut chart at >= 10 settled trades. Each slice toggles the existing setHistOutcome filter. - New pure helper outcomeDistribution(trades, assetFilter) with EXPIRED/ASSIGNED/CALLED/CLOSED ordering and CLOSED closeCost netting - Donut respects asset-filter chips, ignores From/To date pickers - CSS-variable colours only (EXPIRED/ASSIGNED/CALLED/CLOSED) - Clicking the active slice clears the filter (idempotent toggle) Co-Authored-By: Claude Opus 4.7 --- src/css/styles.css | 22 +++++ src/html/body.html | 3 +- src/js/05c-outcome-distribution.js | 31 +++++++ src/js/06a-render-donut.js | 112 +++++++++++++++++++++++ src/js/08-render.js | 1 + test/integration/donut-filter.test.js | 118 +++++++++++++++++++++++++ test/unit/outcome-distribution.test.js | 80 +++++++++++++++++ 7 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/js/05c-outcome-distribution.js create mode 100644 src/js/06a-render-donut.js create mode 100644 test/integration/donut-filter.test.js create mode 100644 test/unit/outcome-distribution.test.js diff --git a/src/css/styles.css b/src/css/styles.css index 81a03a8..d125eb2 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -754,3 +754,25 @@ 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 DONUT ── */ +.hist-donut{display:flex;gap:24px;align-items:center;flex-wrap:wrap;padding:12px 0 14px;border-bottom:1px solid var(--bd)} +.hist-donut-svg{flex-shrink:0} +.hist-donut-slice{cursor:pointer;transition:opacity .15s} +.hist-donut-slice:hover{opacity:.9 !important} +.hist-donut-slice.active{opacity:1;stroke:var(--text);stroke-width:2.5px} +.hist-donut-slice.dim{opacity:.32} +.hist-donut-center{font-family:var(--mono);text-anchor:middle;dominant-baseline:central;pointer-events:none} +.hist-donut-center-num{font-size:20px;font-weight:700;fill:var(--text)} +.hist-donut-center-lbl{font-size:9px;letter-spacing:1.5px;fill:var(--mu);text-transform:uppercase} +.hist-donut-legend{display:flex;flex-direction:column;gap:4px;font-size:.62rem;letter-spacing:.5px;font-family:var(--mono)} +.hist-donut-legend-item{display:flex;align-items:center;gap:8px;cursor:pointer;padding:3px 8px;border:1px solid transparent;transition:all .15s} +.hist-donut-legend-item:hover{border-color:var(--bd)} +.hist-donut-legend-item.active{border-color:var(--bd2);background:var(--s2)} +.hist-donut-legend-item.dim{opacity:.4} +.hist-donut-swatch{width:9px;height:9px;border-radius:1px;flex-shrink:0} +.hist-donut-out{color:var(--text);font-weight:600;letter-spacing:1px;width:74px} +.hist-donut-cnt{color:var(--mu2);min-width:54px} +.hist-donut-prem{color:var(--green);font-weight:600;text-align:right;min-width:60px;font-variant-numeric:tabular-nums} +.hist-donut-prem.neg{color:var(--red)} +.hist-donut-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..bff2a9a 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-donut.js b/src/js/06a-render-donut.js new file mode 100644 index 0000000..e1885da --- /dev/null +++ b/src/js/06a-render-donut.js @@ -0,0 +1,112 @@ +// ── HISTORY OUTCOME DONUT ───────────────────────────────────── +// Renders an SVG donut chart of settled-outcome distribution +// above the Position History table when there are >= 10 settled +// trades (excluding OPEN). Below threshold, the existing pills +// remain. Each slice toggles the outcome filter via setHistOutcome. + +const _DONUT_THRESHOLD = 10; +const _DONUT_COLORS = { + EXPIRED: 'var(--green)', + ASSIGNED: 'var(--red)', + CALLED: 'var(--orange)', + CLOSED: 'var(--blue)', +}; + +function _donutArc(cx, cy, rOuter, rInner, a0, a1) { + const x0 = cx + rOuter * Math.cos(a0), y0 = cy + rOuter * Math.sin(a0); + const x1 = cx + rOuter * Math.cos(a1), y1 = cy + rOuter * Math.sin(a1); + const x2 = cx + rInner * Math.cos(a1), y2 = cy + rInner * Math.sin(a1); + const x3 = cx + rInner * Math.cos(a0), y3 = cy + rInner * Math.sin(a0); + const large = (a1 - a0) > Math.PI ? 1 : 0; + return 'M' + x0 + ',' + y0 + + ' A' + rOuter + ',' + rOuter + ' 0 ' + large + ' 1 ' + x1 + ',' + y1 + + ' L' + x2 + ',' + y2 + + ' A' + rInner + ',' + rInner + ' 0 ' + large + ' 0 ' + x3 + ',' + y3 + + ' Z'; +} + +function rHistDonut() { + const wrap = document.getElementById('hist-donut'); + 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 < _DONUT_THRESHOLD) { + wrap.style.display = 'none'; + wrap.innerHTML = ''; + pills.style.display = ''; + return; + } + pills.style.display = 'none'; + wrap.style.display = ''; + + const cx = 80, cy = 80, rOuter = 68, rInner = 44; + const active = sHistOutcome && sHistOutcome !== 'ALL' ? sHistOutcome : null; + + let slicesHtml = ''; + let angle = -Math.PI / 2; + const sliceMeta = []; + dist.forEach(d => { + const span = (d.count / total) * Math.PI * 2; + const a0 = angle, a1 = angle + span; + const path = _donutArc(cx, cy, rOuter, rInner, a0, a1); + let cls = 'hist-donut-slice'; + if (active === d.outcome) cls += ' active'; + else if (active) cls += ' dim'; + slicesHtml += ''; + sliceMeta.push(d); + angle = a1; + }); + + const centreNum = active ? (dist.find(d => d.outcome === active) || {}).count || 0 : total; + const centreLbl = active ? active : 'Settled'; + + const svg = '' + + slicesHtml + + '' + centreNum + '' + + '' + centreLbl + '' + + ''; + + let legend = '
'; + dist.forEach(d => { + let cls = 'hist-donut-legend-item'; + if (active === d.outcome) cls += ' active'; + else if (active) cls += ' dim'; + const premCls = d.premium < 0 ? ' neg' : ''; + legend += '
' + + '' + + '' + d.outcome + '' + + '' + d.count + ' trade' + (d.count === 1 ? '' : 's') + '' + + '$' + fmt(d.premium) + '' + + '
'; + }); + legend += '
'; + + wrap.innerHTML = svg + legend + + '
'; + + const tip = document.getElementById('hist-donut-tip'); + const onClick = outcome => () => { + setHistOutcome(sHistOutcome === outcome ? 'ALL' : outcome); + }; + wrap.querySelectorAll('.hist-donut-slice').forEach(el => { + const o = el.getAttribute('data-outcome'); + el.addEventListener('click', onClick(o)); + el.addEventListener('mousemove', e => { + const d = sliceMeta.find(x => x.outcome === o); + 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'; }); + }); + wrap.querySelectorAll('.hist-donut-legend-item').forEach(el => { + el.addEventListener('click', onClick(el.getAttribute('data-outcome'))); + }); +} diff --git a/src/js/08-render.js b/src/js/08-render.js index ef29e8a..4b3b11d 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); + rHistDonut(); rCharts(displayRows, lots); } diff --git a/test/integration/donut-filter.test.js b/test/integration/donut-filter.test.js new file mode 100644 index 0000000..07e087a --- /dev/null +++ b/test/integration/donut-filter.test.js @@ -0,0 +1,118 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { setupJsdom } = require('../helpers/setupJsdom'); + +// Build a settled trade with overridable fields. +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('donut hidden + pills shown when < 10 settled trades', (t) => { + const trades = makeSettledSet(5, 'EXPIRED'); + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + + const donut = window.document.getElementById('hist-donut'); + const pills = window.document.getElementById('hist-pills'); + assert.strictEqual(donut.style.display, 'none', 'donut should be hidden'); + assert.notStrictEqual(pills.style.display, 'none', 'pills should be visible'); + assert.strictEqual(donut.querySelectorAll('.hist-donut-slice').length, 0); +}); + +test('donut 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 donut = window.document.getElementById('hist-donut'); + const pills = window.document.getElementById('hist-pills'); + assert.notStrictEqual(donut.style.display, 'none', 'donut should be visible'); + assert.strictEqual(pills.style.display, 'none', 'pills should be hidden'); + const slices = donut.querySelectorAll('.hist-donut-slice'); + assert.strictEqual(slices.length, 2, 'two outcomes → two slices'); +}); + +test('clicking a slice 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 slices = window.document.querySelectorAll('#hist-donut .hist-donut-slice'); + const expiredSlice = [...slices].find(s => s.getAttribute('data-outcome') === 'EXPIRED'); + expiredSlice.dispatchEvent(new window.Event('click', { bubbles: true })); + + // ho-EXPIRED button should be marked active + assert.ok(window.document.getElementById('ho-EXPIRED').classList.contains('active')); + // After re-render: only EXPIRED rows in history body + const histRows = window.document.querySelectorAll('#ttbody-hist tr'); + assert.strictEqual(histRows.length, 7); +}); + +test('clicking the active slice 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-donut .hist-donut-slice')] + .find(s => s.getAttribute('data-outcome') === 'EXPIRED'); + + getExpired().dispatchEvent(new window.Event('click', { bubbles: true })); + assert.ok(window.document.getElementById('ho-EXPIRED').classList.contains('active')); + + // Slice is re-rendered after first click, so re-query. + 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 donut data', (t) => { + const trades = [ + ...makeSettledSet(7, 'EXPIRED', 'BTC'), + ...makeSettledSet(5, 'ASSIGNED', 'ETH'), + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + + // ALL: 12 settled → donut renders, 2 outcomes. + let slices = window.document.querySelectorAll('#hist-donut .hist-donut-slice'); + assert.strictEqual(slices.length, 2); + + // Filter to BTC → only 7 settled → below threshold → pills shown. + window.setFilter('BTC'); + let donut = window.document.getElementById('hist-donut'); + assert.strictEqual(donut.style.display, 'none', 'BTC alone is < 10 settled'); + + // Filter to ETH → only 5 settled → below threshold. + window.setFilter('ETH'); + donut = window.document.getElementById('hist-donut'); + assert.strictEqual(donut.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); +}); From cde60fd0f99b0ef3ca3fbf2865e9de9d7dfa7286 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Thu, 7 May 2026 07:26:34 +0800 Subject: [PATCH 2/3] Switch outcome donut to horizontal treemap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The donut felt too small and left-anchored on a wide row. The treemap fills the full width, sizes cells by trade count, and shows outcome, count, and premium inline without a tooltip. Click-to-filter behaviour unchanged. - Renamed module to 06a-render-outcome-chart.js (rOutcomeChart) - Renamed wrapper id #hist-donut → #hist-outchart, classes follow suit - Replaced donut CSS with treemap cell styles - Updated integration tests to assert on .outchart-cell Co-Authored-By: Claude Opus 4.7 --- src/css/styles.css | 34 ++---- src/html/body.html | 2 +- src/js/06a-render-donut.js | 112 ------------------ src/js/06a-render-outcome-chart.js | 73 ++++++++++++ src/js/08-render.js | 2 +- ...r.test.js => outcome-chart-filter.test.js} | 53 ++++----- 6 files changed, 111 insertions(+), 165 deletions(-) delete mode 100644 src/js/06a-render-donut.js create mode 100644 src/js/06a-render-outcome-chart.js rename test/integration/{donut-filter.test.js => outcome-chart-filter.test.js} (58%) diff --git a/src/css/styles.css b/src/css/styles.css index d125eb2..2e257ed 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -755,24 +755,16 @@ td.td-act { width:1px; white-space:nowrap; padding-right:12px; } .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 DONUT ── */ -.hist-donut{display:flex;gap:24px;align-items:center;flex-wrap:wrap;padding:12px 0 14px;border-bottom:1px solid var(--bd)} -.hist-donut-svg{flex-shrink:0} -.hist-donut-slice{cursor:pointer;transition:opacity .15s} -.hist-donut-slice:hover{opacity:.9 !important} -.hist-donut-slice.active{opacity:1;stroke:var(--text);stroke-width:2.5px} -.hist-donut-slice.dim{opacity:.32} -.hist-donut-center{font-family:var(--mono);text-anchor:middle;dominant-baseline:central;pointer-events:none} -.hist-donut-center-num{font-size:20px;font-weight:700;fill:var(--text)} -.hist-donut-center-lbl{font-size:9px;letter-spacing:1.5px;fill:var(--mu);text-transform:uppercase} -.hist-donut-legend{display:flex;flex-direction:column;gap:4px;font-size:.62rem;letter-spacing:.5px;font-family:var(--mono)} -.hist-donut-legend-item{display:flex;align-items:center;gap:8px;cursor:pointer;padding:3px 8px;border:1px solid transparent;transition:all .15s} -.hist-donut-legend-item:hover{border-color:var(--bd)} -.hist-donut-legend-item.active{border-color:var(--bd2);background:var(--s2)} -.hist-donut-legend-item.dim{opacity:.4} -.hist-donut-swatch{width:9px;height:9px;border-radius:1px;flex-shrink:0} -.hist-donut-out{color:var(--text);font-weight:600;letter-spacing:1px;width:74px} -.hist-donut-cnt{color:var(--mu2);min-width:54px} -.hist-donut-prem{color:var(--green);font-weight:600;text-align:right;min-width:60px;font-variant-numeric:tabular-nums} -.hist-donut-prem.neg{color:var(--red)} -.hist-donut-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} +/* ── 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 bff2a9a..bee4b2e 100644 --- a/src/html/body.html +++ b/src/html/body.html @@ -168,7 +168,7 @@
Position History
- +
Outcome diff --git a/src/js/06a-render-donut.js b/src/js/06a-render-donut.js deleted file mode 100644 index e1885da..0000000 --- a/src/js/06a-render-donut.js +++ /dev/null @@ -1,112 +0,0 @@ -// ── HISTORY OUTCOME DONUT ───────────────────────────────────── -// Renders an SVG donut chart of settled-outcome distribution -// above the Position History table when there are >= 10 settled -// trades (excluding OPEN). Below threshold, the existing pills -// remain. Each slice toggles the outcome filter via setHistOutcome. - -const _DONUT_THRESHOLD = 10; -const _DONUT_COLORS = { - EXPIRED: 'var(--green)', - ASSIGNED: 'var(--red)', - CALLED: 'var(--orange)', - CLOSED: 'var(--blue)', -}; - -function _donutArc(cx, cy, rOuter, rInner, a0, a1) { - const x0 = cx + rOuter * Math.cos(a0), y0 = cy + rOuter * Math.sin(a0); - const x1 = cx + rOuter * Math.cos(a1), y1 = cy + rOuter * Math.sin(a1); - const x2 = cx + rInner * Math.cos(a1), y2 = cy + rInner * Math.sin(a1); - const x3 = cx + rInner * Math.cos(a0), y3 = cy + rInner * Math.sin(a0); - const large = (a1 - a0) > Math.PI ? 1 : 0; - return 'M' + x0 + ',' + y0 - + ' A' + rOuter + ',' + rOuter + ' 0 ' + large + ' 1 ' + x1 + ',' + y1 - + ' L' + x2 + ',' + y2 - + ' A' + rInner + ',' + rInner + ' 0 ' + large + ' 0 ' + x3 + ',' + y3 - + ' Z'; -} - -function rHistDonut() { - const wrap = document.getElementById('hist-donut'); - 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 < _DONUT_THRESHOLD) { - wrap.style.display = 'none'; - wrap.innerHTML = ''; - pills.style.display = ''; - return; - } - pills.style.display = 'none'; - wrap.style.display = ''; - - const cx = 80, cy = 80, rOuter = 68, rInner = 44; - const active = sHistOutcome && sHistOutcome !== 'ALL' ? sHistOutcome : null; - - let slicesHtml = ''; - let angle = -Math.PI / 2; - const sliceMeta = []; - dist.forEach(d => { - const span = (d.count / total) * Math.PI * 2; - const a0 = angle, a1 = angle + span; - const path = _donutArc(cx, cy, rOuter, rInner, a0, a1); - let cls = 'hist-donut-slice'; - if (active === d.outcome) cls += ' active'; - else if (active) cls += ' dim'; - slicesHtml += ''; - sliceMeta.push(d); - angle = a1; - }); - - const centreNum = active ? (dist.find(d => d.outcome === active) || {}).count || 0 : total; - const centreLbl = active ? active : 'Settled'; - - const svg = '' - + slicesHtml - + '' + centreNum + '' - + '' + centreLbl + '' - + ''; - - let legend = '
'; - dist.forEach(d => { - let cls = 'hist-donut-legend-item'; - if (active === d.outcome) cls += ' active'; - else if (active) cls += ' dim'; - const premCls = d.premium < 0 ? ' neg' : ''; - legend += '
' - + '' - + '' + d.outcome + '' - + '' + d.count + ' trade' + (d.count === 1 ? '' : 's') + '' - + '$' + fmt(d.premium) + '' - + '
'; - }); - legend += '
'; - - wrap.innerHTML = svg + legend - + '
'; - - const tip = document.getElementById('hist-donut-tip'); - const onClick = outcome => () => { - setHistOutcome(sHistOutcome === outcome ? 'ALL' : outcome); - }; - wrap.querySelectorAll('.hist-donut-slice').forEach(el => { - const o = el.getAttribute('data-outcome'); - el.addEventListener('click', onClick(o)); - el.addEventListener('mousemove', e => { - const d = sliceMeta.find(x => x.outcome === o); - 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'; }); - }); - wrap.querySelectorAll('.hist-donut-legend-item').forEach(el => { - el.addEventListener('click', onClick(el.getAttribute('data-outcome'))); - }); -} 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 4b3b11d..db53f4f 100644 --- a/src/js/08-render.js +++ b/src/js/08-render.js @@ -3,6 +3,6 @@ function render() { const { streams, lots, allRows, displayRows } = compute(sFilter); rStats(streams, lots, displayRows); rTable(displayRows, streams, lots); - rHistDonut(); + rOutcomeChart(); rCharts(displayRows, lots); } diff --git a/test/integration/donut-filter.test.js b/test/integration/outcome-chart-filter.test.js similarity index 58% rename from test/integration/donut-filter.test.js rename to test/integration/outcome-chart-filter.test.js index 07e087a..7d30c23 100644 --- a/test/integration/donut-filter.test.js +++ b/test/integration/outcome-chart-filter.test.js @@ -2,7 +2,6 @@ const test = require('node:test'); const assert = require('node:assert'); const { setupJsdom } = require('../helpers/setupJsdom'); -// Build a settled trade with overridable fields. function settled(id, outcome, asset = 'BTC') { return { id, @@ -26,19 +25,19 @@ function makeSettledSet(n, outcome = 'EXPIRED', asset = 'BTC') { return out; } -test('donut hidden + pills shown when < 10 settled trades', (t) => { +test('chart hidden + pills shown when < 10 settled trades', (t) => { const trades = makeSettledSet(5, 'EXPIRED'); const { window, teardown } = setupJsdom({ trades }); t.after(teardown); - const donut = window.document.getElementById('hist-donut'); + const chart = window.document.getElementById('hist-outchart'); const pills = window.document.getElementById('hist-pills'); - assert.strictEqual(donut.style.display, 'none', 'donut should be hidden'); + assert.strictEqual(chart.style.display, 'none', 'chart should be hidden'); assert.notStrictEqual(pills.style.display, 'none', 'pills should be visible'); - assert.strictEqual(donut.querySelectorAll('.hist-donut-slice').length, 0); + assert.strictEqual(chart.querySelectorAll('.outchart-cell').length, 0); }); -test('donut shown + pills hidden when >= 10 settled trades', (t) => { +test('chart shown + pills hidden when >= 10 settled trades', (t) => { const trades = [ ...makeSettledSet(7, 'EXPIRED'), ...makeSettledSet(3, 'CALLED'), @@ -46,15 +45,15 @@ test('donut shown + pills hidden when >= 10 settled trades', (t) => { const { window, teardown } = setupJsdom({ trades }); t.after(teardown); - const donut = window.document.getElementById('hist-donut'); + const chart = window.document.getElementById('hist-outchart'); const pills = window.document.getElementById('hist-pills'); - assert.notStrictEqual(donut.style.display, 'none', 'donut should be visible'); + assert.notStrictEqual(chart.style.display, 'none', 'chart should be visible'); assert.strictEqual(pills.style.display, 'none', 'pills should be hidden'); - const slices = donut.querySelectorAll('.hist-donut-slice'); - assert.strictEqual(slices.length, 2, 'two outcomes → two slices'); + const cells = chart.querySelectorAll('.outchart-cell'); + assert.strictEqual(cells.length, 2, 'two outcomes → two cells'); }); -test('clicking a slice filters the history table to that outcome', (t) => { +test('clicking a cell filters the history table to that outcome', (t) => { const trades = [ ...makeSettledSet(7, 'EXPIRED'), ...makeSettledSet(3, 'CALLED'), @@ -62,18 +61,16 @@ test('clicking a slice filters the history table to that outcome', (t) => { const { window, teardown } = setupJsdom({ trades }); t.after(teardown); - const slices = window.document.querySelectorAll('#hist-donut .hist-donut-slice'); - const expiredSlice = [...slices].find(s => s.getAttribute('data-outcome') === 'EXPIRED'); - expiredSlice.dispatchEvent(new window.Event('click', { bubbles: true })); + 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 })); - // ho-EXPIRED button should be marked active assert.ok(window.document.getElementById('ho-EXPIRED').classList.contains('active')); - // After re-render: only EXPIRED rows in history body const histRows = window.document.querySelectorAll('#ttbody-hist tr'); assert.strictEqual(histRows.length, 7); }); -test('clicking the active slice clears the filter (idempotent toggle)', (t) => { +test('clicking the active cell clears the filter (idempotent toggle)', (t) => { const trades = [ ...makeSettledSet(7, 'EXPIRED'), ...makeSettledSet(3, 'CALLED'), @@ -81,20 +78,19 @@ test('clicking the active slice clears the filter (idempotent toggle)', (t) => { const { window, teardown } = setupJsdom({ trades }); t.after(teardown); - const getExpired = () => [...window.document.querySelectorAll('#hist-donut .hist-donut-slice')] - .find(s => s.getAttribute('data-outcome') === 'EXPIRED'); + 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')); - // Slice is re-rendered after first click, so re-query. 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 donut data', (t) => { +test('asset filter chip changes the chart data', (t) => { const trades = [ ...makeSettledSet(7, 'EXPIRED', 'BTC'), ...makeSettledSet(5, 'ASSIGNED', 'ETH'), @@ -102,17 +98,14 @@ test('asset filter chip changes the donut data', (t) => { const { window, teardown } = setupJsdom({ trades }); t.after(teardown); - // ALL: 12 settled → donut renders, 2 outcomes. - let slices = window.document.querySelectorAll('#hist-donut .hist-donut-slice'); - assert.strictEqual(slices.length, 2); + let cells = window.document.querySelectorAll('#hist-outchart .outchart-cell'); + assert.strictEqual(cells.length, 2); - // Filter to BTC → only 7 settled → below threshold → pills shown. window.setFilter('BTC'); - let donut = window.document.getElementById('hist-donut'); - assert.strictEqual(donut.style.display, 'none', 'BTC alone is < 10 settled'); + let chart = window.document.getElementById('hist-outchart'); + assert.strictEqual(chart.style.display, 'none', 'BTC alone is < 10 settled'); - // Filter to ETH → only 5 settled → below threshold. window.setFilter('ETH'); - donut = window.document.getElementById('hist-donut'); - assert.strictEqual(donut.style.display, 'none', 'ETH alone is < 10 settled'); + chart = window.document.getElementById('hist-outchart'); + assert.strictEqual(chart.style.display, 'none', 'ETH alone is < 10 settled'); }); From bb89135f0bef3b8563310fd504c35881895faa33 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Thu, 7 May 2026 07:29:35 +0800 Subject: [PATCH 3/3] Doc: file-map entries for outcome treemap modules Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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