Sergey Krylov 5b9d7f3a27
All checks were successful
CI / ci (pull_request) Successful in 3m20s
CI / ci (push) Successful in 3m1s
docs: fix historical files to new structure and update
2026-06-18 22:04:09 +03:00

20 KiB
Raw Permalink Blame History

Portfolio Analytics — Design Specification (SDD)

Date: 2026-06-14 Status: Draft Author: AI Assistant


1. Product Requirements Document (PRD)

1.1 Product Vision

Добавить в MoexVibe аналитику доходности портфелей: расчёт PnL (прибыль/убыток) по каждой позиции и по портфелю в целом, учёт дивидендного дохода, сравнение фактического распределения с целевым. Пользователь может указать цену покупки для каждой позиции и видеть реальную доходность своих инвестиций.

1.2 Target Audience

Текущие пользователи MoexVibe — частные инвесторы, которые уже используют портфели для отслеживания состава вложений. Аналитика превращает портфель из «учёта» в инструмент оценки эффективности инвестиций.

1.3 Scope

In Scope Out of Scope (Future Phases)
Цена покупки (buyPrice) на позицию История транзакций (buy/sell log)
Дата покупки (buyDate) на позицию XIRR / time-weighted return
Unrealized PnL (абсолютный и %) График стоимости портфеля во времени
Dividend income по позиции Налоговая отчётность
Portfolio-level PnL (total + %) Multi-currency конверсия
Target allocation comparison (факт vs цель) Scheduled snapshots / history
Цветовая индикация PnL (зелёный/красный) Экспорт отчётов

1.4 User Stories

  • US-AN-001: Пользователь указывает цену покупки при добавлении позиции в портфель
  • US-AN-002: Пользователь редактирует цену покупки существующей позиции
  • US-AN-003: Пользователь видит unrealized PnL (в валюте) по каждой позиции
  • US-AN-004: Пользователь видит unrealized PnL (%) по каждой позиции
  • US-AN-005: Пользователь видит суммарный PnL по всему портфелю
  • US-AN-006: Пользователь видит общую доходность портфеля в процентах
  • US-AN-007: Пользователь видит дивидендный доход по позиции (если buyPrice указан)
  • US-AN-008: Пользователь задаёт целевое распределение (shares/bonds %)
  • US-AN-009: Пользователь видит отклонение факта от цели

1.5 Non-Functional Requirements

  • PnL рассчитывается на backend при enrichment (не на frontend)
  • Дивиденды кешируются с TTL 86400s (как сейчас)
  • PnL для облигаций учитывает faceValue и НКД
  • При отсутствии buyPrice колонки PnL показывают

2. Domain Model

Position (расширение существующей модели) {
  // ... существующие поля
  buyPrice: number | null    // цена покупки за единицу (в валюте портфеля)
  buyDate: string | null     // дата покупки (ISO date, опционально)

  // Computed at read time (EnrichedPosition):
  currentPrice: number | null
  currentValue: number | null          // quantity * currentPrice
  totalCost: number | null              // buyPrice * quantity
  unrealizedPnl: number | null          // currentValue - totalCost
  unrealizedPnlPercent: number | null   // (currentPrice - buyPrice) / buyPrice * 100
  dividendIncome: number | null         // сумма дивидендов (если buyDate указан)
  totalReturn: number | null            // (unrealizedPnl + dividendIncome) / totalCost * 100
  weightPercent: number
}

PortfolioAnalytics {
  totalCost: number | null
  totalValue: number
  totalPnl: number | null
  totalPnlPercent: number | null
  totalDividendIncome: number
  totalReturn: number | null
  targetSharesPercent: number | null
  actualSharesPercent: number
  targetBondsPercent: number | null
  actualBondsPercent: number
  sharesDeviation: number | null
  bondsDeviation: number | null
}

DividendSummary {
  secid: string
  registryCloseDate: string
  value: number
  currency: string
}

Расчёт PnL для облигаций

Для облигаций цена указывается в % от номинала. Формула:

currentValue = (currentPrice / 100) * faceValue * quantity
totalCost = buyPrice * quantity        // buyPrice указывается пользователем
unrealizedPnl = currentValue - totalCost + accruedInt

Расчёт дивидендного дохода

dividendIncome = SUM(dividend.value)
  WHERE dividend.registryCloseDate >= position.buyDate
    AND position.type = 'share'

3. Architecture

3.1 Backend — изменения в существующем PortfolioModule

PortfolioModule (изменения)
├── PortfolioService              — расширенная логика enrichment
│   ├── enrichPositions()         — новый расчёт PnL полей
│   ├── calculateDividendIncome() — новый метод
│   └── calculateAnalytics()      — новый метод для портфеля в целом
├── dto/
│   ├── add-position.dto.ts       — новое поле buyPrice (optional), buyDate (optional)
│   ├── update-position.dto.ts    — новое поле buyPrice (optional), buyDate (optional)
│   ├── position-response.dto.ts  — новые PnL поля
│   └── analytics-response.dto.ts — НОВЫЙ: PortfolioAnalyticsDto
└── portfolio.controller.ts       — новый эндпоинт GET /:id/analytics (опционально)

Новые/изменяемые зависимости:

  • PortfolioService использует MoexClientService.getDividends(secid) для dividend income
  • Кеш дивидендов существует (cache.dividendsTtl, 86400s)

3.2 Frontend

src/components/portfolios/
├── SharePositionRow.tsx          — изменён: новые колонки PnL / PnL% / Дox.%
├── BondPositionRow.tsx           — изменён: новые колонки PnL / PnL% / Дox.%
├── PortfolioSummary.tsx          — изменён: добавлен PnL, доходность
└── AnalyticsSummary.tsx          — НОВЫЙ: карточка аналитики портфеля

src/hooks/
├── usePortfolio.ts               — изменён: новые поля в типе
└── usePositionMutations.ts       — изменён: buyPrice передаётся в мутацию

src/api/
├── portfolio.ts                  — без изменений (те же эндпоинты)
└── responses.ts                  — новые поля типах

3.3 Роутинг

Без изменений — аналитика интегрируется в существующую страницу /portfolios/:id.


4. API Contracts

4.1 Изменения в существующих эндпоинтах

POST /api/v1/portfolios/:id/positions

// Добавлены опциональные поля:
Body: {
  secid: string;          // required
  quantity: number;       // required
  buyPrice?: number;      // NEW: optional, цена покупки
  buyDate?: string;       // NEW: optional, ISO date
  notes?: string;
  tags?: string[];
}

PATCH /api/v1/portfolios/:id/positions/:posId

// Добавлены опциональные поля:
Body: {
  quantity?: number;
  buyPrice?: number;      // NEW
  buyDate?: string;       // NEW
  notes?: string;
  tags?: string[];
}

GET /api/v1/portfolios/:id — расширенный ответ

Response: {
  data: {
    id: number;
    name: string;
    description: string | null;
    currency: string;
    createdAt: string;
    updatedAt: string;
    totalValue: number;
    positions: EnrichedPosition[];  // с новыми PnL полями
    analytics: PortfolioAnalytics;  // NEW: агрегированная аналитика
  },
  meta: { fromCache, cachedAt }
}

4.2 Response Types

class EnrichedPosition {
  // ... существующие поля
  totalCost: number | null;
  unrealizedPnl: number | null;
  unrealizedPnlPercent: number | null;
  dividendIncome: number | null;
  totalReturn: number | null;
}

class PortfolioAnalytics {
  totalCost: number | null;
  totalValue: number;
  totalPnl: number | null;
  totalPnlPercent: number | null;
  totalDividendIncome: number;
  totalReturn: number | null;
  targetSharesPercent: number | null;
  actualSharesPercent: number;
  targetBondsPercent: number | null;
  actualBondsPercent: number;
  sharesDeviation: number | null;
  bondsDeviation: number | null;
}

4.3 Error Codes

HTTP Code Когда
400 VALIDATION_ERROR buyPrice ≤ 0, buyDate в будущем
422 NO_DIVIDEND_DATA Дивиденды недоступны (не share)
404 NOT_FOUND Позиция не найдена

5. Database Schema (Prisma)

model Position {
  id          Int      @id @default(autoincrement())
  portfolioId Int
  secid       String
  type        String   @default("share")    // "share" | "bond"
  quantity    Int
  buyPrice    Float?                          // NEW: цена покупки за единицу
  buyDate     DateTime?                       // NEW: дата покупки (опционально)
  notes       String?
  tags        String?   // JSON: ["DIVIDEND", "GROWTH"]
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  portfolio   Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)

  @@unique([portfolioId, secid])
}

Миграция: npx prisma migrate dev --name add-buy-price-to-position

Обратная совместимость: Все существующие Position получают buyPrice = null, buyDate = null. PnL для них не отображается.


6. Business Rules

Rule Описание
BR-AN-001 buyPrice > 0 если указан — цена покупки должна быть положительной
BR-AN-002 buyDate не может быть в будущем
BR-AN-003 Если buyPrice = null, PnL поля не вычисляются (возвращаются как null)
BR-AN-004 dividendIncome вычисляется только для type = 'share' и только если указан buyDate
BR-AN-005 totalReturn = (unrealizedPnl + dividendIncome) / totalCost * 100
BR-AN-006 Для type = 'bond' currentValue рассчитывается через (currentPrice / 100) * faceValue * quantity
BR-AN-007 targetSharesPercent + targetBondsPercent должна быть 100 (если оба указаны)
BR-AN-008 totalPnlPercent = sum(unrealizedPnl) / sum(totalCost) * 100 (weighted average)

7. Events (Domain Events)

Событие Payload Когда происходит Будущее использование
PositionBuyPriceSet { positionId, portfolioId, secid, buyPrice, buyDate } POST/PATCH position Snapshot для графика стоимости
DividendsCalculated { positionId, secid, totalDividends } GET portfolio Аудит, кеш инвалидация

8. Risks and Edge Cases

Risk Impact Mitigation
buyPrice не указан PnL недоступен Показывать , не блокировать остальные функции
Корпоративное действие (сплит) Количество изменилось, buyPrice неактуален Сейчас не обрабатываем. Будущая фаза: CorporateActions
Докупка бумаги Средняя цена входа меняется Нет механизма дозаписей. Работаем с «одной сделкой». Будущая фаза: transactions
Дивиденды за период > 1 года Много запросов к MOEX Кеш 86400s, batch запрос
Bond buyPrice в % vs в рублях Путаница при вводе buyPrice всегда в валюте портфеля (RUB). Бэкенд не конвертирует
Отрицательный PnL на 99% UI переполнение Ограничить отображение 2 знаками после запятой
Облигация с НКД Стоимость покупки ≠ текущая стоимость currentValue = рыночная цена (без НКД). UnrealizedPnl включает накопленный доход

9. Implementation Phases

Phase 1: Cost Basis + PnL Core

Backend:

  • Prisma: добавить buyPrice (Float?) и buyDate (DateTime?) в модель Position
  • Создать миграцию
  • Обновить DTO: AddPositionDto, UpdatePositionDto — добавить buyPrice, buyDate
  • Обновить PortfolioService.enrichPositions():
    • Расчёт totalCost = buyPrice * quantity
    • Расчёт unrealizedPnl = currentValue - totalCost
    • Расчёт unrealizedPnlPercent = (currentPrice - buyPrice) / buyPrice * 100
    • Для bonds: currentValue = (currentPrice / 100) * faceValue * quantity
  • Создать PortfolioAnalytics — агрегация на уровне портфеля
  • Вернуть analytics в findOne()
  • Написать тесты (см. Phase 4)

Frontend:

  • Обновить PositionWithPrice в responses.ts — новые PnL поля
  • Обновить AddPositionDto / UpdatePositionDto — buyPrice, buyDate
  • Обновить usePositionMutations.ts — передавать buyPrice
  • PositionRow (share + bond): добавить колонки:
    • Цена покупки (edit inline)
    • PnL (валюта, зелёный/красный)
    • PnL%
  • PortfolioSummary / новая карточка AnalyticsSummary: total PnL, total return %

Phase 2: Dividend Income

Backend:

  • В PortfolioService: метод calculateDividendIncome(position):
    • Если position.type !== 'share' → return 0
    • Если buyDate === null → return 0
    • Вызвать moexClient.getDividends(secid)
    • Отфильтровать registryCloseDate >= buyDate
    • Суммировать value
  • Добавить dividendIncome в EnrichedPosition
  • Добавить totalDividendIncome в PortfolioAnalytics
  • Кешировать результат на 86400s

Frontend:

  • AnalyticsSummary: добавить строку «Дивидендный доход»
  • SharePositionRow: добавить колонку «Дивиденды»

Phase 3: Target Allocation Comparison

Backend:

  • Реализовать чтение Portfolio.targets (JSON поле уже существует в схеме)
  • Парсить targets как { sharesPercent: number, bondsPercent: number }
  • Вернуть в analytics: targetSharesPercent, targetBondsPercent, sharesDeviation, bondsDeviation
  • Валидация при PATCH portfolio: sharesPercent + bondsPercent === 100

Frontend:

  • PortfolioForm: добавить поля Цель: акции % и Цель: облигации %
  • AnalyticsSummary: отображать факт vs цель, отклонение цветом

10. OpenAPI Specification

Дополнения к текущему backend OpenAPI-контракту по /api/docs-json:

  • Обновить схему PositionResponse — добавить buyPrice, buyDate
  • Создать схему PortfolioAnalytics со всеми полями
  • Обновить PortfolioDetailResponse — добавить analytics

11. ADR

ADR-011: PnL Calculation on Backend

Context: Где рассчитывать PnL — на backend или frontend?

Decision: На backend, в PortfolioService.enrichPositions(). PnL поля — часть EnrichedPosition.

Rationale:

  • Единый источник истины (DRY: frontend и API клиенты получают одинаковые данные)
  • Можно кешировать результат enrichment
  • Сложная логика (особенно dividend income) проще тестируется на backend
  • Соответствует существующей архитектуре (currentPrice, currentValue уже на backend)

Consequences:

  • Backend делает доп.запросы к MOEX за дивидендами (кешируются)
  • GET /portfolios/:id может быть медленнее (но enrichment уже делает batch-запросы)

ADR-012: BuyPrice как Float (не Decimal)

Context: Какой тип данных использовать для buyPrice в SQLite/Prisma?

Decision: Float (Prisma) / REAL (SQLite).

Rationale:

  • SQLite не имеет нативного decimal типа
  • Цены акций MOEX имеют 2 знака после запятой (копейки) — Float достаточен
  • class-validator с @IsNumber обрабатывает Float корректно
  • При миграции на PostgreSQL можно перейти на Decimal

Consequences:

  • Возможны ошибки округления при very large quantities (>1M)
  • На фронтенде форматировать через toFixed(2)

ADR-013: Dividend Income Calculation

Context: Как определять, какие дивиденды относятся к позиции?

Decision: Суммировать все дивиденды MOEX по secid, где registryCloseDate >= position.buyDate.

Rationale:

  • Простейшая имплементация без истории сделок
  • MOEX возвращает полную историю дивидендов по secid
  • Если buyDate не указан — дивиденды не считаются (0)

Consequences:

  • Если пользователь докупал бумагу, дивиденды засчитываются полностью (не пропорционально)
  • Фикса: нужна модель Transaction, что в out of scope

12. Self-Review Checklist

  • Нет placeholder'ов (TBD, TODO)
  • Все разделы заполнены
  • PRD покрывает ключевые user stories
  • Domain model однозначна (Position с buyPrice/buyDate)
  • API контракты расширены обратно-совместимо (все новые поля optional)
  • Business rules полны и непротиворечивы
  • Риски документированы с mitigation
  • Scope Phase 1 чётко отделён от Phase 2/3
  • ADR документируют ключевые решения
  • Обратная совместимость с существующими данными гарантирована