Build 060.3: add REST trade schema validation

This commit is contained in:
2026-07-18 14:16:47 +03:00
parent 013efd8a97
commit c0f69ee94a
4 changed files with 896 additions and 1 deletions

View File

@@ -119,6 +119,11 @@ class CandleFeedRegistryError(MarketDataAcquisitionError):
pass
# Ошибка структуры документа Trades Feed.
class TradeSchemaError(MarketDataAcquisitionError):
pass
# Ошибка определения типа входящего WebSocket-сообщения
# и выбора специализированного адаптера.
class WebSocketMessageRoutingError(MarketDataAcquisitionError):

View File

@@ -11,6 +11,7 @@ from src.market_data.acquisition.exceptions import (
CandleWebSocketSchemaError,
InstrumentReferenceSchemaError,
QuoteSchemaError,
TradeSchemaError,
)
@@ -595,6 +596,94 @@ def _require_candle_mapping(
return value
# Структурно проверенное представление ответа Dzengi /api/v2/aggTrades.
@dataclass(frozen=True, slots=True)
class ValidatedRestAggTradesDocument:
items: tuple[Mapping[str, object], ...]
def validate_rest_agg_trades_schema(
document: object,
) -> ValidatedRestAggTradesDocument:
"""
Проверить структуру ответа Dzengi aggTrades без проверки значений.
Ожидается корневой JSON-массив, каждый элемент которого является
JSON-объектом с обязательными полями:
- a — идентификатор агрегированной сделки;
- p — цена;
- q — количество;
- T — временная метка;
- m — признак buyer is maker.
Валидатор проверяет только структуру документа. Типы и допустимость
значений должны проверяться последующими слоями acquisition pipeline.
"""
if not isinstance(document, list):
raise TradeSchemaError(
"$ должен быть JSON-массивом, "
f"получен {type(document).__name__}."
)
validated_items: list[Mapping[str, object]] = []
for index, item in enumerate(document):
path = f"$[{index}]"
mapping = _require_trade_mapping(
item,
path=path,
)
_validate_rest_agg_trade_item(
mapping,
path=path,
)
validated_items.append(
MappingProxyType(dict(mapping))
)
return ValidatedRestAggTradesDocument(
items=tuple(validated_items),
)
def _validate_rest_agg_trade_item(
item: Mapping[str, object],
*,
path: str,
) -> None:
for key in ("a", "p", "q", "T", "m"):
if key not in item:
raise TradeSchemaError(
f"{path}.{key} отсутствует в документе aggTrades."
)
def _require_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
# Структурно проверенное представление события Dzengi WebSocket ohlc.event.
@dataclass(frozen=True, slots=True)
class ValidatedWebSocketOhlcDocument:

View File

@@ -8,9 +8,11 @@ import pytest
from src.market_data.acquisition.exceptions import (
InstrumentReferenceSchemaError,
TradeSchemaError,
)
from src.market_data.acquisition.validation.schema import (
validate_exchange_info_schema,
validate_rest_agg_trades_schema,
)
@@ -265,4 +267,163 @@ def test_reject_non_object_payload_collection_item(key: str) -> None:
InstrumentReferenceSchemaError,
match=rf"\.{key}\[0\] должен быть JSON-объектом",
):
validate_exchange_info_schema(document)
validate_exchange_info_schema(document)
def test_validate_rest_agg_trades_document() -> None:
document = [
{
"a": 2134857062,
"p": "64497.25",
"q": "0.005",
"T": 1784218066823,
"m": False,
},
{
"a": 2134857063,
"p": "64498.10",
"q": "0.002",
"T": 1784218067000,
"m": True,
},
]
validated = validate_rest_agg_trades_schema(document)
assert len(validated.items) == 2
assert validated.items[0]["a"] == 2134857062
assert validated.items[0]["p"] == "64497.25"
assert validated.items[0]["q"] == "0.005"
assert validated.items[0]["T"] == 1784218066823
assert validated.items[0]["m"] is False
assert isinstance(validated.items, tuple)
assert isinstance(validated.items[0], MappingProxyType)
def test_validate_empty_rest_agg_trades_document() -> None:
validated = validate_rest_agg_trades_schema([])
assert validated.items == ()
def test_rest_agg_trades_schema_preserves_unchecked_field_values() -> None:
document = [
{
"a": "invalid-id",
"p": None,
"q": [],
"T": False,
"m": 123,
}
]
validated = validate_rest_agg_trades_schema(document)
assert validated.items[0]["a"] == "invalid-id"
assert validated.items[0]["p"] is None
assert validated.items[0]["q"] == []
assert validated.items[0]["T"] is False
assert validated.items[0]["m"] == 123
def test_rest_agg_trades_schema_preserves_additional_fields() -> None:
document = [
{
"a": 2134857062,
"p": "64497.25",
"q": "0.005",
"T": 1784218066823,
"m": False,
"extra": "preserved",
}
]
validated = validate_rest_agg_trades_schema(document)
assert validated.items[0]["extra"] == "preserved"
@pytest.mark.parametrize(
"document",
[
None,
{},
"invalid",
123,
],
)
def test_reject_non_list_rest_agg_trades_root(
document: object,
) -> None:
with pytest.raises(
TradeSchemaError,
match=r"\$ должен быть JSON-массивом",
):
validate_rest_agg_trades_schema(document)
@pytest.mark.parametrize(
"item",
[
None,
[],
"invalid",
123,
],
)
def test_reject_non_object_rest_agg_trade_item(
item: object,
) -> None:
with pytest.raises(
TradeSchemaError,
match=r"\$\[0\] должен быть JSON-объектом",
):
validate_rest_agg_trades_schema([item])
@pytest.mark.parametrize(
"missing_key",
[
"a",
"p",
"q",
"T",
"m",
],
)
def test_reject_rest_agg_trade_with_missing_required_field(
missing_key: str,
) -> None:
item: dict[str, object] = {
"a": 2134857062,
"p": "64497.25",
"q": "0.005",
"T": 1784218066823,
"m": False,
}
del item[missing_key]
with pytest.raises(
TradeSchemaError,
match=rf"\$\[0\]\.{missing_key} отсутствует",
):
validate_rest_agg_trades_schema([item])
def test_reject_rest_agg_trade_with_non_string_key() -> None:
document = [
{
"a": 2134857062,
"p": "64497.25",
"q": "0.005",
"T": 1784218066823,
"m": False,
1: "invalid",
}
]
with pytest.raises(
TradeSchemaError,
match=r"\$\[0\] содержит нестроковый ключ типа int",
):
validate_rest_agg_trades_schema(document)