26 KiB
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:
-
Создать
ScreenerService:screen(query: ScreenerQuery): Promise<ScreenerResult>- Получение доски через
moexClient.getShareMarketDataBatch([])илиgetBondPositionDataBatch([]) - Валидация фильтров
- Фильтрация массива
- Сортировка
- Пагинация
-
Создать
ScreenerQueryDtoс валидацией (@IsOptional,@IsNumber,@Min,@Max, etc.) -
Добавить эндпоинт в
SecuritiesController:GET /securities/screener→ScreenerService.screen(query)
-
Зарегистрировать
ScreenerServiceвSecuritiesModule -
Создать
ScreenerResponseDtoсо Swagger-декораторами -
Написать тесты (см. Phase 4)
Frontend:
-
Создать
api/screener.ts:getScreenerResults(params) → ScreenerResult
-
Создать
hooks/useScreener.ts:- Читает URL search params как source of truth
- TanStack Query с key
['screener', params] staleTime: 60000(1 min — соответствует cache TTL)- Debounce на изменение фильтров (300ms)
-
Создать компоненты:
FilterPanel.tsx— обёртка, переключатель share/bondFilterPanelShare.tsx— фильтры для акцийFilterPanelBond.tsx— фильтры для облигацийScreenerTable.tsx— таблица с сортировкой по клику на headerScreenerTableRow.tsx— строка с ссылкой на страницу бумаги
-
Создать
ScreenerPage.tsx:/screenerroute- Layout: filter panel (left) + results table (right)
- Loading / error / empty states
- Pagination controls
-
Добавить ссылку «Скринер» в навигацию (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)