Filter non-RUB data at ingest, collapse per-currency collections into single RUB Money, drop currency from TbankPortfolioValuePoint, remove NON_RUB_ACTIVITY and screener currency filter. BREAKING CHANGE: per-currency API fields replaced with single RUB Money; stock screener currency param removed.
118 lines
4.3 KiB
TypeScript
118 lines
4.3 KiB
TypeScript
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') {
|
|
missingAmountAccounts.add(operation.accountId);
|
|
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 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)) {
|
|
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, 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);
|
|
}
|