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