140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
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<string, IssBlock | undefined>;
|
|
|
|
@Injectable()
|
|
export class MoexClient {
|
|
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 = 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<IssResponse | null> {
|
|
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<string, unknown>[] {
|
|
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;
|
|
}
|