281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|
import { constants as httpStatus } from 'node:http2';
|
|
|
|
export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
|
|
|
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
|
const FIRST_PAGE_OFFSET = 0;
|
|
const EXPECTED_CURSOR_ROWS = 1;
|
|
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;
|
|
|
|
export type MoexBondEvent =
|
|
| { currency: string; date: string; type: 'coupon'; value: string } |
|
|
{ currency: string; date: string; type: 'maturity'; value: string } |
|
|
{ date: string; type: 'offer' };
|
|
|
|
type IssBlock = { columns?: unknown; data?: unknown };
|
|
type IssResponse = Record<string, IssBlock | undefined>;
|
|
export type MoexBondYield = {
|
|
date: string;
|
|
dateType: null | string;
|
|
pricePercent: string;
|
|
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,
|
|
board: string,
|
|
): Promise<MoexBondYield | null> {
|
|
const query = new URLSearchParams({
|
|
'iss.meta': 'off',
|
|
'iss.only': 'securities,marketdata_yields',
|
|
});
|
|
const response = await this.request(
|
|
`${baseUrl}/engines/stock/markets/bonds/securities/${encodeURIComponent(secid)}.json?${query}`,
|
|
);
|
|
if (!response) return null;
|
|
const listing = parseRows(response.securities).find(
|
|
(row) =>
|
|
row.secid === secid && row.isin === isin && row.boardid === board,
|
|
);
|
|
if (!listing) return null;
|
|
const row = parseRows(response.marketdata_yields).find(
|
|
(row) => row.secid === secid && row.boardid === board,
|
|
);
|
|
if (!row) return null;
|
|
const value = numberText(row.effectiveyield);
|
|
const pricePercent = numberText(row.price);
|
|
const date = text(row.yielddate);
|
|
const tradeAt = text(row.trademoment);
|
|
if (
|
|
value === null ||
|
|
pricePercent === null ||
|
|
Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
|
|
!date ||
|
|
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
|
!Number.isFinite(Date.parse(date)) ||
|
|
!tradeAt ||
|
|
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
|
|
) {
|
|
return null;
|
|
}
|
|
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
|
|
if (!Number.isFinite(Date.parse(tradeIso))) return null;
|
|
return {
|
|
value,
|
|
pricePercent,
|
|
date,
|
|
dateType: text(row.yielddatetype),
|
|
tradeAt: new Date(tradeIso).toISOString(),
|
|
};
|
|
}
|
|
|
|
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
|
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
|
const [coupons, offers, description] = await Promise.all([
|
|
this.rows(path, 'coupons'),
|
|
this.rows(path, 'offers'),
|
|
this.rows(
|
|
`securities/${encodeURIComponent(secid)}.json`,
|
|
'description',
|
|
false,
|
|
),
|
|
]);
|
|
const events: MoexBondEvent[] = [];
|
|
for (const row of coupons) {
|
|
const date = text(row.coupondate);
|
|
const value = numberText(row.value);
|
|
// RUB-only: coupons in other currencies are filtered out at this ingest boundary.
|
|
if (date && value && currencyText(row.faceunit) === 'RUB') {
|
|
events.push({ type: 'coupon', date, value, currency: 'RUB' });
|
|
}
|
|
}
|
|
for (const row of offers) {
|
|
const date = text(row.offerdate);
|
|
if (date) events.push({ type: 'offer', date });
|
|
}
|
|
const values = new Map(
|
|
description.map((row) => [text(row.name), text(row.value)]),
|
|
);
|
|
const date = values.get('MATDATE');
|
|
const value = numberText(values.get('FACEVALUE'));
|
|
if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') {
|
|
events.push({ type: 'maturity', date, value, currency: 'RUB' });
|
|
}
|
|
return events;
|
|
}
|
|
|
|
private async rows(
|
|
path: string,
|
|
block: string,
|
|
paginated = true,
|
|
): Promise<Record<string, unknown>[]> {
|
|
const rows: Record<string, unknown>[] = [];
|
|
let start = FIRST_PAGE_OFFSET;
|
|
for (;;) {
|
|
const query = new URLSearchParams({
|
|
'iss.meta': 'off',
|
|
'iss.only': `${block},${block}.cursor`,
|
|
'start': String(start),
|
|
});
|
|
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
|
if (!response) return rows;
|
|
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
|
const current = parseRows(response[block]);
|
|
rows.push(...current);
|
|
if (!paginated) return rows;
|
|
const cursorBlock = response[`${block}.cursor`];
|
|
if (cursorBlock === undefined) {
|
|
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
|
start += FALLBACK_PAGE_SIZE;
|
|
continue;
|
|
}
|
|
const cursors = parseRows(cursorBlock);
|
|
const cursor = cursors[0];
|
|
const index = integer(cursor?.index);
|
|
const total = integer(cursor?.total);
|
|
const pageSize = integer(cursor?.pagesize);
|
|
if (
|
|
cursors.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;
|
|
}
|
|
}
|
|
|
|
private async request(url: string): Promise<IssResponse | null> {
|
|
try {
|
|
const response = await fetch(url, {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
|
|
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
|
const body: unknown = await response.json();
|
|
if (!body || typeof body !== 'object') {
|
|
throw new Error('MOEX malformed response');
|
|
}
|
|
return body as IssResponse;
|
|
} catch {
|
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
|
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
|
}
|
|
const columns = block.columns;
|
|
if (!columns.every((column) => typeof column === 'string')) {
|
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
|
}
|
|
return block.data.map((values) => {
|
|
if (!Array.isArray(values) || values.length !== columns.length) {
|
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
|
}
|
|
return Object.fromEntries(
|
|
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
|
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
|
);
|
|
});
|
|
}
|
|
|
|
function text(value: unknown): null | string {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function numberText(value: unknown): null | string {
|
|
const result = typeof value === 'number' ? String(value) : text(value);
|
|
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
|
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
|
|
}
|
|
|
|
function currencyText(value: unknown): null | string {
|
|
const result = text(value)?.toUpperCase();
|
|
return result === 'SUR' ? 'RUB' : (result ?? null);
|
|
}
|
|
|
|
function integer(value: unknown): null | number {
|
|
const result = Number(numberText(value) ?? NaN);
|
|
return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
|
|
}
|