diff --git a/apps/backend/src/modules/moex-client/moex-client.service.ts b/apps/backend/src/modules/moex-client/moex-client.service.ts index b53e822..68fe130 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.ts @@ -7,6 +7,7 @@ import { MoexShareMarketData, MoexBondData, MoexBondMarketData, + MoexBondPositionData, MoexDividend, MoexCandle, MoexHistoryEntry, @@ -148,6 +149,7 @@ export class MoexClientService { 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, @@ -168,6 +170,98 @@ export class MoexClientService { }; } + async getShareMarketDataBatch( + secids: string[], + boardId = 'TQBR', + ): Promise { + if (secids.length === 0) return []; + const data = await this.request>( + `/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 { + if (secids.length === 0) return []; + const data = await this.request>( + `/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 { const data = await this.request>( `/engines/stock/markets/bonds/securities/${secid}`, diff --git a/apps/backend/src/modules/moex-client/moex-client.types.ts b/apps/backend/src/modules/moex-client/moex-client.types.ts index 245c158..105697a 100644 --- a/apps/backend/src/modules/moex-client/moex-client.types.ts +++ b/apps/backend/src/modules/moex-client/moex-client.types.ts @@ -20,6 +20,7 @@ export interface MoexSecurityDescription { export interface MoexShareMarketData { secid: string; boardid: string; + shortName: string; bid: number | null; offer: number | null; open: number | null; @@ -37,6 +38,26 @@ export interface MoexShareMarketData { 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 { secid: string; boardid: string; diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts index 738ee7c..fd1dcd1 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -7,6 +7,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { MoexClientService } from '../moex-client/moex-client.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 { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; @@ -201,145 +202,107 @@ export class PortfolioService { tags: string | null; }[], ): Promise { - return Promise.all( - positions.map(async (pos) => { - let shortName: string | null = null; - 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 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 base = { - id: pos.id, - 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 [shareDataBySecid, bondDataBySecid] = await Promise.all([ + this.fetchShareBatch(shareSecids), + this.fetchBondBatch(bondSecids), + ]); - if (pos.type === 'bond') { - return this.enrichBondPosition(pos, base); - } - return this.enrichSharePosition(pos, base); - }), + 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[]): Promise> { + 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 enrichSharePosition( - pos: { id: number; secid: string; quantity: number }, - base: EnrichedPosition, - ): Promise { - try { - const { data: marketData } = await this.cache.getOrFetch( - 'marketdata', - ['portfolio', pos.secid], - async () => { - const data = await this.moexClient.getShareMarketData(pos.secid); - return { - 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 { - return { ...base, currentPrice: null, change: null, changePercent: null, currentValue: null }; - } + private async fetchBondBatch(secids: string[]): Promise> { + 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 async enrichBondPosition( + private buildSharePosition( pos: { id: number; secid: string; quantity: number }, base: EnrichedPosition, - ): Promise { - try { - const { data: bondData } = await this.cache.getOrFetch( - 'bonddata', - ['portfolio', pos.secid], - async () => { - const desc = await this.moexClient.getBondData(pos.secid); - const mkt = await this.moexClient.getBondMarketData(pos.secid); - return { - 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', - ); + 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, + }; + } - const currentValue = - bondData.price !== null ? (bondData.price / 100) * bondData.faceValue * pos.quantity : null; - - return { - ...base, - currentPrice: bondData.price, - yieldToMaturity: bondData.yieldToMaturity, - duration: bondData.duration, - couponValue: bondData.couponValue, - couponPercent: bondData.couponPercent, - nextCouponDate: bondData.nextCouponDate, - matDate: bondData.matDate, - accruedInt: bondData.accruedInt, - bid: bondData.bid, - offer: bondData.offer, - couponPeriod: bondData.couponPeriod, - bondType: bondData.bondType, - offerDate: bondData.offerDate, - currentValue, - }; - } catch { - return { - ...base, - currentPrice: null, - 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, - }; - } + 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, + }; } } diff --git a/docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md b/docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md new file mode 100644 index 0000000..df8b140 --- /dev/null +++ b/docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md @@ -0,0 +1,65 @@ +# 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 | diff --git a/docs/superpowers/plans/2026-06-14-portfolio-enricher-optimization.md b/docs/superpowers/plans/2026-06-14-portfolio-enricher-optimization.md new file mode 100644 index 0000000..8944b8f --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-portfolio-enricher-optimization.md @@ -0,0 +1,365 @@ +# 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 { + if (secids.length === 0) return []; + const data = await this.request>( + `/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 { + if (secids.length === 0) return []; + const data = await this.request>( + `/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 { + 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> { + 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> { + 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) diff --git a/docs/superpowers/specs/2026-06-14-portfolio-enricher-optimization.md b/docs/superpowers/specs/2026-06-14-portfolio-enricher-optimization.md new file mode 100644 index 0000000..b5b0b07 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-portfolio-enricher-optimization.md @@ -0,0 +1,74 @@ +# 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).