19 KiB
Portfolio Analytics — Design Specification (SDD)
Date: 2026-06-14 (updated 2026-06-24) Status: Completed — Phases 1–3 реализованы 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()
Frontend:
- Обновить
PositionWithPrice— новые PnL поля - Обновить
AddPositionDto/UpdatePositionDto— buyPrice, buyDate PositionRow(share + bond): колонки цены покупки, PnL, PnL%PortfolioSummary/AnalyticsSummary: total PnL, total return %
Phase 2: Dividend Income ✅
Backend:
- Batch-запрос дивидендов через
moexClient.getDividends(secid)внутриenrichPositions - Фильтрация
registryCloseDate >= buyDate, суммированиеvalue dividendIncomeвEnrichedPosition,totalDividendsвPortfolioSummaryDto- Кеширование через
marketDataTtl
Frontend:
AnalyticsSummary: карточки «Дивиденды» и «Общая доходность»
Phase 3: Target Allocation Comparison ✅
Backend:
- Чтение
Portfolio.targets(JSON), парсинг как{ sharesPercent, bondsPercent } - Расчёт
actualSharesPercent,actualBondsPercent,sharesDeviation,bondsDeviation PortfolioTargetsDtoс валидацией 0–100, сохранение вupdate()- Поля
targetSharesPercent,targetBondsPercentи deviation вPortfolioSummaryDto
Frontend:
PortfolioForm: поля «Цель: акции %» и «Цель: облигации %» с авто-балансировкойAnalyticsSummary: блок целевого распределения с отклонением (цветовая индикация)
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 документируют ключевые решения
- Обратная совместимость с существующими данными гарантирована