# 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 `apps/docs/docs/adr/` as a new ADR 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)