Build 060.11: add WebSocket Trade Parser

This commit is contained in:
2026-07-19 17:51:47 +03:00
parent 034f5984ca
commit d3f8680921
3 changed files with 1348 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ from src.market_data.acquisition.adapters.dzengi.models import (
DzengiTicker24hrResponse, DzengiTicker24hrResponse,
DzengiWebSocketOhlcEvent, DzengiWebSocketOhlcEvent,
DzengiWebSocketQuoteResponse, DzengiWebSocketQuoteResponse,
DzengiWebSocketTradeEvent,
) )
from src.market_data.acquisition.exceptions import ( from src.market_data.acquisition.exceptions import (
CandleParseError, CandleParseError,
@@ -37,6 +38,7 @@ from src.market_data.acquisition.validation.schema import (
ValidatedRestAggTradesDocument, ValidatedRestAggTradesDocument,
ValidatedWebSocketOhlcDocument, ValidatedWebSocketOhlcDocument,
ValidatedWebSocketQuoteDocument, ValidatedWebSocketQuoteDocument,
ValidatedWebSocketTradeDocument,
) )
@@ -820,6 +822,110 @@ def _websocket_ohlc_required_raw_numeric(
return value return value
# Преобразовать структурно проверенное событие WebSocket Trade
# в транспортную модель адаптера Dzengi.
def parse_dzengi_websocket_trade(
document: ValidatedWebSocketTradeDocument,
) -> DzengiWebSocketTradeEvent:
"""
Преобразовать проверенный payload события ``internal.trade``
в transport-модель Dzengi.
Функция не выполняет предметную проверку значений, не проверяет
допустимость цены, размера сделки или timestamp, не интерпретирует
сторону сделки и не выполняет mapping во внутреннюю модель Dzentra.
"""
payload = document.payload
return DzengiWebSocketTradeEvent(
trade_id=_websocket_trade_required_int(
payload.get("id"),
path="$.payload.id",
),
price=_websocket_trade_required_raw_numeric(
payload.get("price"),
path="$.payload.price",
),
size=_websocket_trade_required_raw_numeric(
payload.get("size"),
path="$.payload.size",
),
timestamp=_websocket_trade_required_int(
payload.get("ts"),
path="$.payload.ts",
),
symbol=_websocket_trade_required_string(
payload.get("symbol"),
path="$.payload.symbol",
),
buyer=_websocket_trade_required_bool(
payload.get("buyer"),
path="$.payload.buyer",
),
order_id=_websocket_trade_required_string(
payload.get("orderId"),
path="$.payload.orderId",
),
)
def _websocket_trade_required_string(
value: object,
*,
path: str,
) -> str:
if not isinstance(value, str):
raise TradeParseError(
f"{path} должен быть строкой, "
f"получен {type(value).__name__}."
)
return value
def _websocket_trade_required_int(
value: object,
*,
path: str,
) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise TradeParseError(
f"{path} должен быть целым числом, "
f"получен {type(value).__name__}."
)
return value
def _websocket_trade_required_bool(
value: object,
*,
path: str,
) -> bool:
if not isinstance(value, bool):
raise TradeParseError(
f"{path} должен быть булевым значением, "
f"получен {type(value).__name__}."
)
return value
def _websocket_trade_required_raw_numeric(
value: object,
*,
path: str,
) -> DzengiRawNumeric:
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
raise TradeParseError(
f"{path} должен быть строкой или числом, "
f"получен {type(value).__name__}."
)
return value
# Преобразовать структурно проверенный ответ klines в raw-модели Dzengi. # Преобразовать структурно проверенный ответ klines в raw-модели Dzengi.
def parse_candles( def parse_candles(
document: ValidatedCandlesDocument, document: ValidatedCandlesDocument,

View File

@@ -0,0 +1,228 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_trade_parser.py
from types import MappingProxyType
import pytest
import re
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiWebSocketTradeEvent,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_dzengi_websocket_trade,
)
from src.market_data.acquisition.exceptions import TradeParseError
from src.market_data.acquisition.validation.schema import (
ValidatedWebSocketTradeDocument,
)
def _make_document(
*,
payload_overrides: dict[str, object] | None = None,
status: object = "OK",
destination: object = "internal.trade",
correlation_id: object | None = None,
) -> ValidatedWebSocketTradeDocument:
payload: dict[str, object] = {
"id": 2134846831,
"price": 64555.55,
"size": 0.002,
"ts": 1784218012030,
"symbol": "BTC/USD_LEVERAGE",
"buyer": False,
"orderId": "00a02503-0079-54c4-0000-000081e62b58",
}
if payload_overrides is not None:
payload.update(payload_overrides)
return ValidatedWebSocketTradeDocument(
payload=MappingProxyType(payload),
status=status,
destination=destination,
correlation_id=correlation_id,
)
def test_parse_dzengi_websocket_trade_returns_transport_event() -> None:
document = _make_document()
event = parse_dzengi_websocket_trade(document)
assert event == DzengiWebSocketTradeEvent(
trade_id=2134846831,
price=64555.55,
size=0.002,
timestamp=1784218012030,
symbol="BTC/USD_LEVERAGE",
buyer=False,
order_id="00a02503-0079-54c4-0000-000081e62b58",
)
def test_parse_dzengi_websocket_trade_renames_transport_fields() -> None:
document = _make_document(
payload_overrides={
"id": 42,
"ts": 1700000000000,
"orderId": "order-42",
}
)
event = parse_dzengi_websocket_trade(document)
assert event.trade_id == 42
assert event.timestamp == 1700000000000
assert event.order_id == "order-42"
@pytest.mark.parametrize(
("price", "size"),
[
("64555.55", "0.002"),
(64555, 2),
(64555.55, 0.002),
],
)
def test_parse_dzengi_websocket_trade_preserves_raw_numeric_values(
price: object,
size: object,
) -> None:
document = _make_document(
payload_overrides={
"price": price,
"size": size,
}
)
event = parse_dzengi_websocket_trade(document)
assert event.price == price
assert type(event.price) is type(price)
assert event.size == size
assert type(event.size) is type(size)
def test_parse_dzengi_websocket_trade_preserves_buyer_value() -> None:
document = _make_document(
payload_overrides={
"buyer": True,
}
)
event = parse_dzengi_websocket_trade(document)
assert event.buyer is True
def test_parse_dzengi_websocket_trade_ignores_transport_envelope() -> None:
document = _make_document(
status={"unexpected": "status"},
destination=["unexpected", "destination"],
correlation_id={"unexpected": "correlation"},
)
event = parse_dzengi_websocket_trade(document)
assert event.trade_id == 2134846831
assert event.symbol == "BTC/USD_LEVERAGE"
def test_parse_dzengi_websocket_trade_ignores_additional_payload_fields() -> None:
document = _make_document(
payload_overrides={
"clientOrderId": "client-order-1",
"additionalField": {"nested": True},
}
)
event = parse_dzengi_websocket_trade(document)
assert event == DzengiWebSocketTradeEvent(
trade_id=2134846831,
price=64555.55,
size=0.002,
timestamp=1784218012030,
symbol="BTC/USD_LEVERAGE",
buyer=False,
order_id="00a02503-0079-54c4-0000-000081e62b58",
)
@pytest.mark.parametrize(
("field", "value", "expected_path"),
[
("id", True, "$.payload.id"),
("id", "2134846831", "$.payload.id"),
("price", True, "$.payload.price"),
("price", None, "$.payload.price"),
("size", True, "$.payload.size"),
("size", None, "$.payload.size"),
("ts", True, "$.payload.ts"),
("ts", "1784218012030", "$.payload.ts"),
("symbol", None, "$.payload.symbol"),
("symbol", 123, "$.payload.symbol"),
("buyer", 1, "$.payload.buyer"),
("buyer", "false", "$.payload.buyer"),
("orderId", None, "$.payload.orderId"),
("orderId", 123, "$.payload.orderId"),
],
)
def test_parse_dzengi_websocket_trade_rejects_invalid_field_type(
field: str,
value: object,
expected_path: str,
) -> None:
document = _make_document(
payload_overrides={
field: value,
}
)
with pytest.raises(
TradeParseError,
match=re.escape(expected_path),
):
parse_dzengi_websocket_trade(document)
@pytest.mark.parametrize(
("field", "expected_path"),
[
("id", "$.payload.id"),
("price", "$.payload.price"),
("size", "$.payload.size"),
("ts", "$.payload.ts"),
("symbol", "$.payload.symbol"),
("buyer", "$.payload.buyer"),
("orderId", "$.payload.orderId"),
],
)
def test_parse_dzengi_websocket_trade_rejects_missing_payload_field(
field: str,
expected_path: str,
) -> None:
payload: dict[str, object] = {
"id": 2134846831,
"price": 64555.55,
"size": 0.002,
"ts": 1784218012030,
"symbol": "BTC/USD_LEVERAGE",
"buyer": False,
"orderId": "00a02503-0079-54c4-0000-000081e62b58",
}
payload.pop(field)
document = ValidatedWebSocketTradeDocument(
payload=MappingProxyType(payload),
status="OK",
destination="internal.trade",
correlation_id=None,
)
with pytest.raises(
TradeParseError,
match=re.escape(expected_path),
):
parse_dzengi_websocket_trade(document)

File diff suppressed because it is too large Load Diff