From 6c98e9868173446257ae148e186afe5fcc764da8 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Sat, 16 May 2026 18:39:12 +0800 Subject: [PATCH] =?UTF-8?q?Remove=20APR=20duplication=20in=20renderExpiryT?= =?UTF-8?q?able=20=E2=80=94=20use=20enriched=20rows=20from=20compute()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderExpiryTable now receives allRows (enriched by compute) via rStats instead of filtering raw trades[]. Uses r.annual directly and _liveDte for the DTE label, eliminating the duplicate inline APR formula. Removes the dead `else renderExpiryTable()` branch in fetchExpiryPrices. Closes #58. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- docs/architecture.md | 2 +- src/js/06-render-table.js | 58 +++++++++++------------- src/js/08-render.js | 2 +- test/integration/expiry-table.test.js | 63 +++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 35 deletions(-) create mode 100644 test/integration/expiry-table.test.js diff --git a/CLAUDE.md b/CLAUDE.md index b6b96d2..2f67c40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ globals, and 17-boot.js runs an IIFE last to bootstrap the app. | `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) | +| `06-render-table.js` | 452 | `sortOpen/sortHist`, `renderExpiryTable(allRows)` (today badge + mobile cards — reads enriched rows from `compute`, uses `r.annual` and `_liveDte`; no longer reads raw `trades[]`), `fetchExpiryPrices` (CoinGecko, calls `render()` on success), `rTable` (holdings cards, open & history tables, history filter application), `rStats(streams, lots, allRows, displayRows)` (forwards `allRows` 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` | 8 | `render()` — orchestrator: `compute → rStats → rTable → rOutcomeChart → rCharts` | diff --git a/docs/architecture.md b/docs/architecture.md index 5147d35..7644569 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,7 @@ flowchart TD LS -->|load on boot| Trades Trades -->|per-asset slice| LE LE -->|lots + accounting| CO - CO -->|displayRows| RT + CO -->|allRows + displayRows| RT CO -->|streams| RC Trades -->|all trades| CP CP -->|realisedSeries\nrealised + unrealised| RC diff --git a/src/js/06-render-table.js b/src/js/06-render-table.js index 4142de3..b496f80 100644 --- a/src/js/06-render-table.js +++ b/src/js/06-render-table.js @@ -133,11 +133,11 @@ function _histRow(r) { + ''; } -function rStats(streams, lots, displayRows) { - renderExpiryTable(); +function rStats(streams, lots, allRows, displayRows) { + renderExpiryTable(allRows); } -function renderExpiryTable() { +function renderExpiryTable(allRows) { const wrap = document.getElementById('expiry-table-wrap'); if (!wrap) return; @@ -146,12 +146,12 @@ function renderExpiryTable() { const weekOut = new Date(today); weekOut.setDate(weekOut.getDate() + 7); - const expiring = trades.filter(t => { - if (t.outcome !== 'OPEN') return false; - if (t.type !== 'PUT' && t.type !== 'CALL') return false; - if (sFilter !== 'ALL' && t.asset !== sFilter) return false; - if (!t.expiry) return false; - const exp = new Date(t.expiry + 'T00:00:00'); + const expiring = (allRows || []).filter(r => { + if (r.outcome !== 'OPEN') return false; + if (r.type !== 'PUT' && r.type !== 'CALL') return false; + if (sFilter !== 'ALL' && r.asset !== sFilter) return false; + if (!r.expiry) return false; + const exp = new Date(r.expiry + 'T00:00:00'); return exp <= weekOut; }).sort((a, b) => new Date(a.expiry) - new Date(b.expiry) || a.asset.localeCompare(b.asset)); @@ -177,38 +177,32 @@ function renderExpiryTable() { const assetCol = { BTC: 'btc', ETH: 'eth', HYPE: 'hype', SOL: 'sol' }; - const enriched = expiring.map(t => { - const exp = new Date(t.expiry + 'T00:00:00'); - const daysLeft = Math.round((exp - today) / (1000 * 60 * 60 * 24)); - const dteLabel = daysLeft <= 0 - ? 'today' - : daysLeft + 'd'; - let aprHtml = '—'; - if (t.dte > 0 && t.strike > 0 && t.size > 0) { - const ann = (t.premium / (t.strike * t.size)) * (365 / t.dte) * 100; - aprHtml = ann.toFixed(1) + '%'; - } + const enriched = expiring.map(r => { + const exp = new Date(r.expiry + 'T00:00:00'); + const daysLeft = Math.round((exp - today) / 86400000); + const dteLabel = _liveDte(r.expiry); + const aprHtml = r.annual != null ? r.annual.toFixed(1) + '%' : '—'; let statusHtml = ''; - const spot = livePrices[t.asset]; + const spot = livePrices[r.asset]; if (spot) { - const isPut = t.type === 'PUT'; - const isOTM = isPut ? spot > t.strike : spot < t.strike; - const pct = Math.abs((spot - t.strike) / spot * 100).toFixed(1); + const isPut = r.type === 'PUT'; + const isOTM = isPut ? spot > r.strike : spot < r.strike; + const pct = Math.abs((spot - r.strike) / spot * 100).toFixed(1); statusHtml = isOTM ? 'OTM ' + pct + '%' : 'ITM ' + pct + '%'; } - const col = assetCol[t.asset] || 'mu2'; - const platBadge = (t.platform === 'HSFC') + const col = assetCol[r.asset] || 'mu2'; + const platBadge = (r.platform === 'HSFC') ? 'HSFC' : 'RYSK'; const actionsHtml = '
' - + '' - + (t.type === 'CALL' - ? '' - : '') + + '' + + (r.type === 'CALL' + ? '' + : '') + '
'; - return { t, col, dteLabel, aprHtml, statusHtml, platBadge, actionsHtml, daysLeft }; + return { t: r, col, dteLabel, aprHtml, statusHtml, platBadge, actionsHtml, daysLeft }; }); const rows = enriched.map(e => { @@ -267,7 +261,7 @@ function fetchExpiryPrices() { const el = document.getElementById('expiry-last-refreshed'); if (el) { const n = new Date(); el.textContent = 'refreshed ' + String(n.getUTCHours()).padStart(2,'0') + ':' + String(n.getUTCMinutes()).padStart(2,'0') + ' UTC'; } // Re-render whole page so holdings cards pick up live spot too - if (typeof render === 'function') render(); else renderExpiryTable(); + render(); }) .catch(() => { /* silently fail — table shows — for status */ }); } diff --git a/src/js/08-render.js b/src/js/08-render.js index db53f4f..408a560 100644 --- a/src/js/08-render.js +++ b/src/js/08-render.js @@ -1,7 +1,7 @@ // ── RENDER function render() { const { streams, lots, allRows, displayRows } = compute(sFilter); - rStats(streams, lots, displayRows); + rStats(streams, lots, allRows, displayRows); rTable(displayRows, streams, lots); rOutcomeChart(); rCharts(displayRows, lots); diff --git a/test/integration/expiry-table.test.js b/test/integration/expiry-table.test.js new file mode 100644 index 0000000..0e95628 --- /dev/null +++ b/test/integration/expiry-table.test.js @@ -0,0 +1,63 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { setupJsdom } = require('../helpers/setupJsdom'); + +function isoDaysFromToday(days) { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + days); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return yyyy + '-' + mm + '-' + dd; +} + +test('Expiring This Week APR matches Open Positions APR for the same trade', (t) => { + // dte=21, premium=150, strike=50000, size=0.05 + // annual = (150 / (50000 * 0.05)) * (365 / 21) * 100 ≈ 104.8% + const openPut = { + id: 1, asset: 'BTC', type: 'PUT', + date: '2026-01-01', expiry: isoDaysFromToday(3), + dte: 21, strike: 50000, size: 0.05, premium: 150, + outcome: 'OPEN', closeCost: 0, platform: 'RYSK', + }; + const { window, teardown } = setupJsdom({ trades: [openPut] }); + t.after(teardown); + + // Get APR from Open Positions table (column index 9 = APR: Asset,Platform,Date,Expiry,DTE,Type,Strike,Size,Premium,APR) + const openCells = window.document.querySelectorAll('#ttbody-open tr td'); + const openApr = openCells[9].textContent.trim(); + + // Get APR from Expiring This Week table (column index 7 = APR) + const expRows = window.document.querySelectorAll('.expiry-tbl tbody tr td'); + const expiryApr = expRows[7].textContent.trim(); + + assert.ok(openApr.length > 0, 'open positions APR should not be empty'); + assert.strictEqual(expiryApr, openApr, 'Expiring This Week APR must match Open Positions APR'); +}); + +test('Expiring This Week respects asset filter', (t) => { + const btcPut = { + id: 1, asset: 'BTC', type: 'PUT', + date: '2026-01-01', expiry: isoDaysFromToday(3), + dte: 21, strike: 50000, size: 0.05, premium: 100, + outcome: 'OPEN', closeCost: 0, platform: 'RYSK', + }; + const ethPut = { + id: 2, asset: 'ETH', type: 'PUT', + date: '2026-01-01', expiry: isoDaysFromToday(4), + dte: 21, strike: 3000, size: 0.5, premium: 50, + outcome: 'OPEN', closeCost: 0, platform: 'RYSK', + }; + const { window, teardown } = setupJsdom({ trades: [btcPut, ethPut] }); + t.after(teardown); + + // Apply ETH filter + window.setFilter('ETH'); + window.render(); + + const rows = window.document.querySelectorAll('.expiry-tbl tbody tr'); + assert.strictEqual(rows.length, 1, 'only 1 row should appear with ETH filter'); + const assetBadge = rows[0].querySelector('.badge').textContent.trim(); + assert.strictEqual(assetBadge, 'ETH', 'the remaining row should be ETH'); +});