From 09fd3554b5ca7aa87752cca8bbf17595a81f0557 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Sat, 5 Sep 2026 08:22:29 +0800 Subject: [PATCH] Count CLOSED (buy-to-close) as a settled position calcPremiumStats only treated EXPIRED/ASSIGNED/CALLED as settled, so buy-to-close positions showed '0 positions settled' and were excluded from Return Rate. Count CLOSED as settled (premium kept, no assignment) and fix the Total Premium tooltip which wrongly claimed 'gross' when the value is net of buy-to-close costs. --- src/js/core/05d-calc-stats.js | 10 ++++++---- src/js/core/07-render-charts.js | 4 ++-- test/unit/calc-stats.test.js | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/js/core/05d-calc-stats.js b/src/js/core/05d-calc-stats.js index e34038c..d0895b1 100644 --- a/src/js/core/05d-calc-stats.js +++ b/src/js/core/05d-calc-stats.js @@ -1,6 +1,6 @@ function calcPremiumStats(rows) { let totalPrem = 0, totalNotional = 0, totalCount = 0; - let otmCount = 0, itmCount = 0, openCount = 0; + let otmCount = 0, itmCount = 0, closedCount = 0, openCount = 0; let aprWeightedSum = 0, aprWeightTotal = 0; rows.forEach(r => { @@ -13,17 +13,19 @@ function calcPremiumStats(rows) { if (r.outcome === 'OPEN') { openCount++; } else if (r.outcome === 'EXPIRED') { otmCount++; } else if (r.outcome === 'ASSIGNED' || r.outcome === 'CALLED') { itmCount++; } + else if (r.outcome === 'CLOSED') { closedCount++; } if (r.annual != null) { aprWeightedSum += r.annual * notional; aprWeightTotal += notional; } }); - const settled = otmCount + itmCount; - const returnRate = settled > 0 ? otmCount / settled * 100 : null; + // CLOSED (buy-to-close) is a settled outcome: premium kept, no assignment/call-away. + const settled = otmCount + itmCount + closedCount; + const returnRate = settled > 0 ? (otmCount + closedCount) / settled * 100 : null; const portfolioAPR = aprWeightTotal > 0 ? aprWeightedSum / aprWeightTotal : null; - return { totalPrem, totalNotional, totalCount, otmCount, itmCount, openCount, settled, returnRate, portfolioAPR }; + return { totalPrem, totalNotional, totalCount, otmCount, itmCount, closedCount, openCount, settled, returnRate, portfolioAPR }; } if (typeof module !== 'undefined' && module.exports) { diff --git a/src/js/core/07-render-charts.js b/src/js/core/07-render-charts.js index 82cef2f..a5e9732 100644 --- a/src/js/core/07-render-charts.js +++ b/src/js/core/07-render-charts.js @@ -398,7 +398,7 @@ function rCharts(displayRows, lots) { 'Total Premium Collected', s.totalCount > 0 ? '$' + fmt(s.totalPrem) : dash, s.totalCount > 0 ? pos(s.settled) + ' settled' + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : '', - 'Sum of every option premium collected (gross of buy-to-close costs). Includes settled and open positions.') + + 'Sum of every option premium collected, net of buy-to-close costs. Includes settled and open positions.') + '
' + (_isTradfi() ? '' : tile('', 'Total Notional', s.totalNotional > 0 ? '$' + fmt(s.totalNotional) : dash, @@ -411,7 +411,7 @@ function rCharts(displayRows, lots) { tile('', 'Return Rate', s.returnRate !== null ? s.returnRate.toFixed(1) + '%' : dash, s.settled > 0 ? s.otmCount + ' / ' + s.settled + ' exp OTM' : '', - 'Share of settled options that expired OTM (premium kept, no assignment/call-away). Open options excluded.') + + 'Share of settled options that kept their premium with no assignment/call-away (expired OTM or bought back early). Open options excluded.') + '
'; } else { diff --git a/test/unit/calc-stats.test.js b/test/unit/calc-stats.test.js index 8b4e6a7..023a190 100644 --- a/test/unit/calc-stats.test.js +++ b/test/unit/calc-stats.test.js @@ -50,6 +50,19 @@ describe('calcPremiumStats', () => { assert.equal(s.settled, 2); }); + it('counts CLOSED (buy-to-close) as a settled outcome, net of closeCost', () => { + const rows = [ + makeRow({ outcome: 'CLOSED', premium: 45, closeCost: 37 }), + makeRow({ outcome: 'CLOSED', premium: 65, closeCost: 12 }), + ]; + const s = calcPremiumStats(rows); + assert.equal(s.closedCount, 2); + assert.equal(s.settled, 2); + assert.equal(s.totalPrem, (45 - 37) + (65 - 12)); + // closed-early with no assignment counts as premium kept + assert.equal(s.returnRate, 100); + }); + it('computes portfolioAPR as notional-weighted average of annual', () => { // notional = strike * size // row1: notional=1000, annual=40 → weight contrib = 40000 @@ -65,7 +78,7 @@ describe('calcPremiumStats', () => { it('returns correct shape with expected keys', () => { const s = calcPremiumStats([]); - const expected = ['totalPrem', 'totalNotional', 'totalCount', 'otmCount', 'itmCount', 'openCount', 'settled', 'returnRate', 'portfolioAPR']; + const expected = ['totalPrem', 'totalNotional', 'totalCount', 'otmCount', 'itmCount', 'closedCount', 'openCount', 'settled', 'returnRate', 'portfolioAPR']; for (const k of expected) { assert.ok(Object.prototype.hasOwnProperty.call(s, k), `missing key: ${k}`); }