# 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 Дополнение к текущему 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)