# 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`: `{ 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 } /> } /> ``` --- ## 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 Дополнение к текущему backend OpenAPI-контракту по `/api/docs-json` — новые эндпоинты и схемы для 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 документируют ключевые решения