# Build 040 — Восстановление принудительного REST refresh котировки на канонической модели Quote **Статус:** Завершён **Проект:** Dzentra **Подсистема:** Market Data Acquisition / Exchange Integration / Market Data Runtime **Тип изменения:** Восстановление runtime-контракта после миграции legacy price/snapshot API **Язык документа:** Русский --- ## 1. Цель Build 040 Цель Build 040 — восстановить принудительный REST refresh текущей рыночной котировки для `MarketDataRunner` после завершения Build 039 и удаления legacy price/snapshot API. В Build 039 были удалены legacy-сущности и методы: ```text TickerPrice MarketPriceSnapshot get_price() get_market_snapshot() get_fresh_market_snapshot() refresh_price_cache() refresh_market_snapshot_cache() _get_real_price() ``` После завершения миграции был обнаружен один оставшийся активный consumer удалённого метода: ```text src/integrations/exchange/market_data_runner.py ``` который продолжал вызывать: ```python ExchangeService().refresh_market_snapshot_cache ``` Таким образом, целью Build 040 являлось не изменение runtime-архитектуры и не новый рефакторинг, а минимальное восстановление разорванного контракта на основе канонической модели: ```text src.market_data.acquisition.models.quote.Quote ``` При этом необходимо было сохранить существующую логику работы старого бота без изменений. --- ## 2. Исходное состояние После Build 039 проект находился в следующем состоянии: 1. все основные consumers текущей рыночной цены были переведены на `Quote`; 2. legacy-модели: ```text TickerPrice MarketPriceSnapshot ``` были удалены; 3. legacy-методы получения и обновления price/snapshot были удалены; 4. полный regression suite успешно проходил: ```text 606 passed ``` 5. контрольный grep по основным legacy-контрактам был пустым; 6. при дополнительном анализе runtime-компонентов был обнаружен оставшийся вызов: ```python ExchangeService().refresh_market_snapshot_cache ``` в: ```text app/src/integrations/exchange/market_data_runner.py ``` Метод уже отсутствовал в `ExchangeService`, поэтому существовал разорванный runtime-контракт, который не проявлялся в предыдущем полном наборе тестов. --- ## 3. Архитектурный принцип Build 040 Build 040 сохраняет утверждённый архитектурный поток: ```text Exchange / Market Data Sources ↓ Market Data Acquisition ↓ Canonical Quote ↓ Quote Store / Market Price Cache ↓ ExchangeService ↓ MarketDataRunner / Trading Consumers ``` Канонической моделью текущей котировки остаётся: ```python Quote ``` из: ```text src.market_data.acquisition.models.quote ``` Build 040 не вводит новую параллельную price/snapshot-модель и не восстанавливает удалённые legacy-контракты. Вместо этого добавляется минимальный публичный compatibility-метод: ```python refresh_quote_cache() ``` который использует существующий канонический acquisition pipeline. --- ## 4. Главное требование сохранения поведения Основным требованием Build 040 являлось: > Не менять логику работы текущих файлов и существующего runtime. Поэтому перед реализацией была восстановлена фактическая старая реализация удалённого метода: ```python refresh_market_snapshot_cache() ``` из истории Git. Это позволило определить реальный контракт старого метода без предположений. Старая реализация выполняла: ```text нормализация runtime_key ↓ принудительное получение свежего REST snapshot ↓ проверка ценовых полей ↓ запись результата в MarketPriceCache ↓ возврат свежего snapshot ``` Критически важно: - cache перед REST-запросом не читался; - всегда выполнялось принудительное получение свежих данных; - результат сохранялся в cache соответствующего runtime; - возвращался свежий результат; - ошибки не подавлялись самим refresh-методом; - mock-режим сохранялся. --- ## 5. Восстановление старого контракта через Git Для определения фактического поведения удалённого метода была использована история Git. Команда: ```bash git log -S"def refresh_market_snapshot_cache" \ --oneline \ -- src/integrations/exchange/service.py ``` Результат: ```text a996f2f feat: add market data architecture and complete migration through build 039 e97dcd3 07.4.3.16 — Production Execution Pricing Layer ``` Старая реализация была извлечена командой: ```bash git show a996f2f^:app/src/integrations/exchange/service.py \ | grep -n -A45 -B10 "def refresh_market_snapshot_cache" ``` Восстановленный контракт подтвердил, что новый метод должен быть принудительным refresh-механизмом и не должен использовать обычную cache-first логику `get_quote()`. --- ## 6. Почему get_quote() не подходит для REST fallback Существующий метод: ```python ExchangeService.get_quote() ``` реализует cache-first поведение: ```text валидация символа ↓ чтение MarketPriceCache ↓ если Quote свежий ↓ возврат cached Quote ↓ иначе REST acquisition ``` Такое поведение корректно для обычных consumers, но не подходит для: ```python MarketDataRunner._rest_fallback_once() ``` После отказа WebSocket runner должен проверить именно доступность REST-механизма. Если использовать `get_quote()`, возможна ситуация: ```text WebSocket отключился ↓ в cache остаётся свежий WebSocket Quote ↓ get_quote() возвращает cached Quote ↓ REST-запрос фактически не выполняется ↓ runner ошибочно устанавливает REST state = AVAILABLE ``` Поэтому Build 040 вводит отдельный принудительный refresh-контракт. --- ## 7. Новый публичный контракт refresh_quote_cache() В `ExchangeService` добавлен метод: ```python def refresh_quote_cache( self, symbol: str | None = None, *, runtime_key: str | None = None, ) -> Quote: ``` Его ответственность: ```text нормализовать runtime_key ↓ определить symbol ↓ если exchange disabled получить mock Quote иначе валидировать symbol ↓ принудительно получить свежий Quote через REST acquisition ↓ сохранить тот же Quote в MarketPriceCache ↓ вернуть тот же Quote ``` Метод не читает cache перед получением свежей котировки. --- ## 8. Сохранение mock-поведения Старый метод: ```python refresh_market_snapshot_cache() ``` через: ```python get_fresh_market_snapshot() ``` поддерживал режим: ```text exchange_enabled = False ``` В этом режиме создавались mock-данные, которые затем сохранялись в cache. Новый метод сохраняет эквивалентное поведение через: ```python mock_quote(symbol_to_use) ``` При выключенной бирже: - REST acquisition не выполняется; - validation через exchange reference data не требуется; - создаётся канонический `Quote`; - тот же объект сохраняется в `MarketPriceCache`; - тот же объект возвращается consumer. --- ## 9. Принудительное получение свежего Quote В real-режиме новый метод использует: ```python self._get_fresh_quote( validation.normalized_symbol, ) ``` Этот метод вызывает существующий канонический pipeline: ```text DzengiQuoteDocumentSource ↓ DzengiQuoteDocumentHandler ↓ QuotesFeed ↓ QuoteFeedRegistry ↓ QuoteAcquisitionService ↓ Quote ``` Таким образом, Build 040 не добавляет новый способ получения котировок и не дублирует Market Data Acquisition. --- ## 10. Почему QuoteAcquisitionService не изменялся В ходе анализа было подтверждено, что: ```python QuoteAcquisitionService ``` намеренно отвечает только за получение канонической модели через зарегистрированный feed. Он не должен выполнять: - runtime isolation; - cache management; - fallback orchestration; - validation торгового runtime; - выбор `runtime_key`; - compatibility-поведение старого бота. Поэтому Build 040 не изменяет: ```text app/src/market_data/acquisition/service.py app/src/market_data/acquisition/feeds/quotes_feed.py ``` Compatibility и runtime semantics остаются в: ```text ExchangeService ``` --- ## 11. Изменение MarketDataRunner В: ```text app/src/integrations/exchange/market_data_runner.py ``` был изменён только один runtime-вызов. До Build 040: ```python ExchangeService().refresh_market_snapshot_cache ``` После Build 040: ```python ExchangeService().refresh_quote_cache ``` При этом не изменялись: - `asyncio.to_thread`; - передаваемый symbol; - `runtime_key`; - `last_rest_state`; - `last_rest_error_key`; - WebSocket-first поведение; - cooldown; - retry-логика; - journal events; - event titles; - lifecycle runner; - exception handling. --- ## 12. Runtime isolation Новый метод сохраняет существующее разделение runtime-контекстов через: ```python runtime_key ``` Перед использованием ключ нормализуется существующим методом: ```python self._runtime_key(runtime_key) ``` Котировка сохраняется через: ```python MarketPriceCache.set_quote( quote, runtime_key=normalized_runtime_key, ) ``` Это сохраняет независимость runtime-контекстов, включая: ```text auto debug_auto default ``` и другие существующие значения, если они используются текущим runtime. --- ## 13. Сохранение identity канонического Quote Каноническая модель: ```python Quote ``` является immutable-моделью: ```python @dataclass(frozen=True, slots=True) ``` Build 040 не создаёт копию полученного объекта. Последовательность: ```text fresh Quote ↓ MarketPriceCache.set_quote(quote) ↓ return quote ``` В cache сохраняется тот же экземпляр, который возвращается из метода. Это зафиксировано тестами. --- ## 14. Изменённые production-файлы В рамках Build 040 изменены только два production-файла: ```text app/src/integrations/exchange/service.py app/src/integrations/exchange/market_data_runner.py ``` ### 14.1. service.py Добавлен новый публичный метод: ```python refresh_quote_cache() ``` Существующие методы не подвергались сопутствующему рефакторингу. В частности, не изменялись: ```text get_quote() get_execution_snapshot() _get_fresh_quote() _load_quote_via_acquisition() ``` ### 14.2. market_data_runner.py Изменена только ссылка на удалённый legacy refresh-метод. Остальная логика runner сохранена. --- ## 15. Изменённые тестовые файлы В рамках Build 040 изменены: ```text app/tests/unit/integrations/exchange/test_service_quote.py app/tests/unit/integrations/exchange/test_market_data_runner.py ``` Добавлены тесты нового refresh-контракта и REST fallback runner. --- ## 16. Тесты refresh_quote_cache() Тестами зафиксировано следующее поведение: - принудительное получение свежего `Quote`; - отсутствие cache-first поведения; - замена существующей cache-записи новым объектом; - сохранение того же экземпляра `Quote`; - использование правильного `runtime_key`; - нормализация runtime key; - использование default runtime key; - сохранение `ExchangeError` для невалидного символа; - отсутствие REST acquisition при невалидном символе; - сохранение exception semantics acquisition pipeline; - отсутствие обновления cache при ошибке; - сохранение mock-поведения. --- ## 17. Тесты REST fallback MarketDataRunner Тестами зафиксировано поведение: ```python MarketDataRunner._rest_fallback_once() ``` При успешном refresh: ```text refresh_quote_cache() вызывается ↓ передаётся текущий symbol ↓ передаётся context.runtime_key ↓ last_rest_state = AVAILABLE ↓ last_rest_error_key = None ``` При ошибке сохраняется существующее поведение runner: ```text last_rest_state = UNAVAILABLE ``` и существующая обработка ошибки не изменяется. --- ## 18. Целевая проверка Build 040 Выполнена команда: ```bash python -m pytest -q \ tests/unit/integrations/exchange/test_service_quote.py \ tests/unit/integrations/exchange/test_market_data_runner.py ``` Финальный результат: ```text 18 passed in 0.11s ``` Все целевые тесты Build 040 успешно пройдены. --- ## 19. Связанные регрессионные тесты Выполнена команда: ```bash python -m pytest -q \ tests/unit/integrations/exchange/test_service_execution_quote.py \ tests/unit/integrations/exchange/test_service_symbol_runtime_status.py \ tests/unit/integrations/exchange/test_market_cache.py \ tests/unit/integrations/exchange/test_market_stream.py \ tests/unit/storage/test_quote_store.py ``` Финальный результат: ```text 107 passed in 1.81s ``` Связанные компоненты работают без регрессий. --- ## 20. Полный regression suite После завершения Build 040 выполнен полный набор тестов: ```bash python -m pytest -q ``` Финальный результат: ```text 614 passed in 2.03s ``` Все тесты успешно пройдены. По сравнению с Build 039 количество тестов увеличилось: ```text 606 → 614 ``` Добавлено: ```text 8 тестов ``` --- ## 21. Финальный архитектурный grep Выполнена команда: ```bash grep -RIn \ --exclude-dir="__pycache__" \ --exclude="*.pyc" \ -E "refresh_market_snapshot_cache|refresh_price_cache|MarketPriceSnapshot|TickerPrice" \ src tests ``` Финальный результат: ```text <пусто> ``` Это подтверждает отсутствие следующих legacy-контрактов: ```text refresh_market_snapshot_cache refresh_price_cache MarketPriceSnapshot TickerPrice ``` в: ```text src tests ``` --- ## 22. Что намеренно не изменялось В Build 040 намеренно не изменялись: ```text Market Data Acquisition architecture Quote model QuoteAcquisitionService QuotesFeed Quote Store MarketPriceCache contract get_quote() get_execution_snapshot() WebSocket adapter market_stream.py MarketDataRunner lifecycle MarketDataRunner retry logic runtime state transitions journal events event titles Telegram UI trading strategies execution logic diagnostics logic ``` Также не выполнялись: - переносы файлов; - переименования каталогов; - изменение утверждённой структуры проекта; - добавление новых abstraction layers; - добавление override-параметров «на будущее»; - удаление дополнительных legacy-компонентов вне границ Build 040. --- ## 23. Критерии завершения Build 040 Build 040 считается завершённым, поскольку выполнены все критерии: - [x] найден оставшийся активный вызов удалённого `refresh_market_snapshot_cache`; - [x] фактический старый контракт восстановлен из Git; - [x] новый контракт реализован на канонической модели `Quote`; - [x] добавлен `refresh_quote_cache()`; - [x] новый метод не использует cache-first поведение; - [x] fresh Quote принудительно получается через существующий acquisition pipeline; - [x] runtime isolation сохранена; - [x] mock-поведение сохранено; - [x] exception semantics сохранена; - [x] тот же экземпляр `Quote` сохраняется и возвращается; - [x] `MarketDataRunner` переключён на новый метод; - [x] WebSocket-first логика не изменена; - [x] lifecycle runner не изменён; - [x] целевые тесты проходят; - [x] связанные регрессионные тесты проходят; - [x] полный regression suite проходит; - [x] финальный legacy grep пуст. --- ## 24. Итоговое состояние после Build 040 После завершения Build 040 runtime-поток получения котировки имеет следующий вид: ```text WebSocket available ↓ DzengiWebSocketQuoteAdapter ↓ Quote ↓ MarketPriceCache ``` При отказе WebSocket: ```text WebSocket unavailable ↓ MarketDataRunner._rest_fallback_once() ↓ ExchangeService.refresh_quote_cache() ↓ Quote Acquisition Pipeline ↓ Fresh REST Quote ↓ MarketPriceCache ↓ REST state = AVAILABLE ``` При ошибке REST refresh: ```text REST acquisition error ↓ ExchangeError ↓ MarketDataRunner existing error handling ↓ REST state = UNAVAILABLE ``` Таким образом, Build 040 восстанавливает разорванный fallback-контракт, не изменяя существующую runtime-логику. --- ## 25. Зафиксированный принцип для следующих Builds Build 040 подтверждает принцип, зафиксированный после Build 039: > При миграции нельзя считать новую функцию эквивалентной старой только потому, что они решают похожую задачу. Перед заменой legacy-компонента необходимо восстановить и сравнить фактическое поведение: ```text порядок операций чтение cache запись cache принудительность refresh тип возвращаемого значения identity результата runtime isolation mock-поведение fallback-поведение exception semantics side effects граничные условия ``` Если старый контракт доступен в истории Git, его необходимо использовать как источник фактического поведения вместо предположений. --- ## 26. Статус Build ```text Build 040: COMPLETED Target tests: 18 passed Related regression tests: 107 passed Full regression suite: 614 passed Legacy grep: empty Canonical quote model: Quote REST fallback contract: refresh_quote_cache() ``` Build 040 полностью завершён.