moex-vibe/docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md
Sergey Krylov 1017fd4f08
All checks were successful
CI / lint (pull_request) Successful in 2m31s
CI / test (pull_request) Successful in 1m37s
CI / build (pull_request) Successful in 1m57s
CI / lint (push) Successful in 1m57s
CI / test (push) Successful in 1m48s
CI / build (push) Successful in 1m54s
docs: add spec and plan for portfolio list enrichment
2026-06-14 14:18:29 +03:00

18 KiB

Portfolio List Enrichment — 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 (- [ ]) syntax for tracking.

Goal: Enrich GET /api/v1/portfolios with totalValue, positionCount, shareCount, bondCount from MOEX batch data and display on PortfolioCard.

Architecture: Backend collects all positions across user's portfolios, does ONE batch MOEX call (cached), computes aggregates per portfolio. Frontend displays new fields on the existing card component.

Tech Stack: NestJS, Prisma, MoexClientService (batch), React, TanStack Query


Task 1: Create PortfolioListResponseDto

Files:

  • Create: apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts

  • Step 1: Create DTO file

import { ApiProperty } from '@nestjs/swagger';
import { PortfolioResponseDto } from './portfolio-response.dto';

export class PortfolioListResponseDto extends PortfolioResponseDto {
  @ApiProperty({ description: 'Total market value of all positions' })
  totalValue!: number;

  @ApiProperty({ description: 'Total number of positions' })
  positionCount!: number;

  @ApiProperty({ description: 'Number of share positions' })
  shareCount!: number;

  @ApiProperty({ description: 'Number of bond positions' })
  bondCount!: number;
}
  • Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit -w apps/backend Expected: No errors

  • Step 3: Commit
git add apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts
git commit -m "feat(backend): add PortfolioListResponseDto"

Task 2: Write failing tests for PortfolioService.findAll enrichment

Files:

  • Create: apps/backend/src/modules/portfolio/portfolio.service.spec.ts

  • Step 1: Create test file with failing tests

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { PortfolioService } from './portfolio.service';
import { PrismaService } from '../prisma/prisma.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { ForbiddenException, NotFoundException } from '@nestjs/common';

describe('PortfolioService', () => {
  let service: PortfolioService;
  let prisma: PrismaService;
  let moexClient: MoexClientService;
  let module: TestingModule;

  const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
    id: 1,
    userId: 1,
    name: 'Test Portfolio',
    description: 'A test portfolio',
    currency: 'RUB',
    targets: null,
    createdAt: new Date('2026-01-01'),
    updatedAt: new Date('2026-06-14'),
    ...overrides,
  });

  const mockPosition = (overrides: Record<string, unknown> = {}) => ({
    id: 1,
    portfolioId: 1,
    secid: 'SBER',
    type: 'share',
    quantity: 10,
    notes: null,
    tags: null,
    createdAt: new Date('2026-01-01'),
    updatedAt: new Date('2026-06-14'),
    ...overrides,
  });

  beforeAll(async () => {
    module = await Test.createTestingModule({
      imports: [ConfigModule.forRoot({ load: [configuration] })],
      providers: [
        PortfolioService,
        {
          provide: PrismaService,
          useValue: {
            portfolio: {
              findMany: vi.fn(),
              findUnique: vi.fn(),
              create: vi.fn(),
              update: vi.fn(),
              delete: vi.fn(),
            },
            position: {
              findUnique: vi.fn(),
              create: vi.fn(),
              update: vi.fn(),
              delete: vi.fn(),
            },
          },
        },
        {
          provide: MoexClientService,
          useValue: {
            getShareMarketDataBatch: vi.fn(),
            getBondPositionDataBatch: vi.fn(),
            getSecurityDescription: vi.fn(),
          },
        },
        {
          provide: CacheService,
          useValue: {
            getOrFetch: vi.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<PortfolioService>(PortfolioService);
    prisma = module.get<PrismaService>(PrismaService);
    moexClient = module.get<MoexClientService>(MoexClientService);
    // CacheService is a useValue mock object
  });

  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('findAll', () => {
    it('should return empty array when user has no portfolios', async () => {
      vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
      const result = await service.findAll(1);
      expect(result).toEqual([]);
    });

    it('should return portfolios with zero aggregates when no positions exist', async () => {
      vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
        mockPortfolio({ positions: [] }) as any,
      ]);

      const result = await service.findAll(1);

      expect(result).toHaveLength(1);
      expect(result[0]).toMatchObject({
        name: 'Test Portfolio',
        totalValue: 0,
        positionCount: 0,
        shareCount: 0,
        bondCount: 0,
      });
    });

    it('should enrich portfolios with market data from batch MOEX call', async () => {
      const sharePosition = mockPosition({
        id: 1,
        secid: 'SBER',
        type: 'share',
        quantity: 10,
      });
      const bondPosition = mockPosition({
        id: 2,
        portfolioId: 1,
        secid: 'SU26238RMFS5',
        type: 'bond',
        quantity: 5,
      });

      vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
        mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
      ]);

      vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
        { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
      ] as any);

      vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
        {
          secid: 'SU26238RMFS5',
          shortName: 'OFZ 26238',
          price: 98.5,
          faceValue: 1000,
        },
      ] as any);

      const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
      cacheMock.getOrFetch.mockImplementation(
        async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
          data: await fetchFn(),
          fromCache: false,
          cachedAt: null,
        }),
      );

      const result = await service.findAll(1);

      expect(result).toHaveLength(1);
      expect(result[0].name).toBe('Test Portfolio');
      expect(result[0].positionCount).toBe(2);
      expect(result[0].shareCount).toBe(1);
      expect(result[0].bondCount).toBe(1);
      // SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
      expect(result[0].totalValue).toBe(7425);
    });

    it('should propagate MOEX errors to the caller', async () => {
      vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
        mockPortfolio({ positions: [mockPosition()] }) as any,
      ]);

      const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
      cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down'));

      await expect(service.findAll(1)).rejects.toThrow('MOEX down');
    });

    it('should only return portfolios belonging to the requesting user', async () => {
      vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);

      await service.findAll(2);

      expect(prisma.portfolio.findMany).toHaveBeenCalledWith({
        where: { userId: 2 },
        include: { positions: true },
        orderBy: { updatedAt: 'desc' },
      });
    });
  });

  describe('findOne', () => {
    it('should throw NotFoundException for non-existent portfolio', async () => {
      vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
      await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
    });

    it('should throw ForbiddenException for wrong user', async () => {
      vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
      await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
    });
  });
});
  • Step 2: Run tests to verify they fail

Run: npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend Expected: FAIL — tests assert behavior that's not yet implemented


Task 3: Implement backend enrichment in PortfolioService.findAll

Files:

  • Modify: apps/backend/src/modules/portfolio/portfolio.service.ts — rewrite findAll method

  • Step 1: Replace the findAll method

Current code (lines 62-67):

async findAll(userId: number) {
  return this.prisma.portfolio.findMany({
    where: { userId },
    orderBy: { updatedAt: 'desc' },
  });
}

Replace with:

async findAll(userId: number) {
  const portfolios = await this.prisma.portfolio.findMany({
    where: { userId },
    include: { positions: true },
    orderBy: { updatedAt: 'desc' },
  });

  const allPositions = portfolios.flatMap((p) => p.positions);
  if (allPositions.length === 0) {
    return portfolios.map((p) => ({
      id: p.id,
      name: p.name,
      description: p.description,
      currency: p.currency,
      createdAt: p.createdAt.toISOString(),
      updatedAt: p.updatedAt.toISOString(),
      totalValue: 0,
      positionCount: 0,
      shareCount: 0,
      bondCount: 0,
    }));
  }

  const enrichedPositions = await this.enrichPositions(allPositions);

  const posByPortfolioId = new Map<number, (typeof enrichedPositions)[number][]>();
  for (let i = 0; i < enrichedPositions.length; i++) {
    const pfId = allPositions[i].portfolioId;
    if (!posByPortfolioId.has(pfId)) {
      posByPortfolioId.set(pfId, []);
    }
    posByPortfolioId.get(pfId)!.push(enrichedPositions[i]);
  }

  return portfolios.map((p) => {
    const positions = posByPortfolioId.get(p.id) ?? [];
    const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0);
    return {
      id: p.id,
      name: p.name,
      description: p.description,
      currency: p.currency,
      createdAt: p.createdAt.toISOString(),
      updatedAt: p.updatedAt.toISOString(),
      totalValue: Math.round(totalValue * 100) / 100,
      positionCount: positions.length,
      shareCount: positions.filter((pos) => pos.type === 'share').length,
      bondCount: positions.filter((pos) => pos.type === 'bond').length,
    };
  });
}
  • Step 2: Run tests to verify they pass

Run: npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend Expected: PASS

  • Step 3: Commit
git add apps/backend/src/modules/portfolio/portfolio.service.ts \
       apps/backend/src/modules/portfolio/portfolio.service.spec.ts
git commit -m "feat(backend): enrich portfolio list with MOEX batch data"

Task 4: Update PortfolioController with new DTO

Files:

  • Modify: apps/backend/src/modules/portfolio/portfolio.controller.ts

  • Step 1: Import PortfolioListResponseDto

Add import at top:

import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
  • Step 2: Update findAll to use new DTO in Swagger

Replace method with ApiResponse decorator:

@Get()
@ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
async findAll(@CurrentUser() user: { sub: number }) {
  const portfolios = await this.portfolioService.findAll(user.sub);
  return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
}

Also add the import:

import { ApiOkResponse } from '@nestjs/swagger';
  • Step 3: Run existing tests to verify no regressions

Run: npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend Expected: PASS

  • Step 4: Commit
git add apps/backend/src/modules/portfolio/portfolio.controller.ts
git commit -m "feat(backend): add Swagger decorators for enriched portfolio list"

Task 5: Update frontend types

Files:

  • Modify: apps/frontend/src/api/responses.ts

  • Step 1: Add new fields to Portfolio interface

Current (lines 138-145):

export interface Portfolio {
  id: number;
  name: string;
  description: string | null;
  currency: string;
  createdAt: string;
  updatedAt: string;
}

Replace with:

export interface Portfolio {
  id: number;
  name: string;
  description: string | null;
  currency: string;
  createdAt: string;
  updatedAt: string;
  totalValue: number;
  positionCount: number;
  shareCount: number;
  bondCount: number;
}
  • Step 2: Verify TypeScript compiles

Run: npx tsc -b apps/frontend Expected: No errors

  • Step 3: Commit
git add apps/frontend/src/api/responses.ts
git commit -m "feat(frontend): add enrichment fields to Portfolio type"

Task 6: Update PortfolioCard to show enriched data

Files:

  • Modify: apps/frontend/src/components/portfolios/PortfolioCard.tsx

  • Step 1: Replace PortfolioCard implementation

Current (lines 1-39):

import { Link } from 'react-router-dom';
import type { Portfolio } from '../../api/responses';

export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
  return (
    <Link
      to={`/portfolios/${portfolio.id}`}
      style={{
        display: 'block',
        padding: 20,
        background: 'var(--color-surface)',
        border: '1px solid #e0e0e0',
        borderRadius: 'var(--border-radius)',
        textDecoration: 'none',
        color: 'inherit',
        transition: 'box-shadow 0.2s',
      }}
      onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
      onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
    >
      <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3>
      {portfolio.description && (
        <p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}>
          {portfolio.description}
        </p>
      )}
      <span
        style={{
          fontSize: 12,
          color: 'var(--color-text-secondary)',
          marginTop: 8,
          display: 'inline-block',
        }}
      >
        {portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
      </span>
    </Link>
  );
}

Replace with:

import { Link } from 'react-router-dom';
import type { Portfolio } from '../../api/responses';

export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
  const chipStyle = (bg: string): React.CSSProperties => ({
    background: bg,
    padding: '4px 10px',
    borderRadius: 6,
    fontSize: 12,
    color: '#fff',
    fontWeight: 500,
  });

  return (
    <Link
      to={`/portfolios/${portfolio.id}`}
      style={{
        display: 'block',
        padding: 20,
        background: 'var(--color-surface)',
        border: '1px solid #e0e0e0',
        borderRadius: 'var(--border-radius)',
        textDecoration: 'none',
        color: 'inherit',
        transition: 'box-shadow 0.2s',
      }}
      onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
      onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
    >
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
        <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
        <div style={{ textAlign: 'right' }}>
          <div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
            {portfolio.totalValue.toLocaleString('ru-RU', {
              minimumFractionDigits: 2,
              maximumFractionDigits: 2,
            })}
          </div>
          <div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
            {portfolio.currency}
          </div>
        </div>
      </div>

      {portfolio.description && (
        <p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
          {portfolio.description}
        </p>
      )}

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        {portfolio.shareCount > 0 && (
          <span style={chipStyle('#1b5e20')}>
            {portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')}
          </span>
        )}
        {portfolio.bondCount > 0 && (
          <span style={chipStyle('#0d47a1')}>
            {portfolio.bondCount} {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')}
          </span>
        )}
        <span style={chipStyle('#424242')}>
          {portfolio.positionCount} {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')}
        </span>
      </div>

      <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
        обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
      </span>
    </Link>
  );
}

function pluralize(n: number, one: string, few: string, many: string): string {
  if (n % 10 === 1 && n % 100 !== 11) return one;
  if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few;
  return many;
}
  • Step 2: Verify frontend builds

Run: npm run build:frontend -w apps/frontend (or npx tsc -b apps/frontend) Expected: No errors

  • Step 3: Commit
git add apps/frontend/src/components/portfolios/PortfolioCard.tsx
git commit -m "feat(frontend): display enriched data in PortfolioCard"

Task 7: Run full test suite and verify

  • Step 1: Run backend tests

Run: npm run test:backend Expected: All tests pass (including new portfolio service tests)

  • Step 2: Run frontend build

Run: npm run build:frontend Expected: Build succeeds

  • Step 3: Run linter

Run: npm run lint Expected: No lint errors