moex-vibe/docs/superpowers/specs/2026-06-14-security-screener-design.md
Sergey Krylov 96f003852d
Some checks failed
CI / lint (pull_request) Failing after 1m48s
CI / test (pull_request) Successful in 1m47s
CI / build (pull_request) Successful in 1m53s
CI / lint (push) Failing after 1m59s
CI / test (push) Successful in 1m56s
CI / build (push) Successful in 1m50s
feat: implement portfolio analytics, PnL calculation, and security screener
- Add buyPrice and buyDate to positions for PnL tracking
- Implement backend analytics service for real-time portfolio performance
- Add server-side security screener with filtering, sorting, and pagination
- Update frontend UI with analytics summaries and sortable screener table
- Optimize MOEX API calls with batch fetching and portfolio-specific caching
- Add unit tests for analytics and screener services
2026-06-14 15:59:25 +03:00

26 KiB
Raw Blame History

Security Screener — Design Specification (SDD)

Date: 2026-06-14 Status: Draft Author: AI Assistant


1. Product Requirements Document (PRD)

1.1 Product Vision

Добавить в MoexVibe функциональность скринера (фильтра) ценных бумаг — инструмент для поиска инвестиционных идей. Пользователь может задавать фильтры по ключевым параметрам акций и облигаций MOEX: цена, доходность, объём, дюрация, дивидендная доходность и т.д. — и получать таблицу бумаг, удовлетворяющих критериям.

1.2 Target Audience

Частные инвесторы, которые ищут бумаги для инвестиций по заданным критериям. Скринер — стандартный инструмент брокерских платформ (Tinkoff, BCS, QUIK), отсутствие которого в MoexVibe снижает ценность продукта для активных инвесторов.

1.3 Scope

In Scope Out of Scope (Future Phases)
Фильтр по типу бумаги (акции / облигации) Фундаментальные мультипликаторы (P/E, P/B, EV/EBITDA)
Фильтр по цене (диапазон) Технические индикаторы (RSI, SMA)
Фильтр по изменению цены (%) Сравнение бумаг (side-by-side)
Фильтр по объёму торгов Сохранение скринера (шаблоны фильтров)
Фильтр по капитализации Экспорт результатов
Фильтр по дивидендной доходности (акции) Уведомления по результатам скринера
Фильтр по YTM / YTP (облигации)
Фильтр по дюрации (облигации)
Фильтр по купонной ставке (облигации)
Фильтр по дате погашения (облигации)
Фильтр по типу облигации
Фильтр по уровню листинга
Сортировка по любому столбцу
Пагинация (20 элементов)

1.4 User Stories

  • US-SC-001: Пользователь открывает страницу скринера и видит форму фильтров
  • US-SC-002: Пользователь выбирает тип бумаги (акции/облигации) — форма фильтров меняется
  • US-SC-003: Пользователь задаёт диапазон цены и нажимает «Применить» — видит результаты
  • US-SC-004: Пользователь сортирует результаты по любому столбцу (возрастание/убывание)
  • US-SC-005: Пользователь кликает на бумагу — переходит на её страницу
  • US-SC-006: Пользователь видит количество найденных бумаг и пагинацию
  • US-SC-007: Пользователь очищает фильтры кнопкой «Сбросить»

1.5 Non-Functional Requirements

  • Данные загружаются одним batch-запросом к MOEX (вся доска), фильтрация на backend
  • Кеш всей доски: TTL = 60s (данные меняются в реальном времени)
  • Ответ должен приходить за <500ms при закешированных данных
  • Фильтрация и сортировка на backend (не на frontend)
  • Пагинация: 20 элементов на страницу (default)

2. Domain Model

ScreenerQuery {
  type: 'share' | 'bond'          // required — определяет набор фильтров
  // Общие фильтры:
  priceMin?: number               // минимальная цена (для shares: RUB, bonds: % от номинала)
  priceMax?: number               // максимальная цена
  volumeMin?: number              // минимальный объём торгов
  listLevel?: number              // уровень листинга (1, 2, 3)
  // Фильтры для акций:
  changePercentMin?: number       // минимальное изменение цены (%)
  changePercentMax?: number       // максимальное изменение цены (%)
  capitalizationMin?: number      // минимальная капитализация
  dividendYieldMin?: number       // минимальная дивидендная доходность (%)
  // Фильтры для облигаций:
  yieldMin?: number               // минимальная YTM (%)
  yieldMax?: number               // максимальная YTM (%)
  durationMin?: number            // минимальная дюрация (лет)
  durationMax?: number            // максимальная дюрация (лет)
  couponMin?: number              // минимальный купон (RUB)
  couponMax?: number              // максимальный купон (RUB)
  couponPercentMin?: number       // минимальная купонная ставка (%)
  couponPercentMax?: number       // максимальная купонная ставка (%)
  maturityBefore?: string         // погашение до даты (ISO date)
  maturityAfter?: string          // погашение после даты (ISO date)
  bondType?: string               // тип облигации (ОФЗ, Корпоративная, Субфедеральная, и т.д.)
  // Сортировка и пагинация:
  sortBy?: string                 // поле для сортировки (default: 'price')
  sortOrder?: 'asc' | 'desc'      // default: 'asc'
  page?: number                   // default: 1
  pageSize?: number               // default: 20, max: 100
}

ScreenerResult {
  totalCount: number              // всего найдено (до пагинации)
  page: number
  pageSize: number
  totalPages: number
  items: ScreenerItem[]
}

ScreenerItem {
  // Общие поля:
  secid: string
  shortName: string
  isin: string
  type: 'share' | 'bond'
  price: number | null
  change: number | null           // изменение цены за сегодня
  changePercent: number | null
  volume: number
  listLevel: number
  // Для акций:
  capitalization: number | null
  dividendYield: number | null    // расчётная дивидендная доходность
  // Для облигаций:
  yieldToMaturity: number | null
  duration: number | null
  couponValue: number | null
  couponPercent: number | null
  accruedInt: number | null
  matDate: string | null
  bondType: string | null
}

Фильтрация на backend

function filterShares(items: ShareMarketData[], query: ScreenerQuery): ScreenerItem[]
  return items.filter(item =>
    priceMin <= item.price <= priceMax &&
    volume >= volumeMin &&
    changePercentMin <= item.changePercent <= changePercentMax &&
    capitalization >= capitalizationMin &&
    dividendYield >= dividendYieldMin &&
    listLevel == query.listLevel (если указан)
  )

function filterBonds(items: BondPositionData[], query: ScreenerQuery): ScreenerItem[]
  return items.filter(item =>
    priceMin <= item.price <= priceMax &&
    yieldMin <= item.ytm <= yieldMax &&
    durationMin <= item.duration <= durationMax &&
    couponMin <= item.coupon <= couponMax &&
    maturityBefore >= item.matDate >= maturityAfter &&
    bondType == query.bondType (если указан)
  )

3. Architecture

3.1 Backend

SecuritiesModule (расширение)
├── SecuritiesController          — изменён: новый эндпоинт GET /screener
├── SecuritiesService             — изменён: новый метод search()
├── ScreenerService               — НОВЫЙ: логика фильтрации и пагинации
├── dto/
│   └── screener-query.dto.ts     — НОВЫЙ: DTO для query параметров
├── securities.module.ts          — изменён: ScreenerService в providers
└── MoexClientService (глобальный) — изменён: метод getFullShareBoard(), getFullBondBoard()

Поток данных:

GET /securities/screener?type=share&priceMin=100&priceMax=500&page=1&pageSize=20
  ↓
SecuritiesController.screener(query)
  ↓
ScreenerService.screen(query)
  ↓
CacheService.getOrFetch('screener:board', [type], fetchFn, 'marketDataTtl')
  | где fetchFn = type === 'share'
  |   ? moexClient.getShareMarketDataBatch([])  — все акции
  |   : moexClient.getBondPositionDataBatch([]) — все облигации
  ↓
filter(board, query)          — применяем фильтры
  ↓
sort(board, sortBy, sortOrder) — сортируем
  ↓
paginate(board, page, pageSize) — пагинация
  ↓
Response: { data: ScreenerResult, meta: { fromCache, cachedAt } }

3.2 Frontend

src/pages/screener/
└── ScreenerPage.tsx              — НОВЫЙ: страница скринера

src/hooks/
└── useScreener.ts                — НОВЫЙ: хук для запроса скринера

src/api/
├── screener.ts                   — НОВЫЙ: API client
└── responses.ts                  — новые типы

src/components/screener/
├── FilterPanel.tsx               — НОВЫЙ: панель фильтров
├── FilterPanelShare.tsx          — НОВЫЙ: фильтры для акций
├── FilterPanelBond.tsx           — НОВЫЙ: фильтры для облигаций
├── ScreenerTable.tsx             — НОВЫЙ: таблица результатов
└── ScreenerTableRow.tsx          — НОВЫЙ: строка таблицы

Структура страницы:

┌─────────────────────────────────────────────┐
│  Header с навигацией (Layout)                │
├──────────────┬──────────────────────────────┤
│  FilterPanel │  ScreenerTable               │
│              │  ┌────┬─────┬──────┬───┐     │
│  Тип: ◉ акции│  │Тикер│Цена │Изм.% │ ↗│     │
│    ○ облиг.  │  ├────┼─────┼──────┼───┤     │
│              │  │SBER│322.3│+0.36%│ ↗ │     │
│  Цена от [__]│  │GAZP│155.2│-0.52%│ ↗ │     │
│  Цена до [__]│  │...  │     │      │   │     │
│              │  └────┴─────┴──────┴───┘     │
│  Объём от[__]│  [< 1 2 3 ... 10 >]          │
│              │                              │
│  [Применить] │  Найдено: 145 бумаг          │
│  [Сбросить]  │                              │
└──────────────┴──────────────────────────────┘

3.3 Роутинг

<Route path="/screener" element={<ScreenerPage />} />
// Без ProtectedRoute — скринер доступен всем (как search)

3.4 State Management

  • URL query params — источник истины для фильтров (/screener?type=share&priceMin=100)
  • useScreener() читает URL params, делает запрос
  • При изменении фильтра → debounce 300ms → update URL → refetch
  • Кнопка «Применить» для ручного запуска

4. API Contracts

4.1 Screener Endpoint

GET /api/v1/securities/screener

Query Parameters:
  type: 'share' | 'bond'          (required)
  priceMin: number                (optional)
  priceMax: number                (optional)
  volumeMin: number               (optional)
  listLevel: number               (optional, 1-3)
  // shares:
  changePercentMin: number        (optional)
  changePercentMax: number        (optional)
  capitalizationMin: number       (optional)
  dividendYieldMin: number        (optional)
  // bonds:
  yieldMin: number                (optional)
  yieldMax: number                (optional)
  durationMin: number             (optional)
  durationMax: number             (optional)
  couponMin: number               (optional)
  couponMax: number               (optional)
  couponPercentMin: number        (optional)
  couponPercentMax: number        (optional)
  maturityBefore: string          (optional, ISO date)
  maturityAfter: string           (optional, ISO date)
  bondType: string                (optional)
  // sort & pagination:
  sortBy: string                  (optional, default: 'price')
  sortOrder: 'asc' | 'desc'       (optional, default: 'asc')
  page: number                    (optional, default: 1)
  pageSize: number                (optional, default: 20)

Response: {
  data: {
    totalCount: number,
    page: number,
    pageSize: number,
    totalPages: number,
    items: ScreenerItem[]
  },
  meta: { fromCache, cachedAt }
}

4.2 Response Types

class ScreenerItem {
  secid: string;
  shortName: string;
  isin: string;
  type: 'share' | 'bond';
  price: number | null;
  change: number | null;
  changePercent: number | null;
  volume: number;
  listLevel: number;
  capitalization?: number | null;     // shares only
  dividendYield?: number | null;      // shares only
  yieldToMaturity?: number | null;    // bonds only
  duration?: number | null;           // bonds only
  couponValue?: number | null;        // bonds only
  couponPercent?: number | null;      // bonds only
  accruedInt?: number | null;         // bonds only
  matDate?: string | null;            // bonds only
  bondType?: string | null;           // bonds only
}

class ScreenerResult {
  totalCount: number;
  page: number;
  pageSize: number;
  totalPages: number;
  items: ScreenerItem[];
}

4.3 Error Codes

HTTP Code Когда
400 VALIDATION_ERROR type не указан, page < 1, priceMax < priceMin
422 MOEX_UNAVAILABLE Доска MOEX не загрузилась (circuit breaker open)

5. Database Schema

Изменений в схеме БД не требуется. Все данные получаются из MOEX ISS API и кешируются in-memory.


6. Business Rules

Rule Описание
BR-SC-001 type обязателен — скринер работает или по акциям, или по облигациям
BR-SC-002 priceMin <= priceMax если оба указаны
BR-SC-003 page >= 1, pageSize от 1 до 100
BR-SC-004 dividendYieldMin применяется только для type = 'share'
BR-SC-005 yieldMin/yieldMax/durationMin/durationMax применяются только для type = 'bond'
BR-SC-006 Вся доска кешируется на 60 секунд (TTL = marketDataTtl через config)
BR-SC-007 sortBy может быть любым полем ScreenerItem
BR-SC-008 Неизвестные/неподдерживаемые параметры игнорируются (не ошибка)
BR-SC-009 listLevel — список через запятую (1,2,3) — бумаги любого из указанных уровней

7. Events (Domain Events)

Phase 1 не требует событий. Для будущих фаз:

Событие Payload Когда Будущее использование
ScreenerUsed { type, filterCount, resultCount } GET screener Аналитика популярных фильтров
ScreenerBoardRefreshed { type, count, cachedAt } Обновление кеша доски Мониторинг

8. Risks and Edge Cases

Risk Impact Mitigation
MOEX board endpoint медленный Время ожидания 2-5s при первом запросе Кеш 60s; показать loading state; prefetch при наведении на скринер
Большая доска (300+ акций) Размер ответа ~100KB Ok для JSON. При необходимости — сжатие через accept-encoding
Empty result Нет бумаг под фильтры Показать «Ничего не найдено», предложить смягчить фильтры
Все параметры пустые Возврат всей доски Ok — пользователь видит весь рынок с сортировкой
Dividend yield по всем акциям N запросов к MOEX Сейчас НЕ включаем dividend yield в первый batch. Расчёт при отдельном запросе
Некорректный sortBy Ошибка 400 Валидация на backend: список разрешённых полей сортировки
Одновременные запросы N запросов к MOEX Rate limiter (p-queue, 10/с) + circuit breaker уже есть

9. Implementation Phases

Phase 1: Core Screener (Shares + Bonds)

Backend:

  1. Создать ScreenerService:

    • screen(query: ScreenerQuery): Promise<ScreenerResult>
    • Получение доски через moexClient.getShareMarketDataBatch([]) или getBondPositionDataBatch([])
    • Валидация фильтров
    • Фильтрация массива
    • Сортировка
    • Пагинация
  2. Создать ScreenerQueryDto с валидацией (@IsOptional, @IsNumber, @Min, @Max, etc.)

  3. Добавить эндпоинт в SecuritiesController:

    • GET /securities/screenerScreenerService.screen(query)
  4. Зарегистрировать ScreenerService в SecuritiesModule

  5. Создать ScreenerResponseDto со Swagger-декораторами

  6. Написать тесты (см. Phase 4)

Frontend:

  1. Создать api/screener.ts:

    • getScreenerResults(params) → ScreenerResult
  2. Создать hooks/useScreener.ts:

    • Читает URL search params как source of truth
    • TanStack Query с key ['screener', params]
    • staleTime: 60000 (1 min — соответствует cache TTL)
    • Debounce на изменение фильтров (300ms)
  3. Создать компоненты:

    • FilterPanel.tsx — обёртка, переключатель share/bond
    • FilterPanelShare.tsx — фильтры для акций
    • FilterPanelBond.tsx — фильтры для облигаций
    • ScreenerTable.tsx — таблица с сортировкой по клику на header
    • ScreenerTableRow.tsx — строка с ссылкой на страницу бумаги
  4. Создать ScreenerPage.tsx:

    • /screener route
    • Layout: filter panel (left) + results table (right)
    • Loading / error / empty states
    • Pagination controls
  5. Добавить ссылку «Скринер» в навигацию (Layout)

Phase 2: Dividend Yield (Shares)

Backend:

  • Для акций с dividendYieldMin > 0:
    • После фильтрации — запросить дивиденды для отфильтрованных secid
    • Рассчитать lastYearDividends / currentPrice * 100
    • Отфильтровать по dividendYieldMin (batch-запрос дивидендов по набору secid)

Frontend:

  • FilterPanelShare: добавить поле «Див.доходность от, %»
  • ScreenerTable: добавить колонку «Див.дох.»

Phase 3: Saved Screener Templates

  • Сохранение набора фильтров как «шаблон» (в localStorage или на backend)
  • Быстрый доступ к избранным скринерам
  • Shared link (кодировать params в URL)

10. OpenAPI Specification

Дополнение к существующему docs/openapi/openapi.yaml:

paths:
  /securities/screener:
    get:
      summary: Screen securities by filters
      parameters:
        - name: type
          in: query
          required: true
          schema:
            type: string
            enum: [share, bond]
        - name: priceMin
          in: query
          schema: { type: number }
        - name: priceMax
          in: query
          schema: { type: number }
        # ... остальные параметры
      responses:
        200:
          description: Screener results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScreenerResponse'

components:
  schemas:
    ScreenerItem:
      type: object
      properties:
        secid: { type: string }
        shortName: { type: string }
        price: { type: number, nullable: true }
        changePercent: { type: number, nullable: true }
        # ... остальные поля
    ScreenerResult:
      type: object
      properties:
        totalCount: { type: integer }
        page: { type: integer }
        pageSize: { type: integer }
        totalPages: { type: integer }
        items:
          type: array
          items:
            $ref: '#/components/schemas/ScreenerItem'
    ScreenerResponse:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/ScreenerResult'
        meta:
          $ref: '#/components/schemas/ApiMeta'

11. ADR

ADR-014: Full Board Fetch vs Incremental

Context: Скринер должен фильтровать по всем бумагам рынка. Как получать данные — инкрементально (каждый запрос — свой набор MOEX эндпоинтов) или одним batch-запросом всей доски?

Decision: Один batch-запрос всей доски MOEX (/engines/stock/markets/{shares|bonds}/securities.json) с кешированием на 60s.

Rationale:

  • MOEX отдаёт всю доску одним запросом (300-500 shares, 100-200 bonds)
  • Batch-методы уже реализованы в MoexClientService (используются для portfolio enrichment)
  • Один запрос = 1 http call vs N http calls при инкрементальном подходе
  • Кеш на 60s — разумный компромисс между свежестью и производительностью

Consequences:

  • При первом запросе после TTL — задержка 2-5s (ожидание MOEX)
  • Нельзя фильтровать по данным, которых нет в board response (например, фундаментальные мультипликаторы)
  • Dividend yield требует отдельного прохода (Phase 2)

ADR-015: URL Search Params as Source of Truth

Context: Как хранить состояние фильтров на фронтенде — React state, URL params, или Redux/Zustand?

Decision: URL search params (/screener?type=share&priceMin=100).

Rationale:

  • Shareable URL:用户可以 отправить ссылку с фильтрами
  • Back/forward навигация работает нативно
  • Нет лишних зависимостей (Redux и т.д.)
  • TanStack Query key = URL params — автоматическая интеграция

Consequences:

  • URL может стать длинным (но это нормально для query params)
  • Нужно синхронизировать URL ←→ FilterPanel (useSearchParams)
  • Придётся обрабатывать частичную загрузку страницы с params

ADR-016: Sorting on Backend

Context: Сортировать результаты на backend или frontend?

Decision: На backend.

Rationale:

  • При пагинации сортировка должна быть на сервере (иначе данные первой страницы не соответствуют порядку)
  • Единый источник истины
  • Можно добавить сортировку по полям, которые не отображаются в таблице

Consequences:

  • Нужно передавать sortBy/sortOrder при каждом запросе
  • Backend должен валидировать sortBy (только существующие поля)

12. Self-Review Checklist

  • Нет placeholder'ов (TBD, TODO)
  • Все разделы заполнены
  • PRD покрывает ключевые user stories для MVP
  • Domain model описывает все поля и их типы
  • API контракты полны (все query params, response shape, error codes)
  • Business rules однозначны
  • Скринер доступен без авторизации (как search)
  • Scope Phase 1 отделён от Phase 2/3
  • ADR документируют ключевые архитектурные решения
  • Производительность учтена (batch fetch + cache 60s + пагинация)
  • Edge cases обработаны (empty result, ошибка MOEX, некорректные params)