# 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 ```