Three optimizations: 1. Merge getBondData + getBondMarketData into single batch call (same endpoint, parse both tables) 2. Remove redundant getSecurityDescription for shortName (shortName already in market data responses) 3. Batch by market: 1 call for all shares, 1 call for all bonds (instead of N individual calls) Before: 298 API calls for 104 positions -> ~29.8s After: 2 API calls for 104 positions -> ~0.3s
415 lines
17 KiB
TypeScript
415 lines
17 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import axios, { AxiosInstance } from 'axios';
|
|
import PQueue from 'p-queue';
|
|
import {
|
|
MoexSecurityDescription,
|
|
MoexShareMarketData,
|
|
MoexBondData,
|
|
MoexBondMarketData,
|
|
MoexBondPositionData,
|
|
MoexDividend,
|
|
MoexCandle,
|
|
MoexHistoryEntry,
|
|
MoexBondHistoryEntry,
|
|
} from './moex-client.types';
|
|
|
|
@Injectable()
|
|
export class MoexClientService {
|
|
private readonly logger = new Logger(MoexClientService.name);
|
|
private readonly client: AxiosInstance;
|
|
private readonly queue: PQueue;
|
|
private circuitOpen = false;
|
|
private circuitErrorCount = 0;
|
|
private readonly threshold: number;
|
|
private readonly resetMs: number;
|
|
|
|
constructor(private configService: ConfigService) {
|
|
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
|
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
|
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
|
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
|
|
|
this.client = axios.create({
|
|
baseURL: baseUrl,
|
|
timeout: 10000,
|
|
paramsSerializer: { indexes: null },
|
|
});
|
|
|
|
this.queue = new PQueue({
|
|
interval: 1000,
|
|
intervalCap: rateLimit,
|
|
});
|
|
}
|
|
|
|
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
if (this.circuitOpen) {
|
|
throw new Error('Circuit breaker is open — MOEX requests paused');
|
|
}
|
|
|
|
return this.queue.add(async () => {
|
|
try {
|
|
const jsonPath = path + '.json';
|
|
const response = await this.client.get(jsonPath, {
|
|
params: { ...params, 'iss.meta': 'off' },
|
|
});
|
|
this.circuitErrorCount = 0;
|
|
return response.data as T;
|
|
} catch (error) {
|
|
this.circuitErrorCount++;
|
|
if (this.circuitErrorCount >= this.threshold) {
|
|
this.circuitOpen = true;
|
|
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
|
setTimeout(() => {
|
|
this.circuitOpen = false;
|
|
this.circuitErrorCount = 0;
|
|
this.logger.log('Circuit breaker reset');
|
|
}, this.resetMs);
|
|
}
|
|
throw error;
|
|
}
|
|
}) as Promise<T>;
|
|
}
|
|
|
|
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
|
const table = data[name] as Record<string, unknown> | undefined;
|
|
if (!table || !table.columns || !table.data) return [];
|
|
const columns = table.columns as string[];
|
|
const rows = table.data as unknown[][];
|
|
return rows.map((row) => {
|
|
const obj: Record<string, unknown> = {};
|
|
columns.forEach((col, i) => {
|
|
obj[col] = row[i];
|
|
});
|
|
return obj;
|
|
});
|
|
}
|
|
|
|
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
|
const data = await this.request<Record<string, unknown>>('/securities', {
|
|
q: query,
|
|
});
|
|
return this.extractTable(data, 'securities').map((s) => ({
|
|
secid: s.secid as string,
|
|
isin: s.isin as string,
|
|
name: s.name as string,
|
|
shortName: s.shortName as string,
|
|
latName: (s.latName as string) || null,
|
|
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
|
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
|
faceValue: parseFloat(s.facevalue as string) || 0,
|
|
faceUnit: (s.faceunit as string) || '',
|
|
issueDate: (s.issuedate as string) || '',
|
|
typeName: (s.typename as string) || '',
|
|
group: (s.group as string) || '',
|
|
type: (s.type as string) || '',
|
|
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
|
morningSession: (s.morningsession as string) === '1',
|
|
eveningSession: (s.eveningsession as string) === '1',
|
|
}));
|
|
}
|
|
|
|
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
|
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
|
|
const rows = this.extractTable(data, 'description');
|
|
if (rows.length === 0) return null;
|
|
const map = new Map(rows.map((r) => [r.name, r.value]));
|
|
return {
|
|
secid,
|
|
isin: (map.get('ISIN') as string) || '',
|
|
name: (map.get('NAME') as string) || '',
|
|
shortName: (map.get('SHORTNAME') as string) || '',
|
|
latName: (map.get('LATNAME') as string) || null,
|
|
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
|
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
|
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
|
faceUnit: (map.get('FACEUNIT') as string) || '',
|
|
issueDate: (map.get('ISSUEDATE') as string) || '',
|
|
typeName: (map.get('TYPENAME') as string) || '',
|
|
group: (map.get('GROUP') as string) || '',
|
|
type: (map.get('TYPE') as string) || '',
|
|
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
|
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
|
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
|
};
|
|
}
|
|
|
|
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/stock/markets/shares/securities/${secid}`,
|
|
{ boards: boardId },
|
|
);
|
|
const rows = this.extractTable(data, 'securities');
|
|
const share = rows.find((r) => r.BOARDID === boardId);
|
|
if (!share) return null;
|
|
|
|
const mktRows = this.extractTable(data, 'marketdata');
|
|
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
|
|
|
return {
|
|
secid,
|
|
boardid: boardId,
|
|
shortName: (share?.SHORTNAME as string) || '',
|
|
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
|
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
|
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
|
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
|
last: mkt
|
|
? parseFloat((mkt.LAST as string) || '')
|
|
: parseFloat((share.PREVPRICE as string) || ''),
|
|
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
|
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
|
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
|
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
|
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
|
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
|
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
|
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
|
updateTime: (mkt?.UPDATETIME as string) || '',
|
|
};
|
|
}
|
|
|
|
async getShareMarketDataBatch(
|
|
secids: string[],
|
|
boardId = 'TQBR',
|
|
): Promise<MoexShareMarketData[]> {
|
|
if (secids.length === 0) return [];
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/stock/markets/shares/securities`,
|
|
{ securities: secids.join(','), boards: boardId },
|
|
);
|
|
const securities = this.extractTable(data, 'securities');
|
|
const marketdata = this.extractTable(data, 'marketdata');
|
|
|
|
return secids.map((secid) => {
|
|
const sec =
|
|
securities.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
|
securities.find((r) => r.SECID === secid);
|
|
const mkt =
|
|
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
|
marketdata.find((r) => r.SECID === secid);
|
|
|
|
return {
|
|
secid,
|
|
boardid: boardId,
|
|
shortName: (sec?.SHORTNAME as string) || '',
|
|
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
|
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
|
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
|
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
|
last: mkt
|
|
? parseFloat((mkt.LAST as string) || '')
|
|
: parseFloat((sec?.PREVPRICE as string) || ''),
|
|
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
|
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
|
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
|
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
|
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
|
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
|
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
|
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
|
updateTime: (mkt?.UPDATETIME as string) || '',
|
|
};
|
|
});
|
|
}
|
|
|
|
async getBondPositionDataBatch(
|
|
secids: string[],
|
|
boardId = 'TQCB',
|
|
): Promise<MoexBondPositionData[]> {
|
|
if (secids.length === 0) return [];
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/stock/markets/bonds/securities`,
|
|
{ securities: secids.join(','), boards: boardId },
|
|
);
|
|
const securities = this.extractTable(data, 'securities');
|
|
const marketdata = this.extractTable(data, 'marketdata');
|
|
|
|
return secids.map((secid) => {
|
|
const bond =
|
|
securities.find(
|
|
(r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null,
|
|
) ||
|
|
securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) ||
|
|
securities.find((r) => r.SECID === secid);
|
|
const mkt =
|
|
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
|
marketdata.find((r) => r.LAST != null) ||
|
|
marketdata.find((r) => r.SECID === secid);
|
|
|
|
return {
|
|
secid,
|
|
boardid: boardId,
|
|
shortName: (bond?.SHORTNAME as string) || '',
|
|
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
|
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
|
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
|
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
|
couponPercent:
|
|
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
|
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
|
|
matDate: (bond?.MATDATE as string) || null,
|
|
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
|
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
|
|
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
|
|
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
|
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
|
|
bondType: (bond?.BONDTYPE as string) || null,
|
|
offerDate: (bond?.OFFERDATE as string) || null,
|
|
};
|
|
});
|
|
}
|
|
|
|
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
{ boards: boardId },
|
|
);
|
|
const rows = this.extractTable(data, 'securities');
|
|
const bond =
|
|
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
|
rows.find((r) => r.PREVWAPRICE != null) ||
|
|
rows[0];
|
|
if (!bond) return null;
|
|
|
|
return {
|
|
secid,
|
|
boardid: boardId,
|
|
shortName: (bond.SHORTNAME as string) || '',
|
|
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
|
|
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
|
|
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
|
nextCoupon: (bond.NEXTCOUPON as string) || null,
|
|
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
|
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
|
|
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
|
|
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
|
|
matDate: (bond.MATDATE as string) || '',
|
|
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
|
|
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
|
|
isin: (bond.ISIN as string) || '',
|
|
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
|
offerDate: (bond.OFFERDATE as string) || null,
|
|
buybackDate: (bond.BUYBACKDATE as string) || null,
|
|
bondType: (bond.BONDTYPE as string) || '',
|
|
bondSubType: (bond.BONDSUBTYPE as string) || '',
|
|
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
|
|
};
|
|
}
|
|
|
|
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
{ boards: boardId },
|
|
);
|
|
const mktRows = this.extractTable(data, 'marketdata');
|
|
const mkt =
|
|
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
|
|
mktRows.find((r) => r.LAST != null) ||
|
|
mktRows.find((r) => r.SECID === secid);
|
|
if (!mkt) return null;
|
|
|
|
return {
|
|
secid,
|
|
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
|
|
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
|
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
|
|
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
|
|
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
|
|
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
|
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
|
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
|
|
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
|
|
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
|
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
|
|
value: parseFloat((mkt.VALTODAY as string) || '0'),
|
|
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
|
|
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
|
|
updateTime: (mkt.UPDATETIME as string) || '',
|
|
};
|
|
}
|
|
|
|
async getDividends(secid: string): Promise<MoexDividend[]> {
|
|
const data = await this.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
|
|
return this.extractTable(data, 'dividends').map((d) => ({
|
|
secid: d.secid as string,
|
|
isin: d.isin as string,
|
|
registryCloseDate: d.registryclosedate as string,
|
|
value: parseFloat(d.value as string),
|
|
currencyId: (d.currencyid as string) || 'RUB',
|
|
}));
|
|
}
|
|
|
|
async getCandles(
|
|
engine: 'stock',
|
|
market: 'shares' | 'bonds',
|
|
secid: string,
|
|
interval: 1 | 10 | 60 | 24,
|
|
from: string,
|
|
till: string,
|
|
): Promise<MoexCandle[]> {
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
|
|
{
|
|
interval: String(interval),
|
|
from,
|
|
till,
|
|
},
|
|
);
|
|
return this.extractTable(data, 'candles').map((c) => ({
|
|
open: parseFloat(c.open as string),
|
|
close: parseFloat(c.close as string),
|
|
high: parseFloat(c.high as string),
|
|
low: parseFloat(c.low as string),
|
|
value: parseFloat(c.value as string),
|
|
volume: parseInt(c.volume as string, 10),
|
|
begin: c.begin as string,
|
|
end: c.end as string,
|
|
}));
|
|
}
|
|
|
|
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/stock/markets/shares/securities/${secid}`,
|
|
{ from, till },
|
|
);
|
|
const tableName = Object.keys(data).find(
|
|
(k) => k.startsWith('history') && !k.includes('cursor'),
|
|
);
|
|
if (!tableName) return [];
|
|
return this.extractTable(data, tableName).map((h) => ({
|
|
tradeDate: h.TRADEDATE as string,
|
|
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
|
|
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
|
|
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
|
|
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
|
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
|
volume: parseInt((h.VOLUME as string) || '0', 10),
|
|
value: parseFloat((h.VALUE as string) || '0'),
|
|
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
|
|
}));
|
|
}
|
|
|
|
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
|
|
const data = await this.request<Record<string, unknown>>(
|
|
`/engines/stock/markets/bonds/securities/${secid}`,
|
|
{ from, till },
|
|
);
|
|
const tableName = Object.keys(data).find(
|
|
(k) => k.startsWith('history') && !k.includes('cursor'),
|
|
);
|
|
if (!tableName) return [];
|
|
return this.extractTable(data, tableName).map((h) => ({
|
|
tradeDate: h.TRADEDATE as string,
|
|
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
|
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
|
|
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
|
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
|
|
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
|
|
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
|
|
}));
|
|
}
|
|
}
|