Sergey Krylov 659be4636c
All checks were successful
CI / ci (pull_request) Successful in 12m44s
CI / ci (push) Successful in 14m21s
docs: mark portfolio-analytics and quality-gate-contract-docs as completed in spec/plan
2026-06-24 19:06:24 +03:00

431 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Portfolio Analytics — Design Specification (SDD)
**Date:** 2026-06-14 (updated 2026-06-24)
**Status:** Completed — Phases 13 реализованы
**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()`
**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` с валидацией 0100, сохранение в `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
- [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] Обратная совместимость с существующими данными гарантирована