Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
// Generated, do not edit. sha256:771d7bfccd806caf4ec8847da01e643d018dcebd8d547afd6fc2a566be8ad622
import { ENUMS } from '../../shared/config.js';
import { byKindAndLabel } from '../ui/asset-label.js';
export function accountCards({ kaarten = [], holdings = [], accounts = [], toon = new Set() } = {}) {
const perAsset = new Map(kaarten.map(k => [k.asset.id, k]));
const lijnen = new Map();
const lijn = (accountId, k, saldo) => {
if (!lijnen.has(accountId)) lijnen.set(accountId, []);
lijnen.get(accountId).push({ k, saldo });
};
const opAccount = new Map();
for (const h of holdings) {
const k = perAsset.get(h.assetId);
if (!k || !h.saldo) continue;
lijn(h.accountId, k, h.saldo);
opAccount.set(h.assetId, (opAccount.get(h.assetId) || 0) + h.saldo);
}
for (const k of kaarten) {
const rest = (k.p.net || 0) - (opAccount.get(k.asset.id) || 0);
if (rest) lijn(null, k, rest);
}
for (const id of toon) if (!lijnen.has(id)) lijnen.set(id, []);
const naam = new Map(accounts.map(a => [a.id, a]));
const kaartVan = (accountId, rijen) => {
let value = null, unrealized = null;
const lines = rijen.map(({ k, saldo }) => {
const units = saldo / Math.pow(10, k.asset.decimals);
const v = k.m.fiatPrice != null ? units * k.m.fiatPrice : null;
if (v != null) value = (value ?? 0) + v;
const deel = ongerealiseerdVan(k, accountId, saldo);
if (deel != null) unrealized = (unrealized ?? 0) + deel;
return { asset: k.asset, saldo, value: v, origin: k.m.origin };
}).sort((a, b) => byKindAndLabel(a.asset, b.asset));
return {
accountId, account: naam.get(accountId) || null, value, unrealized, lines,
assetIds: lines.map(l => l.asset.id),
kinds: [...new Set(lines.map(l => l.asset.kind))],
};
};
const ongerealiseerdVan = (k, accountId, saldo) => {
const eigenDeel = k.m.beheerd?.find(d => d.accountId === accountId);
if (eigenDeel) return eigenDeel.unrealized;
const basis = k.m.eigen ?? { net: k.p.net, unrealized: k.m.unrealized };
return basis.unrealized != null && basis.net ? basis.unrealized * (saldo / basis.net) : null;
};
const soort = c => (c.accountId == null ? ENUMS.ACCOUNT_KIND.length + 1
: c.account ? ENUMS.ACCOUNT_KIND.indexOf(c.account.kind) : ENUMS.ACCOUNT_KIND.length);
return [...lijnen].map(([id, rijen]) => kaartVan(id, rijen))
.filter(c => c.lines.length || c.account)
.sort((a, b) => soort(a) - soort(b)
|| String(a.account?.name ?? '').localeCompare(String(b.account?.name ?? ''), 'nl'));
}
+38
View File
@@ -0,0 +1,38 @@
// Generated, do not edit. sha256:f962f4bc88fe2f11615f93f44c35fe729fcb597398ce82af6f05055c78fd9a0a
import { CONFIG } from '../../shared/config.js';
export function carriedPerAsset({ dagen = [], assets = [], rijenPerAsset = new Map() } = {}) {
const uit = new Map();
for (const a of assets) {
if (a.kind === 'fiat') continue;
const echt = new Set((rijenPerAsset.get(a.id) || []).filter(r => r.fiatPrice != null && r.fiatPrice > 0)
.map(r => String(r.observedAt).slice(0, 10)));
if (dagen.length) echt.add(dagen[dagen.length - 1]);
const dicht = CONFIG.PRICE_HISTORY.CLOSED_MARKET_DAYS[a.kind] ?? 0;
const vlag = dagen.map(() => false);
let gezien = false;
let begin = null;
const sluit = eind => {
if (begin != null && eind - begin > dicht) for (let k = begin; k < eind; k++) vlag[k] = true;
begin = null;
};
dagen.forEach((dag, i) => {
if (echt.has(dag)) { sluit(i); gezien = true; return; }
if (gezien && begin == null) begin = i;
});
sluit(dagen.length);
uit.set(a.id, vlag);
}
return uit;
}
export function carriedDays(reeks = {}, ids = null, assets = []) {
const kandidaten = assets.filter(a => ids == null || ids.includes(a.id));
const vlaggen = carriedPerAsset({ dagen: reeks.dagen || [], assets: kandidaten, rijenPerAsset: reeks.rijenPerAsset || new Map() });
const uit = new Set();
for (const [id, vlag] of vlaggen) {
const eenheden = reeks.eenhedenPerAsset?.get(id) || [];
vlag.forEach((aan, i) => { if (aan && eenheden[i]) uit.add(i); });
}
return uit;
}
+361
View File
@@ -0,0 +1,361 @@
// Generated, do not edit. sha256:2f0f6627ca3113d46ca463586c34fddd9f180542b18f6825cae3c17d5a06bf19
import { loadCharting, readPalette } from '../ui/chart-setup.js';
import { tintSeries } from '../ui/charting.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtBase } from '../ui/format.js';
import { ENUMS } from '../../shared/config.js';
export function ringData(kaarten, { restVanaf = 0.02 } = {}) {
const bruikbaar = kaarten.filter(k => typeof k.m.value === 'number' && k.m.value > 0);
const alle = bruikbaar
.map(k => ({ ids: [k.asset.id], kinds: [k.asset.kind], label: k.asset.symbol, value: k.m.value, kind: k.asset.kind }))
.sort((a, b) => b.value - a.value);
const perSoort = new Map();
for (const a of alle) {
const vorig = perSoort.get(a.kind) || { value: 0, ids: [] };
perSoort.set(a.kind, { value: vorig.value + a.value, ids: [...vorig.ids, ...a.ids] });
}
const totaal = alle.reduce((s, a) => s + a.value, 0);
const grens = totaal * restVanaf;
const klein = alle.filter(a => a.value < grens);
const assets = klein.length >= 2
? [...alle.filter(a => a.value >= grens), {
ids: klein.flatMap(a => a.ids),
kinds: [...new Set(klein.flatMap(a => a.kinds))],
label: `${t('wealth.ringOther')} (${klein.length})`,
value: klein.reduce((s, a) => s + a.value, 0),
kind: null,
}]
: alle;
const soorten = ENUMS.ASSET_KIND
.filter(kind => perSoort.has(kind))
.map(kind => ({
kind, label: t('enums.asset_kind.' + kind),
value: perSoort.get(kind).value, ids: perSoort.get(kind).ids, kinds: [kind],
}));
return { soorten, assets };
}
export function segmentIndices(segmenten = [], sel) {
if (!sel) return [];
const uit = [];
if (sel.assetIds?.length) {
const wil = new Set(sel.assetIds);
segmenten.forEach((s, i) => { if ((s.ids || []).some(id => wil.has(id))) uit.push(i); });
return uit;
}
if (sel.kind) {
segmenten.forEach((s, i) => { if ((s.kinds || []).includes(sel.kind)) uit.push(i); });
}
return uit;
}
export function uitsteek(aandeel, max = UITSTEEK) {
const a = Math.min(Math.max(Number(aandeel) || 0, 0), 1);
return Math.round(max * (1 - a));
}
export function nadrukKleuren(basis = [], aan = new Set()) {
return basis.map((k, n) => (aan.size === 0 || aan.has(n) ? k : dim(k)));
}
export function glansStops(rgba) {
const m = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/.exec(String(rgba));
if (!m) return [rgba, rgba];
const [r, g, b, a] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
const licht = c => Math.round(c + (255 - c) * 0.18);
const binnen = Math.round(a * 0.72 * 1000) / 1000;
const buiten = Math.round(Math.min(1, a * 1.1) * 1000) / 1000;
return [`rgba(${r},${g},${b},${binnen})`, `rgba(${licht(r)},${licht(g)},${licht(b)},${buiten})`];
}
export function outerEdge(arc, { offset = 0, spacing = 0 } = {}) {
const { x, y, startAngle, endAngle, innerRadius = 0, outerRadius } = arc;
const g = endAngle - startAngle;
const mid = (startAngle + endAngle) / 2;
const shift = offset / 4;
const grow = shift * (1 - Math.sin(Math.min(Math.PI, g || 0)));
const half = spacing / 2;
const r = Math.max(outerRadius + half + grow, 0);
let trimSpacing = 0;
if (half) {
const avg = ((innerRadius > 0 ? innerRadius - half : 0) + (r > 0 ? r - half : 0)) / 2;
trimSpacing = (g - (avg !== 0 ? (g * avg) / (avg + half) : g)) / 2;
}
const trimOffset = r > 0 ? (g - Math.max(0.001, g * r - grow / Math.PI) / r) / 2 : 0;
return {
cx: x + Math.cos(mid) * shift, cy: y + Math.sin(mid) * shift, r,
start: startAngle + trimOffset + trimSpacing, end: endAngle - trimOffset - trimSpacing,
};
}
export function dim(rgba, factor = 0.35) {
const m = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/.exec(String(rgba));
if (!m) return rgba;
const a = Math.round(Number(m[4]) * factor * 1000) / 1000;
return `rgba(${m[1]},${m[2]},${m[3]},${a})`;
}
const UITSTEEK = 14;
const REST_UITSTEEK = 6;
const basisUitsteek = segmenten => segmenten.map(s => (s.kind === null ? REST_UITSTEEK : 0));
const PCT_IN_BOOG = 5;
const pctTekst = pct => `${pct.toLocaleString(localeTag(), { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`;
function pasNadrukToe(chart) {
const ds = chart?.data?.datasets?.[0];
if (!ds) return;
const st = chart._vermogen || {};
const aan = new Set([...(st.hover || []), ...(st.selectie || [])]);
ds._kleuren = nadrukKleuren(ds._basisKleuren, aan);
if (!ds._glans) ds.backgroundColor = ds._kleuren;
const basis = ds._basisOffset || [];
const totaal = (ds.data || []).reduce((s, v) => s + v, 0);
ds.offset = basis.map((b, n) => (aan.has(n) ? b + uitsteek(totaal > 0 ? ds.data[n] / totaal : 0) : b));
chart.update('none');
}
export async function tekenVerdeling(canvasSoorten, canvasAssets, kaarten, { onHover = () => {}, onSelect = () => {} } = {}) {
const { soorten, assets } = ringData(kaarten);
if (!assets.length) return null;
const Chart = await loadCharting();
const pal = readPalette();
const grafieken = [
canvasSoorten && bouwRing(Chart, pal, canvasSoorten, soorten, { onHover, onSelect, soortRing: true }),
canvasAssets && bouwRing(Chart, pal, canvasAssets, assets, { onHover, onSelect, soortRing: false }),
].filter(Boolean);
if (!grafieken.length) return null;
const zet = (veld, sel) => {
for (const g of grafieken) {
g._vermogen = { ...g._vermogen, [veld]: segmentIndices(g.data.datasets[0]._segmenten, sel) };
pasNadrukToe(g);
}
};
return {
markeer: sel => zet('hover', sel),
selecteer: sel => zet('selectie', sel),
destroy: () => { grafieken.forEach(g => g.destroy()); hideRingTip(); },
};
}
let ringTipEl = null;
const hideRingTip = () => { if (ringTipEl) ringTipEl.classList.remove('is-visible'); };
function ringTip({ chart, tooltip }, deel) {
if (!tooltip || tooltip.opacity === 0 || !tooltip.dataPoints?.length) { hideRingTip(); return; }
if (!ringTipEl || !ringTipEl.isConnected) {
ringTipEl = document.createElement('div');
ringTipEl.className = 'ring-tip';
ringTipEl.setAttribute('role', 'tooltip');
document.body.appendChild(ringTipEl);
window.addEventListener('scroll', hideRingTip, { capture: true, passive: true });
}
const punt = tooltip.dataPoints[0];
const bedrag = document.createElement('span');
bedrag.className = 'val';
bedrag.textContent = fmtBase(punt.parsed);
const naam = punt.dataset._segmenten?.[punt.dataIndex]?.label ?? '';
const naamEl = document.createElement('span');
naamEl.className = 'ring-tip-label';
naamEl.textContent = naam;
const pct = document.createElement('span');
pct.className = 'ring-tip-pct';
pct.textContent = pctTekst(deel(punt.parsed));
const regel = document.createElement('span');
regel.className = 'ring-tip-row';
regel.append(bedrag, pct);
ringTipEl.replaceChildren(naamEl, regel);
const r = chart.canvas.getBoundingClientRect();
ringTipEl.style.left = `${Math.round(r.left + tooltip.caretX)}px`;
ringTipEl.style.top = `${Math.round(r.top + tooltip.caretY)}px`;
ringTipEl.classList.add('is-visible');
}
function bouwRing(Chart, pal, canvas, segmenten, { onHover, onSelect, soortRing }) {
if (!segmenten.length) return null;
const kleuren = tintSeries(pal.accent, segmenten.length);
const totaal = segmenten.reduce((s, a) => s + a.value, 0);
const deel = v => (totaal > 0 ? (v / totaal) * 100 : 0);
const meldingVan = n => (soortRing
? { kind: segmenten[n].kind }
: { assetIds: segmenten[n].ids ?? [] });
let laatst = null;
const grafiek = new Chart(canvas, {
type: 'doughnut',
plugins: [glasPlugin(pal), percentagePlugin(pal), middenLabelPlugin(pal, deel)],
data: {
datasets: [{
data: segmenten.map(s => s.value),
backgroundColor: pal.ringSheen ? glansVulling : kleuren,
_kleuren: kleuren,
_glans: pal.ringSheen,
spacing: pal.ringSeam,
borderWidth: pal.ringSeam > 0 ? 0 : 1,
_segmenten: segmenten,
_basisKleuren: kleuren,
offset: basisUitsteek(segmenten), _basisOffset: basisUitsteek(segmenten),
hoverOffset: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: REST_UITSTEEK + UITSTEEK + 4 },
onHover: (evt, actief) => {
const el = actief[0];
if (!el || el.index === laatst) return;
laatst = el.index;
onHover(meldingVan(el.index));
},
onClick: (evt, actief) => {
const el = actief[0];
return onSelect(el ? meldingVan(el.index) : null);
},
plugins: {
legend: { display: false },
tooltip: {
enabled: false,
external: ctx => ringTip(ctx, deel),
},
},
borderColor: pal.tooltipBg,
borderWidth: 1,
cutout: '58%',
},
});
canvas.addEventListener('mouseleave', () => { laatst = null; onHover(null); });
return grafiek;
}
function glansVulling(context) {
const kleur = context.dataset._kleuren?.[context.dataIndex];
const { chart } = context;
const area = chart?.chartArea;
if (!kleur || !area) return kleur;
const r = Math.min(area.right - area.left, area.bottom - area.top) / 2;
if (!(r > 0)) return kleur;
const cx = (area.left + area.right) / 2;
const cy = (area.top + area.bottom) / 2;
const [binnen, buiten] = glansStops(kleur);
const g = chart.ctx.createRadialGradient(cx, cy, r * 0.58, cx, cy, r);
g.addColorStop(0, binnen);
g.addColorStop(1, buiten);
return g;
}
function glasPlugin(pal) {
return {
id: 'wealth-glass',
afterDatasetsDraw(c) {
if (!pal.ringSheen) return;
const meta = c.getDatasetMeta(0);
if (!meta?.data?.length || meta.hidden) return;
const { ctx } = c;
const st = c._vermogen || {};
const aan = new Set([...(st.hover || []), ...(st.selectie || [])]);
if (aan.size && pal.accentGlow) {
ctx.save();
ctx.shadowColor = pal.accentGlow;
ctx.shadowBlur = 18;
for (const n of aan) meta.data[n]?.draw(ctx);
ctx.restore();
}
if (!pal.hairline) return;
const eerste = meta.data[0];
const lijn = ctx.createLinearGradient(0, eerste.y - eerste.outerRadius, 0, eerste.y + eerste.outerRadius);
lijn.addColorStop(0, pal.hairline);
lijn.addColorStop(1, pal.rimBottom || 'transparent');
ctx.save();
ctx.strokeStyle = lijn;
ctx.lineWidth = 1;
for (const boog of meta.data) {
const rand = outerEdge(boog, { offset: boog.options?.offset || 0, spacing: boog.options?.spacing || 0 });
if (rand.end - rand.start <= 0 || rand.r <= 1) continue;
ctx.beginPath();
ctx.arc(rand.cx, rand.cy, rand.r - 0.5, rand.start, rand.end);
ctx.stroke();
}
ctx.restore();
},
};
}
function percentagePlugin(pal) {
return {
id: 'wealth-pct',
afterDatasetsDraw(c) {
const { ctx } = c;
const meta = c.getDatasetMeta(0);
if (!meta || meta.hidden) return;
const rijen = c.data.datasets[0].data;
const som = rijen.reduce((s, v) => s + v, 0);
ctx.save();
ctx.font = '600 11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
meta.data.forEach((boog, n) => {
const pct = som > 0 ? (rijen[n] / som) * 100 : 0;
if (pct < PCT_IN_BOOG) return;
const { x, y } = boog.getCenterPoint();
const tekst = pctTekst(pct);
ctx.lineWidth = 3;
ctx.strokeStyle = pal.tooltipBg;
ctx.strokeText(tekst, x, y);
ctx.fillStyle = pal.text;
ctx.fillText(tekst, x, y);
});
ctx.restore();
},
};
}
function middenLabelPlugin(pal, deel) {
return {
id: 'wealth-hole',
afterDatasetsDraw(c) {
const st = c._vermogen || {};
const n = (st.hover || [])[0] ?? (st.selectie || [])[0];
if (n == null) return;
const ds = c.data.datasets[0];
const seg = ds._segmenten?.[n];
if (!seg) return;
const meta = c.getDatasetMeta(0);
const boog = meta?.data?.[n];
if (!boog) return;
const { ctx } = c;
const x = boog.x;
const y = boog.y;
const ruimte = (boog.innerRadius ?? 40) * 1.7;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = pal.text;
ctx.font = '700 1.15rem system-ui, sans-serif';
ctx.fillText(pctTekst(deel(seg.value)), x, y - 12);
ctx.font = '600 0.78rem system-ui, sans-serif';
ctx.fillText(kort(ctx, seg.label, ruimte), x, y + 6);
ctx.fillStyle = pal.textSec;
ctx.font = '0.72rem system-ui, sans-serif';
ctx.fillText(kort(ctx, fmtBase(seg.value), ruimte), x, y + 22);
ctx.restore();
},
};
}
function kort(ctx, tekst, breedte) {
const s = String(tekst ?? '');
if (ctx.measureText(s).width <= breedte) return s;
let uit = s;
while (uit.length > 1 && ctx.measureText(uit + '…').width > breedte) uit = uit.slice(0, -1);
return uit + '…';
}
+538
View File
@@ -0,0 +1,538 @@
// Generated, do not edit. sha256:8f2431fd9a93cf7a3b491d7a9c3586996866c1c56d872590bc1b901665a63a6e
import { listAccounts } from '../data/accounts.js';
import { listAssets } from '../data/assets.js';
import { listEvents, listFlowLegs, listTradeEvents } from '../data/events.js';
import { firstPositionDate, positionAt, positionsByAsset, unexplainedByPair } from '../data/positions.js';
import { listPriceHistory } from '../data/price-history.js';
import { getPref } from '../data/preferences.js';
import { toDailyGrid } from '../signals/data.js';
import { listProviders } from '../data/providers.js';
import { provenanceOn, provenanceOfRow, originFrom, liveOrigin } from '../../shared/price-provenance.js';
import { ensurePriceHistoryFor, ensurePointsAtFor, liveSpotPriceFor, lastSpotSource } from '../price/providers.js';
import { REIKWIJDTE, categorieVoor, reikwijdteVoor } from '../tax/constants.js';
import { isOwnWealth } from '../../shared/wealth-scope.js';
import { CONFIG } from '../../shared/config.js';
import { modifiedDietz, irr, averageCapital } from './performance.js';
import { dagreeks, metSpotVandaag, todayResults } from './series.js';
import { listValuations } from '../data/valuations.js';
import { toUtcIso, nowUtcIso } from '../../shared/time.js';
import { t } from '../i18n/index.js';
const DAY_MS = 24 * 60 * 60 * 1000;
function priceAt(raster, isoDate) {
if (!raster || !raster.grid || raster.grid.length === 0) return null;
const { grid, dagen, kind } = raster;
const ts = Date.parse(`${isoDate}T00:00:00Z`);
if (ts < grid[0][0]) return null;
const grens = CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS[kind];
if (grens != null && !versGenoeg(dagen, ts, grens)) return null;
const idx = Math.min(grid.length - 1, Math.floor((ts - grid[0][0]) / DAY_MS));
return grid[idx][1];
}
function versGenoeg(dagen, ts, grens) {
if (!dagen || dagen.length === 0) return false;
for (let i = dagen.length - 1; i >= 0; i--) {
if (dagen[i] <= ts) return ts - dagen[i] <= grens * DAY_MS;
}
return false;
}
const peilIso = year => toUtcIso(`${year}-01-01T00:00:00`);
const localNowIso = () => nowUtcIso();
async function liveSpots(assets) {
const m = new Map();
for (const a of assets) {
try { const price = await liveSpotPriceFor(a); if (price != null) m.set(a.id, price); }
catch { }
}
return m;
}
function waardeVan(sats, div, price) {
if (sats === 0) return 0;
return price != null ? sats / div * price : null;
}
function currentPriceFor(asset, raster, live) {
const spot = live.get(asset.id);
if (spot != null) return spot;
const grid = raster && raster.grid;
return grid && grid.length ? grid[grid.length - 1][1] : null;
}
function raster(rows, asset) {
const dagen = [...new Set(rows.filter(r => r.fiatPrice != null && r.fiatPrice > 0).map(r => r.observedAt.slice(0, 10)))]
.sort()
.map(d => Date.parse(`${d}T00:00:00Z`));
return { grid: toDailyGrid(rows), dagen, kind: asset.kind, rows };
}
function herkomstVan(eigenRaster, isoDate, price, ctx) {
if (price == null || !eigenRaster || !eigenRaster.rows) return null;
return originFrom(provenanceOn(eigenRaster.rows, isoDate,
{ assetKind: eigenRaster.kind, manualProviderId: ctx.manualId, usedPrice: price }), ctx.names);
}
function huidigeHerkomst(asset, eigenRaster, live, price, ctx) {
if (price == null) return null;
if (live.get(asset.id) != null) return liveOrigin(price, lastSpotSource(asset));
const grid = eigenRaster && eigenRaster.grid;
if (!grid || !grid.length || !eigenRaster.rows) return null;
const d = provenanceOn(eigenRaster.rows, new Date(grid[grid.length - 1][0]).toISOString().slice(0, 10),
{ assetKind: eigenRaster.kind, manualProviderId: ctx.manualId, usedPrice: price });
if (!d || !d.row) return null;
return originFrom(provenanceOfRow(d.row, { manualProviderId: ctx.manualId }), ctx.names);
}
export async function herkomstContext() {
try {
const providers = await listProviders();
const manual = providers.find(p => p.key === 'manual');
return { names: new Map(providers.map(p => [p.id, p.name])), manualId: manual ? manual.id : null };
} catch {
return { names: new Map(), manualId: null };
}
}
async function buildGrids(assets, peilDates = []) {
for (const a of assets) {
try { await ensurePriceHistoryFor(a); }
catch { }
}
for (const a of assets) {
if (peilDates.length) { try { await ensurePointsAtFor(a, peilDates); } catch { } }
}
const grids = new Map();
for (const a of assets) {
try { grids.set(a.id, raster(await listPriceHistory(a.id), a)); }
catch { grids.set(a.id, null); }
}
return grids;
}
async function flows(fromIso, untilIso, grids, telt = () => true, grens = []) {
let deposits = 0, withdrawals = 0, giften = 0;
const legs = [];
for (const e of await listTradeEvents(fromIso, untilIso)) {
const kant = e.type === 'buy' ? e.toAccountId
: e.type === 'sell' ? e.fromAccountId
: (e.fromAccountId ?? e.toAccountId);
if (!telt(kant, e.assetId)) continue;
const eenheden = e.amount / Math.pow(10, e.decimals);
const fee = e.fiatFee != null ? e.fiatFee : 0;
if (e.type === 'transfer' && e.fromAccountId != null && fee > 0) {
deposits += fee;
legs.push({ fiat: fee, atIso: e.occurredAt });
}
if (e.type === 'transfer' && e.priceOrigin !== 'none') {
if ((e.fromAccountId == null) === (e.toAccountId == null)) continue;
const eigenRaster = grids ? grids.get(e.assetId) : null;
const prijs = eigenRaster ? priceAt(eigenRaster, e.occurredAt.slice(0, 10)) : null;
if (prijs == null) continue;
const fiat = eenheden * prijs;
if (e.fromAccountId == null) { deposits += fiat; legs.push({ fiat, atIso: e.occurredAt }); }
else { withdrawals += fiat; legs.push({ fiat: -fiat, atIso: e.occurredAt }); }
continue;
}
if (e.priceOrigin === 'none') {
if (e.type === 'transfer' && (e.fromAccountId == null) === (e.toAccountId == null)) continue;
const eigenRaster = grids ? grids.get(e.assetId) : null;
const prijs = eigenRaster ? priceAt(eigenRaster, e.occurredAt.slice(0, 10)) : null;
const binnen = e.type === 'transfer' ? e.fromAccountId == null : e.type === 'buy';
if (prijs != null) {
const fiat = (binnen ? 1 : -1) * eenheden * prijs;
giften += fiat;
legs.push({ fiat, atIso: e.occurredAt });
}
continue;
}
const fiat = eenheden * e.fiatPrice;
if (e.type === 'buy') {
deposits += fiat + fee;
legs.push({ fiat: fiat + fee, atIso: e.occurredAt });
} else {
withdrawals += fiat - fee;
legs.push({ fiat: -(fiat - fee), atIso: e.occurredAt });
}
}
for (const g of grens) {
if (!(g.occurredAt > fromIso && g.occurredAt <= untilIso)) continue;
const eigenRaster = grids ? grids.get(g.assetId) : null;
const prijs = eigenRaster ? priceAt(eigenRaster, g.occurredAt.slice(0, 10)) : null;
if (prijs == null) continue;
const fiat = g.eenheden * prijs;
if (g.naarBuiten) { withdrawals += fiat; legs.push({ fiat: -fiat, atIso: g.occurredAt }); }
else { deposits += fiat; legs.push({ fiat, atIso: g.occurredAt }); }
}
return { deposits, withdrawals, giften, legs };
}
function legValue(leg, eigenRaster) {
const units = leg.units / Math.pow(10, leg.decimals);
if (leg.priceOrigin !== 'none') {
if (leg.type === 'buy') return units * leg.fiatPrice + (leg.fiatFee || 0);
if (leg.type === 'sell') return units * leg.fiatPrice - (leg.fiatFee || 0);
}
const price = priceAt(eigenRaster, leg.occurredAt.slice(0, 10));
return price == null ? null : units * price;
}
async function pairFlows(fromIso, untilIso, grids) {
const uit = new Map();
for (const leg of await listFlowLegs(fromIso, untilIso)) {
const key = `${leg.accountId}|${leg.assetId}`;
const rec = uit.get(key) || { in: 0, uit: 0, onvolledig: false, legs: [] };
const fiat = legValue(leg, grids.get(leg.assetId));
if (fiat == null) rec.onvolledig = true;
else {
if (leg.direction === 'in') rec.in += fiat;
else rec.uit += fiat;
rec.legs.push({ fiat: leg.direction === 'in' ? fiat : -fiat, atIso: leg.occurredAt });
}
const fee = Number(leg.fiatFee) || 0;
if (leg.type === 'transfer' && leg.direction === 'out' && fee > 0) {
rec.in += fee;
rec.legs.push({ fiat: fee, atIso: leg.occurredAt });
}
uit.set(key, rec);
}
return uit;
}
async function setRowPct(rows, grids, { y, running, vanIso, totIso, openingPerPaar, methode }) {
const stromen = await pairFlows(vanIso, totIso, grids);
for (const r of rows) {
const eindCel = running ? r.current : r.perYear[y + 1];
const beginCel = r.perYear[y];
if (!eindCel || !beginCel) continue;
const key = `${r.account.id}|${r.asset.id}`;
const stroom = stromen.get(key) || { in: 0, uit: 0, onvolledig: false, legs: [] };
const begin = beginCel.value;
const bruikbaar = begin != null && eindCel.value != null && !stroom.onvolledig;
eindCel.gain = bruikbaar
? eindCel.value - begin - stroom.in + stroom.uit - (openingPerPaar.get(key) || 0)
: null;
eindCel.pct = bruikbaar && begin > 0
? rendementVan(methode, {
gain: eindCel.gain, begin, eind: eindCel.value - (openingPerPaar.get(key) || 0), flows: stroom.legs,
fromIso: vanIso, untilIso: totIso,
})
: null;
eindCel.basis = bruikbaar && begin > 0
? averageCapital(begin, stroom.legs, { fromIso: vanIso, untilIso: totIso })
: null;
eindCel.weging = bruikbaar
? averageCapital(begin, stroom.legs, { fromIso: vanIso, untilIso: totIso })
: null;
}
}
export function sumReturn(cellen = []) {
let gain = 0, weging = 0, begin = 0;
for (const { begin: b, eind: e } of cellen) {
if (!e || !b || e.gain == null || e.weging == null) return null;
gain += e.gain;
weging += e.weging;
begin += b.value || 0;
}
if (!cellen.length) return null;
return { gain, pct: begin > 0 && weging > 0 ? gain / weging : null };
}
function rendementVan(methode, { gain, begin, eind, flows, fromIso, untilIso }) {
if (methode === 'irr') {
const r = irr([
{ fiat: -begin, atIso: fromIso },
...flows.map(f => ({ fiat: -f.fiat, atIso: f.atIso })),
{ fiat: eind, atIso: untilIso },
], { fromIso });
if (r != null) return r;
}
return modifiedDietz({ gain, begin, flows, fromIso, untilIso });
}
const geenRekening = () => ({ id: null, name: t('wealth.noAccount'), taxCategory: null, taxScope: null, isUnassigned: true });
async function restPerAsset(peil, perAssetSom) {
const uit = new Map();
for (const r of await positionsByAsset(peil)) {
const rest = r.net - (perAssetSom.get(r.id) || 0);
if (rest !== 0) uit.set(r.id, rest);
}
return uit;
}
const withoutTaxSettings = x => ({ ...x, taxCategory: null, taxScope: null });
export async function buildWealthModel({ ignoreTaxSettings = false } = {}) {
let assets = await listAssets();
let accounts = await listAccounts();
const hasTaxSettings = [...assets, ...accounts].some(x => x.taxCategory != null || x.taxScope != null);
if (ignoreTaxSettings) {
assets = assets.map(withoutTaxSettings);
accounts = accounts.map(withoutTaxSettings);
}
const first = await firstPositionDate();
const now = new Date();
const nowIso = localNowIso();
if (!first || assets.length === 0) return { years: [], rows: [], totals: null };
const firstYear = Number(first.slice(0, 4));
const huidigJaar = now.getFullYear();
const startPref = Number(await getPref('app', 'start_year')) || CONFIG.START_YEAR.AUTO;
const methode = String(await getPref('app', 'mwr_method') || 'dietz');
const startYear = Math.min(Math.max(firstYear + 1, startPref), huidigJaar);
const ingekort = startYear > firstYear + 1;
const years = [];
for (let y = startYear; y <= huidigJaar; y++) years.push(y);
const grids = await buildGrids(assets, years.map(y => `${y}-01-01`));
const live = await liveSpots(assets);
const multiAsset = assets.length > 1;
const hctx = await herkomstContext();
let rows = [];
const somPerAsset = new Map();
for (const moment of [...years.map(peilIso), nowIso]) somPerAsset.set(moment, new Map());
const telMee = (moment, assetId, sats) => {
const m = somPerAsset.get(moment);
m.set(assetId, (m.get(assetId) || 0) + sats);
};
for (const ac of accounts) {
for (const a of assets) {
const eigenRaster = grids.get(a.id);
const div = Math.pow(10, a.decimals);
const perYear = {};
let any = false;
for (const y of years) {
const sats = await positionAt(ac.id, a.id, peilIso(y));
telMee(peilIso(y), a.id, sats);
const price = priceAt(eigenRaster, `${y}-01-01`);
perYear[y] = { sats, price, value: waardeVan(sats, div, price), gain: null, pct: null,
origin: sats === 0 ? null : herkomstVan(eigenRaster, `${y}-01-01`, price, hctx) };
if (sats !== 0) any = true;
}
const curSats = await positionAt(ac.id, a.id, nowIso);
telMee(nowIso, a.id, curSats);
const curPrice = currentPriceFor(a, eigenRaster, live);
const current = { sats: curSats, price: curPrice, value: waardeVan(curSats, div, curPrice), gain: null, pct: null,
origin: curSats === 0 ? null : huidigeHerkomst(a, eigenRaster, live, curPrice, hctx) };
if (curSats !== 0) any = true;
if (!any) continue;
rows.push({
account: ac, asset: a,
categorie: categorieVoor(ac, a),
reikwijdte: reikwijdteVoor(ac, a),
name: multiAsset ? `${ac.name} · ${a.symbol}` : ac.name,
perYear, current,
});
}
}
const restPerMoment = new Map();
for (const [moment, som] of somPerAsset) restPerMoment.set(moment, await restPerAsset(moment, som));
for (const a of assets) {
const div = Math.pow(10, a.decimals);
const eigenRaster = grids.get(a.id);
const perYear = {};
let any = false;
for (const y of years) {
const sats = restPerMoment.get(peilIso(y)).get(a.id) || 0;
const price = priceAt(eigenRaster, `${y}-01-01`);
perYear[y] = { sats, price, value: waardeVan(sats, div, price), gain: null, pct: null,
origin: sats === 0 ? null : herkomstVan(eigenRaster, `${y}-01-01`, price, hctx) };
if (sats !== 0) any = true;
}
const curSats = restPerMoment.get(nowIso).get(a.id) || 0;
const curPrice = currentPriceFor(a, eigenRaster, live);
const current = { sats: curSats, price: curPrice, value: waardeVan(curSats, div, curPrice), gain: null, pct: null,
origin: curSats === 0 ? null : huidigeHerkomst(a, eigenRaster, live, curPrice, hctx) };
if (curSats !== 0) any = true;
if (!any) continue;
const ac = geenRekening();
rows.push({
account: ac, asset: a,
categorie: categorieVoor(ac, a),
reikwijdte: reikwijdteVoor(ac, a),
name: multiAsset ? `${ac.name} · ${a.symbol}` : ac.name,
perYear, current,
});
}
const buitenBox3 = rows
.filter(r => r.reikwijdte === REIKWIJDTE.NONE)
.map(r => ({ name: r.name, value: r.current.value }));
rows = rows.filter(r => r.reikwijdte !== REIKWIJDTE.NONE);
const categoryRows = rows;
rows = rows.filter(r => r.reikwijdte !== REIKWIJDTE.CATEGORY);
const emptyYears = years.filter(y => rows.every(r => r.perYear[y].sats === 0));
const totals = { perYear: {}, current: 0 };
for (const y of years) totals.perYear[y] = rows.reduce((s, r) => s + (r.perYear[y].value || 0), 0);
totals.current = rows.reduce((s, r) => s + (r.current.value || 0), 0);
const ONBEKEND = 'onbekend';
const catVanRij = (r) => r.categorie || ONBEKEND;
const perCategorie = (waardeVanRij, lijst = rows) => {
const uit = {};
for (const r of lijst) {
const cat = catVanRij(r);
uit[cat] = (uit[cat] || 0) + (waardeVanRij(r) || 0);
}
return uit;
};
totals.perYearPerCategorie = {};
for (const y of years) totals.perYearPerCategorie[y] = perCategorie(r => r.perYear[y].value);
totals.currentPerCategorie = perCategorie(r => r.current.value);
const categorySplit = { perYear: {}, current: perCategorie(r => r.current.value, categoryRows) };
for (const y of years) categorySplit.perYear[y] = perCategorie(r => r.perYear[y].value, categoryRows);
const jaarTotaal = (perCat) => Object.values(perCat).reduce((s, v) => s + v, 0);
const rijPerPaar = new Map(rows.map(r => [`${r.account.id}|${r.asset.id}`, r]));
const rekeningVan = new Map(accounts.map(a => [a.id, a]));
const assetVan = new Map(assets.map(a => [a.id, a]));
const telt = (accountId, assetId) =>
reikwijdteVoor(accountId == null ? geenRekening() : rekeningVan.get(accountId), assetVan.get(assetId))
=== REIKWIJDTE.ALL;
const grens = (await listEvents())
.filter(e => e.type === 'transfer' && e.fromAccountId != null && e.toAccountId != null
&& telt(e.fromAccountId, e.assetId) !== telt(e.toAccountId, e.assetId))
.map(e => ({
assetId: e.assetId, occurredAt: e.occurredAt, naarBuiten: telt(e.fromAccountId, e.assetId),
eenheden: (Number(e.amount) || 0) / Math.pow(10, assetVan.get(e.assetId)?.decimals ?? 0),
}));
const aanwas = [];
for (const y of years) {
const running = y === now.getFullYear();
const perCatBegin = totals.perYearPerCategorie[y];
const perCatEind = running ? totals.currentPerCategorie : totals.perYearPerCategorie[y + 1];
const begin = jaarTotaal(perCatBegin);
const eindIso = running ? nowIso : peilIso(y + 1);
const eind = perCatEind ? jaarTotaal(perCatEind) : null;
if (eind == null) continue;
let beginWeg = 0, eindWeg = 0;
const zonderKoers = [];
for (const r of rows) {
const b = r.perYear[y], e = running ? r.current : r.perYear[y + 1];
if (!b || !e) continue;
const bOnbekend = b.sats !== 0 && b.value == null;
const eOnbekend = e.sats !== 0 && e.value == null;
if (bOnbekend === eOnbekend) continue;
if (bOnbekend) eindWeg += e.value || 0;
else beginWeg += b.value || 0;
zonderKoers.push(r.name);
}
const f = await flows(peilIso(y), eindIso, grids, telt, grens);
let unexplained = 0;
let uitBalansen = 0;
const unexplainedRows = [];
const openingPerPaar = new Map();
for (const brok of await unexplainedByPair(peilIso(y), eindIso)) {
const paar = `${brok.accountId}|${brok.assetId}`;
const r = rijPerPaar.get(paar);
if (!r) continue;
const prijs = running ? r.current.price : (r.perYear[y + 1] ? r.perYear[y + 1].price : null);
if (prijs == null) continue;
const valueOf = eenheden => eenheden / Math.pow(10, r.asset.decimals) * prijs;
const opening = valueOf(brok.openingUnits);
const value = valueOf(brok.units);
uitBalansen += opening;
openingPerPaar.set(paar, opening);
if (Math.abs(value - opening) < 0.005) continue;
unexplained += value - opening;
unexplainedRows.push({ name: r.name, units: brok.units, value: value - opening });
}
unexplainedRows.sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
await setRowPct(rows, grids, { y, running, vanIso: peilIso(y), totIso: eindIso, openingPerPaar, methode });
const result = (eind - eindWeg) - (begin - beginWeg) - f.deposits + f.withdrawals - uitBalansen - f.giften;
const beginMeet = begin - beginWeg;
const pct = beginMeet > 0
? rendementVan(methode, {
gain: result, begin: beginMeet, eind: eind - eindWeg - uitBalansen, flows: f.legs,
fromIso: peilIso(y), untilIso: eindIso,
})
: null;
aanwas.push({
year: y, running, begin, eind, beginWeg,
deposits: f.deposits, withdrawals: f.withdrawals, uitBalansen, giften: f.giften,
result, pct, unexplained, unexplainedRows, zonderKoers,
});
}
const pct = { perYear: {}, current: null };
for (const y of years) pct.perYear[y] = null;
for (const a of aanwas) {
if (a.running) pct.current = a.pct; else pct.perYear[a.year + 1] = a.pct;
}
const perJaar = years.map(y => {
const perCategorie = totals.perYearPerCategorie[y] || {};
const rendement = aanwas.find(x => x.year === y) || null;
return {
year: y,
value: totals.perYear[y],
perCategorie,
categorieCompleet: !(perCategorie[ONBEKEND] > 0),
rendement: rendement ? rendement.result : null,
running: rendement ? rendement.running : false,
};
});
return { years, nowIso, rows, totals, pct, aanwas, perJaar, multiAsset, buitenBox3, firstYear,
ingekort, emptyYears, mwrMethod: methode, categoryRows, categorySplit, hasTaxSettings };
}
export async function currentStanding() {
const assets = await listAssets();
if (assets.length === 0) return { rows: [], total: 0, totalSats: 0, today: null };
const grids = await buildGrids(assets);
const live = await liveSpots(assets);
const hctx = await herkomstContext();
const nowIso = localNowIso();
const rows = [];
let total = 0, totalSats = 0;
const som = new Map();
const voegToe = (ac, a, sats) => {
if (sats === 0) return;
const eigenRaster = grids.get(a.id);
const price = currentPriceFor(a, eigenRaster, live);
const value = price != null ? sats / Math.pow(10, a.decimals) * price : null;
const origin = value != null ? huidigeHerkomst(a, eigenRaster, live, price, hctx) : null;
rows.push({ account: ac, asset: a, sats, value, origin });
if (value != null) total += value;
totalSats += sats;
};
const accounts = await listAccounts();
for (const ac of accounts) {
const eigen = isOwnWealth(ac);
for (const a of assets) {
const sats = await positionAt(ac.id, a.id, nowIso);
som.set(a.id, (som.get(a.id) || 0) + sats);
if (eigen) voegToe(ac, a, sats);
}
}
const rest = await restPerAsset(nowIso, som);
for (const a of assets) voegToe(geenRekening(), a, rest.get(a.id) || 0);
rows.sort((x, y) => (y.value || 0) - (x.value || 0));
return { rows, total, totalSats, today: await resultToday(assets, grids, live, accounts) };
}
async function resultToday(assets, grids, live, accounts) {
try {
const [events, valuations] = await Promise.all([listEvents(), listValuations()]);
const prijzen = new Map(assets.map(a => [a.id, grids.get(a.id)?.grid || []]));
const excludedAccounts = new Set(accounts.filter(ac => !isOwnWealth(ac)).map(ac => ac.id));
return todayResults(metSpotVandaag(dagreeks({ events, valuations, assets, prijzen, excludedAccounts }), assets, live));
} catch (err) {
console.warn('[standing] today\'s result not computed:', err);
return null;
}
}
+150
View File
@@ -0,0 +1,150 @@
// Generated, do not edit. sha256:effc21e27380a5223a30516e074971aec0e56f100cf6c94c8684cfdf36fcca0e
import { CONFIG } from '../../shared/config.js';
export function weightOf(atIso, fromIso, untilIso) {
const from = Date.parse(fromIso);
const until = Date.parse(untilIso);
const at = Date.parse(atIso);
if (!Number.isFinite(from) || !Number.isFinite(until) || !Number.isFinite(at)) return null;
const span = until - from;
if (span <= 0) return 1;
return Math.min(1, Math.max(0, (until - at) / span));
}
export function averageCapital(begin, flows = [], { fromIso, untilIso } = {}) {
if (typeof begin !== 'number' || !Number.isFinite(begin)) return null;
let som = begin;
for (const f of flows) {
const w = weightOf(f?.atIso, fromIso, untilIso);
const fiat = Number(f?.fiat);
if (w == null || !Number.isFinite(fiat)) continue;
som += fiat * w;
}
return som;
}
export function modifiedDietz({ gain, begin, flows = [], fromIso, untilIso } = {}) {
if (typeof gain !== 'number' || !Number.isFinite(gain)) return null;
const noemer = averageCapital(begin, flows, { fromIso, untilIso });
if (noemer == null || !(noemer > 0)) return null;
return gain / noemer;
}
export function dailyFlows(cumulative = []) {
return cumulative.map((v, i) => (i === 0 ? 0 : (Number(v) || 0) - (Number(cumulative[i - 1]) || 0)));
}
export function timeWeighted(values = [], flows = [], outflows = []) {
let factor = 1;
let measured = false;
for (const f of dailyFactors(values, flows, outflows)) {
if (f == null) continue;
factor *= f;
measured = true;
}
return measured ? factor - 1 : null;
}
export function dailyFactors(values = [], flows = [], outflows = []) {
if (!Array.isArray(values)) return [];
const out = values.map(() => null);
let closed = false;
for (let i = 1; i < values.length; i++) {
const f = Number(flows[i]) || 0;
const inflow = Math.max(f, 0);
const outflow = Math.max(-f, 0) + (Number(outflows[i]) || 0);
const previous = Number(values[i - 1]);
if (closed) {
const reopen = inflow > 0 && (!(previous > 0) || inflow >= CONFIG.WEALTH_RETURN.REOPEN_MULTIPLE * previous);
if (!reopen) continue;
closed = false;
}
const end = Number(values[i]);
const base = previous + inflow;
if (!Number.isFinite(end) || !Number.isFinite(base) || !(base > 0)) continue;
out[i] = (end + outflow) / base;
if (outflow > 0 && end < CONFIG.WEALTH_RETURN.DUST_SHARE * base) closed = true;
}
return out;
}
export function lastDayHeld(units = [], from = 0, to = 0) {
let i = Math.min(to, units.length - 1);
while (i > from && !units[i]) i--;
return i >= from && units[i] ? i : null;
}
export function windowTwr(reeks = {}, venster = {}) {
const s = windowSlices(reeks, venster);
return s ? timeWeighted(s.values, s.inflows, s.outflows) : null;
}
export function windowFactors(reeks = {}, venster = {}) {
const out = (reeks.waarden || []).map(() => null);
const s = windowSlices(reeks, venster);
if (!s) return out;
dailyFactors(s.values, s.inflows, s.outflows).forEach((f, k) => {
const i = s.first + k;
if (i >= 0 && i < out.length) out[i] = f;
});
return out;
}
function windowSlices(reeks, venster) {
const waarden = reeks.waarden || [];
const van = Number(venster.van) || 0;
const start = reeks.eersteDag == null ? 0 : Number(reeks.eersteDag) - 1;
const vanaf = Math.max(start, van > 0 ? van : -1);
if (reeks.eenheden && lastDayHeld(reeks.eenheden, Math.max(vanaf, 0), venster.tot) == null) return null;
const tot = Math.min(venster.tot, waarden.length - 1) + 1;
const nulDag = vanaf < 0 ? [0] : [];
const snee = r => [...nulDag, ...(r || []).slice(Math.max(vanaf, 0), tot)];
const { erin, eruit } = flowsOf(snee(reeks.inleg), snee(reeks.ankers), reeks.uit ? snee(reeks.uit) : []);
return { values: snee(waarden), inflows: erin, outflows: eruit, first: vanaf };
}
export function flowsOf(inleg = [], ankers = [], uit = []) {
const bruto = dailyFlows(inleg.map((v, i) => (Number(v) || 0) + (Number(uit[i]) || 0)));
const verkocht = dailyFlows(uit);
const anker = dailyFlows(ankers);
return {
erin: bruto.map((v, i) => v + Math.max(anker[i] || 0, 0)),
eruit: bruto.map((_, i) => (verkocht[i] || 0) + Math.max(-(anker[i] || 0), 0)),
};
}
const IRR_EPS = 1e-7;
const IRR_STAPPEN = 200;
const IRR_MIN = -0.9999, IRR_MAX = 10;
function contanteWaarde(kasstromen, rente) {
let som = 0;
for (const k of kasstromen) som += k.fiat / Math.pow(1 + rente, k.jaren);
return som;
}
export function irr(flows = [], { fromIso } = {}) {
const start = Date.parse(fromIso ?? flows[0]?.atIso);
if (!Number.isFinite(start)) return null;
const kasstromen = [];
for (const f of flows) {
const at = Date.parse(f?.atIso);
const fiat = Number(f?.fiat);
if (!Number.isFinite(at) || !Number.isFinite(fiat)) return null;
kasstromen.push({ fiat, jaren: (at - start) / (365.2425 * 24 * 60 * 60 * 1000) });
}
if (!kasstromen.some(k => k.fiat > 0) || !kasstromen.some(k => k.fiat < 0)) return null;
let laag = IRR_MIN, hoog = IRR_MAX;
let fLaag = contanteWaarde(kasstromen, laag);
let fHoog = contanteWaarde(kasstromen, hoog);
if (!Number.isFinite(fLaag) || !Number.isFinite(fHoog) || fLaag * fHoog > 0) return null;
for (let i = 0; i < IRR_STAPPEN; i++) {
const midden = (laag + hoog) / 2;
const fMidden = contanteWaarde(kasstromen, midden);
if (!Number.isFinite(fMidden)) return null;
if (Math.abs(fMidden) < IRR_EPS || (hoog - laag) / 2 < IRR_EPS) return midden;
if (fLaag * fMidden <= 0) { hoog = midden; fHoog = fMidden; } else { laag = midden; fLaag = fMidden; }
}
return (laag + hoog) / 2;
}
+143
View File
@@ -0,0 +1,143 @@
// Generated, do not edit. sha256:80acd8f6f37bf4f64acd4a1259ffb1f609bdfe3d99f67a807eaad2154af68875
import { loadCharting, readPalette } from '../ui/chart-setup.js';
import { hexToRgb } from '../ui/charting.js';
import { t } from '../i18n/index.js';
import { fmtBaseSigned, fmtPct, colouredSigns } from '../ui/format.js';
let tipEl = null;
const hide = () => tipEl?.classList.remove('is-visible');
const tone = v => (v > 0.005 ? 't-pos' : v < -0.005 ? 't-neg' : '');
function row(label, amount, pct, extra = '') {
const r = document.createElement('span');
r.className = `ring-tip-row ${extra}`.trim();
const name = document.createElement('span');
name.className = 'ring-tip-pct';
name.textContent = label;
const value = document.createElement('span');
value.className = `val ${tone(amount)}`;
value.textContent = fmtBaseSigned(amount, colouredSigns()) + (pct == null ? '' : ` (${fmtPct(pct, { fine: true, signs: colouredSigns() })})`);
r.append(name, value);
return r;
}
function tip({ chart, tooltip }, points, labels) {
if (!tipEl || !tipEl.isConnected) {
tipEl = document.createElement('div');
tipEl.className = 'ring-tip';
tipEl.setAttribute('role', 'tooltip');
document.body.appendChild(tipEl);
window.addEventListener('scroll', hide, { capture: true, passive: true });
}
if (!tooltip.opacity || !tooltip.dataPoints?.length) { hide(); return; }
const n = tooltip.dataPoints[0].dataIndex;
const p = points[n];
const head = document.createElement('span');
head.className = 'ring-tip-label';
head.textContent = labels[n];
const rows = [head];
if (p.day != null) rows.push(row(t('wealth.returnDay'), p.day, p.pct));
for (const c of p.top || []) rows.push(row(c.label, c.day, c.pct, 'ring-tip-row--sub'));
rows.push(row(t('wealth.returnSoFar'), p.cumulative, null));
if (p.carried) {
const note = document.createElement('span');
note.className = 'ring-tip-row ring-tip-row--sub t-muted';
note.textContent = t('wealth.returnCarried');
rows.push(note);
}
tipEl.replaceChildren(...rows);
const box = chart.canvas.getBoundingClientRect();
tipEl.style.left = `${Math.round(box.left + tooltip.caretX)}px`;
tipEl.style.top = `${Math.round(box.top + tooltip.caretY)}px`;
tipEl.classList.add('is-visible');
}
const rgba = (hex, a) => { const c = hexToRgb(hex); return c ? `rgba(${c.r},${c.g},${c.b},${a})` : null; };
function gradientFill(green, red, pal) {
return {
id: 'return-gradient-fill',
afterLayout(chart) {
const { top, bottom } = chart.chartArea || {};
const zero = chart.scales?.y?.getPixelForValue(0);
if (![top, bottom, zero].every(Number.isFinite)) return;
const upTop = rgba(green, 0.42), upZero = rgba(green, 0.04);
const downZero = rgba(red, 0.04), downBottom = rgba(red, 0.42);
if (!upTop || !downBottom) return;
const { ctx } = chart;
const up = ctx.createLinearGradient(0, top, 0, Math.max(zero, top + 1));
up.addColorStop(0, upTop);
up.addColorStop(1, upZero);
const down = ctx.createLinearGradient(0, Math.min(zero, bottom - 1), 0, bottom);
down.addColorStop(0, downZero);
down.addColorStop(1, downBottom);
chart.data.datasets[0].fill = { target: 'origin', above: up, below: down };
},
};
}
function hoverLine(pal) {
return {
id: 'return-hover-line',
afterDatasetsDraw(chart) {
const active = chart.tooltip?.getActiveElements?.() || [];
if (!active.length) return;
const x = active[0].element.x;
const { top, bottom } = chart.chartArea;
const { ctx } = chart;
ctx.save();
ctx.strokeStyle = pal.textSec;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.round(x) + 0.5, top);
ctx.lineTo(Math.round(x) + 0.5, bottom);
ctx.stroke();
ctx.restore();
},
};
}
export async function drawReturnChart(canvas, points, labels) {
const Chart = await loadCharting();
const pal = readPalette();
const green = pal.sigGreen, red = pal.sigRed;
const chart = new Chart(canvas, {
type: 'line',
plugins: [hoverLine(pal), gradientFill(green, red, pal)],
data: {
labels,
datasets: [{
data: points.map(p => p.cumulative),
borderColor: green, borderWidth: 2, pointRadius: 0, pointHoverRadius: 3,
segment: {
borderColor: c => (c.p1.parsed.y < 0 ? red : green),
borderDash: c => (points[c.p0DataIndex]?.carried || points[c.p1DataIndex]?.carried ? [4, 4] : undefined),
},
pointHoverBackgroundColor: c => ((c.parsed?.y ?? 0) < 0 ? red : green), cubicInterpolationMode: 'monotone',
fill: { target: 'origin', above: pal.greenZone, below: pal.redZone },
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
layout: { padding: { top: 6, right: 0, bottom: 6, left: 0 } },
scales: {
x: { display: false },
y: { beginAtZero: true, ticks: { display: false }, border: { display: false }, afterFit: s => { s.width = 0; },
grid: { color: c => (c.tick?.value === 0 ? pal.textSec : 'transparent') } },
},
plugins: {
legend: { display: false },
tooltip: { enabled: false, external: ctx => tip(ctx, points, labels) },
},
},
});
canvas.addEventListener('mouseleave', hide);
const destroy = chart.destroy.bind(chart);
chart.destroy = () => { hide(); destroy(); };
return chart;
}
+181
View File
@@ -0,0 +1,181 @@
// Generated, do not edit. sha256:c1446a22c58388fe401f93d2f54194b187120f3e6ec07b997b51d0acb9bb51b4
import * as dialog from '../ui/dialog.js';
import { esc } from '../ui/escape.js';
import { fmtBaseSigned, fmtPct, pctTone, colouredSigns, isBaseAsset } from '../ui/format.js';
import { assetLabel, byKindAndLabel } from '../ui/asset-label.js';
import { t } from '../i18n/index.js';
import { localDateNl } from '../../shared/time.js';
import { ENUMS } from '../../shared/config.js';
import { seriesFor } from './series.js';
import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay } from './return-view.js';
import { getPref, setPref } from '../data/preferences.js';
import { drawReturnChart } from './return-chart.js';
import { carriedDays } from './carried.js';
import { selectLabel } from '../ui/select-label.js';
const DASH = '-';
const PREF_SCOPE = 'wealth.return';
const live = new Set();
export function openReturnDialog({ reeks, assets = [], page = null, selection = null } = {}) {
const closed = dialog.detail({ title: t('wealth.returnTitle'), html: '<div class="return-view return-view--dialog" data-return></div>' });
const hosts = document.querySelectorAll('.dialog-overlay [data-return]');
const host = hosts[hosts.length - 1];
if (!host) return;
const controls = host.closest('.dialog')?.querySelector('[data-dialog-aside]') || null;
const ids = idsForSelection(selection, assets);
const view = mount(host, { reeks, assets, page, data: dataKeyFor(selection, ids), selectionIds: ids, controls });
closed.then(() => view.destroy());
}
export async function mountReturnPanel(host, { reeks, asset }) {
const key = String(asset.id);
const stored = await getPref(PREF_SCOPE, key, null).catch(() => null);
if (!host.isConnected) return null;
return mount(host, { reeks, assets: [asset], page: null, data: `asset:${asset.id}`, stored,
onPeriod: period => setPref(PREF_SCOPE, key, period).catch(() => { }) });
}
export function refreshReturn() {
for (const v of [...live]) v.render();
}
export function lineForData({ reeks, assets = [], data = 'total', venster = null, selectionIds = null }) {
const ids = idsOf(data, assets, selectionIds);
const line = returnLine(seriesFor(reeks, ids && ids.length === 1 ? ids[0] : ids), venster);
const noPct = withoutPct(ids, assets);
const top = contributorsOf(reeks, ids ?? assets.map(a => a.id), venster, assets);
const carried = carriedDays(reeks, ids, assets);
return {
amount: line.amount,
pct: noPct ? null : line.pct,
points: line.points.map(p => ({ ...p, pct: noPct ? null : p.pct, top: top.get(p.i) || [], carried: carried.has(p.i) })),
};
}
export function drawLine(canvas, reeks, points) {
return drawReturnChart(canvas, points, points.map(p => localDateNl(`${reeks.dagen[p.i]}T00:00:00Z`)));
}
function dataKeyFor(selection, ids) {
if (!ids) return 'total';
if (selection?.kind) return `kind:${selection.kind}`;
if (ids.length === 1) return `asset:${ids[0]}`;
return 'selection';
}
function idsOf(key, assets, selectionIds) {
if (key === 'total') return null;
if (key === 'selection') return selectionIds;
if (key.startsWith('kind:')) return assets.filter(a => a.kind === key.slice(5)).map(a => a.id);
return [Number(key.slice(6))];
}
const withoutPct = (ids, assets) => ids?.length === 1 && isBaseAsset(assets.find(a => a.id === ids[0]) || {});
function contributorsOf(reeks, ids, venster, assets) {
const byId = new Map(assets.map(a => [a.id, a]));
const named = new Map();
for (const [i, list] of contributorsPerDay(reeks, ids, venster)) {
named.set(i, list.filter(c => byId.has(c.id)).map(c => ({
label: assetLabel(byId.get(c.id)), day: c.day, pct: isBaseAsset(byId.get(c.id)) ? null : c.pct,
})));
}
return named;
}
function mount(host, { reeks, assets, page, data, selectionIds = null, controls = null, stored = null, onPeriod = null }) {
const length = reeks?.dagen?.length || 0;
const periods = periodChoices(length, page);
const state = { data, period: startPeriod(periods, stored), chart: null, gen: 0, lines: null };
const view = {
render() {
if (!host.isConnected) { view.destroy(); return; }
if (!length) { host.innerHTML = `<div class="t-muted-cell">${t('wealth.noChart')}</div>`; return; }
const lines = new Map(periods.map(p => [p.key, lineForData({ reeks, assets, data: state.data, venster: p.venster, selectionIds })]));
state.lines = lines;
if (controls) controls.innerHTML = dataSelect(assets, state.data, selectionIds);
host.innerHTML = `
<div class="return-periods" role="group">${periods.map(p => periodButton(p, lines.get(p.key), state.period)).join('')}</div>
<div class="return-plot"><canvas data-return-chart></canvas></div>`;
drawChart(lines.get(state.period)?.points || []);
},
destroy() {
live.delete(view);
state.chart?.destroy();
state.chart = null;
},
};
async function drawChart(points) {
const gen = ++state.gen;
state.chart?.destroy();
state.chart = null;
const canvas = host.querySelector('[data-return-chart]');
if (!canvas) return;
if (points.length < 2) {
canvas.parentElement.innerHTML = `<div class="t-muted-cell">${t('wealth.noChart')}</div>`;
return;
}
const chart = await drawLine(canvas, reeks, points).catch(() => null);
if (gen !== state.gen || !host.isConnected) { chart?.destroy(); return; }
state.chart = chart;
}
host.addEventListener('click', e => {
const btn = e.target.closest('[data-period]');
if (!btn || btn.disabled || btn.dataset.period === state.period) return;
state.period = btn.dataset.period;
onPeriod?.(state.period);
for (const b of host.querySelectorAll('[data-period]')) b.classList.toggle('is-on', b === btn);
if (!host.querySelector('[data-return-chart]')) {
host.querySelector('.return-plot').innerHTML = '<canvas data-return-chart></canvas>';
}
drawChart(state.lines?.get(state.period)?.points || []);
});
controls?.addEventListener('change', e => {
const sel = e.target.closest('[data-return-data]');
if (!sel) return;
state.data = sel.value;
view.render();
});
live.add(view);
view.render();
return view;
}
function periodLabel(p) {
if (p.key === 'page') return p.label ?? DASH;
if (p.days == null) return t('wealth.sinceStart');
if (p.days === 1) return t('wealth.periodDay');
if (p.days === 365) return t('wealth.periodYear');
return t('wealth.periodDays', { n: p.days });
}
const tone = v => (v == null ? '' : v > 0.005 ? 't-pos' : v < -0.005 ? 't-neg' : '');
function periodButton(p, line, current) {
const on = p.key === current ? ' is-on' : '';
const off = p.venster ? '' : ' disabled';
const amount = line?.amount == null ? DASH : `<span class="val">${esc(fmtBaseSigned(line.amount, colouredSigns()))}</span>`;
const pct = line?.pct == null ? '' : fmtPct(line.pct, { fine: p.days === 1, signs: colouredSigns() });
return `<button type="button" class="return-period${on}" data-period="${esc(p.key)}"${off}>
<span class="return-period-label">${esc(periodLabel(p))}</span>
<span class="return-period-amount ${tone(line?.amount)}">${amount}</span>
<span class="return-period-pct">${pct ? `<span class="delta ${pctTone(line.pct, { fine: p.days === 1 })}">${esc(pct)}</span>` : ''}</span>
</button>`;
}
function dataSelect(assets, current, selectionIds) {
const opt = (value, label) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(label)}</option>`;
const kinds = ENUMS.ASSET_KIND.filter(k => assets.some(a => a.kind === k));
const sorted = [...assets].sort(byKindAndLabel);
return selectLabel(`<select data-return-data aria-label="${esc(t('wealth.returnData'))}">
${opt('total', t('wealth.returnTotal'))}
${selectionIds?.length > 1 ? opt('selection', t('wealth.returnSelection', { n: selectionIds.length })) : ''}
<optgroup label="${esc(t('wealth.returnKinds'))}">${kinds.map(k => opt(`kind:${k}`, t('enums.asset_kind.' + k))).join('')}</optgroup>
<optgroup label="${esc(t('wealth.returnAssets'))}">${sorted.map(a => opt(`asset:${a.id}`, assetLabel(a))).join('')}</optgroup>
</select>`);
}
+61
View File
@@ -0,0 +1,61 @@
// Generated, do not edit. sha256:79d0944a46421ed3e71c6aff535ca9c3c77b373253595260dd7e749f587d9edd
import { CONFIG } from '../../shared/config.js';
import { windowTwr, windowFactors } from './performance.js';
import { dailyResults, meetVenster, rollingWindows, seriesFor, windowResult } from './series.js';
export function periodChoices(length = 0, page = null, days = CONFIG.WEALTH_RETURN.ROLLING_DAYS) {
const rolling = rollingWindows(length, days)
.map(p => ({ key: p.days == null ? 'start' : `d${p.days}`, days: p.days, label: null, venster: p.venster }));
if (!page?.venster || !rolling.length) return rolling;
const venster = meetVenster(page.venster, length);
if (venster.van <= 0 && venster.tot >= length - 1) return rolling;
return [{ key: 'page', days: null, label: page.label ?? null, venster }, ...rolling];
}
export function startPeriod(periods, stored = null) {
if (stored && periods.some(p => p.key === stored && p.venster)) return stored;
return periods.some(p => p.key === 'page') ? 'page' : 'start';
}
export function returnLine(input = {}, venster = null) {
if (!venster) return { amount: null, pct: null, points: [] };
const results = dailyResults(input);
const factors = windowFactors(input, venster);
const tot = Math.min(venster.tot, results.length - 1);
const points = [];
let cumulative = 0;
if (venster.van > 0) points.push({ i: venster.van, cumulative: 0, day: null, pct: null });
for (let i = venster.van > 0 ? venster.van + 1 : 0; i <= tot; i++) {
cumulative += results[i];
points.push({ i, cumulative, day: results[i], pct: factors[i] == null ? null : factors[i] - 1 });
}
return { amount: windowResult(input, venster), pct: windowTwr(input, venster), points };
}
export function contributorsPerDay(reeks = {}, ids = [], venster = null, n = 3) {
const out = new Map();
if (!venster || !ids || ids.length < 2) return out;
const per = ids.map(id => {
const input = seriesFor(reeks, id);
return { id, results: dailyResults(input), factors: windowFactors(input, venster) };
});
const tot = Math.min(venster.tot, (reeks.dagen?.length || 0) - 1);
for (let i = venster.van > 0 ? venster.van + 1 : 0; i <= tot; i++) {
const top = per
.map(p => ({ id: p.id, day: p.results[i] || 0, pct: p.factors[i] == null ? null : p.factors[i] - 1 }))
.filter(c => Math.abs(c.day) >= 0.005)
.sort((a, b) => Math.abs(b.day) - Math.abs(a.day))
.slice(0, n);
if (top.length) out.set(i, top);
}
return out;
}
export function idsForSelection(sel, assets = []) {
if (sel?.assetIds?.length) return [...sel.assetIds];
if (sel?.kind) {
const ids = assets.filter(a => a.kind === sel.kind).map(a => a.id);
return ids.length ? ids : null;
}
return null;
}
+52
View File
@@ -0,0 +1,52 @@
// Generated, do not edit. sha256:0655118dcbbdee6f738790678dd93e04fa937dd8c25c946b1d37d8d899f98d2a
export function sameSelection(a, b) {
if (!a || !b) return a === b;
if (a.kind || b.kind) return a.kind === b.kind;
if (a.account != null || b.account != null) return a.account === b.account;
return String(a.assetIds) === String(b.assetIds);
}
export function selectionFromStored(bewaard) {
if (!bewaard || typeof bewaard !== 'object') return null;
if (bewaard.kind) return { kind: String(bewaard.kind) };
const ids = Array.isArray(bewaard.assetIds) ? bewaard.assetIds.map(Number).filter(n => Number.isInteger(n) && n > 0) : [];
const account = Number(bewaard.account);
if (bewaard.account != null && Number.isInteger(account) && account >= 0) return { account, assetIds: ids };
return ids.length ? { assetIds: ids } : null;
}
export function hiddenFromStored(bewaard, { allowZero = false } = {}) {
if (!Array.isArray(bewaard)) return new Set();
return new Set(bewaard.map(Number).filter(n => Number.isInteger(n) && (allowZero ? n >= 0 : n > 0)));
}
export function hiddenAfterFilter(hidden, itemIds, selected) {
const chips = new Set(itemIds);
const s = new Set([...hidden].filter(id => !chips.has(id)));
for (const id of itemIds) if (!selected.has(id)) s.add(id);
return [...s].sort((a, b) => (typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b))));
}
export const NO_ACCOUNT_KIND = 'none';
export const accountKindOf = c => c.account?.kind ?? NO_ACCOUNT_KIND;
export function hiddenKindsFromStored(bewaard, allowed = []) {
if (!Array.isArray(bewaard)) return new Set();
const geldig = new Set([...allowed, NO_ACCOUNT_KIND]);
return new Set(bewaard.map(String).filter(k => geldig.has(k)));
}
export function accountKindItems(cards = []) {
const uit = [];
for (const c of cards) {
const kind = accountKindOf(c);
if (!uit.includes(kind)) uit.push(kind);
}
return uit;
}
export function splitHidden(kaarten = [], hidden = new Set(), keyOf = k => k.asset.id) {
const shown = [], verborgen = [];
for (const k of kaarten) (hidden.has(keyOf(k)) ? verborgen : shown).push(k);
return { shown, hidden: verborgen };
}
+554
View File
@@ -0,0 +1,554 @@
// Generated, do not edit. sha256:ed8ca8faa6323fa2c4ff508c950678174612f6518b6d32ceba92945717d7bce7
import { listEvents } from '../data/events.js';
import { listValuations } from '../data/valuations.js';
import { listAssets } from '../data/assets.js';
import { listPriceHistory } from '../data/price-history.js';
import { toDailyGrid } from '../signals/data.js';
import { liveSpotPriceFor } from '../price/providers.js';
import { nowUtcIso } from '../../shared/time.js';
import { saleResult, feeResult } from '../../shared/wealth-metrics.js';
import { CONFIG } from '../../shared/config.js';
import { windowTwr } from './performance.js';
const DAG_MS = 24 * 60 * 60 * 1000;
export const dagVan = iso => String(iso).slice(0, 10);
const paar = (accountId, assetId) => `${accountId ?? 'geen'}|${assetId}`;
const accountVanSleutel = sleutel => {
const deel = String(sleutel).split('|')[0];
return deel === 'geen' ? null : Number(deel);
};
export function dagenTussen(vanDag, totDag) {
const start = Date.parse(`${vanDag}T00:00:00Z`);
const eind = Date.parse(`${totDag}T00:00:00Z`);
if (!Number.isFinite(start) || !Number.isFinite(eind) || eind < start) return [];
const uit = [];
for (let t = start; t <= eind; t += DAG_MS) uit.push(new Date(t).toISOString().slice(0, 10));
return uit;
}
export function deltasVan(e) {
const bedrag = Number(e.amount) || 0;
const fee = Number(e.unitFee) || 0;
switch (e.type) {
case 'buy':
case 'interest':
return [{ sleutel: paar(e.toAccountId, e.assetId), delta: bedrag }];
case 'sell':
return [{ sleutel: paar(e.fromAccountId, e.assetId), delta: -(bedrag + fee) }];
case 'transfer':
return [
...(e.fromAccountId != null ? [{ sleutel: paar(e.fromAccountId, e.assetId), delta: -(bedrag + fee) }] : []),
...(e.toAccountId != null ? [{ sleutel: paar(e.toAccountId, e.assetId), delta: bedrag }] : []),
];
default:
return [];
}
}
export function ownView(e, excluded) {
if (!excluded || !excluded.size) return e;
const outside = id => id != null && excluded.has(id);
if (e.type === 'transfer') {
const fromOwn = e.fromAccountId != null && !outside(e.fromAccountId);
const toOwn = e.toAccountId != null && !outside(e.toAccountId);
if (!fromOwn && !toOwn) return null;
if (fromOwn && outside(e.toAccountId)) return { ...e, toAccountId: null, crossing: -1 };
if (toOwn && outside(e.fromAccountId)) return { ...e, fromAccountId: null, crossing: 1 };
return e;
}
if (e.type === 'sell') return outside(e.fromAccountId) ? null : e;
return outside(e.toAccountId) ? null : e;
}
function accountsVan(boekingen, ankers) {
const uit = new Set();
for (const e of boekingen) for (const { sleutel } of deltasVan(e)) uit.add(accountVanSleutel(sleutel));
for (const v of ankers) uit.add(v.accountId);
return [...uit];
}
function accountFlows(e, waardeVan) {
const v = waardeVan(e.amount);
if (v == null) return [];
const van = e.fromAccountId ?? null;
const naar = e.toAccountId ?? null;
if (e.type === 'transfer') {
if (van != null && naar != null) return van === naar ? [] : [{ accountId: van, fiat: -v }, { accountId: naar, fiat: v }];
if (van == null && naar == null) return [];
return van == null ? [{ accountId: naar, fiat: v }] : [{ accountId: van, fiat: -v }];
}
const uit = [];
if (e.priceOrigin === 'none') uit.push({ accountId: e.type === 'sell' ? van : naar, fiat: e.type === 'buy' ? v : -v });
if (e.type === 'interest') uit.push({ accountId: naar, fiat: v, rente: v });
return uit;
}
const oplopend = perAsset => new Map([...perAsset].map(([id, set]) => [id, [...set].sort((a, b) => a - b)]));
function koersOp(grid, dag) {
if (!grid || !grid.length) return null;
const ts = Date.parse(`${dag}T00:00:00Z`);
if (ts < grid[0][0]) return null;
const i = Math.min(grid.length - 1, Math.floor((ts - grid[0][0]) / DAG_MS));
return grid[i][1];
}
export function dagreeks({ events = [], valuations = [], assets = [], prijzen = new Map(),
vanDag = null, totDag = null, excludedAccounts = null } = {}) {
const buiten = excludedAccounts && excludedAccounts.size ? excludedAccounts : null;
const ankers = valuations.filter(v => v.accountId != null && !(buiten && buiten.has(v.accountId)));
const boekingen = buiten ? events.map(e => ownView(e, buiten)).filter(Boolean) : events;
const eersteDag = vanDag ?? vroegsteDag(boekingen, ankers);
const laatsteDag = totDag ?? dagVan(nowUtcIso());
const dagen = eersteDag ? dagenTussen(eersteDag, laatsteDag) : [];
const leeg = { 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: [] };
if (!dagen.length) return leeg;
const tijdlijn = [
...boekingen.map(e => ({ op: e.occurredAt, soort: 'boeking', e })),
...ankers.map(v => ({ op: v.rateAt, soort: 'anker', v })),
].sort((a, b) => String(a.op).localeCompare(String(b.op)));
const decimalen = new Map(assets.map(a => [a.id, Math.pow(10, a.decimals)]));
const stand = new Map();
const assetVanPaar = new Map();
let inleg = 0;
const waarde = [];
const ingelegd = [];
const perAsset = new Map(assets.map(a => [a.id, []]));
const koersPerAsset = new Map(assets.map(a => [a.id, []]));
const eenhedenPerAsset = new Map(assets.map(a => [a.id, []]));
const inlegPerAsset = new Map(assets.map(a => [a.id, []]));
const inlegNu = new Map(assets.map(a => [a.id, 0]));
let uit = 0;
const uitgehaald = [];
const uitPerAsset = new Map(assets.map(a => [a.id, []]));
const uitNu = new Map(assets.map(a => [a.id, 0]));
const ankersPerAsset = new Map(assets.map(a => [a.id, []]));
const ankerNu = new Map(assets.map(a => [a.id, 0]));
const rentePerAsset = new Map(assets.map(a => [a.id, []]));
const renteNu = new Map(assets.map(a => [a.id, 0]));
const gerealiseerdPerAsset = new Map(assets.map(a => [a.id, []]));
const gerealiseerdNu = new Map(assets.map(a => [a.id, 0]));
const onbekendPerAsset = new Map(assets.map(a => [a.id, []]));
const onbekendNu = new Map(assets.map(a => [a.id, 0]));
const kostprijsPerAsset = new Map(assets.map(a => [a.id, { buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, gift_amount: 0 }]));
const eenhedenVan = assetId => {
let n = 0;
for (const [sleutel, eenheden] of stand) if (assetVanPaar.get(sleutel) === assetId) n += eenheden;
return n;
};
const nietVerklaardPerAsset = new Map(assets.map(a => [a.id, []]));
const nietVerklaardNu = new Map(assets.map(a => [a.id, 0]));
const geboekt = new Set();
const aankoopDagen = new Map(assets.map(a => [a.id, new Set()]));
const verkoopDagen = new Map(assets.map(a => [a.id, new Set()]));
const eersteDagPerAsset = new Map(assets.map(a => [a.id, null]));
const accounts = accountsVan(boekingen, ankers);
const perAccountReeks = () => new Map(accounts.map(id => [id, []]));
const perAccountNu = () => new Map(accounts.map(id => [id, 0]));
const acc = {
reeks: { waarde: perAccountReeks(), inleg: perAccountReeks(), uit: perAccountReeks(), ankers: perAccountReeks(),
rente: perAccountReeks(), nietVerklaard: perAccountReeks(), bezit: perAccountReeks() },
nu: { inleg: perAccountNu(), uit: perAccountNu(), ankers: perAccountNu(), rente: perAccountNu(), nietVerklaard: perAccountNu() },
};
const accBij = (soort, accountId, v) => { if (acc.nu[soort].has(accountId)) acc.nu[soort].set(accountId, acc.nu[soort].get(accountId) + v); };
const eersteDagPerAccount = new Map(accounts.map(id => [id, null]));
let i = 0;
for (const [dagNr, dag] of dagen.entries()) {
const grens = `${dag}T23:59:59.999Z`;
while (i < tijdlijn.length && String(tijdlijn[i].op) <= grens) {
const item = tijdlijn[i++];
if (item.soort === 'anker') {
const s = paar(item.v.accountId, item.v.assetId);
const nieuw = Number(item.v.balance) || 0;
const sprong = nieuw - (stand.get(s) || 0);
const koers = koersOp(prijzen.get(item.v.assetId), dag);
if (sprong !== 0 && koers != null && ankerNu.has(item.v.assetId)) {
const fiat = (sprong / (decimalen.get(item.v.assetId) || 1)) * koers;
ankerNu.set(item.v.assetId, ankerNu.get(item.v.assetId) + fiat);
const bevinding = geboekt.has(s) && !item.v.gapAcceptedAt;
if (bevinding) nietVerklaardNu.set(item.v.assetId, nietVerklaardNu.get(item.v.assetId) + fiat);
accBij('ankers', item.v.accountId, fiat);
if (bevinding) accBij('nietVerklaard', item.v.accountId, fiat);
}
stand.set(s, nieuw);
assetVanPaar.set(s, item.v.assetId);
continue;
}
const e = item.e;
const kp = kostprijsPerAsset.get(e.assetId);
if (kp && e.type === 'sell' && e.priceOrigin !== 'none') {
const decimals = assets.find(a => a.id === e.assetId)?.decimals ?? 0;
const r = saleResult({ decimals, net: eenhedenVan(e.assetId), ...kp }, e);
if (r == null) onbekendNu.set(e.assetId, onbekendNu.get(e.assetId) + 1);
else gerealiseerdNu.set(e.assetId, gerealiseerdNu.get(e.assetId) + r);
}
if (kp && e.type === 'transfer' && e.fromAccountId != null && Number(e.unitFee) > 0) {
const decimals = assets.find(a => a.id === e.assetId)?.decimals ?? 0;
const r = feeResult({ decimals, net: eenhedenVan(e.assetId), ...kp }, e.unitFee);
if (r == null) onbekendNu.set(e.assetId, onbekendNu.get(e.assetId) + 1);
else gerealiseerdNu.set(e.assetId, gerealiseerdNu.get(e.assetId) + r);
}
if (kp && e.type === 'transfer' && e.fromAccountId != null && Number(e.fiatFee) > 0) {
gerealiseerdNu.set(e.assetId, gerealiseerdNu.get(e.assetId) - Number(e.fiatFee));
}
if (kp) {
const n = Number(e.amount) || 0;
if (e.type === 'buy' && e.priceOrigin !== 'none') {
kp.buy_amount += n;
kp.buy_spot_raw += n * (Number(e.fiatPrice) || 0);
kp.buy_fee_total += Number(e.fiatFee) || 0;
}
if (e.priceOrigin === 'none') {
if (e.type === 'buy') kp.gift_amount += n;
else if (e.type === 'sell') kp.gift_amount -= n;
else if (e.type === 'transfer' && e.fromAccountId == null && e.toAccountId != null) kp.gift_amount += n;
else if (e.type === 'transfer' && e.toAccountId == null && e.fromAccountId != null) kp.gift_amount -= n;
}
}
for (const { sleutel, delta } of deltasVan(e)) {
stand.set(sleutel, (stand.get(sleutel) || 0) + delta);
assetVanPaar.set(sleutel, e.assetId);
geboekt.add(sleutel);
}
const bij = inlegVan(e, decimalen.get(e.assetId) || 1);
inleg += bij;
if (inlegNu.has(e.assetId)) inlegNu.set(e.assetId, inlegNu.get(e.assetId) + bij);
const eruit = uitVan(e, decimalen.get(e.assetId) || 1);
uit += eruit;
if (uitNu.has(e.assetId)) uitNu.set(e.assetId, uitNu.get(e.assetId) + eruit);
if (decimalen.has(e.assetId)) {
const kant = e.type === 'sell' || e.type === 'transfer' ? (e.fromAccountId ?? null) : (e.toAccountId ?? null);
accBij('inleg', kant, bij);
accBij('uit', kant, eruit);
const koers = koersOp(prijzen.get(e.assetId), dag);
const waardeVan = n => (koers == null ? null : ((Number(n) || 0) / decimalen.get(e.assetId)) * koers);
for (const f of accountFlows(e, waardeVan)) {
accBij('ankers', f.accountId, f.fiat);
if (f.rente) accBij('rente', f.accountId, f.rente);
}
}
const eenzijdig = e.type === 'transfer' && (e.fromAccountId == null) !== (e.toAccountId == null);
if (e.priceOrigin === 'none' && (e.type !== 'transfer' || eenzijdig) && ankerNu.has(e.assetId)) {
const koers = koersOp(prijzen.get(e.assetId), dag);
if (koers != null) {
const binnen = e.type === 'transfer' ? e.fromAccountId == null : e.type === 'buy';
const fiat = ((Number(e.amount) || 0) / (decimalen.get(e.assetId) || 1)) * koers;
ankerNu.set(e.assetId, ankerNu.get(e.assetId) + (binnen ? fiat : -fiat));
}
}
if (eenzijdig && e.priceOrigin !== 'none' && ankerNu.has(e.assetId)) {
const koers = koersOp(prijzen.get(e.assetId), dag);
if (koers != null) {
const fiat = ((Number(e.amount) || 0) / (decimalen.get(e.assetId) || 1)) * koers;
ankerNu.set(e.assetId, ankerNu.get(e.assetId) + (e.fromAccountId == null ? fiat : -fiat));
}
}
if (e.type === 'interest' && ankerNu.has(e.assetId)) {
const koers = koersOp(prijzen.get(e.assetId), dag);
if (koers != null) {
const fiat = ((Number(e.amount) || 0) / (decimalen.get(e.assetId) || 1)) * koers;
ankerNu.set(e.assetId, ankerNu.get(e.assetId) + fiat);
renteNu.set(e.assetId, renteNu.get(e.assetId) + fiat);
}
}
if (dagVan(e.occurredAt) === dag) {
if (e.type === 'buy') aankoopDagen.get(e.assetId)?.add(dagNr);
else if (e.type === 'sell') verkoopDagen.get(e.assetId)?.add(dagNr);
}
}
const eenhedenVandaag = new Map();
for (const [sleutel, eenheden] of stand) {
const assetId = assetVanPaar.get(sleutel);
eenhedenVandaag.set(assetId, (eenhedenVandaag.get(assetId) || 0) + eenheden);
}
let dagTotaal = 0;
for (const a of assets) {
const eenheden = eenhedenVandaag.get(a.id) || 0;
if (eenheden !== 0 && eersteDagPerAsset.get(a.id) === null) eersteDagPerAsset.set(a.id, dagNr);
const koers = koersOp(prijzen.get(a.id), dag);
const v = koers == null ? 0 : (eenheden / (decimalen.get(a.id) || 1)) * koers;
perAsset.get(a.id).push(v);
koersPerAsset.get(a.id).push(koers);
eenhedenPerAsset.get(a.id).push(eenheden);
inlegPerAsset.get(a.id).push(inlegNu.get(a.id) || 0);
uitPerAsset.get(a.id).push(uitNu.get(a.id) || 0);
ankersPerAsset.get(a.id).push(ankerNu.get(a.id) || 0);
rentePerAsset.get(a.id).push(renteNu.get(a.id) || 0);
gerealiseerdPerAsset.get(a.id).push(gerealiseerdNu.get(a.id) || 0);
onbekendPerAsset.get(a.id).push(onbekendNu.get(a.id) || 0);
nietVerklaardPerAsset.get(a.id).push(nietVerklaardNu.get(a.id) || 0);
dagTotaal += v;
}
waarde.push(dagTotaal);
ingelegd.push(inleg);
uitgehaald.push(uit);
const accWaarde = new Map(accounts.map(id => [id, 0]));
const accBezit = new Map(accounts.map(id => [id, 0]));
for (const [sleutel, eenheden] of stand) {
const assetId = assetVanPaar.get(sleutel);
const accountId = accountVanSleutel(sleutel);
if (!eenheden || !decimalen.has(assetId) || !accWaarde.has(accountId)) continue;
const koers = koersOp(prijzen.get(assetId), dag);
if (koers != null) accWaarde.set(accountId, accWaarde.get(accountId) + (eenheden / decimalen.get(assetId)) * koers);
accBezit.set(accountId, accBezit.get(accountId) + 1);
}
for (const id of accounts) {
if (accBezit.get(id) && eersteDagPerAccount.get(id) === null) eersteDagPerAccount.set(id, dagNr);
acc.reeks.waarde.get(id).push(accWaarde.get(id));
acc.reeks.bezit.get(id).push(accBezit.get(id));
for (const soort of Object.keys(acc.nu)) acc.reeks[soort].get(id).push(acc.nu[soort].get(id));
}
}
return {
dagen, waarde, ingelegd, uitgehaald, perAsset, inlegPerAsset, uitPerAsset, ankersPerAsset, rentePerAsset,
gerealiseerdPerAsset, onbekendPerAsset, nietVerklaardPerAsset,
koersPerAsset, eenhedenPerAsset, eersteDagPerAsset,
aankopen: oplopend(aankoopDagen),
verkopen: oplopend(verkoopDagen),
perAccount: acc.reeks.waarde, inlegPerAccount: acc.reeks.inleg, uitPerAccount: acc.reeks.uit,
ankersPerAccount: acc.reeks.ankers, rentePerAccount: acc.reeks.rente, nietVerklaardPerAccount: acc.reeks.nietVerklaard,
bezitPerAccount: acc.reeks.bezit, eersteDagPerAccount,
standNu: [...stand].filter(([s, n]) => n && decimalen.has(assetVanPaar.get(s)))
.map(([s, n]) => ({ accountId: accountVanSleutel(s), assetId: assetVanPaar.get(s), eenheden: n,
deler: decimalen.get(assetVanPaar.get(s)) })),
};
}
export function accountSeries(reeks = {}, accountId = null) {
return {
waarden: reeks.perAccount?.get(accountId) || [],
inleg: reeks.inlegPerAccount?.get(accountId) || [],
uit: reeks.uitPerAccount?.get(accountId) || [],
ankers: reeks.ankersPerAccount?.get(accountId) || [],
rente: reeks.rentePerAccount?.get(accountId) || [],
eenheden: reeks.bezitPerAccount?.get(accountId),
eersteDag: reeks.eersteDagPerAccount?.get(accountId) ?? 0,
};
}
export function inlegVan(e, deler) {
const kosten = Number(e.fiatFee) || 0;
if (e.type === 'buy') return ((Number(e.amount) || 0) / deler) * (Number(e.fiatPrice) || 0) + kosten;
if (e.type === 'sell') return -(((Number(e.amount) || 0) / deler) * (Number(e.fiatPrice) || 0)) + kosten;
if (e.type === 'transfer' && e.fromAccountId != null) return kosten;
return 0;
}
export function uitVan(e, deler) {
if (e.type !== 'sell') return 0;
return ((Number(e.amount) || 0) / deler) * (Number(e.fiatPrice) || 0) - (Number(e.fiatFee) || 0);
}
export function metSpotVandaag(reeks, assets = [], spots = new Map(), vandaag = dagVan(nowUtcIso())) {
const i = (reeks?.dagen?.length || 0) - 1;
if (i < 0 || reeks.dagen[i] !== vandaag) return reeks;
const decimalen = new Map(assets.map(a => [a.id, Math.pow(10, a.decimals)]));
let totaal = 0;
for (const [assetId, waarden] of reeks.perAsset) {
const spot = Number(spots.get(assetId));
const eenheden = reeks.eenhedenPerAsset?.get(assetId);
if (Number.isFinite(spot) && spot > 0 && eenheden && decimalen.has(assetId)) {
reeks.koersPerAsset.get(assetId)[i] = spot;
waarden[i] = (eenheden[i] / decimalen.get(assetId)) * spot;
}
totaal += waarden[i] || 0;
}
reeks.waarde[i] = totaal;
if (reeks.perAccount?.size) {
const perAccount = new Map([...reeks.perAccount.keys()].map(id => [id, 0]));
for (const { accountId, assetId, eenheden, deler } of (reeks.standNu || [])) {
const koers = reeks.koersPerAsset?.get(assetId)?.[i];
if (koers != null && deler && perAccount.has(accountId)) {
perAccount.set(accountId, perAccount.get(accountId) + (eenheden / deler) * koers);
}
}
for (const [id, waarde] of perAccount) reeks.perAccount.get(id)[i] = waarde;
}
return reeks;
}
export function jaarVensters(dagen = []) {
const uit = new Map();
dagen.forEach((dag, i) => {
const jaar = Number(String(dag).slice(0, 4));
if (!Number.isFinite(jaar)) return;
const v = uit.get(jaar);
if (v) v.tot = i;
else uit.set(jaar, { van: i, tot: i });
});
return uit;
}
export function meetVenster({ van, tot } = {}, lengte = Infinity) {
return { van, tot: Math.min((Number(tot) || 0) + 1, lengte - 1) };
}
export function windowDelta(r, { van = 0, tot = 0 } = {}) {
if (!Array.isArray(r) || !r.length) return 0;
const eind = Number(r[Math.min(tot, r.length - 1)]) || 0;
return eind - (van > 0 ? (Number(r[van]) || 0) : 0);
}
export function windowResult({ waarden = [], inleg = [], ankers = [], rente = [] } = {}, venster = {}) {
return windowDelta(waarden, venster) - windowDelta(inleg, venster)
- windowDelta(ankers, venster) + windowDelta(rente, venster);
}
export function dailyResults({ waarden = [], inleg = [], ankers = [], rente = [] } = {}) {
const step = (r, i) => (Number(r[i]) || 0) - (i > 0 ? (Number(r[i - 1]) || 0) : 0);
return waarden.map((_, i) => step(waarden, i) - step(inleg, i) - step(ankers, i) + step(rente, i));
}
export function todayResults(reeks = {}, vandaag = dagVan(nowUtcIso())) {
const i = (reeks.dagen?.length || 0) - 1;
if (i < 0 || reeks.dagen[i] !== vandaag) return null;
const last = input => dailyResults(input)[i];
const perAsset = new Map();
for (const id of (reeks.perAsset?.keys?.() || [])) perAsset.set(id, last(seriesFor(reeks, id)));
return { total: last(seriesFor(reeks)), perAsset };
}
export function seriesFor(reeks = {}, assetId = null) {
if (assetId == null) {
return {
waarden: reeks.waarde || [], inleg: reeks.ingelegd || [], uit: reeks.uitgehaald || [],
ankers: sumPerDay(reeks.ankersPerAsset), rente: sumPerDay(reeks.rentePerAsset), eersteDag: 0,
};
}
if (Array.isArray(assetId)) {
const only = map => new Map([...(map || new Map())].filter(([id]) => assetId.includes(id)));
const firsts = assetId.map(id => reeks.eersteDagPerAsset?.get(id)).filter(d => d != null);
return {
waarden: sumPerDay(only(reeks.perAsset)), inleg: sumPerDay(only(reeks.inlegPerAsset)),
uit: sumPerDay(only(reeks.uitPerAsset)), ankers: sumPerDay(only(reeks.ankersPerAsset)),
rente: sumPerDay(only(reeks.rentePerAsset)), eersteDag: firsts.length ? Math.min(...firsts) : 0,
};
}
return {
waarden: reeks.perAsset?.get(assetId) || [],
inleg: reeks.inlegPerAsset?.get(assetId) || [],
uit: reeks.uitPerAsset?.get(assetId) || [],
ankers: reeks.ankersPerAsset?.get(assetId) || [],
rente: reeks.rentePerAsset?.get(assetId) || [],
eenheden: reeks.eenhedenPerAsset?.get(assetId),
eersteDag: reeks.eersteDagPerAsset?.get(assetId) ?? 0,
};
}
function sumPerDay(perAsset) {
const out = [];
for (const r of (perAsset?.values?.() || [])) {
r.forEach((v, i) => { out[i] = (out[i] || 0) + v; });
}
return out;
}
export function rollingWindows(length = 0, days = CONFIG.WEALTH_RETURN.ROLLING_DAYS) {
const last = length - 1;
if (last < 0) return [];
return [
...days.map(n => ({ days: n, venster: last - n >= 1 ? { van: last - n, tot: last } : null })),
{ days: null, venster: { van: 0, tot: last } },
];
}
export function rollingReturns(input = {}, days = CONFIG.WEALTH_RETURN.ROLLING_DAYS) {
return rollingWindows((input.waarden || []).length, days).map(({ days: n, venster }) => ({
days: n,
venster,
amount: venster ? windowResult(input, venster) : null,
pct: venster ? windowTwr(input, venster) : null,
}));
}
export function vroegsteDag(events, ankers) {
const momenten = [...events.map(e => e.occurredAt), ...ankers.map(v => v.rateAt)]
.filter(Boolean).map(dagVan).sort();
return momenten.length ? momenten[0] : null;
}
export function managedCostPots({ events = [], prijzen = new Map(), managed = new Set(), totIso = null } = {}) {
const uit = new Map();
if (!managed || !managed.size) return uit;
const pot = (accountId, assetId) => {
if (accountId == null || !managed.has(accountId)) return null;
const sleutel = `${accountId}|${assetId}`;
if (!uit.has(sleutel)) uit.set(sleutel, { buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, buy_events: 0, gift_amount: 0 });
return uit.get(sleutel);
};
const volgorde = events.filter(e => !totIso || String(e.occurredAt) <= totIso)
.sort((a, b) => String(a.occurredAt).localeCompare(String(b.occurredAt)));
for (const e of volgorde) {
const n = Number(e.amount) || 0;
const geen = e.priceOrigin === 'none';
if (e.type === 'buy') {
const p = pot(e.toAccountId, e.assetId);
if (!p) continue;
if (geen) { p.gift_amount += n; continue; }
p.buy_amount += n;
p.buy_spot_raw += n * (Number(e.fiatPrice) || 0);
p.buy_fee_total += Number(e.fiatFee) || 0;
p.buy_events += 1;
} else if (e.type === 'sell') {
const p = pot(e.fromAccountId, e.assetId);
if (p && geen) p.gift_amount -= n;
} else if (e.type === 'transfer') {
const van = e.fromAccountId ?? null;
const naar = e.toAccountId ?? null;
const p = pot(naar, e.assetId);
if (p && van != null && van !== naar) {
const koers = koersOp(prijzen.get(e.assetId), dagVan(e.occurredAt));
if (koers != null) { p.buy_amount += n; p.buy_spot_raw += n * koers; }
} else if (p && van == null && geen) {
p.gift_amount += n;
}
const q = naar == null ? pot(van, e.assetId) : null;
if (q && geen) q.gift_amount -= n;
}
}
return uit;
}
export async function dagreeksVanNu({ vanDag = null, totDag = null, excludedAccounts = null, managedAccounts = null } = {}) {
const [events, valuations, assets] = await Promise.all([listEvents(), listValuations(), listAssets()]);
const prijzen = new Map();
const rijenPerAsset = new Map();
for (const a of assets) {
let rijen = [];
try { rijen = await listPriceHistory(a.id); } catch { rijen = []; }
rijenPerAsset.set(a.id, rijen);
try {
prijzen.set(a.id, toDailyGrid(rijen));
} catch {
prijzen.set(a.id, []);
}
}
const spots = new Map();
for (const a of assets) {
try { const price = await liveSpotPriceFor(a); if (price != null) spots.set(a.id, price); }
catch { }
}
const reeks = metSpotVandaag(dagreeks({ events, valuations, assets, prijzen, vanDag, totDag, excludedAccounts }), assets, spots);
reeks.rijenPerAsset = rijenPerAsset;
if (managedAccounts && managedAccounts.size) {
reeks.beheerdeKostprijs = totIso => managedCostPots({ events, prijzen, managed: managedAccounts, totIso });
}
return reeks;
}
+80
View File
@@ -0,0 +1,80 @@
// Generated, do not edit. sha256:2b462247335f4413d5ae4038e57da8d4d3e70b4fb676fc84dcbba876e90e1c18
import { loadCharting, readPalette } from '../ui/chart-setup.js';
import { t } from '../i18n/index.js';
import { fmtBase } from '../ui/format.js';
let tipEl = null;
const verberg = () => tipEl?.classList.remove('is-visible');
function tip({ chart, tooltip }, rijen) {
if (!tipEl) {
tipEl = document.createElement('div');
tipEl.className = 'ring-tip';
document.body.appendChild(tipEl);
window.addEventListener('scroll', verberg, { capture: true, passive: true });
}
if (!tooltip.opacity || !tooltip.dataPoints?.length) { verberg(); return; }
const r = rijen[tooltip.dataPoints[0].dataIndex];
const regel = (label, bedrag) => {
const rij = document.createElement('span');
rij.className = 'ring-tip-row';
const naam = document.createElement('span');
naam.className = 'ring-tip-pct';
naam.textContent = label;
const val = document.createElement('span');
val.className = 'val';
val.textContent = fmtBase(bedrag || 0);
rij.append(naam, val);
return rij;
};
const kop = document.createElement('span');
kop.className = 'ring-tip-label';
kop.textContent = r.label;
tipEl.replaceChildren(kop,
regel(t('wealth.valueEnd'), r.waarde),
regel(t('wealth.investedNet'), r.inleg),
regel(t('wealth.result'), r.resultaat));
const box = chart.canvas.getBoundingClientRect();
tipEl.style.left = `${Math.round(box.left + tooltip.caretX)}px`;
tipEl.style.top = `${Math.round(box.top + tooltip.caretY)}px`;
tipEl.classList.add('is-visible');
}
export async function tekenJaarGrafiek(canvas, rijen) {
const Chart = await loadCharting();
const pal = readPalette();
const resultaat = rijen.map(r => r.resultaat || 0);
const grafiek = new Chart(canvas, {
data: {
labels: rijen.map(r => r.label),
datasets: [
{ type: 'bar', label: t('wealth.investedNet'), data: rijen.map(r => r.inleg || 0),
backgroundColor: pal.accentFaint, order: 2, yAxisID: 'y' },
{ type: 'bar', label: t('wealth.result'), data: resultaat,
backgroundColor: resultaat.map(v => (v >= 0 ? pal.sigGreen : pal.sigRed)), order: 2, yAxisID: 'y' },
{ type: 'line', label: t('wealth.valueEnd'), data: rijen.map(r => r.waarde || 0),
borderColor: pal.accent, backgroundColor: pal.accent, borderWidth: 2, pointRadius: 3,
cubicInterpolationMode: 'monotone', order: 1, yAxisID: 'waarde' },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { color: pal.textSec } },
y: { ticks: { display: false }, grid: { color: pal.grid }, border: { display: false } },
waarde: { position: 'right', display: false, beginAtZero: true },
},
plugins: {
legend: { display: true, position: 'bottom', labels: { color: pal.textSec, boxWidth: 10, boxHeight: 10 } },
tooltip: { enabled: false, external: ctx => tip(ctx, rijen) },
},
},
});
canvas.addEventListener('mouseleave', verberg);
const destroy = grafiek.destroy.bind(grafiek);
grafiek.destroy = () => { verberg(); destroy(); };
return grafiek;
}
+27
View File
@@ -0,0 +1,27 @@
// Generated, do not edit. sha256:e2b7a86933c03dfaed3d23288997f18fd48f414cd445220f5c23fdf7ceb2cc5e
export const ALL_YEARS = 'alles';
export function choiceToText(k) {
if (k === ALL_YEARS) return ALL_YEARS;
return k.van === k.tot ? String(k.van) : `${k.van}-${k.tot}`;
}
export function choiceFromText(tekst) {
if (tekst === ALL_YEARS) return ALL_YEARS;
const m = /^(\d{4})(?:-(\d{4}))?$/.exec(String(tekst ?? ''));
if (!m) { const nu = new Date().getFullYear(); return { van: nu, tot: nu }; }
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
return { van: Math.min(a, b), tot: Math.max(a, b) };
}
export function withoutYear(k, jaar) {
if (k === ALL_YEARS || !Number.isFinite(jaar)) return k;
if (jaar >= k.tot) return ALL_YEARS;
return { van: jaar + 1, tot: k.tot };
}
export function yearsIn(k) {
if (k === ALL_YEARS) return [];
return Array.from({ length: k.tot - k.van + 1 }, (_, i) => k.van + i);
}