diff --git a/app/src/market_data/acquisition/adapters/dzengi/rest.py b/app/src/market_data/acquisition/adapters/dzengi/rest.py index 7cac2e4..09a38b4 100644 --- a/app/src/market_data/acquisition/adapters/dzengi/rest.py +++ b/app/src/market_data/acquisition/adapters/dzengi/rest.py @@ -9,12 +9,14 @@ from src.market_data.acquisition.exceptions import ( CandleTransportError, InstrumentReferenceTransportError, QuoteTransportError, + TradeTransportError, ) _EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo" _TICKER_24HR_PATH = "/api/v1/ticker/24hr" _KLINES_PATH = "/api/v1/klines" +_AGG_TRADES_PATH = "/api/v1/aggTrades" # Минимальный транспортный контракт, необходимый Dzengi REST adapter. @@ -154,4 +156,60 @@ class DzengiCandlesDocumentSource: "Не удалось получить свечи " f"от Dzengi для символа '{symbol}', " f"интервала '{interval}' и типа цены '{price_type}': {exc}" + ) from exc + + +class DzengiTradesDocumentSource: + """Источник сырого документа агрегированных сделок через Dzengi REST API.""" + + def __init__( + self, + client: _PayloadRestClient | None = None, + ) -> None: + self._client = client + + def fetch_trades_document( + self, + symbol: str, + *, + start_time: int | None = None, + end_time: int | None = None, + limit: int | None = None, + ) -> object: + """ + Получить декодированный ответ Dzengi aggTrades без его обработки. + + Метод не выполняет schema validation, parsing, value validation, + mapping, сортировку, дедупликацию или кэширование. + """ + + params: dict[str, str] = { + "symbol": symbol, + } + + if start_time is not None: + params["startTime"] = str(start_time) + + if end_time is not None: + params["endTime"] = str(end_time) + + if limit is not None: + params["limit"] = str(limit) + + try: + client: _PayloadRestClient = ( + self._client + if self._client is not None + else ExchangeRestClient() + ) + + return client.get_payload( + _AGG_TRADES_PATH, + params=params, + ) + + except Exception as exc: + raise TradeTransportError( + "Не удалось получить агрегированные сделки " + f"от Dzengi для символа '{symbol}': {exc}" ) from exc \ No newline at end of file diff --git a/app/src/market_data/acquisition/exceptions.py b/app/src/market_data/acquisition/exceptions.py index 3e00dbd..9f79046 100644 --- a/app/src/market_data/acquisition/exceptions.py +++ b/app/src/market_data/acquisition/exceptions.py @@ -119,6 +119,11 @@ class CandleFeedRegistryError(MarketDataAcquisitionError): pass +# Ошибка получения Trades Feed от внешнего источника. +class TradeTransportError(MarketDataAcquisitionError): + pass + + # Ошибка структуры документа Trades Feed. class TradeSchemaError(MarketDataAcquisitionError): pass diff --git a/app/tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py b/app/tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py new file mode 100644 index 0000000..8f4d7e1 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py @@ -0,0 +1,234 @@ +# tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py + +from __future__ import annotations + +import pytest + +from src.market_data.acquisition.adapters.dzengi.rest import ( + DzengiTradesDocumentSource, +) +from src.market_data.acquisition.exceptions import TradeTransportError + + +class _StubRestClient: + def __init__(self) -> None: + self.path: str | None = None + self.params: dict[str, str] | None = None + self.headers: dict[str, str] | None = None + self.payload: object = object() + + def get_payload( + self, + path: str, + params: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + ) -> object: + self.path = path + self.params = params + self.headers = headers + + return self.payload + + +class _FailingRestClient: + def get_payload( + self, + path: str, + params: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + ) -> object: + raise RuntimeError("network failure") + + +def test_source_exposes_fetch_trades_document() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + assert hasattr(source, "fetch_trades_document") + + +def test_fetch_trades_document_uses_injected_client() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + ) + + assert client.path == "/api/v1/aggTrades" + + +def test_fetch_trades_document_returns_raw_object() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + document = source.fetch_trades_document( + "BTCUSDT", + ) + + assert document is client.payload + + +def test_fetch_trades_document_uses_expected_endpoint() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + ) + + assert client.path == "/api/v1/aggTrades" + + +def test_fetch_trades_document_passes_symbol() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + ) + + assert client.params == { + "symbol": "BTCUSDT", + } + + +def test_fetch_trades_document_passes_all_optional_parameters() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + start_time=1000, + end_time=2000, + limit=500, + ) + + assert client.params == { + "symbol": "BTCUSDT", + "startTime": "1000", + "endTime": "2000", + "limit": "500", + } + + +def test_fetch_trades_document_omits_optional_parameters_when_none() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + start_time=None, + end_time=None, + limit=None, + ) + + assert client.params == { + "symbol": "BTCUSDT", + } + + +def test_fetch_trades_document_converts_start_time_to_string() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + start_time=123456789, + ) + + assert client.params == { + "symbol": "BTCUSDT", + "startTime": "123456789", + } + + +def test_fetch_trades_document_converts_end_time_to_string() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + end_time=987654321, + ) + + assert client.params == { + "symbol": "BTCUSDT", + "endTime": "987654321", + } + + +def test_fetch_trades_document_converts_limit_to_string() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + limit=100, + ) + + assert client.params == { + "symbol": "BTCUSDT", + "limit": "100", + } + + +def test_fetch_trades_document_does_not_pass_headers() -> None: + client = _StubRestClient() + source = DzengiTradesDocumentSource(client) + + source.fetch_trades_document( + "BTCUSDT", + ) + + assert client.headers is None + + +def test_fetch_trades_document_wraps_transport_exception() -> None: + source = DzengiTradesDocumentSource( + _FailingRestClient(), + ) + + with pytest.raises( + TradeTransportError, + match="Не удалось получить агрегированные сделки", + ) as exc_info: + source.fetch_trades_document( + "BTCUSDT", + ) + + assert isinstance(exc_info.value.__cause__, RuntimeError) + + +def test_fetch_trades_document_lazily_creates_exchange_rest_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_clients: list[_StubRestClient] = [] + + def create_client() -> _StubRestClient: + client = _StubRestClient() + created_clients.append(client) + + return client + + monkeypatch.setattr( + "src.market_data.acquisition.adapters.dzengi.rest.ExchangeRestClient", + create_client, + ) + + source = DzengiTradesDocumentSource() + + assert created_clients == [] + + document = source.fetch_trades_document( + "BTCUSDT", + limit=10, + ) + + assert len(created_clients) == 1 + assert document is created_clients[0].payload + assert created_clients[0].path == "/api/v1/aggTrades" + assert created_clients[0].params == { + "symbol": "BTCUSDT", + "limit": "10", + } \ No newline at end of file diff --git a/docs/migrations/build_060_8.md b/docs/migrations/build_060_8.md new file mode 100644 index 0000000..873af12 --- /dev/null +++ b/docs/migrations/build_060_8.md @@ -0,0 +1,1902 @@ +# Build 060.8 — REST Trades Document Source + +**Engineering Migration Report** + +--- + +# Контроль документа + +| Свойство | Значение | +|---|---| +| Build | 060.8 | +| Название | REST Trades Document Source | +| Статус | Завершён | +| Проект | Dzentra | +| Подсистема | Market Data Acquisition | +| Компонент | Trades Feed / Time & Sales | +| Версия документа | 1.0 | +| Дата завершения | 2026-07-19 | + +--- + +# Цель Build + +После завершения Build 060.7 подсистема обработки исторических сделок уже содержала полностью сформированный конвейер преобразования REST-документа в канонические сделки. + +К этому моменту были реализованы: + +- транспортная модель агрегированной сделки; +- Schema Validation; +- Parser; +- Value Validation; +- Mapper; +- REST Trade Adapter. + +Pipeline обработки документа уже имел следующий вид: + +```text +ValidatedRestAggTradesDocument + + │ + + ▼ + +REST Trade Adapter + + │ + + ▼ + +Parser + + │ + + ▼ + +Value Validation + + │ + + ▼ + +Mapper + + │ + + ▼ + +tuple[Trade] +``` + +Однако данный pipeline начинался уже после получения документа. + +В архитектуре отсутствовал компонент, отвечающий исключительно за взаимодействие с REST API биржи. + +Верхние уровни системы по-прежнему должны были самостоятельно: + +- знать REST endpoint; +- формировать параметры HTTP-запроса; +- работать с ExchangeRestClient; +- создавать HTTP-клиент; +- обрабатывать транспортные ошибки. + +Таким образом отсутствовал отдельный Source Layer для получения документа. + +Это противоречило архитектуре остальных компонентов Acquisition Layer, где получение данных и их обработка являются независимыми слоями. + +Следовательно возникла необходимость реализовать специализированный REST Source, который будет отвечать исключительно за получение документа агрегированных сделок. + +Именно эту задачу решает Build 060.8. + +Build намеренно **не включает**: + +- Schema Validation; +- Parser; +- Value Validation; +- Mapper; +- REST Trade Adapter; +- REST polling; +- Trades Feed; +- Runtime Integration; +- Registry; +- Acquisition Service; +- объединение REST и WebSocket сделок; +- дедупликацию; +- сортировку; +- хранение истории; +- агрегацию; +- аналитическую обработку; +- production-интеграцию. + +Все перечисленные задачи относятся к другим архитектурным слоям и будут реализованы отдельными Build. + +--- + +# Архитектурный контекст + +Во всей подсистеме Market Data Acquisition применяется единая архитектурная модель разделения ответственности. + +Получение документа никогда не совмещается с его обработкой. + +Полный pipeline Acquisition выглядит следующим образом. + +```text +Exchange + + │ + + ▼ + +REST Client + + │ + + ▼ + +Document Source + + │ + + ▼ + +Raw Document + + │ + + ▼ + +Schema Validation + + │ + + ▼ + +Validated Document + + │ + + ▼ + +Adapter + + │ + + ▼ + +Canonical Model +``` + +Именно такое разделение уже используется для остальных источников данных проекта. + +Например: + +- Instrument Document Source; +- Quote Document Source; +- Candles Document Source. + +Каждый из этих компонентов отвечает исключительно за получение транспортного документа. + +Ни один из них: + +- не выполняет Schema Validation; +- не создаёт предметные модели; +- не анализирует содержимое документа; +- не выполняет бизнес-логику. + +REST Trades должны полностью следовать этому архитектурному принципу. + +После завершения Build 060.8 архитектура приобретает следующий вид. + +```text +Exchange + + │ + + ▼ + +ExchangeRestClient + + │ + + ▼ + +DzengiTradesDocumentSource + + │ + + ▼ + +Raw REST Document + + │ + + ▼ + +Schema Validation + + │ + + ▼ + +ValidatedRestAggTradesDocument + + │ + + ▼ + +REST Trade Adapter + + │ + + ▼ + +Parser + + │ + + ▼ + +Value Validation + + │ + + ▼ + +Mapper + + │ + + ▼ + +tuple[Trade] +``` + +Таким образом Build завершает формирование Source Layer для REST Trade Pipeline. + +--- + +# Исходное состояние + +До начала Build 060.8 проект уже содержал следующие REST Source. + +```text +DzengiInstrumentDocumentSource + +DzengiQuoteDocumentSource + +DzengiCandlesDocumentSource +``` + +Все три компонента использовали единый архитектурный подход. + +Каждый Source: + +- инкапсулировал ExchangeRestClient; +- выполнял HTTP GET; +- формировал параметры запроса; +- преобразовывал транспортные исключения; +- возвращал необработанный REST-документ. + +При этом отдельный Source для получения агрегированных сделок отсутствовал. + +Получение документа должно было выполняться напрямую через ExchangeRestClient. + +Подобная схема нарушала единообразие архитектуры Acquisition Layer. + +Кроме того, отсутствовал специализированный тип транспортной ошибки для REST Trade. + +В системе уже существовали: + +```text +InstrumentReferenceTransportError + +QuoteTransportError + +CandleTransportError +``` + +Но отсутствовал: + +```text +TradeTransportError +``` + +Таким образом Build 060.8 должен был устранить оба архитектурных пробела: + +- добавить REST Source; +- добавить специализированное транспортное исключение. + +При этом никакие существующие компоненты проекта не должны были изменять собственную ответственность. + +Build должен был остаться полностью **Local Additive Change**. + +--- + +# Предварительный архитектурный аудит + +Перед началом реализации был выполнен повторный аудит существующей реализации REST Source. + +Были проанализированы следующие файлы. + +```text +app/src/market_data/acquisition/adapters/dzengi/rest.py + +app/src/integrations/exchange/rest_client.py + +app/src/market_data/acquisition/exceptions.py + +docs/migrations/build_060_7.md +``` + +Кроме анализа исходного кода был повторно проверен полный REST Pipeline Acquisition. + +По результатам аудита были подтверждены следующие архитектурные выводы. + +- ExchangeRestClient уже является универсальным HTTP-транспортом. +- REST Source не должен знать структуру документа. +- REST Source не должен выполнять Schema Validation. +- REST Source не должен выполнять Parser. +- REST Source не должен выполнять Value Validation. +- REST Source не должен выполнять Mapper. +- REST Source отвечает исключительно за получение документа и преобразование транспортных ошибок. + +Именно эти выводы стали основой проектирования Build 060.8. + +# Архитектурная задача Build + +Главная задача Build 060.8 заключается не в добавлении новой логики обработки сделок. + +Вся обработка документа уже полностью реализована предыдущими Build. + +Задача Build состоит в создании официального Source Layer для REST Trade Pipeline. + +После завершения Build вызывающий код должен работать только с одним специализированным компонентом: + +```python +DzengiTradesDocumentSource +``` + +Именно он становится единственной точкой получения документа агрегированных сделок из REST API биржи. + +После получения документа ответственность Source заканчивается. + +Дальнейшая обработка выполняется следующими архитектурными слоями. + +```text +Schema Validation + +↓ + +REST Trade Adapter + +↓ + +Parser + +↓ + +Value Validation + +↓ + +Mapper +``` + +Таким образом каждый слой сохраняет собственную область ответственности. + +--- + +# Рассмотренные архитектурные решения + +Перед началом реализации было рассмотрено несколько вариантов построения нового компонента. + +Основной вопрос заключался не в выборе способа выполнения HTTP-запроса. + +Главной задачей было определить, + +какая именно архитектурная ответственность должна принадлежать Source Layer. + +Именно на этом этапе были рассмотрены несколько вариантов реализации. + +--- + +## Вариант 1 + +Получать REST-документ напрямую через ExchangeRestClient. + +Например: + +```python +client = ExchangeRestClient() + +document = client.get_payload(...) +``` + +Такой вариант первоначально выглядел самым простым. + +Однако после анализа существующей архитектуры проекта он был отклонён. + +Причины. + +В этом случае каждый вызывающий компонент обязан самостоятельно: + +- знать endpoint; +- знать параметры запроса; +- создавать HTTP-клиент; +- обрабатывать транспортные ошибки. + +Таким образом логика получения документа начинает дублироваться во многих местах системы. + +Кроме того, + +подобный подход полностью противоречит архитектуре остальных REST Source проекта. + +Следовательно данный вариант был отклонён. + +--- + +## Вариант 2 + +Разместить получение документа внутри REST Trade Adapter. + +Например: + +```text +HTTP + +↓ + +JSON + +↓ + +REST Trade Adapter + +↓ + +Parser + +↓ + +Validation + +↓ + +Mapper +``` + +На первый взгляд подобное решение кажется логичным. + +Однако после повторного анализа архитектуры было подтверждено, + +что Adapter отвечает исключительно за обработку уже полученного документа. + +Получение документа относится к Source Layer. + +Если Adapter начнёт самостоятельно выполнять HTTP-запрос, + +он одновременно получит две независимые ответственности: + +- получение данных; +- обработку данных. + +Это нарушает принцип Single Responsibility. + +Поэтому данный вариант был отклонён. + +--- + +## Вариант 3 + +Создать специализированный REST Source. + +Именно этот вариант оказался полностью совместимым с архитектурой проекта. + +Новый компонент отвечает исключительно за: + +- получение REST-документа; +- подготовку параметров запроса; +- работу с ExchangeRestClient; +- преобразование транспортных ошибок. + +После получения документа управление полностью передаётся следующему архитектурному уровню. + +Именно этот вариант был утверждён для реализации Build 060.8. + +--- + +# Почему выбран отдельный REST Source + +Во время проектирования обсуждался вопрос, + +нужно ли вообще создавать отдельный компонент, + +если ExchangeRestClient уже умеет выполнять HTTP GET. + +После анализа существующего проекта ответ оказался однозначным. + +ExchangeRestClient представляет собой универсальный транспортный механизм. + +Он ничего не знает: + +- о сделках; +- о свечах; +- о котировках; +- о торговых инструментах. + +Напротив, + +Source Layer знает предметную область. + +Именно Source определяет: + +- какой endpoint использовать; +- какие параметры допустимы; +- какие исключения относятся к данному виду данных. + +Следовательно REST Source является не транспортом, + +а специализированной предметной оболочкой над универсальным транспортом. + +Именно такое разделение уже используется всеми существующими REST Source проекта. + +--- + +# Почему Trades Source реализован классом + +Во время реализации отдельно обсуждался вопрос, + +следует ли использовать функцию, + +как это было сделано в Build 060.7, + +или класс. + +На первый взгляд мог появиться следующий API. + +```python +fetch_trades_document(...) +``` + +После анализа архитектуры данный вариант был отклонён. + +Причина заключается в том, + +что Source инкапсулирует зависимость. + +Он содержит экземпляр: + +```python +ExchangeRestClient +``` + +или получает его через Dependency Injection. + +Таким образом объект Source обладает состоянием. + +Даже если это состояние состоит только из одной зависимости, + +оно всё равно является частью жизненного цикла компонента. + +Следовательно данный компонент относится к категории Stateful. + +В соответствии с утверждённым архитектурным принципом проекта: + +```text +Stateful + +↓ + +Class +``` + +Source должен быть реализован именно классом. + +--- + +# Почему используется Dependency Injection + +Все существующие REST Source проекта допускают передачу собственного экземпляра клиента. + +Например: + +```python +DzengiQuoteDocumentSource( + client=... +) +``` + +Новый компонент полностью сохраняет данную архитектурную модель. + +Если клиент передан извне, + +используется именно он. + +Если клиент отсутствует, + +Source самостоятельно создаёт: + +```python +ExchangeRestClient() +``` + +Такой подход обеспечивает сразу несколько преимуществ. + +Во-первых, + +упрощается unit-тестирование. + +Во-вторых, + +Source не зависит от конкретной реализации клиента. + +В-третьих, + +архитектура остаётся полностью совместимой с уже существующими Source Layer. + +--- + +# Почему используется ленивое создание клиента + +Во время реализации обсуждалось, + +следует ли создавать ExchangeRestClient непосредственно в конструкторе. + +Например: + +```python +self._client = ExchangeRestClient() +``` + +Подобный вариант был отклонён. + +Причины. + +Во-первых, + +если клиент передаётся через Dependency Injection, + +создание собственного экземпляра становится бессмысленным. + +Во-вторых, + +ленивое создание позволяет не создавать HTTP-клиент, + +если Source был создан, + +но фактически ещё ни разу не использовался. + +Именно поэтому применяется следующая схема. + +```text +client отсутствует + +↓ + +первый вызов fetch_trades_document() + +↓ + +создание ExchangeRestClient +``` + +Такой подход полностью соответствует реализации остальных REST Source проекта. + +--- + +# Почему Source не выполняет Schema Validation + +Во время проектирования отдельно обсуждался вопрос, + +следует ли REST Source сразу возвращать: + +```text +ValidatedRestAggTradesDocument +``` + +После анализа архитектуры данный вариант был отклонён. + +Причины. + +Schema Validation уже является самостоятельным архитектурным уровнем. + +В системе существуют независимые функции: + +```python +validate_quote_schema() + +validate_candle_schema() + +validate_rest_agg_trades_schema() +``` + +Если Source начнёт самостоятельно выполнять проверку структуры, + +он одновременно получит две независимые ответственности: + +- получение документа; +- проверку структуры документа. + +Это нарушает уже сформированную архитектуру Acquisition Layer. + +Следовательно REST Source возвращает исключительно необработанный REST-документ. + +Следующий слой самостоятельно выполняет Schema Validation. + +Полный Pipeline после завершения Build принимает следующий вид. + +```text +Exchange + +↓ + +ExchangeRestClient + +↓ + +DzengiTradesDocumentSource + +↓ + +Raw REST Document + +↓ + +Schema Validation + +↓ + +ValidatedRestAggTradesDocument +``` + +Именно такое разделение полностью соответствует архитектурным принципам проекта. + +# Почему Source не выполняет Parser + +Во время проектирования рассматривался вариант, + +при котором REST Source сразу возвращал бы транспортные модели. + +Например: + +```text +REST JSON + +↓ + +Parser + +↓ + +tuple[DzengiRestAggTrade] +``` + +На первый взгляд подобный подход выглядит привлекательным, + +поскольку вызывающий код получает уже разобранные объекты. + +Однако после анализа архитектуры данный вариант был отклонён. + +Причины. + +Parser представляет собой самостоятельный уровень Acquisition Pipeline. + +Его задача — исключительно преобразование уже проверенного документа в транспортные модели. + +Если Parser переносится внутрь Source, + +Source начинает одновременно выполнять две независимые задачи: + +- получение документа; +- преобразование документа. + +Это нарушает принцип разделения ответственности. + +Кроме того, + +остальные Source проекта не содержат Parser. + +Следовательно новый компонент также не должен выполнять данную функцию. + +--- + +# Почему Source не выполняет Value Validation + +После отказа от Parser обсуждался ещё один вариант. + +Можно было бы возвращать транспортные модели, + +которые уже прошли проверку корректности значений. + +Например: + +```text +HTTP + +↓ + +REST Source + +↓ + +Parser + +↓ + +Value Validation + +↓ + +tuple[DzengiRestAggTrade] +``` + +Данный вариант также был отклонён. + +Причины. + +Value Validation представляет собой отдельный архитектурный слой. + +Его задача — проверка содержимого транспортной модели. + +Source не должен знать, + +какие именно ограничения существуют для: + +- цены; +- количества; +- времени; +- идентификаторов сделок. + +Все подобные проверки относятся исключительно к Validation Layer. + +Следовательно Source обязан оставаться полностью независимым от предметной модели. + +--- + +# Почему Source не выполняет Mapper + +Во время проектирования обсуждалась возможность, + +при которой REST Source сразу возвращал бы: + +```python +tuple[Trade] +``` + +Подобная схема выглядела следующим образом. + +```text +Exchange + +↓ + +REST Source + +↓ + +Canonical Trade +``` + +После анализа архитектуры данный вариант был отклонён. + +Причины очевидны. + +Source отвечает исключительно за получение транспортного документа. + +Создание канонической модели относится к Mapper Layer. + +Если Source начинает создавать экземпляры: + +```text +Trade +``` + +он становится зависимым: + +- от предметной модели; +- от правил отображения; +- от внутренней структуры Canonical Layer. + +Подобная зависимость нарушает архитектурную изоляцию между транспортным и предметным уровнями. + +Следовательно создание Canonical Trade остаётся исключительной ответственностью Mapper. + +--- + +# Почему введён отдельный TradeTransportError + +До начала Build система уже содержала специализированные транспортные исключения. + +Например: + +```text +InstrumentReferenceTransportError + +QuoteTransportError + +CandleTransportError +``` + +Однако для REST Trade использовался общий тип исключений, + +либо транспортные ошибки пробрасывались без предметной семантики. + +Во время проектирования обсуждалось, + +следует ли использовать существующий общий тип. + +После анализа было принято решение отказаться от этого подхода. + +Причины. + +Каждый Source проекта использует собственное специализированное исключение. + +Это позволяет вызывающему коду: + +- сразу определить источник ошибки; +- принимать различные решения для разных видов данных; +- сохранять единообразие архитектуры. + +Поэтому был введён новый тип: + +```python +TradeTransportError +``` + +который становится официальным транспортным исключением REST Trade Pipeline. + +--- + +# Новый публичный API + +После завершения Build 060.8 в системе появляется новый публичный компонент. + +```python +DzengiTradesDocumentSource +``` + +Он предоставляет единственную публичную операцию. + +```python +fetch_trades_document(...) +``` + +Назначение метода. + +- принимает параметры REST-запроса; +- формирует параметры HTTP GET; +- обращается к ExchangeRestClient; +- возвращает необработанный REST-документ; +- преобразует транспортные исключения в TradeTransportError. + +На этом ответственность Source полностью заканчивается. + +--- + +# Семантика REST Trades Source + +После завершения Build новый компонент становится официальной точкой получения агрегированных сделок из REST API. + +Полная последовательность работы выглядит следующим образом. + +```text +ExchangeRestClient + + │ + + ▼ + +HTTP GET + + │ + + ▼ + +REST JSON + + │ + + ▼ + +DzengiTradesDocumentSource + + │ + + ▼ + +Raw Document +``` + +Source намеренно не анализирует содержимое ответа. + +Он не проверяет: + +- наличие обязательных полей; +- корректность структуры; +- корректность значений; +- сортировку; +- наличие дубликатов; +- непрерывность последовательности сделок. + +Все перечисленные задачи относятся к следующим архитектурным слоям. + +Таким образом Source остаётся максимально простым и полностью соответствует принципу Single Responsibility. + +--- + +# Что намеренно не делает Source + +Build 060.8 специально не расширяет область ответственности нового компонента. + +REST Source намеренно: + +не выполняет Schema Validation; + +не выполняет Parser; + +не выполняет Value Validation; + +не выполняет Mapper; + +не создаёт Canonical Trade; + +не агрегирует сделки; + +не сортирует данные; + +не удаляет дубликаты; + +не вычисляет статистику; + +не создаёт свечи; + +не объединяет REST и WebSocket сделки; + +не взаимодействует с Runtime; + +не взаимодействует с Feed; + +не взаимодействует с Registry; + +не сохраняет историю; + +не выполняет повторные HTTP-запросы; + +не реализует polling. + +Все перечисленные задачи относятся к другим Build и намеренно исключены из области ответственности Source. + +# Изменённые файлы + +В рамках Build 060.8 были изменены два файла проекта. + +Добавлен новый REST Source. + +```text +app/src/market_data/acquisition/adapters/dzengi/rest.py +``` + +В существующий модуль исключений добавлен новый специализированный тип транспортной ошибки. + +```text +app/src/market_data/acquisition/exceptions.py +``` + +Кроме того, был добавлен новый набор unit-тестов. + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py +``` + +Никакие другие компоненты проекта не изменялись. + +Build полностью соответствует принципу **Local Additive Change**. + +--- + +# Изменения в rest.py + +В существующий модуль REST Source добавлен новый компонент. + +```python +DzengiTradesDocumentSource +``` + +Он полностью повторяет архитектурный стиль уже существующих Source проекта. + +Новый компонент содержит: + +- поддержку Dependency Injection; +- ленивое создание ExchangeRestClient; +- специализированный REST endpoint; +- подготовку параметров запроса; +- преобразование транспортных исключений. + +Также в модуле определён новый endpoint. + +```text +/api/v1/aggTrades +``` + +Именно данный endpoint используется для получения исторических агрегированных сделок. + +Никакие существующие REST Source при этом не изменялись. + +--- + +# Изменения в exceptions.py + +В рамках Build введён новый специализированный тип исключения. + +```python +TradeTransportError +``` + +Он наследуется от существующего базового класса транспортных ошибок Acquisition Layer. + +Назначение нового исключения состоит исключительно в семантическом разделении транспортных ошибок различных источников данных. + +После завершения Build система содержит отдельные транспортные исключения для: + +```text +Instrument + +Quote + +Candle + +Trade +``` + +Тем самым достигается единообразие всей подсистемы получения рыночных данных. + +--- + +# Изменения в unit-тестах + +Для нового REST Source создан самостоятельный файл тестов. + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py +``` + +Это соответствует принятому соглашению проекта, + +согласно которому каждый самостоятельный компонент Acquisition Layer имеет собственный независимый набор unit-тестов. + +Существующие тесты: + +- Instrument REST Source; +- Quote REST Source; +- Candle REST Source; + +не изменялись. + +Build полностью additive. + +--- + +# Проверяемые сценарии + +Новый набор тестов проверяет исключительно ответственность REST Source. + +Подтверждаются следующие сценарии. + +- успешное получение документа; +- корректное использование endpoint; +- корректная передача обязательного параметра symbol; +- корректная передача startTime; +- корректная передача endTime; +- корректная передача limit; +- отсутствие параметров со значением None; +- преобразование всех параметров в строковый формат; +- отсутствие лишних HTTP-заголовков; +- корректное использование переданного ExchangeRestClient; +- ленивое создание собственного клиента; +- преобразование транспортных ошибок в TradeTransportError; +- возврат необработанного REST-документа без изменений. + +Тем самым подтверждается, + +что новый Source полностью соответствует архитектурной ответственности Source Layer. + +--- + +# Что намеренно не изменялось + +Build 060.8 специально не изменяет существующие компоненты Acquisition Pipeline. + +Без изменений остаются: + +```text +app/src/market_data/acquisition/adapters/dzengi/parser.py + +app/src/market_data/acquisition/adapters/dzengi/mapper.py + +app/src/market_data/acquisition/adapters/dzengi/rest_trade_adapter.py + +app/src/market_data/acquisition/validation/schema.py + +app/src/market_data/acquisition/validation/values.py + +app/src/market_data/acquisition/models/trade.py + +app/src/market_data/acquisition/runtime/ + +app/src/market_data/acquisition/feeds/ + +app/src/market_data/acquisition/service.py + +app/src/market_data/acquisition/registry.py +``` + +Также Build намеренно не включает: + +- Schema Validation; +- Parser; +- Value Validation; +- Mapper; +- REST polling; +- Trades Feed; +- Runtime Integration; +- объединение REST и WebSocket сделок; +- дедупликацию; +- хранение истории; +- аналитическую обработку. + +Все перечисленные задачи реализуются отдельными Build согласно утверждённой дорожной карте. + +Таким образом Build 060.8 остаётся полностью локальным и не выходит за пределы собственной архитектурной ответственности. + +--- + +# Проверка компиляции + +После завершения реализации была выполнена проверка компиляции проекта. + +Команда выполнялась из каталога: + +```text +~/vsprojects/dzentra_bot/app +``` + +При активированном виртуальном окружении: + +```bash +source .venv/bin/activate +``` + +Выполнена команда: + +```bash +python -m compileall src +``` + +Результат: + +```text +успешно +``` + +Все модули проекта успешно скомпилированы. + +Ошибок синтаксиса не обнаружено. + +Build не нарушил корректность структуры проекта. + +--- + +# Проверка локальных unit-тестов + +После завершения реализации нового REST Source были выполнены специализированные unit-тесты. + +Команда: + +```bash +python -m pytest \ +tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py \ +-v +``` + +Результат: + +```text +13 passed in 0.04s +``` + +Проверки подтвердили: + +- корректную работу REST Source; +- корректное формирование HTTP-запроса; +- корректную обработку параметров; +- корректную работу Dependency Injection; +- корректное создание клиента; +- корректное преобразование транспортных ошибок. + +Ни одна существующая проверка проекта не была нарушена. + +--- + +# Проверка подсистемы REST Adapter + +После завершения Build был выполнен полный набор тестов всех адаптеров Dzengi. + +Команда: + +```bash +python -m pytest \ +tests/unit/market_data/acquisition/adapters/dzengi/ +``` + +Результат: + +```text +243 passed in 0.10s +``` + +Регрессий не обнаружено. + +Все существующие REST- и WebSocket-компоненты продолжают работать без изменений. + +--- + +# Проверка подсистемы Acquisition + +После завершения Build была выполнена проверка всей подсистемы получения рыночных данных. + +Команда: + +```bash +python -m pytest \ +tests/unit/market_data/acquisition/ +``` + +Результат: + +```text +788 passed in 0.27s +``` + +Проверка подтвердила, + +что новый REST Source полностью совместим с существующей архитектурой и не вызвал регрессий в других компонентах Acquisition Layer. + +# Проверка форматирования + +После завершения работы выполнена команда: + +```bash +git diff --check +``` + +Вывод отсутствует. + +Это подтверждает отсутствие: + +- trailing whitespace; +- лишних пробелов; +- нарушений форматирования; +- ошибок оформления diff. + +Build соответствует принятым требованиям оформления исходного кода. + +--- + +# Контроль размещения новой функциональности + +После завершения Build вся новая функциональность сосредоточена только в предназначенных для неё компонентах. + +REST Source расположен в существующем модуле: + +```text +src/market_data/acquisition/adapters/dzengi/rest.py +``` + +Новый тип транспортного исключения расположен исключительно в: + +```text +src/market_data/acquisition/exceptions.py +``` + +Unit-тесты расположены исключительно в: + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py +``` + +Другие подсистемы проекта Build не затрагивает. + +Архитектурная изоляция полностью сохранена. + +--- + +# Состояние Git + +После завершения Build выполнена команда: + +```bash +git status +``` + +Для Build 060.8 зафиксированы изменения: + +```text +modified: + +app/src/market_data/acquisition/adapters/dzengi/rest.py + +app/src/market_data/acquisition/exceptions.py + +added: + +app/tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py + +untracked: + +docs/migrations/build_060_8.md +``` + +Ветка разработки: + +```text +main +``` + +опережает `origin/main`. + +Данное состояние соответствует текущему процессу разработки и не связано с архитектурой Build. + +--- + +# Фактический diff + +В рамках Build реализованы следующие изменения. + +В модуле REST Source добавлен новый компонент: + +```python +DzengiTradesDocumentSource +``` + +Добавлен новый REST endpoint: + +```text +/api/v1/aggTrades +``` + +Добавлен новый публичный метод: + +```python +fetch_trades_document() +``` + +В модуле исключений добавлен новый тип: + +```python +TradeTransportError +``` + +Также добавлен отдельный набор unit-тестов: + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_trade_rest.py +``` + +Все изменения являются полностью additive. + +Никакое существующее поведение системы не изменялось. + +--- + +# Архитектурный результат + +После завершения Build 060.8 REST Trade Pipeline впервые получает полноценный Source Layer. + +Полная архитектура теперь выглядит следующим образом. + +```text +Exchange + + │ + + ▼ + +ExchangeRestClient + + │ + + ▼ + +DzengiTradesDocumentSource + + │ + + ▼ + +Raw REST Document + + │ + + ▼ + +Schema Validation + + │ + + ▼ + +ValidatedRestAggTradesDocument + + │ + + ▼ + +REST Trade Adapter + + │ + + ▼ + +Parser + + │ + + ▼ + +Value Validation + + │ + + ▼ + +Mapper + + │ + + ▼ + +tuple[Trade] +``` + +Каждый слой обладает собственной зоной ответственности. + +Ни один слой не выполняет задачи соседнего. + +Source отвечает исключительно за получение документа. + +Schema Validation отвечает исключительно за проверку структуры. + +Adapter отвечает исключительно за композицию существующих этапов обработки. + +Таким образом окончательно разделены три независимые архитектурные области: + +- получение данных; +- обработка данных; +- построение канонической модели. + +Именно такое разделение является одной из базовых архитектурных целей подсистемы Market Data Acquisition. + +--- + +# Влияние на последующие Build + +Build 060.8 завершает формирование инфраструктуры получения исторических сделок через REST API. + +После его окончания все последующие компоненты могут использовать единый специализированный Source вместо прямой работы с ExchangeRestClient. + +Это означает, + +что любые изменения: + +- REST endpoint; +- параметров HTTP-запроса; +- механизма авторизации; +- реализации HTTP-клиента; +- политики обработки транспортных ошибок; + +будут локализованы исключительно внутри Source Layer. + +Все остальные компоненты Acquisition останутся неизменными. + +Таким образом Build создаёт стабильную архитектурную границу между транспортным уровнем и логикой обработки рыночных данных. + +--- + +# Критерии завершения + +Build 060.8 считается полностью завершённым, поскольку: + +- проведён повторный архитектурный аудит существующего REST Layer; +- реализован специализированный REST Trades Source; +- введён новый публичный API получения документа; +- реализована поддержка Dependency Injection; +- реализовано ленивое создание ExchangeRestClient; +- реализовано формирование параметров REST-запроса; +- реализовано преобразование транспортных ошибок в TradeTransportError; +- сохранено разделение ответственности между Source Layer и Pipeline обработки; +- Source не выполняет Schema Validation; +- Source не выполняет Parser; +- Source не выполняет Value Validation; +- Source не выполняет Mapper; +- Source не создаёт Canonical Trade; +- compile-проверка успешно пройдена; +- локальные unit-тесты успешно пройдены; +- тесты всех адаптеров успешно пройдены; +- тесты всей подсистемы Acquisition успешно пройдены; +- проверка форматирования успешно пройдена; +- scope Build не расширен. + +--- + +# Архитектурные инварианты + +После завершения Build 060.8 следующие свойства REST Trade Source считаются архитектурным контрактом проекта. + +Изменение любого из перечисленных инвариантов требует отдельного архитектурного решения. + +--- + +## Инвариант 1 + +REST Source отвечает исключительно за получение документа. + +Никакая обработка содержимого документа внутри Source не допускается. + +--- + +## Инвариант 2 + +REST Source всегда возвращает необработанный транспортный документ. + +Schema Validation остаётся отдельным архитектурным слоем. + +--- + +## Инвариант 3 + +REST Source не зависит от Parser, Validation и Mapper. + +Любая обработка транспортной модели выполняется исключительно после получения документа. + +--- + +## Инвариант 4 + +Все транспортные ошибки получения исторических сделок преобразуются в: + +```text +TradeTransportError +``` + +Использование общего типа транспортных исключений не допускается. + +--- + +## Инвариант 5 + +REST Source реализуется классом. + +Причина — + +компонент инкапсулирует зависимость ExchangeRestClient и обладает собственным жизненным циклом. + +--- + +## Инвариант 6 + +ExchangeRestClient создаётся лениво. + +Если клиент передан через Dependency Injection, + +создание собственного экземпляра не выполняется. + +--- + +## Инвариант 7 + +REST Source является единственной официальной точкой получения исторических агрегированных сделок. + +Все последующие компоненты должны использовать именно его. + +Прямое обращение к ExchangeRestClient за пределами Source Layer не допускается. + +--- + +## Инвариант 8 + +Source Layer не зависит от предметной модели. + +Он ничего не знает о существовании: + +```text +Trade +``` + +или других канонических объектов системы. + +Его контракт ограничивается исключительно транспортным документом. + +--- + +# Итог + +**Build 060.8 завершён успешно.** + +Текущее состояние REST Trade Pipeline: + +```text +Transport Source — реализован + +Transport Exception — реализована + +Schema Validation — реализована + +REST Trade Adapter — реализован + +Parser — реализован + +Value Validation — реализована + +Mapper — реализован + +Canonical Trade — используется + +Compile check — успешно + +Target tests — 13 passed + +Dzengi adapters — 243 passed + +Acquisition subsystem — 788 passed + +Whitespace check — успешно + +Production Integration — намеренно не выполнялась +``` + +После завершения Build 060.8 подсистема получения исторических сделок получила полноценный специализированный Source Layer, полностью соответствующий архитектуре остальных компонентов Market Data Acquisition. + +Получение REST-документа теперь полностью изолировано от его последующей обработки, что завершает формирование транспортного уровня REST Trade Pipeline. + +--- + +# Следующий этап + +Следующим этапом развития серии Build 060 становится: + +```text +Build 060.9 — WebSocket Trade Transport Model +``` + +На данном этапе будет реализована транспортная модель сообщений WebSocket, описывающая формат агрегированных сделок, поступающих от биржи в режиме реального времени. + +Как и в случае с REST Pipeline, данный Build ограничивается исключительно транспортным уровнем и не включает: + +- Schema Validation; +- Parser; +- Value Validation; +- Mapper; +- Adapter; +- маршрутизацию WebSocket-сообщений; +- механизм подписки; +- Trades Feed; +- Runtime Integration; +- объединение REST и WebSocket данных. + +Все перечисленные задачи относятся к последующим Build и будут реализованы согласно утверждённой дорожной карте проекта. + +После завершения Build 060.9 будет сформирована транспортная основа WebSocket Trade Pipeline, полностью симметричная ранее реализованной REST-модели. + +Полная последовательность формирования WebSocket Pipeline будет выглядеть следующим образом. + +```text +WebSocket Trade Transport Model + + │ + + ▼ + +WebSocket Trade Schema Validation + + │ + + ▼ + +WebSocket Trade Parser + + │ + + ▼ + +WebSocket Trade Value Validation + + │ + + ▼ + +WebSocket Trade Mapper + + │ + + ▼ + +WebSocket Trade Adapter +``` + +После завершения этих этапов станет возможным переход к построению общей инфраструктуры обработки сделок. + +Следующие Build последовательно реализуют: + +```text +Unified WebSocket Routing + + │ + + ▼ + +Trade Subscription Layer + + │ + + ▼ + +Trades Feed Core + + │ + + ▼ + +Trade Ordering & Deduplication + + │ + + ▼ + +REST Backfill & Gap Recovery + + │ + + ▼ + +Trades Feed Registry + + │ + + ▼ + +Acquisition Protocol Integration + + │ + + ▼ + +Acquisition Service Integration + + │ + + ▼ + +Runtime Integration + + │ + + ▼ + +Reconnect & Recovery + + │ + + ▼ + +Integration & Regression + + │ + + ▼ + +Final Documentation +``` + +Таким образом последовательность реализации остаётся полностью согласованной с утверждённой архитектурной дорожной картой проекта. + +Сначала полностью формируется REST Pipeline, затем — полностью симметричный WebSocket Pipeline, после чего оба источника объединяются в единый **Trades Feed (Time & Sales)** без необходимости пересмотра или переработки ранее реализованных компонентов. \ No newline at end of file