From b7bef85d20b3bb9a04fe6925bf8a79704a22fc76 Mon Sep 17 00:00:00 2001 From: Sergey Date: Sun, 19 Jul 2026 22:13:33 +0300 Subject: [PATCH] =?UTF-8?q?Build=20060.13=20=E2=80=94=20WebSocket=20Trade?= =?UTF-8?q?=20Mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../acquisition/adapters/dzengi/mapper.py | 55 + .../dzengi/test_websocket_trade_mapper.py | 347 +++++ docs/migrations/build_060_13.md | 1169 +++++++++++++++++ 3 files changed, 1571 insertions(+) create mode 100644 app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_trade_mapper.py create mode 100644 docs/migrations/build_060_13.md diff --git a/app/src/market_data/acquisition/adapters/dzengi/mapper.py b/app/src/market_data/acquisition/adapters/dzengi/mapper.py index 2deb418..07d8331 100644 --- a/app/src/market_data/acquisition/adapters/dzengi/mapper.py +++ b/app/src/market_data/acquisition/adapters/dzengi/mapper.py @@ -17,6 +17,7 @@ from src.market_data.acquisition.adapters.dzengi.models import ( DzengiTicker24hrResponse, DzengiWebSocketOhlcEvent, DzengiWebSocketQuoteResponse, + DzengiWebSocketTradeEvent, ) from src.market_data.acquisition.exceptions import ( CandleMappingError, @@ -37,6 +38,7 @@ from src.market_data.acquisition.models.trade import ( _DZENGI_SOURCE_NAME = "dzengi" _DZENGI_WEBSOCKET_OHLC_SOURCE_NAME = "dzengi_websocket_ohlc" +_DZENGI_WEBSOCKET_TRADE_SOURCE_NAME = "dzengi_websocket_trade" def map_dzengi_symbol_to_instrument( @@ -537,6 +539,41 @@ def map_dzengi_rest_agg_trades_to_trades( ) +def map_dzengi_websocket_trade_to_trade( + event: DzengiWebSocketTradeEvent, +) -> Trade: + """ + Преобразовать проверенную transport-модель + WebSocket Trade в каноническую модель Trade. + + Предполагается, что ранее успешно выполнены: + + - Schema Validation + - Parser + - Value Validation + """ + + return Trade( + symbol=event.symbol.strip(), + trade_id=event.trade_id, + price=_required_trade_decimal( + event.price, + field_name="price", + ), + quantity=_required_trade_decimal( + event.size, + field_name="size", + ), + executed_at=_trade_timestamp_ms_to_utc_datetime( + event.timestamp, + ), + aggressor_side=_websocket_trade_aggressor_side( + event.buyer, + ), + source=_DZENGI_WEBSOCKET_TRADE_SOURCE_NAME, + ) + + def _map_dzengi_rest_agg_trade( trade: DzengiRestAggTrade, *, @@ -572,6 +609,24 @@ def _trade_aggressor_side( return TradeAggressorSide.BUY +def _websocket_trade_aggressor_side( + buyer: bool, +) -> TradeAggressorSide: + """ + Преобразовать направление WebSocket Trade + в каноническую сторону агрессора. + + WebSocket: + buyer=True -> BUY + buyer=False -> SELL + """ + + if buyer: + return TradeAggressorSide.BUY + + return TradeAggressorSide.SELL + + def _required_trade_decimal( value: DzengiRawNumeric, *, diff --git a/app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_trade_mapper.py b/app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_trade_mapper.py new file mode 100644 index 0000000..add2a1d --- /dev/null +++ b/app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_trade_mapper.py @@ -0,0 +1,347 @@ +# app/tests/unit/market_data/acquisition/adapters/dzengi/ +# test_websocket_trade_mapper.py + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from datetime import datetime, timezone +from decimal import Decimal + +import pytest + +from src.market_data.acquisition.adapters.dzengi.mapper import ( + map_dzengi_websocket_trade_to_trade, +) +from src.market_data.acquisition.adapters.dzengi.models import ( + DzengiWebSocketTradeEvent, +) +from src.market_data.acquisition.exceptions import ( + TradeMappingError, +) +from src.market_data.acquisition.models.trade import ( + Trade, + TradeAggressorSide, +) + + +def _websocket_trade_event( + *, + trade_id: int = 101, + price: str | int | float = "123.45", + size: str | int | float = "0.25", + timestamp: int = 1783537921471, + symbol: str = "BTC/USDT", + buyer: bool = True, + order_id: str = "order-101", +) -> DzengiWebSocketTradeEvent: + return DzengiWebSocketTradeEvent( + trade_id=trade_id, + price=price, + size=size, + timestamp=timestamp, + symbol=symbol, + buyer=buyer, + order_id=order_id, + ) + + +def test_map_dzengi_websocket_trade_to_trade() -> None: + event = _websocket_trade_event() + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert isinstance(trade, Trade) + assert trade.symbol == "BTC/USDT" + assert trade.trade_id == 101 + assert trade.price == Decimal("123.45") + assert trade.quantity == Decimal("0.25") + assert trade.executed_at == datetime.fromtimestamp( + 1783537921471 / 1000, + tz=timezone.utc, + ) + assert trade.aggressor_side is TradeAggressorSide.BUY + assert trade.source == "dzengi_websocket_trade" + + +def test_websocket_trade_mapper_maps_size_to_quantity() -> None: + event = _websocket_trade_event( + size="1.75", + ) + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert trade.quantity == Decimal("1.75") + + +def test_websocket_trade_mapper_maps_buyer_to_buy_aggressor() -> None: + event = _websocket_trade_event( + buyer=True, + ) + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert trade.aggressor_side is TradeAggressorSide.BUY + + +def test_websocket_trade_mapper_maps_seller_to_sell_aggressor() -> None: + event = _websocket_trade_event( + buyer=False, + ) + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert trade.aggressor_side is TradeAggressorSide.SELL + + +@pytest.mark.parametrize( + ( + "price", + "size", + "expected_price", + "expected_quantity", + ), + [ + ( + "123.4500", + "0.2500", + Decimal("123.4500"), + Decimal("0.2500"), + ), + ( + 123, + 2, + Decimal("123"), + Decimal("2"), + ), + ( + 123.5, + 0.25, + Decimal("123.5"), + Decimal("0.25"), + ), + ], +) +def test_websocket_trade_mapper_converts_numeric_values_to_decimal( + price: str | int | float, + size: str | int | float, + expected_price: Decimal, + expected_quantity: Decimal, +) -> None: + event = _websocket_trade_event( + price=price, + size=size, + ) + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert trade.price == expected_price + assert trade.quantity == expected_quantity + + +def test_websocket_trade_mapper_converts_timestamp_to_utc_datetime() -> None: + timestamp = 1783537921471 + + event = _websocket_trade_event( + timestamp=timestamp, + ) + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert trade.executed_at == datetime.fromtimestamp( + timestamp / 1000, + tz=timezone.utc, + ) + assert trade.executed_at.tzinfo is timezone.utc + + +def test_websocket_trade_mapper_strips_symbol() -> None: + event = _websocket_trade_event( + symbol=" BTC/USDT ", + ) + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert trade.symbol == "BTC/USDT" + + +def test_websocket_trade_mapper_sets_websocket_source() -> None: + event = _websocket_trade_event() + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert trade.source == "dzengi_websocket_trade" + + +def test_websocket_trade_mapper_does_not_expose_order_id() -> None: + event = _websocket_trade_event( + order_id="exchange-order-999", + ) + + trade = map_dzengi_websocket_trade_to_trade(event) + + assert not hasattr(trade, "order_id") + + +def test_websocket_trade_mapper_does_not_modify_transport_event() -> None: + event = _websocket_trade_event( + trade_id=202, + price="456.78", + size="3.5", + timestamp=1783537921999, + symbol=" ETH/USDT ", + buyer=False, + order_id="order-202", + ) + + original_values = ( + event.trade_id, + event.price, + event.size, + event.timestamp, + event.symbol, + event.buyer, + event.order_id, + ) + + map_dzengi_websocket_trade_to_trade(event) + + assert ( + event.trade_id, + event.price, + event.size, + event.timestamp, + event.symbol, + event.buyer, + event.order_id, + ) == original_values + + +def test_mapped_websocket_trade_is_immutable() -> None: + event = _websocket_trade_event() + + trade = map_dzengi_websocket_trade_to_trade(event) + + with pytest.raises(FrozenInstanceError): + trade.price = Decimal("1") # type: ignore[misc] + + +@pytest.mark.parametrize( + ( + "field_name", + "invalid_value", + "expected_mapper_field", + ), + [ + ( + "price", + "not-a-number", + "price", + ), + ( + "size", + "not-a-number", + "size", + ), + ], +) +def test_websocket_trade_mapper_rejects_invalid_decimal_value( + field_name: str, + invalid_value: str, + expected_mapper_field: str, +) -> None: + values = { + "price": "123.45", + "size": "0.25", + } + values[field_name] = invalid_value + + event = _websocket_trade_event( + price=values["price"], + size=values["size"], + ) + + with pytest.raises( + TradeMappingError, + match=( + rf"{expected_mapper_field}.*" + r"невозможно преобразовать в Decimal" + ), + ): + map_dzengi_websocket_trade_to_trade(event) + + +@pytest.mark.parametrize( + ( + "field_name", + "invalid_value", + "expected_mapper_field", + ), + [ + ( + "price", + float("nan"), + "price", + ), + ( + "price", + float("inf"), + "price", + ), + ( + "price", + float("-inf"), + "price", + ), + ( + "size", + float("nan"), + "size", + ), + ( + "size", + float("inf"), + "size", + ), + ( + "size", + float("-inf"), + "size", + ), + ], +) +def test_websocket_trade_mapper_rejects_non_finite_decimal_value( + field_name: str, + invalid_value: float, + expected_mapper_field: str, +) -> None: + values: dict[str, str | int | float] = { + "price": "123.45", + "size": "0.25", + } + values[field_name] = invalid_value + + event = _websocket_trade_event( + price=values["price"], + size=values["size"], + ) + + with pytest.raises( + TradeMappingError, + match=( + rf"{expected_mapper_field}.*" + r"должно быть конечным числом" + ), + ): + map_dzengi_websocket_trade_to_trade(event) + + +def test_websocket_trade_mapper_rejects_unrepresentable_timestamp() -> None: + event = _websocket_trade_event( + timestamp=10**30, + ) + + with pytest.raises( + TradeMappingError, + match=r"timestamp.*невозможно преобразовать в UTC datetime", + ): + map_dzengi_websocket_trade_to_trade(event) \ No newline at end of file diff --git a/docs/migrations/build_060_13.md b/docs/migrations/build_060_13.md new file mode 100644 index 0000000..26fd771 --- /dev/null +++ b/docs/migrations/build_060_13.md @@ -0,0 +1,1169 @@ +# Build 060.13 — WebSocket Trade Mapper + +**Engineering Migration Report** + +--- + +# Контроль документа + +| Свойство | Значение | +|----------|----------| +| Build | 060.13 | +| Название | WebSocket Trade Mapper | +| Статус | Completed | +| Проект | Dzentra | +| Подсистема | Market Data Acquisition | +| Компонент | Trades Feed | +| Версия | 1.0 | + +--- + +# Цель Build + +После завершения Build 060.12 система получила полностью реализованный уровень **WebSocket Trade Value Validation**, подтверждающий корректность всех значений транспортной модели + +```text +DzengiWebSocketTradeEvent +``` + +На этом этапе Pipeline гарантирует: + +- корректную структуру транспортного документа; +- успешное построение transport model; +- корректность всех обязательных значений; +- отсутствие недопустимых числовых представлений; +- корректность строковых идентификаторов. + +Однако даже после успешного прохождения всех перечисленных этапов транспортная модель ещё не может использоваться остальной частью системы. + +Несмотря на корректность структуры и значений, объект + +```text +DzengiWebSocketTradeEvent +``` + +по-прежнему остаётся транспортной моделью биржи Dzengi. + +Он содержит особенности конкретного источника данных: + +- транспортные числовые представления; +- транспортное поле `size`; +- транспортное поле `buyer`; +- транспортное поле `order_id`; +- транспортное представление времени в миллисекундах Unix Epoch. + +Использование подобных моделей за пределами слоя адаптера противоречит базовым архитектурным принципам Dzentra. + +Внутренние компоненты системы не должны зависеть от особенностей API конкретной биржи. + +Для решения данной задачи архитектура Dzentra предусматривает следующий обязательный уровень Pipeline — + +**Mapper**. + +Именно Mapper выполняет преобразование транспортной модели источника данных в единую каноническую модель предметной области. + +Build 060.13 реализует данный уровень для WebSocket Trade. + +Основная задача Build — преобразовать проверенную транспортную модель + +```text +DzengiWebSocketTradeEvent +``` + +в каноническую immutable-модель + +```text +Trade +``` + +с выполнением всех необходимых преобразований типов данных. + +При этом Build не затрагивает: + +- Schema Validation; +- Parser; +- Value Validation; +- WebSocket Runtime; +- Routing; +- Trades Feed. + +--- + +# Предпосылки + +К началу Build архитектура Market Data Acquisition уже содержала полностью реализованные Mapper для остальных типов рыночных данных. + +Аналогичный уровень преобразования уже существовал для: + +- REST Quote; +- WebSocket Quote; +- WebSocket OHLC; +- REST Aggregate Trade. + +Во всех случаях использовалась одинаковая архитектурная схема обработки. + +```text +Transport Model + │ + ▼ +Mapper + │ + ▼ +Canonical Model +``` + +После завершения Build 060.12 аналогичный транспортный конвейер появился и для WebSocket Trade. + +```text +ValidatedWebSocketTradeDocument + │ + ▼ +Trade Parser + │ + ▼ +DzengiWebSocketTradeEvent + │ + ▼ +Value Validation + │ + ▼ +DzengiWebSocketTradeEvent +``` + +Однако следующий обязательный уровень — преобразование транспортной модели в каноническую модель системы — ещё отсутствовал. + +Таким образом WebSocket-конвейер обработки сделок оставался архитектурно незавершённым. + +--- + +# Архитектурное основание + +Одним из базовых принципов архитектуры Dzentra является полная изоляция внутренних компонентов системы от формата данных конкретной биржи. + +Любые особенности транспортного протокола должны оставаться исключительно внутри слоя адаптера. + +Общий конвейер обработки рыночных данных имеет следующий вид. + +```text +Raw Source + │ + ▼ +Schema Validation + │ + ▼ +Transport Model + │ + ▼ +Value Validation + │ + ▼ +Mapper + │ + ▼ +Domain Model +``` + +Каждый уровень Pipeline отвечает только за одну категорию задач. + +Для WebSocket Trade это разделение выглядит следующим образом. + +```text +Schema Validation +``` + +гарантирует корректность структуры транспортного документа. + +--- + +```text +Parser +``` + +создаёт транспортную модель + +```text +DzengiWebSocketTradeEvent +``` + +без изменения типов данных. + +--- + +```text +Value Validation +``` + +подтверждает корректность значений транспортной модели без выполнения каких-либо преобразований. + +--- + +Следующий уровень — + +```text +Mapper +``` + +выполняет преобразование транспортной модели в каноническую модель предметной области. + +Именно Mapper отвечает за: + +- преобразование транспортных числовых представлений в `Decimal`; +- преобразование транспортного timestamp в `datetime`; +- преобразование транспортных признаков сделки в канонические перечисления; +- переименование транспортных полей; +- создание immutable-модели `Trade`. + +При этом Mapper принципиально **не выполняет**: + +- повторную Validation; +- анализ структуры JSON; +- Runtime-логику; +- бизнес-логику; +- маршрутизацию событий. + +Такое разделение ответственности позволяет каждому уровню Pipeline оставаться независимым, повторно используемым и легко тестируемым. + +--- + +# Результаты архитектурного аудита + +Перед реализацией Build был выполнен аудит существующего слоя Mapper. + +В ходе анализа подтверждено наличие полностью сформированной архитектуры преобразования транспортных моделей. + +Для различных типов рыночных данных уже реализованы функции: + +```text +map_dzengi_quote_to_quote(...) + +map_dzengi_websocket_quote_to_quote(...) + +map_dzengi_websocket_ohlc_to_candle_close_event(...) + +map_dzengi_rest_agg_trades_to_trades(...) +``` + +Все существующие Mapper используют одинаковые архитектурные принципы. + +Преобразование транспортных типов выполняется исключительно внутри Mapper. + +Для этого повторно используются существующие helper-функции проекта. + +Одновременно аудит подтвердил наличие общего исключения + +```text +TradeMappingError +``` + +используемого всеми существующими Mapper при невозможности построения канонической модели. + +При этом отдельный Mapper для транспортной модели + +```text +DzengiWebSocketTradeEvent +``` + +в системе отсутствовал. + +Таким образом единственным отсутствующим элементом архитектурной цепочки являлся собственный уровень Mapping для WebSocket Trade. + +Build 060.13 полностью закрывает данный пробел и завершает следующий обязательный уровень Pipeline серии Build 060. + +--- + +# Архитектурное решение + +По результатам проведённого аудита было принято решение полностью повторить архитектурный шаблон, уже используемый Mapper остальных типов рыночных данных. + +В систему добавлена функция + +```text +map_dzengi_websocket_trade_to_trade(...) +``` + +которая принимает транспортную модель + +```text +DzengiWebSocketTradeEvent +``` + +и создаёт каноническую immutable-модель + +```text +Trade +``` + +Во время преобразования выполняются все необходимые преобразования транспортных типов данных. + +После успешного завершения Mapping транспортная модель больше не используется последующими уровнями системы. + +Конвейер WebSocket Trade принимает следующий вид. + +```text +Raw WebSocket Trade + │ + ▼ +WebSocket Trade Schema Validation + │ + ▼ +ValidatedWebSocketTradeDocument + │ + ▼ +WebSocket Trade Parser + │ + ▼ +DzengiWebSocketTradeEvent + │ + ▼ +WebSocket Trade Value Validation + │ + ▼ +DzengiWebSocketTradeEvent + │ + ▼ +WebSocket Trade Mapper + │ + ▼ +Trade +``` + +Build 060.13 не изменяет архитектуру ранее реализованных компонентов и завершает следующий обязательный уровень транспортного Pipeline. + +# Реализованный уровень Mapper + +В файл + +```text +src/market_data/acquisition/adapters/dzengi/mapper.py +``` + +добавлена новая функция + +```text +map_dzengi_websocket_trade_to_trade(...) +``` + +Функция получает транспортную модель + +```text +DzengiWebSocketTradeEvent +``` + +и выполняет построение канонической модели + +```text +Trade +``` + +Во время выполнения Mapping создаётся новый immutable-объект предметной области. + +Транспортная модель при этом остаётся неизменной. + +Таким образом следующий уровень Pipeline получает уже не транспортную модель биржи, а полностью независимую внутреннюю модель системы. + +--- + +# Почему Mapper создаёт новую модель + +Во время архитектурного проектирования отдельно рассматривался вопрос о возможности повторного использования объекта + +```text +DzengiWebSocketTradeEvent +``` + +на последующих уровнях системы. + +По результатам анализа было принято решение полностью отказаться от подобного подхода. + +Основные причины: + +- транспортная модель отражает структуру конкретной биржи; +- транспортная модель содержит поля, отсутствующие в предметной области; +- транспортная модель использует транспортные представления числовых данных; +- транспортная модель использует транспортные соглашения о наименовании полей; +- внутренние компоненты системы не должны зависеть от API биржи. + +Поэтому Mapper всегда создаёт новый экземпляр + +```text +Trade +``` + +который становится единственной моделью, используемой за пределами слоя адаптера. + +Подобное решение полностью соответствует базовому архитектурному принципу Dzentra — полной изоляции внутренних компонентов от транспортных контрактов внешних источников данных. + +--- + +# Преобразуемая транспортная модель + +Преобразование выполняется над объектом + +```python +@dataclass(frozen=True, slots=True) +class DzengiWebSocketTradeEvent: + trade_id: int + price: DzengiRawNumeric + size: DzengiRawNumeric + timestamp: int + symbol: str + buyer: bool + order_id: str +``` + +После выполнения Mapping создаётся объект + +```python +@dataclass(frozen=True, slots=True) +class Trade: + symbol: str + trade_id: int + price: Decimal + quantity: Decimal + executed_at: datetime + aggressor_side: TradeAggressorSide + source: str +``` + +Таким образом Mapper полностью устраняет зависимость системы от транспортной модели биржи. + +--- + +# Преобразование идентификатора сделки + +Поле + +```text +trade_id +``` + +имеет одинаковую семантику в транспортной и канонической модели. + +Поэтому Mapper переносит его без изменения значения. + +Дополнительных преобразований не выполняется. + +--- + +# Преобразование цены сделки + +Поле + +```text +price +``` + +в транспортной модели может быть представлено в нескольких форматах. + +Например: + +```text +"63992.50" + +63992 + +63992.50 +``` + +Во время Mapping используется существующий helper проекта, преобразующий транспортное представление в + +```text +Decimal +``` + +После завершения Mapping каноническая модель всегда содержит внутренний числовой тип системы независимо от исходного представления данных. + +--- + +# Преобразование количества сделки + +Транспортная модель использует поле + +```text +size +``` + +которое отражает терминологию API биржи. + +В канонической модели используется единое наименование + +```text +quantity +``` + +Во время Mapping выполняются одновременно два действия: + +- преобразование транспортного значения в `Decimal`; +- переименование транспортного поля в соответствии с внутренней моделью предметной области. + +После завершения Mapping дальнейшая работа системы полностью абстрагируется от терминологии конкретной биржи. + +--- + +# Преобразование временной метки + +Поле + +```text +timestamp +``` + +в транспортной модели представляет собой количество миллисекунд, прошедших с начала эпохи Unix. + +Подобное представление удобно для передачи данных по сети, однако не является внутренним представлением времени в Dzentra. + +Во время Mapping используется существующая helper-функция преобразования времени. + +В результате каноническая модель получает объект + +```text +datetime +``` + +в часовом поясе UTC. + +Таким образом все внутренние компоненты системы используют единый формат представления времени независимо от способа передачи данных биржей. + +--- + +# Преобразование стороны агрессора + +Во время архитектурного аудита отдельно анализировалась семантика транспортного поля + +```text +buyer +``` + +В WebSocket API Dzengi данное поле определяет сторону покупателя сделки. + +Однако внутренняя модель Dzentra использует перечисление + +```text +TradeAggressorSide +``` + +Поэтому Mapper выполняет явное преобразование транспортного признака в каноническое перечисление. + +Используется следующее соответствие. + +```text +buyer = True + │ + ▼ +TradeAggressorSide.BUY +``` + +```text +buyer = False + │ + ▼ +TradeAggressorSide.SELL +``` + +Подобное преобразование делает внутреннюю модель полностью независимой от конкретного транспортного соглашения биржи. + +--- + +# Преобразование источника данных + +Каноническая модель + +```text +Trade +``` + +содержит поле + +```text +source +``` + +которое используется для идентификации происхождения события. + +Во время Mapping данное поле получает фиксированное значение + +```text +dzengi_websocket_trade +``` + +Использование отдельного идентификатора источника позволяет последующим уровням системы различать происхождение канонических моделей без анализа транспортного Pipeline. + +--- + +# Почему order_id отсутствует в канонической модели + +Транспортная модель WebSocket содержит дополнительное поле + +```text +order_id +``` + +которое используется исключительно транспортным контрактом биржи. + +Во время архитектурного проектирования отдельно анализировался вопрос необходимости переноса данного значения в каноническую модель. + +По результатам анализа было принято решение отказаться от подобного преобразования. + +Основные причины: + +- идентификатор ордера отсутствует в модели предметной области; +- последующие уровни системы не используют данное значение; +- сохранение транспортного идентификатора нарушило бы независимость канонической модели. + +В результате поле + +```text +order_id +``` + +полностью завершается на уровне адаптера и не попадает в модель + +```text +Trade +``` + +--- + +# Повторное использование существующих helper-функций + +Build 060.13 не вводит новых механизмов преобразования числовых значений и времени. + +Вместо этого повторно используются уже существующие helper-функции проекта. + +Для преобразования числовых представлений применяется существующий механизм преобразования в + +```text +Decimal +``` + +Для преобразования временной метки используется существующая функция построения объекта + +```text +datetime +``` + +Подобный подход обеспечивает единообразное поведение всех Mapper проекта независимо от типа источника данных. + +--- + +# Использование TradeMappingError + +Все ошибки преобразования используют существующее исключение + +```text +TradeMappingError +``` + +Build не вводит новых типов исключений. + +Это сохраняет единый механизм обработки ошибок Mapping во всей подсистеме Market Data Acquisition. + +Value Validation продолжает использовать + +```text +TradeValueError +``` + +а Mapper использует исключительно + +```text +TradeMappingError +``` + +Тем самым достигается чёткое разделение ошибок проверки данных и ошибок преобразования транспортной модели. + +--- + +# Изменённые файлы + +В рамках Build были изменены только два файла. + +## Mapper + +```text +src/market_data/acquisition/adapters/dzengi/mapper.py +``` + +Добавлена функция + +```text +map_dzengi_websocket_trade_to_trade(...) +``` + +а также вспомогательная функция преобразования стороны агрессора WebSocket Trade. + +Существующая логика Mapping Quote, OHLC и REST Trade не изменялась. + +--- + +## Unit-тесты + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_trade_mapper.py +``` + +Добавлен полный набор unit-тестов нового уровня Mapping. + +--- + +# Добавленные тесты + +В рамках Build реализовано двадцать два unit-теста, полностью покрывающих новую функциональность. + +## Проверка корректного Mapping + +Подтверждается успешное создание канонической модели + +```text +Trade +``` + +из полностью корректной транспортной модели. + +--- + +## Проверка преобразования стороны агрессора + +Отдельные тесты подтверждают корректное преобразование обоих допустимых значений: + +- `buyer=True`; +- `buyer=False`. + +--- + +## Проверка преобразования числовых представлений + +Параметризованные тесты подтверждают корректную обработку: + +- строк; +- целых чисел; +- чисел с плавающей точкой. + +для полей: + +- `price`; +- `size`. + +--- + +## Проверка преобразования времени + +Подтверждается корректное построение объекта + +```text +datetime +``` + +в часовом поясе UTC. + +--- + +## Проверка канонических имён полей + +Отдельные тесты подтверждают: + +- преобразование `size → quantity`; +- удаление пробельных символов из `symbol`; +- заполнение поля `source`; +- отсутствие поля `order_id` в канонической модели. + +--- + +## Проверка неизменяемости моделей + +Отдельные тесты подтверждают: + +- неизменяемость транспортной модели после Mapping; +- неизменяемость созданной модели `Trade`. + +--- + +## Проверка ошибок преобразования + +Реализованы проверки генерации + +```text +TradeMappingError +``` + +при невозможности преобразования: + +- числовых значений; +- специальных числовых представлений; +- недопустимого значения timestamp. + +# Результаты тестирования + +После завершения реализации выполнен целевой запуск нового набора unit-тестов. + +```bash +python -m pytest \ + tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_trade_mapper.py \ + -q +``` + +Результат: + +```text +22 passed in 0.04s +``` + +Все проверки новой функциональности успешно завершены. + +Новый набор тестов полностью покрывает: + +- успешное построение канонической модели `Trade`; +- преобразование транспортных числовых представлений; +- преобразование временной метки в `datetime`; +- преобразование стороны агрессора; +- переименование транспортных полей; +- заполнение источника данных; +- генерацию исключений для всех типов ошибок Mapping; +- неизменяемость транспортной и канонической моделей. + +Параметризованные тесты позволили существенно сократить объём тестового кода без уменьшения покрытия и обеспечили единообразную проверку различных вариантов входных данных. + +--- + +# Регрессионное тестирование + +После завершения реализации выполнен полный запуск набора unit-тестов проекта. + +```bash +python -m pytest -q +``` + +Результат: + +```text +1230 passed in 2.00s +``` + +Регрессий существующей функциональности не обнаружено. + +Все ранее реализованные Build продолжают работать без каких-либо изменений. + +Это подтверждает, что добавленная функциональность полностью изолирована и не влияет на существующие конвейеры обработки Quote, OHLC, REST Trade и остальные подсистемы проекта. + +--- + +# Проверка компиляции + +После завершения реализации выполнена полная проверка компиляции проекта. + +```bash +python -m compileall src tests +``` + +Компиляция завершилась успешно. + +Ошибок синтаксиса не обнаружено. + +Все изменённые файлы успешно компилируются и не нарушают целостность проекта. + +--- + +# Проверка Git diff + +После завершения реализации выполнена финальная проверка изменений. + +```bash +git diff --check +``` + +Результат: + +```text +без замечаний +``` + +Проверка подтвердила отсутствие: + +- trailing whitespace; +- ошибок окончания строк; +- конфликтов diff; +- нарушений форматирования. + +--- + +# Scope Build 060.13 + +В рамках данного Build реализован исключительно уровень + +```text +WebSocket Trade Mapper +``` + +Build **не включает**: + +- WebSocket Trade Schema Validation; +- WebSocket Trade Parser; +- WebSocket Trade Value Validation; +- WebSocket Trade Adapter; +- Runtime Integration; +- Unified Routing; +- Trades Feed. + +Подобное ограничение полностью соответствует принятому принципу атомарной реализации Build. + +Каждый этап дорожной карты реализует только один архитектурный уровень Pipeline. + +--- + +# Архитектурный результат + +После завершения Build система содержит полностью реализованный транспортный Pipeline WebSocket Trade вплоть до создания канонической модели предметной области. + +Конвейер обработки принимает следующий вид. + +```text +Raw WebSocket Trade + │ + ▼ +WebSocket Trade Schema Validation + │ + ▼ +ValidatedWebSocketTradeDocument + │ + ▼ +WebSocket Trade Parser + │ + ▼ +DzengiWebSocketTradeEvent + │ + ▼ +WebSocket Trade Value Validation + │ + ▼ +DzengiWebSocketTradeEvent + │ + ▼ +WebSocket Trade Mapper + │ + ▼ +Trade +``` + +Таким образом архитектура WebSocket Trade теперь полностью повторяет ранее реализованные конвейеры обработки Quote, OHLC и REST Trade. + +После завершения Mapping дальнейшие уровни системы больше не используют транспортную модель биржи. + +Все последующие компоненты работают исключительно с канонической моделью + +```text +Trade +``` + +что полностью соответствует базовым архитектурным принципам Dzentra. + +--- + +# Состояние WebSocket Trade Pipeline + +После завершения Build 060.13 конвейер имеет следующий вид. + +```text +Raw WebSocket Trade + │ + ▼ +Schema Validation + │ + ▼ +ValidatedWebSocketTradeDocument + │ + ▼ +Parser + │ + ▼ +DzengiWebSocketTradeEvent + │ + ▼ +Value Validation + │ + ▼ +DzengiWebSocketTradeEvent + │ + ▼ +Mapper + │ + ▼ +Trade +``` + +Статус реализации компонентов: + +| Компонент | Build | Статус | +|-----------|-------|--------| +| Canonical Trade Model | 060.1 | ✔ Completed | +| WebSocket Trade Transport Model | 060.9 | ✔ Completed | +| WebSocket Trade Schema Validation | 060.10 | ✔ Completed | +| WebSocket Trade Parser | 060.11 | ✔ Completed | +| WebSocket Trade Value Validation | 060.12 | ✔ Completed | +| WebSocket Trade Mapper | 060.13 | ✔ Completed | +| WebSocket Trade Adapter | 060.14 | Pending | +| Unified WebSocket Routing | 060.15 | Pending | + +--- + +# Соблюдение архитектурных принципов + +В рамках Build полностью сохранены архитектурные инварианты Dzentra. + +## Локальность изменений + +Изменены только: + +- `adapters/dzengi/mapper.py`; +- unit-тесты нового уровня Mapping. + +Существующая логика Quote, OHLC и REST Trade не изменялась. + +--- + +## Повторное использование архитектуры + +Новая реализация полностью повторяет существующий архитектурный шаблон Mapper. + +Новая архитектура не проектировалась. + +Использован уже существующий подход, применяемый для остальных типов рыночных данных. + +--- + +## Повторное использование инфраструктуры + +Для преобразования числовых значений, временных меток и канонических перечислений повторно использованы существующие helper-функции проекта. + +Build не вводит новых механизмов преобразования и не дублирует уже реализованную функциональность. + +Это обеспечивает единообразное поведение всех Mapper проекта. + +--- + +## Разделение ответственности + +Mapper отвечает исключительно за преобразование транспортной модели в каноническую модель предметной области. + +Build не выполняет: + +- Schema Validation; +- Value Validation; +- Runtime Integration; +- бизнес-логику; +- маршрутизацию событий. + +Все перечисленные задачи остаются ответственностью предыдущих либо последующих уровней Pipeline. + +--- + +## Обратная совместимость + +Существующая обработка: + +- Quote; +- OHLC; +- REST Trade; + +не изменилась. + +Добавленная функциональность полностью изолирована и не оказывает влияния на ранее реализованные компоненты системы. + +--- + +# Архитектурные решения Build (ADR) + +## ADR-060.13-001 + +**Mapper всегда создаёт новую каноническую модель.** + +После завершения преобразования транспортная модель больше не используется последующими уровнями системы. + +Это обеспечивает полную изоляцию внутренней архитектуры от API конкретной биржи. + +--- + +## ADR-060.13-002 + +**Все преобразования транспортных типов выполняются исключительно внутри Mapper.** + +Преобразование транспортных числовых представлений в `Decimal`, временной метки в `datetime` и транспортных признаков сделки в канонические перечисления не допускается ни на одном другом уровне Pipeline. + +--- + +## ADR-060.13-003 + +**Транспортные поля, отсутствующие в предметной области, не переносятся в каноническую модель.** + +Поле + +```text +order_id +``` + +является частью транспортного контракта биржи и не включается в модель + +```text +Trade +``` + +--- + +## ADR-060.13-004 + +**Mapper использует существующее исключение TradeMappingError.** + +Build не вводит новых типов исключений. + +Все ошибки преобразования продолжают использовать единый механизм обработки ошибок Mapping. + +--- + +# Критерии завершения Build + +Build 060.13 считается завершённым, поскольку выполнены все поставленные задачи. + +- ✔ реализована функция `map_dzengi_websocket_trade_to_trade()`; +- ✔ реализовано преобразование транспортной модели в каноническую модель `Trade`; +- ✔ реализовано преобразование транспортных числовых представлений в `Decimal`; +- ✔ реализовано преобразование временной метки в `datetime`; +- ✔ реализовано преобразование стороны агрессора; +- ✔ реализовано заполнение поля `source`; +- ✔ исключено транспортное поле `order_id`; +- ✔ повторно использованы существующие helper-функции; +- ✔ используется существующее исключение `TradeMappingError`; +- ✔ транспортная модель остаётся неизменяемой; +- ✔ реализовано 22 unit-теста; +- ✔ целевой набор тестов успешно проходит; +- ✔ полное регрессионное тестирование успешно завершено; +- ✔ проект успешно компилируется; +- ✔ `git diff --check` не выявил замечаний; +- ✔ изменения полностью укладываются в согласованный scope Build. + +--- + +# Следующий этап + +Следующим этапом дорожной карты является + +```text +Build 060.14 — WebSocket Trade Adapter +``` + +Цель следующего Build: + +- объединить Schema Validation, Parser, Value Validation и Mapper в единый адаптер; +- реализовать единую точку обработки WebSocket Trade; +- завершить адаптер получения канонической модели `Trade` из сырого WebSocket-сообщения; +- подготовить основу для последующей интеграции в Unified WebSocket Routing. + +--- + +# Итог + +Build 060.13 завершил реализацию уровня **WebSocket Trade Mapper** и сделал транспортный Pipeline WebSocket Trade полностью независимым от формата данных биржи Dzengi. + +Новая реализация основана на уже существующих архитектурных принципах Dzentra, повторно использует существующую инфраструктуру преобразования данных, создаёт каноническую модель предметной области и сохраняет строгое разделение ответственности между уровнями Pipeline. + +Build ограничен согласованным scope, успешно прошёл целевое и полное регрессионное тестирование, подтвердил отсутствие регрессий и завершил построение транспортного конвейера WebSocket Trade до уровня канонической модели. + +Следующим этапом развития серии является **Build 060.14 — WebSocket Trade Adapter**, который объединит все реализованные уровни Pipeline в единый компонент обработки входящих WebSocket-сообщений. \ No newline at end of file