From 42b1d60fbab42dc9c9f637ab065088225ebb3580 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 8 Nov 2026 14:20:05 +0300 Subject: [PATCH] feat(performance): add investment performance vs index endpoint (WealthLedger/main#54) --- src/app.module.ts | 2 + src/integrations/tbank/tbank-protocol.ts | 6 + .../domain/performance-availability.spec.ts | 27 +++ .../domain/performance-availability.ts | 35 +++ .../domain/performance-flows.spec.ts | 41 ++++ .../performance/domain/performance-flows.ts | 116 ++++++++++ .../performance/domain/shadow-index.spec.ts | 41 ++++ .../performance/domain/shadow-index.ts | 81 +++++++ .../performance/domain/xirr.spec.ts | 33 +++ .../investments/performance/domain/xirr.ts | 94 ++++++++ .../infrastructure/performance.repository.ts | 34 +++ .../performance/performance.module.ts | 16 ++ .../performance/performance.service.spec.ts | 111 ++++++++++ .../performance/performance.service.ts | 202 ++++++++++++++++++ .../presentation/performance.controller.ts | 34 +++ .../presentation/performance.dto.ts | 139 ++++++++++++ .../domain/complete-valuation.spec.ts | 15 ++ .../portfolio/domain/complete-valuation.ts | 13 ++ .../infrastructure/portfolio.repository.ts | 7 +- .../investments/portfolio/portfolio.api.ts | 1 + 20 files changed, 1044 insertions(+), 4 deletions(-) create mode 100644 src/modules/investments/performance/domain/performance-availability.spec.ts create mode 100644 src/modules/investments/performance/domain/performance-availability.ts create mode 100644 src/modules/investments/performance/domain/performance-flows.spec.ts create mode 100644 src/modules/investments/performance/domain/performance-flows.ts create mode 100644 src/modules/investments/performance/domain/shadow-index.spec.ts create mode 100644 src/modules/investments/performance/domain/shadow-index.ts create mode 100644 src/modules/investments/performance/domain/xirr.spec.ts create mode 100644 src/modules/investments/performance/domain/xirr.ts create mode 100644 src/modules/investments/performance/infrastructure/performance.repository.ts create mode 100644 src/modules/investments/performance/performance.module.ts create mode 100644 src/modules/investments/performance/performance.service.spec.ts create mode 100644 src/modules/investments/performance/performance.service.ts create mode 100644 src/modules/investments/performance/presentation/performance.controller.ts create mode 100644 src/modules/investments/performance/presentation/performance.dto.ts create mode 100644 src/modules/investments/portfolio/domain/complete-valuation.spec.ts create mode 100644 src/modules/investments/portfolio/domain/complete-valuation.ts diff --git a/src/app.module.ts b/src/app.module.ts index 9b9bf6f..71f9dee 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { BrokerageAccountsModule } from './modules/investments/brokerage-account import { IncomeModule } from './modules/investments/income/income.module'; import { InstrumentsModule } from './modules/investments/instruments/instruments.module'; import { OperationsModule } from './modules/investments/operations/operations.module'; +import { PerformanceModule } from './modules/investments/performance/performance.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; @@ -27,6 +28,7 @@ import { PrismaModule } from './prisma/prisma.module'; BankCardsModule, BrokerageAccountsModule, PortfolioModule, + PerformanceModule, OperationsModule, IncomeModule, UpcomingEventsModule, diff --git a/src/integrations/tbank/tbank-protocol.ts b/src/integrations/tbank/tbank-protocol.ts index 2bd49b4..ceca086 100644 --- a/src/integrations/tbank/tbank-protocol.ts +++ b/src/integrations/tbank/tbank-protocol.ts @@ -49,11 +49,17 @@ export enum TbankOperationType { Dividend = 'OPERATION_TYPE_DIVIDEND', DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX', DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER', + InpMulti = 'OPERATION_TYPE_INP_MULTI', Input = 'OPERATION_TYPE_INPUT', + InputAcquiring = 'OPERATION_TYPE_INPUT_ACQUIRING', InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES', + InputSwift = 'OPERATION_TYPE_INPUT_SWIFT', MarginFee = 'OPERATION_TYPE_MARGIN_FEE', + OutMulti = 'OPERATION_TYPE_OUT_MULTI', Output = 'OPERATION_TYPE_OUTPUT', + OutputAcquiring = 'OPERATION_TYPE_OUTPUT_ACQUIRING', OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES', + OutputSwift = 'OPERATION_TYPE_OUTPUT_SWIFT', Sell = 'OPERATION_TYPE_SELL', SellCard = 'OPERATION_TYPE_SELL_CARD', SellMargin = 'OPERATION_TYPE_SELL_MARGIN', diff --git a/src/modules/investments/performance/domain/performance-availability.spec.ts b/src/modules/investments/performance/domain/performance-availability.spec.ts new file mode 100644 index 0000000..8fcd097 --- /dev/null +++ b/src/modules/investments/performance/domain/performance-availability.spec.ts @@ -0,0 +1,27 @@ +import { hasPerformanceCoverage } from './performance-availability'; + +const start = new Date('2024-01-01T00:00:00Z'); +const valuation = new Date('2024-02-01T15:00:00Z'); + +describe('performance operations coverage', () => { + it('requires contiguous history through the exact valuation instant', () => { + expect(hasPerformanceCoverage('A', start, valuation, [{ + from: start, + to: new Date('2024-02-01T15:00:00Z'), + asOf: valuation, + }])).toBe(false); + const end = new Date('2024-02-02T00:00:00Z'); + expect(hasPerformanceCoverage('A', start, valuation, [ + { from: start, to: new Date('2024-01-15T00:00:00Z'), asOf: new Date('2024-01-15T00:00:00Z') }, + { from: new Date('2024-01-15T00:00:00Z'), to: end, asOf: end }, + ])).toBe(true); + }); + + it('rejects missing history in the middle', () => { + const end = new Date('2024-02-02T00:00:00Z'); + expect(hasPerformanceCoverage('A', start, valuation, [ + { from: start, to: new Date('2024-01-15T00:00:00Z'), asOf: end }, + { from: new Date('2024-01-16T00:00:00Z'), to: end, asOf: end }, + ])).toBe(false); + }); +}); diff --git a/src/modules/investments/performance/domain/performance-availability.ts b/src/modules/investments/performance/domain/performance-availability.ts new file mode 100644 index 0000000..cde1d66 --- /dev/null +++ b/src/modules/investments/performance/domain/performance-availability.ts @@ -0,0 +1,35 @@ +import { uncoveredOperationIntervals } from '../../operations/domain/operations-sync-plan'; + +const ZERO_MILLIS = 0; +const NEXT_MILLISECOND = 1; + +export enum PerformanceUnavailableReason { + AccountValuationDatesDiffer = 'ACCOUNT_VALUATION_DATES_DIFFER', + AmbiguousReturn = 'AMBIGUOUS_RETURN', + HistoryStartUnknown = 'HISTORY_START_UNKNOWN', + IndexHistoryUnavailable = 'INDEX_HISTORY_UNAVAILABLE', + NonRubActivity = 'NON_RUB_ACTIVITY', + NoSolution = 'NO_SOLUTION', + OperationsHistoryIncomplete = 'OPERATIONS_HISTORY_INCOMPLETE', + PortfolioValuationIncomplete = 'PORTFOLIO_VALUATION_INCOMPLETE', + UnconfirmedTransfer = 'UNCONFIRMED_TRANSFER', + UnvaluedSecuritiesTransfer = 'UNVALUED_SECURITIES_TRANSFER', +} + +export const performanceUnavailableReasons = Object.values(PerformanceUnavailableReason); + +type Coverage = { asOf: Date; from: Date; to: Date }; + +/** Coverage is half-open and must include the instant of the last valuation. */ +export function hasPerformanceCoverage( + accountId: string, + from: Date, + valuationAt: Date, + coverage: Coverage[], +): boolean { + if (from.valueOf() >= valuationAt.valueOf()) return false; + const end = new Date(valuationAt.valueOf() + NEXT_MILLISECOND); + if (!coverage.some((item) => item.asOf >= valuationAt && item.to > valuationAt)) return false; + const confirmed = coverage.map((item) => ({ accountId, from: item.from, to: item.to })); + return uncoveredOperationIntervals([{ accountId, from, to: end }], confirmed).length === ZERO_MILLIS; +} diff --git a/src/modules/investments/performance/domain/performance-flows.spec.ts b/src/modules/investments/performance/domain/performance-flows.spec.ts new file mode 100644 index 0000000..21207e9 --- /dev/null +++ b/src/modules/investments/performance/domain/performance-flows.spec.ts @@ -0,0 +1,41 @@ +import { formatDecimal } from '#src/common/decimal'; + +import { candidateTransfers, readPerformanceOperations, sumFlows } from './performance-flows'; + +const at = (date: string) => new Date(date); +const op = (accountId: string, type: string, date: string, units: string, currency = 'rub') => ({ + accountId, occurredAt: at(date), instrumentUid: null, + payload: { type, payment: { units, nano: 0, currency } }, +}); + +describe('performance cash flows', () => { + it('includes only external cash and preserves dividends within the portfolio', () => { + const operations = [ + op('A', 'OPERATION_TYPE_INPUT', '2025-01-01T15:00:00Z', '100000'), + op('A', 'OPERATION_TYPE_INPUT_ACQUIRING', '2025-02-01T15:00:00Z', '50000'), + op('A', 'OPERATION_TYPE_OUTPUT', '2025-03-01T15:00:00Z', '-20000'), + op('A', 'OPERATION_TYPE_DIVIDEND', '2025-04-01T15:00:00Z', '3000'), + ]; + const result = readPerformanceOperations(operations); + expect(formatDecimal(sumFlows(result.flows))).toBe('130000'); + expect(result.flows).toHaveLength(3); + }); + + it('treats equal opposite cash movements as unconfirmed transfer candidates, not a proven pair', () => { + const result = readPerformanceOperations([ + op('A', 'OPERATION_TYPE_OUTPUT', '2025-03-01T15:00:00Z', '-50000'), + op('B', 'OPERATION_TYPE_INPUT', '2025-03-01T15:01:00Z', '50000'), + ]); + expect(candidateTransfers(result)).toEqual([{ accountIds: ['A', 'B'], type: 'cash' }]); + expect(result.flows).toHaveLength(2); + }); + + it('detects non-RUB operations and unvalued securities transfers', () => { + const result = readPerformanceOperations([ + op('A', 'OPERATION_TYPE_INPUT', '2025-01-01T15:00:00Z', '10', 'usd'), + op('B', 'OPERATION_TYPE_INPUT_SECURITIES', '2025-01-01T15:00:00Z', '0'), + ]); + expect([...result.nonRubAccounts]).toEqual(['A']); + expect([...result.unvaluedAccounts]).toEqual(['B']); + }); +}); diff --git a/src/modules/investments/performance/domain/performance-flows.ts b/src/modules/investments/performance/domain/performance-flows.ts new file mode 100644 index 0000000..17ebd22 --- /dev/null +++ b/src/modules/investments/performance/domain/performance-flows.ts @@ -0,0 +1,116 @@ +import { add, type Decimal, decimalFromString, formatDecimal } from '#src/common/decimal'; +import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol'; + +import { moscowDate } from '../../investment-calendar'; + +const ZERO: Decimal = { value: 0n, scale: 0 }; +const ZERO_UNITS = 0n; +const NANO_SCALE = 9; +const TRANSFER_WINDOW_MS = 300_000; + +export type PerformanceFlow = { accountId: string; amount: Decimal; at: Date; date: string }; +export type Candidate = { accountIds: [string, string]; type: 'cash' }; + +type Operation = { + accountId: string; + instrumentUid: null | string; + occurredAt: Date; + payload: unknown; +}; + +const deposits = new Set([ + TbankOperationType.InpMulti, TbankOperationType.Input, + TbankOperationType.InputAcquiring, TbankOperationType.InputSwift, +]); +const withdrawals = new Set([ + TbankOperationType.OutMulti, TbankOperationType.Output, + TbankOperationType.OutputAcquiring, TbankOperationType.OutputSwift, +]); +const securities = new Set([ + TbankOperationType.InputSecurities, TbankOperationType.OutputSecurities, + TbankOperationType.TransBsBs, TbankOperationType.TransIisBs, +]); + +function record(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function money(value: unknown): null | { currency: string; value: Decimal } { + const source = record(value); + if (typeof source.currency !== 'string') return null; + const units = typeof source.units === 'string' || typeof source.units === 'number' + ? String(source.units) + : '0'; + let nano = ZERO_UNITS; + if (typeof source.nano === 'number' && Number.isInteger(source.nano)) nano = BigInt(source.nano); + if (typeof source.nano === 'string' && /^-?\d+$/.test(source.nano)) nano = BigInt(source.nano); + const whole = decimalFromString(units); + return { + currency: source.currency.toUpperCase(), + value: add(whole, { value: nano, scale: NANO_SCALE }), + }; +} + +function magnitude(value: Decimal): Decimal { + return { value: value.value < ZERO_UNITS ? -value.value : value.value, scale: value.scale }; +} + +function appendCashFlow( + operation: Operation, + payload: Record, + value: null | { currency: string; value: Decimal }, + flows: PerformanceFlow[], + missingAmountAccounts: Set, +) { + if (!value) { + missingAmountAccounts.add(operation.accountId); + return; + } + if (value.currency !== 'RUB') return; + const amount = magnitude(value.value); + flows.push({ + accountId: operation.accountId, + amount: deposits.has(payload.type as string) ? amount : { value: -amount.value, scale: amount.scale }, + date: moscowDate(operation.occurredAt), at: operation.occurredAt, + }); +} + +export function readPerformanceOperations(operations: Operation[]) { + const flows: PerformanceFlow[] = []; + const nonRubAccounts = new Set(); + const unvaluedAccounts = new Set(); + const missingAmountAccounts = new Set(); + for (const operation of operations) { + const payload = record(operation.payload); + if (typeof payload.type !== 'string') continue; + const value = money(payload.payment); + if (deposits.has(payload.type) || withdrawals.has(payload.type)) { + if (value && value.currency !== 'RUB') nonRubAccounts.add(operation.accountId); + appendCashFlow(operation, payload, value, flows, missingAmountAccounts); + } else if (securities.has(payload.type)) { + // Securities moved in or out carry no money amount for the flow. + unvaluedAccounts.add(operation.accountId); + } + } + return { flows, missingAmountAccounts, nonRubAccounts, unvaluedAccounts }; +} + +export function candidateTransfers(input: ReturnType): Candidate[] { + const candidates: Candidate[] = []; + for (const out of input.flows.filter((flow) => flow.amount.value < ZERO_UNITS)) { + for (const incoming of input.flows.filter((flow) => flow.amount.value > ZERO_UNITS)) { + if (out.accountId !== incoming.accountId && + Math.abs(out.at.valueOf() - incoming.at.valueOf()) <= TRANSFER_WINDOW_MS && + formatDecimal(magnitude(out.amount)) === formatDecimal(incoming.amount)) { + candidates.push({ accountIds: [out.accountId, incoming.accountId], type: 'cash' }); + } + } + } + return candidates; +} + +export function sumFlows(flows: PerformanceFlow[]): Decimal { + return flows.reduce((sum, flow) => add(sum, flow.amount), ZERO); +} diff --git a/src/modules/investments/performance/domain/shadow-index.spec.ts b/src/modules/investments/performance/domain/shadow-index.spec.ts new file mode 100644 index 0000000..09ed59d --- /dev/null +++ b/src/modules/investments/performance/domain/shadow-index.spec.ts @@ -0,0 +1,41 @@ +import { decimalFromString } from '#src/common/decimal'; + +import { shadowIndex } from './shadow-index'; + +const flow = (date: string, amount: string) => ({ + accountId: 'A', amount: decimalFromString(amount), + date, at: new Date(`${date}T12:00:00Z`), +}); + +describe('shadowIndex', () => { + it('values identical deposits at the index close and links later index growth', () => { + const result = shadowIndex([flow('2026-11-06', '100000')], [ + { tradeDate: '2026-11-06', close: '1000' }, + { tradeDate: '2026-11-09', close: '1100' }, + ], '2026-11-06', '2026-11-09'); + expect(result?.value).toBe('110000'); + expect(result?.points.map((point) => point.value)).toEqual(['100000', '110000']); + }); + + it('uses Friday close for a Saturday deposit, not Monday close', () => { + const result = shadowIndex([flow('2026-11-07', '100000')], [ + { tradeDate: '2026-11-06', close: '1000' }, + { tradeDate: '2026-11-09', close: '1100' }, + ], '2026-11-07', '2026-11-09'); + expect(result?.value).toBe('110000'); + expect(result?.points).toEqual([{ date: '2026-11-09', netInvested: '100000', value: '110000' }]); + }); + + it('refuses withdrawals larger than the index position', () => { + expect(shadowIndex([flow('2026-11-06', '100000'), flow('2026-11-09', '-120000')], [ + { tradeDate: '2026-11-06', close: '1000' }, + { tradeDate: '2026-11-09', close: '900' }, + ], '2026-11-06', '2026-11-09')).toBeNull(); + }); + + it('does not invent a price for a flow preceding the first close', () => { + expect(shadowIndex([flow('2026-11-05', '100000')], [ + { tradeDate: '2026-11-06', close: '1000' }, + ], '2026-11-05', '2026-11-06')).toBeNull(); + }); +}); diff --git a/src/modules/investments/performance/domain/shadow-index.ts b/src/modules/investments/performance/domain/shadow-index.ts new file mode 100644 index 0000000..8782cf3 --- /dev/null +++ b/src/modules/investments/performance/domain/shadow-index.ts @@ -0,0 +1,81 @@ +import { + add, type Decimal, decimalFromString, divide, formatDecimal, multiply, +} from '#src/common/decimal'; + +import type { PerformanceFlow } from './performance-flows'; + +const UNIT_SCALE = 12; +const CENT_SCALE = 2; +const ZERO: Decimal = { scale: 0, value: 0n }; +const FIRST = 0; +const NEXT = 1; +const LAST = -1; +const CENT = decimalFromString('1'); + +export type IndexClose = { close: string; tradeDate: string }; +export type IndexPoint = { date: string; netInvested: string; value: string }; + +function valueAt(units: Decimal, close: string): string { + return formatDecimal(divide(multiply(units, decimalFromString(close)), CENT, CENT_SCALE)); +} + +/** A closed price on or before every cash flow is mandatory; no invented prices. */ +export function shadowIndex( + flows: PerformanceFlow[], + closes: IndexClose[], + from: string, + to: string, +): null | { asOf: string; points: IndexPoint[]; value: string } { + const prices = closes.filter((close) => close.tradeDate <= to) + .sort((a, b) => a.tradeDate.localeCompare(b.tradeDate)); + if (!prices.length) return null; + const events = [...flows].sort((a, b) => a.at.valueOf() - b.at.valueOf()); + let closeIndex = FIRST; + let eventIndex = FIRST; + let units = ZERO; + let invested = ZERO; + const points: IndexPoint[] = []; + + // Advance prices only up to the date of the event, never to the next trading day. + const applyEvent = (event: PerformanceFlow): boolean => { + while (prices[closeIndex + NEXT]?.tradeDate <= event.date) closeIndex++; + // eslint-disable-next-line security/detect-object-injection -- Numeric index advances only through this validated sorted local array. + const price = prices[closeIndex]; + if (!price || price.tradeDate > event.date) return false; + units = add(units, divide(event.amount, decimalFromString(price.close), UNIT_SCALE)); + // Withdrawals larger than the index position cannot be replayed honestly. + if (units.value < ZERO.value) return false; + invested = add(invested, event.amount); + return true; + }; + + for (const close of prices) { + // eslint-disable-next-line security/detect-object-injection -- Sequential local iteration bounded by array length. + while (events[eventIndex]?.date <= close.tradeDate) { + // eslint-disable-next-line security/detect-object-injection -- While condition ensures an event exists at this index. + const event = events[eventIndex]; + if (!event || !applyEvent(event)) return null; + eventIndex++; + } + if (close.tradeDate >= from) { + points.push({ + date: close.tradeDate, + netInvested: formatDecimal(invested), + value: valueAt(units, close.close), + }); + } + } + // A flow on a market holiday after the last close still buys at that close. + // eslint-disable-next-line security/detect-object-injection -- Sequential local iteration bounded by array length. + while (events[eventIndex]?.date <= to) { + // eslint-disable-next-line security/detect-object-injection -- While condition ensures an event exists at this index. + const event = events[eventIndex]; + if (!event || !applyEvent(event)) return null; + eventIndex++; + } + if (eventIndex !== events.length || !points.length) return null; + const last = points.at(LAST); + const finalPrice = prices.at(LAST); + if (!last || !finalPrice) return null; + return { asOf: last.date, points, value: valueAt(units, finalPrice.close) }; +} diff --git a/src/modules/investments/performance/domain/xirr.spec.ts b/src/modules/investments/performance/domain/xirr.spec.ts new file mode 100644 index 0000000..ab1e37a --- /dev/null +++ b/src/modules/investments/performance/domain/xirr.spec.ts @@ -0,0 +1,33 @@ +import { decimalFromString } from '#src/common/decimal'; + +import { xirr } from './xirr'; + +const cash = (date: string, value: string) => ({ date, amount: decimalFromString(value) }); + +describe('XIRR of actual dated external flows', () => { + it('finds 10% annual on 100000 invested for exactly 730 days', () => { + expect(xirr([cash('2024-01-01', '-100000'), cash('2025-12-31', '121000')])) + .toEqual({ value: '0.1', reason: null }); + }); + + it('returns no rate when an investment is worth zero at the end', () => { + expect(xirr([cash('2024-01-01', '-100000'), cash('2025-01-01', '0')])) + .toEqual({ value: null, reason: 'NO_SOLUTION' }); + }); + + it('never selects one of two mathematically valid IRR roots', () => { + // NPV = -100 + 230/(1+r) - 132/(1+r)^2: roots 10% and 20%. + expect(xirr([ + cash('2023-01-01', '-100'), + cash('2024-01-01', '230'), + cash('2024-12-31', '-132'), + ])).toEqual({ value: null, reason: 'AMBIGUOUS_RETURN' }); + }); + + it('combines flows on the same day before choosing a rate', () => { + expect(xirr([ + cash('2024-01-01', '-100000'), cash('2024-01-01', '25000'), + cash('2024-12-31', '82500'), + ])).toEqual({ value: '0.1', reason: null }); + }); +}); diff --git a/src/modules/investments/performance/domain/xirr.ts b/src/modules/investments/performance/domain/xirr.ts new file mode 100644 index 0000000..a41a523 --- /dev/null +++ b/src/modules/investments/performance/domain/xirr.ts @@ -0,0 +1,94 @@ +import { add, type Decimal, formatDecimal } from '#src/common/decimal'; + +const DAYS_IN_YEAR = 365; +const DAY_MS = 86_400_000; +const MIN_RATE = -0.9999; +const MAX_RATE = 100; +const NPV_TOLERANCE_RUB = 0.01; +const MAX_ITERATIONS = 200; +const MIDPOINT = 2; +const ROOT_COUNT_NONE = 0; +const ROOT_COUNT_ONE = 1; +const ZERO = 0; +const ZERO_UNITS = 0n; +const RATE_SCALE = 6; +const RATE_BASE = 1; + +export type DatedCashFlow = { amount: Decimal; date: string }; +export type XirrResult = + | { reason: 'AMBIGUOUS_RETURN' | 'NO_SOLUTION'; value: null } | + { reason: null; value: string }; + +// With x=(1+r)^(-1/365) the dated NPV is a polynomial with terms at integer +// day exponents. Descartes' rule proves uniqueness when its nonzero +// coefficients have exactly one sign change. More sign changes may still +// yield a unique root: conservatively report AMBIGUOUS_RETURN until all +// candidate roots can be isolated, never silently choose one. +export function xirr(flows: DatedCashFlow[]): XirrResult { + if (!flows.length) return { reason: 'NO_SOLUTION', value: null }; + const byDate = new Map(); + for (const flow of flows) { + byDate.set(flow.date, add(byDate.get(flow.date) ?? { value: ZERO_UNITS, scale: ZERO }, flow.amount)); + } + const ordered = [...byDate.entries()].sort(([left], [right]) => left.localeCompare(right)) + .filter(([, amount]) => amount.value !== ZERO_UNITS); + const changes = signChanges(ordered); + if (changes > ROOT_COUNT_ONE) return { reason: 'AMBIGUOUS_RETURN', value: null }; + if (changes < ROOT_COUNT_ONE) return { reason: 'NO_SOLUTION', value: null }; + + const firstDay = ordered[0]?.[0]; + if (!firstDay) return { reason: 'NO_SOLUTION', value: null }; + const first = Date.parse(`${firstDay}T00:00:00Z`); + if (!Number.isFinite(first)) return { reason: 'NO_SOLUTION', value: null }; + const terms = ordered.map(([date, amount]) => ({ + days: (Date.parse(`${date}T00:00:00Z`) - first) / DAY_MS, + amount: Number(formatDecimal(amount)), + })); + if (terms.some((term) => !Number.isFinite(term.days) || !Number.isFinite(term.amount))) { + return { reason: 'NO_SOLUTION', value: null }; + } + const npv = (rate: number) => terms.reduce((sum, term) => + sum + term.amount / Math.pow(RATE_BASE + rate, term.days / DAYS_IN_YEAR), ZERO); + return bisect(npv); +} + +function bisect(npv: (rate: number) => number): XirrResult { + let low = MIN_RATE; + let high = MAX_RATE; + let lower = npv(low); + const upper = npv(high); + if (!Number.isFinite(lower) || !Number.isFinite(upper) || lower * upper > ZERO) { + return { reason: 'NO_SOLUTION', value: null }; + } + if (Math.abs(lower) <= NPV_TOLERANCE_RUB) { + return { reason: null, value: String(Number(low.toFixed(RATE_SCALE))) }; + } + if (Math.abs(upper) <= NPV_TOLERANCE_RUB) { + return { reason: null, value: String(Number(high.toFixed(RATE_SCALE))) }; + } + for (let i = ROOT_COUNT_NONE; i < MAX_ITERATIONS; i++) { + const mid = (low + high) / MIDPOINT; + const value = npv(mid); + if (!Number.isFinite(value)) return { reason: 'NO_SOLUTION', value: null }; + if (Math.abs(value) <= NPV_TOLERANCE_RUB) { + return { reason: null, value: String(Number(mid.toFixed(RATE_SCALE))) }; + } + if ((lower < ZERO) === (value < ZERO)) { + low = mid; + lower = value; + } else { + high = mid; + } + } + return { reason: 'NO_SOLUTION', value: null }; +} + +function signChanges(ordered: [string, Decimal][]): number { + let changes = ROOT_COUNT_NONE; + let previous = ZERO_UNITS; + for (const [, amount] of ordered) { + if (previous * amount.value < ZERO_UNITS) changes++; + previous = amount.value; + } + return changes; +} diff --git a/src/modules/investments/performance/infrastructure/performance.repository.ts b/src/modules/investments/performance/infrastructure/performance.repository.ts new file mode 100644 index 0000000..1699fcc --- /dev/null +++ b/src/modules/investments/performance/infrastructure/performance.repository.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '#src/prisma/prisma.service'; + +@Injectable() +export class PerformanceRepository { + constructor(private readonly prisma: PrismaService) {} + + read(userId: string, revision: string, accountId: string) { + return this.prisma.tbankConnection.findUnique({ + where: { userId_revision: { userId, revision }, invalidToken: false }, + select: { + membershipAt: true, + accounts: { + where: accountId === 'all' ? {} : { accountId }, + orderBy: { upstreamOrder: 'asc' }, + select: { + accountId: true, name: true, historyBoundary: true, historyFrom: true, + portfolio: { select: { payload: true, fetchedAt: true } }, + operations: { + orderBy: { occurredAt: 'asc' }, + select: { occurredAt: true, payload: true, instrumentUid: true }, + }, + coverage: { + where: { instrumentPredicate: '' }, + select: { from: true, to: true, asOf: true }, + orderBy: { from: 'asc' }, + }, + }, + }, + }, + }); + } +} diff --git a/src/modules/investments/performance/performance.module.ts b/src/modules/investments/performance/performance.module.ts new file mode 100644 index 0000000..a01e606 --- /dev/null +++ b/src/modules/investments/performance/performance.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { TbankModule } from '#src/integrations/tbank/tbank.module'; + +import { AuthModule } from '../../auth/auth.module'; +import { MarketIndexModule } from '../market-index/market-index.module'; +import { PerformanceRepository } from './infrastructure/performance.repository'; +import { PerformanceService } from './performance.service'; +import { PerformanceController } from './presentation/performance.controller'; + +@Module({ + imports: [AuthModule, TbankModule, MarketIndexModule], + controllers: [PerformanceController], + providers: [PerformanceService, PerformanceRepository], +}) +export class PerformanceModule {} diff --git a/src/modules/investments/performance/performance.service.spec.ts b/src/modules/investments/performance/performance.service.spec.ts new file mode 100644 index 0000000..b9ad434 --- /dev/null +++ b/src/modules/investments/performance/performance.service.spec.ts @@ -0,0 +1,111 @@ +import { PerformanceService } from './performance.service'; + +jest.mock('./infrastructure/performance.repository', () => ({ PerformanceRepository: class {} })); +jest.mock('../market-index/market-index.service', () => ({ MarketIndexHistoryService: class {} })); +jest.mock('#src/integrations/tbank/tbank-access.service', () => ({ TbankAccessService: class {} })); + +const first = new Date('2024-01-01T00:00:00Z'); +const end = new Date('2025-12-31T15:00:00Z'); +const coverageEnd = new Date('2026-01-01T00:00:00Z'); +const operation = (accountId: string, type: string, units: string, date = first) => ({ + accountId, instrumentUid: null, occurredAt: date, + payload: { type, payment: { currency: 'rub', units, nano: 0 } }, +}); + +function account(accountId: string, amount = '121000') { + return { + accountId, name: accountId, historyFrom: first, historyBoundary: 'known', + portfolio: { fetchedAt: end, payload: { + positions: [], totalsByCurrency: [{ currency: 'RUB', value: amount }], + } }, + coverage: [{ from: first, to: coverageEnd, asOf: coverageEnd }], + operations: [operation(accountId, 'OPERATION_TYPE_INPUT', '100000')], + }; +} + +function setup(accounts: ReturnType[]) { + const access = { local: jest.fn().mockResolvedValue({ revision: 'revision' }) }; + const repository = { read: jest.fn().mockResolvedValue({ membershipAt: first, accounts }) }; + const indexes = { read: jest.fn().mockImplementation((code: string) => Promise.resolve([ + { tradeDate: '2024-01-01', close: code === 'MCFTRR' ? '1000' : '2000' }, + { tradeDate: '2025-12-31', close: code === 'MCFTRR' ? '1100' : '2100' }, + ])) }; + const logger = { event: jest.fn() }; + const service = new PerformanceService(access as never, repository as never, indexes as never, logger as never); + return { service, indexes }; +} + +describe('investment performance read', () => { + it('returns independently verifiable XIRR and same-flow benchmark, including profit', async () => { + const { service } = setup([account('A')]); + const result = await service.read('user', 'A'); + expect(result.data).toMatchObject({ + netInvested: { currency: 'RUB', value: '100000' }, + currentValue: { currency: 'RUB', value: '121000' }, + profit: { currency: 'RUB', value: '21000' }, + return: { basis: 'annual', value: '0.1', unavailableReason: null }, + benchmark: { code: 'MCFTRR', value: { currency: 'RUB', value: '110000' } }, + outperformance: { money: { currency: 'RUB', value: '11000' } }, + }); + expect(result.data?.outperformance?.percentagePoints).toBe('0.051191'); + }); + + it('does not return numbers for accounts with unconfirmed cash transfer candidates', async () => { + const a = account('A'); + const b = account('B'); + a.operations.push(operation('A', 'OPERATION_TYPE_OUTPUT', '-50000', + new Date('2024-02-01T12:00:00Z'))); + b.operations.push(operation('B', 'OPERATION_TYPE_INPUT', '50000', + new Date('2024-02-01T12:01:00Z'))); + expect(await setup([a, b]).service.read('user', 'all')).toMatchObject({ + data: null, + meta: { unavailable: [ + { accountId: 'A', reason: 'UNCONFIRMED_TRANSFER' }, + { accountId: 'B', reason: 'UNCONFIRMED_TRANSFER' }, + ] }, + }); + }); + + it('does not sum account valuations observed on different Moscow days', async () => { + const b = account('B'); + b.portfolio.fetchedAt = new Date('2026-01-01T15:00:00Z'); + b.coverage[0].to = new Date('2026-01-02T00:00:00Z'); + b.coverage[0].asOf = new Date('2026-01-02T00:00:00Z'); + expect((await setup([account('A'), b]).service.read('user', 'all')).meta.unavailable) + .toEqual(expect.arrayContaining([{ accountId: 'A', name: 'A', reason: 'ACCOUNT_VALUATION_DATES_DIFFER' }])); + }); + + it('keeps the profit when the index is unavailable', async () => { + const { service, indexes } = setup([account('A')]); + indexes.read.mockResolvedValue(null); + const result = await service.read('user', 'A'); + expect(result.data?.profit.value).toBe('21000'); + expect(result.data?.benchmark.unavailableReason).toBe('INDEX_HISTORY_UNAVAILABLE'); + expect(result.data?.series).toEqual([]); + }); + + it('starts the period at the first deposit, not at account opening', async () => { + const late = account('A', '110000'); + late.historyFrom = new Date('2025-03-07T00:00:00Z'); + late.coverage[0].from = late.historyFrom; + late.operations = [operation('A', 'OPERATION_TYPE_INPUT', '100000', new Date('2025-09-22T09:00:00Z'))]; + const result = await setup([late]).service.read('user', 'A'); + expect(result.data?.period).toEqual({ from: '2025-09-22', to: '2025-12-31', days: 100 }); + expect(result.data?.return).toMatchObject({ basis: 'period', value: '0.1' }); + }); + + it('reports incomplete history for a deposit without an amount', async () => { + const broken = account('A'); + broken.operations.push({ ...operation('A', 'OPERATION_TYPE_INPUT', '0'), payload: { type: 'OPERATION_TYPE_INPUT' } } as never); + expect((await setup([broken]).service.read('user', 'A')).meta.unavailable) + .toEqual([{ accountId: 'A', name: 'A', reason: 'OPERATIONS_HISTORY_INCOMPLETE' }]); + }); + + it('keeps a zero rate available and calculates outperformance', async () => { + const { service } = setup([account('A', '100000')]); + const result = await service.read('user', 'A'); + expect(result.data?.return).toMatchObject({ value: '0', unavailableReason: null }); + expect(result.data?.outperformance?.money.value).toBe('-10000'); + expect(result.data?.outperformance?.percentagePoints).not.toBeNull(); + }); +}); diff --git a/src/modules/investments/performance/performance.service.ts b/src/modules/investments/performance/performance.service.ts new file mode 100644 index 0000000..5f07670 --- /dev/null +++ b/src/modules/investments/performance/performance.service.ts @@ -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>>['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 }[]; + 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 ?? [] }; + } +} diff --git a/src/modules/investments/performance/presentation/performance.controller.ts b/src/modules/investments/performance/presentation/performance.controller.ts new file mode 100644 index 0000000..6e45830 --- /dev/null +++ b/src/modules/investments/performance/presentation/performance.controller.ts @@ -0,0 +1,34 @@ +import type { Request } from 'express'; + +import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; + +import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator'; + +import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; +import { PerformanceService } from '../performance.service'; +import { PerformanceResourceDto } from './performance.dto'; + +@ApiTags('brokerage-accounts') +@ApiProblemResponses() +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('brokerage-accounts') +export class PerformanceController { + constructor(private readonly performance: PerformanceService) {} + + @Get('all/performance') + @ApiOkResponse({ type: PerformanceResourceDto }) + all(@Req() request: Request & { user: AccessPayload }) { + return this.performance.read(request.user.sub, 'all'); + } + + @Get(':accountId/performance') + @ApiOkResponse({ type: PerformanceResourceDto }) + account( + @Req() request: Request & { user: AccessPayload }, + @Param('accountId') accountId: string, + ) { + return this.performance.read(request.user.sub, accountId); + } +} diff --git a/src/modules/investments/performance/presentation/performance.dto.ts b/src/modules/investments/performance/presentation/performance.dto.ts new file mode 100644 index 0000000..84ea475 --- /dev/null +++ b/src/modules/investments/performance/presentation/performance.dto.ts @@ -0,0 +1,139 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { MoneyDto } from '#src/common/money.dto'; + +import { InvestmentDependencyDto } from '../../investment-resource.dto'; +import { PerformanceUnavailableReason } from '../domain/performance-availability'; + +const rateBasis = ['annual', 'period'] as const; +const resultCompleteness = ['complete', 'incomplete'] as const; +const indexCodes = ['MCFTRR', 'IMOEX'] as const; + +export class PerformanceReturnDto { + @ApiProperty({ type: String, nullable: true, description: 'Decimal fraction: 0.151 = 15.1%' }) + declare value: null | string; + + @ApiProperty({ enum: rateBasis }) + declare basis: (typeof rateBasis)[number]; + + @ApiProperty({ enum: PerformanceUnavailableReason, enumName: 'PerformanceUnavailableReason', nullable: true }) + declare unavailableReason: null | PerformanceUnavailableReason; +} + +export class PerformancePeriodDto { + @ApiProperty({ format: 'date' }) + declare from: string; + + @ApiProperty({ format: 'date' }) + declare to: string; + + @ApiProperty() + declare days: number; +} + +export class PerformanceIndexDto { + @ApiProperty({ enum: indexCodes }) + declare code: (typeof indexCodes)[number]; + + @ApiProperty({ type: MoneyDto, nullable: true }) + declare value: MoneyDto | null; + + @ApiProperty({ type: PerformanceReturnDto }) + declare return: PerformanceReturnDto; + + @ApiProperty({ type: String, format: 'date', nullable: true }) + declare asOf: null | string; + + @ApiProperty({ enum: PerformanceUnavailableReason, enumName: 'PerformanceUnavailableReason', nullable: true }) + declare unavailableReason: null | PerformanceUnavailableReason; +} + +export class PerformanceOutperformanceDto { + @ApiProperty({ type: String, description: 'Difference between rates as decimal fractions' }) + declare percentagePoints: string; + + @ApiProperty({ type: MoneyDto }) + declare money: MoneyDto; +} + +export class PerformanceSeriesPointDto { + @ApiProperty({ format: 'date' }) + declare date: string; + + @ApiProperty({ type: String }) + declare netInvested: string; + + @ApiProperty({ type: String }) + declare benchmarkValue: string; + + @ApiProperty({ type: String, nullable: true }) + declare referenceValue: null | string; +} + +export class PerformanceExcludedAccountDto { + @ApiProperty() + declare accountId: string; + + @ApiProperty() + declare name: string; + + @ApiProperty({ enum: PerformanceUnavailableReason, enumName: 'PerformanceUnavailableReason' }) + declare reason: PerformanceUnavailableReason; +} + +export class PerformanceDto { + @ApiProperty() + declare accountId: string; + + @ApiProperty({ type: PerformancePeriodDto }) + declare period: PerformancePeriodDto; + + @ApiProperty({ type: MoneyDto }) + declare netInvested: MoneyDto; + + @ApiProperty({ type: MoneyDto }) + declare currentValue: MoneyDto; + + @ApiProperty({ type: MoneyDto }) + declare profit: MoneyDto; + + @ApiProperty({ type: PerformanceReturnDto }) + declare return: PerformanceReturnDto; + + @ApiProperty({ type: PerformanceIndexDto }) + declare benchmark: PerformanceIndexDto; + + @ApiProperty({ type: PerformanceIndexDto }) + declare reference: PerformanceIndexDto; + + @ApiProperty({ type: PerformanceOutperformanceDto, nullable: true }) + declare outperformance: null | PerformanceOutperformanceDto; + + @ApiProperty({ type: PerformanceSeriesPointDto, isArray: true }) + declare series: PerformanceSeriesPointDto[]; + + @ApiProperty({ enum: resultCompleteness }) + declare completeness: (typeof resultCompleteness)[number]; + + @ApiProperty({ type: PerformanceExcludedAccountDto, isArray: true }) + declare excludedAccounts: PerformanceExcludedAccountDto[]; + + @ApiProperty({ format: 'date-time' }) + declare calculatedAt: string; +} + +export class PerformanceMetaDto { + @ApiProperty({ type: InvestmentDependencyDto, isArray: true }) + declare dependencies: InvestmentDependencyDto[]; + + @ApiProperty({ type: PerformanceExcludedAccountDto, isArray: true }) + declare unavailable: PerformanceExcludedAccountDto[]; +} + +export class PerformanceResourceDto { + @ApiProperty({ type: PerformanceDto, nullable: true }) + declare data: null | PerformanceDto; + + @ApiProperty({ type: PerformanceMetaDto }) + declare meta: PerformanceMetaDto; +} diff --git a/src/modules/investments/portfolio/domain/complete-valuation.spec.ts b/src/modules/investments/portfolio/domain/complete-valuation.spec.ts new file mode 100644 index 0000000..6b9e3c0 --- /dev/null +++ b/src/modules/investments/portfolio/domain/complete-valuation.spec.ts @@ -0,0 +1,15 @@ +import { isCompleteValuation } from './complete-valuation'; + +describe('isCompleteValuation', () => { + it('requires an actual currency total and every position value', () => { + expect(isCompleteValuation({ totalsByCurrency: [], positions: [] })).toBe(false); + expect(isCompleteValuation({ + totalsByCurrency: [{ currency: 'RUB', value: '100' }], + positions: [{ currentValue: null }], + })).toBe(false); + expect(isCompleteValuation({ + totalsByCurrency: [{ currency: 'RUB', value: '100' }], + positions: [{ currentValue: { currency: 'RUB', value: '0' } }], + })).toBe(true); + }); +}); diff --git a/src/modules/investments/portfolio/domain/complete-valuation.ts b/src/modules/investments/portfolio/domain/complete-valuation.ts new file mode 100644 index 0000000..5ffc610 --- /dev/null +++ b/src/modules/investments/portfolio/domain/complete-valuation.ts @@ -0,0 +1,13 @@ +type Valuation = { + positions: { currentValue: unknown }[]; + totalsByCurrency: unknown[]; +}; + +const MIN_TOTALS_COUNT = 0; + +export function isCompleteValuation( + snapshot: Valuation, +): boolean { + return snapshot.totalsByCurrency.length > MIN_TOTALS_COUNT && + snapshot.positions.every((position) => position.currentValue !== null); +} diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 14f9448..8ceba25 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -11,6 +11,8 @@ import { toJson } from '#src/prisma/to-json'; import type { PortfolioDto } from '../presentation/portfolio.dto'; +import { isCompleteValuation } from '../domain/complete-valuation'; + const EXPECTED_UPDATED_ACCOUNTS = 1; type PortfolioAttempt = { @@ -140,10 +142,7 @@ export class PortfolioRepository { }); // Value history points require a fully valued snapshot; the attempt // identity deduplicates repeated or late publications of one attempt. - if ( - snapshot.totalsByCurrency.length > Number.MIN_VALUE && - snapshot.positions.every((position) => position.currentValue !== null) - ) { + if (isCompleteValuation(snapshot)) { await tx.tbankPortfolioValueHistory.upsert({ where: { userId_accountId: { userId: attempt.userId, accountId }, diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index 6deff8a..93fde80 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -1,5 +1,6 @@ export { ForecastService } from './application/forecast.service'; export { PortfolioService } from './application/portfolio.service'; +export { isCompleteValuation } from './domain/complete-valuation'; // Public cross-module surface. Nest providers are exported by the matching module. export {