Files
backend/src/modules/investments/performance/performance.service.ts
T
ksv741 3b55b611b5 refactor!: support RUB only (WealthLedger/main#55)
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.
2026-11-11 08:22:16 +03:00

196 lines
9.8 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { add, type Decimal, decimalFromString, divide, formatDecimal, subtract } from '#src/common/decimal';
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { TbankAccessService } from '#src/integrations/tbank/tbank-access.service';
import { moscowDate } from '../investment-calendar';
import { MarketIndexHistoryService } from '../market-index/market-index.service';
import { isCompleteValuation } from '../portfolio/domain/complete-valuation';
import { hasPerformanceCoverage, PerformanceUnavailableReason } from './domain/performance-availability';
import { candidateTransfers, type PerformanceFlow, readPerformanceOperations, sumFlows } from './domain/performance-flows';
import { shadowIndex } from './domain/shadow-index';
import { xirr } from './domain/xirr';
import { PerformanceRepository } from './infrastructure/performance.repository';
import {
PerformanceDto, PerformanceExcludedAccountDto, PerformanceIndexDto, PerformanceResourceDto,
PerformanceReturnDto, PerformanceSeriesPointDto,
} from './presentation/performance.dto';
const DAY_MS = 86_400_000;
const DAYS_IN_YEAR = 365;
const RATE_SCALE = 6;
const MONEY_SCALE = 2;
const RUB = 'RUB';
const ZERO = 0;
const ONE = 1;
const ZERO_DECIMAL = decimalFromString('0');
const LAST_FLOW = -1;
const ONE_DECIMAL = decimalFromString('1');
type Account = NonNullable<Awaited<ReturnType<PerformanceRepository['read']>>>['accounts'][number];
type Included = { account: Account; flows: PerformanceFlow[]; to: string; value: Decimal };
type IncludedResult = { included: Included | null; reason: null | PerformanceUnavailableReason };
function money(value: Decimal) {
return { currency: RUB, value: formatDecimal(divide(value, ONE_DECIMAL, MONEY_SCALE)) };
}
function unavailable(accountId: string, name: string, reason: PerformanceUnavailableReason): PerformanceExcludedAccountDto {
return { accountId, name, reason };
}
function duration(from: string, to: string) {
return (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS;
}
// Float rate → exact decimal string, rounded by the shared money helper.
function roundRate(value: number): string {
return formatDecimal(divide(decimalFromString(value.toFixed(RATE_SCALE + ONE)), ONE_DECIMAL, RATE_SCALE));
}
function rate(flows: PerformanceFlow[], endValue: Decimal, to: string, days: number): PerformanceReturnDto {
const basis = days >= DAYS_IN_YEAR ? 'annual' : 'period';
const result = xirr([
...flows.map((flow) => ({ date: flow.date, amount: { value: -flow.amount.value, scale: flow.amount.scale } })),
{ date: to, amount: endValue },
]);
if (result.value === null) {
return { value: null, basis, unavailableReason: result.reason === 'AMBIGUOUS_RETURN'
? PerformanceUnavailableReason.AmbiguousReturn
: PerformanceUnavailableReason.NoSolution };
}
const raw = Number(result.value);
const value = basis === 'annual' ? raw : Math.pow(ONE + raw, days / DAYS_IN_YEAR) - ONE;
return { value: roundRate(value), basis, unavailableReason: null };
}
@Injectable()
export class PerformanceService {
constructor(
private readonly access: TbankAccessService,
private readonly repository: PerformanceRepository,
private readonly indexes: MarketIndexHistoryService,
private readonly logger: StructuredLogger,
) {}
async read(userId: string, accountId: string): Promise<PerformanceResourceDto> {
const started = Date.now();
const connection = await this.access.local(userId, accountId);
const saved = await this.repository.read(userId, connection.revision, accountId);
if (accountId !== 'all' && saved?.membershipAt && !saved.accounts.length) throw new NotFoundException();
const accounts = saved?.membershipAt ? saved.accounts : [];
const excluded: PerformanceExcludedAccountDto[] = [];
let included = accounts.flatMap((account) => {
const result = this.includeAccount(account);
if (result.reason) {
excluded.push(unavailable(account.accountId, account.name, result.reason));
return [];
}
return result.included ? [result.included] : [];
});
if (accountId === 'all' && included.length > ZERO) {
const allOps = included.flatMap((item) => item.account.operations
.filter((op) => item.account.portfolio && op.occurredAt <= item.account.portfolio.fetchedAt)
.map((op) => ({ ...op, accountId: item.account.accountId })));
const candidates = candidateTransfers(readPerformanceOperations(allOps));
const affected = new Set(candidates.flatMap((candidate) => candidate.accountIds));
if (affected.size) {
included = included.filter((item) => {
if (!affected.has(item.account.accountId)) return true;
excluded.push(unavailable(item.account.accountId, item.account.name, PerformanceUnavailableReason.UnconfirmedTransfer));
return false;
});
}
}
if (!included.length) return { data: null, meta: { dependencies: [], unavailable: excluded } };
const dates = new Set(included.map((item) => item.to));
if (dates.size > ONE) {
return {
data: null,
meta: { dependencies: [], unavailable: [
...excluded,
...included.map((item) => unavailable(item.account.accountId, item.account.name,
PerformanceUnavailableReason.AccountValuationDatesDiffer)),
] },
};
}
const [to] = dates;
const flows = included.flatMap((item) => item.flows).sort((a, b) => a.at.valueOf() - b.at.valueOf());
// The period starts when money was first put in (owner decision), not at account opening.
// eslint-disable-next-line security/detect-object-injection -- Fixed first element of a locally sorted array.
const from = flows[ZERO]?.date ?? to;
const days = duration(from, to);
const invested = sumFlows(flows);
const current = included.reduce((total, item) => add(total, item.value), ZERO_DECIMAL);
const profit = subtract(current, invested);
const ownReturn = rate(flows, current, to, days);
const [benchmark, reference] = await Promise.all([
this.indexResult('MCFTRR', flows, from, to, days),
this.indexResult('IMOEX', flows, from, to, days),
]);
const outperformance = ownReturn.value !== null && benchmark.index.return.value !== null && benchmark.index.value
? {
percentagePoints: formatDecimal(subtract(decimalFromString(ownReturn.value),
decimalFromString(benchmark.index.return.value))),
money: money(subtract(current, decimalFromString(benchmark.index.value.value))),
}
: null;
const referenceByDay = new Map(reference.points.map((point) => [point.date, point.value]));
const series: PerformanceSeriesPointDto[] = benchmark.points.map((point) => ({
date: point.date,
netInvested: point.netInvested,
benchmarkValue: point.value,
referenceValue: referenceByDay.get(point.date) ?? null,
}));
const data: PerformanceDto = {
accountId, period: { from, to, days }, netInvested: money(invested), currentValue: money(current),
profit: money(profit), return: ownReturn, benchmark: benchmark.index, reference: reference.index,
outperformance, series, completeness: excluded.length ? 'incomplete' : 'complete',
excludedAccounts: excluded, calculatedAt: new Date().toISOString(),
};
this.logger.event('debug', 'performance.calculated', {
count: included.length, durationMs: Date.now() - started,
});
return { data, meta: { dependencies: [], unavailable: [] } };
}
private includeAccount(account: Account): IncludedResult {
const fail = (reason: PerformanceUnavailableReason): IncludedResult => ({ included: null, reason });
if (account.historyBoundary !== 'known' || !account.historyFrom) {
return fail(PerformanceUnavailableReason.HistoryStartUnknown);
}
const portfolio = account.portfolio;
if (!portfolio) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
const snapshot = portfolio.payload as { positions: { currentValue: unknown }[];
totals: { value: string }; };
if (!isCompleteValuation(snapshot)) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
if (!hasPerformanceCoverage(account.accountId, account.historyFrom, portfolio.fetchedAt, account.coverage)) {
return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
}
const operations = account.operations.filter((op) => op.occurredAt <= portfolio.fetchedAt)
.map((op) => ({ ...op, accountId: account.accountId }));
const parsed = readPerformanceOperations(operations);
if (parsed.missingAmountAccounts.size) return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
if (parsed.unvaluedAccounts.size) return fail(PerformanceUnavailableReason.UnvaluedSecuritiesTransfer);
return {
included: { account, flows: parsed.flows, value: decimalFromString(snapshot.totals.value),
to: moscowDate(portfolio.fetchedAt) },
reason: null,
};
}
private async indexResult(code: 'IMOEX' | 'MCFTRR', flows: PerformanceFlow[], from: string, to: string, days: number) {
const closes = await this.indexes.read(code, from, to, flows.at(LAST_FLOW)?.date ?? from);
const shadow = closes && shadowIndex(flows, closes, from, to);
const unavailableReason = shadow ? null : PerformanceUnavailableReason.IndexHistoryUnavailable;
const result = shadow && rate(flows, decimalFromString(shadow.value), to, days);
const index: PerformanceIndexDto = {
code, value: shadow ? money(decimalFromString(shadow.value)) : null,
return: result || { basis: days >= DAYS_IN_YEAR ? 'annual' : 'period', value: null, unavailableReason },
asOf: shadow?.asOf ?? null, unavailableReason,
};
return { index, points: shadow?.points ?? [] };
}
}