# Build 012 — Instrument Acquisition Service **Статус:** Завершён **Подсистема:** `market_data/acquisition` **Область:** Instrument Reference Data **Тип изменения:** Изолированное добавление application-level сервиса получения справочника инструментов без подключения к production runtime **Результат полного набора тестов:** `180 passed` --- ## 1. Цель Build 012 Цель Build 012 — реализовать application-level сервис, который получает Instrument Feed из Registry и запускает загрузку справочника инструментов. Реализован класс: ```text InstrumentAcquisitionService ``` Его архитектурная граница: ```text source_name ↓ InstrumentAcquisitionService ↓ InstrumentFeedRegistry ↓ InstrumentFeedProtocol ↓ load_instruments() ↓ tuple[Instrument, ...] ``` Build 012 завершает orchestration-слой новой изолированной acquisition pipeline. На этом этапе сервис: ```text получает Feed из Registry; вызывает Feed; возвращает результат Feed без изменения; сохраняет специализированные ошибки без wrapping. ``` Build 012 не подключается к существующему `ExchangeService`, не меняет legacy runtime и не переключает production-потребителей на новую реализацию. --- ## 2. Почему Build 012 выполняется именно сейчас До начала Build 012 были завершены: ```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 008 — Dzengi REST Adapter Build 009 — Instrument Handler Build 010 — Instrument Feed Build 011 — Instrument Feed Registry ``` После Build 011 уже существовала цепочка: ```text source_name ↓ InstrumentFeedRegistry ↓ InstrumentFeedProtocol ``` И отдельно существовал полный Feed pipeline: ```text InstrumentFeed ↓ InstrumentDocumentSource ↓ DzengiInstrumentDocumentSource ↓ Dzengi REST API ↓ raw document ↓ InstrumentDocumentHandler ↓ DzengiInstrumentDocumentHandler ↓ schema validation ↓ parser ↓ value validation ↓ mapper ↓ tuple[Instrument, ...] ``` Однако отсутствовала application-level точка входа, соединяющая Registry с запуском Feed. Build 012 создаёт эту точку: ```text InstrumentAcquisitionService ``` --- ## 3. Архитектурная ответственность Acquisition Service Единственная предметная ответственность сервиса: ```text получить source_name ↓ получить Feed через Registry ↓ вызвать load_instruments() ↓ вернуть tuple[Instrument, ...] ``` Минимальная логика сервиса: ```python feed = self._registry.get(source_name) return feed.load_instruments() ``` Сервис не содержит собственной transport-, parsing-, validation-, mapping- или storage-логики. --- ## 4. Изменённые файлы В рамках Build 012 реализован: ```text app/src/market_data/acquisition/service.py ``` Создан тестовый файл: ```text app/tests/unit/market_data/acquisition/test_service.py ``` Другие production-файлы не изменялись. В частности, не изменялись: ```text app/src/market_data/acquisition/exceptions.py app/src/market_data/acquisition/protocol.py app/src/market_data/acquisition/registry.py app/src/market_data/acquisition/feeds/instrument_feed.py app/src/market_data/acquisition/adapters/dzengi/rest.py app/src/market_data/acquisition/handlers/instrument_handler.py app/src/integrations/exchange/* app/src/telegram/* app/src/trading/* ``` Re-export в `__init__.py` не добавлялся. --- ## 5. Реализованный InstrumentAcquisitionService В файле: ```text app/src/market_data/acquisition/service.py ``` реализован класс: ```python class InstrumentAcquisitionService: ... ``` Его публичный контракт: ```python def load_instruments( self, source_name: str, ) -> tuple[Instrument, ...]: ... ``` Полная схема: ```text InstrumentAcquisitionService.load_instruments(source_name) ↓ InstrumentFeedRegistry.get(source_name) ↓ InstrumentFeedProtocol ↓ InstrumentFeedProtocol.load_instruments() ↓ tuple[Instrument, ...] ``` --- ## 6. Явная dependency injection Registry Registry передаётся сервису через конструктор: ```python def __init__( self, *, registry: InstrumentFeedRegistry, ) -> None: self._registry = registry ``` Правильная композиция: ```python registry = InstrumentFeedRegistry() registry.register( "dzengi", feed, ) service = InstrumentAcquisitionService( registry=registry, ) ``` После этого: ```python instruments = service.load_instruments("dzengi") ``` Сервис не создаёт Registry самостоятельно. --- ## 7. Почему Service не создаёт Registry Внутри `InstrumentAcquisitionService` отсутствует: ```python self._registry = InstrumentFeedRegistry() ``` Это принципиальное архитектурное решение. Если бы Service самостоятельно создавал Registry: ```text Service ↓ создаёт Registry ↓ Registry изначально пуст ↓ требуется скрытая регистрация Feed ↓ Service начинает знать о конкретных источниках ``` Вместо этого используется: ```text готовый Registry ↓ передаётся Service ``` Это обеспечивает: ```text явные зависимости; контролируемую composition; независимость от конкретного источника; простое тестирование; отсутствие скрытой инициализации. ``` --- ## 8. Независимость от Dzengi `InstrumentAcquisitionService` не импортирует и не создаёт: ```text DzengiInstrumentDocumentSource DzengiInstrumentDocumentHandler DzengiExchangeInfoResponse ExchangeRestClient InstrumentFeed Dzengi parser Dzengi mapper ``` Сервис зависит только от: ```text Instrument InstrumentFeedRegistry ``` Архитектурная схема: ```text InstrumentAcquisitionService ↓ InstrumentFeedRegistry ↓ InstrumentFeedProtocol ``` Конкретный источник определяется снаружи через регистрацию Feed. --- ## 9. Передача source_name без изменения Service не выполняет над `source_name`: ```text strip() lower() casefold() replace() alias resolution automatic source mapping ``` Он непосредственно передаёт полученное значение Registry: ```python feed = self._registry.get(source_name) ``` Например: ```text " dzengi " ``` передаётся в Registry именно как: ```text " dzengi " ``` Нормализация внешних пробелов является ответственностью: ```text InstrumentFeedRegistry ``` Это исключает дублирование правил между Service и Registry. --- ## 10. Получение Feed через Registry Service не хранит Feed напрямую. Отсутствует: ```python self._feed = feed ``` Вместо этого для каждого вызова: ```python service.load_instruments(source_name) ``` выполняется: ```python feed = self._registry.get(source_name) ``` Таким образом: ```text source_name ↓ Registry ↓ соответствующий Feed ``` Service не определяет самостоятельно, какой Feed использовать. --- ## 11. Однократный вызов Registry Для одного вызова: ```python service.load_instruments("dzengi") ``` метод: ```python registry.get("dzengi") ``` вызывается ровно один раз. Отсутствуют: ```text повторный lookup; предварительная проверка наличия; двойной get(); fallback lookup. ``` Это подтверждено unit-тестом. --- ## 12. Однократный вызов Feed После успешного получения Feed выполняется: ```python feed.load_instruments() ``` ровно один раз. Цепочка: ```text Service.load_instruments() ↓ Registry.get() ↓ Feed.load_instruments() ↓ return result ``` Отсутствуют: ```text retry; повторный вызов после ошибки; предварительный вызов; дополнительная проверочная загрузка. ``` --- ## 13. Возврат результата без копирования Service непосредственно возвращает: ```python return feed.load_instruments() ``` Он не выполняет: ```python tuple(feed.load_instruments()) ``` или: ```python result = feed.load_instruments() return tuple(result) ``` Поэтому сохраняется identity результата: ```python result is instruments ``` равно: ```text True ``` Это подтверждено unit-тестами. --- ## 14. Сохранение порядка инструментов Service не выполняет: ```text sorting; filtering; deduplication; grouping; reordering. ``` Если Feed возвращает: ```text BTC/USD_LEVERAGE ETH/USD_LEVERAGE XRP/USD_LEVERAGE ``` Service возвращает инструменты в том же порядке: ```text BTC/USD_LEVERAGE ETH/USD_LEVERAGE XRP/USD_LEVERAGE ``` Порядок результата Feed сохраняется. --- ## 15. Поведение при пустом результате Если Feed возвращает: ```python () ``` Service также возвращает: ```python () ``` без ошибки. Service не интерпретирует пустой результат как: ```text transport error; schema error; value error; mapping error; отсутствие источника. ``` На Build 012 отсутствует утверждённое правило, согласно которому пустой `tuple` должен считаться ошибкой. --- ## 16. Сохранение специализированных ошибок В `InstrumentAcquisitionService` отсутствует общий `try/except`, который заменял бы исходные ошибки новой общей ошибкой. Без изменения могут пройти: ```text InstrumentFeedRegistryError InstrumentReferenceTransportError InstrumentReferenceSchemaError InstrumentReferenceParseError InstrumentReferenceValueError InstrumentReferenceMappingError ``` Схема: ```text Registry error ↓ Service ↓ та же Registry error ``` или: ```text Feed error ↓ Service ↓ та же Feed error ``` --- ## 17. Сохранение identity ошибки Тесты подтверждают не только тип ошибки, но и сохранение исходного объекта. Если Feed выбрасывает: ```python original_error = InstrumentReferenceTransportError( "Network error." ) ``` то Service передаёт именно этот объект: ```python exc_info.value is original_error ``` равно: ```text True ``` Ошибка не: ```text копируется; оборачивается; заменяется; переводится в другой тип. ``` --- ## 18. Ошибка отсутствующего Feed Если Registry не содержит источник: ```text "dzengi" ``` вызов: ```python service.load_instruments("dzengi") ``` приводит к: ```text InstrumentFeedRegistryError ``` Service не выполняет: ```text fallback; создание Feed; регистрацию Feed; использование default source; возврат пустого tuple. ``` Ошибка Registry проходит наружу без wrapping. --- ## 19. Feed не вызывается при ошибке Registry Последовательность: ```text Service.load_instruments() ↓ Registry.get() ↓ InstrumentFeedRegistryError ``` останавливается на ошибке Registry. Метод: ```text feed.load_instruments() ``` не вызывается. Это подтверждено unit-тестом. --- ## 20. Отсутствие retry после ошибки Feed Если Feed выбрасывает: ```text InstrumentReferenceTransportError ``` Service не повторяет вызов. Схема: ```text feed.load_instruments() ↓ InstrumentReferenceTransportError ↓ ошибка немедленно выходит из Service ``` Счётчик вызовов Feed остаётся: ```text 1 ``` Таким образом, Build 012 не вводит скрытую retry policy. --- ## 21. Почему retry отсутствует Retry является отдельной operational policy. Для его корректной реализации необходимо отдельно определить: ```text какие ошибки являются retryable; максимальное число попыток; интервалы между попытками; backoff; jitter; timeout budget; логирование повторных попыток; поведение при исчерпании попыток. ``` Build 012 не должен неявно принимать эти архитектурные решения. Поэтому: ```text один вызов Service ↓ один вызов Feed ``` --- ## 22. Почему не создан новый Service exception В Build 012 не добавлен: ```text InstrumentAcquisitionServiceError ``` Уже существуют специализированные ошибки: ```text InstrumentFeedRegistryError InstrumentReferenceTransportError InstrumentReferenceSchemaError InstrumentReferenceParseError InstrumentReferenceValueError InstrumentReferenceMappingError ``` Создание общей ошибки: ```text InstrumentAcquisitionServiceError ``` и wrapping всех причин в неё ухудшило бы диагностируемость. Поэтому сохраняется точная причина отказа. --- ## 23. Что Service не хранит Внутри `InstrumentAcquisitionService` отсутствуют: ```text tuple[Instrument, ...]; последний успешный справочник; предыдущий snapshot; timestamp; TTL; cache age; последняя ошибка; индекс инструментов; legacy ExchangeSymbol. ``` Service хранит только зависимость: ```text InstrumentFeedRegistry ``` --- ## 24. Что Service не делает Build 012 сознательно не выполняет: ```text REST-запросы напрямую; получение exchangeInfo напрямую; schema validation; parsing; value validation; mapping; создание Registry; создание Feed; создание Source; создание Handler; автоматическую регистрацию Feed; retry; backoff; кэширование; хранение Instrument; сравнение snapshot; фильтрацию инструментов; сортировку инструментов; дедупликацию инструментов; нормализацию торговых символов; преобразование Instrument в ExchangeSymbol; изменение ExchangeService; изменение AutoTrade; изменение Telegram UI; изменение trading runtime; формирование пользовательских ошибок; логирование событий. ``` Единственная orchestration-ответственность: ```text Registry lookup ↓ Feed invocation ``` --- ## 25. Production composition не входит в Build 012 Build 012 не создаёт автоматически полную production-композицию: ```text DzengiInstrumentDocumentSource + DzengiInstrumentDocumentHandler ↓ InstrumentFeed ↓ InstrumentFeedRegistry ↓ InstrumentAcquisitionService ``` На текущем этапе новая pipeline остаётся изолированной. Не создаётся: ```text global Registry; global Service; singleton Feed; application startup wiring; dependency container; ExchangeService integration. ``` Это предотвращает преждевременное изменение production runtime. --- ## 26. Реализованные тестовые сценарии Создан файл: ```text app/tests/unit/market_data/acquisition/test_service.py ``` Фактически выполнено: ```text 13 tests ``` Проверены следующие сценарии: 1. загрузка инструментов из зарегистрированного Feed; 2. передача `source_name` Registry без изменения; 3. однократный вызов Registry; 4. однократный вызов Feed; 5. возврат результата без копирования; 6. сохранение порядка инструментов; 7. возврат пустого tuple без ошибки; 8. сохранение Registry error без wrapping; 9. отсутствие вызова Feed при ошибке Registry; 10. сохранение transport error без wrapping; 11. сохранение value error без wrapping; 12. сохранение mapping error без wrapping; 13. отсутствие retry после ошибки Feed. Часть сценариев реализована через параметризацию. Фактическое количество выполненных тестовых случаев: ```text 13 ``` --- ## 27. Проверка получения инструментов Тест подтверждает: ```python registry = InstrumentFeedRegistry() instruments = ( _instrument(), ) feed = StubInstrumentFeed( instruments=instruments, ) registry.register( "dzengi", feed, ) service = InstrumentAcquisitionService( registry=registry, ) result = service.load_instruments("dzengi") assert result is instruments ``` Это доказывает: ```text Service получил Feed через Registry; Feed был вызван; результат Feed возвращён; identity результата сохранена. ``` --- ## 28. Проверка передачи source_name без изменения Используется тестовый Registry, который записывает полученные имена. Вызов: ```python service.load_instruments(" dzengi ") ``` приводит к передаче в Registry именно: ```text " dzengi " ``` Тест подтверждает: ```python assert registry.requested_source_names == [ " dzengi ", ] ``` Service не дублирует нормализацию Registry. --- ## 29. Проверка отсутствия retry Тестовый Feed содержит счётчик: ```python self.load_call_count = 0 ``` При вызове: ```python feed.load_instruments() ``` счётчик увеличивается. Feed настроен на выбрасывание: ```text InstrumentReferenceTransportError ``` После вызова Service подтверждено: ```python assert feed.load_call_count == 1 ``` Это доказывает отсутствие скрытой повторной попытки. --- ## 30. Выполненные проверки ### Проверка 1 — unit-тесты Acquisition Service Команда: ```bash python -m pytest \ tests/unit/market_data/acquisition/test_service.py \ -q ``` Результат: ```text ............. [100%] 13 passed in 0.01s ``` Статус: ```text PASSED ``` --- ### Проверка 2 — Python compilation Команда: ```bash python -m py_compile \ src/market_data/acquisition/service.py \ tests/unit/market_data/acquisition/test_service.py ``` Результат: ```text Команда завершилась без ошибок и без вывода. ``` Статус: ```text PASSED ``` --- ### Проверка 3 — полный набор тестов проекта Команда: ```bash python -m pytest -q ``` Результат: ```text .................................................................................................................................................................................... [100%] 180 passed in 0.09s ``` Статус: ```text PASSED ``` --- ### Проверка 4 — отсутствие преждевременной production-интеграции Команда: ```bash grep -RIn \ --exclude-dir="__pycache__" \ --exclude="*.pyc" \ -E "InstrumentAcquisitionService" \ src tests ``` Полученное production-определение находится только в: ```text src/market_data/acquisition/service.py ``` Остальные использования находятся исключительно в: ```text tests/unit/market_data/acquisition/test_service.py ``` Не обнаружено подключения к: ```text ExchangeService Telegram UI AutoTrade Trading runtime другим production-потребителям ``` Статус: ```text PASSED ``` --- ## 31. Архитектура после Build 012 После завершения Build 012 новая часть подсистемы имеет следующую структуру: ```text market_data/ └── acquisition/ ├── exceptions.py │ ├── MarketDataAcquisitionError │ ├── InstrumentReferenceTransportError │ ├── InstrumentReferenceSchemaError │ ├── InstrumentReferenceParseError │ ├── InstrumentReferenceValueError │ ├── InstrumentReferenceMappingError │ └── InstrumentFeedRegistryError │ ├── protocol.py │ ├── InstrumentDocumentSource │ ├── InstrumentDocumentHandler │ └── InstrumentFeedProtocol │ ├── registry.py │ └── InstrumentFeedRegistry │ ├── service.py │ └── InstrumentAcquisitionService │ ├── models/ │ └── instrument.py │ └── Instrument │ ├── validation/ │ ├── schema.py │ └── values.py │ ├── handlers/ │ └── instrument_handler.py │ └── DzengiInstrumentDocumentHandler │ ├── feeds/ │ └── instrument_feed.py │ └── InstrumentFeed │ └── adapters/ └── dzengi/ ├── models.py ├── parser.py ├── mapper.py └── rest.py ├── _PayloadRestClient └── DzengiInstrumentDocumentSource ``` --- ## 32. Полная архитектурная цепочка после Build 012 После Build 012 реализована полная изолированная acquisition pipeline: ```text source_name ↓ InstrumentAcquisitionService ↓ InstrumentFeedRegistry ↓ InstrumentFeedProtocol ↓ InstrumentFeed ↓ InstrumentDocumentSource ↓ DzengiInstrumentDocumentSource ↓ ExchangeRestClient.get_payload() ↓ Dzengi REST API ``` После получения документа: ```text object ↓ InstrumentDocumentHandler ↓ DzengiInstrumentDocumentHandler ↓ validate_exchange_info_schema() ↓ ValidatedExchangeInfoDocument ↓ parse_exchange_info() ↓ DzengiExchangeInfoResponse ↓ validate_exchange_info_values() ↓ map_dzengi_exchange_info_to_instruments() ↓ tuple[Instrument, ...] ``` Таким образом, техническая цепочка новой acquisition-подсистемы завершена. --- ## 33. Что означает завершение изолированной acquisition pipeline После Build 012 новая подсистема уже содержит все необходимые уровни: ```text Internal Model ↓ Raw Models ↓ Schema Validation ↓ Parser ↓ Value Validation ↓ Mapper ↓ Protocols ↓ REST Adapter ↓ Handler ↓ Feed ↓ Registry ↓ Acquisition Service ``` Но она ещё не заменяет legacy implementation. Существующий бот продолжает использовать старый путь. --- ## 34. Что ещё не реализовано После Build 012 отсутствуют: ```text проверка эквивалентности старой и новой реализации; compatibility mapper Instrument → ExchangeSymbol; переключение get_exchange_symbols(); перевод normalize_symbol()/symbol_candidates(); переключение validate_symbol(); переключение get_symbol_runtime_status(); подготовка переноса кэша; перенос кэша; перевод production-потребителей; удаление legacy-кода. ``` Эти задачи относятся к следующим Build. --- ## 35. Влияние на legacy-систему Build 012 не подключён к существующим компонентам: ```text ExchangeService ExchangeSymbol SymbolValidationResult Telegram UI AutoTrade Market Stream Market Data Runner Execution Quality Trading runtime ``` Не изменены: ```text ExchangeService.get_exchange_symbols() ExchangeService.validate_symbol() ExchangeService.get_symbol_runtime_status() normalize_symbol() symbol_candidates() ``` Старый production-путь продолжает работать без изменений. --- ## 36. Обратная совместимость Подтверждено сохранение: ```text сигнатур существующих legacy-методов; старых импортов; существующего формата legacy-ошибок; Telegram UI; автоторговли; runtime-поведения; legacy ExchangeSymbol; legacy-кэша. ``` Build 012 имеет полную обратную совместимость. --- ## 37. Классификация изменений | Изменение | Классификация | |---|---| | `InstrumentAcquisitionService` | Обязательное архитектурное изменение | | Registry lookup → Feed invocation | Обязательное архитектурное изменение | | Явная dependency injection Registry | Обязательное разделение ответственности | | Передача `source_name` без изменения | Исключение дублирования ответственности | | Однократный вызов Registry | Предсказуемое orchestration-поведение | | Однократный вызов Feed | Предсказуемое orchestration-поведение | | Возврат результата без копирования | Сохранение контракта Feed | | Сохранение специализированных ошибок | Улучшение диагностируемости | | Retry | Не выполняется | | Кэширование | Не выполняется | | Production composition | Не выполняется | | Изменение legacy-кода | Отсутствует | | Изменение production-поведения | Отсутствует | --- ## 38. Условие завершения Build 012 Все условия выполнены: ```text InstrumentAcquisitionService реализован; Registry передаётся как явная зависимость; source_name передаётся Registry без изменения; Feed получается через Registry; Registry вызывается ровно один раз; Feed вызывается ровно один раз; результат Feed возвращается без копирования; порядок Instrument сохраняется; пустой tuple возвращается без ошибки; специализированные ошибки сохраняются без wrapping; identity ошибки сохраняется; Feed не вызывается при ошибке Registry; retry отсутствует; кэширование отсутствует; прямые Dzengi-зависимости отсутствуют; unit-тесты проходят; полный pytest проходит; production runtime не затронут. ``` --- ## 39. Итог Build 012 Build 012 завершён успешно. Реализовано: ```text InstrumentAcquisitionService source_name ↓ InstrumentFeedRegistry.get() ↓ InstrumentFeedProtocol ↓ load_instruments() ↓ tuple[Instrument, ...] ``` Подтверждено: ```text 13 Acquisition Service tests passed 180 total project tests passed Python compilation passed No premature production integration detected Legacy bot behavior unchanged ``` Итоговый статус: ```text BUILD 012 — COMPLETE ``` --- ## 40. Следующий этап Следующий этап утверждённого плана: ```text Build 013 — Проверка эквивалентности старой и новой реализации ``` Его задача — до любого переключения production-кода доказать, что legacy и новая implementation получают эквивалентные данные из одного и того же реального `exchangeInfo`. Целевая схема: ```text один реальный exchangeInfo document ├──→ legacy implementation │ ↓ │ list[ExchangeSymbol] │ └──→ new acquisition pipeline ↓ tuple[Instrument, ...] ↓ equivalence comparison ``` На Build 013 необходимо определить точный набор полей для сравнения: ```text symbol; name; status; base_asset; quote_asset; asset_type; market_type; market_modes; order_types; precisions; tick_size; tick_value; step_size; min_qty; max_qty; min_notional; country; sector; industry; trading_hours. ``` Build 013 не должен: ```text переключать ExchangeService.get_exchange_symbols(); изменять validate_symbol(); изменять get_symbol_runtime_status(); создавать compatibility mapper; переносить кэш; изменять Telegram UI; изменять AutoTrade; изменять trading runtime. ``` Только после доказанной эквивалентности можно переходить к: ```text Build 014 — Compatibility mapper Instrument → ExchangeSymbol ```