feat(performance): investment performance vs Moscow Exchange index (WealthLedger/main#54) (#200)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 7m19s

This commit was merged in pull request #200.
This commit is contained in:
2026-11-08 16:45:29 +03:00
parent 84433ee207
commit fee61537b6
28 changed files with 1507 additions and 4 deletions
@@ -0,0 +1,116 @@
import { add, type Decimal, decimalFromString, formatDecimal } from '#src/common/decimal';
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
import { moscowDate } from '../../investment-calendar';
const ZERO: Decimal = { value: 0n, scale: 0 };
const ZERO_UNITS = 0n;
const NANO_SCALE = 9;
const TRANSFER_WINDOW_MS = 300_000;
export type PerformanceFlow = { accountId: string; amount: Decimal; at: Date; date: string };
export type Candidate = { accountIds: [string, string]; type: 'cash' };
type Operation = {
accountId: string;
instrumentUid: null | string;
occurredAt: Date;
payload: unknown;
};
const deposits = new Set<string>([
TbankOperationType.InpMulti, TbankOperationType.Input,
TbankOperationType.InputAcquiring, TbankOperationType.InputSwift,
]);
const withdrawals = new Set<string>([
TbankOperationType.OutMulti, TbankOperationType.Output,
TbankOperationType.OutputAcquiring, TbankOperationType.OutputSwift,
]);
const securities = new Set<string>([
TbankOperationType.InputSecurities, TbankOperationType.OutputSecurities,
TbankOperationType.TransBsBs, TbankOperationType.TransIisBs,
]);
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function money(value: unknown): null | { currency: string; value: Decimal } {
const source = record(value);
if (typeof source.currency !== 'string') return null;
const units = typeof source.units === 'string' || typeof source.units === 'number'
? String(source.units)
: '0';
let nano = ZERO_UNITS;
if (typeof source.nano === 'number' && Number.isInteger(source.nano)) nano = BigInt(source.nano);
if (typeof source.nano === 'string' && /^-?\d+$/.test(source.nano)) nano = BigInt(source.nano);
const whole = decimalFromString(units);
return {
currency: source.currency.toUpperCase(),
value: add(whole, { value: nano, scale: NANO_SCALE }),
};
}
function magnitude(value: Decimal): Decimal {
return { value: value.value < ZERO_UNITS ? -value.value : value.value, scale: value.scale };
}
function appendCashFlow(
operation: Operation,
payload: Record<string, unknown>,
value: null | { currency: string; value: Decimal },
flows: PerformanceFlow[],
missingAmountAccounts: Set<string>,
) {
if (!value) {
missingAmountAccounts.add(operation.accountId);
return;
}
if (value.currency !== 'RUB') return;
const amount = magnitude(value.value);
flows.push({
accountId: operation.accountId,
amount: deposits.has(payload.type as string) ? amount : { value: -amount.value, scale: amount.scale },
date: moscowDate(operation.occurredAt), at: operation.occurredAt,
});
}
export function readPerformanceOperations(operations: Operation[]) {
const flows: PerformanceFlow[] = [];
const nonRubAccounts = new Set<string>();
const unvaluedAccounts = new Set<string>();
const missingAmountAccounts = new Set<string>();
for (const operation of operations) {
const payload = record(operation.payload);
if (typeof payload.type !== 'string') continue;
const value = money(payload.payment);
if (deposits.has(payload.type) || withdrawals.has(payload.type)) {
if (value && value.currency !== 'RUB') nonRubAccounts.add(operation.accountId);
appendCashFlow(operation, payload, value, flows, missingAmountAccounts);
} else if (securities.has(payload.type)) {
// Securities moved in or out carry no money amount for the flow.
unvaluedAccounts.add(operation.accountId);
}
}
return { flows, missingAmountAccounts, nonRubAccounts, unvaluedAccounts };
}
export function candidateTransfers(input: ReturnType<typeof readPerformanceOperations>): Candidate[] {
const candidates: Candidate[] = [];
for (const out of input.flows.filter((flow) => flow.amount.value < ZERO_UNITS)) {
for (const incoming of input.flows.filter((flow) => flow.amount.value > ZERO_UNITS)) {
if (out.accountId !== incoming.accountId &&
Math.abs(out.at.valueOf() - incoming.at.valueOf()) <= TRANSFER_WINDOW_MS &&
formatDecimal(magnitude(out.amount)) === formatDecimal(incoming.amount)) {
candidates.push({ accountIds: [out.accountId, incoming.accountId], type: 'cash' });
}
}
}
return candidates;
}
export function sumFlows(flows: PerformanceFlow[]): Decimal {
return flows.reduce((sum, flow) => add(sum, flow.amount), ZERO);
}