19 KiB
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 Роутинг
<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)
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)
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
- Нет placeholder'ов (TBD, TODO)
- Все разделы заполнены
- Терминология согласована (Portfolio/Position, не "watchlist"/"holding")
- API контракты полны (CRUD для обоих ресурсов)
- Business rules однозначны
- Scope фазы 1 чётко отделён от будущих фаз
- ADR документируют ключевые решения