Files
dzentra_bot/docs/migrations/build_008.md

1092 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```