diff --git a/src/js/core/05-compute.js b/src/js/core/05-compute.js index 872f244..9afcdb4 100644 --- a/src/js/core/05-compute.js +++ b/src/js/core/05-compute.js @@ -13,6 +13,9 @@ function compute(assetFilter) { const assets = [...new Set(trades.map(t => t.asset))]; const streams = {}; const lots = {}; + // Legs that were rolled into a new option (Wheeler roll). Their buy-back is + // part of the roll, so a standalone APR would be misleading — left null. + const rolled = new Set(trades.filter(t => t.rolledFrom != null).map(t => t.rolledFrom)); assets.forEach(a => { const assetTrades = trades.filter(t => t.asset === a) @@ -38,7 +41,11 @@ function compute(assetFilter) { const collateral = t.strike * t.size; const returnPct = collateral > 0 ? (netPrem / collateral) * 100 : null; - const dte = t.dte && t.dte > 0 ? t.dte : null; + // A buy-to-close is annualised over the days actually held (open → close + // date, min 1), not the full term; rolled legs get no APR at all. + const held = t.outcome === 'CLOSED' && t.closeDate + ? Math.max(1, Math.round((new Date(t.closeDate) - new Date(t.date)) / 86400000)) : null; + const dte = rolled.has(t.id) ? null : held || (t.dte && t.dte > 0 ? t.dte : null); const monthly = (returnPct !== null && dte) ? returnPct / dte * 30 : null; const annual = (returnPct !== null && dte) ? returnPct / dte * 365 : null; diff --git a/test/unit/compute.test.js b/test/unit/compute.test.js index 55dabf0..20a2f17 100644 --- a/test/unit/compute.test.js +++ b/test/unit/compute.test.js @@ -41,3 +41,23 @@ test('compute derives the roster from trades[] — arbitrary tickers work', () = // TSLA lot: costBasis 200, lotPremiums 50 (call), size 10 → 200 - 50/10 = 195 assert.strictEqual(lots.TSLA[0].netCost, 195); }); + +test('CLOSED APR annualises over days held; rolled legs get no APR', () => { + global.trades = [ + // Closed early: held 6 of 11 days. net 51 / 7550 collateral → annualised over 6d. + { id: 1, asset: 'MES', type: 'PUT', date: '2026-09-28', expiry: '2026-10-09', dte: 11, strike: 7550, size: 1, premium: 68.5, closeCost: 17.5, closeDate: '2026-10-04', outcome: 'CLOSED' }, + // Closed with no closeDate: falls back to the full term. + { id: 2, asset: 'MES', type: 'PUT', date: '2026-09-01', expiry: '2026-09-11', dte: 10, strike: 7000, size: 1, premium: 50, closeCost: 10, closeDate: '', outcome: 'CLOSED' }, + // Rolled leg (id 3 → 4): APR suppressed. + { id: 3, asset: 'PURR', type: 'PUT', date: '2026-10-06', expiry: '2026-11-20', dte: 45, strike: 11, size: 200, premium: 160, closeCost: 448, closeDate: '2026-10-15', outcome: 'CLOSED' }, + { id: 4, asset: 'PURR', type: 'PUT', date: '2026-10-15', expiry: '2027-03-19', dte: 155, strike: 10, size: 200, premium: 502, closeCost: 0, closeDate: '', outcome: 'OPEN', rolledFrom: 3 }, + ]; + const { compute } = require('../../src/js/core/05-compute.js'); + const row = id => compute('ALL').allRows.find(r => r.id === id); + + assert.strictEqual(row(1).annual.toFixed(1), (51 / 7550 * 100 / 6 * 365).toFixed(1)); + assert.strictEqual(row(2).annual.toFixed(1), (40 / 7000 * 100 / 10 * 365).toFixed(1)); + assert.strictEqual(row(3).annual, null); + assert.strictEqual(row(3).monthly, null); + assert.strictEqual(row(4).annual.toFixed(1), (502 / 2000 * 100 / 155 * 365).toFixed(1)); +});