diff --git a/src/js/05d-calc-stats.js b/src/js/05d-calc-stats.js new file mode 100644 index 0000000..e34038c --- /dev/null +++ b/src/js/05d-calc-stats.js @@ -0,0 +1,31 @@ +function calcPremiumStats(rows) { + let totalPrem = 0, totalNotional = 0, totalCount = 0; + let otmCount = 0, itmCount = 0, openCount = 0; + let aprWeightedSum = 0, aprWeightTotal = 0; + + rows.forEach(r => { + if (r.type === 'HOLDING') return; + const net = (r.premium || 0) - (r.closeCost || 0); + const notional = (r.strike || 0) * (r.size || 0); + totalPrem += net; + totalNotional += notional; + totalCount++; + if (r.outcome === 'OPEN') { openCount++; } + else if (r.outcome === 'EXPIRED') { otmCount++; } + else if (r.outcome === 'ASSIGNED' || r.outcome === 'CALLED') { itmCount++; } + if (r.annual != null) { + aprWeightedSum += r.annual * notional; + aprWeightTotal += notional; + } + }); + + const settled = otmCount + itmCount; + const returnRate = settled > 0 ? otmCount / settled * 100 : null; + const portfolioAPR = aprWeightTotal > 0 ? aprWeightedSum / aprWeightTotal : null; + + return { totalPrem, totalNotional, totalCount, otmCount, itmCount, openCount, settled, returnRate, portfolioAPR }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { calcPremiumStats }; +} diff --git a/src/js/07-render-charts.js b/src/js/07-render-charts.js index 7b8824a..369b559 100644 --- a/src/js/07-render-charts.js +++ b/src/js/07-render-charts.js @@ -405,35 +405,6 @@ function rCharts(displayRows, lots) { const el = document.getElementById('ppnl-body'); if (!el) return; - function calcStats(rows) { - let totalPrem = 0, totalCount = 0; - let otmCount = 0, itmCount = 0; - let openCount = 0; - let aprWeightedSum = 0, aprWeightTotal = 0; - let assignmentLoss = 0, callAwayCredit = 0, totalNotional = 0; - rows.forEach(r => { - if (r.type === 'HOLDING') return; - const net = (r.premium || 0) - (r.closeCost || 0); - const notional = (r.strike || 0) * (r.size || 0); - totalPrem += net; - totalNotional += notional; - totalCount++; - if (r.outcome === 'OPEN') { openCount++; } - else if (r.outcome === 'EXPIRED') otmCount++; - else if (r.outcome === 'ASSIGNED') { itmCount++; assignmentLoss += notional; } - else if (r.outcome === 'CALLED') { itmCount++; callAwayCredit += notional; } - if (r.annual != null) { - aprWeightedSum += r.annual * notional; - aprWeightTotal += notional; - } - }); - const settled = otmCount + itmCount; - const returnRate = settled > 0 ? otmCount / settled * 100 : null; - const portfolioAPR = aprWeightTotal > 0 ? aprWeightedSum / aprWeightTotal : null; - const netPnl = totalPrem - assignmentLoss + callAwayCredit; - return { totalPrem, totalCount, otmCount, itmCount, openCount, returnRate, settled, portfolioAPR, assignmentLoss, callAwayCredit, netPnl, totalNotional }; - } - const pos = n => n === 1 ? '1 position' : n + ' positions'; const asgn = n => n === 1 ? '1 assignment' : n + ' assignments'; const dash = '—'; @@ -444,7 +415,7 @@ function rCharts(displayRows, lots) { } if (sPpnlTab === 'total') { - const s = calcStats(displayRows); + const s = calcPremiumStats(displayRows); function tile(extraClass, label, main, sub, tip) { const tipAttr = tip ? ' data-tip="' + tip.replace(/"/g, '"') + '"' : ''; const cls = 'ppnl-card' + (extraClass ? ' ' + extraClass : '') + (tip ? ' has-tip' : ''); @@ -497,7 +468,7 @@ function rCharts(displayRows, lots) { } const rows = months.map(ym => { - const s = calcStats(monthMap[ym]); + const s = calcPremiumStats(monthMap[ym]); const rateClass = s.returnRate === null ? '' : s.returnRate >= 70 ? ' class="rate-hi"' : s.returnRate < 50 ? ' class="rate-lo"' : ''; const realisedM = realisedByMonth[ym]; const hasRealised = realisedM !== undefined; diff --git a/test/unit/calc-stats.test.js b/test/unit/calc-stats.test.js new file mode 100644 index 0000000..5494c30 --- /dev/null +++ b/test/unit/calc-stats.test.js @@ -0,0 +1,77 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); + +// Wire up globals needed by the dual-export require chain +global.trades = []; +global.livePrices = {}; + +const { calcPremiumStats } = require('../../src/js/05d-calc-stats.js'); + +function makeRow(overrides) { + return Object.assign({ + type: 'PUT', + outcome: 'EXPIRED', + premium: 100, + closeCost: 0, + strike: 1000, + size: 1, + annual: 50, + }, overrides); +} + +describe('calcPremiumStats', () => { + it('excludes HOLDING trades from all counts', () => { + const rows = [ + makeRow({ type: 'HOLDING', premium: 0, strike: 1000, size: 1 }), + ]; + const s = calcPremiumStats(rows); + assert.equal(s.totalCount, 0); + assert.equal(s.totalPrem, 0); + assert.equal(s.totalNotional, 0); + }); + + it('returns null returnRate when no settled trades', () => { + const rows = [makeRow({ outcome: 'OPEN' })]; + const s = calcPremiumStats(rows); + assert.equal(s.returnRate, null); + assert.equal(s.settled, 0); + assert.equal(s.openCount, 1); + }); + + it('returns 100% returnRate when all settled trades expired OTM', () => { + const rows = [ + makeRow({ outcome: 'EXPIRED' }), + makeRow({ outcome: 'EXPIRED' }), + ]; + const s = calcPremiumStats(rows); + assert.equal(s.returnRate, 100); + assert.equal(s.otmCount, 2); + assert.equal(s.itmCount, 0); + assert.equal(s.settled, 2); + }); + + it('computes portfolioAPR as notional-weighted average of annual', () => { + // notional = strike * size + // row1: notional=1000, annual=40 → weight contrib = 40000 + // row2: notional=2000, annual=60 → weight contrib = 120000 + // weighted avg = 160000 / 3000 ≈ 53.333... + const rows = [ + makeRow({ outcome: 'EXPIRED', strike: 1000, size: 1, annual: 40 }), + makeRow({ outcome: 'EXPIRED', strike: 1000, size: 2, annual: 60 }), + ]; + const s = calcPremiumStats(rows); + assert.ok(Math.abs(s.portfolioAPR - (40 * 1000 + 60 * 2000) / 3000) < 0.001); + }); + + it('returns correct shape with expected keys', () => { + const s = calcPremiumStats([]); + const expected = ['totalPrem', 'totalNotional', 'totalCount', 'otmCount', 'itmCount', 'openCount', 'settled', 'returnRate', 'portfolioAPR']; + for (const k of expected) { + assert.ok(Object.prototype.hasOwnProperty.call(s, k), `missing key: ${k}`); + } + // dead fields must not be present + assert.ok(!Object.prototype.hasOwnProperty.call(s, 'assignmentLoss')); + assert.ok(!Object.prototype.hasOwnProperty.call(s, 'callAwayCredit')); + assert.ok(!Object.prototype.hasOwnProperty.call(s, 'netPnl')); + }); +});