Build 060.3: add REST trade schema validation
This commit is contained in:
@@ -119,6 +119,11 @@ class CandleFeedRegistryError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка структуры документа Trades Feed.
|
||||
class TradeSchemaError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка определения типа входящего WebSocket-сообщения
|
||||
# и выбора специализированного адаптера.
|
||||
class WebSocketMessageRoutingError(MarketDataAcquisitionError):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user