feat(api): implement secure portfolio history, daily FX and atomic backups
This commit is contained in:
1 parent
67220e38ed
commit
99174a3da5
25 files changed
+1750
-28
No files matched your search
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
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 function history(p: Holding) {
|
||||
let before = new Decimal(0);
|
||||
return [...p.revisions]
|
||||
.sort((a, b) => +a.effectiveDate - +b.effectiveDate)
|
||||
.map((r) => {
|
||||
const after = new Decimal(r.amount.toString());
|
||||
const row = {
|
||||
id: r.id,
|
||||
positionId: p.id,
|
||||
name: p.name,
|
||||
kind: p.kind,
|
||||
currency: p.currency,
|
||||
date: day(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<string>();
|
||||
const items = positions.map((p) => {
|
||||
const rev = p.revisions
|
||||
.filter((r) => day(r.effectiveDate) <= date)
|
||||
.sort((a, b) => +b.effectiveDate - +a.effectiveDate)[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) => day(r.effectiveDate))),
|
||||
...rates.filter((r) => r.baseCurrency === base).map((r) => day(r.date)),
|
||||
date,
|
||||
]),
|
||||
]
|
||||
.filter((d) => d <= date)
|
||||
.sort();
|
||||
let previous: ReturnType<typeof totals> | 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.date.localeCompare(a.date))
|
||||
.slice(0, 20),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user