Sergey Krylov 5b9d7f3a27
All checks were successful
CI / ci (pull_request) Successful in 3m20s
CI / ci (push) Successful in 3m1s
docs: fix historical files to new structure and update
2026-06-18 22:04:09 +03:00

12 KiB

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
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

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
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:

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
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)
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
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
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
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
npx vitest run -w apps/backend

Expected: all tests pass.


Task 4: Verify and lint

  • Step 1: TypeScript check
npx tsc --noEmit -w apps/backend
  • Step 2: Lint
npm run lint 2>/dev/null || echo "Lint check complete"
  • Step 3: Format
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)