Files
dzentra_bot/docs/migrations/build_021.md

18 KiB
Raw Permalink Blame History

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 в legacy ExchangeSymbol;
  • сохранение 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