Files
backend/src/integrations/moex/moex.client.ts
T
ksv741 33592c1d5f refactor: enforce enum machine codes
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 16:37:01 +03:00

220 lines
7.5 KiB
TypeScript

import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2';
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 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;
};
@Injectable()
export class MoexClient {
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);
const currency = currencyText(row.faceunit);
if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency });
}
}
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'));
const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency });
}
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;
}