# 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.
```