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
+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,