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.
196 lines
9.8 KiB
TypeScript
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 ?? [] };
|
|
}
|
|
}
|