import Decimal from 'decimal.js'; Decimal.set({ precision: 50, rounding: Decimal.ROUND_HALF_UP }); export type Holding = { id: string; name: string; kind: string; side: string; currency: string; revisions: { id: string; sequence?: number; createdAt?: Date; amount: { toString(): string }; effectiveDate: Date; notes: string; reason: string; }[]; }; export type Rate = { currency: string; baseCurrency: string; date: Date; rate: { toString(): string }; source: string; }; export const day = (d: Date) => d.toISOString().slice(0, 10); export const businessTime = (d: Date) => new Date(+d + 8 * 3600000).toISOString().slice(0, 16); export const businessDay = (d: Date) => businessTime(d).slice(0, 10); export function compareRevisions(a: Holding['revisions'][number], b: Holding['revisions'][number]) { return +a.effectiveDate - +b.effectiveDate || (a.sequence || 0) - (b.sequence || 0); } export function history(p: Holding) { let before = new Decimal(0); return [...p.revisions].sort(compareRevisions).map((r) => { const after = new Decimal(r.amount.toString()); const row = { id: r.id, sequence: r.sequence || 0, createdAt: r.createdAt?.toISOString() || null, positionId: p.id, name: p.name, kind: p.kind, side: p.side, currency: p.currency, date: businessDay(r.effectiveDate), time: businessTime(r.effectiveDate), before: before.toFixed(), after: after.toFixed(), delta: after.minus(before).toFixed(), notes: r.notes, reason: r.reason, }; before = after; return row; }); } export function rateAt(rates: Rate[], currency: string, base: string, date: string) { if (currency === base) return { value: new Decimal(1), date, source: 'identity' }; const r = rates .filter((r) => r.currency === currency && r.baseCurrency === base && day(r.date) <= date) .sort((a, b) => +b.date - +a.date)[0]; return r ? { value: new Decimal(r.rate.toString()), date: day(r.date), source: r.source } : null; } export function totals(positions: Holding[], rates: Rate[], base: string, date: string) { let assets = new Decimal(0), liabilities = new Decimal(0); const missing = new Set(); const items = positions.map((p) => { const rev = p.revisions .filter((r) => businessDay(r.effectiveDate) <= date) .sort((a, b) => compareRevisions(b, a))[0], amount = new Decimal(rev?.amount.toString() || '0'), fx = rateAt(rates, p.currency, base, date); if (!fx && !amount.isZero()) missing.add(p.currency); const converted = fx ? amount.mul(fx.value) : null; if (converted) { if (p.side === 'asset') assets = assets.plus(converted); else liabilities = liabilities.plus(converted); } return { id: p.id, name: p.name, kind: p.kind, side: p.side, currency: p.currency, amount: amount.toFixed(), converted: converted?.toFixed(2) ?? null, rateDate: fx?.date ?? null, source: fx?.source ?? null, }; }); return { date, assets: assets.toFixed(2), liabilities: liabilities.toFixed(2), net: assets.minus(liabilities).toFixed(2), complete: missing.size === 0, missing: [...missing], items, }; } export function overview(positions: Holding[], rates: Rate[], base: string, date: string) { const dates = [ ...new Set([ ...positions.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate))), ...rates.filter((r) => r.baseCurrency === base).map((r) => day(r.date)), date, ]), ] .filter((d) => d <= date) .sort(); let previous: ReturnType | undefined; const trend = dates.map((d) => { const value = totals(positions, rates, base, d); let balanceChange: string | null = null, fxChange: string | null = null; if (previous?.complete && value.complete) { let revalued = new Decimal(0); for (const item of previous.items) { const fx = rateAt(rates, item.currency, base, d); if (!fx && !new Decimal(item.amount).isZero()) { revalued = new Decimal(NaN); break; } const v = new Decimal(item.amount).mul(fx?.value || 0); revalued = revalued.plus(item.side === 'asset' ? v : v.neg()); } if (revalued.isFinite()) { fxChange = revalued.minus(previous.net).toFixed(2); balanceChange = new Decimal(value.net).minus(revalued).toFixed(2); } } previous = value; return { ...value, balanceChange, fxChange }; }); return { baseCurrency: base, ...totals(positions, rates, base, date), trend, recent: positions .flatMap(history) .sort((a, b) => b.time.localeCompare(a.time) || b.sequence - a.sequence) .slice(0, 20), }; } // Sort once, advance each revision/rate once, and retain only the running balances. // Sampling uses end-of-period balances; attribution sums daily changes in the bucket. export function trend( positions: Holding[], rates: Rate[], base: string, from: string, to: string, grain: 'day' | 'week' | 'month' = 'day', ) { const dates = positions .flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate))) .sort(); if (!dates.length || dates[0] > to) return []; from = from < dates[0] ? dates[0] : from; const streams = positions.map((p) => ({ p, rows: [...p.revisions].sort(compareRevisions), index: 0, current: [] as Holding['revisions'], })); const fxRows = rates.filter((r) => r.baseCurrency === base).sort((a, b) => +a.date - +b.date); let fxIndex = 0; const fx = new Map(); function advance(d: string) { for (const s of streams) while (s.index < s.rows.length && businessDay(s.rows[s.index].effectiveDate) <= d) s.current = [s.rows[s.index++]]; while (fxIndex < fxRows.length && day(fxRows[fxIndex].date) <= d) { const r = fxRows[fxIndex++]; fx.set(r.currency, r); } return totals( streams.map((s) => ({ ...s.p, revisions: s.current })), [...fx.values()], base, d, ); } let previous = advance(new Date(+new Date(from) - 86400000).toISOString().slice(0, 10)); const output: (Omit, 'items'> & { balanceChange: string | null; fxChange: string | null; })[] = []; let balance = new Decimal(0), exchange = new Decimal(0), complete = true; for (let time = +new Date(from); time <= +new Date(to); time += 86400000) { const d = new Date(time).toISOString().slice(0, 10), value = advance(d); if (previous.complete && value.complete) { let revalued = new Decimal(0); for (const item of previous.items) { const rate = item.currency === base ? new Decimal(1) : fx.get(item.currency)?.rate; const v = new Decimal(item.amount).mul(rate?.toString() || '0'); revalued = revalued.plus(item.side === 'asset' ? v : v.neg()); } exchange = exchange.plus(revalued.minus(previous.net)); balance = balance.plus(new Decimal(value.net).minus(revalued)); } else complete = false; previous = value; const next = new Date(time + 86400000); if ( grain === 'day' || d === to || (grain === 'week' ? next.getUTCDay() === 1 : next.getUTCDate() === 1) ) { const { items, ...total } = value; output.push({ ...total, balanceChange: complete ? balance.toFixed(2) : null, fxChange: complete ? exchange.toFixed(2) : null, }); balance = new Decimal(0); exchange = new Decimal(0); complete = true; } } return output; }