feat(market-index): store MOEX index closes with confirmed coverage (WealthLedger/main#54)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user