Build 060.10: add WebSocket Trade Schema Validation

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

View File

@@ -812,3 +812,131 @@ def _require_websocket_ohlc_mapping(
) )
return value return value
# Структурно проверенное представление события Dzengi WebSocket internal.trade.
@dataclass(frozen=True, slots=True)
class ValidatedWebSocketTradeDocument:
payload: Mapping[str, object]
status: object
destination: object
correlation_id: object | None
def validate_dzengi_websocket_trade_schema(
document: object,
) -> ValidatedWebSocketTradeDocument:
"""
Проверить структуру события Dzengi WebSocket Trade без проверки значений.
Ожидаемый runtime-контракт:
{
"status": "OK",
"destination": "internal.trade",
"payload": {
"buyer": false,
"id": 2134846831,
"orderId": "00a02503-0079-54c4-0000-000081e62b58",
"price": 64555.55,
"size": 0.002,
"symbol": "BTC/USD_LEVERAGE",
"ts": 1784218012030
}
}
Функция проверяет только структуру сообщения:
- корневой JSON-объект;
- наличие status;
- наличие destination;
- наличие payload;
- обязательный набор полей Trade;
- строковые ключи объектов.
Функция не проверяет предметные значения, не преобразует timestamp,
не преобразует цену и объём и не создаёт transport-модель адаптера.
"""
root = _require_websocket_trade_mapping(
document,
path="$",
)
if "status" not in root:
raise TradeSchemaError(
"$.status отсутствует в событии WebSocket Trade."
)
if "destination" not in root:
raise TradeSchemaError(
"$.destination отсутствует в событии WebSocket Trade."
)
if "payload" not in root:
raise TradeSchemaError(
"$.payload отсутствует в событии WebSocket Trade."
)
payload = _require_websocket_trade_mapping(
root.get("payload"),
path="$.payload",
)
_validate_websocket_trade_payload(payload)
return ValidatedWebSocketTradeDocument(
payload=MappingProxyType(dict(payload)),
status=root.get("status"),
destination=root.get("destination"),
correlation_id=root.get("correlationId"),
)
def _validate_websocket_trade_payload(
payload: Mapping[str, object],
) -> None:
required_fields = (
"id",
"price",
"size",
"ts",
"symbol",
"buyer",
"orderId",
)
missing_fields = tuple(
field_name
for field_name in required_fields
if field_name not in payload
)
if missing_fields:
formatted_fields = ", ".join(missing_fields)
raise TradeSchemaError(
"$.payload не содержит обязательные поля WebSocket Trade: "
f"{formatted_fields}."
)
def _require_websocket_trade_mapping(
value: object,
*,
path: str,
) -> Mapping[str, object]:
if not isinstance(value, dict):
raise TradeSchemaError(
f"{path} должен быть JSON-объектом, "
f"получен {type(value).__name__}."
)
for key in value:
if not isinstance(key, str):
raise TradeSchemaError(
f"{path} содержит нестроковый ключ "
f"типа {type(key).__name__}."
)
return value

View File

@@ -0,0 +1,226 @@
# app/tests/unit/market_data/acquisition/validation/test_websocket_trade_schema.py
from __future__ import annotations
from types import MappingProxyType
import pytest
from src.market_data.acquisition.exceptions import TradeSchemaError
from src.market_data.acquisition.validation.schema import (
ValidatedWebSocketTradeDocument,
validate_dzengi_websocket_trade_schema,
)
def _valid_document() -> dict[str, object]:
return {
"status": "OK",
"destination": "internal.trade",
"payload": {
"buyer": False,
"id": 2134846831,
"orderId": "00a02503-0079-54c4-0000-000081e62b58",
"price": 64555.55,
"size": 0.002,
"symbol": "BTC/USD_LEVERAGE",
"ts": 1784218012030,
},
}
def test_validate_websocket_trade_schema_returns_immutable_document() -> None:
result = validate_dzengi_websocket_trade_schema(
_valid_document()
)
assert isinstance(result, ValidatedWebSocketTradeDocument)
assert isinstance(result.payload, MappingProxyType)
assert result.status == "OK"
assert result.destination == "internal.trade"
assert result.correlation_id is None
assert result.payload == {
"buyer": False,
"id": 2134846831,
"orderId": "00a02503-0079-54c4-0000-000081e62b58",
"price": 64555.55,
"size": 0.002,
"symbol": "BTC/USD_LEVERAGE",
"ts": 1784218012030,
}
def test_validate_websocket_trade_schema_preserves_correlation_id() -> None:
document = _valid_document()
document["correlationId"] = "correlation-1"
result = validate_dzengi_websocket_trade_schema(document)
assert result.correlation_id == "correlation-1"
def test_validate_websocket_trade_schema_copies_payload() -> None:
document = _valid_document()
payload = document["payload"]
assert isinstance(payload, dict)
result = validate_dzengi_websocket_trade_schema(document)
payload["price"] = 1.0
assert result.payload["price"] == 64555.55
def test_validate_websocket_trade_schema_rejects_non_mapping_root() -> None:
with pytest.raises(
TradeSchemaError,
match=r"\$ должен быть JSON-объектом",
):
validate_dzengi_websocket_trade_schema([])
@pytest.mark.parametrize(
"field_name",
[
"status",
"destination",
"payload",
],
)
def test_validate_websocket_trade_schema_rejects_missing_root_field(
field_name: str,
) -> None:
document = _valid_document()
del document[field_name]
with pytest.raises(
TradeSchemaError,
match=rf"\$\.{field_name} отсутствует",
):
validate_dzengi_websocket_trade_schema(document)
def test_validate_websocket_trade_schema_rejects_non_mapping_payload() -> None:
document = _valid_document()
document["payload"] = []
with pytest.raises(
TradeSchemaError,
match=r"\$\.payload должен быть JSON-объектом",
):
validate_dzengi_websocket_trade_schema(document)
@pytest.mark.parametrize(
"field_name",
[
"id",
"price",
"size",
"ts",
"symbol",
"buyer",
"orderId",
],
)
def test_validate_websocket_trade_schema_rejects_missing_payload_field(
field_name: str,
) -> None:
document = _valid_document()
payload = document["payload"]
assert isinstance(payload, dict)
del payload[field_name]
with pytest.raises(
TradeSchemaError,
match=rf"обязательные поля WebSocket Trade: {field_name}",
):
validate_dzengi_websocket_trade_schema(document)
def test_validate_websocket_trade_schema_reports_all_missing_fields() -> None:
document = _valid_document()
payload = document["payload"]
assert isinstance(payload, dict)
del payload["id"]
del payload["size"]
del payload["orderId"]
with pytest.raises(
TradeSchemaError,
match=r"id, size, orderId",
):
validate_dzengi_websocket_trade_schema(document)
def test_validate_websocket_trade_schema_does_not_validate_values() -> None:
document = _valid_document()
payload = document["payload"]
assert isinstance(payload, dict)
document["status"] = 123
document["destination"] = None
payload["id"] = "invalid-id"
payload["price"] = None
payload["size"] = []
payload["ts"] = -1
payload["symbol"] = False
payload["buyer"] = "unknown"
payload["orderId"] = object()
result = validate_dzengi_websocket_trade_schema(document)
assert result.status == 123
assert result.destination is None
assert result.payload["id"] == "invalid-id"
assert result.payload["ts"] == -1
def test_validate_websocket_trade_schema_allows_additional_fields() -> None:
document = _valid_document()
payload = document["payload"]
assert isinstance(payload, dict)
payload["clientOrderId"] = "optional-client-order-id"
payload["unknownField"] = "preserved"
result = validate_dzengi_websocket_trade_schema(document)
assert result.payload["clientOrderId"] == "optional-client-order-id"
assert result.payload["unknownField"] == "preserved"
def test_validate_websocket_trade_schema_rejects_non_string_root_key() -> None:
document = _valid_document()
document[1] = "invalid" # type: ignore[index]
with pytest.raises(
TradeSchemaError,
match=r"\$ содержит нестроковый ключ",
):
validate_dzengi_websocket_trade_schema(document)
def test_validate_websocket_trade_schema_rejects_non_string_payload_key() -> None:
document = _valid_document()
payload = document["payload"]
assert isinstance(payload, dict)
payload[1] = "invalid" # type: ignore[index]
with pytest.raises(
TradeSchemaError,
match=r"\$\.payload содержит нестроковый ключ",
):
validate_dzengi_websocket_trade_schema(document)

File diff suppressed because it is too large Load Diff