# Build 015 — Переключение `get_exchange_symbols()` на новый Acquisition Pipeline ## Статус **COMPLETE** --- ## Цель Переключить существующий публичный метод: ```python ExchangeService.get_exchange_symbols() ``` с прямого legacy-получения и обработки `exchangeInfo` на новый стандартизированный Instrument Reference Data acquisition pipeline, сохранив при этом существующий внешний контракт и работоспособность старого бота. --- ## Исходное состояние До Build 015 метод: ```python ExchangeService.get_exchange_symbols() ``` самостоятельно выполнял весь цикл обработки `exchangeInfo`: 1. создавал `ExchangeRestClient`; 2. выполнял прямой REST-запрос: ```text /api/v1/exchangeInfo ``` 3. извлекал массив `symbols`; 4. преобразовывал каждый элемент в legacy-модель `ExchangeSymbol`; 5. сохранял результат в class-level cache: ```python _exchange_symbols_cache ``` Таким образом, transport, validation, parsing, mapping и compatibility logic были сосредоточены внутри legacy `ExchangeService`. --- ## Реализованное изменение Метод: ```python ExchangeService.get_exchange_symbols() ``` переключён на новый Instrument Reference Data acquisition pipeline. Теперь production-путь использует следующую цепочку: ```text ExchangeService.get_exchange_symbols() │ ▼ _load_exchange_symbols_via_acquisition() │ ▼ DzengiInstrumentDocumentSource │ ▼ DzengiInstrumentDocumentHandler │ ▼ InstrumentFeed │ ▼ InstrumentFeedRegistry │ ▼ InstrumentAcquisitionService │ ▼ Instrument │ ▼ map_instruments_to_exchange_symbols() │ ▼ ExchangeSymbol ``` --- ## Новый production-путь В `ExchangeService` используется отдельный compatibility bridge: ```python def _load_exchange_symbols_via_acquisition( self, ) -> list[ExchangeSymbol]: ``` Его задача: 1. создать источник Instrument Reference Data для Dzengi; 2. создать обработчик документа; 3. собрать `InstrumentFeed`; 4. зарегистрировать feed; 5. выполнить acquisition через `InstrumentAcquisitionService`; 6. получить канонические модели `Instrument`; 7. преобразовать их в legacy-модели `ExchangeSymbol`. Это позволяет старому боту продолжать использовать существующий контракт: ```python list[ExchangeSymbol] ``` при том, что фактическим источником данных уже является новая архитектура `market_data/acquisition`. --- ## Сохранённый публичный контракт Сигнатура метода не изменилась: ```python def get_exchange_symbols(self) -> list[ExchangeSymbol]: ``` Это принципиально важно для безопасной поэтапной миграции. Существующие потребители не требуют немедленного изменения и продолжают работать через прежний API. В частности, существующий UI продолжает использовать: ```python exchange_service.get_exchange_symbols() ``` без знания о внутреннем переходе на новый acquisition pipeline. --- ## Сохранение cache semantics Сохранён существующий class-level cache: ```python _exchange_symbols_cache: list[ExchangeSymbol] | None = None ``` Поведение осталось прежним: ```text Первый вызов │ ▼ Новый acquisition pipeline │ ▼ Compatibility mapping │ ▼ _exchange_symbols_cache │ ▼ list[ExchangeSymbol] ``` Последующие вызовы: ```text _exchange_symbols_cache │ ▼ list[ExchangeSymbol] ``` без повторного обращения к acquisition pipeline. Cache заполняется только после успешной загрузки данных. При ошибке acquisition cache остаётся незаполненным. --- ## Поведение при отключённой бирже Сохранено прежнее поведение: ```python if not self.settings.exchange_enabled: return [] ``` Новый acquisition pipeline в этом случае не вызывается. --- ## Обработка ошибок Ошибки нового acquisition pipeline проходят через существующую систему `ExchangeService`. При ошибке: 1. ошибка логируется через: ```python self._log_exchange_error(...) ``` 2. используется legacy endpoint identifier: ```text exchangeInfo ``` 3. вызывающему коду возвращается совместимая `ExchangeError`. Это сохраняет существующее поведение старого бота и его журналирования. --- ## Удаление прямого legacy REST-пути После Build 015 метод: ```python get_exchange_symbols() ``` больше не выполняет прямой вызов: ```python ExchangeRestClient().get_json("/api/v1/exchangeInfo") ``` Фактический REST transport теперь инкапсулирован в: ```text src/market_data/acquisition/adapters/dzengi/rest.py ``` через: ```python DzengiInstrumentDocumentSource ``` и константу: ```python _EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo" ``` Таким образом, ownership получения Instrument Reference Data перенесён из: ```text integrations/exchange ``` в: ```text market_data/acquisition ``` --- ## Legacy helpers В `ExchangeService` временно остаются legacy helpers: ```python _extract_exchange_symbols_raw() _parse_exchange_symbol() _parse_exchange_symbol_status() _parse_market_modes() _extract_filter_value() ``` Они больше не являются частью нового production-пути `get_exchange_symbols()`. Их немедленное удаление не выполнялось в Build 015, поскольку миграция проводится поэтапно и без ненужного расширения scope текущего Build. Удаление legacy helpers должно выполняться отдельным контролируемым этапом после подтверждения отсутствия production-зависимостей и завершения необходимых migration/equivalence проверок. --- ## Добавленные тесты Создан файл: ```text tests/unit/integrations/exchange/test_service_exchange_symbols.py ``` Тестами проверяются: - возврат пустого списка при отключённой бирже; - отсутствие вызова acquisition pipeline при отключённой бирже; - возврат существующего cache; - отсутствие повторного acquisition при наличии cache; - загрузка через новый acquisition pipeline; - заполнение `_exchange_symbols_cache`; - повторное использование cache; - сохранение legacy-типа `ExchangeSymbol`; - сохранение порядка инструментов; - корректное распространение ошибок; - отсутствие заполнения cache при ошибке; - сохранение существующего error logging; - отсутствие прямого legacy REST-вызова из `get_exchange_symbols()`; - корректная сборка нового acquisition pipeline; - использование compatibility mapper; - корректное поведение пустого результата. --- ## Исправление статической типизации теста После первоначального завершения Build 015 в файле: ```text tests/unit/integrations/exchange/test_service_exchange_symbols.py ``` были обнаружены две ошибки статической типизации Pylance. ### Типизация yield-fixture Исходная аннотация: ```python @pytest.fixture(autouse=True) def reset_exchange_symbols_cache() -> None: ``` была некорректна, поскольку функция содержит `yield` и является генератором. Исправлено на: ```python @pytest.fixture(autouse=True) def reset_exchange_symbols_cache() -> Iterator[None]: ExchangeService._exchange_symbols_cache = None yield ExchangeService._exchange_symbols_cache = None ``` Добавлен импорт: ```python from collections.abc import Iterator ``` ### Типизация тестовых settings Тестовый helper создаёт `ExchangeService` без вызова его конструктора: ```python service = object.__new__(ExchangeService) ``` Для изоляции теста используется `SimpleNamespace`, тогда как production-атрибут: ```python service.settings ``` типизирован как `Settings`. Для явного обозначения тестовой границы применён `cast`: ```python service.settings = cast( Settings, _settings( exchange_enabled=exchange_enabled, ), ) ``` Таким образом: - production-код не изменялся; - тестовая изоляция сохранена; - `# type: ignore` не использовался; - ошибки Pylance устранены. --- ## Результаты окончательной проверки ### Проверка компиляции Команда: ```bash python -m py_compile \ src/integrations/exchange/service.py \ tests/unit/integrations/exchange/test_service_exchange_symbols.py ``` Результат: ```text Ошибок нет. ``` --- ### Unit-тесты Build 015 Команда: ```bash python -m pytest \ tests/unit/integrations/exchange/test_service_exchange_symbols.py \ -q ``` Результат: ```text 16 passed in 0.07s ``` --- ### Полный regression suite Команда: ```bash python -m pytest -q ``` Результат: ```text 220 passed in 0.15s ``` --- ## Проверка production-пути Выполнен поиск: ```bash grep -RIn \ --exclude-dir="__pycache__" \ --exclude="*.pyc" \ -E "get_exchange_symbols|_exchange_symbols_cache|_load_exchange_symbols_via_acquisition|DzengiInstrumentDocumentSource|InstrumentAcquisitionService|map_instruments_to_exchange_symbols|ExchangeRestClient.*exchangeInfo|exchangeInfo" \ src tests ``` Проверка подтвердила: - `get_exchange_symbols()` использует `_load_exchange_symbols_via_acquisition()`; - новый production-путь использует `DzengiInstrumentDocumentSource`; - используется `InstrumentAcquisitionService`; - используется compatibility mapper `map_instruments_to_exchange_symbols()`; - class-level cache `_exchange_symbols_cache` сохранён; - прямой legacy REST-вызов `exchangeInfo` удалён из `get_exchange_symbols()`; - существующие внешние потребители продолжают работать через прежний публичный контракт. --- ## Архитектурный результат До Build 015: ```text Legacy consumer │ ▼ ExchangeService.get_exchange_symbols() │ ▼ ExchangeRestClient │ ▼ exchangeInfo │ ▼ Legacy parsing │ ▼ ExchangeSymbol ``` После Build 015: ```text Legacy consumer │ ▼ ExchangeService.get_exchange_symbols() │ ▼ Instrument Acquisition Pipeline │ ▼ Canonical Instrument │ ▼ Compatibility Mapper │ ▼ ExchangeSymbol ``` Таким образом: - новый `market_data/acquisition` стал фактическим production-владельцем получения Instrument Reference Data; - legacy `ExchangeService` сохраняет прежний публичный API; - существующий бот продолжает работать без массового изменения потребителей; - создан безопасный compatibility boundary между новой и старой архитектурой; - переход выполнен без регрессий. --- ## Итог ```text BUILD 015 — COMPLETE ``` Build 015 завершён. `ExchangeService.get_exchange_symbols()` успешно переключён на новый Instrument Reference Data acquisition pipeline с сохранением: - существующего публичного контракта; - legacy-модели `ExchangeSymbol`; - cache semantics; - обработки ошибок; - журналирования; - существующих потребителей старого бота. Окончательные результаты проверки: ```text py_compile — успешно 16 passed in 0.07s 220 passed in 0.15s ```