# 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` — 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` - [x] **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]) } ``` - [x] **Run Prisma migration** ```bash npx prisma migrate dev --name add-buy-price-to-position -w apps/backend ``` - [x] **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` - [x] **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[]; } ``` - [x] **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` - [x] **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; } ``` - [x] **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 }; ``` - [x] **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, }; } ``` - [x] **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, }; } ``` - [x] **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, }; ``` - [x] **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, }; } ``` - [x] **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, }, }); ``` - [x] **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` - [x] **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` - [x] **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 }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ 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 }; cacheMock.getOrFetch.mockImplementation( async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ 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(); }); ``` - [x] **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` - [x] **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` - [x] **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(`/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(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { method: 'PATCH', body: data, }); } ``` - [x] **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` - [x] **Add buyPrice inline editing and PnL columns** Replace the `` content with additional cells between колонка «Стоимость» and «Доля»: ```typescript // After currentValue column (index 6), before weightPercent column: {/* Цена покупки */} {position.buyPrice != null ? position.buyPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, }) : '—'} {/* PnL */} {position.unrealizedPnl != null ? ( = 0 ? '#43a047' : '#e53935' }}> {position.unrealizedPnl >= 0 ? '+' : ''} {position.unrealizedPnl.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} ) : '—'} {/* PnL% */} {position.unrealizedPnlPercent != null ? ( = 0 ? '#43a047' : '#e53935' }}> {position.unrealizedPnlPercent >= 0 ? '+' : ''} {position.unrealizedPnlPercent.toFixed(2)}% ) : '—'} ``` 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` - [x] **Add same PnL columns after НКД column (index 13), same logic as SharePositionRow** Insert after the totalAccrued cell: ```typescript {/* Цена покупки */} {position.buyPrice != null ? position.buyPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} {/* PnL */} {position.unrealizedPnl != null ? ( = 0 ? '#43a047' : '#e53935' }}> {position.unrealizedPnl >= 0 ? '+' : ''} {position.unrealizedPnl.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ) : '—'} {/* PnL% */} {position.unrealizedPnlPercent != null ? ( = 0 ? '#43a047' : '#e53935' }}> {position.unrealizedPnlPercent >= 0 ? '+' : ''} {position.unrealizedPnlPercent.toFixed(2)}% ) : '—'} ``` Also update `onUpdate` props: ```typescript interface Props { position: PositionWithPrice; onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; onDelete: () => void; } ``` Update the SharePositionTable and BondPositionTable `` 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` - [x] **Create AnalyticsSummary component** ```typescript import type { PortfolioAnalytics } from '../../api/responses'; interface Props { analytics: PortfolioAnalytics; currency: string; } export function AnalyticsSummary({ analytics, currency }: Props) { return (
Общая стоимость
{analytics.totalValue.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} {currency}
{analytics.totalCost != null && ( <>
Вложено
{analytics.totalCost.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}
PnL
= 0 ? '#43a047' : '#e53935', }} > {analytics.totalPnl != null ? `${analytics.totalPnl >= 0 ? '+' : ''}${analytics.totalPnl.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}` : '—'}
Доходность
= 0 ? '#43a047' : '#e53935', }} > {analytics.totalPnlPercent != null ? `${analytics.totalPnlPercent >= 0 ? '+' : ''}${analytics.totalPnlPercent.toFixed(2)}%` : '—'}
)}
); } ``` - [x] **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 (
Позиций
{portfolio.positions.length}
); } ``` --- ### Task 11: Frontend PortfolioDetailPage — add buyPrice to add position form **Files:** - Modify: `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` - [x] **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
setNewBuyPrice(e.target.value)} placeholder="250.50" style={{ padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 'var(--border-radius)', fontSize: 14, width: 100, }} />
``` 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(''); }, }, ); } ``` - [x] **Verify frontend builds** ```bash npm run build:frontend ``` Expected: no TypeScript errors --- ### Task 12: Verify everything works - [x] **Run all backend tests** ```bash npx vitest run -w apps/backend ``` Expected: all tests pass - [x] **Run frontend tests** ```bash npx vitest run -w apps/frontend ``` Expected: all tests pass - [x] **Run lint** ```bash npm run lint ``` Expected: no errors - [x] **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" ```