feat(market-index): store MOEX index closes with confirmed coverage (WealthLedger/main#54)

This commit is contained in:
2026-11-08 14:20:05 +03:00
parent 84433ee207
commit 74dea05113
8 changed files with 463 additions and 0 deletions
@@ -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
);
+18
View File
@@ -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
+67
View File
@@ -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,
+62
View File
@@ -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,
@@ -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,
});
}
}
}