Files
dzentra_bot/docs/migrations/build_021.md

814 lines
18 KiB
Markdown
Raw 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.
# 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
```