Compare commits
2
Commits
main
..
fc0da12032
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0da12032 | ||
|
|
74dea05113 |
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MoexIndexClose[]> {
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<MarketIndexRepository['read']>>;
|
||||
|
||||
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<IndexId, Promise<void>>();
|
||||
|
||||
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<MoexIndexClose[] | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string>([
|
||||
TbankOperationType.InpMulti, TbankOperationType.Input,
|
||||
TbankOperationType.InputAcquiring, TbankOperationType.InputSwift,
|
||||
]);
|
||||
const withdrawals = new Set<string>([
|
||||
TbankOperationType.OutMulti, TbankOperationType.Output,
|
||||
TbankOperationType.OutputAcquiring, TbankOperationType.OutputSwift,
|
||||
]);
|
||||
const securities = new Set<string>([
|
||||
TbankOperationType.InputSecurities, TbankOperationType.OutputSecurities,
|
||||
TbankOperationType.TransBsBs, TbankOperationType.TransIisBs,
|
||||
]);
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
value: null | { currency: string; value: Decimal },
|
||||
flows: PerformanceFlow[],
|
||||
missingAmountAccounts: Set<string>,
|
||||
) {
|
||||
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<string>();
|
||||
const unvaluedAccounts = new Set<string>();
|
||||
const missingAmountAccounts = new Set<string>();
|
||||
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<typeof readPerformanceOperations>): 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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, Decimal>();
|
||||
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;
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<typeof account>[]) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { add, type Decimal, decimalFromString, divide, formatDecimal, subtract } from '#src/common/decimal';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { TbankAccessService } from '#src/integrations/tbank/tbank-access.service';
|
||||
|
||||
import { moscowDate } from '../investment-calendar';
|
||||
import { MarketIndexHistoryService } from '../market-index/market-index.service';
|
||||
import { isCompleteValuation } from '../portfolio/domain/complete-valuation';
|
||||
import { hasPerformanceCoverage, PerformanceUnavailableReason } from './domain/performance-availability';
|
||||
import { candidateTransfers, type PerformanceFlow, readPerformanceOperations, sumFlows } from './domain/performance-flows';
|
||||
import { shadowIndex } from './domain/shadow-index';
|
||||
import { xirr } from './domain/xirr';
|
||||
import { PerformanceRepository } from './infrastructure/performance.repository';
|
||||
import {
|
||||
PerformanceDto, PerformanceExcludedAccountDto, PerformanceIndexDto, PerformanceResourceDto,
|
||||
PerformanceReturnDto, PerformanceSeriesPointDto,
|
||||
} from './presentation/performance.dto';
|
||||
|
||||
const RUB = 'RUB';
|
||||
const DAY_MS = 86_400_000;
|
||||
const DAYS_IN_YEAR = 365;
|
||||
const RATE_SCALE = 6;
|
||||
const MONEY_SCALE = 2;
|
||||
const ZERO = 0;
|
||||
const ONE = 1;
|
||||
const ZERO_UNITS = 0n;
|
||||
const ZERO_DECIMAL = decimalFromString('0');
|
||||
const LAST_FLOW = -1;
|
||||
const ONE_DECIMAL = decimalFromString('1');
|
||||
|
||||
type Account = NonNullable<Awaited<ReturnType<PerformanceRepository['read']>>>['accounts'][number];
|
||||
type Included = { account: Account; flows: PerformanceFlow[]; to: string; value: Decimal };
|
||||
type IncludedResult = { included: Included | null; reason: null | PerformanceUnavailableReason };
|
||||
|
||||
function money(value: Decimal) {
|
||||
return { currency: RUB, value: formatDecimal(divide(value, ONE_DECIMAL, MONEY_SCALE)) };
|
||||
}
|
||||
|
||||
function unavailable(accountId: string, name: string, reason: PerformanceUnavailableReason): PerformanceExcludedAccountDto {
|
||||
return { accountId, name, reason };
|
||||
}
|
||||
|
||||
function duration(from: string, to: string) {
|
||||
return (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS;
|
||||
}
|
||||
|
||||
// Float rate → exact decimal string, rounded by the shared money helper.
|
||||
function roundRate(value: number): string {
|
||||
return formatDecimal(divide(decimalFromString(value.toFixed(RATE_SCALE + ONE)), ONE_DECIMAL, RATE_SCALE));
|
||||
}
|
||||
|
||||
function rate(flows: PerformanceFlow[], endValue: Decimal, to: string, days: number): PerformanceReturnDto {
|
||||
const basis = days >= DAYS_IN_YEAR ? 'annual' : 'period';
|
||||
const result = xirr([
|
||||
...flows.map((flow) => ({ date: flow.date, amount: { value: -flow.amount.value, scale: flow.amount.scale } })),
|
||||
{ date: to, amount: endValue },
|
||||
]);
|
||||
if (result.value === null) {
|
||||
return { value: null, basis, unavailableReason: result.reason === 'AMBIGUOUS_RETURN'
|
||||
? PerformanceUnavailableReason.AmbiguousReturn
|
||||
: PerformanceUnavailableReason.NoSolution };
|
||||
}
|
||||
const raw = Number(result.value);
|
||||
const value = basis === 'annual' ? raw : Math.pow(ONE + raw, days / DAYS_IN_YEAR) - ONE;
|
||||
return { value: roundRate(value), basis, unavailableReason: null };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceService {
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly repository: PerformanceRepository,
|
||||
private readonly indexes: MarketIndexHistoryService,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async read(userId: string, accountId: string): Promise<PerformanceResourceDto> {
|
||||
const started = Date.now();
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
const saved = await this.repository.read(userId, connection.revision, accountId);
|
||||
if (accountId !== 'all' && saved?.membershipAt && !saved.accounts.length) throw new NotFoundException();
|
||||
const accounts = saved?.membershipAt ? saved.accounts : [];
|
||||
const excluded: PerformanceExcludedAccountDto[] = [];
|
||||
let included = accounts.flatMap((account) => {
|
||||
const result = this.includeAccount(account);
|
||||
if (result.reason) {
|
||||
excluded.push(unavailable(account.accountId, account.name, result.reason));
|
||||
return [];
|
||||
}
|
||||
return result.included ? [result.included] : [];
|
||||
});
|
||||
if (accountId === 'all' && included.length > ZERO) {
|
||||
const allOps = included.flatMap((item) => item.account.operations
|
||||
.filter((op) => item.account.portfolio && op.occurredAt <= item.account.portfolio.fetchedAt)
|
||||
.map((op) => ({ ...op, accountId: item.account.accountId })));
|
||||
const candidates = candidateTransfers(readPerformanceOperations(allOps));
|
||||
const affected = new Set(candidates.flatMap((candidate) => candidate.accountIds));
|
||||
if (affected.size) {
|
||||
included = included.filter((item) => {
|
||||
if (!affected.has(item.account.accountId)) return true;
|
||||
excluded.push(unavailable(item.account.accountId, item.account.name, PerformanceUnavailableReason.UnconfirmedTransfer));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!included.length) return { data: null, meta: { dependencies: [], unavailable: excluded } };
|
||||
const dates = new Set(included.map((item) => item.to));
|
||||
if (dates.size > ONE) {
|
||||
return {
|
||||
data: null,
|
||||
meta: { dependencies: [], unavailable: [
|
||||
...excluded,
|
||||
...included.map((item) => unavailable(item.account.accountId, item.account.name,
|
||||
PerformanceUnavailableReason.AccountValuationDatesDiffer)),
|
||||
] },
|
||||
};
|
||||
}
|
||||
const [to] = dates;
|
||||
const flows = included.flatMap((item) => item.flows).sort((a, b) => a.at.valueOf() - b.at.valueOf());
|
||||
// The period starts when money was first put in (owner decision), not at account opening.
|
||||
// eslint-disable-next-line security/detect-object-injection -- Fixed first element of a locally sorted array.
|
||||
const from = flows[ZERO]?.date ?? to;
|
||||
const days = duration(from, to);
|
||||
const invested = sumFlows(flows);
|
||||
const current = included.reduce((total, item) => add(total, item.value), ZERO_DECIMAL);
|
||||
const profit = subtract(current, invested);
|
||||
const ownReturn = rate(flows, current, to, days);
|
||||
const [benchmark, reference] = await Promise.all([
|
||||
this.indexResult('MCFTRR', flows, from, to, days),
|
||||
this.indexResult('IMOEX', flows, from, to, days),
|
||||
]);
|
||||
const outperformance = ownReturn.value !== null && benchmark.index.return.value !== null && benchmark.index.value
|
||||
? {
|
||||
percentagePoints: formatDecimal(subtract(decimalFromString(ownReturn.value),
|
||||
decimalFromString(benchmark.index.return.value))),
|
||||
money: money(subtract(current, decimalFromString(benchmark.index.value.value))),
|
||||
}
|
||||
: null;
|
||||
const referenceByDay = new Map(reference.points.map((point) => [point.date, point.value]));
|
||||
const series: PerformanceSeriesPointDto[] = benchmark.points.map((point) => ({
|
||||
date: point.date,
|
||||
netInvested: point.netInvested,
|
||||
benchmarkValue: point.value,
|
||||
referenceValue: referenceByDay.get(point.date) ?? null,
|
||||
}));
|
||||
const data: PerformanceDto = {
|
||||
accountId, period: { from, to, days }, netInvested: money(invested), currentValue: money(current),
|
||||
profit: money(profit), return: ownReturn, benchmark: benchmark.index, reference: reference.index,
|
||||
outperformance, series, completeness: excluded.length ? 'incomplete' : 'complete',
|
||||
excludedAccounts: excluded, calculatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.logger.event('debug', 'performance.calculated', {
|
||||
count: included.length, durationMs: Date.now() - started,
|
||||
});
|
||||
return { data, meta: { dependencies: [], unavailable: [] } };
|
||||
}
|
||||
|
||||
private includeAccount(account: Account): IncludedResult {
|
||||
const fail = (reason: PerformanceUnavailableReason): IncludedResult => ({ included: null, reason });
|
||||
if (account.historyBoundary !== 'known' || !account.historyFrom) {
|
||||
return fail(PerformanceUnavailableReason.HistoryStartUnknown);
|
||||
}
|
||||
const portfolio = account.portfolio;
|
||||
if (!portfolio) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
|
||||
const snapshot = portfolio.payload as { positions: { currentValue: unknown }[];
|
||||
totalsByCurrency: { currency: string; value: string }[]; };
|
||||
if (!isCompleteValuation(snapshot)) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
|
||||
if (!hasPerformanceCoverage(account.accountId, account.historyFrom, portfolio.fetchedAt, account.coverage)) {
|
||||
return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
|
||||
}
|
||||
const operations = account.operations.filter((op) => op.occurredAt <= portfolio.fetchedAt)
|
||||
.map((op) => ({ ...op, accountId: account.accountId }));
|
||||
const parsed = readPerformanceOperations(operations);
|
||||
if (parsed.nonRubAccounts.size || snapshot.totalsByCurrency.some((row) =>
|
||||
row.currency !== RUB && decimalFromString(row.value).value !== ZERO_UNITS)) {
|
||||
return fail(PerformanceUnavailableReason.NonRubActivity);
|
||||
}
|
||||
if (parsed.missingAmountAccounts.size) return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
|
||||
if (parsed.unvaluedAccounts.size) return fail(PerformanceUnavailableReason.UnvaluedSecuritiesTransfer);
|
||||
const value = snapshot.totalsByCurrency.find((row) => row.currency === RUB);
|
||||
if (!value) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
|
||||
return {
|
||||
included: { account, flows: parsed.flows, value: decimalFromString(value.value),
|
||||
to: moscowDate(portfolio.fetchedAt) },
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async indexResult(code: 'IMOEX' | 'MCFTRR', flows: PerformanceFlow[], from: string, to: string, days: number) {
|
||||
const closes = await this.indexes.read(code, from, to, flows.at(LAST_FLOW)?.date ?? from);
|
||||
const shadow = closes && shadowIndex(flows, closes, from, to);
|
||||
const unavailableReason = shadow ? null : PerformanceUnavailableReason.IndexHistoryUnavailable;
|
||||
const result = shadow && rate(flows, decimalFromString(shadow.value), to, days);
|
||||
const index: PerformanceIndexDto = {
|
||||
code, value: shadow ? money(decimalFromString(shadow.value)) : null,
|
||||
return: result || { basis: days >= DAYS_IN_YEAR ? 'annual' : 'period', value: null, unavailableReason },
|
||||
asOf: shadow?.asOf ?? null, unavailableReason,
|
||||
};
|
||||
return { index, points: shadow?.points ?? [] };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user