feat(performance): add investment performance vs Moscow Exchange index endpoint (WealthLedger/main#54)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user