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