Some checks failed
- 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
1038 lines
29 KiB
Markdown
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"
|
|
```
|