feat: enrich portfolio list with total value and position breakdown from MOEX #10

Merged
ksv741 merged 7 commits from perf/portfolio-enricher-optimization into main 2026-06-14 14:19:56 +03:00
9 changed files with 1093 additions and 13 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
.superpowers/
.env .env
*.log *.log
.DS_Store .DS_Store

View File

@ -0,0 +1,16 @@
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;
}

View File

@ -1,5 +1,6 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger';
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
@ -15,6 +16,7 @@ export class PortfolioController {
@Get() @Get()
@ApiOperation({ summary: 'Get all portfolios for current user' }) @ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
async findAll(@CurrentUser() user: { sub: number }) { async findAll(@CurrentUser() user: { sub: number }) {
const portfolios = await this.portfolioService.findAll(user.sub); const portfolios = await this.portfolioService.findAll(user.sub);
return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; return { data: portfolios, meta: { cachedAt: null, fromCache: false } };

View File

@ -0,0 +1,201 @@
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);
});
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);
});
});
});

View File

@ -60,10 +60,55 @@ export class PortfolioService {
} }
async findAll(userId: number) { async findAll(userId: number) {
return this.prisma.portfolio.findMany({ const portfolios = await this.prisma.portfolio.findMany({
where: { userId }, where: { userId },
include: { positions: true },
orderBy: { updatedAt: 'desc' }, 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,
};
});
} }
async findOne(userId: number, id: number) { async findOne(userId: number, id: number) {

View File

@ -142,6 +142,10 @@ export interface Portfolio {
currency: string; currency: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
totalValue: number;
positionCount: number;
shareCount: number;
bondCount: number;
} }
export interface PositionWithPrice { export interface PositionWithPrice {

View File

@ -2,6 +2,15 @@ import { Link } from 'react-router-dom';
import type { Portfolio } from '../../api/responses'; import type { Portfolio } from '../../api/responses';
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { 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 ( return (
<Link <Link
to={`/portfolios/${portfolio.id}`} to={`/portfolios/${portfolio.id}`}
@ -18,22 +27,61 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
> >
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3> <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 && ( {portfolio.description && (
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}> <p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
{portfolio.description} {portfolio.description}
</p> </p>
)} )}
<span
style={{ <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
fontSize: 12, {portfolio.shareCount > 0 && (
color: 'var(--color-text-secondary)', <span style={chipStyle('#1b5e20')}>
marginTop: 8, {portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')}
display: 'inline-block', </span>
}} )}
> {portfolio.bondCount > 0 && (
{portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} <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> </span>
</Link> </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;
}

View File

@ -0,0 +1,613 @@
# 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**
```typescript
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**
```bash
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**
```typescript
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):
```typescript
async findAll(userId: number) {
return this.prisma.portfolio.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
});
}
```
Replace with:
```typescript
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**
```bash
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:
```typescript
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
```
- [ ] **Step 2: Update findAll to use new DTO in Swagger**
Replace method with ApiResponse decorator:
```typescript
@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:
```typescript
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**
```bash
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):
```typescript
export interface Portfolio {
id: number;
name: string;
description: string | null;
currency: string;
createdAt: string;
updatedAt: string;
}
```
Replace with:
```typescript
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**
```bash
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):
```typescript
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:
```typescript
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**
```bash
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

View File

@ -0,0 +1,150 @@
# Portfolio List Enrichment
**Date:** 2026-06-14
**Status:** Draft
## Problem
`GET /api/v1/portfolios` возвращает сырые записи из БД без какой-либо обогащённой информации. На фронтенде карточка портфеля показывает только название, описание, валюту и дату обновления. Пользователь не видит общую стоимость портфеля, количество позиций и распределение по типам без перехода на страницу деталей.
## Solution
Обогатить `GET /api/v1/portfolios` данными из MOEX, используя тот же batch-подход, что и в `enrichPositions` для детального эндпоинта.
### Backend
#### Новый DTO: `PortfolioListResponseDto`
```typescript
class PortfolioListResponseDto extends PortfolioResponseDto {
totalValue: number;
positionCount: number;
shareCount: number;
bondCount: number;
}
```
#### Изменение `PortfolioService.findAll(userId)`
Текущий код:
```typescript
async findAll(userId: number) {
return this.prisma.portfolio.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
});
}
```
Новый код:
```typescript
async findAll(userId: number) {
const portfolios = await this.prisma.portfolio.findMany({
where: { userId },
include: { positions: true },
orderBy: { updatedAt: 'desc' },
});
// Собрать все unique secid из всех портфелей
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,
}));
}
// Один batch-запрос к MOEX для всех secid разом
const enrichedPositions = await this.enrichPositions(allPositions);
const posByPortfolioId = new Map<number, EnrichedPosition[]>();
for (const pos of enrichedPositions) {
const pfId = allPositions.find((ap) => ap.id === pos.id)!.portfolioId;
if (!posByPortfolioId.has(pfId)) posByPortfolioId.set(pfId, []);
posByPortfolioId.get(pfId)!.push(pos);
}
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,
};
});
}
```
#### Изменение `PortfolioController.findAll`
Ответ типизируется как `PortfolioListResponseDto[]`.
### Frontend
#### Тип `Portfolio` в `responses.ts` — добавить поля
```typescript
export interface Portfolio {
id: number;
name: string;
description: string | null;
currency: string;
createdAt: string;
updatedAt: string;
totalValue: number;
positionCount: number;
shareCount: number;
bondCount: number;
}
```
#### `PortfolioCard.tsx` — расширить
Показывать:
1. Название (слева) + общая стоимость (справа, крупно, с валютой)
2. Описание (если есть)
3. Чипсы: `N акций`, `M облигаций`, `K позиций` (тёмный фон, белый текст)
4. Дата обновления
#### `PortfoliosListPage.tsx` — без изменений
### Data Flow
```
GET /api/v1/portfolios
→ PortfolioService.findAll(userId)
→ prisma.portfolio.findMany({ include: { positions: true } })
→ Collect all unique secids across all portfolios
→ enrichPositions(allPositions) // ONE batch MOEX call (cached)
→ fetchShareBatch(shareSecids) // batched
→ fetchBondBatch(bondSecids) // batched
→ Compute aggregated fields per portfolio
→ Return PortfolioListResponseDto[]
```
Кеширование: batch-запросы к MOEX уже кешируются через `CacheService` с `marketDataTtl` (900s). При повторном запросе в течение 15 минут ответ будет из кеша.
### Risks and Edge Cases
| Risk | Mitigation |
|---|---|
| **Позиций нет ни в одном портфеле** | Early return без вызова MOEX |
| **MOEX недоступен** | enrichPositions уже обрабатывает `null` данные — totalValue будет 0, чипсы покажут только количество |
| **Много портфелей с сотнями позиций** | Те же batch-запросы, что и у findOne — не более 2 HTTP-вызовов |
| **Кеш прогрет от findOne** | List получит данные мгновенно |
| **Пустой portfolioId Map** | positions = [] → totalValue = 0, positionCount = 0 |