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