# 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 Дополнения к существующему `docs/openapi/openapi.yaml`: - Обновить схему `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] Обратная совместимость с существующими данными гарантирована