1122 lines
53 KiB
JavaScript
1122 lines
53 KiB
JavaScript
// 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);
|
||
|
|
});
|
||
|
|
});
|