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; 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 { 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 { 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[]> { const rows: Record[] = []; 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 { 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[] { 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; }