refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { type MoexBondEvent, MoexClient } from './moex.client';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { MoexClient } from './moex.client';
|
||||
|
||||
describe('MoexClient', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
securities: {
|
||||
columns: ['SECID', 'ISIN', 'BOARDID'],
|
||||
data: [['BOND', 'RU000TEST', 'TQOB']],
|
||||
},
|
||||
marketdata_yields: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'EFFECTIVEYIELD',
|
||||
'PRICE',
|
||||
'YIELDDATE',
|
||||
'YIELDDATETYPE',
|
||||
'TRADEMOMENT',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'BOND',
|
||||
'OTHER',
|
||||
99,
|
||||
20,
|
||||
'2028-12-01',
|
||||
'OFFERDATE',
|
||||
'2026-09-10 18:49:00',
|
||||
],
|
||||
[
|
||||
'BOND',
|
||||
'TQOB',
|
||||
-0.5,
|
||||
101,
|
||||
'2028-12-01',
|
||||
'OFFERDATE',
|
||||
'2026-09-10 18:49:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
const client = new MoexClient();
|
||||
expect(await client.bondYield('BOND', 'RU000TEST', 'TQOB')).toEqual({
|
||||
value: '-0.5',
|
||||
pricePercent: '101',
|
||||
date: '2028-12-01',
|
||||
dateType: 'OFFERDATE',
|
||||
tradeAt: '2026-09-10T15:49:00.000Z',
|
||||
});
|
||||
expect(await client.bondYield('BOND', 'WRONG', 'TQOB')).toBeNull();
|
||||
expect(await client.bondYield('BOND', 'RU000TEST', 'OTHER')).toBeNull();
|
||||
});
|
||||
it.each([null, 0])(
|
||||
'does not report unknown yield as zero: %s',
|
||||
async (value) => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
securities: {
|
||||
columns: ['SECID', 'ISIN', 'BOARDID'],
|
||||
data: [['BOND', 'ISIN', 'TQOB']],
|
||||
},
|
||||
marketdata_yields: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'EFFECTIVEYIELD',
|
||||
'PRICE',
|
||||
'YIELDDATE',
|
||||
'TRADEMOMENT',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'BOND',
|
||||
'TQOB',
|
||||
value,
|
||||
100,
|
||||
'2030-01-01',
|
||||
'2026-09-10 12:00:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
const result = await new MoexClient().bondYield('BOND', 'ISIN', 'TQOB');
|
||||
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
||||
},
|
||||
);
|
||||
it('treats a successful response without the requested block as no events', async () => {
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
description: { columns: ['name', 'value'], data: [] },
|
||||
boards: { columns: ['secid'], data: [] },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClient } from './moex.client';
|
||||
|
||||
@Module({
|
||||
providers: [MoexClient],
|
||||
exports: [MoexClient],
|
||||
})
|
||||
export class MoexModule {}
|
||||
Reference in New Issue
Block a user