# Build 008 — Dzengi REST Adapter **Статус:** Завершён **Подсистема:** `market_data/acquisition` **Область:** Instrument Reference Data **Тип изменения:** Изолированное добавление transport adapter без подключения к production runtime **Результат полного набора тестов:** `123 passed` --- ## 1. Цель Build 008 Цель Build 008 — реализовать конкретный транспортный источник Instrument Reference Data для Dzengi REST API, соответствующий созданному в Build 007 протоколу: ```python class InstrumentDocumentSource(Protocol): def fetch_instrument_document(self) -> object: ... ``` Реализован класс: ```text DzengiInstrumentDocumentSource ``` Архитектурная граница Build 008: ```text Dzengi REST API ↓ ExchangeRestClient ↓ DzengiInstrumentDocumentSource ↓ object ``` Build 008 отвечает только за получение декодированного транспортного документа. Он не выполняет его структурную проверку, parsing, проверку значений или преобразование во внутренние модели `Instrument`. --- ## 2. Почему Build 008 выполняется именно сейчас До начала Build 008 были завершены: ```text Build 001 — внутренняя модель Instrument Reference Data Build 002 — raw-модели ответа Dzengi Build 003 — структурная валидация exchangeInfo Build 004 — parser exchangeInfo Build 005 — value validation Build 006 — mapper Dzengi → Instrument Build 007 — Protocol и Exceptions ``` После Build 007 были зафиксированы контракты будущей orchestration-цепочки: ```text InstrumentDocumentSource ↓ InstrumentDocumentHandler ↓ InstrumentFeedProtocol ↓ Registry ↓ Acquisition Service ``` Следующим необходимым шагом стала конкретная реализация первого контракта: ```text InstrumentDocumentSource ``` для источника Dzengi. --- ## 3. Анализ существующей реализации Перед написанием кода были проанализированы: ```text app/src/integrations/exchange/rest_client.py app/src/integrations/exchange/exceptions.py app/src/core/config.py app/src/integrations/exchange/service.py app/src/market_data/acquisition/adapters/dzengi/rest.py ``` Также выполнен поиск существующих вызовов `exchangeInfo`: ```bash grep -RIn \ --exclude-dir="__pycache__" \ --exclude="*.pyc" \ -E "exchangeInfo|exchange_info|get_exchange_symbols" \ src/integrations/exchange ``` Было подтверждено, что legacy-реализация использует: ```python client.get_json("/api/v1/exchangeInfo") ``` в методе: ```text ExchangeService.get_exchange_symbols() ``` Таким образом, фактически используемый работающим ботом endpoint: ```text /api/v1/exchangeInfo ``` --- ## 4. Принятое решение по HTTP transport В Build 008 не создавалась новая параллельная HTTP-реализация. Вместо этого новый Dzengi REST Adapter временно переиспользует существующий: ```text src.integrations.exchange.rest_client.ExchangeRestClient ``` Причины: ```text клиент уже использует актуальный EXCHANGE_BASE_URL; клиент уже применяет EXCHANGE_TIMEOUT_SEC; клиент уже реализует HTTP GET; клиент уже устанавливает необходимые HTTP headers; клиент уже выполняет JSON decoding; клиент уже преобразует HTTP/network ошибки в exchange exceptions; создание второго HTTP transport дублировало бы существующую инфраструктуру; legacy-код работающего бота не требуется изменять. ``` Это является временной переходной зависимостью. Условие её будущего удаления: ```text появление утверждённого общего transport-клиента или полный вывод legacy integrations/exchange из эксплуатации. ``` --- ## 5. Изменённые файлы В рамках Build 008 изменён production-файл: ```text app/src/market_data/acquisition/adapters/dzengi/rest.py ``` Создан тестовый файл: ```text app/tests/unit/market_data/acquisition/adapters/dzengi/test_rest.py ``` Другие production-файлы не изменялись. --- ## 6. Реализованный Dzengi REST Adapter В файле: ```text app/src/market_data/acquisition/adapters/dzengi/rest.py ``` реализован класс: ```python class DzengiInstrumentDocumentSource: ... ``` Его публичный метод: ```python def fetch_instrument_document(self) -> object: ... ``` соответствует контракту: ```text InstrumentDocumentSource ``` --- ## 7. Endpoint exchangeInfo В adapter зафиксирован endpoint: ```python _EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo" ``` Он соответствует фактически используемому legacy-кодом endpoint. Путь вынесен в приватную константу, чтобы не дублировать строковый литерал внутри методов. Build 008: ```text не меняет версию endpoint; не вводит альтернативный endpoint; не изменяет legacy-вызов; не переключает production runtime. ``` --- ## 8. Почему используется get_payload() Legacy REST-клиент предоставляет два метода: ```python get_payload() -> object ``` и: ```python get_json() -> dict ``` Для нового adapter выбран: ```python get_payload() ``` Это принципиальное архитектурное решение. Контракт `InstrumentDocumentSource` возвращает: ```python object ``` Следовательно, transport layer не должен утверждать, что полученный JSON обязательно является объектом `dict`. Если внешний источник вернёт: ```text dict list str int float bool None ``` REST adapter должен вернуть декодированный документ следующему слою без структурной интерпретации. Проверка структуры относится к Build 003: ```text validate_exchange_info_schema() ``` Поэтому архитектурная граница остаётся: ```text Dzengi REST API ↓ ExchangeRestClient.get_payload() ↓ object ↓ Schema Validation ``` Использование `get_json()` преждевременно смешало бы: ```text transport responsibility ``` и: ```text schema validation responsibility ``` --- ## 9. Отсутствие преобразования документа `DzengiInstrumentDocumentSource` возвращает результат: ```python client.get_payload(_EXCHANGE_INFO_PATH) ``` без изменения. Adapter не выполняет: ```text копирование документа; изменение полей; извлечение payload; извлечение symbols; нормализацию значений; преобразование коллекций; создание raw-моделей; создание Instrument. ``` Тестами подтверждено сохранение identity: ```python assert result is document ``` как для `dict`, так и для `list`. --- ## 10. Dependency Injection Adapter поддерживает передачу существующего REST-клиента: ```python class DzengiInstrumentDocumentSource: def __init__( self, client: _PayloadRestClient | None = None, ) -> None: self._client = client ``` Поведение: ```text client передан ↓ используется переданный объект client не передан ↓ создаётся ExchangeRestClient() ``` Dependency injection необходим уже на текущем Build для: ```text unit-тестирования без реальных сетевых запросов; точной проверки вызываемого endpoint; эмуляции transport errors; проверки неизменности возвращаемого документа. ``` --- ## 11. Приватный транспортный Protocol Первоначально конструктор adapter был аннотирован следующим образом: ```python client: ExchangeRestClient | None = None ``` При передаче тестового `StubRestClient` Pylance корректно обнаружил несовместимость типов: ```text Argument of type "StubRestClient" cannot be assigned to parameter "client" of type "ExchangeRestClient | None" ``` Оставлять такую диагностическую ошибку в Build было признано неправильным. Для исправления создан минимальный приватный структурный контракт: ```python class _PayloadRestClient(Protocol): def get_payload( self, path: str, params: dict[str, str] | None = None, headers: dict[str, str] | None = None, ) -> object: ... ``` Теперь конструктор принимает: ```python client: _PayloadRestClient | None = None ``` Это позволяет типобезопасно передавать: ```text ExchangeRestClient; StubRestClient; любую другую реализацию с совместимым get_payload(). ``` --- ## 12. Почему _PayloadRestClient является приватным Protocol назван: ```text _PayloadRestClient ``` с ведущим подчёркиванием сознательно. На текущем этапе он нужен только как внутренняя типовая граница конкретного Dzengi REST Adapter. Он: ```text не является публичным контрактом всей acquisition subsystem; не экспортируется через __init__.py; не используется другими production-модулями; не создаёт новый общий transport abstraction layer. ``` Если в будущем нескольким adapter потребуется общий HTTP transport contract, его выделение в публичный модуль должно выполняться отдельным архитектурным решением на основании реальной потребности. --- ## 13. Ленивое создание ExchangeRestClient Если клиент не передан, он создаётся внутри: ```python fetch_instrument_document() ``` а не в конструкторе `DzengiInstrumentDocumentSource`. Последовательность: ```text DzengiInstrumentDocumentSource() ↓ объект создаётся без немедленного создания ExchangeRestClient fetch_instrument_document() ↓ создаётся ExchangeRestClient ↓ выполняется REST-запрос ``` Это важно, потому что конструктор legacy `ExchangeRestClient`: ```text загружает настройки; проверяет EXCHANGE_BASE_URL; может выбросить исключение ещё до HTTP-запроса. ``` Создание клиента внутри `try` гарантирует, что ошибка его создания также преобразуется в: ```text InstrumentReferenceTransportError ``` --- ## 14. Transport error boundary Build 007 добавил специализированную ошибку: ```python InstrumentReferenceTransportError ``` Build 008 впервые использует её в production-коде новой подсистемы. Любая ошибка, возникающая внутри transport boundary: ```python except Exception as exc: ``` преобразуется в: ```python raise InstrumentReferenceTransportError( "Не удалось получить Instrument Reference Data " f"от Dzengi: {exc}" ) from exc ``` Это обеспечивает единый публичный тип transport-ошибки для новой acquisition subsystem. --- ## 15. Какие ошибки оборачиваются Тестами подтверждена обработка: ```text ExchangeConnectionError ExchangeResponseError RuntimeError ошибки создания ExchangeRestClient ``` Также текущая граница перехватывает другие `Exception`, возникающие при получении документа. Наружу новой подсистемы не должны непосредственно выходить legacy-типы: ```text ExchangeConnectionError ExchangeResponseError ExchangeError ``` На границе нового adapter они преобразуются в: ```text InstrumentReferenceTransportError ``` --- ## 16. Exception chaining При преобразовании ошибки сохраняется исходное исключение: ```python raise InstrumentReferenceTransportError(...) from exc ``` Благодаря этому: ```python wrapped_error.__cause__ is original_error ``` Тестами подтверждено сохранение исходной причины. Это важно для: ```text диагностики; логирования; traceback; будущего анализа transport failures. ``` --- ## 17. Что REST Adapter не делает Build 008 сознательно не выполняет: ```text schema validation; parsing; value validation; mapping; создание Instrument; создание Handler; создание Feed; создание Registry; создание Acquisition Service; кэширование; retry; логирование; формирование Telegram-сообщений; проверку exchange_enabled; production-подключение. ``` В adapter отсутствуют вызовы: ```text validate_exchange_info_schema() parse_exchange_info() validate_exchange_info_values() map_dzengi_exchange_info_to_instruments() ``` --- ## 18. Почему adapter не проверяет exchange_enabled `DzengiInstrumentDocumentSource` является transport adapter. Его ответственность: ```text получить документ от конкретного внешнего источника. ``` Решение о том: ```text разрешено ли приложению обращаться к бирже; в каком режиме работает приложение; нужно ли использовать mock; нужно ли использовать cache; когда именно запускать acquisition; ``` относится к orchestration или service layer. Поэтому Build 008 не добавляет проверку: ```python exchange_enabled ``` и не реализует mock-поведение. --- ## 19. Почему adapter не использует format_exchange_error_for_user() В legacy-модуле существует: ```python format_exchange_error_for_user() ``` Он предназначен для пользовательского представления ошибок. REST adapter не является UI-слоем. Поэтому он не должен: ```text формировать пользовательский текст; знать о Telegram UI; классифицировать ошибку для экрана; принимать решение о повторной попытке. ``` Его задача — предоставить точную предметную ошибку: ```text InstrumentReferenceTransportError ``` --- ## 20. Реализованные тестовые сценарии Создан файл: ```text app/tests/unit/market_data/acquisition/adapters/dzengi/test_rest.py ``` Реализовано 10 тестов. Проверены следующие сценарии: 1. `DzengiInstrumentDocumentSource` соответствует `InstrumentDocumentSource`; 2. вызывается endpoint `/api/v1/exchangeInfo`; 3. возвращаемый `dict` передаётся без изменения; 4. возвращаемый `list` передаётся без изменения; 5. injected client действительно используется; 6. `ExchangeConnectionError` преобразуется в `InstrumentReferenceTransportError`; 7. `ExchangeResponseError` преобразуется в `InstrumentReferenceTransportError`; 8. неизвестная `RuntimeError` также преобразуется в transport error; 9. ошибка создания `ExchangeRestClient` преобразуется в transport error; 10. adapter не преобразует wrapped document. Дополнительно проверено: ```text исходное исключение сохраняется в __cause__; текст исходной ошибки сохраняется в transport error; реальный сетевой запрос в unit-тестах не выполняется. ``` --- ## 21. Выполненные проверки ### Проверка 1 — unit-тесты Dzengi REST Adapter Команда: ```bash python -m pytest \ tests/unit/market_data/acquisition/adapters/dzengi/test_rest.py \ -q ``` Результат: ```text .......... [100%] 10 passed in 0.02s ``` Статус: ```text PASSED ``` --- ### Проверка 2 — Python compilation Команда: ```bash python -m py_compile \ src/market_data/acquisition/adapters/dzengi/rest.py \ tests/unit/market_data/acquisition/adapters/dzengi/test_rest.py ``` Результат: ```text Команда завершилась без ошибок и без вывода. ``` Статус: ```text PASSED ``` --- ### Проверка 3 — полный набор тестов проекта Команда: ```bash python -m pytest -q ``` Результат: ```text ........................................................................................................................... [100%] 123 passed in 0.08s ``` Статус: ```text PASSED ``` --- ### Проверка 4 — отсутствие преждевременной production-интеграции Команда: ```bash grep -RIn \ --exclude-dir="__pycache__" \ --exclude="*.pyc" \ -E "DzengiInstrumentDocumentSource|_PayloadRestClient|InstrumentReferenceTransportError" \ src tests ``` Подтверждено: ```text DzengiInstrumentDocumentSource используется только в новом Dzengi REST Adapter и его unit-тестах; _PayloadRestClient остаётся приватным контрактом внутри rest.py; InstrumentReferenceTransportError используется только внутри новой market_data/acquisition subsystem и её unit-тестов. ``` Не обнаружено подключения к: ```text ExchangeService Telegram UI AutoTrade Trading runtime другим production-потребителям ``` Статус: ```text PASSED ``` --- ## 22. Архитектура после Build 008 После завершения Build 008 новая часть подсистемы имеет следующую структуру: ```text market_data/ └── acquisition/ ├── exceptions.py │ ├── MarketDataAcquisitionError │ ├── InstrumentReferenceTransportError │ ├── InstrumentReferenceSchemaError │ ├── InstrumentReferenceParseError │ ├── InstrumentReferenceValueError │ └── InstrumentReferenceMappingError │ ├── protocol.py │ ├── InstrumentDocumentSource │ ├── InstrumentDocumentHandler │ └── InstrumentFeedProtocol │ ├── models/ │ └── instrument.py │ └── Instrument │ ├── validation/ │ ├── schema.py │ └── values.py │ └── adapters/ └── dzengi/ ├── models.py ├── parser.py ├── mapper.py └── rest.py ├── _PayloadRestClient └── DzengiInstrumentDocumentSource ``` На текущем этапе ещё не реализованы: ```text instrument_handler.py instrument_feed.py registry.py service.py ``` --- ## 23. Полная архитектурная цепочка после Build 008 Теперь реализован транспортный этап: ```text Dzengi REST API ↓ ExchangeRestClient.get_payload() ↓ DzengiInstrumentDocumentSource ↓ object ``` Уже реализован processing pipeline: ```text object ↓ validate_exchange_info_schema() ↓ ValidatedExchangeInfoDocument ↓ parse_exchange_info() ↓ DzengiExchangeInfoResponse ↓ validate_exchange_info_values() ↓ map_dzengi_exchange_info_to_instruments() ↓ tuple[Instrument, ...] ``` Однако эти две части пока намеренно не соединены. Их соединение относится к: ```text Build 009 — Instrument Handler ``` --- ## 24. Влияние на legacy-систему Build 008 не подключён к существующим компонентам: ```text ExchangeService ExchangeSymbol SymbolValidationResult Telegram UI AutoTrade Market Stream Market Data Runner Execution Quality ``` Не изменены: ```text ExchangeService.get_exchange_symbols() ExchangeService.validate_symbol() ExchangeService.get_symbol_runtime_status() normalize_symbol() symbol_candidates() ``` Старый production-путь продолжает работать без изменений. Новый adapter существует изолированно и пока вызывается только unit-тестами. --- ## 25. Обратная совместимость Подтверждено сохранение: ```text сигнатур существующих методов; старых импортов; существующего формата legacy-ошибок; Telegram UI; автоторговли; runtime-поведения; legacy ExchangeSymbol; legacy-кэша. ``` Build 008 имеет полную обратную совместимость. --- ## 26. Классификация изменений | Изменение | Классификация | |---|---| | `DzengiInstrumentDocumentSource` | Обязательное архитектурное изменение | | Переиспользование `ExchangeRestClient` | Временная переходная зависимость | | `_PayloadRestClient` | Необходимый приватный structural contract | | Dependency injection клиента | Улучшение тестируемости и типизации | | Использование `get_payload()` | Обязательное разделение transport и schema validation | | `InstrumentReferenceTransportError` в production adapter | Обязательная граница ошибок новой подсистемы | | Exception chaining | Улучшение диагностируемости | | Изменение legacy REST-клиента | Отсутствует | | Изменение production-поведения | Отсутствует | --- ## 27. Условие завершения Build 008 Все условия выполнены: ```text DzengiInstrumentDocumentSource реализован; InstrumentDocumentSource соблюдён; endpoint /api/v1/exchangeInfo вызывается через ExchangeRestClient.get_payload(); сырой декодированный документ возвращается без преобразования; transport exceptions преобразуются в InstrumentReferenceTransportError; исходная причина ошибки сохраняется через exception chaining; unit-тесты не выполняют реальных сетевых запросов; полный pytest проходит; adapter не подключён к production runtime. ``` --- ## 28. Итог Build 008 Build 008 завершён успешно. Реализовано: ```text DzengiInstrumentDocumentSource _PayloadRestClient transport error boundary dependency injection REST-клиента получение /api/v1/exchangeInfo через get_payload() ``` Подтверждено: ```text 10 REST adapter tests passed 123 total project tests passed Python compilation passed No premature production integration detected Legacy bot behavior unchanged ``` Итоговый статус: ```text BUILD 008 — COMPLETE ``` --- ## 29. Следующий этап Следующий этап утверждённого плана: ```text Build 009 — Instrument Handler ``` Его задача — соединить уже реализованный processing pipeline: ```text object ↓ validate_exchange_info_schema() ↓ parse_exchange_info() ↓ validate_exchange_info_values() ↓ map_dzengi_exchange_info_to_instruments() ↓ tuple[Instrument, ...] ``` в конкретную реализацию контракта: ```text InstrumentDocumentHandler ``` Предполагаемая архитектурная граница Build 009: ```text object ↓ Instrument Handler ↓ tuple[Instrument, ...] ``` Build 009 не должен: ```text самостоятельно выполнять HTTP-запросы; создавать Instrument Feed; создавать Registry; создавать Acquisition Service; изменять ExchangeService; подключаться к production runtime. ```