Files
backend/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts
T
codex-bot 809936e62d
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m32s
Backend verify / verify (push) Failing after 9m25s
refactor!: support RUB only (WealthLedger/main#55) (#204)
BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
2026-11-11 10:07:11 +03:00

115 lines
3.8 KiB
TypeScript

import type { Money } from '#src/common/money.types';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
subtract,
} from '#src/common/decimal';
import { decimal, rubMoney } from '#src/integrations/tbank/decimal';
import {
metadataKey,
type TbankInstrument,
type TbankMoney,
TbankPortfolio,
} from '#src/integrations/tbank/tbank.api';
import type { Classification } from '../application/position-classification.service';
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
import { portfolioStructure } from '../domain/portfolio-structure';
const RUB = 'RUB';
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
etf: 'FUND',
currency: 'CURRENCY',
futures: 'FUTURE',
option: 'OPTION',
sp: 'STRUCTURED_NOTE',
dfa: 'DIGITAL_FINANCIAL_ASSET',
};
function mapPortfolio(
upstream: NonNullable<TbankPortfolio['positions']>,
cashBalances: TbankMoney[],
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
lastSuccessfulAt: string,
classification: ReadonlyMap<string, Classification> = new Map(),
): PortfolioDto {
const positions = upstream
// Currency positions mirror cash balances and are non-RUB holdings by definition.
.filter((position) => types[position.instrumentType?.toLowerCase() ?? ''] !== 'CURRENCY' &&
(!position.currentPrice?.currency?.trim() || position.currentPrice.currency.trim().toUpperCase() === RUB))
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null, classification.get(metadataKey(position))))
.filter((position): position is PortfolioPositionDto => position !== null);
let cashTotal = decimal({});
for (const value of cashBalances) {
if (value.currency?.trim().toUpperCase() !== RUB) continue;
cashTotal = add(cashTotal, decimal(value));
}
const cash: Money = { currency: RUB, value: formatDecimal(cashTotal) };
let total = cashTotal;
for (const position of positions) {
if (!position.currentValue) continue;
total = add(total, decimalFromString(position.currentValue.value));
}
const totals: Money = { currency: RUB, value: formatDecimal(total) };
return {
lastSuccessfulAt,
isStale: false,
totals,
positions,
cash,
structure: portfolioStructure(positions, [cash]),
};
}
function mapPosition(
position: NonNullable<TbankPortfolio['positions']>[number],
metadata: null | TbankInstrument,
classification?: Classification,
): null | PortfolioPositionDto {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
const instrumentUid = position.instrumentUid?.trim() || null;
const currentPrice = rubMoney(position.currentPrice);
const averagePurchasePrice = rubMoney(position.averagePositionPrice);
const currentValue = currentPrice && {
currency: RUB,
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
};
const absoluteReturn =
currentPrice && averagePurchasePrice
? {
currency: RUB,
value: formatDecimal(multiply(
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
quantity,
)),
}
: null;
return {
figi,
instrumentUid,
displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument',
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
quantity: formatDecimal(quantity),
currentValue,
averagePurchasePrice,
absoluteReturn,
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
sector: classification?.sector ?? null,
issuer: classification?.issuer ?? null,
};
}
export { mapPortfolio };