Sergey Krylov 5b9d7f3a27
All checks were successful
CI / ci (pull_request) Successful in 3m20s
CI / ci (push) Successful in 3m1s
docs: fix historical files to new structure and update
2026-06-18 22:04:09 +03:00

585 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<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
```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<ScreenerResult>`
- Получение доски через `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
Дополнение к текущему backend OpenAPI-контракту по `/api/docs-json`:
```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)