440 lines
19 KiB
Markdown
440 lines
19 KiB
Markdown
# 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 документируют ключевые решения
|