spec: add stock screener
Specs security / security (push) Successful in 7s
Specs verify / verify (push) Successful in 9s

This commit is contained in:
2026-10-11 10:44:30 +03:00
parent 13e3e53d06
commit ed6cf299f2
10 changed files with 653 additions and 0 deletions
@@ -0,0 +1,67 @@
# System Analysis
## Requirement Coverage
| Требование delta spec | Межсистемная граница и поток |
| --- | --- |
| Скринер ищет среди доступных к покупке акций | Запуск поиска, полный список Shares, обязательные признаки и время получения: [поиск](sequences/stock-screener.mmd), [контракт](contracts/stock-screener.openapi.yaml). |
| Условия скринера применяются совместно | Необязательные параметры в запросе, отбор по всему полученному списку до разделения на страницы; неизвестное не совпадает. |
| Результат представляет отдельные выпуски | После отбора группировка по нормализованному ISIN, иначе по UID; карточка выбранного представителя с точным UID. |
| Страницы относятся к одному результату поиска | Создание нового результата при каждом запуске, чтение последующих страниц того же результата по непрозрачному идентификатору; не более 30 выпусков на страницу. |
| Отсутствие совпадений отличается от недоступности поиска | Успешный пустой набор имеет `200` и `items: []`; ошибка каталога или потеря результата возвращает ошибку, не пустую страницу. |
## Systems and Responsibilities
- Пользователь задаёт условия и запускает поиск в приложении.
- Frontend хранит черновик условий отдельно от применённого поиска, показывает карточки, время получения и управляет навигацией по страницам. Он не отбирает только первые найденные строки и не склеивает результаты разных поисков.
- Backend проверяет локальную сессию и действующее подключение T-Bank, получает полный список акций, применяет обязательные признаки и выбранные условия, выбирает один подходящий инструмент на выпуск, фиксирует результат и выдаёт страницы. Инструмент и личный портфель — разные данные.
- T-Invest поставляет список акций с характеристиками и признаками доступности. Это источник сведений для нового поиска, но не подтверждение доступа конкретного счёта к сделке.
## Interaction Flows
[stock-screener.mmd](sequences/stock-screener.mmd) описывает новый поиск без текста, переход к следующей странице того же результата, повторный поиск, неподтверждённую доступность инструмента, отказ внешнего источника и отсутствие сохранённого результата. Чтение карточки выбранной акции после перехода по ссылке относится к существующему маршруту инструмента.
## Contracts
- **Frontend → Backend**, HTTP JSON, новая пара операций `POST /api/v1/stocks/screener/search` и `GET /api/v1/stocks/screener/search/{searchId}`. Целевой контракт версии `/api/v1`: [stock-screener.openapi.yaml](contracts/stock-screener.openapi.yaml). Каждый POST — новый поиск даже при совпадающих параметрах; повтор того же POST не идемпотентен в смысле получения данных. GET следующей страницы не повторяет обращение к каталогу. Ответ содержит `items`, `asOf`, `searchId`, `page`, `hasNext`. `searchId` непрозрачен и принадлежит аутентифицированному пользователю и текущему подключению; `page` — номер страницы с единицы. Контракт не задаёт срок хранения результата и не позволяет подменить применённые условия в запросе страницы.
- **Backend → T-Invest**, существующий gRPC over TLS: `InstrumentsService.Shares(InstrumentsRequest)` с `instrument_status = INSTRUMENT_STATUS_ALL` возвращает список `Share`. Базовый список не должен заранее исключать выпуск, у которого оба необходимых признака подтверждены. Отбор по сектору, типу, валюте, площадке, стране риска и лоту выполняется после получения всего списка: этих параметров нет в `InstrumentsRequest`. Ожидаются `buy_available_flag`, `api_trade_available_flag`, `uid`, `isin`, `ticker`, `name`, `sector`, `share_type`, `currency`, `real_exchange`, `country_of_risk`, `lot`. `FindInstrument` с обязательной поисковой строкой не является источником полного скринера.
- Контракт старого `GET /api/v1/stocks?query=...` не меняется в этой версии. Его обязательный текст, ограничение и текущая форма ответа продолжают работать для существующих потребителей; новое представление не выводится из его первых 30 результатов.
## Data and State Semantics
Источник истины для нового поиска — полученный целиком ответ Shares, а не накопленные инструменты портфеля или прежние текстовые запросы. Backend фиксирует полный набор совпавших выпусков и время успешного получения каталога до выдачи первой страницы. Пустая выдача подтверждается только успешным получением списка. Результат поиска отделён от долговременного самостоятельного справочника инструментов; правила существующего `online-instrument-information` остаются в силе.
Признаки `buy_available_flag = true` и `api_trade_available_flag = true` обязательны вместе. Неизвестное либо неподтверждённое значение любого из них исключает инструмент. Эти признаки относятся к снимку каталога, а не к биржевой сессии, счёту пользователя или исполнению заявки.
Применённые условия сравниваются с данными конкретного инструмента: текст — с названием или тикером, выбранные категории — с соответствующими значениями, `maxLot` — с известным положительным размером лота не больше границы. Отсутствие выбранного свойства исключает инструмент; отсутствие незаданного свойства не мешает совпадению. Страна риска — код страны основного бизнеса, а не юрисдикция регистрации. «Площадка» означает `real_exchange`, а не секцию/режим `class_code` и не дилерский `instrument_exchange`.
После отбора инструменты с одинаковым нормализованным ISIN представляют один выпуск; пустой ISIN заменяется точным UID, а не общим пустым ключом. При нескольких подходящих инструментах одного выпуска выбор представителя должен быть детерминированным; он определяет ссылку и все сведения карточки, данные режимов не смешиваются. Затем выпуски упорядочиваются по тикеру и UID выбранного инструмента и делятся на страницы по 30. `asOf` и порядок неизменны для всех страниц одного `searchId`. Повторное нажатие «Найти» не использует старый результат как новый, даже если условия совпадают.
## Error and Recovery Semantics
- Неверные границы или формат условий / страницы: `400` с публичной ошибкой в стандартном профиле. Отсутствующая сессия: `401`. Отсутствующее/недействительное подключение T-Bank: соответствующая ошибка доступа подключения; секрет и upstream-исключение не раскрываются.
- Источник каталога недоступен, возвращает неполный или неинтерпретируемый список: ошибка запроса, не `items: []`. При новом поиске предыдущий успешно показанный результат не становится доказательством нового поиска. Пользователь может повторить запуск.
- Идентификатор результата больше не доступен или не принадлежит текущему пользователю/подключению: ошибка вместо пустой страницы или пересчёта по новому каталогу. Frontend предлагает выполнить новый поиск. Потеря результата в рамках действующего поиска — техническая деградация; время и данные прошлого результата не выдаются за новый.
- Нет утверждений о «прямо сейчас можно купить»: статус торгов и права конкретного счёта не проверяются. Недоступная цена/логотип не меняет состав подходящих выпусков.
## Observability Semantics
Существенны попытка нового поиска, успешное получение каталога (`asOf`, число исходных инструментов, подходящих выпусков), технический отказ источника, чтение страницы недоступного результата и отказ доступа к чужому результату. Секрет подключения, пользовательский токен и полный текст запроса не нужны для диагностики. Успешный пустой результат отличим от отказа источника.
## Security and NFR at System Boundaries
Обе HTTP-операции требуют локальной аутентификации; доступ к T-Invest выполняется только на backend с токеном текущего подключения. Идентификатор результата не является правом доступа и не переносит данные между пользователями или поколениями подключения. Полный каталог нужен до пагинации, чтобы 30 карточек не превратились в ограничение области поиска. Значение `asOf` относится к получению каталога, не к актуальности возможности купить позже. Количественная задержка/SLA для получения списка пока не установлена; не следует обещать мгновенную выдачу при сбое внешнего сервиса.
## Compatibility / Migration
Новый маршрут добавляется без изменения действующего текстового поиска и адресов отдельных акций. Frontend переходит на новый контракт для скринера; старый маршрут остаётся доступен его текущим потребителям. После реализации backend-generated OpenAPI должен быть семантически совместим с целевым контрактом в архивируемом change. Документ в этом change не становится вторым постоянным источником исполняемой схемы.
## Durable Documentation Candidates
- Новое назначение раздела акций, набор фильтров и смысл «доступна к покупке по данным каталога» в описании домена акций.
- Граница между online-каталогом, страницей акции и личным портфелем; снимок результата, время получения и стабильные страницы.
- Семантика новых HTTP-операций на уровне описания API без дублирования machine-readable OpenAPI. Окончательный выбор принадлежит `docs-impact`.
## Open Questions
Нет блокирующих вопросов по согласованным границам поведения. Выбор механизма хранения результата и допустимой внутренней длительности его доступности принадлежит Engineering Design; потеря результата остаётся ошибкой, а не молчаливой заменой поиска.
@@ -0,0 +1,177 @@
openapi: 3.0.3
info:
title: WealthLedger stock screener target contract
version: 1.0.0
description: Целевой контракт change; исполняемый HTTP-контракт после реализации генерирует backend.
paths:
/api/v1/stocks/screener/search:
post:
operationId: createStockScreenerSearch
summary: Получить новый результат скринера по полному каталогу акций
security:
- bearer: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StockScreenerCriteria'
responses:
'200':
description: Первая страница нового результата, включая подтверждённый пустой список
content:
application/json:
schema:
$ref: '#/components/schemas/StockScreenerPage'
'400':
$ref: '#/components/responses/Problem'
'401':
$ref: '#/components/responses/Problem'
'503':
$ref: '#/components/responses/Problem'
/api/v1/stocks/screener/search/{searchId}:
get:
operationId: readStockScreenerPage
summary: Прочитать страницу того же зафиксированного результата
security:
- bearer: []
parameters:
- name: searchId
in: path
required: true
schema:
type: string
description: Непрозрачный идентификатор результата; не является полномочием доступа.
- name: page
in: query
required: true
schema:
type: integer
minimum: 1
description: Номер страницы с единицы. Условия поиска при чтении страницы не меняются.
responses:
'200':
description: Страница зафиксированного результата с тем же временем получения каталога
content:
application/json:
schema:
$ref: '#/components/schemas/StockScreenerPage'
'400':
$ref: '#/components/responses/Problem'
'401':
$ref: '#/components/responses/Problem'
'404':
$ref: '#/components/responses/Problem'
'503':
$ref: '#/components/responses/Problem'
components:
securitySchemes:
bearer:
type: http
scheme: bearer
responses:
Problem:
description: Ошибка в текущем публичном профиле WealthLedger; отсутствие результата не кодируется пустым списком.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
schemas:
Problem:
type: object
required: [status, code]
properties:
status:
type: integer
code:
type: string
title:
type: string
detail:
type: string
StockScreenerCriteria:
type: object
description: Все условия необязательны; отсутствие полей означает поиск по всем доступным к покупке акциям каталога.
additionalProperties: false
properties:
query:
type: string
maxLength: 80
description: Необязательная подстрока названия или тикера; пустая строка равнозначна отсутствию условия.
sector:
type: string
minLength: 1
shareType:
type: string
minLength: 1
currency:
type: string
minLength: 1
realExchange:
type: string
minLength: 1
description: Реальная площадка исполнения, не код торгового режима.
countryOfRisk:
type: string
minLength: 1
description: Код страны основного бизнеса.
maxLot:
type: integer
minimum: 1
description: Включительная верхняя граница числа бумаг в лоте.
StockScreenerPage:
type: object
required: [searchId, asOf, page, hasNext, items]
properties:
searchId:
type: string
description: Идентификатор только этого поиска, ограничен пользователем и подключением.
asOf:
type: string
format: date-time
description: Время получения полного списка акций от T-Invest, одинаковое на всех страницах результата.
page:
type: integer
minimum: 1
hasNext:
type: boolean
items:
type: array
maxItems: 30
items:
$ref: '#/components/schemas/StockScreenerItem'
StockScreenerItem:
type: object
required: [instrumentUid, name, ticker, isin, shareType, sector, currency, realExchange, countryOfRisk, lot]
properties:
instrumentUid:
type: string
format: uuid
description: Точный выбранный инструмент для ссылки на страницу акции; нельзя подменять неоднозначным тикером.
name:
type: string
ticker:
type: string
nullable: true
isin:
type: string
nullable: true
shareType:
type: string
nullable: true
sector:
type: string
nullable: true
currency:
type: string
nullable: true
realExchange:
type: string
nullable: true
countryOfRisk:
type: string
nullable: true
lot:
type: integer
minimum: 1
nullable: true
@@ -0,0 +1,40 @@
sequenceDiagram
actor U as Пользователь
participant F as WealthLedger frontend
participant B as WealthLedger backend
participant T as T-Invest API
U->>F: Выбирает условия и нажимает «Найти»
F->>B: POST /api/v1/stocks/screener/search (условия)
B->>B: Проверка сессии и подключения
alt Нет доступа или неверные условия
B-->>F: 400 / 401 / ошибка подключения
F-->>U: Объяснение ошибки без нового результата
else Доступ подтверждён
B->>T: Shares (INSTRUMENT_STATUS_ALL)
alt Каталог получен полностью
T-->>B: Share[]
B->>B: Проверить доступность, фильтры, выпуски, сортировку и зафиксировать asOf
B-->>F: 200, items (до 30), searchId, asOf, hasNext
F-->>U: Первая страница или подтверждённый пустой результат
opt Есть следующая страница
U->>F: Далее
F->>B: GET /api/v1/stocks/screener/search/{searchId}?page=2
alt Результат доступен тому же пользователю и подключению
B-->>F: 200, следующие items, тот же asOf
F-->>U: Следующая страница без пропусков и повторов
else Результат недоступен или сменилось подключение
B-->>F: Ошибка без пересчёта по новому каталогу
F-->>U: Результат недоступен, предложить новый поиск
end
end
else Каталог недоступен или неполон
T-->>B: Ошибка / неполные сведения
B-->>F: Ошибка поиска, не пустая выборка
F-->>U: Поиск недоступен, можно повторить
end
end
opt Условия изменены или нажат повторный поиск
U->>F: Нажимает «Найти»
F->>B: Новый POST с текущими условиями
Note over B,T: Новый список и новая первая страница без подмены прежнего searchId
end