feat: add market data architecture and complete migration through build 039

This commit is contained in:
2026-07-14 09:58:16 +03:00
parent 26deb861bc
commit a996f2f797
443 changed files with 80452 additions and 1335 deletions

View File

@@ -0,0 +1,814 @@
# Build 021 — Перевод первой группы потребителей на канонический Instrument API
## Статус
**Завершён**
---
## Цель Build
Перевести первую группу production-потребителей справочника инструментов с legacy-модели:
```python
ExchangeSymbol
```
и legacy API:
```python
ExchangeService.get_exchange_symbols()
```
на каноническую модель:
```python
Instrument
```
и новый публичный API:
```python
ExchangeService.get_instruments()
```
При этом необходимо:
- сохранить работоспособность существующего бота;
- не удалять legacy API преждевременно;
- не нарушить существующие runtime-контуры;
- сохранить прежнее поведение потребителей;
- продолжить постепенный переход к каноническому Instrument Reference Data pipeline;
- не создавать параллельный источник истины для справочника инструментов.
---
## Исходное состояние
После завершения Build 020 канонический справочник инструментов уже сохранялся в:
```python
InstrumentStoreProtocol
```
с текущей in-memory реализацией:
```python
InMemoryInstrumentStore
```
В `ExchangeService` существовали два уровня представления данных:
```text
Instrument
Instrument Store
get_exchange_symbols()
ExchangeSymbol
legacy consumers
```
Каноническая модель:
```python
Instrument
```
уже являлась источником полных reference data инструмента, однако production-потребители продолжали использовать legacy-модель:
```python
ExchangeSymbol
```
Первым выбранным потребителем стал:
```text
app/src/telegram/ui/currency_ui.py
```
До миграции он получал список инструментов через:
```python
exchange_service.get_exchange_symbols()
```
и работал с:
```python
ExchangeSymbol
```
---
## Принятое архитектурное решение
В `ExchangeService` добавлен публичный канонический API:
```python
def get_instruments(self) -> tuple[Instrument, ...]:
...
```
Теперь новые потребители должны получать reference data через:
```python
ExchangeService.get_instruments()
```
Legacy API:
```python
ExchangeService.get_exchange_symbols()
```
сохраняется как compatibility API для ещё не переведённых потребителей.
Целевая архитектура на текущем этапе:
```text
Dzengi exchangeInfo
DzengiInstrumentDocumentSource
InstrumentFeed
DzengiInstrumentDocumentHandler
InstrumentAcquisitionService
tuple[Instrument, ...]
Instrument Store
ExchangeService.get_instruments()
├──→ new consumers
└──→ get_exchange_symbols()
map_instruments_to_exchange_symbols()
list[ExchangeSymbol]
legacy consumers
```
Таким образом:
- `Instrument` является канонической моделью;
- `Instrument Store` является runtime-хранилищем канонического справочника;
- `get_instruments()` является публичным API для новых и мигрированных потребителей;
- `get_exchange_symbols()` является временным compatibility API;
- `ExchangeSymbol` не является источником истины.
---
## Реализованные изменения
### 1. Добавлен публичный `get_instruments()`
В файле:
```text
app/src/integrations/exchange/service.py
```
добавлен публичный метод:
```python
def get_instruments(self) -> tuple[Instrument, ...]:
...
```
Метод обеспечивает единый доступ к каноническому справочнику инструментов.
Его поведение:
```text
exchange disabled
return ()
exchange enabled
Instrument Store lookup
┌── cache hit ──→ return tuple[Instrument, ...]
└── cache miss
acquisition pipeline
tuple[Instrument, ...]
Instrument Store
return instruments
```
---
### 2. Сохранено поведение при выключенной бирже
Если:
```python
exchange_enabled is False
```
метод:
```python
get_instruments()
```
возвращает:
```python
()
```
При этом:
- `Instrument Store` не читается;
- acquisition pipeline не запускается;
- внешние запросы к бирже не выполняются.
---
### 3. Реализовано чтение из Instrument Store
При вызове:
```python
get_instruments()
```
сначала проверяется каноническое хранилище:
```python
ExchangeService._instrument_store
```
Если данные уже присутствуют, метод возвращает сохранённый:
```python
tuple[Instrument, ...]
```
без повторного запуска acquisition pipeline.
Это устраняет повторную загрузку reference data и сохраняет единый runtime-источник канонических инструментов.
---
### 4. Реализована загрузка при отсутствии данных в Store
Если `Instrument Store` не содержит данных для текущего источника, `get_instruments()` запускает существующий acquisition pipeline:
```text
DzengiInstrumentDocumentSource
InstrumentFeed
DzengiInstrumentDocumentHandler
InstrumentAcquisitionService
tuple[Instrument, ...]
```
Полученные канонические модели:
```python
Instrument
```
сохраняются в:
```python
Instrument Store
```
и затем возвращаются вызывающему коду.
---
### 5. `get_exchange_symbols()` переведён на канонический `get_instruments()`
Legacy API:
```python
get_exchange_symbols()
```
больше не должен самостоятельно загружать справочник инструментов.
Теперь его роль ограничена compatibility projection:
```text
get_instruments()
tuple[Instrument, ...]
map_instruments_to_exchange_symbols()
list[ExchangeSymbol]
```
Таким образом, оба API используют один канонический источник данных:
```text
Instrument Store
```
а параллельная загрузка reference data отсутствует.
---
### 6. Переведён первый production-потребитель
Файл:
```text
app/src/telegram/ui/currency_ui.py
```
переведён с:
```python
ExchangeSymbol
```
на:
```python
Instrument
```
До миграции использовался resolver:
```python
_resolve_asset_quote_symbol()
```
После миграции используется:
```python
_resolve_asset_quote_instrument()
```
До миграции:
```python
symbols = exchange_service.get_exchange_symbols()
```
После миграции:
```python
instruments = exchange_service.get_instruments()
```
Теперь `currency_ui.py` больше не зависит от:
```python
ExchangeSymbol
```
и:
```python
get_exchange_symbols()
```
---
## Поведение `currency_ui.py` после миграции
Логика выбора торгового инструмента сохранена.
Для заданного актива:
```python
BTC
```
resolver получает:
```python
tuple[Instrument, ...]
```
и выбирает кандидатов, у которых:
```text
base_asset == BTC
```
и:
```text
quote_asset ∈ {USD, USDT}
```
Затем кандидаты сортируются по прежним приоритетам:
```text
1. quote asset
2. trading status
3. market type
4. symbol
```
Приоритет котируемой валюты:
```text
USD → 3
USDT → 2
other → 0
```
Приоритет статуса:
```text
TRADING → 2
other status → 1
HALT/BREAK → 0
```
Приоритет типа рынка:
```text
SPOT → 3
LEVERAGE → 2
other → 1
```
Таким образом, поведение выбора инструмента осталось совместимым с предыдущей реализацией.
---
## Получение USD-оценки актива
Функция:
```python
get_asset_usd_rate()
```
теперь использует канонический `Instrument`.
Последовательность:
```text
currency
USD / USDT?
├── yes → 1.0
└── no
price cache hit?
├── yes → cached rate
└── no
_resolve_asset_quote_instrument()
Instrument | None
exchange_service.get_price(instrument.symbol)
rate
```
При этом существующая логика:
```python
USDT ~= USD
```
сохранена без изменения.
---
## Обработка ошибок
Если:
```python
exchange_service.get_instruments()
```
вызывает:
```python
ExchangeError
```
resolver возвращает:
```python
None
```
Если подходящий инструмент отсутствует:
```python
None
```
Если получение цены вызывает:
```python
ExchangeError
```
в price cache сохраняется:
```python
None
```
и функция возвращает:
```python
None
```
Таким образом, прежняя отказоустойчивая семантика `currency_ui.py` сохранена.
---
## Добавленные тесты
Добавлен отдельный тестовый файл:
```text
app/tests/unit/integrations/exchange/test_service_instruments.py
```
Он проверяет канонический API:
```python
ExchangeService.get_instruments()
```
В том числе:
- возврат пустого tuple при выключенной бирже;
- отсутствие чтения Store при выключенной бирже;
- отсутствие запуска acquisition pipeline при выключенной бирже;
- чтение существующих инструментов из Store;
- загрузку через acquisition pipeline при cache miss;
- сохранение загруженных инструментов в Store;
- повторное использование Store;
- общее состояние Store между экземплярами `ExchangeService`;
- сохранение ошибок acquisition pipeline;
- отсутствие fallback на legacy REST path;
- использование `get_instruments()` внутри `get_exchange_symbols()`;
- преобразование канонических `Instrument` в legacy `ExchangeSymbol`;
- сохранение identity compatibility projection cache.
---
## Добавлены тесты для первого production-потребителя
Добавлен файл:
```text
app/tests/unit/telegram/ui/test_currency_ui.py
```
Тестами зафиксировано, что `currency_ui.py`:
- использует `get_instruments()`;
- не использует `get_exchange_symbols()`;
- работает с `Instrument`;
- выбирает инструмент с `USD` раньше `USDT`;
- учитывает статус инструмента;
- учитывает тип рынка;
- сохраняет детерминированную сортировку;
- возвращает `None`, если подходящего инструмента нет;
- возвращает `None` при ошибке получения справочника;
- не выполняет instrument lookup при наличии цены в cache;
- сохраняет прежнее поведение USD/USDT;
- корректно получает цену через `instrument.symbol`;
- сохраняет `None` в cache при ошибке получения цены;
- корректно рассчитывает USD-оценку баланса.
---
## Результаты тестирования
Проверка канонического API:
```text
14 passed in 0.11s
```
Проверка первого production-потребителя:
```text
20 passed in 0.08s
```
Совместная проверка затронутого migration-контура:
```text
73 passed in 0.11s
```
Полный regression suite:
```text
460 passed in 0.26s
```
Все тесты проходят успешно.
---
## Проверка фактического состояния кода
После завершения Build 021 файл:
```text
app/src/telegram/ui/currency_ui.py
```
содержит:
```python
from src.market_data.acquisition.models.instrument import Instrument
```
и использует:
```python
exchange_service.get_instruments()
```
Legacy-зависимости в этом production-потребителе отсутствуют:
```text
ExchangeSymbol
get_exchange_symbols()
_resolve_asset_quote_symbol()
```
В `ExchangeService` одновременно существуют:
```python
def get_instruments(self) -> tuple[Instrument, ...]:
...
```
и:
```python
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
...
```
Это ожидаемое промежуточное состояние миграции.
---
## Что намеренно не сделано в Build 021
В рамках этого Build не удалялись:
```python
ExchangeSymbol
```
```python
SymbolValidationResult
```
```python
get_exchange_symbols()
```
```python
validate_symbol()
```
```python
map_instruments_to_exchange_symbols()
```
```python
_exchange_symbols_projection_cache
```
Они остаются необходимыми для ещё не переведённых legacy-потребителей.
Также не переводились следующие runtime-контуры:
```text
ExchangeService internal runtime methods
market_stream.py
market_data_runner.py
telegram/handlers/market.py
```
Их миграция должна выполняться отдельными Build с собственными regression tests.
---
## Архитектурный результат
До Build 021:
```text
Instrument
Instrument Store
ExchangeSymbol projection
all production consumers
```
После Build 021:
```text
Instrument
Instrument Store
ExchangeService.get_instruments()
┌─────┴─────┐
↓ ↓
new consumers compatibility
↓ ↓
currency_ui get_exchange_symbols()
ExchangeSymbol
legacy consumers
```
Первый production-потребитель полностью переведён на канонический `Instrument API`.
---
## Критерии завершения Build 021
Build считается завершённым, поскольку выполнены все критерии:
- [x] добавлен публичный `ExchangeService.get_instruments()`;
- [x] `get_instruments()` использует `Instrument Store`;
- [x] при cache miss используется acquisition pipeline;
- [x] при выключенной бирже не читается Store и не запускается acquisition;
- [x] `get_exchange_symbols()` получает данные через `get_instruments()`;
- [x] первый production-потребитель переведён на `Instrument`;
- [x] `currency_ui.py` больше не использует `ExchangeSymbol`;
- [x] `currency_ui.py` больше не вызывает `get_exchange_symbols()`;
- [x] legacy API сохранён для остальных потребителей;
- [x] добавлены unit tests для `get_instruments()`;
- [x] добавлены unit tests для `currency_ui.py`;
- [x] migration-контур проходит `73` теста;
- [x] полный regression suite проходит `460` тестов;
- [x] существующий бот остаётся работоспособным.
---
## Итог
**Build 021 завершён успешно.**
В проекте появился публичный канонический API:
```python
ExchangeService.get_instruments()
```
Первый production-потребитель:
```text
app/src/telegram/ui/currency_ui.py
```
переведён с legacy-модели:
```python
ExchangeSymbol
```
на каноническую:
```python
Instrument
```
При этом legacy compatibility layer сохранён для остальных потребителей, а полный regression suite подтверждает отсутствие регрессий:
```text
460 passed in 0.26s
```