442 lines
20 KiB
Markdown
442 lines
20 KiB
Markdown
# 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**
|
||
```typescript
|
||
// Добавлены опциональные поля:
|
||
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**
|
||
```typescript
|
||
// Добавлены опциональные поля:
|
||
Body: {
|
||
quantity?: number;
|
||
buyPrice?: number; // NEW
|
||
buyDate?: string; // NEW
|
||
notes?: string;
|
||
tags?: string[];
|
||
}
|
||
```
|
||
|
||
**GET /api/v1/portfolios/:id — расширенный ответ**
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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)
|
||
|
||
```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
|
||
|
||
- [x] Нет placeholder'ов (TBD, TODO)
|
||
- [x] Все разделы заполнены
|
||
- [x] PRD покрывает ключевые user stories
|
||
- [x] Domain model однозначна (Position с buyPrice/buyDate)
|
||
- [x] API контракты расширены обратно-совместимо (все новые поля optional)
|
||
- [x] Business rules полны и непротиворечивы
|
||
- [x] Риски документированы с mitigation
|
||
- [x] Scope Phase 1 чётко отделён от Phase 2/3
|
||
- [x] ADR документируют ключевые решения
|
||
- [x] Обратная совместимость с существующими данными гарантирована
|