Build 060.11: add WebSocket Trade Parser
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
1014
docs/migrations/build_060_11.md
Normal file
1014
docs/migrations/build_060_11.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user