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 (- [ ]) 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

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"