29 KiB
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 (
- [x]) 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— addbuyPrice(Float?),buyDate(DateTime?) to Positionapps/backend/src/modules/portfolio/dto/add-position.dto.ts— addbuyPrice,buyDateapps/backend/src/modules/portfolio/dto/update-position.dto.ts— addbuyPrice,buyDateapps/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 typeapps/frontend/src/api/portfolio.ts— add buyPrice/buyDate to add/update position typesapps/frontend/src/hooks/usePositionMutations.ts— pass buyPrice/buyDateapps/frontend/src/components/portfolios/SharePositionRow.tsx— add buyPrice edit + PnL columnsapps/frontend/src/components/portfolios/BondPositionRow.tsx— add buyPrice edit + PnL columnsapps/frontend/src/components/portfolios/PortfolioSummary.tsx— add analytics sectionapps/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
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
npx prisma migrate dev --name add-buy-price-to-position -w apps/backend
- Generate Prisma client
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
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
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:
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:
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
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
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:
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
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:
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:
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
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:
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
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:
export interface PositionWithPrice {
// ... existing fields
buyPrice?: number | null;
buyDate?: string | null;
totalCost?: number | null;
unrealizedPnl?: number | null;
unrealizedPnlPercent?: number | null;
}
Add new types:
export interface PortfolioAnalytics {
totalCost: number | null;
totalValue: number;
totalPnl: number | null;
totalPnlPercent: number | null;
totalDividendIncome: number;
totalReturn: number | null;
}
Update PortfolioDetail to include analytics:
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
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:
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:
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:
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 «Доля»:
// 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:
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:
{/* Цена покупки */}
<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 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
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
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:
const [newBuyPrice, setNewBuyPrice] = useState('');
Add the input field after the quantity input in the add form:
<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:
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
npm run build:frontend
Expected: no TypeScript errors
Task 12: Verify everything works
- Run all backend tests
npx vitest run -w apps/backend
Expected: all tests pass
- Run frontend tests
npx vitest run -w apps/frontend
Expected: all tests pass
- Run lint
npm run lint
Expected: no errors
- Commit
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"