# 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 Роутинг
```tsx
} />
// Без 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
```typescript
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`
- Получение доски через `moexClient.getShareMarketDataBatch([])` или `getBondPositionDataBatch([])`
- Валидация фильтров
- Фильтрация массива
- Сортировка
- Пагинация
2. Создать `ScreenerQueryDto` с валидацией (`@IsOptional`, `@IsNumber`, `@Min`, `@Max`, etc.)
3. Добавить эндпоинт в `SecuritiesController`:
- `GET /securities/screener` → `ScreenerService.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`:
```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
- [x] Нет placeholder'ов (TBD, TODO)
- [x] Все разделы заполнены
- [x] PRD покрывает ключевые user stories для MVP
- [x] Domain model описывает все поля и их типы
- [x] API контракты полны (все query params, response shape, error codes)
- [x] Business rules однозначны
- [x] Скринер доступен без авторизации (как search)
- [x] Scope Phase 1 отделён от Phase 2/3
- [x] ADR документируют ключевые архитектурные решения
- [x] Производительность учтена (batch fetch + cache 60s + пагинация)
- [x] Edge cases обработаны (empty result, ошибка MOEX, некорректные params)