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

19 KiB
Raw Blame History

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