Files
backend/src/integrations/moex/moex.client.ts
T
codex-bot d37fa2c903
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m8s
refactor: publish typed problem detail codes (WealthLedger/backend#117) (#144)
2026-10-28 17:11:41 +03:00

220 lines
7.5 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 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;
}