Compare commits
No commits in common. "38729249f3b66f3f24bbf811821d044210eff714" and "4c15bda30e4db7efbfbe28f4c3e6e025b0d0b4b5" have entirely different histories.
38729249f3
...
4c15bda30e
@ -7,7 +7,6 @@ import {
|
|||||||
MoexShareMarketData,
|
MoexShareMarketData,
|
||||||
MoexBondData,
|
MoexBondData,
|
||||||
MoexBondMarketData,
|
MoexBondMarketData,
|
||||||
MoexBondPositionData,
|
|
||||||
MoexDividend,
|
MoexDividend,
|
||||||
MoexCandle,
|
MoexCandle,
|
||||||
MoexHistoryEntry,
|
MoexHistoryEntry,
|
||||||
@ -149,7 +148,6 @@ export class MoexClientService {
|
|||||||
return {
|
return {
|
||||||
secid,
|
secid,
|
||||||
boardid: boardId,
|
boardid: boardId,
|
||||||
shortName: (share?.SHORTNAME as string) || '',
|
|
||||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||||
@ -170,98 +168,6 @@ export class MoexClientService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
||||||
const data = await this.request<Record<string, unknown>>(
|
const data = await this.request<Record<string, unknown>>(
|
||||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||||
|
|||||||
@ -20,7 +20,6 @@ export interface MoexSecurityDescription {
|
|||||||
export interface MoexShareMarketData {
|
export interface MoexShareMarketData {
|
||||||
secid: string;
|
secid: string;
|
||||||
boardid: string;
|
boardid: string;
|
||||||
shortName: string;
|
|
||||||
bid: number | null;
|
bid: number | null;
|
||||||
offer: number | null;
|
offer: number | null;
|
||||||
open: number | null;
|
open: number | null;
|
||||||
@ -38,26 +37,6 @@ export interface MoexShareMarketData {
|
|||||||
updateTime: string;
|
updateTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoexBondPositionData {
|
|
||||||
secid: string;
|
|
||||||
boardid: string;
|
|
||||||
shortName: string;
|
|
||||||
price: number | null;
|
|
||||||
yieldToMaturity: number | null;
|
|
||||||
duration: number | null;
|
|
||||||
couponValue: number | null;
|
|
||||||
couponPercent: number | null;
|
|
||||||
nextCouponDate: string | null;
|
|
||||||
matDate: string | null;
|
|
||||||
accruedInt: number | null;
|
|
||||||
faceValue: number;
|
|
||||||
bid: number | null;
|
|
||||||
offer: number | null;
|
|
||||||
couponPeriod: number | null;
|
|
||||||
bondType: string | null;
|
|
||||||
offerDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MoexBondData {
|
export interface MoexBondData {
|
||||||
secid: string;
|
secid: string;
|
||||||
boardid: string;
|
boardid: string;
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types';
|
|
||||||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||||
import { AddPositionDto } from './dto/add-position.dto';
|
import { AddPositionDto } from './dto/add-position.dto';
|
||||||
@ -202,107 +201,145 @@ export class PortfolioService {
|
|||||||
tags: string | null;
|
tags: string | null;
|
||||||
}[],
|
}[],
|
||||||
): Promise<EnrichedPosition[]> {
|
): Promise<EnrichedPosition[]> {
|
||||||
const sharePositions = positions.filter((p) => p.type === 'share');
|
return Promise.all(
|
||||||
const bondPositions = positions.filter((p) => p.type === 'bond');
|
positions.map(async (pos) => {
|
||||||
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
let shortName: string | null = null;
|
||||||
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
try {
|
||||||
|
const { data: desc } = await this.cache.getOrFetch(
|
||||||
|
'security',
|
||||||
|
['portfolio-name', pos.secid],
|
||||||
|
async () => {
|
||||||
|
const d = await this.moexClient.getSecurityDescription(pos.secid);
|
||||||
|
return { shortName: d?.shortName ?? null };
|
||||||
|
},
|
||||||
|
'securityTtl',
|
||||||
|
);
|
||||||
|
shortName = desc.shortName;
|
||||||
|
} catch {
|
||||||
|
shortName = null;
|
||||||
|
}
|
||||||
|
|
||||||
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
|
const base = {
|
||||||
this.fetchShareBatch(shareSecids),
|
id: pos.id,
|
||||||
this.fetchBondBatch(bondSecids),
|
secid: pos.secid,
|
||||||
]);
|
shortName,
|
||||||
|
type: pos.type,
|
||||||
|
quantity: pos.quantity,
|
||||||
|
notes: pos.notes,
|
||||||
|
tags: pos.tags ? JSON.parse(pos.tags) : null,
|
||||||
|
weightPercent: 0,
|
||||||
|
currentPrice: null as number | null,
|
||||||
|
currentValue: null as number | null,
|
||||||
|
};
|
||||||
|
|
||||||
const enriched: EnrichedPosition[] = [];
|
if (pos.type === 'bond') {
|
||||||
|
return this.enrichBondPosition(pos, base);
|
||||||
|
}
|
||||||
|
return this.enrichSharePosition(pos, base);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const pos of positions) {
|
private async enrichSharePosition(
|
||||||
const base = {
|
pos: { id: number; secid: string; quantity: number },
|
||||||
id: pos.id,
|
base: EnrichedPosition,
|
||||||
secid: pos.secid,
|
): Promise<EnrichedPosition> {
|
||||||
shortName: null as string | null,
|
try {
|
||||||
type: pos.type,
|
const { data: marketData } = await this.cache.getOrFetch(
|
||||||
quantity: pos.quantity,
|
'marketdata',
|
||||||
notes: pos.notes,
|
['portfolio', pos.secid],
|
||||||
tags: pos.tags ? JSON.parse(pos.tags) : null,
|
async () => {
|
||||||
weightPercent: 0,
|
const data = await this.moexClient.getShareMarketData(pos.secid);
|
||||||
currentPrice: null as number | null,
|
return {
|
||||||
currentValue: null as number | null,
|
price: data?.last ?? null,
|
||||||
|
change: data?.lastChange ?? null,
|
||||||
|
changePercent: data?.lastChangePrcnt ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'marketDataTtl',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
currentPrice: marketData.price,
|
||||||
|
change: marketData.change,
|
||||||
|
changePercent: marketData.changePercent,
|
||||||
|
currentValue: marketData.price !== null ? marketData.price * pos.quantity : null,
|
||||||
};
|
};
|
||||||
|
} catch {
|
||||||
if (pos.type === 'bond') {
|
return { ...base, currentPrice: null, change: null, changePercent: null, currentValue: null };
|
||||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
|
||||||
} else {
|
|
||||||
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return enriched;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchShareBatch(secids: string[]): Promise<Map<string, MoexShareMarketData>> {
|
private async enrichBondPosition(
|
||||||
if (secids.length === 0) return new Map();
|
|
||||||
const cacheKey = secids.join(',');
|
|
||||||
const { data } = await this.cache.getOrFetch(
|
|
||||||
'batchdata',
|
|
||||||
['shares', cacheKey],
|
|
||||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
|
||||||
'marketDataTtl',
|
|
||||||
);
|
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchBondBatch(secids: string[]): Promise<Map<string, MoexBondPositionData>> {
|
|
||||||
if (secids.length === 0) return new Map();
|
|
||||||
const cacheKey = secids.join(',');
|
|
||||||
const { data } = await this.cache.getOrFetch(
|
|
||||||
'batchdata',
|
|
||||||
['bonds', cacheKey],
|
|
||||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
|
||||||
'marketDataTtl',
|
|
||||||
);
|
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildSharePosition(
|
|
||||||
pos: { id: number; secid: string; quantity: number },
|
pos: { id: number; secid: string; quantity: number },
|
||||||
base: EnrichedPosition,
|
base: EnrichedPosition,
|
||||||
data: MoexShareMarketData | undefined,
|
): Promise<EnrichedPosition> {
|
||||||
): EnrichedPosition {
|
try {
|
||||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
const { data: bondData } = await this.cache.getOrFetch(
|
||||||
return {
|
'bonddata',
|
||||||
...base,
|
['portfolio', pos.secid],
|
||||||
shortName: data.shortName,
|
async () => {
|
||||||
currentPrice: data.last,
|
const desc = await this.moexClient.getBondData(pos.secid);
|
||||||
change: data.lastChange,
|
const mkt = await this.moexClient.getBondMarketData(pos.secid);
|
||||||
changePercent: data.lastChangePrcnt,
|
return {
|
||||||
currentValue: data.last !== null ? data.last * pos.quantity : null,
|
price: mkt?.last ?? null,
|
||||||
};
|
yieldToMaturity: mkt?.yield ?? null,
|
||||||
}
|
duration: mkt?.duration ?? null,
|
||||||
|
couponValue: desc?.couponValue ?? null,
|
||||||
|
couponPercent: desc?.couponPercent ?? null,
|
||||||
|
nextCouponDate: desc?.nextCoupon ?? null,
|
||||||
|
matDate: desc?.matDate ?? null,
|
||||||
|
accruedInt: desc?.accruedInt ?? null,
|
||||||
|
faceValue: desc?.faceValue ?? 1000,
|
||||||
|
bid: mkt?.bid ?? null,
|
||||||
|
offer: mkt?.offer ?? null,
|
||||||
|
couponPeriod: desc?.couponPeriod ?? null,
|
||||||
|
bondType: desc?.bondType ?? null,
|
||||||
|
offerDate: desc?.offerDate ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'marketDataTtl',
|
||||||
|
);
|
||||||
|
|
||||||
private buildBondPosition(
|
const currentValue =
|
||||||
pos: { id: number; secid: string; quantity: number },
|
bondData.price !== null ? (bondData.price / 100) * bondData.faceValue * pos.quantity : null;
|
||||||
base: EnrichedPosition,
|
|
||||||
data: MoexBondPositionData | undefined,
|
return {
|
||||||
): EnrichedPosition {
|
...base,
|
||||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
currentPrice: bondData.price,
|
||||||
const currentValue =
|
yieldToMaturity: bondData.yieldToMaturity,
|
||||||
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
|
duration: bondData.duration,
|
||||||
return {
|
couponValue: bondData.couponValue,
|
||||||
...base,
|
couponPercent: bondData.couponPercent,
|
||||||
shortName: data.shortName,
|
nextCouponDate: bondData.nextCouponDate,
|
||||||
currentPrice: data.price,
|
matDate: bondData.matDate,
|
||||||
yieldToMaturity: data.yieldToMaturity,
|
accruedInt: bondData.accruedInt,
|
||||||
duration: data.duration,
|
bid: bondData.bid,
|
||||||
couponValue: data.couponValue,
|
offer: bondData.offer,
|
||||||
couponPercent: data.couponPercent,
|
couponPeriod: bondData.couponPeriod,
|
||||||
nextCouponDate: data.nextCouponDate,
|
bondType: bondData.bondType,
|
||||||
matDate: data.matDate,
|
offerDate: bondData.offerDate,
|
||||||
accruedInt: data.accruedInt,
|
currentValue,
|
||||||
bid: data.bid,
|
};
|
||||||
offer: data.offer,
|
} catch {
|
||||||
couponPeriod: data.couponPeriod,
|
return {
|
||||||
bondType: data.bondType,
|
...base,
|
||||||
offerDate: data.offerDate,
|
currentPrice: null,
|
||||||
currentValue,
|
yieldToMaturity: null,
|
||||||
};
|
duration: null,
|
||||||
|
couponValue: null,
|
||||||
|
couponPercent: null,
|
||||||
|
nextCouponDate: null,
|
||||||
|
matDate: null,
|
||||||
|
accruedInt: null,
|
||||||
|
bid: null,
|
||||||
|
offer: null,
|
||||||
|
couponPeriod: null,
|
||||||
|
bondType: null,
|
||||||
|
offerDate: null,
|
||||||
|
currentValue: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["vitest/globals"],
|
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
@ -21,6 +20,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test/**"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
# ADR: Portfolio Enricher Optimization
|
|
||||||
|
|
||||||
**Date:** 2026-06-14
|
|
||||||
**Status:** Implemented
|
|
||||||
**Deciders:** AI Agent + Human
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`GET /api/v1/portfolios/1` выполнялся ~29 секунд для портфеля с 104 позициями.
|
|
||||||
Причина: per-position enrichment генерировал 298 последовательных HTTP-запросов к MOEX ISS через rate limiter (10 req/s).
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Три оптимизации, реализованные одновременно:
|
|
||||||
|
|
||||||
### 1. Merge bond data calls
|
|
||||||
|
|
||||||
`getBondData` и `getBondMarketData` вызывали **один и тот же** MOEX endpoint
|
|
||||||
(`/engines/stock/markets/bonds/securities/{secid}`), но парсили разные таблицы ответа.
|
|
||||||
|
|
||||||
Новый метод `getBondPositionDataBatch` делает один запрос на все облигации и парсит обе таблицы.
|
|
||||||
|
|
||||||
**Profit:** 180 → 90 запросов для bonds
|
|
||||||
|
|
||||||
### 2. Remove redundant `getSecurityDescription`
|
|
||||||
|
|
||||||
Каждая позиция делала отдельный запрос для shortName. Но shortName уже доступен:
|
|
||||||
- в `securities` таблице ответа `getShareMarketData`
|
|
||||||
- в `getBondData` / `getBondPositionDataBatch`
|
|
||||||
|
|
||||||
Удалили вызов `getSecurityDescription` из `enrichPositions`.
|
|
||||||
|
|
||||||
**Profit:** 104 → 0 запросов
|
|
||||||
|
|
||||||
### 3. Batch requests by market
|
|
||||||
|
|
||||||
Вместо N индивидуальных запросов — группируем secid по типу и делаем 2 batch-запроса:
|
|
||||||
- `GET /engines/stock/markets/shares/securities.json?securities=SBER,VTBR,...`
|
|
||||||
- `GET /engines/stock/markets/bonds/securities.json?securities=RU000...,SU262...`
|
|
||||||
|
|
||||||
Новые методы: `getShareMarketDataBatch`, `getBondPositionDataBatch`.
|
|
||||||
|
|
||||||
**Profit:** 104 → 2 запроса
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
| Metric | Before | After | Reduction |
|
|
||||||
|---|---|---|---|
|
|
||||||
| API calls to MOEX | 298 | 2 | **99.3%** |
|
|
||||||
| Estimated latency (cache cold) | ~29.8s | ~0.3s | **99%** |
|
|
||||||
| Code in PortfolioService | ~150 lines | ~90 lines | **40%** |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Cache key format changed**: from `marketdata:portfolio:{secid}` / `bonddata:portfolio:{secid}` / `security:portfolio-name:{secid}` to `batchdata:shares:{sortedSecids}` / `batchdata:bonds:{sortedSecids}`. Old cache entries will naturally expire via TTL.
|
|
||||||
- **Cache granularity**: batch results are cached as a unit. If portfolio positions change, the cache key changes (because sorted secids change), triggering a fresh fetch.
|
|
||||||
- **Backward compatibility**: `getShareMarketData(secid)` and `getBondData(secid)` + `getBondMarketData(secid)` are preserved for other consumers.
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `moex-client.types.ts` | Added `shortName` to `MoexShareMarketData`, added `MoexBondPositionData` |
|
|
||||||
| `moex-client.service.ts` | Added `getShareMarketDataBatch`, `getBondPositionDataBatch`, added `shortName` to `getShareMarketData` |
|
|
||||||
| `portfolio.service.ts` | Rewrote `enrichPositions` to batch, removed redundant `getSecurityDescription` calls, removed old per-position enrichment methods |
|
|
||||||
@ -1,365 +0,0 @@
|
|||||||
# Portfolio Enricher Optimization — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for tracking.
|
|
||||||
|
|
||||||
**Goal:** Reduce portfolio enrichment from 298 MOEX API calls (~30s) to 2 batch calls (~0.3s) by merging redundant bond data calls, eliminating extra security descriptions, and batching by market.
|
|
||||||
|
|
||||||
**Architecture:** 3-phase: (1) type changes, (2) new batch methods on MoexClientService, (3) rewrite PortfolioService.enrichPositions to use batch + remove redundant calls.
|
|
||||||
|
|
||||||
**Tech Stack:** NestJS, TypeScript, MOEX ISS API, PQueue
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add types — `shortName` on share market data + `MoexBondPositionData` combined type
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/backend/src/modules/moex-client/moex-client.types.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Extend `MoexShareMarketData` with `shortName`**
|
|
||||||
|
|
||||||
Add `shortName: string;` field — it's already returned by MOEX in the `securities` table of the share endpoint, but was never extracted.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `MoexBondPositionData` combined type**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface MoexBondPositionData {
|
|
||||||
secid: string;
|
|
||||||
boardid: string;
|
|
||||||
shortName: string;
|
|
||||||
price: number | null;
|
|
||||||
yieldToMaturity: number | null;
|
|
||||||
duration: number | null;
|
|
||||||
couponValue: number | null;
|
|
||||||
couponPercent: number | null;
|
|
||||||
nextCouponDate: string | null;
|
|
||||||
matDate: string | null;
|
|
||||||
accruedInt: number | null;
|
|
||||||
faceValue: number;
|
|
||||||
bid: number | null;
|
|
||||||
offer: number | null;
|
|
||||||
couponPeriod: number | null;
|
|
||||||
bondType: string | null;
|
|
||||||
offerDate: string | null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This replaces the need for both `MoexBondData` + `MoexBondMarketData` — combined from a single endpoint response.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Add batch methods to MoexClientService
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/backend/src/modules/moex-client/moex-client.service.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `getShareMarketDataBatch` method**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
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) || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Key: uses existing `request()` method (rate-limited via PQueue). The `securities` param accepts comma-separated secids.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `getBondPositionDataBatch` method**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This replaces `getBondData` + `getBondMarketData` with a single batch call that parses both tables.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `getShareMarketData` to also extract `shortName`**
|
|
||||||
|
|
||||||
In the single-security `getShareMarketData`, find the securities row and extract shortName:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const share = rows.find((r) => r.BOARDID === boardId);
|
|
||||||
return {
|
|
||||||
secid,
|
|
||||||
boardid: boardId,
|
|
||||||
shortName: (share?.SHORTNAME as string) || '', // NEW
|
|
||||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
|
||||||
// ... rest unchanged
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run existing tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx vitest run -w apps/backend
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: existing tests pass (no regressions).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Rewrite `enrichPositions` in PortfolioService
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Rewrite `enrichPositions` to use batch + eliminate redundant calls**
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Group positions by type (share/bond)
|
|
||||||
2. For shares: 1 `getShareMarketDataBatch` call → map by secid
|
|
||||||
3. For bonds: 1 `getBondPositionDataBatch` call → map by secid
|
|
||||||
4. Build enriched positions from maps (no more individual API calls)
|
|
||||||
5. shortName comes from market data response (no more `getSecurityDescription`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private async enrichPositions(
|
|
||||||
positions: {
|
|
||||||
id: number; portfolioId: number; secid: string;
|
|
||||||
type: string; quantity: number; notes: string | null; tags: string | null;
|
|
||||||
}[],
|
|
||||||
portfolioId: number,
|
|
||||||
): Promise<EnrichedPosition[]> {
|
|
||||||
const sharePositions = positions.filter((p) => p.type === 'share');
|
|
||||||
const bondPositions = positions.filter((p) => p.type === 'bond');
|
|
||||||
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
|
||||||
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
|
||||||
|
|
||||||
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
|
|
||||||
this.fetchShareBatch(shareSecids, portfolioId),
|
|
||||||
this.fetchBondBatch(bondSecids, portfolioId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const enriched: EnrichedPosition[] = [];
|
|
||||||
|
|
||||||
for (const pos of positions) {
|
|
||||||
const base = {
|
|
||||||
id: pos.id, secid: pos.secid,
|
|
||||||
shortName: null as string | null,
|
|
||||||
type: pos.type, quantity: pos.quantity,
|
|
||||||
notes: pos.notes, tags: pos.tags ? JSON.parse(pos.tags) : null,
|
|
||||||
weightPercent: 0, currentPrice: null as number | null,
|
|
||||||
currentValue: null as number | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pos.type === 'bond') {
|
|
||||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
|
||||||
} else {
|
|
||||||
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return enriched;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchShareBatch(
|
|
||||||
secids: string[], portfolioId: number,
|
|
||||||
): Promise<Map<string, MoexShareMarketData>> {
|
|
||||||
if (secids.length === 0) return new Map();
|
|
||||||
const cacheKey = secids.join(',');
|
|
||||||
const { data } = await this.cache.getOrFetch(
|
|
||||||
'batchdata', ['shares', cacheKey],
|
|
||||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
|
||||||
'marketDataTtl',
|
|
||||||
);
|
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchBondBatch(
|
|
||||||
secids: string[], portfolioId: number,
|
|
||||||
): Promise<Map<string, MoexBondPositionData>> {
|
|
||||||
if (secids.length === 0) return new Map();
|
|
||||||
const cacheKey = secids.join(',');
|
|
||||||
const { data } = await this.cache.getOrFetch(
|
|
||||||
'batchdata', ['bonds', cacheKey],
|
|
||||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
|
||||||
'marketDataTtl',
|
|
||||||
);
|
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `buildSharePosition` method**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private buildSharePosition(
|
|
||||||
pos: { id: number; secid: string; quantity: number },
|
|
||||||
base: EnrichedPosition,
|
|
||||||
data: MoexShareMarketData | undefined,
|
|
||||||
): EnrichedPosition {
|
|
||||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
shortName: data.shortName,
|
|
||||||
currentPrice: data.last,
|
|
||||||
change: data.lastChange,
|
|
||||||
changePercent: data.lastChangePrcnt,
|
|
||||||
currentValue: data.last !== null ? data.last * pos.quantity : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `buildBondPosition` method**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private buildBondPosition(
|
|
||||||
pos: { id: number; secid: string; quantity: number },
|
|
||||||
base: EnrichedPosition,
|
|
||||||
data: MoexBondPositionData | undefined,
|
|
||||||
): EnrichedPosition {
|
|
||||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
|
||||||
const currentValue =
|
|
||||||
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
shortName: data.shortName,
|
|
||||||
currentPrice: data.price,
|
|
||||||
yieldToMaturity: data.yieldToMaturity,
|
|
||||||
duration: data.duration,
|
|
||||||
couponValue: data.couponValue,
|
|
||||||
couponPercent: data.couponPercent,
|
|
||||||
nextCouponDate: data.nextCouponDate,
|
|
||||||
matDate: data.matDate,
|
|
||||||
accruedInt: data.accruedInt,
|
|
||||||
bid: data.bid,
|
|
||||||
offer: data.offer,
|
|
||||||
couponPeriod: data.couponPeriod,
|
|
||||||
bondType: data.bondType,
|
|
||||||
offerDate: data.offerDate,
|
|
||||||
currentValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `findOne` to pass `portfolio.id` to `enrichPositions`**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, portfolio.id);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Clean up removed methods**
|
|
||||||
|
|
||||||
Remove old private methods: `enrichSharePosition`, `enrichBondPosition` (replaced by `buildSharePosition`, `buildBondPosition`).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Remove unused import `CacheService` if it becomes unused**
|
|
||||||
|
|
||||||
Actually `CacheService` is still used via `fetchShareBatch`/`fetchBondBatch`. Keep it.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx vitest run -w apps/backend
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all tests pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Verify and lint
|
|
||||||
|
|
||||||
- [ ] **Step 1: TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit -w apps/backend
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Lint**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint 2>/dev/null || echo "Lint check complete"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Format**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run format
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Document performance gain
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write ADR or performance note in docs**
|
|
||||||
|
|
||||||
Add to `docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md` documenting:
|
|
||||||
- Problem: 298 API calls → 29s
|
|
||||||
- Changes made: merged bond calls, removed redundant securityDescription, batch by market
|
|
||||||
- Result: 2 API calls → ~0.3s (97% reduction)
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
# Portfolio Enricher Optimization
|
|
||||||
|
|
||||||
**Date:** 2026-06-14
|
|
||||||
**Status:** Approved
|
|
||||||
**Author:** AI Agent
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`GET /api/v1/portfolios/1` с 104 позициями (90 облигаций + 14 акций) выполняется ~29 секунд из-за 298 последовательных HTTP-запросов к MOEX ISS через rate limiter (10 req/s).
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
|
|
||||||
Per-position enrichment в `PortfolioService.enrichPositions()` генерирует:
|
|
||||||
|
|
||||||
| Шаг | Вызовов | Метод |
|
|
||||||
|---|---|---|
|
|
||||||
| shortName | 104 | `getSecurityDescription` — **избыточно** |
|
|
||||||
| Акции (14) | 14 | `getShareMarketData` |
|
|
||||||
| Облигации (90) | 90 | `getBondData` |
|
|
||||||
| Облигации (90) | 90 | `getBondMarketData` — **дублирует endpoint** |
|
|
||||||
| **Total** | **298** | |
|
|
||||||
|
|
||||||
Две ключевые проблемы:
|
|
||||||
1. `getBondData` и `getBondMarketData` вызывают **один и тот же** MOEX endpoint, но парсят разные таблицы ответа
|
|
||||||
2. `getSecurityDescription` для shortName — избыточен: shortName уже доступен в market data ответах
|
|
||||||
3. Каждый secid запрашивается отдельно, хотя MOEX ISS поддерживает batch через `?securities=` параметр
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
### 1. Merge bond data calls
|
|
||||||
|
|
||||||
Новый метод `getBondDataCombined(secid)` делает один запрос к MOEX и парсит обе таблицы (`securities` + `marketdata`), возвращая объединённый результат.
|
|
||||||
|
|
||||||
**Profit:** 180 → 90 запросов для bonds
|
|
||||||
|
|
||||||
### 2. Remove redundant getSecurityDescription
|
|
||||||
|
|
||||||
- `getShareMarketData` response уже содержит `SHORTNAME` в `securities` таблице — добавим поле `shortName` в тип `MoexShareMarketData`
|
|
||||||
- `getBondDataCombined` уже возвращает shortName из `securities` таблицы
|
|
||||||
|
|
||||||
**Profit:** 104 → 0 запросов
|
|
||||||
|
|
||||||
### 3. Batch requests by market
|
|
||||||
|
|
||||||
Группируем secid по типу (share/bond) и делаем 2 batch-запроса вместо индивидуальных:
|
|
||||||
|
|
||||||
- `GET /engines/stock/markets/shares/securities.json?securities=SBER,VTBR,...&boards=TQBR`
|
|
||||||
- `GET /engines/stock/markets/bonds/securities.json?securities=RU000A...,SU26240...&boards=TQCB`
|
|
||||||
|
|
||||||
Новые методы: `getShareMarketDataBatch(secids)`, `getBondMarketDataBatch(secids)`.
|
|
||||||
|
|
||||||
**Profit:** 104 → 2 запроса
|
|
||||||
|
|
||||||
### Metrics
|
|
||||||
|
|
||||||
| Scenario | API calls | Est. time (10 req/s) |
|
|
||||||
|---|---|---|
|
|
||||||
| Before | 298 | ~29.8s |
|
|
||||||
| After | 2 | ~0.3s |
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `apps/backend/src/modules/moex-client/moex-client.types.ts` | Add `shortName` to `MoexShareMarketData` |
|
|
||||||
| `apps/backend/src/modules/moex-client/moex-client.service.ts` | +`getShareMarketDataBatch`, +`getBondMarketDataBatch`, merge bond methods, add `shortName` to share response |
|
|
||||||
| `apps/backend/src/modules/portfolio/portfolio.service.ts` | Rewrite `enrichPositions` — batch, no redundant calls |
|
|
||||||
|
|
||||||
## Risks and Mitigations
|
|
||||||
|
|
||||||
- **MOEX ISS rate limiting**: Batch reduces requests, lowering risk. Circuit breaker stays intact.
|
|
||||||
- **Cache invalidation**: Batch results cached per market, not per secid. TTL unchanged (900s market data).
|
|
||||||
- **Empty batches**: If portfolio has no shares or no bonds, skip market entirely. No unnecessary calls.
|
|
||||||
- **Long secid lists**: URL length may exceed limits. Mitigation: split batches if secids > 50 per call (monitor and split if needed).
|
|
||||||
Loading…
x
Reference in New Issue
Block a user