Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -0,0 +1,94 @@
|
||||
// Generated, do not edit. sha256:0f6993661d02e71fdfac02fe8654043545e492226f4b4b24bcf5f37c477e0be0
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { accountCards } from '../../client/wealth/account-cards.js';
|
||||
|
||||
const BTC = { id: 1, symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', decimals: 8 };
|
||||
const EUR = { id: 2, symbol: 'EUR', name: 'Euro', kind: 'fiat', decimals: 2 };
|
||||
const kaart = (asset, net, fiatPrice, unrealized = null) => ({ asset, p: { id: asset.id, net }, m: { fiatPrice, unrealized, origin: null } });
|
||||
const accounts = [
|
||||
{ id: 10, name: 'Koude wallet', kind: 'wallet' },
|
||||
{ id: 11, name: 'Beurs', kind: 'exchange' },
|
||||
{ id: 12, name: 'Bank', kind: 'bank' },
|
||||
];
|
||||
const rond = v => Math.round(v * 1e6) / 1e6;
|
||||
|
||||
describe('accountCards', () => {
|
||||
it('telt per account op tot de assetkaarten, ongerealiseerd naar rato van de eenheden', () => {
|
||||
const cards = accountCards({
|
||||
kaarten: [kaart(BTC, 3e8, 100, 60), kaart(EUR, 50000, 1)],
|
||||
holdings: [
|
||||
{ accountId: 10, assetId: BTC.id, saldo: 2e8 },
|
||||
{ accountId: 11, assetId: BTC.id, saldo: 1e8 },
|
||||
{ accountId: 11, assetId: EUR.id, saldo: 50000 },
|
||||
],
|
||||
accounts,
|
||||
});
|
||||
assert.deepEqual(cards.map(c => c.accountId), [11, 10], 'beurs vóór wallet: de volgorde van de soorten');
|
||||
const [beurs, wallet] = cards;
|
||||
assert.equal(rond(wallet.value), 200);
|
||||
assert.equal(rond(wallet.unrealized), 40);
|
||||
assert.equal(rond(beurs.value), 600);
|
||||
assert.equal(rond(beurs.unrealized), 20, 'de euro heeft geen kostprijs en telt niet mee');
|
||||
assert.deepEqual(beurs.kinds.sort(), ['crypto', 'fiat']);
|
||||
});
|
||||
|
||||
it('geeft wat op geen account staat een eigen kaart, achteraan', () => {
|
||||
const cards = accountCards({
|
||||
kaarten: [kaart(BTC, 3e8, 100)],
|
||||
holdings: [{ accountId: 10, assetId: BTC.id, saldo: 2e8 }],
|
||||
accounts,
|
||||
});
|
||||
assert.deepEqual(cards.map(c => c.accountId), [10, null]);
|
||||
assert.equal(rond(cards[1].value), 100);
|
||||
assert.equal(cards[1].account, null);
|
||||
});
|
||||
|
||||
it('laat waarde en ongerealiseerd leeg zonder koers, en niet nul', () => {
|
||||
const [c] = accountCards({
|
||||
kaarten: [kaart(BTC, 1e8, null, null)],
|
||||
holdings: [{ accountId: 10, assetId: BTC.id, saldo: 1e8 }],
|
||||
accounts,
|
||||
});
|
||||
assert.equal(c.value, null);
|
||||
assert.equal(c.unrealized, null);
|
||||
assert.equal(c.lines.length, 1);
|
||||
});
|
||||
|
||||
it('toont een account dat in de periode bewoog ook zonder saldo, en een onbekend id zonder saldo niet', () => {
|
||||
const cards = accountCards({
|
||||
kaarten: [kaart(BTC, 1e8, 100)],
|
||||
holdings: [{ accountId: 10, assetId: BTC.id, saldo: 1e8 }],
|
||||
accounts,
|
||||
toon: new Set([12, 99]),
|
||||
});
|
||||
assert.deepEqual(cards.map(c => c.accountId), [10, 12]);
|
||||
assert.equal(cards[1].value, null);
|
||||
});
|
||||
|
||||
it('geeft een beheerd account zijn eigen deel, en de eigen accounts het eigen deel naar rato', () => {
|
||||
const k = kaart(BTC, 3e8, 100, 30);
|
||||
k.m.eigen = { net: 2e8, unrealized: 40 };
|
||||
k.m.beheerd = [{ accountId: 10, saldo: 1e8, unrealized: -10 }];
|
||||
const cards = accountCards({
|
||||
kaarten: [k],
|
||||
holdings: [{ accountId: 10, assetId: BTC.id, saldo: 1e8 }, { accountId: 11, assetId: BTC.id, saldo: 1.5e8 }],
|
||||
accounts,
|
||||
});
|
||||
const per = new Map(cards.map(c => [c.accountId, c]));
|
||||
assert.equal(rond(per.get(10).unrealized), -10, 'het beheerde account tegen zijn eigen kostprijs');
|
||||
assert.equal(rond(per.get(11).unrealized), 30, 'driekwart van het eigen deel van 40');
|
||||
assert.equal(rond(per.get(null).unrealized), 10, 'de rest zonder account: een kwart van het eigen deel');
|
||||
assert.equal(rond(cards.reduce((s, c) => s + c.unrealized, 0)), 30, 'samen weer de assetkaart');
|
||||
});
|
||||
|
||||
it('negeert een saldo van een asset zonder kaart, zodat de som de pagina volgt', () => {
|
||||
const cards = accountCards({
|
||||
kaarten: [kaart(BTC, 1e8, 100)],
|
||||
holdings: [{ accountId: 10, assetId: BTC.id, saldo: 1e8 }, { accountId: 10, assetId: 77, saldo: 5 }],
|
||||
accounts,
|
||||
});
|
||||
assert.equal(cards.length, 1);
|
||||
assert.deepEqual(cards[0].assetIds, [BTC.id]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Generated, do not edit. sha256:515b7630b75a6d1f882ae0d4ae11739fa997ad5145e50b0f07c78592fad5f7e2
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { carriedPerAsset, carriedDays } from '../../client/wealth/carried.js';
|
||||
|
||||
const dagen = (van, n) => Array.from({ length: n }, (_, i) =>
|
||||
new Date(Date.parse(`${van}T00:00:00Z`) + i * 86_400_000).toISOString().slice(0, 10));
|
||||
const rijen = (...ds) => ds.map(d => ({ observedAt: `${d}T00:00:00Z`, fiatPrice: 100 }));
|
||||
const BTC = { id: 1, kind: 'crypto' };
|
||||
const XAU = { id: 2, kind: 'metal' };
|
||||
|
||||
describe('carriedPerAsset', () => {
|
||||
it('markeert bij crypto elke dag zonder koers, en niet de dagen ervoor', () => {
|
||||
const d = dagen('2026-03-01', 6);
|
||||
const v = carriedPerAsset({ dagen: d, assets: [BTC], rijenPerAsset: new Map([[1, rijen('2026-03-02', '2026-03-05')]]) });
|
||||
assert.deepEqual(v.get(1), [false, false, true, true, false, false]);
|
||||
});
|
||||
|
||||
it('markeert bij metaal een weekend niet, en een week zonder koers wel', () => {
|
||||
const d = dagen('2026-03-06', 11);
|
||||
const weekend = carriedPerAsset({ dagen: d, assets: [XAU],
|
||||
rijenPerAsset: new Map([[2, rijen('2026-03-06', '2026-03-09', '2026-03-10', '2026-03-11', '2026-03-12', '2026-03-13', '2026-03-14', '2026-03-15', '2026-03-16')]]) });
|
||||
assert.ok(weekend.get(2).every(x => !x), 'twee dagen dicht is geen gat');
|
||||
const week = carriedPerAsset({ dagen: d, assets: [XAU], rijenPerAsset: new Map([[2, rijen('2026-03-06', '2026-03-16')]]) });
|
||||
assert.deepEqual(week.get(2), [false, true, true, true, true, true, true, true, true, true, false]);
|
||||
});
|
||||
|
||||
it('telt de laatste dag als geprijsd, laat fiat weg en negeert een koers van nul', () => {
|
||||
const d = dagen('2026-03-01', 3);
|
||||
const v = carriedPerAsset({ dagen: d, assets: [BTC, { id: 3, kind: 'fiat' }],
|
||||
rijenPerAsset: new Map([[1, [...rijen('2026-03-01'), { observedAt: '2026-03-02T00:00:00Z', fiatPrice: 0 }]]]) });
|
||||
assert.deepEqual(v.get(1), [false, true, false]);
|
||||
assert.equal(v.has(3), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('carriedDays', () => {
|
||||
it('neemt alleen dagen waarop je het asset had, over de gekozen assets', () => {
|
||||
const d = dagen('2026-03-01', 5);
|
||||
const reeks = { dagen: d,
|
||||
rijenPerAsset: new Map([[1, rijen('2026-03-01')], [2, rijen('2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04')]]),
|
||||
eenhedenPerAsset: new Map([[1, [1, 1, 0, 0, 0]], [2, [1, 1, 1, 1, 1]]]) };
|
||||
assert.deepEqual([...carriedDays(reeks, null, [BTC, XAU])], [1]);
|
||||
assert.deepEqual([...carriedDays(reeks, [2], [BTC, XAU])], []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// Generated, do not edit. sha256:f1e6b320617af57c85b4584dfef9c069d414816f5a7ef1ec466834b230e30b89
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ringData, dim, segmentIndices, uitsteek, nadrukKleuren, glansStops, outerEdge,
|
||||
} from '../../client/wealth/chart.js';
|
||||
|
||||
describe('ringData', () => {
|
||||
let volgendId = 1;
|
||||
const kaart = (symbol, kind, value) => ({ asset: { id: volgendId++, symbol, kind }, m: { value } });
|
||||
|
||||
it('sorteert de assets aflopend op waarde', () => {
|
||||
const d = ringData([kaart('XAU', 'metal', 500), kaart('BTC', 'crypto', 2000), kaart('EUR', 'fiat', 100)]);
|
||||
assert.deepEqual(d.assets.map(a => a.label), ['BTC', 'XAU', 'EUR']);
|
||||
});
|
||||
|
||||
it('telt de soorten op en houdt de volgorde van de enum aan', () => {
|
||||
const d = ringData([kaart('XAU', 'metal', 900), kaart('BTC', 'crypto', 100), kaart('XAG', 'metal', 100)]);
|
||||
assert.deepEqual(d.soorten.map(s => s.value), [100, 1000], 'crypto vóór metaal, ook al is metaal groter');
|
||||
});
|
||||
|
||||
it('geeft een soortsegment de id\'s van zijn assets mee', () => {
|
||||
const xau = kaart('XAU', 'metal', 900);
|
||||
const xag = kaart('XAG', 'metal', 100);
|
||||
const d = ringData([xau, kaart('BTC', 'crypto', 500), xag]);
|
||||
const metaal = d.soorten.find(s => s.kind === 'metal');
|
||||
assert.deepEqual(metaal.ids, [xau.asset.id, xag.asset.id]);
|
||||
assert.deepEqual(metaal.kinds, ['metal'], 'en zijn eigen soort, zodat één matcher beide ringen dient');
|
||||
});
|
||||
|
||||
it('laat een asset zonder koers uit de grafiek', () => {
|
||||
const d = ringData([kaart('BTC', 'crypto', 100), kaart('XAU', 'metal', null)]);
|
||||
assert.deepEqual(d.assets.map(a => a.label), ['BTC']);
|
||||
assert.equal(d.soorten.length, 1);
|
||||
});
|
||||
|
||||
it('laat nul en negatief er ook uit', () => {
|
||||
const d = ringData([kaart('BTC', 'crypto', 100), kaart('XAG', 'metal', 0), kaart('XAU', 'metal', -5)]);
|
||||
assert.deepEqual(d.assets.map(a => a.label), ['BTC']);
|
||||
});
|
||||
|
||||
it('geeft twee lege ringen als er niets bruikbaars is', () => {
|
||||
assert.deepEqual(ringData([kaart('BTC', 'crypto', null)]), { soorten: [], assets: [] });
|
||||
assert.deepEqual(ringData([]), { soorten: [], assets: [] });
|
||||
});
|
||||
|
||||
describe('kleine posten', () => {
|
||||
it('bundelt alles onder de drempel in één post, met het aantal erbij', () => {
|
||||
const d = ringData([
|
||||
kaart('BTC', 'crypto', 9000), kaart('XAU', 'metal', 700),
|
||||
kaart('XAG', 'metal', 100), kaart('EUR', 'fiat', 100), kaart('DOGE', 'crypto', 100),
|
||||
]);
|
||||
assert.deepEqual(d.assets.map(a => a.label), ['BTC', 'XAU', 'Overig (3)']);
|
||||
assert.equal(d.assets.at(-1).value, 300, 'de rest is de som van de kleintjes');
|
||||
});
|
||||
|
||||
it('draagt de id\'s van álle gebundelde assets mee', () => {
|
||||
const klein = [kaart('XAG', 'metal', 100), kaart('EUR', 'fiat', 100)];
|
||||
const d = ringData([kaart('BTC', 'crypto', 9800), ...klein]);
|
||||
assert.deepEqual(d.assets.at(-1).ids, klein.map(k => k.asset.id));
|
||||
});
|
||||
|
||||
it('bundelt NIET als er maar één kleine post is', () => {
|
||||
const d = ringData([kaart('BTC', 'crypto', 9900), kaart('XAG', 'metal', 100)]);
|
||||
assert.deepEqual(d.assets.map(a => a.label), ['BTC', 'XAG']);
|
||||
});
|
||||
|
||||
it('laat de soortengrafiek ongemoeid: een soort verliest zijn aandeel niet', () => {
|
||||
const d = ringData([
|
||||
kaart('BTC', 'crypto', 9000), kaart('XAU', 'metal', 700),
|
||||
kaart('XAG', 'metal', 100), kaart('DOGE', 'crypto', 200),
|
||||
]);
|
||||
const metaal = d.soorten.find(s => s.kind === 'metal');
|
||||
assert.equal(metaal.value, 800, 'inclusief het zilver dat in Overig zit');
|
||||
});
|
||||
|
||||
it('neemt de drempel als parameter aan', () => {
|
||||
const kaarten = [kaart('BTC', 'crypto', 9000), kaart('XAU', 'metal', 600),
|
||||
kaart('XAG', 'metal', 200), kaart('EUR', 'fiat', 200)];
|
||||
assert.equal(ringData(kaarten, { restVanaf: 0 }).assets.length, 4, 'nul: nooit bundelen');
|
||||
assert.equal(ringData(kaarten, { restVanaf: 0.5 }).assets.length, 2, 'de helft: bijna alles bundelen');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('segmentIndices', () => {
|
||||
const ASSETS = [
|
||||
{ label: 'BTC', ids: [7], kinds: ['crypto'] },
|
||||
{ label: 'XAU', ids: [3], kinds: ['metal'] },
|
||||
{ label: 'Overig (2)', ids: [9, 11], kinds: ['metal', 'fiat'] },
|
||||
];
|
||||
const SOORTEN = [
|
||||
{ label: 'Crypto', ids: [7], kinds: ['crypto'] },
|
||||
{ label: 'Metaal', ids: [3, 9], kinds: ['metal'] },
|
||||
{ label: 'Fiat', ids: [11], kinds: ['fiat'] },
|
||||
];
|
||||
|
||||
it('wijst het segment van één asset aan', () => {
|
||||
assert.deepEqual(segmentIndices(ASSETS, { assetIds: [3] }), [1]);
|
||||
});
|
||||
|
||||
it('wijst het restsegment aan voor een asset dat daarin gebundeld is', () => {
|
||||
assert.deepEqual(segmentIndices(ASSETS, { assetIds: [9] }), [2]);
|
||||
assert.deepEqual(segmentIndices(ASSETS, { assetIds: [11] }), [2]);
|
||||
});
|
||||
|
||||
it('wijst álle segmenten van een soort aan, het restsegment incluis', () => {
|
||||
assert.deepEqual(segmentIndices(ASSETS, { kind: 'metal' }), [1, 2]);
|
||||
});
|
||||
|
||||
it('vertaalt dezelfde aanwijzing ook in de SOORTENRING', () => {
|
||||
assert.deepEqual(segmentIndices(SOORTEN, { assetIds: [9] }), [1], 'een metaal-asset wijst metaal aan');
|
||||
assert.deepEqual(segmentIndices(SOORTEN, { kind: 'fiat' }), [2]);
|
||||
});
|
||||
|
||||
it('levert niets op bij null, bij een leeg segment of bij een onbekend asset', () => {
|
||||
assert.deepEqual(segmentIndices(ASSETS, null), []);
|
||||
assert.deepEqual(segmentIndices(ASSETS, { assetIds: [] }), []);
|
||||
assert.deepEqual(segmentIndices(ASSETS, { assetIds: [999] }), []);
|
||||
assert.deepEqual(segmentIndices(undefined, { kind: 'metal' }), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uitsteek', () => {
|
||||
it('schuift een kleine post bijna volledig uit', () => {
|
||||
assert.equal(uitsteek(0.05, 14), 13);
|
||||
});
|
||||
|
||||
it('laat een dominante post vrijwel stilstaan', () => {
|
||||
assert.equal(uitsteek(0.8, 14), 3);
|
||||
});
|
||||
|
||||
it('is continu en heeft dus geen sprong rond de helft', () => {
|
||||
assert.equal(uitsteek(0.49, 100), 51);
|
||||
assert.equal(uitsteek(0.51, 100), 49);
|
||||
});
|
||||
|
||||
it('klemt buiten het bereik in plaats van negatief te worden', () => {
|
||||
assert.equal(uitsteek(1.4, 14), 0);
|
||||
assert.equal(uitsteek(-1, 14), 14);
|
||||
assert.equal(uitsteek(undefined, 14), 14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nadrukKleuren', () => {
|
||||
const KLEUREN = ['rgba(1,1,1,1)', 'rgba(1,1,1,0.6)', 'rgba(1,1,1,0.28)'];
|
||||
|
||||
it('houdt het aangewezen segment vol en dimt de rest', () => {
|
||||
assert.deepEqual(nadrukKleuren(KLEUREN, new Set([1])),
|
||||
['rgba(1,1,1,0.35)', 'rgba(1,1,1,0.6)', 'rgba(1,1,1,0.098)']);
|
||||
});
|
||||
|
||||
it('laat alles vol staan als er niets is aangewezen', () => {
|
||||
assert.deepEqual(nadrukKleuren(KLEUREN, new Set()), KLEUREN);
|
||||
});
|
||||
|
||||
it('kan meerdere segmenten tegelijk vol houden', () => {
|
||||
const uit = nadrukKleuren(KLEUREN, new Set([0, 2]));
|
||||
assert.deepEqual([uit[0], uit[2]], [KLEUREN[0], KLEUREN[2]]);
|
||||
assert.equal(uit[1], 'rgba(1,1,1,0.21)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dim', () => {
|
||||
it('verlaagt alleen de alfa en houdt de tint', () => {
|
||||
assert.equal(dim('rgba(255,107,0,1)'), 'rgba(255,107,0,0.35)');
|
||||
assert.equal(dim('rgba(255,107,0,0.5)', 0.5), 'rgba(255,107,0,0.25)');
|
||||
});
|
||||
|
||||
it('laat iets dat geen rgba is met rust in plaats van onzin te maken', () => {
|
||||
assert.equal(dim('#FF6B00'), '#FF6B00');
|
||||
assert.equal(dim(undefined), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outerEdge', () => {
|
||||
const boog = { x: 100, y: 100, startAngle: 0, endAngle: Math.PI / 2, innerRadius: 40, outerRadius: 80 };
|
||||
|
||||
it('valt zonder uitschuiven en zonder naad samen met de boog zelf', () => {
|
||||
const r = outerEdge(boog);
|
||||
assert.deepEqual([r.cx, r.cy, r.r], [100, 100, 80]);
|
||||
assert.ok(Math.abs(r.start - 0) < 1e-3 && Math.abs(r.end - Math.PI / 2) < 1e-3);
|
||||
});
|
||||
|
||||
it('schuift een aangewezen segment een KWART van zijn offset op, niet de hele offset', () => {
|
||||
const r = outerEdge(boog, { offset: 16 });
|
||||
const mid = Math.PI / 4;
|
||||
assert.ok(Math.abs(r.cx - (100 + Math.cos(mid) * 4)) < 1e-9, `cx ${r.cx}`);
|
||||
assert.ok(Math.abs(r.cy - (100 + Math.sin(mid) * 4)) < 1e-9, `cy ${r.cy}`);
|
||||
assert.ok(Math.abs(r.r - 80) < 1e-9, `r ${r.r}`);
|
||||
});
|
||||
|
||||
it('laat de naad buiten de lijn: de uiteinden schuiven naar binnen', () => {
|
||||
const r = outerEdge(boog, { spacing: 2 });
|
||||
assert.equal(r.r, 81);
|
||||
assert.ok(r.start > 0 && r.end < Math.PI / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('glansStops', () => {
|
||||
it('maakt de binnenkant dieper en de buitenrand lichter', () => {
|
||||
assert.deepEqual(glansStops('rgba(100,50,0,0.5)'), ['rgba(100,50,0,0.36)', 'rgba(128,87,46,0.55)']);
|
||||
});
|
||||
|
||||
it('houdt de volgorde van de tinten intact, want de alfa draagt de rangorde', () => {
|
||||
const reeks = ['rgba(59,130,246,1)', 'rgba(59,130,246,0.64)', 'rgba(59,130,246,0.28)'];
|
||||
const alfa = s => Number(/,([\d.]+)\)$/.exec(s)[1]);
|
||||
const binnen = reeks.map(k => alfa(glansStops(k)[0]));
|
||||
const buiten = reeks.map(k => alfa(glansStops(k)[1]));
|
||||
assert.ok(binnen[0] > binnen[1] && binnen[1] > binnen[2], `binnen: ${binnen}`);
|
||||
assert.ok(buiten[0] >= buiten[1] && buiten[1] > buiten[2], `buiten: ${buiten}`);
|
||||
});
|
||||
|
||||
it('komt nooit boven een alfa van 1 uit', () => {
|
||||
assert.equal(glansStops('rgba(59,130,246,1)')[1], 'rgba(94,153,248,1)');
|
||||
});
|
||||
|
||||
it('laat iets dat geen rgba is ongemoeid, op beide stops', () => {
|
||||
assert.deepEqual(glansStops('#3B82F6'), ['#3B82F6', '#3B82F6']);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,202 @@
|
||||
// Generated, do not edit. sha256:78fe0c105c60d329e375c6de82599c303dbce923361c391f4536e4a44fb6d65d
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { assetMetrics, saleResult, feeResult, combinePositions } from '../../shared/wealth-metrics.js';
|
||||
|
||||
const positie = ({ decimals = 8, net, buys = [], fee = 0, gift = 0 }) => ({
|
||||
decimals,
|
||||
net,
|
||||
buy_events: buys.length,
|
||||
buy_amount: buys.reduce((s, b) => s + b.amount, 0),
|
||||
buy_spot_raw: buys.reduce((s, b) => s + b.amount * b.price, 0),
|
||||
buy_fee_total: fee,
|
||||
gift_amount: gift,
|
||||
});
|
||||
|
||||
describe('assetMetrics: de waarde', () => {
|
||||
it('rekent de kleinste eenheid om naar hele eenheden en dan naar euro', () => {
|
||||
const m = assetMetrics(positie({ net: 10e8, buys: [{ amount: 10e8, price: 100 }] }), 110);
|
||||
assert.equal(m.units, 10);
|
||||
assert.equal(m.value, 1100);
|
||||
});
|
||||
|
||||
it('geeft géén waarde zonder koers, en geen nul', () => {
|
||||
const m = assetMetrics(positie({ net: 3e8, buys: [{ amount: 3e8, price: 25 }] }), null);
|
||||
assert.equal(m.value, null);
|
||||
assert.equal(m.unrealized, null);
|
||||
assert.equal(m.invested, 75, 'de inleg komt uit de boekingen en heeft geen koers van nu nodig');
|
||||
assert.equal(m.avgPrice, 25);
|
||||
});
|
||||
|
||||
it('laat de divisors tegen elkaar wegvallen in de gemiddelde aankoopkoers', () => {
|
||||
const m = assetMetrics(positie({
|
||||
net: 2e8, buys: [{ amount: 1e8, price: 100 }, { amount: 1e8, price: 120 }],
|
||||
}), 110);
|
||||
assert.equal(m.avgPrice, 110);
|
||||
assert.equal(m.invested, 220);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetMetrics: ongerealiseerd tegenover ingelegd', () => {
|
||||
it('meet ongerealiseerd tegen de gemiddelde aankoopkoers en niet tegen de inleg', () => {
|
||||
const m = assetMetrics(positie({ net: 60e8, buys: [{ amount: 100e8, price: 10 }] }), 10);
|
||||
assert.equal(m.unrealized, 0);
|
||||
assert.equal(m.invested, 1000);
|
||||
assert.equal(m.value, 600);
|
||||
});
|
||||
|
||||
it('telt de kosten bij de kostprijs en niet bij de koers', () => {
|
||||
const m = assetMetrics(positie({ net: 10e8, buys: [{ amount: 10e8, price: 10 }], fee: 5 }), 10);
|
||||
assert.equal(m.invested, 105);
|
||||
assert.equal(m.avgPrice, 10);
|
||||
assert.equal(m.avgCost, 10.5);
|
||||
assert.equal(m.unrealized, -5);
|
||||
assert.equal(m.avgPremium, 5, 'de premie is per gebeurtenis');
|
||||
});
|
||||
|
||||
it('rekent de LINK-aankoop van de gebruiker zoals de afschriften', () => {
|
||||
const m = assetMetrics(positie({ net: 5e8, buys: [{ amount: 5e8, price: 19.968 }], fee: 0.15 }), 12.65);
|
||||
assert.equal(Math.round(m.unrealized * 100) / 100, -36.74);
|
||||
assert.equal(m.avgPrice, 19.968, 'de getoonde aankoopkoers blijft zonder kosten');
|
||||
});
|
||||
|
||||
it('verdeelt de kosten naar rato over wat er na een verkoop over is', () => {
|
||||
const m = assetMetrics(positie({ net: 60e8, buys: [{ amount: 100e8, price: 10 }], fee: 20 }), 10);
|
||||
assert.equal(Math.round(m.unrealized * 100) / 100, -12);
|
||||
});
|
||||
|
||||
it('geeft geen gemiddelden zonder aankopen', () => {
|
||||
const m = assetMetrics(positie({ net: 5e8 }), 20);
|
||||
assert.equal(m.avgPrice, null);
|
||||
assert.equal(m.avgPremium, null);
|
||||
assert.equal(m.unrealized, null, 'zonder aankoopkoers is er niets om tegen af te meten');
|
||||
assert.equal(m.value, 100, 'de waarde bestaat wél: die heeft alleen de stand en de koers nodig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saleResult', () => {
|
||||
it('trekt de kostprijs inclusief kosten af van de netto opbrengst', () => {
|
||||
const r = saleResult(positie({ net: 10e8, buys: [{ amount: 10e8, price: 10 }], fee: 5 }),
|
||||
{ amount: 4e8, fiatPrice: 12, fiatFee: 1 });
|
||||
assert.equal(Math.round(r * 100) / 100, 5);
|
||||
});
|
||||
|
||||
it('sluit aan op ongerealiseerd: samen is het waarde plus opbrengst min betaald', () => {
|
||||
const voor = positie({ net: 10e8, buys: [{ amount: 10e8, price: 10 }], fee: 5 });
|
||||
const r = saleResult(voor, { amount: 4e8, fiatPrice: 12, fiatFee: 1 });
|
||||
const na = assetMetrics({ ...voor, net: 6e8 }, 12);
|
||||
assert.equal(Math.round((r + na.unrealized) * 100) / 100, 72 + 47 - 105);
|
||||
});
|
||||
|
||||
it('geeft null als het verkochte geen bekende kostprijs heeft', () => {
|
||||
assert.equal(saleResult(positie({ net: 5e8 }), { amount: 5e8, fiatPrice: 20, fiatFee: 0 }), null);
|
||||
});
|
||||
|
||||
it('telt de fee in eenheden mee als verkocht, niet als opbrengst', () => {
|
||||
const voor = positie({ net: 10e8, buys: [{ amount: 10e8, price: 10 }] });
|
||||
const r = saleResult(voor, { amount: 4e8, fiatPrice: 10, fiatFee: 0, unitFee: 1e8 });
|
||||
assert.equal(r, -10);
|
||||
const na = assetMetrics({ ...voor, net: 5e8 }, 12);
|
||||
assert.equal(r + na.unrealized, 60 + 40 - 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feeResult', () => {
|
||||
it('rekent de fee-eenheden tegen de gemiddelde kostprijs als verlies', () => {
|
||||
const r = feeResult(positie({ net: 10e8, buys: [{ amount: 10e8, price: 10 }], fee: 5 }), 1e8);
|
||||
assert.equal(r, -10.5);
|
||||
});
|
||||
|
||||
it('geeft null als de fee-eenheden geen bekende kostprijs hebben', () => {
|
||||
assert.equal(feeResult(positie({ net: 5e8 }), 1e8), null);
|
||||
});
|
||||
|
||||
it('kost niets als de fee uit een gift komt', () => {
|
||||
assert.equal(feeResult(positie({ net: 5e8, gift: 5e8 }), 1e8), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetMetrics: metaal rekent in grammen en niet in ounces', () => {
|
||||
it('houdt de eenheid van het asset aan, wat die ook is', () => {
|
||||
const m = assetMetrics(positie({
|
||||
decimals: 4, net: 300e4,
|
||||
buys: [{ amount: 200e4, price: 50 }, { amount: 100e4, price: 50 }],
|
||||
}), 63);
|
||||
assert.equal(m.units, 300);
|
||||
assert.equal(m.value, 18900);
|
||||
assert.equal(m.invested, 15000);
|
||||
assert.equal(m.unrealized, 3900);
|
||||
});
|
||||
|
||||
it('telt de premie bij de kostprijs, in grammen', () => {
|
||||
const OZT = 31.1034768;
|
||||
const m = assetMetrics(positie({
|
||||
decimals: 4, net: OZT * 1e4, buys: [{ amount: OZT * 1e4, price: 1000 / OZT }], fee: 25,
|
||||
}), 1025 / OZT);
|
||||
assert.equal(Math.round(m.unrealized * 100) / 100, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetMetrics: een gift draagt kostprijs nul', () => {
|
||||
it('meet alleen de betaalde eenheden tegen de gemiddelde aankoopkoers', () => {
|
||||
const m = assetMetrics(positie({ net: 150e8, buys: [{ amount: 100e8, price: 10 }], gift: 50e8 }), 20);
|
||||
assert.equal(m.value, 3000);
|
||||
assert.equal(m.unrealized, 2000);
|
||||
assert.equal(m.avgPrice, 10, 'de getoonde aankoopkoers blijft van de aankopen');
|
||||
assert.equal(m.invested, 1000, 'en de inleg ook: aan een gift is niets uitgegeven');
|
||||
});
|
||||
|
||||
it('geeft de hele waarde als er alleen een gift is', () => {
|
||||
const m = assetMetrics(positie({ net: 50e8, gift: 50e8 }), 20);
|
||||
assert.equal(m.avgPrice, null, 'er is nog steeds geen aankoopkoers om te tonen');
|
||||
assert.equal(m.value, 1000);
|
||||
assert.equal(m.unrealized, 1000);
|
||||
});
|
||||
|
||||
it('houdt een positie zonder gift op een streepje als er niets gekocht is', () => {
|
||||
const m = assetMetrics(positie({ net: 5e8 }), 20);
|
||||
assert.equal(m.unrealized, null);
|
||||
});
|
||||
|
||||
it('laat een weggegeven gift tegen de ontvangen gift wegvallen', () => {
|
||||
const m = assetMetrics(positie({ net: 100e8, buys: [{ amount: 100e8, price: 10 }], gift: 0 }), 20);
|
||||
assert.equal(m.unrealized, 1000);
|
||||
});
|
||||
|
||||
it('telt bij verkoop eerst de betaalde eenheden, dan de gift tegen nul', () => {
|
||||
const r = saleResult(positie({ net: 150e8, buys: [{ amount: 100e8, price: 10 }], gift: 50e8 }),
|
||||
{ amount: 120e8, fiatPrice: 20, fiatFee: 0 });
|
||||
assert.equal(r, 1400);
|
||||
});
|
||||
|
||||
it('gaat niet onder nul als de positie kleiner is dan de gift', () => {
|
||||
const m = assetMetrics(positie({ net: 10e8, buys: [{ amount: 100e8, price: 10 }], gift: 50e8 }), 20);
|
||||
assert.equal(m.unrealized, 200, 'de hele reststand is gift, dus kostprijs nul');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combinePositions', () => {
|
||||
it('weegt het gemiddelde naar wat er gekocht is, niet per account', () => {
|
||||
const groot = positie({ net: 3e8, buys: [{ amount: 3e8, price: 100 }], fee: 6 });
|
||||
const klein = positie({ net: 1e8, buys: [{ amount: 1e8, price: 200 }], fee: 2 });
|
||||
const m = assetMetrics(combinePositions([groot, klein]), 150);
|
||||
assert.equal(m.units, 4);
|
||||
assert.equal(m.avgPrice, 125, '(3 × 100 + 1 × 200) / 4, en niet (100 + 200) / 2');
|
||||
assert.equal(m.avgPremium, 4, 'de kosten per aankoop: 8 over twee aankopen');
|
||||
assert.equal(m.invested, 508);
|
||||
assert.equal(m.unrealized, 600 - 508);
|
||||
});
|
||||
|
||||
it('telt een gift mee tegen kostprijs nul, zoals bij één account', () => {
|
||||
const gekocht = positie({ net: 1e8, buys: [{ amount: 1e8, price: 100 }] });
|
||||
const gekregen = positie({ net: 1e8, gift: 1e8 });
|
||||
assert.equal(assetMetrics(combinePositions([gekocht, gekregen]), 150).unrealized, 200);
|
||||
});
|
||||
|
||||
it('geeft bij geen enkele positie een lege positie en geen fout', () => {
|
||||
const m = assetMetrics(combinePositions([]), 150);
|
||||
assert.equal(m.units, 0);
|
||||
assert.equal(m.avgPrice, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
// Generated, do not edit. sha256:d59f0912a4578c0ab6f39878716960d471f5cbde7d6f6d3686e3e3fe87378fc2
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { weightOf, averageCapital, modifiedDietz, irr, dailyFlows, lastDayHeld, timeWeighted, windowTwr,
|
||||
dailyFactors, windowFactors } from '../../client/wealth/performance.js';
|
||||
|
||||
const JAAR = { fromIso: '2025-01-01T00:00:00Z', untilIso: '2026-01-01T00:00:00Z' };
|
||||
const HALF = '2025-07-01T00:00:00Z';
|
||||
const rond = (v, n = 4) => (v == null ? v : Math.round(v * 10 ** n) / 10 ** n);
|
||||
|
||||
describe('weightOf', () => {
|
||||
it('geeft één op de eerste dag en nul op de laatste', () => {
|
||||
assert.equal(weightOf(JAAR.fromIso, JAAR.fromIso, JAAR.untilIso), 1);
|
||||
assert.equal(weightOf(JAAR.untilIso, JAAR.fromIso, JAAR.untilIso), 0);
|
||||
});
|
||||
|
||||
it('geeft ongeveer de helft halverwege', () => {
|
||||
assert.equal(rond(weightOf(HALF, JAAR.fromIso, JAAR.untilIso), 2), 0.5);
|
||||
});
|
||||
|
||||
it('klemt een stroom van buiten het venster op de rand', () => {
|
||||
assert.equal(weightOf('2024-06-01T00:00:00Z', JAAR.fromIso, JAAR.untilIso), 1);
|
||||
assert.equal(weightOf('2026-06-01T00:00:00Z', JAAR.fromIso, JAAR.untilIso), 0);
|
||||
});
|
||||
|
||||
it('geeft één bij een venster zonder lengte in plaats van te delen door nul', () => {
|
||||
assert.equal(weightOf(JAAR.fromIso, JAAR.fromIso, JAAR.fromIso), 1);
|
||||
});
|
||||
|
||||
it('geeft null bij een datum die niet te lezen is', () => {
|
||||
assert.equal(weightOf('geen datum', JAAR.fromIso, JAAR.untilIso), null);
|
||||
assert.equal(weightOf(JAAR.fromIso, null, JAAR.untilIso), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('averageCapital', () => {
|
||||
it('geeft de beginwaarde als er niets stroomt', () => {
|
||||
assert.equal(averageCapital(1000, [], JAAR), 1000);
|
||||
});
|
||||
|
||||
it('telt een storting halverwege voor ongeveer de helft mee', () => {
|
||||
assert.equal(rond(averageCapital(1000, [{ fiat: 1000, atIso: HALF }], JAAR), 1), 1504.1);
|
||||
});
|
||||
|
||||
it('haalt een opname er weer af, gewogen', () => {
|
||||
assert.equal(rond(averageCapital(1000, [{ fiat: -1000, atIso: HALF }], JAAR), 1), 495.9);
|
||||
});
|
||||
|
||||
it('slaat een stroom zonder leesbare datum over in plaats van hem vol mee te tellen', () => {
|
||||
assert.equal(averageCapital(1000, [{ fiat: 500, atIso: null }], JAAR), 1000);
|
||||
});
|
||||
|
||||
it('geeft null zonder bruikbare beginwaarde', () => {
|
||||
assert.equal(averageCapital(null, [], JAAR), null);
|
||||
assert.equal(averageCapital(undefined, [], JAAR), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifiedDietz', () => {
|
||||
it('deelt door de beginwaarde als er niets stroomt, net als de oude som', () => {
|
||||
assert.equal(modifiedDietz({ gain: 200, begin: 1000, flows: [], ...JAAR }), 0.2);
|
||||
});
|
||||
|
||||
it('verlaagt het percentage als je halverwege bijstort', () => {
|
||||
const oud = 200 / 1000;
|
||||
const nieuw = modifiedDietz({ gain: 200, begin: 1000, flows: [{ fiat: 1000, atIso: HALF }], ...JAAR });
|
||||
assert.equal(rond(nieuw, 4), 0.133);
|
||||
assert.ok(nieuw < oud, 'meer geld aan het werk voor hetzelfde resultaat is een lager rendement');
|
||||
});
|
||||
|
||||
it('geeft null bij een noemer van nul of lager', () => {
|
||||
assert.equal(modifiedDietz({ gain: 50, begin: 0, flows: [], ...JAAR }), null);
|
||||
assert.equal(modifiedDietz({ gain: 50, begin: 100, flows: [{ fiat: -1000, atIso: JAAR.fromIso }], ...JAAR }), null);
|
||||
});
|
||||
|
||||
it('geeft null zonder resultaat om te delen', () => {
|
||||
assert.equal(modifiedDietz({ gain: null, begin: 1000, flows: [], ...JAAR }), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('irr', () => {
|
||||
const reeks = (...paren) => paren.map(([fiat, atIso]) => ({ fiat, atIso }));
|
||||
|
||||
it('vindt de rente die de stromen verklaart', () => {
|
||||
const r = irr(reeks([-1000, JAAR.fromIso], [1100, JAAR.untilIso]), { fromIso: JAAR.fromIso });
|
||||
assert.equal(rond(r, 3), 0.1);
|
||||
});
|
||||
|
||||
it('rekent per JAAR en niet per periode', () => {
|
||||
const r = irr(reeks([-1000, JAAR.fromIso], [1100, HALF]), { fromIso: JAAR.fromIso });
|
||||
assert.ok(r > 0.2, `verwacht meer dan 20% op jaarbasis, kreeg ${r}`);
|
||||
});
|
||||
|
||||
it('verwerkt een storting halverwege', () => {
|
||||
const r = irr(reeks([-1000, JAAR.fromIso], [-1000, HALF], [2100, JAAR.untilIso]), { fromIso: JAAR.fromIso });
|
||||
assert.ok(r > 0 && r < 0.2, `verwacht een bescheiden positief rendement, kreeg ${r}`);
|
||||
});
|
||||
|
||||
it('geeft null als er geen tekenwisseling is', () => {
|
||||
assert.equal(irr(reeks([-1000, JAAR.fromIso], [-500, HALF]), { fromIso: JAAR.fromIso }), null);
|
||||
assert.equal(irr(reeks([1000, JAAR.fromIso], [500, HALF]), { fromIso: JAAR.fromIso }), null);
|
||||
});
|
||||
|
||||
it('geeft null bij een verlies dat buiten het zoekbereik valt', () => {
|
||||
assert.equal(irr(reeks([-1000, JAAR.fromIso], [0, JAAR.untilIso]), { fromIso: JAAR.fromIso }), null);
|
||||
});
|
||||
|
||||
it('geeft null bij een onleesbare datum of een leeg bedrag', () => {
|
||||
assert.equal(irr(reeks([-1000, JAAR.fromIso], [1100, 'ooit']), { fromIso: JAAR.fromIso }), null);
|
||||
assert.equal(irr([], { fromIso: JAAR.fromIso }), null);
|
||||
});
|
||||
|
||||
it('komt op hetzelfde uit als Modified Dietz zolang er niets stroomt', () => {
|
||||
const md = modifiedDietz({ gain: 100, begin: 1000, flows: [], ...JAAR });
|
||||
const i = irr(reeks([-1000, JAAR.fromIso], [1100, JAAR.untilIso]), { fromIso: JAAR.fromIso });
|
||||
assert.equal(rond(md, 3), rond(i, 3));
|
||||
});
|
||||
});
|
||||
|
||||
describe('dailyFlows', () => {
|
||||
it('maakt van een cumulatieve reeks de stap per dag', () => {
|
||||
assert.deepEqual(dailyFlows([0, 100, 100, 250]), [0, 100, 0, 150]);
|
||||
});
|
||||
|
||||
it('geeft een negatieve stap bij een verkoop', () => {
|
||||
assert.deepEqual(dailyFlows([500, 500, 300]), [0, 0, -200]);
|
||||
});
|
||||
|
||||
it('begint op nul, want de eerste dag heeft geen vorige', () => {
|
||||
assert.deepEqual(dailyFlows([1000]), [0]);
|
||||
assert.deepEqual(dailyFlows([]), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeWeighted', () => {
|
||||
it('geeft de koersverandering als er niets stroomt', () => {
|
||||
assert.equal(rond(timeWeighted([100, 110, 120], [0, 0, 0]), 4), 0.2);
|
||||
});
|
||||
|
||||
it('HAALT EEN STORTING ERUIT, en dat is het hele punt', () => {
|
||||
assert.equal(rond(timeWeighted([100, 110, 1110], [0, 0, 1000]), 4), 0.1);
|
||||
});
|
||||
|
||||
it('ketent twee deelperiodes in plaats van ze te middelen', () => {
|
||||
assert.equal(rond(timeWeighted([100, 110, 121], [0, 0, 0]), 4), 0.21);
|
||||
});
|
||||
|
||||
it('telt een dag zonder basis als niets in plaats van de keten te breken', () => {
|
||||
const r = timeWeighted([100, 0, 0, 220], [0, -110, 0, 200]);
|
||||
assert.equal(rond(r, 4), 0.21);
|
||||
});
|
||||
|
||||
it('geeft null als er niets te meten valt', () => {
|
||||
assert.equal(timeWeighted([100], [0]), null);
|
||||
assert.equal(timeWeighted([], []), null);
|
||||
assert.equal(timeWeighted([0, 0, 0], [0, 0, 0]), null, 'nooit een basis gehad');
|
||||
});
|
||||
|
||||
it('overleeft een reeks zonder stromen-array', () => {
|
||||
assert.equal(rond(timeWeighted([100, 150]), 4), 0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastDayHeld', () => {
|
||||
it('vindt de laatste dag met een positie', () => {
|
||||
assert.equal(lastDayHeld([0, 100, 100, 0, 0], 0, 4), 2);
|
||||
});
|
||||
|
||||
it('geeft de laatste dag van het venster als de positie er dan nog is', () => {
|
||||
assert.equal(lastDayHeld([0, 100, 100], 0, 2), 2);
|
||||
});
|
||||
|
||||
it('kijkt niet buiten het venster', () => {
|
||||
assert.equal(lastDayHeld([0, 100, 100, 0, 50], 0, 3), 2);
|
||||
});
|
||||
|
||||
it('gaat niet onder de ondergrens van het venster', () => {
|
||||
assert.equal(lastDayHeld([100, 100, 0, 0], 2, 3), null);
|
||||
});
|
||||
|
||||
it('geeft null zonder enige positie in het venster', () => {
|
||||
assert.equal(lastDayHeld([0, 0, 0], 0, 2), null);
|
||||
assert.equal(lastDayHeld([], 0, 5), null);
|
||||
});
|
||||
|
||||
it('klemt op de lengte van de reeks', () => {
|
||||
assert.equal(lastDayHeld([0, 100], 0, 99), 1);
|
||||
});
|
||||
|
||||
describe('een verkochte positie', () => {
|
||||
const waarden = [0, 1000, 1200, 0];
|
||||
|
||||
it('telt de verkoopdag als wat de verkoop opbracht', () => {
|
||||
assert.equal(rond(timeWeighted(waarden, [0, 1000, 0, -1180])), 0.18);
|
||||
});
|
||||
|
||||
it('en een verkoop boven de slotkoers telt net zo, zonder sprong', () => {
|
||||
assert.equal(rond(timeWeighted(waarden, [0, 1000, 0, -1220])), 0.22);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeWeighted: dust', () => {
|
||||
const waarden = [0, 50, 51, 0.02, 0.03, 0];
|
||||
const stromen = [0, 50, 0, -51.15, 0, -0.01];
|
||||
|
||||
it('stopt de meting bij de verkoop die alleen dust overlaat', () => {
|
||||
assert.equal(rond(timeWeighted(waarden, stromen)), rond((0.02 + 51.15) / 50 - 1));
|
||||
});
|
||||
|
||||
it('meet weer zodra er geld in komt', () => {
|
||||
const r = timeWeighted([0, 50, 51, 0.02, 100.02, 110.02], [0, 50, 0, -51.15, 100, 0]);
|
||||
assert.equal(rond(r), rond((51.17 / 50) * (100.02 / 100.02) * (110.02 / 100.02) - 1));
|
||||
});
|
||||
|
||||
it('sluit niet bij een gewone gedeeltelijke verkoop', () => {
|
||||
const r = timeWeighted([0, 100, 100, 50, 55], [0, 100, 0, -50, 0]);
|
||||
assert.equal(rond(r), 0.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('windowTwr: aankoop en verkoop op één dag', () => {
|
||||
it('telt de aankoop aan het begin en de verkoop aan het eind', () => {
|
||||
const r = windowTwr({
|
||||
waarden: [1, 0.95, 0],
|
||||
inleg: [1, 1, 1 + 11.56 - 12.65],
|
||||
uit: [0, 0, 12.65],
|
||||
eenheden: [5, 5, 0],
|
||||
}, { van: 1, tot: 2 });
|
||||
assert.equal(rond(r), rond(12.65 / (0.95 + 11.56) - 1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('windowTwr', () => {
|
||||
const BETAALD = 99.84, SLOT = 104.77, EIND_2025 = 53.58, NU = 63.25;
|
||||
|
||||
it('meet vanaf het betaalde bedrag en niet vanaf de slotkoers van de aankoopdag', () => {
|
||||
const r = windowTwr({
|
||||
waarden: [0, 0, SLOT, EIND_2025],
|
||||
inleg: [0, 0, BETAALD, BETAALD],
|
||||
eenheden: [0, 0, 5, 5],
|
||||
eersteDag: 2,
|
||||
}, { van: 0, tot: 3 });
|
||||
assert.equal(rond(r), rond(EIND_2025 / BETAALD - 1));
|
||||
});
|
||||
|
||||
it('doet dat ook als de aankoop op de eerste dag van de reeks valt', () => {
|
||||
const r = windowTwr({
|
||||
waarden: [SLOT, EIND_2025],
|
||||
inleg: [BETAALD, BETAALD],
|
||||
eenheden: [5, 5],
|
||||
eersteDag: 0,
|
||||
}, { van: 0, tot: 1 });
|
||||
assert.equal(rond(r), rond(EIND_2025 / BETAALD - 1));
|
||||
});
|
||||
|
||||
it('de keten van twee jaren is gelijk aan eindwaarde gedeeld door betaald', () => {
|
||||
const reeks = {
|
||||
waarden: [0, SLOT, EIND_2025, NU],
|
||||
inleg: [0, BETAALD, BETAALD, BETAALD],
|
||||
eenheden: [0, 5, 5, 5],
|
||||
eersteDag: 1,
|
||||
};
|
||||
const r1 = windowTwr(reeks, { van: 0, tot: 2 });
|
||||
const r2 = windowTwr(reeks, { van: 2, tot: 3 });
|
||||
assert.equal(rond(r2), rond(NU / EIND_2025 - 1), 'het tweede jaar meet vanaf de jaargrens');
|
||||
assert.equal(rond((1 + r1) * (1 + r2) - 1), rond(NU / BETAALD - 1));
|
||||
});
|
||||
|
||||
it('telt een aankoop OP de jaargrens precies één keer', () => {
|
||||
const reeks = {
|
||||
waarden: [0, 0, SLOT, NU],
|
||||
inleg: [0, 0, BETAALD, BETAALD],
|
||||
eenheden: [0, 0, 5, 5],
|
||||
eersteDag: 2,
|
||||
};
|
||||
const r1 = windowTwr(reeks, { van: 0, tot: 2 });
|
||||
const r2 = windowTwr(reeks, { van: 2, tot: 3 });
|
||||
assert.equal(rond(r2), rond(NU / SLOT - 1), 'het nieuwe jaar begint bij de slotkoers');
|
||||
assert.equal(rond((1 + r1) * (1 + r2) - 1), rond(NU / BETAALD - 1));
|
||||
});
|
||||
|
||||
it('begint zonder eersteDag gewoon bij het venster', () => {
|
||||
assert.equal(rond(windowTwr({ waarden: [SLOT, EIND_2025], inleg: [BETAALD, BETAALD] }, { van: 0, tot: 1 })),
|
||||
rond(EIND_2025 / SLOT - 1));
|
||||
});
|
||||
});
|
||||
|
||||
const product = factoren => {
|
||||
const gemeten = factoren.filter(f => f != null);
|
||||
return gemeten.length ? gemeten.reduce((p, f) => p * f, 1) - 1 : null;
|
||||
};
|
||||
|
||||
describe('dailyFactors', () => {
|
||||
it('geeft per dag de factor, en null op dag nul', () => {
|
||||
assert.deepEqual(dailyFactors([100, 110, 121], [0, 0, 0]).map(f => rond(f)), [null, 1.1, 1.1]);
|
||||
});
|
||||
|
||||
it('meet de dagen van een restje stof NIET', () => {
|
||||
const f = dailyFactors([0, 50, 51, 0.02, 0.03, 0], [0, 50, 0, -51.15, 0, -0.01]);
|
||||
assert.equal(f[4], null);
|
||||
assert.equal(f[5], null);
|
||||
});
|
||||
|
||||
it('geeft null op een dag zonder basis, en niet een factor van één', () => {
|
||||
const f = dailyFactors([0, 0, 100], [0, 0, 100]);
|
||||
assert.equal(f[1], null);
|
||||
assert.equal(f[2], 1);
|
||||
});
|
||||
|
||||
it('heeft als product precies timeWeighted, ook als een gesloten positie heropent', () => {
|
||||
const waarden = [0, 50, 51, 0.02, 100.02, 110.02];
|
||||
const stromen = [0, 50, 0, -51.15, 100, 0];
|
||||
assert.equal(rond(product(dailyFactors(waarden, stromen))), rond(timeWeighted(waarden, stromen)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('windowFactors', () => {
|
||||
const BETAALD = 99.84, SLOT = 104.77, EIND_2025 = 53.58, NU = 63.25;
|
||||
const reeks = {
|
||||
waarden: [0, SLOT, EIND_2025, NU],
|
||||
inleg: [0, BETAALD, BETAALD, BETAALD],
|
||||
eenheden: [0, 5, 5, 5],
|
||||
eersteDag: 1,
|
||||
};
|
||||
|
||||
it('heeft als product over elk venster precies windowTwr', () => {
|
||||
for (const venster of [{ van: 0, tot: 3 }, { van: 2, tot: 3 }, { van: 1, tot: 2 }]) {
|
||||
assert.equal(rond(product(windowFactors(reeks, venster))), rond(windowTwr(reeks, venster)),
|
||||
`venster ${venster.van} t/m ${venster.tot}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('zet de factor op de dag van de reeks, ook als er een lege dag voor gezet is', () => {
|
||||
const f = windowFactors({ waarden: [SLOT, EIND_2025], inleg: [BETAALD, BETAALD], eenheden: [5, 5], eersteDag: 0 },
|
||||
{ van: 0, tot: 1 });
|
||||
assert.deepEqual(f.map(x => rond(x)), [rond(SLOT / BETAALD), rond(EIND_2025 / SLOT)]);
|
||||
});
|
||||
|
||||
it('is null buiten het venster en even lang als de reeks', () => {
|
||||
const f = windowFactors(reeks, { van: 2, tot: 3 });
|
||||
assert.equal(f.length, 4);
|
||||
assert.equal(f[1], null);
|
||||
assert.equal(f[2], null, 'het venster meet de stap NA zijn begin');
|
||||
assert.equal(rond(f[3]), rond(NU / EIND_2025));
|
||||
});
|
||||
|
||||
it('geeft alleen null als het venster nooit een positie had', () => {
|
||||
const f = windowFactors({ ...reeks, eenheden: [0, 0, 0, 0] }, { van: 0, tot: 3 });
|
||||
assert.deepEqual(f, [null, null, null, null]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Generated, do not edit. sha256:3b34cf97cc5aa20ba08ae8a6246f0235b943e5e85fc0d31e21707e06b2c2c371
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay } from '../../client/wealth/return-view.js';
|
||||
import { dagreeks, seriesFor, meetVenster } from '../../client/wealth/series.js';
|
||||
import { windowTwr } from '../../client/wealth/performance.js';
|
||||
|
||||
const rond = x => (x == null ? x : Math.round(x * 1e6) / 1e6);
|
||||
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: 1, ...o });
|
||||
|
||||
const A = { id: 1, decimals: 8, kind: 'crypto' };
|
||||
const B = { id: 3, decimals: 8, kind: 'metal' };
|
||||
const reeks = () => dagreeks({
|
||||
assets: [A, B],
|
||||
prijzen: new Map([
|
||||
[A.id, grid('2026-01-01', [100, 110, 99, 120, 130, 125])],
|
||||
[B.id, grid('2026-01-01', [50, 50, 55, 60, 54, 57])],
|
||||
]),
|
||||
vanDag: '2026-01-01', totDag: '2026-01-06',
|
||||
events: [
|
||||
ev({ assetId: A.id, amount: 1e8, fiatPrice: 100, occurredAt: '2026-01-01T10:00:00Z' }),
|
||||
ev({ assetId: B.id, amount: 2e8, fiatPrice: 55, occurredAt: '2026-01-03T10:00:00Z' }),
|
||||
],
|
||||
valuations: [],
|
||||
});
|
||||
|
||||
describe('periodChoices', () => {
|
||||
it('zet de periode van de pagina vooraan, gemeten zoals de jaarkeuze meet', () => {
|
||||
const p = periodChoices(400, { label: '2025', venster: { van: 10, tot: 50 } });
|
||||
assert.deepEqual(p[0], { key: 'page', days: null, label: '2025', venster: meetVenster({ van: 10, tot: 50 }, 400) });
|
||||
assert.deepEqual(p.slice(1).map(x => x.key), ['d1', 'd7', 'd30', 'd365', 'start']);
|
||||
});
|
||||
|
||||
it('laat de pagina weg als die de hele reeks beslaat: dat is "sinds begin"', () => {
|
||||
assert.deepEqual(periodChoices(400, { label: 'x', venster: { van: 0, tot: 399 } }).map(x => x.key),
|
||||
['d1', 'd7', 'd30', 'd365', 'start']);
|
||||
});
|
||||
|
||||
it('geeft zonder reeks niets', () => {
|
||||
assert.deepEqual(periodChoices(0, { label: '2025', venster: { van: 0, tot: 0 } }), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPeriod', () => {
|
||||
it('opent op de bewaarde periode als die bestaat en te meten is', () => {
|
||||
assert.equal(startPeriod(periodChoices(400), 'd30'), 'd30');
|
||||
});
|
||||
|
||||
it('valt terug op "sinds begin" als de reeks te kort is voor de bewaarde periode', () => {
|
||||
const kort = periodChoices(10);
|
||||
assert.equal(kort.find(p => p.key === 'd365')?.venster ?? null, null, 'voorwaarde: een jaar is niet te meten');
|
||||
assert.equal(startPeriod(kort, 'd365'), 'start');
|
||||
});
|
||||
|
||||
it('valt terug op de periode van de pagina bij een onbekende of lege keuze', () => {
|
||||
const metPagina = periodChoices(400, { label: '2025', venster: { van: 10, tot: 50 } });
|
||||
assert.equal(startPeriod(metPagina, 'weg'), 'page');
|
||||
assert.equal(startPeriod(metPagina, null), 'page');
|
||||
assert.equal(startPeriod(periodChoices(400), null), 'start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('returnLine', () => {
|
||||
const invoer = () => seriesFor(reeks());
|
||||
|
||||
it('eindigt op het bedrag erboven, in elk venster', () => {
|
||||
for (const venster of [{ van: 0, tot: 5 }, { van: 2, tot: 5 }, { van: 4, tot: 5 }]) {
|
||||
const lijn = returnLine(invoer(), venster);
|
||||
assert.equal(rond(lijn.points.at(-1).cumulative), rond(lijn.amount), `venster ${venster.van} t/m ${venster.tot}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('begint op nul na het begin van de reeks, en op de aankoopdag vanaf dag nul', () => {
|
||||
const later = returnLine(invoer(), { van: 2, tot: 5 });
|
||||
assert.deepEqual(later.points[0], { i: 2, cumulative: 0, day: null, pct: null });
|
||||
const vanaf = returnLine(invoer(), { van: 0, tot: 5 });
|
||||
assert.equal(vanaf.points[0].i, 0);
|
||||
assert.equal(rond(vanaf.points[0].day), 0, 'gekocht tegen de slotkoers van die dag');
|
||||
});
|
||||
|
||||
it('geeft per dag het rendement van die dag', () => {
|
||||
const lijn = returnLine(invoer(), { van: 3, tot: 5 });
|
||||
assert.equal(rond(lijn.points[1].pct), rond(238 / 240 - 1));
|
||||
assert.equal(rond(lijn.points[1].day), -2);
|
||||
});
|
||||
|
||||
it('heeft zonder venster geen lijn en geen getallen', () => {
|
||||
assert.deepEqual(returnLine(invoer(), null), { amount: null, pct: null, points: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('seriesFor met een groep', () => {
|
||||
it('is met alle assets erin hetzelfde als het totaal', () => {
|
||||
const r = reeks();
|
||||
const groep = seriesFor(r, [A.id, B.id]);
|
||||
assert.deepEqual(groep.waarden.map(rond), r.waarde.map(rond));
|
||||
assert.equal(rond(windowTwr(groep, { van: 0, tot: 5 })), rond(windowTwr(seriesFor(r), { van: 0, tot: 5 })));
|
||||
});
|
||||
|
||||
it('telt alleen de assets van de groep, vanaf de eerste dag van een van hen', () => {
|
||||
const r = reeks();
|
||||
const groep = seriesFor(r, [B.id]);
|
||||
assert.deepEqual(groep.waarden.map(rond), r.perAsset.get(B.id).map(rond));
|
||||
assert.equal(groep.eersteDag, r.eersteDagPerAsset.get(B.id));
|
||||
assert.equal(groep.eenheden, undefined, 'eenheden van verschillende assets tellen niet op');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contributorsPerDay', () => {
|
||||
it('telt per dag op tot het resultaat van de lijn, grootste bedrag eerst', () => {
|
||||
const r = reeks();
|
||||
const per = contributorsPerDay(r, [A.id, B.id], { van: 0, tot: 5 });
|
||||
assert.deepEqual(per.get(4).map(c => [c.id, rond(c.day), rond(c.pct)]),
|
||||
[[B.id, -12, rond(54 / 60 - 1)], [A.id, 10, rond(130 / 120 - 1)]]);
|
||||
const lijn = returnLine(seriesFor(r), { van: 0, tot: 5 });
|
||||
for (const p of lijn.points.filter(x => per.has(x.i))) {
|
||||
assert.equal(rond(per.get(p.i).reduce((s, c) => s + c.day, 0)), rond(p.day), `dag ${p.i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('houdt het bij n per dag, en laat een asset zonder beweging weg', () => {
|
||||
const per = contributorsPerDay(reeks(), [A.id, B.id], { van: 0, tot: 5 }, 1);
|
||||
assert.deepEqual(per.get(4).map(c => c.id), [B.id]);
|
||||
assert.deepEqual(contributorsPerDay(reeks(), [A.id, B.id], { van: 0, tot: 5 }).get(1).map(c => c.id), [A.id]);
|
||||
});
|
||||
|
||||
it('is leeg voor één asset: dat is de lijn zelf', () => {
|
||||
assert.equal(contributorsPerDay(reeks(), [A.id], { van: 0, tot: 5 }).size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idsForSelection', () => {
|
||||
const assets = [A, B, { id: 4, kind: 'crypto' }];
|
||||
it('volgt de selectie van de pagina', () => {
|
||||
assert.deepEqual(idsForSelection({ assetIds: [3] }, assets), [3]);
|
||||
assert.deepEqual(idsForSelection({ kind: 'crypto' }, assets), [1, 4]);
|
||||
});
|
||||
it('valt terug op het hele vermogen', () => {
|
||||
assert.equal(idsForSelection(null, assets), null);
|
||||
assert.equal(idsForSelection({ kind: 'fiat' }, assets), null, 'een soort zonder assets');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user