From 74dea0511346b38a9a88488d3fe63132d0a74917 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 8 Nov 2026 14:20:05 +0300 Subject: [PATCH 1/2] feat(market-index): store MOEX index closes with confirmed coverage (WealthLedger/main#54) --- .../migration.sql | 15 +++ prisma/schema.prisma | 18 +++ src/integrations/moex/moex.client.spec.ts | 67 ++++++++++ src/integrations/moex/moex.client.ts | 62 +++++++++ .../market-index/market-index.module.ts | 13 ++ .../market-index/market-index.repository.ts | 52 ++++++++ .../market-index/market-index.service.spec.ts | 121 ++++++++++++++++++ .../market-index/market-index.service.ts | 115 +++++++++++++++++ 8 files changed, 463 insertions(+) create mode 100644 prisma/migrations/20261107214500_add_market_index_history/migration.sql create mode 100644 src/modules/investments/market-index/market-index.module.ts create mode 100644 src/modules/investments/market-index/market-index.repository.ts create mode 100644 src/modules/investments/market-index/market-index.service.spec.ts create mode 100644 src/modules/investments/market-index/market-index.service.ts diff --git a/prisma/migrations/20261107214500_add_market_index_history/migration.sql b/prisma/migrations/20261107214500_add_market_index_history/migration.sql new file mode 100644 index 0000000..daa1001 --- /dev/null +++ b/prisma/migrations/20261107214500_add_market_index_history/migration.sql @@ -0,0 +1,15 @@ +CREATE TABLE "MarketIndexDailyClose" ( + "secid" TEXT NOT NULL, + "tradeDate" TEXT NOT NULL, + "close" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "MarketIndexDailyClose_pkey" PRIMARY KEY ("secid","tradeDate") +); + +CREATE TABLE "MarketIndexSyncState" ( + "secid" TEXT NOT NULL PRIMARY KEY, + "lastAttemptAt" DATETIME, + "lastSuccessAt" DATETIME, + "coveredFrom" TEXT, + "coveredTo" TEXT +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4bcbf80..7fc478e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,6 +8,24 @@ datasource db { provider = "sqlite" } +// Public MOEX index closes contain no user holdings or credentials. +model MarketIndexDailyClose { + secid String + tradeDate String + close String + createdAt DateTime @default(now()) + + @@id([secid, tradeDate]) +} + +model MarketIndexSyncState { + secid String @id + lastAttemptAt DateTime? + lastSuccessAt DateTime? + coveredFrom String? + coveredTo String? +} + model User { id String @id email String @unique diff --git a/src/integrations/moex/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts index 6da226b..e9a6dd1 100644 --- a/src/integrations/moex/moex.client.spec.ts +++ b/src/integrations/moex/moex.client.spec.ts @@ -8,6 +8,73 @@ const emptyEvents = { describe('MoexClient', () => { afterEach(() => jest.restoreAllMocks()); + it('loads all pages of index closes without dropping a page', async () => { + const fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => { + const start = Number(new URL(input as string).searchParams.get('start')); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ + 'history': { + columns: ['TRADEDATE', 'CLOSE'], + data: start === 0 ? [['2026-10-01', 5752.22]] : [['2026-10-02', 5745.61]], + }, + 'history.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[start, 2, 1]], + }, + }), + } as Response); + }); + expect(await new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02')).toEqual([ + { tradeDate: '2026-10-01', close: '5752.22' }, + { tradeDate: '2026-10-02', close: '5745.61' }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects a later malformed page instead of returning partial index history', async () => { + jest.spyOn(global, 'fetch').mockImplementation((input) => { + const start = Number(new URL(input as string).searchParams.get('start')); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ + 'history': { + columns: ['TRADEDATE', 'CLOSE'], + data: [[start ? '2026-10-02' : '2026-10-01', start ? null : 100]], + }, + 'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[start, 2, 1]] }, + }), + } as Response); + }); + await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02')) + .rejects.toThrow(); + }); + + it('rejects an unsuccessful index history response', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 503 } as Response); + await expect(new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02')) + .rejects.toThrow(); + }); + + it('rejects a timed-out index history request', async () => { + jest.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Timed out', 'TimeoutError')); + await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02')) + .rejects.toThrow(); + }); + + it('rejects duplicate or out-of-order trading dates', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, status: 200, + json: () => Promise.resolve({ + 'history': { columns: ['TRADEDATE', 'CLOSE'], data: [['2026-10-02', 100], ['2026-10-01', 101]] }, + 'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 2, 100]] }, + }), + } as Response); + await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02')) + .rejects.toThrow(); + }); it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index df037e0..742285d 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -10,6 +10,13 @@ const EMPTY_PAGE_SIZE = 0; const MIN_CURSOR_VALUE = 0; const FALLBACK_PAGE_SIZE = 100; +const INDEX_COLUMNS = ['TRADEDATE', 'CLOSE']; +const ISO_DATE_LENGTH = 10; +const ZERO_CLOSE = 0; +const ISO_DATE_START = 0; +const LAST_HISTORY_ROW = -1; + +const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/; const baseUrl = 'https://iss.moex.com/iss'; const timeoutMs = 10_000; @@ -28,9 +35,64 @@ export type MoexBondYield = { tradeAt: string; value: string; }; +export type MoexIndexClose = { close: string; tradeDate: string }; @Injectable() export class MoexClient { + async indexHistory( + secid: 'IMOEX' | 'MCFTRR', + from: string, + till: string, + ): Promise { + const path = `history/engines/stock/markets/index/securities/${secid}.json`; + const rows: MoexIndexClose[] = []; + let start = FIRST_PAGE_OFFSET; + for (;;) { + const query = new URLSearchParams({ + from, + till, + 'start': String(start), + 'iss.meta': 'off', + 'history.columns': 'TRADEDATE,CLOSE', + }); + const response = await this.request(`${baseUrl}/${path}?${query}`); + if (!response) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); + const block = response.history; + if ( + !block || !Array.isArray(block.columns) || + block.columns.length !== INDEX_COLUMNS.length || + // eslint-disable-next-line security/detect-object-injection -- Offset comes from a fixed-length validated columns array. + block.columns.some((column, offset) => column !== INDEX_COLUMNS[offset]) + ) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); + const current = parseRows(block); + let previousDate = rows.at(LAST_HISTORY_ROW)?.tradeDate; + for (const row of current) { + const tradeDate = text(row.tradedate); + const close = numberText(row.close); + if ( + !tradeDate || !isoDayPattern.test(tradeDate) || + Number.isNaN(Date.parse(`${tradeDate}T00:00:00Z`)) || + new Date(`${tradeDate}T00:00:00Z`).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== tradeDate || + !close || Number(close) <= ZERO_CLOSE || + (previousDate !== undefined && tradeDate <= previousDate) + ) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); + rows.push({ tradeDate, close }); + previousDate = tradeDate; + } + const cursor = parseRows(response['history.cursor']); + const index = integer(cursor[0]?.index); + const total = integer(cursor[0]?.total); + const pageSize = integer(cursor[0]?.pagesize); + if ( + cursor.length !== EXPECTED_CURSOR_ROWS || index !== start || + total === null || pageSize === null || pageSize === EMPTY_PAGE_SIZE || + current.length !== Math.min(pageSize, total - start) + ) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); + if (index + pageSize >= total) return rows; + start = index + pageSize; + } + } + async bondYield( secid: string, isin: string, diff --git a/src/modules/investments/market-index/market-index.module.ts b/src/modules/investments/market-index/market-index.module.ts new file mode 100644 index 0000000..b927ec0 --- /dev/null +++ b/src/modules/investments/market-index/market-index.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { MoexModule } from '#src/integrations/moex/moex.module'; + +import { MarketIndexRepository } from './market-index.repository'; +import { MarketIndexHistoryService } from './market-index.service'; + +@Module({ + imports: [MoexModule], + providers: [MarketIndexHistoryService, MarketIndexRepository], + exports: [MarketIndexHistoryService], +}) +export class MarketIndexModule {} diff --git a/src/modules/investments/market-index/market-index.repository.ts b/src/modules/investments/market-index/market-index.repository.ts new file mode 100644 index 0000000..62e3088 --- /dev/null +++ b/src/modules/investments/market-index/market-index.repository.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; + +import type { MoexIndexClose } from '#src/integrations/moex/moex.client'; + +import { PrismaService } from '#src/prisma/prisma.service'; + +@Injectable() +export class MarketIndexRepository { + constructor(private readonly prisma: PrismaService) {} + + async read(secid: 'IMOEX' | 'MCFTRR', from: string, to: string) { + const [closes, state] = await Promise.all([ + this.prisma.marketIndexDailyClose.findMany({ + where: { secid, tradeDate: { gte: from, lte: to } }, + orderBy: { tradeDate: 'asc' }, + }), + this.prisma.marketIndexSyncState.findUnique({ where: { secid } }), + ]); + return { closes, state }; + } + + async attempt(secid: 'IMOEX' | 'MCFTRR', at: Date) { + await this.prisma.marketIndexSyncState.upsert({ + where: { secid }, create: { secid, lastAttemptAt: at }, update: { lastAttemptAt: at }, + }); + } + + // coveredTo is the `till` MOEX answered for: a close for today that is not + // published yet is re-checked by the service while the last close < to. + async save(secid: 'IMOEX' | 'MCFTRR', closes: MoexIndexClose[], from: string, to: string, at: Date) { + await this.prisma.$transaction(async (tx) => { + for (const close of closes) { + await tx.marketIndexDailyClose.upsert({ + where: { secid_tradeDate: { secid, tradeDate: close.tradeDate } }, + create: { secid, ...close }, + update: { close: close.close }, + }); + } + const state = await tx.marketIndexSyncState.findUniqueOrThrow({ where: { secid } }); + const joined = Boolean(state.coveredFrom && state.coveredTo && + from <= state.coveredTo && to >= state.coveredFrom); + await tx.marketIndexSyncState.update({ + where: { secid }, + data: { + lastSuccessAt: at, + coveredFrom: joined && state.coveredFrom && state.coveredFrom < from ? state.coveredFrom : from, + coveredTo: joined && state.coveredTo && state.coveredTo > to ? state.coveredTo : to, + }, + }); + }); + } +} diff --git a/src/modules/investments/market-index/market-index.service.spec.ts b/src/modules/investments/market-index/market-index.service.spec.ts new file mode 100644 index 0000000..6bed5c6 --- /dev/null +++ b/src/modules/investments/market-index/market-index.service.spec.ts @@ -0,0 +1,121 @@ +import { MarketIndexHistoryService } from './market-index.service'; + +jest.mock('./market-index.repository', () => ({ MarketIndexRepository: class {} })); + +type State = { + coveredFrom: null | string; + coveredTo: null | string; + lastAttemptAt: Date | null; + lastSuccessAt: Date | null; +}; +type Close = { close: string; tradeDate: string }; + +const HOUR_MS = 3_600_000; + +// In-memory stand-in mirroring the repository's merge rules. +function setup(history: Close[]) { + let state: null | State = null; + const stored = new Map(); + const repository = { + read: jest.fn((_: string, from: string, to: string) => Promise.resolve({ + closes: [...stored.entries()] + .filter(([date]) => date >= from && date <= to) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([tradeDate, close]) => ({ tradeDate, close })), + state: state && { ...state }, + })), + attempt: jest.fn((_: string, at: Date) => { + state = { coveredFrom: null, coveredTo: null, lastSuccessAt: null, ...state, lastAttemptAt: at }; + return Promise.resolve(); + }), + save: jest.fn((_: string, closes: Close[], from: string, to: string, at: Date) => { + for (const close of closes) stored.set(close.tradeDate, close.close); + const current = state as State; + const joined = Boolean(current.coveredFrom && current.coveredTo && + from <= current.coveredTo && to >= current.coveredFrom); + state = { + ...current, + lastSuccessAt: at, + coveredFrom: joined && current.coveredFrom && current.coveredFrom < from ? current.coveredFrom : from, + coveredTo: joined && current.coveredTo && current.coveredTo > to ? current.coveredTo : to, + }; + return Promise.resolve(); + }), + }; + const moex = { + indexHistory: jest.fn((_: string, from: string, till: string) => Promise.resolve( + history.filter((row) => row.tradeDate >= from && row.tradeDate <= till), + )), + }; + const service = new MarketIndexHistoryService( + repository as never, moex as never, { event: jest.fn() } as never, + ); + return { service, moex, setHistory: (rows: Close[]) => history.splice(0, history.length, ...rows) }; +} + +const closes = (...dates: string[]) => dates.map((tradeDate) => ({ tradeDate, close: '100' })); + +describe('MarketIndexHistoryService', () => { + afterEach(() => jest.useRealTimers()); + + it('loads a ten-day lookback once and reuses the confirmed range', async () => { + const { service, moex } = setup(closes('2026-09-30', '2026-10-02', '2026-10-05')); + expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3); + expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3); + expect(moex.indexHistory).toHaveBeenCalledTimes(1); + expect(moex.indexHistory).toHaveBeenCalledWith('MCFTRR', '2026-09-22', '2026-10-05'); + }); + + it('extends history backward at once, even right after a successful load', async () => { + const { service, moex } = setup(closes('2023-01-03', '2026-09-30', '2026-10-05')); + await service.read('MCFTRR', '2026-10-01', '2026-10-05'); + expect(await service.read('MCFTRR', '2023-01-05', '2026-10-05')).toHaveLength(3); + expect(moex.indexHistory).toHaveBeenLastCalledWith('MCFTRR', '2022-12-26', '2026-10-05'); + }); + + it('re-checks a missing tail close after an hour, but not sooner', async () => { + jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') }); + const { service, moex, setHistory } = setup(closes('2026-09-30', '2026-10-02')); + await service.read('MCFTRR', '2026-10-02', '2026-10-05'); + setHistory(closes('2026-09-30', '2026-10-02', '2026-10-05')); + await service.read('MCFTRR', '2026-10-02', '2026-10-05'); + expect(moex.indexHistory).toHaveBeenCalledTimes(1); + jest.setSystemTime(Date.now() + HOUR_MS + 1); + const rows = await service.read('MCFTRR', '2026-10-02', '2026-10-05'); + expect(rows?.at(-1)?.tradeDate).toBe('2026-10-05'); + expect(moex.indexHistory).toHaveBeenCalledTimes(2); + }); + + it('keeps saved history usable when a later refresh fails and throttles retries', async () => { + jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') }); + const { service, moex } = setup(closes('2026-09-30', '2026-10-02')); + await service.read('MCFTRR', '2026-10-02', '2026-10-02'); + jest.setSystemTime(Date.now() + 1000); + moex.indexHistory.mockRejectedValue(new Error('MOEX down')); + expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2); + expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2); + expect(moex.indexHistory).toHaveBeenCalledTimes(2); + }); + + it('returns no history when nothing was ever loaded and MOEX fails', async () => { + const { service, moex } = setup([]); + moex.indexHistory.mockRejectedValue(new Error('MOEX down')); + expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toBeNull(); + }); + + it('refuses a range that does not reach the last cash flow', async () => { + const { service, moex } = setup(closes('2026-09-30', '2026-10-02')); + await service.read('MCFTRR', '2026-10-02', '2026-10-02'); + moex.indexHistory.mockRejectedValue(new Error('MOEX down')); + expect(await service.read('MCFTRR', '2026-10-02', '2026-10-09', '2026-10-08')).toBeNull(); + }); + + it('shares one request for parallel reads', async () => { + const { service, moex } = setup(closes('2026-09-30', '2026-10-02')); + await Promise.all([ + service.read('IMOEX', '2026-10-02', '2026-10-05'), + service.read('IMOEX', '2026-10-02', '2026-10-05'), + ]); + expect(moex.indexHistory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/investments/market-index/market-index.service.ts b/src/modules/investments/market-index/market-index.service.ts new file mode 100644 index 0000000..190dd0f --- /dev/null +++ b/src/modules/investments/market-index/market-index.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; + +import { StructuredLogger } from '#src/common/logging/structured-logger.service'; +import { MoexClient, type MoexIndexClose } from '#src/integrations/moex/moex.client'; + +import { MarketIndexRepository } from './market-index.repository'; + +const HOUR_MS = 3_600_000; +const LOOKBACK_DAYS = 10; +const ISO_DATE_LENGTH = 10; +const ISO_DATE_START = 0; +const LAST_CLOSE = -1; +const MAX_REFRESH_ROUNDS = 2; +const FIRST_ROUND = 0; + +type IndexId = 'IMOEX' | 'MCFTRR'; +type Saved = Awaited>; + +function shiftDays(date: string, days: number): string { + const value = new Date(`${date}T00:00:00Z`); + value.setUTCDate(value.getUTCDate() + days); + return value.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); +} + +function within(at: Date | null | undefined, ms: number) { + return Boolean(at && Date.now() - at.valueOf() < ms); +} + +@Injectable() +export class MarketIndexHistoryService { + private readonly pending = new Map>(); + + constructor( + private readonly repository: MarketIndexRepository, + private readonly moex: MoexClient, + private readonly logger: StructuredLogger, + ) {} + + /** + * Closes covering [from − 10 days, to]. `requiredThrough` is the latest date + * that must have a close on or after it (the last cash flow); null when the + * confirmed history does not cover the period. + */ + async read( + secid: IndexId, + from: string, + to: string, + requiredThrough = from, + ): Promise { + const rangeFrom = shiftDays(from, -LOOKBACK_DAYS); + let saved = await this.repository.read(secid, rangeFrom, to); + for (let round = FIRST_ROUND; round < MAX_REFRESH_ROUNDS; round++) { + const range = this.refreshRange(saved, rangeFrom, to); + if (!range) break; + const job = this.pending.get(secid) ?? this.start(secid, range.from, to); + await job; + saved = await this.repository.read(secid, rangeFrom, to); + } + return this.covered(saved, from, rangeFrom, to, requiredThrough) + ? saved.closes.map(({ tradeDate, close }) => ({ tradeDate, close })) + : null; + } + + // coveredFrom..coveredTo is the range MOEX was successfully asked about. + // Extending it backward is fetched at once; an unpublished tail close is + // re-checked at most hourly; a failed attempt is retried at most hourly. + private refreshRange(saved: Saved, rangeFrom: string, to: string) { + const state = saved.state; + const lastAttempt = state?.lastAttemptAt ?? null; + const lastSuccess = state?.lastSuccessAt ?? null; + // Success is stamped with its own attempt time, so equal means success. + const failed = lastAttempt !== null && (lastSuccess === null || lastSuccess.valueOf() !== lastAttempt.valueOf()); + if (failed && within(lastAttempt, HOUR_MS)) return null; + if (!state?.coveredFrom || !state.coveredTo || state.coveredFrom > rangeFrom) return { from: rangeFrom }; + if (state.coveredTo < to) return { from: state.coveredTo }; + const last = saved.closes.at(LAST_CLOSE)?.tradeDate; + if ((!last || last < to) && !within(lastSuccess, HOUR_MS)) return { from: last ?? rangeFrom }; + return null; + } + + private covered(saved: Saved, from: string, rangeFrom: string, to: string, requiredThrough: string) { + const last = saved.closes.at(LAST_CLOSE)?.tradeDate; + return Boolean( + saved.state?.coveredFrom && saved.state.coveredFrom <= rangeFrom && + saved.closes.some((row) => row.tradeDate <= from) && + last && last >= shiftDays(to, -LOOKBACK_DAYS) && + // The last cash flow must lie inside the checked range, otherwise it + // would be priced by a stale close. + saved.state.coveredTo && saved.state.coveredTo >= requiredThrough, + ); + } + + private start(secid: IndexId, from: string, to: string): Promise { + const job = this.refresh(secid, from, to).finally(() => this.pending.delete(secid)); + this.pending.set(secid, job); + return job; + } + + private async refresh(secid: IndexId, from: string, to: string): Promise { + const at = new Date(); + const started = Date.now(); + await this.repository.attempt(secid, at); + try { + const closes = await this.moex.indexHistory(secid, from, to); + await this.repository.save(secid, closes, from, to, at); + this.logger.event('info', 'integration.moex.index_history.succeeded', { + operation: secid, count: closes.length, durationMs: Date.now() - started, + }); + } catch { + this.logger.event('warn', 'integration.moex.index_history.failed', { + operation: secid, durationMs: Date.now() - started, + }); + } + } +} -- 2.54.0 From 42b1d60fbab42dc9c9f637ab065088225ebb3580 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 8 Nov 2026 14:20:05 +0300 Subject: [PATCH 2/2] 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 { -- 2.54.0