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>>['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 { 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 ?? [] }; } }