refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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>;
|
||||
export type MoexBondYield = {
|
||||
value: string;
|
||||
pricePercent: string;
|
||||
date: string;
|
||||
dateType: string | null;
|
||||
tradeAt: 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) <= 0 ||
|
||||
!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 = 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;
|
||||
}
|
||||
Reference in New Issue
Block a user