Some checks failed
- 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
585 lines
26 KiB
Markdown
585 lines
26 KiB
Markdown
# 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
|
||
|
||
Дополнение к существующему `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)
|