// Generated, do not edit. sha256:52b988fcd7bf7c33b612444857cca01f2a6b410502dbb572577c4f862592f0cc import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { dagreeks, dagenTussen, deltasVan, inlegVan, uitVan, dagVan, jaarVensters, meetVenster, windowDelta, windowResult, metSpotVandaag, ownView, dailyResults, seriesFor, rollingWindows, rollingReturns, todayResults, accountSeries, managedCostPots } from '../../client/wealth/series.js'; import { timeWeighted, dailyFlows, windowTwr } from '../../client/wealth/performance.js'; const BTC = { id: 1, decimals: 8 }; const EUR = { id: 2, decimals: 2 }; const grid = (vanafDag, koersen) => { const start = Date.parse(`${vanafDag}T00:00:00Z`); return koersen.map((k, i) => [start + i * 86400000, k]); }; const ev = (o) => ({ type: 'buy', amount: 0, fiatPrice: null, fiatFee: null, unitFee: null, fromAccountId: null, toAccountId: null, ...o }); const anker = (o) => ({ accountId: 1, assetId: BTC.id, balance: 0, rateAt: '2026-01-01T00:00:00Z', ...o }); describe('dagenTussen', () => { it('geeft beide grenzen mee', () => { assert.deepEqual(dagenTussen('2026-01-01', '2026-01-03'), ['2026-01-01', '2026-01-02', '2026-01-03']); }); it('geeft één dag als begin en eind gelijk zijn', () => { assert.deepEqual(dagenTussen('2026-01-01', '2026-01-01'), ['2026-01-01']); }); it('geeft niets als het eind vóór het begin ligt', () => { assert.deepEqual(dagenTussen('2026-02-01', '2026-01-01'), []); }); it('loopt over een maand- en jaargrens zonder dag te verliezen', () => { const d = dagenTussen('2025-12-30', '2026-01-02'); assert.deepEqual(d, ['2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02']); }); it('telt in hele dagen sinds epoch, dus een zomertijdgrens verschuift niets', () => { const d = dagenTussen('2026-03-28', '2026-03-31'); assert.deepEqual(d, ['2026-03-28', '2026-03-29', '2026-03-30', '2026-03-31']); }); }); describe('dagVan', () => { it('neemt de UTC-dag uit een tijdstempel', () => { assert.equal(dagVan('2026-03-01T23:59:59.999Z'), '2026-03-01'); }); }); describe('deltasVan', () => { it('telt een aankoop bij de ontvangende rekening op', () => { assert.deepEqual(deltasVan(ev({ type: 'buy', amount: 500, toAccountId: 1, assetId: 1 })), [{ sleutel: '1|1', delta: 500 }]); }); it('haalt een verkoop van de verzendende rekening af', () => { assert.deepEqual(deltasVan(ev({ type: 'sell', amount: 200, fromAccountId: 1, assetId: 1 })), [{ sleutel: '1|1', delta: -200 }]); }); it('haalt bij een verkoop ook de fee af, net als het grootboek (19-10-2026)', () => { assert.deepEqual(deltasVan(ev({ type: 'sell', amount: 200, unitFee: 3, fromAccountId: 1, assetId: 1 })), [{ sleutel: '1|1', delta: -203 }]); }); it('telt rente op, net als een aankoop', () => { assert.deepEqual(deltasVan(ev({ type: 'interest', amount: 30, toAccountId: 2, assetId: 1 })), [{ sleutel: '2|1', delta: 30 }]); }); it('laat een verplaatsing de kosten alléén bij de verzender kwijtraken', () => { assert.deepEqual(deltasVan(ev({ type: 'transfer', amount: 100, unitFee: 7, fromAccountId: 1, toAccountId: 2, assetId: 1 })), [{ sleutel: '1|1', delta: -107 }, { sleutel: '2|1', delta: 100 }]); }); it('houdt bij een verplaatsing naar JEZELF alleen de kosten over', () => { const d = deltasVan(ev({ type: 'transfer', amount: 100, unitFee: 7, fromAccountId: 1, toAccountId: 1, assetId: 1 })); assert.equal(d.reduce((s, x) => s + x.delta, 0), -7); }); it('zet een boeking zonder rekening in een eigen emmer in plaats van hem te laten vallen', () => { assert.deepEqual(deltasVan(ev({ type: 'buy', amount: 500, assetId: 1 })), [{ sleutel: 'geen|1', delta: 500 }]); }); it('telt een verplaatsing van BUITEN alleen bij de ontvanger, en een naar buiten alleen bij de verzender', () => { assert.deepEqual(deltasVan(ev({ type: 'transfer', amount: 100, toAccountId: 2, assetId: 1 })), [{ sleutel: '2|1', delta: 100 }]); assert.deepEqual(deltasVan(ev({ type: 'transfer', amount: 100, unitFee: 7, fromAccountId: 1, assetId: 1 })), [{ sleutel: '1|1', delta: -107 }]); }); }); describe('inlegVan', () => { it('telt een aankoop met zijn kosten op', () => { assert.equal(inlegVan(ev({ type: 'buy', amount: 1e8, fiatPrice: 30000, fiatFee: 12 }), 1e8), 30012); }); it('haalt de opbrengst van een verkoop eraf, maar de kosten niet', () => { assert.equal(inlegVan(ev({ type: 'sell', amount: 1e8, fiatPrice: 30000, fiatFee: 12 }), 1e8), -29988); }); it('telt rente NIET als inleg', () => { assert.equal(inlegVan(ev({ type: 'interest', amount: 1e8, fiatPrice: 30000 }), 1e8), 0); }); it('telt een verplaatsing NIET als inleg', () => { assert.equal(inlegVan(ev({ type: 'transfer', amount: 1e8, fromAccountId: 1, toAccountId: 2 }), 1e8), 0); }); it('telt de euro-fee van een verplaatsing wél, en alleen met een eigen verzendende kant', () => { assert.equal(inlegVan(ev({ type: 'transfer', amount: 1e8, fiatFee: 2, fromAccountId: 1, toAccountId: 2 }), 1e8), 2); assert.equal(inlegVan(ev({ type: 'transfer', amount: 1e8, fiatFee: 2, fromAccountId: null, toAccountId: 2 }), 1e8), 0); }); }); describe('uitVan', () => { it('geeft de netto opbrengst van een verkoop, zodat inleg plus uit nul is', () => { const verkoop = ev({ type: 'sell', amount: 1e8, fiatPrice: 30000, fiatFee: 12 }); assert.equal(uitVan(verkoop, 1e8), 29988); assert.equal(inlegVan(verkoop, 1e8) + uitVan(verkoop, 1e8), 0); assert.equal(uitVan(ev({ type: 'buy', amount: 1e8, fiatPrice: 30000 }), 1e8), 0); }); it('komt per asset en in het totaal in de reeks', () => { const r = dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100])]]), vanDag: '2026-01-01', totDag: '2026-01-02', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 2e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'sell', assetId: BTC.id, amount: 1e8, fiatPrice: 110, fiatFee: 1, fromAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ], }); assert.deepEqual(r.uitPerAsset.get(BTC.id), [0, 109]); assert.deepEqual(r.uitgehaald, [0, 109]); }); }); describe('dagreeks: rente en beloningen', () => { const r = dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 110])]]), vanDag: '2026-01-01', totDag: '2026-01-03', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'interest', assetId: BTC.id, amount: 1e8, toAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ], }); it('houdt het ontvangen bedrag bij tegen de koers van de dag', () => { assert.deepEqual(r.rentePerAsset.get(BTC.id), [0, 100, 100]); }); it('laat het rendement alleen de koers zien', () => { const tw = windowTwr({ waarden: r.perAsset.get(BTC.id), inleg: r.inlegPerAsset.get(BTC.id), ankers: r.ankersPerAsset.get(BTC.id), uit: r.uitPerAsset.get(BTC.id), eenheden: r.eenhedenPerAsset.get(BTC.id), eersteDag: r.eersteDagPerAsset.get(BTC.id), }, { van: 0, tot: 2 }); assert.equal(Math.round(tw * 1e4) / 1e4, 0.1, 'van 100 naar 110 is +10%, de beloning telt niet'); }); }); describe('dagreeks: de SOL-verkoop met stof', () => { const SOL = { id: 3, decimals: 9 }; const dagen = dagenTussen('2025-07-29', '2025-10-01'); const koersen = dagen.map(d => (d < '2025-08-12' ? 160 : d === '2025-08-12' ? 162.8 : 185)); const r = dagreeks({ assets: [SOL], prijzen: new Map([[SOL.id, grid('2025-07-29', koersen)]]), vanDag: '2025-07-29', totDag: '2025-10-01', events: [ ev({ type: 'buy', assetId: SOL.id, amount: 315300000, fiatPrice: 158.52, fiatFee: 0.12, toAccountId: 1, occurredAt: '2025-07-29T10:00:00Z' }), ev({ type: 'sell', assetId: SOL.id, amount: 315172000, fiatPrice: 162.8, fiatFee: 0.08, fromAccountId: 1, occurredAt: '2025-08-12T10:00:00Z' }), ev({ type: 'sell', assetId: SOL.id, amount: 128000, fiatPrice: 0.02 / 0.000128, fiatFee: 0.01, fromAccountId: 1, occurredAt: '2025-09-29T10:00:00Z' }), ], }); it('meet alleen de koers tot de verkoop, en niet het stof', () => { const tw = windowTwr({ waarden: r.perAsset.get(SOL.id), inleg: r.inlegPerAsset.get(SOL.id), ankers: r.ankersPerAsset.get(SOL.id), uit: r.uitPerAsset.get(SOL.id), eenheden: r.eenhedenPerAsset.get(SOL.id), eersteDag: r.eersteDagPerAsset.get(SOL.id), }, { van: 0, tot: r.dagen.length - 1 }); const betaald = 0.3153 * 158.52 + 0.12; const terug = 0.315172 * 162.8 - 0.08 + 0.000128 * 162.8; assert.equal(Math.round(tw * 1e4) / 1e4, Math.round((terug / betaald - 1) * 1e4) / 1e4); }); it('heropent de meting niet voor een stakingbeloning op het stof', () => { const metStaking = dagreeks({ assets: [SOL], prijzen: new Map([[SOL.id, grid('2025-07-29', koersen)]]), vanDag: '2025-07-29', totDag: '2025-10-01', events: [ ev({ type: 'buy', assetId: SOL.id, amount: 315300000, fiatPrice: 158.52, fiatFee: 0.12, toAccountId: 1, occurredAt: '2025-07-29T10:00:00Z' }), ev({ type: 'sell', assetId: SOL.id, amount: 315172000, fiatPrice: 162.8, fiatFee: 0.08, fromAccountId: 1, occurredAt: '2025-08-12T10:00:00Z' }), ev({ type: 'interest', assetId: SOL.id, amount: 200, toAccountId: 1, occurredAt: '2025-08-19T10:00:00Z' }), ev({ type: 'interest', assetId: SOL.id, amount: 200, toAccountId: 1, occurredAt: '2025-08-26T10:00:00Z' }), ev({ type: 'sell', assetId: SOL.id, amount: 128400, fiatPrice: 0.02 / 0.0001284, fiatFee: 0.01, fromAccountId: 1, occurredAt: '2025-09-29T10:00:00Z' }), ], }); const tw = windowTwr({ waarden: metStaking.perAsset.get(SOL.id), inleg: metStaking.inlegPerAsset.get(SOL.id), ankers: metStaking.ankersPerAsset.get(SOL.id), uit: metStaking.uitPerAsset.get(SOL.id), eenheden: metStaking.eenhedenPerAsset.get(SOL.id), eersteDag: metStaking.eersteDagPerAsset.get(SOL.id), }, { van: 0, tot: metStaking.dagen.length - 1 }); const betaald = 0.3153 * 158.52 + 0.12; const terug = 0.315172 * 162.8 - 0.08 + 0.000128 * 162.8; assert.equal(Math.round(tw * 1e4) / 1e4, Math.round((terug / betaald - 1) * 1e4) / 1e4); }); }); describe('windowDelta', () => { const r = [0, 10, 10, 10, 10]; const jaar1 = meetVenster({ van: 0, tot: 0 }, r.length); const jaar2 = meetVenster({ van: 1, tot: 3 }, r.length); it('telt een boeking op 1 januari in het jaar ervoor, zoals het rendement', () => { assert.equal(windowDelta(r, jaar1), 10); assert.equal(windowDelta(r, jaar2), 0); }); it('laat de jaren optellen tot het geheel', () => { const heel = meetVenster({ van: 0, tot: 4 }, r.length); assert.equal(windowDelta(r, jaar1) + windowDelta(r, jaar2), windowDelta(r, heel)); }); }); describe('windowResult', () => { it('is de waardeverandering min de inleg, met rente erin en een balanssprong eruit', () => { const reeks = { waarden: [0, 100, 130], inleg: [0, 100, 100], ankers: [0, 0, 20], rente: [0, 0, 5], }; assert.equal(windowResult(reeks, { van: 0, tot: 2 }), 15, '+10 koers en +5 rente'); }); it('is voor LINK eind 2025 gelijk aan ongerealiseerd', () => { assert.equal(Math.round(windowResult({ waarden: [0, 53.58], inleg: [0, 99.99] }, { van: 0, tot: 1 }) * 100) / 100, -46.41); }); }); describe('dagreeks: gerealiseerd', () => { const r = dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 100])]]), vanDag: '2026-01-01', totDag: '2026-01-03', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 2e8, fiatPrice: 100, fiatFee: 2, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'sell', assetId: BTC.id, amount: 1e8, fiatPrice: 120, fiatFee: 1, fromAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ], }); it('telt een verkoop op zijn dag, tegen de kostprijs inclusief kosten', () => { assert.deepEqual(r.gerealiseerdPerAsset.get(BTC.id), [0, 18, 18]); assert.deepEqual(r.onbekendPerAsset.get(BTC.id), [0, 0, 0]); }); it('telt een verkoop zonder bekende kostprijs als onbekend en niet als winst', () => { const z = dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100])]]), vanDag: '2026-01-01', totDag: '2026-01-02', events: [ ev({ type: 'interest', assetId: BTC.id, amount: 1e8, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'sell', assetId: BTC.id, amount: 1e8, fiatPrice: 100, fromAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ], }); assert.deepEqual(z.gerealiseerdPerAsset.get(BTC.id), [0, 0]); assert.deepEqual(z.onbekendPerAsset.get(BTC.id), [0, 1]); }); const verplaatsing = (o) => dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100])]]), vanDag: '2026-01-01', totDag: '2026-01-02', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 2e8, fiatPrice: 100, fiatFee: 2, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 1e8, unitFee: 0.1e8, occurredAt: '2026-01-02T10:00:00Z', ...o }), ], }); it('telt de fee van een verplaatsing tegen de kostprijs als verlies', () => { for (const kanten of [{ fromAccountId: 1, toAccountId: 2 }, { fromAccountId: 1, toAccountId: 1 }]) { const r = verplaatsing(kanten); assert.deepEqual(r.gerealiseerdPerAsset.get(BTC.id).map(x => Math.round(x * 100) / 100), [0, -10.1]); } }); it('telt de euro-fee van een verplaatsing als gerealiseerd verlies', () => { const r = verplaatsing({ fromAccountId: 1, toAccountId: 2, unitFee: null, fiatFee: 3 }); assert.deepEqual(r.gerealiseerdPerAsset.get(BTC.id), [0, -3]); assert.deepEqual(verplaatsing({ fromAccountId: null, toAccountId: 1, unitFee: null, fiatFee: 3 }) .gerealiseerdPerAsset.get(BTC.id), [0, 0], 'zonder eigen verzendende kant niet'); }); it('rekent geen fee als verlies zonder eigen verzendende kant', () => { const r = verplaatsing({ fromAccountId: null, toAccountId: 1 }); assert.deepEqual(r.gerealiseerdPerAsset.get(BTC.id), [0, 0]); assert.deepEqual(r.onbekendPerAsset.get(BTC.id), [0, 0]); }); }); describe('dagreeks', () => { const basis = (extra = {}) => ({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 200, 200])]]), vanDag: '2026-01-01', totDag: '2026-01-04', ...extra, }); it('geeft een lege reeks zonder gebeurtenissen en zonder ankers', () => { assert.deepEqual(dagreeks({ assets: [BTC], prijzen: new Map() }), { dagen: [], waarde: [], ingelegd: [], uitgehaald: [], perAsset: new Map(), inlegPerAsset: new Map(), uitPerAsset: new Map(), ankersPerAsset: new Map(), rentePerAsset: new Map(), gerealiseerdPerAsset: new Map(), onbekendPerAsset: new Map(), nietVerklaardPerAsset: new Map(), koersPerAsset: new Map(), eenhedenPerAsset: new Map(), aankopen: new Map(), verkopen: new Map(), eersteDagPerAsset: new Map(), perAccount: new Map(), inlegPerAccount: new Map(), uitPerAccount: new Map(), ankersPerAccount: new Map(), rentePerAccount: new Map(), nietVerklaardPerAccount: new Map(), bezitPerAccount: new Map(), eersteDagPerAccount: new Map(), standNu: [] }); }); it('levert de hoeveelheid per dag per asset, in de kleinste eenheid', () => { const r = dagreeks(basis({ events: [ ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ev({ assetId: BTC.id, type: 'sell', amount: 1e8, fiatPrice: 200, fromAccountId: 1, occurredAt: '2026-01-04T10:00:00Z' }), ], })); assert.deepEqual(r.eenhedenPerAsset.get(BTC.id), [0, 1e8, 1e8, 0]); }); it('houdt de hoeveelheid ook bij als er geen koers is', () => { const r = dagreeks(basis({ prijzen: new Map(), events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' })], })); assert.deepEqual(r.perAsset.get(BTC.id), [0, 0, 0, 0]); assert.deepEqual(r.eenhedenPerAsset.get(BTC.id), [0, 1e8, 1e8, 1e8]); }); it('levert de koers per dag per asset, met dezelfde forward-fill als de waardelijn', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], })); assert.deepEqual(r.koersPerAsset.get(BTC.id), [100, 100, 200, 200]); assert.deepEqual(r.waarde, r.koersPerAsset.get(BTC.id)); }); it('geeft `null` op een dag waarvan geen koers bekend is, en niet nul', () => { const r = dagreeks(basis({ prijzen: new Map([[BTC.id, grid('2026-01-03', [200, 200])]]), events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], })); assert.deepEqual(r.koersPerAsset.get(BTC.id), [null, null, 200, 200]); }); it('houdt de stand vast vanaf de dag van de boeking', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' })], })); assert.deepEqual(r.dagen, ['2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04']); assert.deepEqual(r.waarde, [0, 100, 200, 200]); }); it('boekt een gebeurtenis van laat op de avond op zijn eigen dag', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-02T23:59:00Z' })], })); assert.deepEqual(r.waarde, [0, 100, 200, 200]); }); it('laat een anker de stand van dat paar overschrijven', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], valuations: [anker({ accountId: 1, assetId: BTC.id, balance: 3e8, rateAt: '2026-01-03T00:00:00Z' })], })); assert.deepEqual(r.waarde, [100, 100, 600, 600]); }); it('laat boekingen ná het anker weer verschuiven', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'sell', amount: 1e8, fromAccountId: 1, fiatPrice: 200, occurredAt: '2026-01-04T10:00:00Z' })], valuations: [anker({ accountId: 1, assetId: BTC.id, balance: 3e8, rateAt: '2026-01-01T00:00:00Z' })], })); assert.deepEqual(r.waarde, [300, 300, 600, 400], 'dag 4: 2 BTC over, tegen 200'); }); it('negeert een portefeuille-brede waardering, want dat is geen anker', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], valuations: [anker({ accountId: null, balance: 99e8, rateAt: '2026-01-02T00:00:00Z' })], })); assert.deepEqual(r.waarde, [100, 100, 200, 200]); }); it('telt de ankers van twee rekeningen bij elkaar op', () => { const r = dagreeks(basis({ valuations: [ anker({ accountId: 1, balance: 1e8, rateAt: '2026-01-01T00:00:00Z' }), anker({ accountId: 2, balance: 2e8, rateAt: '2026-01-01T00:00:00Z' }), ], })); assert.deepEqual(r.waarde, [300, 300, 600, 600]); }); it('geeft geen waarde vóór het begin van de koershistorie, en geen nul', () => { const r = dagreeks(basis({ prijzen: new Map([[BTC.id, grid('2026-01-03', [200, 200])]]), events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], })); assert.deepEqual(r.waarde, [0, 0, 200, 200]); }); it('houdt de inleg cumulatief bij, los van de waarde', () => { const r = dagreeks(basis({ events: [ ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, fiatFee: 5, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-03T10:00:00Z' }), ], })); assert.deepEqual(r.ingelegd, [105, 105, 205, 205]); assert.deepEqual(r.waarde, [100, 100, 400, 400], 'en de waarde volgt de koers, niet de inleg'); }); it('splitst de waarde per asset uit, en die tellen op tot het totaal', () => { const r = dagreeks({ assets: [BTC, EUR], prijzen: new Map([ [BTC.id, grid('2026-01-01', [100, 100])], [EUR.id, grid('2026-01-01', [1, 1])], ]), vanDag: '2026-01-01', totDag: '2026-01-02', events: [ ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T00:00:00Z' }), ev({ assetId: EUR.id, type: 'buy', amount: 5000, fiatPrice: 1, toAccountId: 1, occurredAt: '2026-01-01T00:00:00Z' }), ], }); assert.deepEqual(r.perAsset.get(BTC.id), [100, 100]); assert.deepEqual(r.perAsset.get(EUR.id), [50, 50]); r.dagen.forEach((_, i) => { assert.equal(r.perAsset.get(BTC.id)[i] + r.perAsset.get(EUR.id)[i], r.waarde[i], `dag ${i}`); }); }); it('begint bij de vroegste gebeurtenis als er geen begindag is opgegeven', () => { const r = dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-05', [100, 100, 100])]]), events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-05T10:00:00Z' })], totDag: '2026-01-07', }); assert.deepEqual(r.dagen, ['2026-01-05', '2026-01-06', '2026-01-07']); }); it('telt een boeking zonder rekening gewoon mee in het vermogen', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, occurredAt: '2026-01-01T10:00:00Z' })], })); assert.deepEqual(r.waarde, [100, 100, 200, 200]); }); it('begint op de opgegeven begindag, ook als er eerder al geboekt is', () => { const r = dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2020-01-01', Array(2500).fill(100))]]), events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2020-06-01T00:00:00Z' })], vanDag: '2026-01-01', totDag: '2026-01-02', }); assert.deepEqual(r.dagen, ['2026-01-01', '2026-01-02']); assert.deepEqual(r.waarde, [100, 100], 'de aankoop van 2020 telt gewoon mee'); }); it('verwerkt binnen één dag eerst de boeking en dan het latere anker', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 5e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-02T08:00:00Z' })], valuations: [anker({ accountId: 1, balance: 1e8, rateAt: '2026-01-02T09:00:00Z' })], })); assert.deepEqual(r.waarde, [0, 100, 200, 200], 'het anker wint, want hij is later die dag'); }); }); describe('dagreeks: inleg per asset en boekingsdagen', () => { const basis = (extra = {}) => ({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 200, 200])]]), vanDag: '2026-01-01', totDag: '2026-01-04', ...extra, }); const koop = (dag, amount = 1e8) => ev({ assetId: BTC.id, type: 'buy', amount, fiatPrice: 100, toAccountId: 1, occurredAt: `${dag}T10:00:00Z`, }); it('splitst de inleg uit per asset, cumulatief', () => { const r = dagreeks(basis({ events: [ ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, fiatFee: 5, toAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 200, toAccountId: 1, occurredAt: '2026-01-03T10:00:00Z' }), ], })); assert.deepEqual(r.inlegPerAsset.get(BTC.id), [0, 105, 305, 305]); }); it('telt de inleg van een ánder asset er niet bij op', () => { const r = dagreeks(basis({ assets: [BTC, EUR], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 200, 200])], [EUR.id, grid('2026-01-01', [1, 1, 1, 1])]]), events: [ ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ev({ assetId: EUR.id, type: 'buy', amount: 5000, fiatPrice: 1, toAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), ], })); assert.deepEqual(r.inlegPerAsset.get(BTC.id), [0, 100, 100, 100]); assert.deepEqual(r.inlegPerAsset.get(EUR.id), [0, 50, 50, 50]); assert.deepEqual(r.ingelegd, [0, 150, 150, 150], 'en samen zijn ze de portefeuille-brede inleg'); }); it('markeert de dag van een aankoop, als index in `dagen`', () => { const r = dagreeks(basis({ events: [koop('2026-01-02'), koop('2026-01-04')] })); assert.deepEqual(r.aankopen.get(BTC.id), [1, 3]); }); it('ontdubbelt twee aankopen op dezelfde dag tot één stip', () => { const r = dagreeks(basis({ events: [koop('2026-01-02'), koop('2026-01-02', 2e8)] })); assert.deepEqual(r.aankopen.get(BTC.id), [1]); }); it('houdt aankopen en verkopen uit elkaar, en negeert een verplaatsing', () => { const r = dagreeks(basis({ events: [ koop('2026-01-02'), ev({ assetId: BTC.id, type: 'sell', amount: 5e7, fiatPrice: 200, fromAccountId: 1, occurredAt: '2026-01-03T10:00:00Z' }), ev({ assetId: BTC.id, type: 'transfer', amount: 1e7, fromAccountId: 1, toAccountId: 2, occurredAt: '2026-01-04T10:00:00Z' }), ], })); assert.deepEqual(r.aankopen.get(BTC.id), [1]); assert.deepEqual(r.verkopen.get(BTC.id), [2], 'de verplaatsing van dag 4 hoort er niet bij'); }); it('zet een verkoop van vóór de begindag ook niet op dag nul', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'sell', amount: 1e7, fiatPrice: 100, fromAccountId: 1, occurredAt: '2025-06-01T10:00:00Z' })], })); assert.deepEqual(r.verkopen.get(BTC.id), []); }); it('zet een aankoop van vóór de begindag NIET op dag nul', () => { const r = dagreeks(basis({ events: [koop('2025-06-01'), koop('2026-01-03')] })); assert.deepEqual(r.aankopen.get(BTC.id), [2]); assert.deepEqual(r.perAsset.get(BTC.id), [100, 100, 400, 400], 'maar de stand telt hem wél mee'); }); }); describe('dagreeks: ankersPerAsset', () => { const basis = (extra = {}) => ({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 100, 100])]]), vanDag: '2026-01-01', totDag: '2026-01-04', ...extra, }); it('vangt het verschil tussen een balans en wat de boekingen zeggen', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], valuations: [anker({ accountId: 1, balance: 2e8, rateAt: '2026-01-03T10:00:00Z' })], })); assert.deepEqual(r.ankersPerAsset.get(BTC.id), [0, 0, 100, 100], 'cumulatief, vanaf de dag zelf'); assert.deepEqual(r.nietVerklaardPerAsset.get(BTC.id), [0, 0, 100, 100], 'en dít is een bevinding, want er wás geboekt voor dit paar'); }); it('blijft nul als de balans bevestigt wat de boekingen al zeiden', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], valuations: [anker({ accountId: 1, balance: 1e8, rateAt: '2026-01-03T10:00:00Z' })], })); assert.deepEqual(r.ankersPerAsset.get(BTC.id), [0, 0, 0, 0]); }); it('gaat de andere kant op als de balans LAGER is', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 2e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], valuations: [anker({ accountId: 1, balance: 1e8, rateAt: '2026-01-03T10:00:00Z' })], })); assert.deepEqual(r.ankersPerAsset.get(BTC.id), [0, 0, -100, -100]); }); it('SPLITST EEN BEGINBALANS AF VAN EEN BEVINDING, en dat is het verschil dat telt', () => { const r = dagreeks(basis({ valuations: [anker({ accountId: 1, balance: 1e8, rateAt: '2026-01-02T10:00:00Z' })], })); assert.equal(r.ankersPerAsset.get(BTC.id).at(-1), 100, 'het is wél een ankersprong'); assert.equal(r.nietVerklaardPerAsset.get(BTC.id).at(-1), 0, 'maar geen bevinding'); }); it('meldt een AANVAARD verschil ook niet', () => { const r = dagreeks(basis({ events: [ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' })], valuations: [anker({ accountId: 1, balance: 2e8, rateAt: '2026-01-03T10:00:00Z', gapAcceptedAt: '2026-01-03T12:00:00Z' })], })); assert.equal(r.ankersPerAsset.get(BTC.id).at(-1), 100); assert.equal(r.nietVerklaardPerAsset.get(BTC.id).at(-1), 0, 'aanvaard is afgehandeld'); }); it('waardeert de sprong op de koers van DIE dag en niet van vandaag', () => { const r = dagreeks(basis({ prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 100, 500])]]), valuations: [anker({ accountId: 1, balance: 1e8, rateAt: '2026-01-03T10:00:00Z' })], })); assert.equal(r.ankersPerAsset.get(BTC.id).at(-1), 100, 'op 100 en niet op 500'); }); }); describe('dagreeks: eersteDagPerAsset', () => { const basis = (extra = {}) => ({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 200, 200])]]), vanDag: '2026-01-01', totDag: '2026-01-04', ...extra, }); const koop = (dag) => ev({ assetId: BTC.id, type: 'buy', amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: `${dag}T10:00:00Z`, }); it('geeft de eerste dag waarop je het asset hád', () => { assert.equal(dagreeks(basis({ events: [koop('2026-01-03')] })).eersteDagPerAsset.get(BTC.id), 2); }); it('geeft nul als je het al vóór de periode had', () => { assert.equal(dagreeks(basis({ events: [koop('2025-06-01')] })).eersteDagPerAsset.get(BTC.id), 0); }); it('geeft null als je het asset in deze periode nooit had', () => { assert.equal(dagreeks(basis({ events: [] })).eersteDagPerAsset.get(BTC.id), null); }); it('blijft op de eerste dag staan als je tussendoor op nul komt', () => { const r = dagreeks(basis({ events: [ koop('2026-01-01'), ev({ assetId: BTC.id, type: 'sell', amount: 1e8, fiatPrice: 100, fromAccountId: 1, occurredAt: '2026-01-02T10:00:00Z' }), koop('2026-01-04'), ], })); assert.equal(r.eersteDagPerAsset.get(BTC.id), 0); }); }); describe('jaarVensters', () => { it('geeft per jaar de eerste en de laatste index', () => { const v = jaarVensters(['2024-12-30', '2024-12-31', '2025-01-01', '2025-01-02']); assert.deepEqual(v.get(2024), { van: 0, tot: 1 }); assert.deepEqual(v.get(2025), { van: 2, tot: 3 }); }); it('geeft een venster van één dag als het jaar er maar één heeft', () => { const v = jaarVensters(['2025-12-31', '2026-01-01']); assert.deepEqual(v.get(2026), { van: 1, tot: 1 }); }); it('houdt de jaren in de volgorde waarin ze voorkomen', () => { assert.deepEqual([...jaarVensters(['2023-01-01', '2024-01-01', '2025-01-01']).keys()], [2023, 2024, 2025]); }); it('valt niet om op een lege reeks of op onzin', () => { assert.equal(jaarVensters([]).size, 0); assert.equal(jaarVensters(['geen datum']).size, 0); }); }); describe('meetVenster', () => { it('eindigt één dag later dan het tekenvenster, en nooit voorbij het einde', () => { assert.deepEqual(meetVenster({ van: 10, tot: 20 }, 100), { van: 10, tot: 21 }); assert.deepEqual(meetVenster({ van: 10, tot: 99 }, 100), { van: 10, tot: 99 }); }); it('verlengt aan het EIND en niet aan het begin, want zo leest deze app een jaar', () => { assert.equal(meetVenster({ van: 366, tot: 730 }, 977).van, 366, 'het begin blijft 1 januari'); }); it('KETENT tot precies het geheel, over drie kalenderjaren mét stortingen', () => { const dagen = []; for (const jaar of [2024, 2025, 2026]) { for (const dag of ['12-30', '12-31']) dagen.push(`${jaar}-${dag}`); for (const dag of ['01-01', '01-02', '01-03']) dagen.push(`${jaar + 1}-${dag}`); } dagen.sort(); const waarde = [100, 104, 130, 128, 133, 140, 138, 175, 171, 180, 190, 187, 240, 235, 250]; const ingelegd = [0, 0, 20, 20, 20, 20, 20, 50, 50, 50, 50, 50, 50, 50, 50]; assert.equal(dagen.length, waarde.length); assert.equal(dagen.length, ingelegd.length); const rendement = ({ van, tot }) => timeWeighted( waarde.slice(van, tot + 1), dailyFlows(ingelegd.slice(van, tot + 1))); const heel = { van: 0, tot: dagen.length - 1 }; const perJaar = [...jaarVensters(dagen).values()] .map(v => rendement(meetVenster(v, dagen.length))); assert.ok(perJaar.length >= 3, 'de reeks hoort meerdere kalenderjaren te beslaan'); const geketend = perJaar.reduce((f, r) => f * (1 + (r ?? 0)), 1) - 1; assert.ok(Math.abs(geketend - rendement(heel)) < 1e-12, `geketend ${geketend} hoort gelijk te zijn aan het geheel ${rendement(heel)}`); }); it('en ZONDER meetVenster komt het níet uit, want dan mist elke jaargrens een dag', () => { const dagen = ['2024-12-30', '2024-12-31', '2025-01-01', '2025-01-02', '2025-12-31', '2026-01-01']; const waarde = [100, 110, 150, 160, 200, 260]; const rendement = ({ van, tot }) => timeWeighted(waarde.slice(van, tot + 1), []); const heel = { van: 0, tot: dagen.length - 1 }; const vensters = [...jaarVensters(dagen).values()]; const zonder = vensters.map(rendement).reduce((f, r) => f * (1 + (r ?? 0)), 1) - 1; const met = vensters.map(v => rendement(meetVenster(v, dagen.length))) .reduce((f, r) => f * (1 + (r ?? 0)), 1) - 1; assert.ok(Math.abs(met - rendement(heel)) < 1e-12, 'mét het meetvenster klopt het'); assert.ok(Math.abs(zonder - rendement(heel)) > 0.1, 'en zonder scheelt het fors: de sprongen op 1 januari vallen dan weg'); }); }); describe('metSpotVandaag', () => { const reeksVan = (dagen, koersen, eenheden) => ({ dagen, waarde: koersen.map((k, i) => (eenheden[i] / 1e8) * k), koersPerAsset: new Map([[BTC.id, [...koersen]]]), perAsset: new Map([[BTC.id, koersen.map((k, i) => (eenheden[i] / 1e8) * k)]]), eenhedenPerAsset: new Map([[BTC.id, [...eenheden]]]), }); it('zet de koers, de waarde en het dagtotaal van vandaag op de spot', () => { const r = reeksVan(['2026-09-21', '2026-09-22'], [100, 110], [2e8, 2e8]); metSpotVandaag(r, [BTC], new Map([[BTC.id, 130]]), '2026-09-22'); assert.equal(r.koersPerAsset.get(BTC.id)[1], 130); assert.equal(r.perAsset.get(BTC.id)[1], 260, '2 BTC tegen 130'); assert.equal(r.waarde[1], 260, 'het dagtotaal telt dezelfde waarde opnieuw op'); }); it('laat de dagen ERVOOR ongemoeid, want dat zijn slotkoersen', () => { const r = reeksVan(['2026-09-21', '2026-09-22'], [100, 110], [2e8, 2e8]); metSpotVandaag(r, [BTC], new Map([[BTC.id, 130]]), '2026-09-22'); assert.equal(r.koersPerAsset.get(BTC.id)[0], 100); assert.equal(r.perAsset.get(BTC.id)[0], 200); }); it('doet NIETS als de reeks niet tot vandaag loopt', () => { const r = reeksVan(['2026-09-20', '2026-09-21'], [100, 110], [2e8, 2e8]); metSpotVandaag(r, [BTC], new Map([[BTC.id, 130]]), '2026-09-22'); assert.deepEqual(r.koersPerAsset.get(BTC.id), [100, 110]); assert.deepEqual(r.waarde, [200, 220]); }); it('houdt de opgeslagen koers aan zonder bruikbare spot', () => { const r = reeksVan(['2026-09-21', '2026-09-22'], [100, 110], [2e8, 2e8]); metSpotVandaag(r, [BTC], new Map([[BTC.id, 0]]), '2026-09-22'); assert.equal(r.koersPerAsset.get(BTC.id)[1], 110); assert.equal(r.waarde[1], 220); metSpotVandaag(r, [BTC], new Map(), '2026-09-22'); assert.equal(r.koersPerAsset.get(BTC.id)[1], 110); }); it('telt een asset ZONDER spot gewoon mee in het dagtotaal', () => { const r = { dagen: ['2026-09-22'], waarde: [300], koersPerAsset: new Map([[BTC.id, [100]], [EUR.id, [1]]]), perAsset: new Map([[BTC.id, [200]], [EUR.id, [100]]]), eenhedenPerAsset: new Map([[BTC.id, [2e8]], [EUR.id, [10000]]]), }; metSpotVandaag(r, [BTC, EUR], new Map([[BTC.id, 150]]), '2026-09-22'); assert.equal(r.perAsset.get(BTC.id)[0], 300); assert.equal(r.perAsset.get(EUR.id)[0], 100, 'ongemoeid'); assert.equal(r.waarde[0], 400, '300 + 100, dus de euro-rekening telt mee'); }); it('valt niet om op een lege reeks', () => { const leeg = { dagen: [], waarde: [], koersPerAsset: new Map(), perAsset: new Map(), eenhedenPerAsset: new Map() }; assert.equal(metSpotVandaag(leeg, [BTC], new Map([[BTC.id, 130]]), '2026-09-22'), leeg); }); }); describe('dagreeks: eigen vermogen', () => { const EIGEN = 1, SPAARPOT = 9; const buiten = new Set([SPAARPOT]); const basis = (extra = []) => ({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 110, 110, 110])]]), vanDag: '2026-01-01', totDag: '2026-01-05', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 1e8, fiatPrice: 100, toAccountId: EIGEN, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 0.5e8, fromAccountId: EIGEN, toAccountId: SPAARPOT, occurredAt: '2026-01-02T10:00:00Z' }), ev({ type: 'buy', assetId: BTC.id, amount: 2e8, fiatPrice: 100, toAccountId: SPAARPOT, occurredAt: '2026-01-02T11:00:00Z' }), ...extra, ], valuations: [anker({ accountId: SPAARPOT, balance: 9e8, rateAt: '2026-01-04T00:00:00Z' })], }); const meet = r => ({ tw: windowTwr({ waarden: r.perAsset.get(BTC.id), inleg: r.inlegPerAsset.get(BTC.id), ankers: r.ankersPerAsset.get(BTC.id), uit: r.uitPerAsset.get(BTC.id), eenheden: r.eenhedenPerAsset.get(BTC.id), eersteDag: r.eersteDagPerAsset.get(BTC.id), }, { van: 0, tot: 4 }), resultaat: windowResult({ waarden: r.perAsset.get(BTC.id), inleg: r.inlegPerAsset.get(BTC.id), ankers: r.ankersPerAsset.get(BTC.id), rente: r.rentePerAsset.get(BTC.id), }, { van: 0, tot: 4 }), }); const rond = x => Math.round(x * 1e6) / 1e6; it('ziet zonder grens alles, met de spaarpot erbij', () => { const r = dagreeks(basis()); assert.equal(r.waarde[4], 9e8 / 1e8 * 110 + 0.5 * 110, 'de balans op de spaarpot telt mee'); }); it('telt een overboeking naar de spaarpot als onttrekking en niet als verlies', () => { const r = dagreeks({ ...basis(), excludedAccounts: buiten }); assert.deepEqual(r.waarde.map(rond), [100, 50, 55, 55, 55]); assert.deepEqual(r.ankersPerAsset.get(BTC.id).map(rond), [0, -50, -50, -50, -50], 'de halve BTC vertrok tegen 100'); const { tw, resultaat } = meet(r); assert.equal(rond(tw), 0.1, 'alleen de koers: van 100 naar 110'); assert.equal(rond(resultaat), 5, 'een halve BTC die 10 steeg'); }); it('telt een aankoop en een balans op de spaarpot niet als inleg of stand', () => { const r = dagreeks({ ...basis(), excludedAccounts: buiten }); assert.deepEqual(r.ingelegd.map(rond), [100, 100, 100, 100, 100]); assert.deepEqual(r.aankopen.get(BTC.id), [0], 'geen aankoopstip voor de spaarpot'); }); it('telt een overboeking terug als storting', () => { const terug = ev({ type: 'transfer', assetId: BTC.id, amount: 0.2e8, fromAccountId: SPAARPOT, toAccountId: EIGEN, occurredAt: '2026-01-04T10:00:00Z' }); const r = dagreeks({ ...basis([terug]), excludedAccounts: buiten }); assert.equal(rond(r.waarde[4]), 77); assert.equal(rond(meet(r).resultaat), 5, 'de storting is geen winst'); }); it('houdt de fee van een overboeking als kost in het resultaat', () => { const r = dagreeks({ ...basis(), excludedAccounts: buiten, events: basis().events.map(e => (e.type === 'transfer' ? { ...e, unitFee: 0.01e8 } : e)) }); assert.equal(rond(meet(r).resultaat), 3.9, '0,49 BTC die 10 steeg, min 1 fee'); }); }); describe('managedCostPots', () => { const EIGEN = 1, KIND = 9, ANDER = 8; const prijzen = new Map([[BTC.id, grid('2026-01-01', [100, 120, 90, 150, 150])]]); const pot = (events, managed = new Set([KIND]), totIso = null) => managedCostPots({ events, prijzen, managed, totIso }).get(`${KIND}|${BTC.id}`); const koop = ev({ type: 'buy', assetId: BTC.id, amount: 1e8, fiatPrice: 100, toAccountId: EIGEN, occurredAt: '2026-01-01T10:00:00Z' }); const naarKind = ev({ type: 'transfer', assetId: BTC.id, amount: 0.5e8, fromAccountId: EIGEN, toAccountId: KIND, occurredAt: '2026-01-03T10:00:00Z' }); it('neemt de dagkoers van binnenkomst en niet de aankoop van eigen vermogen', () => { const p = pot([koop, naarKind]); assert.equal(p.buy_amount, 0.5e8); assert.equal(p.buy_spot_raw / p.buy_amount, 90, 'de koers van 3 januari, niet de 100 van de aankoop'); assert.equal(p.buy_events, 0, 'een binnenkomst is geen aankoop voor de gemiddelde premie'); }); it('telt een aankoop op het beheerde account zelf met prijs en kosten', () => { const p = pot([koop, naarKind, ev({ type: 'buy', assetId: BTC.id, amount: 0.5e8, fiatPrice: 150, fiatFee: 2, toAccountId: KIND, occurredAt: '2026-01-04T10:00:00Z' })]); assert.equal(p.buy_amount, 1e8); assert.equal(p.buy_spot_raw / p.buy_amount, 120, 'half tegen 90, half tegen 150'); assert.equal(p.buy_fee_total, 2); assert.equal(p.buy_events, 1); }); it('laat het gemiddelde staan als er weer iets teruggaat naar eigen vermogen', () => { const terug = ev({ type: 'transfer', assetId: BTC.id, amount: 0.2e8, fromAccountId: KIND, toAccountId: EIGEN, occurredAt: '2026-01-04T10:00:00Z' }); const p = pot([koop, naarKind, terug]); assert.equal(p.buy_amount, 0.5e8, 'wat vertrekt verandert de aggregaten niet'); assert.equal(p.buy_spot_raw / p.buy_amount, 90); assert.equal(managedCostPots({ events: [koop, naarKind, terug], prijzen, managed: new Set([KIND]) }).has(`${EIGEN}|${BTC.id}`), false, 'eigen vermogen krijgt hier geen pot: dat blijft positionsByAsset'); }); it('telt een verplaatsing tussen twee beheerde accounts als binnenkomst bij de ontvanger', () => { const doorschuiven = ev({ type: 'transfer', assetId: BTC.id, amount: 0.5e8, fromAccountId: KIND, toAccountId: ANDER, occurredAt: '2026-01-04T10:00:00Z' }); const potten = managedCostPots({ events: [koop, naarKind, doorschuiven], prijzen, managed: new Set([KIND, ANDER]) }); assert.equal(potten.get(`${ANDER}|${BTC.id}`).buy_spot_raw / 0.5e8, 150); }); it('voegt zonder dagkoers niets toe, en telt niets na het peilmoment', () => { const vroeg = { ...naarKind, occurredAt: '2025-12-31T10:00:00Z' }; assert.equal(pot([koop, vroeg]).buy_amount, 0, 'vóór het rooster is er geen koers'); assert.equal(pot([koop, naarKind], new Set([KIND]), '2026-01-02T23:59:59Z'), undefined); }); it('telt een gift op het beheerde account als gift en een interne verplaatsing niet', () => { const gift = ev({ type: 'buy', assetId: BTC.id, amount: 0.1e8, fiatPrice: 0, priceOrigin: 'none', toAccountId: KIND, occurredAt: '2026-01-02T10:00:00Z' }); const zelf = ev({ type: 'transfer', assetId: BTC.id, amount: 0.1e8, fromAccountId: KIND, toAccountId: KIND, occurredAt: '2026-01-03T09:00:00Z' }); const p = pot([gift, zelf]); assert.equal(p.gift_amount, 0.1e8); assert.equal(p.buy_amount, 0); }); it('geeft niets zonder beheerde accounts', () => { assert.equal(managedCostPots({ events: [koop, naarKind], prijzen, managed: new Set() }).size, 0); }); }); describe('dagreeks: de eenzijdige verplaatsing', () => { const reeks = (verplaatsing) => dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 110])]]), vanDag: '2026-01-01', totDag: '2026-01-03', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 0.4e8, occurredAt: '2026-01-02T10:00:00Z', ...verplaatsing }), ], }); const resultaat = r => windowResult({ waarden: r.perAsset.get(BTC.id), inleg: r.inlegPerAsset.get(BTC.id), ankers: r.ankersPerAsset.get(BTC.id), rente: r.rentePerAsset.get(BTC.id) }, { van: 0, tot: 2 }); it('telt wat naar buiten gaat als onttrekking en wat binnenkomt als storting, niet als resultaat', () => { assert.equal(Math.round(resultaat(reeks({ fromAccountId: 1, toAccountId: null })) * 100) / 100, 6); assert.equal(Math.round(resultaat(reeks({ fromAccountId: null, toAccountId: 1 })) * 100) / 100, 14); }); it('laat een verplaatsing met twee kanten buiten de stromen, ook met "niets tegenover"', () => { for (const priceOrigin of [null, 'none']) { const r = reeks({ fromAccountId: 1, toAccountId: 2, priceOrigin }); assert.deepEqual(r.ankersPerAsset.get(BTC.id), [0, 0, 0], `herkomst ${priceOrigin}`); } }); }); describe('ownView', () => { const buiten = new Set([9]); it('laat een boeking die helemaal buiten valt weg, en houdt een boeking zonder account', () => { assert.equal(ownView(ev({ type: 'transfer', fromAccountId: 9, toAccountId: 9 }), buiten), null); assert.equal(ownView(ev({ type: 'transfer', fromAccountId: 9, toAccountId: null }), buiten), null); assert.equal(ownView(ev({ type: 'sell', fromAccountId: 9 }), buiten), null); assert.equal(ownView(ev({ type: 'interest', toAccountId: 9 }), buiten), null); const zonder = ev({ type: 'buy', toAccountId: null }); assert.equal(ownView(zonder, buiten), zonder); }); it('maakt van een overboeking over de grens een eenzijdige, met de richting', () => { assert.deepEqual(ownView(ev({ type: 'transfer', fromAccountId: 1, toAccountId: 9 }), buiten), ev({ type: 'transfer', fromAccountId: 1, toAccountId: null, crossing: -1 })); assert.deepEqual(ownView(ev({ type: 'transfer', fromAccountId: 9, toAccountId: 1 }), buiten), ev({ type: 'transfer', fromAccountId: null, toAccountId: 1, crossing: 1 })); }); }); describe('rendement per dag en per periode', () => { const EIGEN = 1, SPAARPOT = 9; const eigenReeks = () => dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 110, 110, 110])]]), vanDag: '2026-01-01', totDag: '2026-01-05', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 1e8, fiatPrice: 100, toAccountId: EIGEN, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 0.5e8, fromAccountId: EIGEN, toAccountId: SPAARPOT, occurredAt: '2026-01-02T10:00:00Z' }), ], valuations: [], excludedAccounts: new Set([SPAARPOT]), }); const rond = x => (x == null ? x : Math.round(x * 1e6) / 1e6); it('maakt van een overboeking naar een beheerd account geen verliesdag', () => { assert.deepEqual(dailyResults(seriesFor(eigenReeks())).map(rond), [0, 0, 5, 0, 0]); }); it('telt per dag op tot windowResult, ook voor het venster dat op dag nul begint', () => { const invoer = { waarden: [104.77, 90, 95], inleg: [99.84, 99.84, 99.84], ankers: [0, 0, 0], rente: [0, 0, 0] }; const dag = dailyResults(invoer); const som = (van, tot) => dag.slice(van > 0 ? van + 1 : 0, tot + 1).reduce((s, v) => s + v, 0); for (const v of [{ van: 0, tot: 2 }, { van: 1, tot: 2 }, { van: 0, tot: 1 }]) { assert.equal(rond(som(v.van, v.tot)), rond(windowResult(invoer, v)), `venster ${v.van} t/m ${v.tot}`); } assert.equal(rond(dag[0]), rond(104.77 - 99.84), 'de aankoopdag'); }); it('telt rente als resultaat, ook al loopt hij als stroom mee in de ankers', () => { assert.deepEqual(dailyResults({ waarden: [100, 101], inleg: [100, 100], ankers: [0, 1], rente: [0, 1] }), [0, 1]); }); it('seriesFor: het totaal telt ankers en rente per dag over de assets op', () => { const reeks = { waarde: [10, 20], ingelegd: [10, 10], uitgehaald: [0, 0], ankersPerAsset: new Map([[1, [0, 1]], [2, [2, 3]]]), rentePerAsset: new Map([[1, [0, 1]], [2, [0, 0]]]), eenhedenPerAsset: new Map([[1, [5, 5]]]), }; const totaal = seriesFor(reeks); assert.deepEqual(totaal.ankers, [2, 4]); assert.deepEqual(totaal.rente, [0, 1]); assert.equal(totaal.eersteDag, 0); assert.equal(totaal.eenheden, undefined, 'het totaal heeft geen positie die op nul kan lopen'); assert.deepEqual(seriesFor(reeks, 1).eenheden, [5, 5]); assert.equal(seriesFor(reeks, 1).eersteDag, 0, 'zonder eerste dag telt de reeks vanaf het begin'); }); it('rollingWindows: een periode eindigt op de laatste dag en meet n stappen', () => { assert.deepEqual(rollingWindows(400), [ { days: 1, venster: { van: 398, tot: 399 } }, { days: 7, venster: { van: 392, tot: 399 } }, { days: 30, venster: { van: 369, tot: 399 } }, { days: 365, venster: { van: 34, tot: 399 } }, { days: null, venster: { van: 0, tot: 399 } }, ]); }); it('rollingWindows: een periode die tot de eerste dag reikt of langer is, heeft geen venster', () => { const v = rollingWindows(31); assert.deepEqual(v.map(p => p.venster && p.venster.van), [29, 23, null, null, 0]); assert.deepEqual(rollingWindows(0), []); }); it('todayResults: het resultaat van de laatste dag, alleen als die vandaag is', () => { const r = dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 110])]]), vanDag: '2026-01-01', totDag: '2026-01-03', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 1e8, fiatPrice: 100, toAccountId: EIGEN, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 0.5e8, fromAccountId: EIGEN, toAccountId: SPAARPOT, occurredAt: '2026-01-02T10:00:00Z' }), ], valuations: [], excludedAccounts: new Set([SPAARPOT]), }); const v = todayResults(r, '2026-01-03'); assert.equal(rond(v.total), 5); assert.equal(rond(v.perAsset.get(BTC.id)), 5); assert.equal(todayResults(r, '2026-01-04'), null, 'een reeks die gisteren eindigt heeft geen vandaag'); }); it('rollingReturns: bedrag en percentage per periode, met dezelfde sommen als de jaarkeuze', () => { const invoer = seriesFor(eigenReeks()); const [dag, week, , , start] = rollingReturns(invoer); assert.deepEqual({ ...dag, amount: rond(dag.amount), pct: rond(dag.pct) }, { days: 1, venster: { van: 3, tot: 4 }, amount: 0, pct: 0 }); assert.deepEqual(week, { days: 7, venster: null, amount: null, pct: null }); assert.equal(rond(start.amount), 5, 'een halve BTC die 10 steeg'); assert.equal(rond(start.pct), 0.1, 'alleen de koers: van 100 naar 110'); }); }); describe('dagreeks per account', () => { const rond = v => Math.round(v * 1e6) / 1e6; const verplaatst = () => dagreeks({ assets: [BTC], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 100, 120])]]), vanDag: '2026-01-01', totDag: '2026-01-03', events: [ ev({ type: 'buy', assetId: BTC.id, amount: 1e8, fiatPrice: 100, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 99e6, unitFee: 1e6, fromAccountId: 1, toAccountId: 2, occurredAt: '2026-01-02T10:00:00Z' }), ], }); it('telt een verplaatsing tussen eigen accounts als opname en storting tegen de dagkoers', () => { const r = verplaatst(); const heel = { van: 0, tot: 2 }; assert.equal(rond(windowTwr(accountSeries(r, 1), heel)), -0.01, 'alleen de fee is weg'); assert.equal(rond(windowTwr(accountSeries(r, 2), heel)), 0.2, 'alleen de koers'); assert.equal(rond(windowResult(accountSeries(r, 2), heel)), 19.8); assert.equal(rond(windowResult(accountSeries(r, 1), heel)), -1); }); it('telt per dag op tot de reeks van het geheel', () => { const EUR_ = { id: 3, decimals: 2 }; const r = dagreeks({ assets: [BTC, EUR_], prijzen: new Map([[BTC.id, grid('2026-01-01', [100, 110, 90, 95])], [EUR_.id, grid('2026-01-01', [1, 1, 1, 1])]]), vanDag: '2026-01-01', totDag: '2026-01-04', valuations: [anker({ accountId: 2, balance: 5e7, rateAt: '2026-01-03T12:00:00Z' })], events: [ ev({ type: 'buy', assetId: BTC.id, amount: 2e8, fiatPrice: 100, fiatFee: 2, toAccountId: 1, occurredAt: '2026-01-01T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 1e8, unitFee: 1e5, fiatFee: 1, fromAccountId: 1, toAccountId: 2, occurredAt: '2026-01-02T10:00:00Z' }), ev({ type: 'interest', assetId: BTC.id, amount: 1e6, toAccountId: 2, occurredAt: '2026-01-02T11:00:00Z' }), ev({ type: 'buy', assetId: BTC.id, amount: 1e7, priceOrigin: 'none', toAccountId: 1, occurredAt: '2026-01-03T10:00:00Z' }), ev({ type: 'transfer', assetId: BTC.id, amount: 2e7, fromAccountId: 1, toAccountId: null, occurredAt: '2026-01-03T11:00:00Z' }), ev({ type: 'sell', assetId: BTC.id, amount: 3e7, fiatPrice: 95, fromAccountId: 1, occurredAt: '2026-01-04T10:00:00Z' }), ev({ type: 'buy', assetId: EUR_.id, amount: 5000, fiatPrice: 1, toAccountId: null, occurredAt: '2026-01-04T11:00:00Z' }), ], }); const som = map => r.dagen.map((_, i) => rond([...map.values()].reduce((s, a) => s + (a[i] || 0), 0))); assert.deepEqual(som(r.perAccount), r.waarde.map(rond)); assert.deepEqual(som(r.inlegPerAccount), r.ingelegd.map(rond)); assert.deepEqual(som(r.uitPerAccount), r.uitgehaald.map(rond)); assert.deepEqual(som(r.ankersPerAccount), som(r.ankersPerAsset)); assert.deepEqual(som(r.rentePerAccount), som(r.rentePerAsset)); assert.ok(r.perAccount.has(null), 'een boeking zonder account krijgt een eigen emmer'); assert.equal(rond(r.perAccount.get(null)[3]), 50); }); it('metSpotVandaag zet ook de laatste dag per account op de spot', () => { const r = verplaatst(); const vandaag = r.dagen[r.dagen.length - 1]; metSpotVandaag(r, [BTC], new Map([[BTC.id, 200]]), vandaag); assert.equal(rond(r.perAccount.get(2)[2]), 198); assert.equal(rond(r.perAccount.get(1)[2]), 0); }); });