import { Injectable, ServiceUnavailableException } from '@nestjs/common'; const baseUrl = 'https://iss.moex.com/iss'; const timeoutMs = 10_000; export type MoexBondEvent = | { type: 'coupon'; date: string; value: string; currency: string } | { type: 'maturity'; date: string; value: string; currency: string } | { type: 'offer'; date: string }; type IssBlock = { columns?: unknown; data?: unknown }; type IssResponse = Record; @Injectable() export class MoexClient { 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 = 0; 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; const current = parseRows(response[block]); rows.push(...current); if (!paginated) return rows; const cursorBlock = response[`${block}.cursor`]; if (!cursorBlock) { if (current.length < 100) return rows; start += 100; continue; } const cursor = parseRows(cursorBlock)[0]; const index = integer(cursor?.index); const total = integer(cursor?.total); const pageSize = integer(cursor?.pagesize); if ( index === null || total === null || pageSize === null || 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 === 404) 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: 'MOEX_UNAVAILABLE' }); } } } function parseRows(block: IssBlock | undefined): Record[] { if (!block) return []; if (!Array.isArray(block.columns) || !Array.isArray(block.data)) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); const columns = block.columns; if (!columns.every((column) => typeof column === 'string')) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); return block.data.map((values) => { if (!Array.isArray(values)) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); return Object.fromEntries( columns.map((column, index) => [column.toLowerCase(), values[index]]), ); }); } function text(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function numberText(value: unknown): string | null { const result = typeof value === 'number' ? String(value) : text(value); return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null; } function currencyText(value: unknown): string | null { const result = text(value)?.toUpperCase(); return result === 'SUR' ? 'RUB' : (result ?? null); } function integer(value: unknown): number | null { const result = Number(value); return Number.isSafeInteger(result) && result >= 0 ? result : null; }