148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
import type { Money } from '../../../../common/money.types';
|
|
import type { Classification } from '../application/position-classification.service';
|
|
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
|
|
|
import {
|
|
add,
|
|
decimalFromString,
|
|
formatDecimal,
|
|
isZero,
|
|
multiply,
|
|
subtract,
|
|
} from '../../../../common/decimal';
|
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
import {
|
|
metadataKey,
|
|
type TbankInstrument,
|
|
type TbankMoney,
|
|
TbankPortfolio,
|
|
TbankPositions,
|
|
} from '../../../../integrations/tbank/tbank.api';
|
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
|
|
|
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']>,
|
|
cash: TbankMoney[],
|
|
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
|
|
lastSuccessfulAt: string,
|
|
classification: ReadonlyMap<string, Classification> = new Map(),
|
|
): PortfolioDto {
|
|
const positions = upstream
|
|
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null, classification.get(metadataKey(position))))
|
|
.filter((position): position is PortfolioPositionDto => position !== null);
|
|
const balances = new Map<string, ReturnType<typeof decimal>>();
|
|
for (const value of cash) {
|
|
const currency = value.currency?.trim().toUpperCase();
|
|
const amount = decimal(value);
|
|
if (!currency || isZero(amount)) continue;
|
|
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
|
|
}
|
|
const cashBalances = [...balances].map(([currency, value]) => ({
|
|
currency,
|
|
value: formatDecimal(value),
|
|
}));
|
|
return {
|
|
lastSuccessfulAt,
|
|
isStale: false,
|
|
totalsByCurrency: totalsByCurrency(upstream, cash),
|
|
positions,
|
|
cashBalances,
|
|
structure: portfolioStructure(positions, cashBalances),
|
|
};
|
|
}
|
|
|
|
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 = money(position.currentPrice);
|
|
const averagePurchasePrice = money(position.averagePositionPrice);
|
|
const currentValue = currentPrice && {
|
|
currency: currentPrice.currency,
|
|
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
|
|
};
|
|
const absoluteReturn =
|
|
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
|
|
? {
|
|
currency: currentPrice.currency,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function money(value: TbankMoney | undefined): Money | null {
|
|
const currency = value?.currency?.trim().toUpperCase();
|
|
return currency && value
|
|
? { currency, value: formatDecimal(decimal(value)) }
|
|
: null;
|
|
}
|
|
|
|
function totalsByCurrency(
|
|
positions: NonNullable<TbankPortfolio['positions']>,
|
|
money: NonNullable<TbankPositions['money']>,
|
|
): Money[] {
|
|
const totals = new Map<string, ReturnType<typeof decimal>>();
|
|
const addTotal = (
|
|
currency: string | undefined,
|
|
amount: ReturnType<typeof decimal>,
|
|
) => {
|
|
const normalizedCurrency = currency?.trim().toUpperCase();
|
|
if (!normalizedCurrency || isZero(amount)) return;
|
|
totals.set(
|
|
normalizedCurrency,
|
|
add(totals.get(normalizedCurrency) ?? decimal({}), amount),
|
|
);
|
|
};
|
|
|
|
for (const value of money) addTotal(value.currency, decimal(value));
|
|
for (const position of positions) {
|
|
const price = position.currentPrice;
|
|
// CURRENCY positions mirror the cash balances already added above.
|
|
if (!price || types[position.instrumentType?.toLowerCase() ?? ''] === 'CURRENCY') continue;
|
|
addTotal(
|
|
price.currency,
|
|
multiply(decimal(position.quantity ?? {}), decimal(price)),
|
|
);
|
|
}
|
|
|
|
return [...totals]
|
|
.filter(([, value]) => !isZero(value))
|
|
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
|
}
|
|
|
|
export { mapPortfolio, totalsByCurrency };
|