feat(performance): investment performance vs Moscow Exchange index (WealthLedger/main#54) (#200)
This commit was merged in pull request #200.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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 RUB = 'RUB';
|
||||
const DAY_MS = 86_400_000;
|
||||
const DAYS_IN_YEAR = 365;
|
||||
const RATE_SCALE = 6;
|
||||
const MONEY_SCALE = 2;
|
||||
const ZERO = 0;
|
||||
const ONE = 1;
|
||||
const ZERO_UNITS = 0n;
|
||||
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 }[];
|
||||
totalsByCurrency: { currency: string; 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.nonRubAccounts.size || snapshot.totalsByCurrency.some((row) =>
|
||||
row.currency !== RUB && decimalFromString(row.value).value !== ZERO_UNITS)) {
|
||||
return fail(PerformanceUnavailableReason.NonRubActivity);
|
||||
}
|
||||
if (parsed.missingAmountAccounts.size) return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
|
||||
if (parsed.unvaluedAccounts.size) return fail(PerformanceUnavailableReason.UnvaluedSecuritiesTransfer);
|
||||
const value = snapshot.totalsByCurrency.find((row) => row.currency === RUB);
|
||||
if (!value) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
|
||||
return {
|
||||
included: { account, flows: parsed.flows, value: decimalFromString(value.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 ?? [] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user