build 039: complete Quotes Feed migration foundation
This commit is contained in:
814
docs/migrations/build_021.md
Normal file
814
docs/migrations/build_021.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user