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
75 lines
3.4 KiB
Markdown
75 lines
3.4 KiB
Markdown
# 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).
|