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('app.moex.baseUrl')!; this.threshold = this.configService.get('app.moex.circuitBreakerThreshold', 5); this.resetMs = this.configService.get('app.moex.circuitBreakerResetSeconds', 30) * 1000; const rateLimit = this.configService.get('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(path: string, params?: Record): Promise { 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; } private extractTable(data: Record, name: string): Record[] { const table = data[name] as Record | 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 = {}; columns.forEach((col, i) => { obj[col] = row[i]; }); return obj; }); } async searchSecurities(query: string): Promise { const data = await this.request>('/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 { const data = await this.request>(`/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 { const data = await this.request>( `/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 { const params: Record = { boards: boardId }; if (secids.length > 0) { params.securities = secids.join(','); } const data = await this.request>( `/engines/stock/markets/shares/securities`, params, ); const securities = this.extractTable(data, 'securities'); const marketdata = this.extractTable(data, 'marketdata'); const secidSet = secids.length > 0 ? new Set(secids) : null; const filteredSecurities = secidSet ? securities.filter((r) => secidSet.has(r.SECID as string)) : securities; return filteredSecurities.map((sec) => { const secid = sec.SECID as string; 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 { const params: Record = { boards: boardId }; if (secids.length > 0) { params.securities = secids.join(','); } const data = await this.request>( `/engines/stock/markets/bonds/securities`, params, ); const securities = this.extractTable(data, 'securities'); const marketdata = this.extractTable(data, 'marketdata'); const secidSet = secids.length > 0 ? new Set(secids) : null; const filteredSecurities = secidSet ? securities.filter((r) => secidSet.has(r.SECID as string)) : securities; return filteredSecurities.map((bond) => { const secid = bond.SECID as string; const mkt = marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) || marketdata.find((r) => r.SECID === secid && r.LAST != null) || marketdata.find((r) => r.SECID === secid); return { secid, boardid: (bond.BOARDID as string) || 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 { const data = await this.request>( `/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 { const data = await this.request>( `/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 { const data = await this.request>(`/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 { const data = await this.request>( `/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 { const data = await this.request>( `/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 { const data = await this.request>( `/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, })); } }