moex-vibe/docs/superpowers/specs/2026-06-14-portfolio-design.md
Sergey Krylov a980520261
Some checks failed
CI / lint (pull_request) Successful in 1m59s
CI / test (pull_request) Successful in 1m45s
CI / build (pull_request) Failing after 1m54s
CI / lint (push) Successful in 1m50s
CI / test (push) Successful in 1m47s
CI / build (push) Failing after 2m3s
feat: portfolio management with share/bond separation
- Add Portfolio + Position models (Prisma + migrations)
- Backend: PortfolioModule with CRUD, enrichment, type detection
- Backend: enrichBondPosition returns 13 financial fields (YTM, duration,
  coupon, NCD, accrued interest, bid/offer, bondType, offerDate, etc.)
- Frontend: portfolio pages, 4 TanStack Query hooks, split share/bond tables
- Fix: MOEX bond marketdata board fallback (TQCB → TQOB for OFZ)
- Frontend: clickable ticker links to /stocks/:secid and /bonds/:secid
- Remove: target allocation, deviation, tags display from Phase 1
- Docs: ADR-009 (domain model), ADR-010 (price computation),
  portfolio backend doc, superpowers spec + plan
2026-06-14 11:14:04 +03:00

440 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 — Design Specification (SDD)
**Date:** 2026-06-14
**Status:** Draft
**Author:** AI Assistant
---
## 1. Product Requirements Document (PRD)
### 1.1 Product Vision
Добавить в MoexVibe возможность создания и управления инвестиционными портфелями-слежения. Пользователь может создавать виртуальные портфели, добавлять в них позиции по ценным бумагам MOEX, указывать количество, теги и заметки, а также задавать целевое распределение капитала. Система отображает текущую стоимость портфеля на основе рыночных данных MOEX и отклонение от целевого распределения.
### 1.2 Target Audience
Текущие пользователи MoexVibe — частные инвесторы, интересующиеся российским фондовым рынком. Функция портфелей ориентирована на пользователей, которые хотят отслеживать состав и структуру своих вложений без привязки к реальному брокерскому счёту.
### 1.3 Scope
| In Scope (Phase 1) | Out of Scope (Future Phases) |
|---|---|
| CRUD портфелей (название, описание, валюта) | История транзакций (buy/sell) |
| CRUD позиций (secid, количество, теги, заметки) | P&L и налоговая отчётность |
| Текущая стоимость портфеля (из MOEX) | График стоимости портфеля во времени |
| Целевое распределение (% на позицию) | XIRR / доходность |
| Отклонение от целевого распределения | Дивидендный календарь / прогноз купонов |
| Предопределённые теги | Корпоративные действия (сплиты и т.д.) |
| Аутентификация через существующую JWT-систему | Экспорт / импорт портфеля |
### 1.4 User Stories
- US-PF-001: Пользователь создаёт портфель с названием, описанием и валютой
- US-PF-002: Пользователь видит список своих портфелей
- US-PF-003: Пользователь открывает портфель и видит таблицу позиций с текущими ценами и стоимостью
- US-PF-004: Пользователь добавляет бумагу в портфель, выбирая её через поиск
- US-PF-005: Пользователь редактирует количество бумаги в позиции (inline)
- US-PF-006: Пользователь удаляет позицию из портфеля
- US-PF-007: Пользователь задаёт целевой процент для каждой позиции
- US-PF-008: Пользователь видит отклонение фактического распределения от целевого
- US-PF-009: Пользователь помечает позиции тегами (DIVIDEND, GROWTH, и т.д.)
- US-PF-010: Пользователь добавляет текстовую заметку к позиции
- US-PF-011: Пользователь удаляет портфель со всеми позициями
### 1.5 Non-Functional Requirements
- Цены позиций загружаются через существующий `MoexClientService` с кешированием (TTL: 900s)
- Страница портфеля отображает данные менее чем за 1 секунду (с кешем)
- Оптимистичные обновления при изменении количества позиции (TanStack Query mutation)
- При недоступности MOEX цена показывается как `—` с индикатором stale
---
## 2. Domain Model
```
Portfolio {
id: Int
userId: Int
name: String
description: String (optional)
currency: Currency (default: RUB)
targets: TargetAllocation[] (optional, JSON)
createdAt: DateTime
updatedAt: DateTime
}
Position {
id: Int
portfolioId: Int
secid: String — MOEX security ID (e.g. "SBER")
quantity: Int — количество бумаг (>= 0)
notes: String (optional, free text)
tags: Tag[] (optional, JSON)
createdAt: DateTime
updatedAt: DateTime
// Computed at read time:
currentPrice: number — из MOEX marketdata
currentValue: number — quantity * currentPrice
weightPercent: number — currentValue / totalValue * 100
targetPercent: number — из Portfolio.targets
deviation: number — weightPercent - targetPercent
}
enum Currency {
RUB, USD, EUR, CNY, KZT, BYN
}
enum Tag {
DIVIDEND, GROWTH, DEFENSIVE, SPECULATIVE,
BOND, ETF, GOVERNMENT, CASH
}
TargetAllocation {
secid: String
targetPercent: number // 0-100
// sum(targetPercent) across all positions = 100
}
```
---
## 3. Architecture
### 3.1 Backend
```
PortfolioModule
├── PortfolioController — /api/v1/portfolios
├── PortfolioService — бизнес-логика
├── dto/
│ ├── create-portfolio.dto.ts
│ ├── update-portfolio.dto.ts
│ ├── portfolio-response.dto.ts
│ ├── add-position.dto.ts
│ ├── update-position.dto.ts
│ └── position-response.dto.ts
└── portfolio.module.ts
```
- `PortfolioService` использует существующий `MoexClientService` для получения цен
- `PortfolioService` использует `PrismaService` для доступа к БД
- Все эндпоинты защищены `JwtAuthGuard` (глобальный guard) — только аутентифицированные пользователи
- Ответы обёрнуты в `ApiEnvelope<T>`: `{ data: T, meta: { fromCache, cachedAt } }`
### 3.2 Frontend
```
src/pages/portfolios/
├── PortfoliosListPage.tsx — /portfolios — список портфелей
└── PortfolioDetailPage.tsx — /portfolios/:id — детали + позиции
src/hooks/
├── usePortfolios.ts
├── usePortfolio.ts
├── usePortfolioMutations.ts
└── usePositionMutations.ts
src/components/portfolios/
├── PortfolioCard.tsx
├── PortfolioForm.tsx — create/edit form
├── PositionTable.tsx — редактируемая таблица
├── PositionRow.tsx — строка с inline edit qty
├── PortfolioSummary.tsx — total + distribution
├── TargetAllocationEditor.tsx
└── TagBadge.tsx
src/api/portfolio.ts — API client functions
```
- TanStack Query stale time: 900s (как у stock/bond)
- Optimistic updates при изменении количества позиции
- Существующий Layout + ProtectedRoute для страниц портфелей
### 3.3 Роутинг
```tsx
<Route path="/portfolios" element={<ProtectedRoute><PortfoliosListPage /></ProtectedRoute>} />
<Route path="/portfolios/:id" element={<ProtectedRoute><PortfolioDetailPage /></ProtectedRoute>} />
```
---
## 4. API Contracts
### 4.1 Portfolio CRUD
```
GET /api/v1/portfolios
Response: { data: Portfolio[], meta: { fromCache, cachedAt } }
POST /api/v1/portfolios
Body: { name, description?, currency? }
Response: { data: Portfolio, meta: ... }
GET /api/v1/portfolios/:id
Response: { data: PortfolioDetail, meta: ... }
// PortfolioDetail = Portfolio + positions[] (with computed prices)
PATCH /api/v1/portfolios/:id
Body: { name?, description?, currency?, targets? }
Response: { data: Portfolio, meta: ... }
DELETE /api/v1/portfolios/:id
Response: { data: null, meta: ... }
```
### 4.2 Position CRUD
```
GET /api/v1/portfolios/:id/positions
Response: { data: Position[], meta: ... }
POST /api/v1/portfolios/:id/positions
Body: { secid, quantity, notes?, tags? }
Response: { data: Position, meta: ... }
PATCH /api/v1/portfolios/:id/positions/:posId
Body: { quantity?, notes?, tags? }
Response: { data: Position, meta: ... }
DELETE /api/v1/portfolios/:id/positions/:posId
Response: { data: null, meta: ... }
```
### 4.3 Response Types (Swagger DTOs)
```typescript
class PortfolioResponseDto {
@ApiProperty() id: number;
@ApiProperty() name: string;
@ApiPropertyOptional() description: string;
@ApiProperty({ default: 'RUB' }) currency: string;
@ApiPropertyOptional() targets: TargetAllocationDto[];
@ApiProperty() createdAt: string;
@ApiProperty() updatedAt: string;
}
class PortfolioDetailResponseDto extends PortfolioResponseDto {
@ApiProperty({ type: [PositionWithPriceDto] })
positions: PositionWithPriceDto[];
@ApiProperty() totalValue: number;
}
class PositionWithPriceDto {
@ApiProperty() id: number;
@ApiProperty() secid: string;
@ApiProperty() quantity: number;
@ApiPropertyOptional() notes: string;
@ApiPropertyOptional() tags: string[];
@ApiProperty() currentPrice: number | null;
@ApiProperty() currentValue: number | null;
@ApiProperty() weightPercent: number;
@ApiProperty() targetPercent: number | null;
@ApiProperty() deviation: number | null;
}
```
---
## 5. Database Schema (Prisma)
```prisma
model Portfolio {
id Int @id @default(autoincrement())
userId Int
name String
description String?
currency String @default("RUB")
targets String? // JSON: [{ secid: "SBER", targetPercent: 30 }]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
positions Position[]
@@unique([userId, name]) // уникальное имя в рамках пользователя
}
model Position {
id Int @id @default(autoincrement())
portfolioId Int
secid String
quantity Int
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]) // один secid — одна позиция в портфеле
}
```
**Миграция:** `npx prisma migrate dev --name add-portfolio-position`
---
## 6. Business Rules
| Rule | Описание |
|---|---|
| BR-001 | `quantity >= 0` — отрицательное количество запрещено |
| BR-002 | `quantity = 0` — автоматическое удаление позиции (best-effort) |
| BR-003 | `sum(targets.targetPercent)` должна быть 100 (на стороне backend при PATCH portfolio) |
| BR-004 | `tags` — только значения из enum `Tag` |
| BR-005 | `secid` должен существовать в MOEX (проверка через MoexClient при POST position) |
| BR-006 | `currency` — только из enum `Currency` |
| BR-007 | Один `secid` может быть только в одной позиции внутри портфеля (unique constraint) |
| BR-008 | Только владелец может видеть/редактировать портфель (проверка `userId`) |
| BR-009 | При удалении портфеля удаляются все его позиции (cascade) |
---
## 7. Events (Domain Events)
Phase 1 не требует событийной шины. События документируются для будущих фаз:
| Событие | Payload | Когда происходит | Будущее использование |
|---|---|---|---|
| `PortfolioCreated` | `{ portfolioId, userId }` | POST portfolio | Аудит, уведомления |
| `PortfolioDeleted` | `{ portfolioId, userId }` | DELETE portfolio | Очистка связанных данных |
| `PositionAdded` | `{ portfolioId, secid, quantity }` | POST position | Триггер аналитики |
| `PositionQuantityChanged` | `{ portfolioId, secid, oldQty, newQty }` | PATCH position | Snapshot для графиков |
| `PositionRemoved` | `{ portfolioId, secid }` | DELETE position | Фиксация P&L |
---
## 8. Risks and Edge Cases
| Risk | Impact | Mitigation |
|---|---|---|
| **MOEX недоступен** | Цены не загружаются | Показывать `—` + stale indicator, не блокировать CRUD |
| **SECID удалён из MOEX** | Цена = N/A | Position остаётся, currentPrice = null |
| **Очень много позиций** | Производительность | Backend: пагинация positions (Phase 2). Сейчас ~50 позиций не проблема |
| **Параллельное редактирование** | Lost update | Пока ignored (Single user per portfolio). Фаза 2: `updatedAt` optimistic locking |
| **Target sum ≠ 100** | Некорректное распределение | Backend validation при сохранении |
| **Смена валюты портфеля** | Стоимость в разной валюте | Пока только переименование. Конвертация — Phase 3 |
| **Некорректный secid** | Ошибка при добавлении | Проверка существования в MOEX при POST, возвращать 422 |
| **Удаление пользователя** | Потеря портфелей | Cascade delete, ok |
| **Очень большое количество** | `quantity` Int overflow | Использовать `BigInt` при необходимости (сейчас Int до ~2B) |
---
## 9. Implementation Phases
### Phase 1: Portfolio + Position CRUD
**Backend:**
- Prisma: добавить модели `Portfolio` и `Position`, запустить миграцию
- Создать `PortfolioModule` с `PortfolioController`, `PortfolioService`
- DTO: create/update portfolio, add/update/position, response
- CRUD эндпоинты для портфелей и позиций
- Интеграция с `MoexClientService` для получения текущей цены (`getShareMarketData` / `getBondMarketData`)
- Расчёт computed полей (currentValue, weightPercent, deviation)
- Валидация бизнес-правил (BR-001 — BR-009)
**Frontend:**
- API client functions в `src/api/portfolio.ts`
- Hooks: `usePortfolios`, `usePortfolio`, `usePortfolioMutations`, `usePositionMutations`
- Страница `/portfolios` — список портфелей, кнопка создать
- Страница `/portfolios/:id` — детали портфеля + таблица позиций
- `PortfolioForm` (create/edit modal)
- `PositionRow` с inline editing количества
- `TargetAllocationEditor` — числовой ввод процентов
- `PortfolioSummary` — общая стоимость + pie chart распределения
- Добавить ссылку в навигацию (Layout)
**Test:**
- Backend: unit-тесты `PortfolioService` (CRUD, валидация, расчёты)
- Backend: e2e-тесты эндпоинтов портфелей
### Phase 2: Positions — расширение
- Target allocation editor с drag-to-set
- Pie chart визуализация (Chart.js или lightweight-charts)
- Отклонение от цели: цветовая индикация (зелёный/жёлтый/красный)
- Фильтрация и сортировка позиций в таблице
- Группировка по тегам
### Phase 3: Transactions (опционально)
- Модель `Transaction` (type: BUY/SELL, date, price, quantity, commission)
- История операций по позиции
- P&L по закрытым позициям
- Средняя цена входа и общая доходность
### Phase 4: Analytics
- Snapshot текущей стоимости по дням (cron + таблица `PortfolioSnapshot`)
- График стоимости портфеля во времени
- Сравнение с бенчмарком (IMOEX)
- Доходность (простая, XIRR)
### Phase 5: Dividend and Coupon Forecasts
- Использовать `SharesService.getDividends` / `BondsService.getCoupons`
- Агрегировать предстоящие выплаты по позициям
- Календарь выплат на ближайшие 12 месяцев
- Прогнозный доход к портфелю
### Phase 6: Corporate Actions
- Модель `CorporateAction` (type, secid, date, ratio)
- Автокорректировка количества позиций при сплитах/консолидациях
- Обработка допэмиссий
- Уведомления о грядущих корпоративных действиях
---
## 10. OpenAPI Specification
Дополнение к существующему `docs/openapi/openapi.yaml` — новые эндпоинты и схемы для Portfolio и Position.
---
## 11. ADR
### ADR-009: Portfolio Domain Model
**Context:** Выбор между SQL-моделью и document store для хранения портфелей.
**Decision:** SQL через Prisma (существующая БД). Portfolio и Position — отдельные таблицы. Targets и Tags — JSON-поля, т.к.:
- SQLite поддерживает JSON
- Нет необходимости в join по тегам (максимум 50 позиций на портфель)
- Миграция на PostgreSQL в будущем не потребует изменений схемы
**Consequences:**
- Нельзя делать SQL-запросы по тегам (не нужно для Phase 1)
- Targets редактируются целиком (замена JSON), что достаточно для сценария
### ADR-010: Backend Price Computation
**Context:** Где вычислять текущую стоимость портфеля — на backend или frontend?
**Decision:** На backend. `GET /portfolios/:id` возвращает `PortfolioDetailResponseDto` с computed полями (`currentPrice`, `currentValue`, `weightPercent`, `deviation`).
**Rationale:**
- Единый источник правды
- Frontend получает готовые данные для отображения
- Можно кешировать computed response
**Consequences:**
- Backend делает N запросов к MOEX (по числу уникальных secid)
- Используется in-memory cache для цен (900s TTL)
---
## 12. Self-Review Checklist
- [x] Нет placeholder'ов (TBD, TODO)
- [x] Все разделы заполнены
- [x] Терминология согласована (Portfolio/Position, не "watchlist"/"holding")
- [x] API контракты полны (CRUD для обоих ресурсов)
- [x] Business rules однозначны
- [x] Scope фазы 1 чётко отделён от будущих фаз
- [x] ADR документируют ключевые решения