moex-vibe/docs/superpowers/plans/2026-06-14-portfolio-analytics.md
Sergey Krylov 96f003852d
Some checks failed
CI / lint (pull_request) Failing after 1m48s
CI / test (pull_request) Successful in 1m47s
CI / build (pull_request) Successful in 1m53s
CI / lint (push) Failing after 1m59s
CI / test (push) Successful in 1m56s
CI / build (push) Successful in 1m50s
feat: implement portfolio analytics, PnL calculation, and security screener
- Add buyPrice and buyDate to positions for PnL tracking
- Implement backend analytics service for real-time portfolio performance
- Add server-side security screener with filtering, sorting, and pagination
- Update frontend UI with analytics summaries and sortable screener table
- Optimize MOEX API calls with batch fetching and portfolio-specific caching
- Add unit tests for analytics and screener services
2026-06-14 15:59:25 +03:00

1038 lines
29 KiB
Markdown

# Portfolio Analytics — 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:** Add cost basis tracking (buyPrice/buyDate) to positions, calculate unrealized PnL at position and portfolio level, display PnL in UI.
**Architecture:** Extend existing Prisma Position model with buyPrice/buyDate. PnL calculated on backend during enrichment. New PnL columns in position tables. New AnalyticsSummary component.
**Tech Stack:** NestJS, Prisma + SQLite, TanStack Query v5, React 18
---
## File Structure
### Backend (modified files)
- `apps/backend/prisma/schema.prisma` — add `buyPrice` (Float?), `buyDate` (DateTime?) to Position
- `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` — add `buyPrice`, `buyDate`
- `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` — add `buyPrice`, `buyDate`
- `apps/backend/src/modules/portfolio/portfolio.service.ts` — add PnL fields to EnrichedPosition + calculateAnalytics()
### Backend (new files)
- `apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts` — PortfolioAnalyticsDto
### Frontend (modified files)
- `apps/frontend/src/api/responses.ts` — add PnL fields to PositionWithPrice, add PortfolioAnalytics type
- `apps/frontend/src/api/portfolio.ts` — add buyPrice/buyDate to add/update position types
- `apps/frontend/src/hooks/usePositionMutations.ts` — pass buyPrice/buyDate
- `apps/frontend/src/components/portfolios/SharePositionRow.tsx` — add buyPrice edit + PnL columns
- `apps/frontend/src/components/portfolios/BondPositionRow.tsx` — add buyPrice edit + PnL columns
- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` — add analytics section
- `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` — add buyPrice to add position form
### Frontend (new files)
- `apps/frontend/src/components/portfolios/AnalyticsSummary.tsx` — portfolio-level analytics card
---
### Task 1: Prisma schema — add buyPrice and buyDate to Position
**Files:**
- Modify: `apps/backend/prisma/schema.prisma`
- Run: `npx prisma migrate dev`
- [ ] **Add buyPrice and buyDate fields to Position model**
```prisma
model Position {
id Int @id @default(autoincrement())
portfolioId Int
secid String
type String @default("share")
quantity Int
buyPrice Float? // NEW
buyDate DateTime? // NEW
notes String?
tags String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
@@unique([portfolioId, secid])
}
```
- [ ] **Run Prisma migration**
```bash
npx prisma migrate dev --name add-buy-price-to-position -w apps/backend
```
- [ ] **Generate Prisma client**
```bash
npx prisma generate -w apps/backend
```
---
### Task 2: Backend DTO updates — add-position and update-position
**Files:**
- Modify: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`
- Modify: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`
- [ ] **Add buyPrice and buyDate to AddPositionDto**
```typescript
import {
IsString, IsOptional, IsInt, Min, IsArray, IsIn,
MaxLength, MinLength, IsNumber,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
const TAGS = [
'DIVIDEND', 'GROWTH', 'DEFENSIVE', 'SPECULATIVE',
'BOND', 'ETF', 'GOVERNMENT', 'CASH',
] as const;
export class AddPositionDto {
@ApiProperty({ example: 'SBER' })
@IsString()
@MinLength(1)
@MaxLength(50)
secid!: string;
@ApiProperty({ example: 10 })
@IsInt()
@Min(0)
quantity!: number;
@ApiPropertyOptional({ example: 250.5 })
@IsNumber()
@Min(0)
@IsOptional()
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' })
@IsString()
@IsOptional()
buyDate?: string;
@ApiPropertyOptional({ example: 'Покупка на дип' })
@IsString()
@IsOptional()
@MaxLength(500)
notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS })
@IsArray()
@IsIn(TAGS, { each: true })
@IsOptional()
tags?: string[];
}
```
- [ ] **Add buyPrice and buyDate to UpdatePositionDto**
```typescript
import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength, IsNumber } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
const TAGS = [
'DIVIDEND', 'GROWTH', 'DEFENSIVE', 'SPECULATIVE',
'BOND', 'ETF', 'GOVERNMENT', 'CASH',
] as const;
export class UpdatePositionDto {
@ApiPropertyOptional({ example: 15 })
@IsInt()
@Min(0)
@IsOptional()
quantity?: number;
@ApiPropertyOptional({ example: 260.0 })
@IsNumber()
@Min(0)
@IsOptional()
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-15' })
@IsString()
@IsOptional()
buyDate?: string;
@ApiPropertyOptional({ example: 'Докупка' })
@IsString()
@IsOptional()
@MaxLength(500)
notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS })
@IsArray()
@IsIn(TAGS, { each: true })
@IsOptional()
tags?: string[];
}
```
---
### Task 3: Backend PortfolioService — PnL enrichment
**Files:**
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts`
- [ ] **Add PnL fields to EnrichedPosition interface and implement calculateAnalytics**
Replace the `EnrichedPosition` interface and methods in `portfolio.service.ts`:
```typescript
export interface EnrichedPosition {
id: number;
secid: string;
shortName: string | null;
type: string;
quantity: number;
notes: string | null;
tags: string[] | null;
buyPrice: number | null; // NEW
buyDate: string | null; // NEW
currentPrice: number | null;
currentValue: number | null;
totalCost: number | null; // NEW: buyPrice * quantity
unrealizedPnl: number | null; // NEW: currentValue - totalCost
unrealizedPnlPercent: number | null; // NEW: (currentPrice - buyPrice) / buyPrice * 100
weightPercent: number;
change?: number | null;
changePercent?: number | null;
yieldToMaturity?: number | null;
duration?: number | null;
couponValue?: number | null;
couponPercent?: number | null;
nextCouponDate?: string | null;
matDate?: string | null;
accruedInt?: number | null;
bid?: number | null;
offer?: number | null;
couponPeriod?: number | null;
bondType?: string | null;
offerDate?: string | null;
}
export interface PortfolioAnalytics {
totalCost: number | null;
totalValue: number;
totalPnl: number | null;
totalPnlPercent: number | null;
totalDividendIncome: number;
totalReturn: number | null;
}
```
- [ ] **Update enrichPositions to pass buyPrice/buyDate through enrichment**
In the `enrichPositions` method, update the base object constructor:
```typescript
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,
buyPrice: (pos as any).buyPrice ?? null, // NEW
buyDate: (pos as any).buyDate // NEW
? ((pos as any).buyDate as Date).toISOString().split('T')[0]
: null as string | null,
weightPercent: 0,
currentPrice: null as number | null,
currentValue: null as number | null,
totalCost: null as number | null, // NEW
unrealizedPnl: null as number | null, // NEW
unrealizedPnlPercent: null as number | null, // NEW
};
```
- [ ] **Update buildSharePosition to calculate PnL**
```typescript
private buildSharePosition(
pos: { id: number; secid: string; quantity: number; buyPrice?: number | null },
base: EnrichedPosition,
data: MoexShareMarketData | undefined,
): EnrichedPosition {
if (!data) return { ...base, currentPrice: null, currentValue: null, totalCost: null, unrealizedPnl: null, unrealizedPnlPercent: null };
const currentPrice = data.last;
const currentValue = currentPrice !== null ? currentPrice * pos.quantity : null;
const totalCost = pos.buyPrice != null ? pos.buyPrice * pos.quantity : null;
const unrealizedPnl = totalCost != null && currentValue != null ? currentValue - totalCost : null;
const unrealizedPnlPercent = pos.buyPrice != null && currentPrice != null
? ((currentPrice - pos.buyPrice) / pos.buyPrice) * 100
: null;
return {
...base,
shortName: data.shortName,
currentPrice,
change: data.lastChange,
changePercent: data.lastChangePrcnt,
currentValue,
totalCost,
unrealizedPnl,
unrealizedPnlPercent,
};
}
```
- [ ] **Update buildBondPosition to calculate PnL**
```typescript
private buildBondPosition(
pos: { id: number; secid: string; quantity: number; buyPrice?: number | null },
base: EnrichedPosition,
data: MoexBondPositionData | undefined,
): EnrichedPosition {
if (!data) return { ...base, currentPrice: null, currentValue: null, totalCost: null, unrealizedPnl: null, unrealizedPnlPercent: null };
const currentPrice = data.price;
const currentValue = data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
const totalCost = pos.buyPrice != null ? pos.buyPrice * pos.quantity : null;
const unrealizedPnl = totalCost != null && currentValue != null ? currentValue - totalCost : null;
const unrealizedPnlPercent = pos.buyPrice != null && currentPrice != null
? ((currentPrice - pos.buyPrice) / pos.buyPrice) * 100
: null;
return {
...base,
shortName: data.shortName,
currentPrice,
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,
totalCost,
unrealizedPnl,
unrealizedPnlPercent,
};
}
```
- [ ] **Update findOne to calculate and return analytics**
Replace the final return block in `findOne`:
```typescript
const positionsWithWeights: EnrichedPosition[] = positionsWithPrices.map((p) => {
const weightPercent = totalValue > 0 ? ((p.currentValue ?? 0) / totalValue) * 100 : 0;
return {
...p,
weightPercent: Math.round(weightPercent * 2) / 2,
};
});
const analytics = this.calculateAnalytics(positionsWithWeights);
return {
id: portfolio.id,
name: portfolio.name,
description: portfolio.description,
currency: portfolio.currency,
createdAt: portfolio.createdAt.toISOString(),
updatedAt: portfolio.updatedAt.toISOString(),
positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100,
analytics,
};
```
- [ ] **Add calculateAnalytics private method**
```typescript
private calculateAnalytics(positions: EnrichedPosition[]): PortfolioAnalytics {
const totalCost = positions.reduce(
(sum, p) => sum + (p.totalCost ?? 0),
0,
);
const totalValue = positions.reduce(
(sum, p) => sum + (p.currentValue ?? 0),
0,
);
const totalPnl = positions.reduce(
(sum, p) => sum + (p.unrealizedPnl ?? 0),
0,
);
const totalPnlPercent = totalCost > 0 ? (totalPnl / totalCost) * 100 : null;
return {
totalCost: totalCost > 0 ? Math.round(totalCost * 100) / 100 : null,
totalValue: Math.round(totalValue * 100) / 100,
totalPnl: totalPnl !== 0 ? Math.round(totalPnl * 100) / 100 : null,
totalPnlPercent: totalPnlPercent != null ? Math.round(totalPnlPercent * 100) / 100 : null,
totalDividendIncome: 0,
totalReturn: totalPnlPercent,
};
}
```
- [ ] **Update addPosition to accept buyPrice/buyDate**
Replace the `data` block in the `create` call inside `addPosition`:
```typescript
return this.prisma.position.create({
data: {
portfolioId,
secid: dto.secid,
type,
quantity: dto.quantity,
buyPrice: dto.buyPrice ?? null,
buyDate: dto.buyDate ? new Date(dto.buyDate) : null,
notes: dto.notes ?? null,
tags: dto.tags ? JSON.stringify(dto.tags) : null,
},
});
```
- [ ] **Update updatePosition to accept buyPrice/buyDate**
Replace the `data` block in the `update` call inside `updatePosition`:
```typescript
return this.prisma.position.update({
where: { id: positionId },
data: {
...(dto.quantity !== undefined && { quantity: dto.quantity }),
...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }),
...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }),
...(dto.notes !== undefined && { notes: dto.notes }),
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
},
});
```
---
### Task 4: Backend AnalyticsResponseDto
**Files:**
- Create: `apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts`
- [ ] **Create AnalyticsResponseDto**
```typescript
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AnalyticsResponseDto {
@ApiPropertyOptional()
totalCost: number | null;
@ApiProperty()
totalValue: number;
@ApiPropertyOptional()
totalPnl: number | null;
@ApiPropertyOptional()
totalPnlPercent: number | null;
@ApiProperty()
totalDividendIncome: number;
@ApiPropertyOptional()
totalReturn: number | null;
}
```
---
### Task 5: Backend tests — PnL calculation
**Files:**
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts`
- [ ] **Add test: PnL calculation for share position**
Add inside `describe('findOne')` block:
```typescript
it('should calculate PnL for share position with buyPrice', async () => {
const sharePosition = mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
buyPrice: 200,
buyDate: new Date('2026-06-01'),
});
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(
mockPortfolio({ positions: [sharePosition] }) as any,
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any);
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([]);
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.findOne(1, 1);
expect(result.positions).toHaveLength(1);
expect(result.positions[0].buyPrice).toBe(200);
expect(result.positions[0].totalCost).toBe(2000); // 200 * 10
expect(result.positions[0].unrealizedPnl).toBe(500); // 2500 - 2000
expect(result.positions[0].unrealizedPnlPercent).toBe(25); // (250 - 200) / 200 * 100
expect(result.analytics.totalCost).toBe(2000);
expect(result.analytics.totalPnl).toBe(500);
expect(result.analytics.totalPnlPercent).toBe(25);
});
it('should return null PnL when buyPrice is not set', async () => {
const sharePosition = mockPosition({
id: 1,
secid: 'SBER',
type: 'share',
quantity: 10,
});
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(
mockPortfolio({ positions: [sharePosition] }) as any,
);
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any);
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([]);
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.findOne(1, 1);
expect(result.positions[0].totalCost).toBeNull();
expect(result.positions[0].unrealizedPnl).toBeNull();
expect(result.positions[0].unrealizedPnlPercent).toBeNull();
});
```
- [ ] **Run tests to verify**
```bash
npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend
```
Expected: all tests pass (including existing ones + 2 new ones)
---
### Task 6: Frontend types — add PnL fields to responses.ts
**Files:**
- Modify: `apps/frontend/src/api/responses.ts`
- [ ] **Add PnL fields to PositionWithPrice and add PortfolioAnalytics type**
Add new fields to `PositionWithPrice`:
```typescript
export interface PositionWithPrice {
// ... existing fields
buyPrice?: number | null;
buyDate?: string | null;
totalCost?: number | null;
unrealizedPnl?: number | null;
unrealizedPnlPercent?: number | null;
}
```
Add new types:
```typescript
export interface PortfolioAnalytics {
totalCost: number | null;
totalValue: number;
totalPnl: number | null;
totalPnlPercent: number | null;
totalDividendIncome: number;
totalReturn: number | null;
}
```
Update `PortfolioDetail` to include analytics:
```typescript
export interface PortfolioDetail extends Portfolio {
positions: PositionWithPrice[];
totalValue: number;
analytics: PortfolioAnalytics; // NEW
}
```
---
### Task 7: Frontend API client + hooks — pass buyPrice/buyDate
**Files:**
- Modify: `apps/frontend/src/api/portfolio.ts`
- Modify: `apps/frontend/src/hooks/usePositionMutations.ts`
- [ ] **Update addPosition and updatePosition types in api/portfolio.ts**
```typescript
export function addPosition(
portfolioId: number,
data: { secid: string; quantity: number; buyPrice?: number; buyDate?: string; notes?: string; tags?: string[] },
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
method: 'POST',
body: data,
});
}
export function updatePosition(
portfolioId: number,
positionId: number,
data: { quantity?: number; buyPrice?: number; buyDate?: string; notes?: string; tags?: string[] },
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
method: 'PATCH',
body: data,
});
}
```
- [ ] **Update usePositionMutations to accept buyPrice/buyDate**
Update the `add` mutation function type:
```typescript
const add = useMutation({
mutationFn: (data: {
secid: string;
quantity: number;
buyPrice?: number;
buyDate?: string;
notes?: string;
tags?: string[];
}) => addPosition(portfolioId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
},
});
```
Update the `update` mutation function type:
```typescript
const update = useMutation({
mutationFn: ({
positionId,
data,
}: {
positionId: number;
data: { quantity?: number; buyPrice?: number; buyDate?: string; notes?: string; tags?: string[] };
}) => updatePosition(portfolioId, positionId, data),
// ... rest unchanged
});
```
Update the optimistic update to handle buyPrice:
```typescript
queryClient.setQueryData(['portfolio', portfolioId], (old: any) => {
if (!old) return old;
return {
...old,
positions: old.positions.map((p: any) =>
p.id === positionId
? {
...p,
...(data.quantity !== undefined ? { quantity: data.quantity } : {}),
...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}),
}
: p,
),
};
});
```
---
### Task 8: Frontend SharePositionRow — add PnL columns
**Files:**
- Modify: `apps/frontend/src/components/portfolios/SharePositionRow.tsx`
- [ ] **Add buyPrice inline editing and PnL columns**
Replace the `<tr>` content with additional cells between колонка «Стоимость» and «Доля»:
```typescript
// After currentValue column (index 6), before weightPercent column:
{/* Цена покупки */}
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.buyPrice != null
? position.buyPrice.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
{/* PnL */}
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.unrealizedPnl != null ? (
<span style={{ color: position.unrealizedPnl >= 0 ? '#43a047' : '#e53935' }}>
{position.unrealizedPnl >= 0 ? '+' : ''}
{position.unrealizedPnl.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : '—'}
</td>
{/* PnL% */}
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.unrealizedPnlPercent != null ? (
<span style={{ color: position.unrealizedPnlPercent >= 0 ? '#43a047' : '#e53935' }}>
{position.unrealizedPnlPercent >= 0 ? '+' : ''}
{position.unrealizedPnlPercent.toFixed(2)}%
</span>
) : '—'}
</td>
```
Also update `onUpdate` props interface to accept `buyPrice`:
```typescript
interface Props {
position: PositionWithPrice;
onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
onDelete: () => void;
}
```
---
### Task 9: Frontend BondPositionRow — add PnL columns
**Files:**
- Modify: `apps/frontend/src/components/portfolios/BondPositionRow.tsx`
- [ ] **Add same PnL columns after НКД column (index 13), same logic as SharePositionRow**
Insert after the totalAccrued cell:
```typescript
{/* Цена покупки */}
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.buyPrice != null
? position.buyPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—'}
</td>
{/* PnL */}
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.unrealizedPnl != null ? (
<span style={{ color: position.unrealizedPnl >= 0 ? '#43a047' : '#e53935' }}>
{position.unrealizedPnl >= 0 ? '+' : ''}
{position.unrealizedPnl.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
) : '—'}
</td>
{/* PnL% */}
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.unrealizedPnlPercent != null ? (
<span style={{ color: position.unrealizedPnlPercent >= 0 ? '#43a047' : '#e53935' }}>
{position.unrealizedPnlPercent >= 0 ? '+' : ''}
{position.unrealizedPnlPercent.toFixed(2)}%
</span>
) : '—'}
</td>
```
Also update `onUpdate` props:
```typescript
interface Props {
position: PositionWithPrice;
onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
onDelete: () => void;
}
```
Update the SharePositionTable and BondPositionTable `<th>` headers to include the new columns ("Цена покупки", "PnL", "PnL%").
---
### Task 10: Frontend AnalyticsSummary + PortfolioSummary update
**Files:**
- Create: `apps/frontend/src/components/portfolios/AnalyticsSummary.tsx`
- Modify: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx`
- [ ] **Create AnalyticsSummary component**
```typescript
import type { PortfolioAnalytics } from '../../api/responses';
interface Props {
analytics: PortfolioAnalytics;
currency: string;
}
export function AnalyticsSummary({ analytics, currency }: Props) {
return (
<div
style={{
display: 'flex',
gap: 32,
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
}}
>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Общая стоимость
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>
{analytics.totalValue.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
<span style={{ fontSize: 14, fontWeight: 400, color: 'var(--color-text-secondary)', marginLeft: 4 }}>
{currency}
</span>
</div>
</div>
{analytics.totalCost != null && (
<>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Вложено
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>
{analytics.totalCost.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
PnL
</div>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: analytics.totalPnl != null && analytics.totalPnl >= 0 ? '#43a047' : '#e53935',
}}
>
{analytics.totalPnl != null
? `${analytics.totalPnl >= 0 ? '+' : ''}${analytics.totalPnl.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
: '—'}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Доходность
</div>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: analytics.totalPnlPercent != null && analytics.totalPnlPercent >= 0 ? '#43a047' : '#e53935',
}}
>
{analytics.totalPnlPercent != null
? `${analytics.totalPnlPercent >= 0 ? '+' : ''}${analytics.totalPnlPercent.toFixed(2)}%`
: '—'}
</div>
</div>
</>
)}
</div>
);
}
```
- [ ] **Update PortfolioSummary to include AnalyticsSummary**
```typescript
import { AllocationChart } from './AllocationChart';
import { AnalyticsSummary } from './AnalyticsSummary';
import type { PortfolioDetail } from '../../api/responses';
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div
style={{
display: 'flex',
gap: 32,
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
}}
>
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Позиций
</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
</div>
</div>
<AnalyticsSummary analytics={portfolio.analytics} currency={portfolio.currency} />
</div>
);
}
```
---
### Task 11: Frontend PortfolioDetailPage — add buyPrice to add position form
**Files:**
- Modify: `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx`
- [ ] **Add buyPrice input field to the add position form**
Add state variable:
```typescript
const [newBuyPrice, setNewBuyPrice] = useState('');
```
Add the input field after the quantity input in the add form:
```typescript
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
Цена покупки
</label>
<input
type="number"
min={0}
step={0.01}
value={newBuyPrice}
onChange={(e) => setNewBuyPrice(e.target.value)}
placeholder="250.50"
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
width: 100,
}}
/>
</div>
```
Update `handleAddPosition`:
```typescript
function handleAddPosition() {
if (!newSecid.trim() || !parseInt(newQty, 10)) return;
addPosition.mutate(
{
secid: newSecid.trim().toUpperCase(),
quantity: parseInt(newQty, 10),
buyPrice: newBuyPrice ? parseFloat(newBuyPrice) : undefined,
},
{
onSuccess: () => {
setShowAddForm(false);
setNewSecid('');
setNewQty('1');
setNewBuyPrice('');
},
},
);
}
```
- [ ] **Verify frontend builds**
```bash
npm run build:frontend
```
Expected: no TypeScript errors
---
### Task 12: Verify everything works
- [ ] **Run all backend tests**
```bash
npx vitest run -w apps/backend
```
Expected: all tests pass
- [ ] **Run frontend tests**
```bash
npx vitest run -w apps/frontend
```
Expected: all tests pass
- [ ] **Run lint**
```bash
npm run lint
```
Expected: no errors
- [ ] **Commit**
```bash
git add apps/backend/prisma/schema.prisma \
apps/backend/src/modules/portfolio/dto/add-position.dto.ts \
apps/backend/src/modules/portfolio/dto/update-position.dto.ts \
apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts \
apps/backend/src/modules/portfolio/portfolio.service.ts \
apps/backend/src/modules/portfolio/portfolio.service.spec.ts \
apps/frontend/src/api/responses.ts \
apps/frontend/src/api/portfolio.ts \
apps/frontend/src/hooks/usePositionMutations.ts \
apps/frontend/src/components/portfolios/SharePositionRow.tsx \
apps/frontend/src/components/portfolios/BondPositionRow.tsx \
apps/frontend/src/components/portfolios/PortfolioSummary.tsx \
apps/frontend/src/components/portfolios/AnalyticsSummary.tsx \
apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx \
apps/backend/prisma/migrations
git commit -m "feat: add portfolio analytics with PnL and cost basis tracking"
```