feat: add агегку events

This commit is contained in:
2026-08-14 15:46:26 +03:00
parent 326fb67def
commit 34c2b79e27
10 changed files with 635 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000;
export type MoexDividend = {
date: string;
value: string;
currency: string;
};
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 dividends(secid: string): Promise<MoexDividend[]> {
const rows = await this.rows(
`securities/${encodeURIComponent(secid)}/dividends.json`,
'dividends',
);
return rows.flatMap((row) => {
const date = text(row.registryclosedate);
const value = numberText(row.value);
const currency = currencyText(row.currencyid);
return date && value && currency ? [{ date, value, currency }] : [];
});
}
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 (!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;
}