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,
)
@@ -266,3 +268,162 @@ def test_reject_non_object_payload_collection_item(key: str) -> None:
match=rf"\.{key}\[0\] должен быть JSON-объектом",
):
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)

View File

@@ -0,0 +1,640 @@
# Build 060.3 — REST Trade Schema Validation
## Статус
✅ Завершён
---
# Цель Build
После завершения Build 060.2 в системе уже существовал transport-контракт отдельной сделки:
```
REST JSON
DzengiRestAggTrade
```
Однако отсутствовал обязательный архитектурный слой Schema Validation.
В соответствии с архитектурой Acquisition Pipeline каждый внешний документ обязан проходить структурную проверку до начала парсинга.
Для REST Trades это означало необходимость добавить отдельный слой:
```
REST JSON
Schema Validation
ValidatedRestAggTradesDocument
REST Trade Parser
DzengiRestAggTrade
```
Именно этот слой реализован в Build 060.3.
---
# Причины появления Build
REST endpoint `/api/v2/aggTrades` возвращает JSON-массив объектов.
Пример ответа:
```json
[
{
"a": 2134857062,
"p": "64497.25",
"q": "0.005",
"T": 1784218066823,
"m": false
},
{
"a": 2134857063,
"p": "64498.10",
"q": "0.002",
"T": 1784218067000,
"m": true
}
]
```
До Build 060.3 никакой проверки структуры документа не существовало.
Parser был вынужден бы принимать произвольный объект.
Это нарушало принятую архитектуру Dzentra.
---
# Архитектурное решение
Во время проектирования рассматривались два варианта.
## Вариант A
Schema Validation получает весь REST-документ.
```
REST response
ValidatedRestAggTradesDocument
parse_rest_agg_trades()
tuple[DzengiRestAggTrade]
```
---
## Вариант B
Schema Validation работает с одной сделкой.
```
REST response
trade
ValidatedRestAggTradeDocument
```
После анализа существующей архитектуры проекта был выбран вариант A.
Причины:
- ExchangeInfo валидируется целиком;
- Candles валидируются целиком;
- WebSocket сообщения валидируются целиком;
- Parser всегда получает уже структурно проверенный документ.
Trades Feed должен следовать тем же правилам.
---
# Итоговая архитектура
После Build 060.3 pipeline выглядит следующим образом.
```
REST JSON
validate_rest_agg_trades_schema()
ValidatedRestAggTradesDocument
parse_rest_agg_trades()
tuple[DzengiRestAggTrade]
Value Validation
Canonical Trade
```
Каждый слой отвечает только за собственную область ответственности.
---
# Ответственность Schema Validation
Schema Validation проверяет исключительно структуру документа.
Проверяется:
- корневой JSON-массив;
- каждый элемент массива является JSON-объектом;
- все ключи являются строками;
- обязательные поля присутствуют;
- документ переводится в immutable-представление.
Schema Validation не занимается:
- проверкой типов значений;
- преобразованием данных;
- проверкой диапазонов;
- проверкой бизнес-ограничений;
- созданием transport-моделей.
Все перечисленные обязанности принадлежат последующим слоям Acquisition Pipeline.
---
# Новое исключение
В Build добавлено новое исключение.
```python
class TradeSchemaError(MarketDataAcquisitionError):
pass
```
Исключение используется исключительно для ошибок структуры REST Trade документа.
Build сознательно не добавляет:
- TradeParseError;
- TradeValueError;
- TradeMappingError.
Они будут появляться только в соответствующих Build.
Это позволяет сохранить строгую границу ответственности каждого этапа разработки.
---
# Новый контракт Schema Layer
Добавлен immutable-контракт.
```python
@dataclass(frozen=True, slots=True)
class ValidatedRestAggTradesDocument:
items: tuple[Mapping[str, object], ...]
```
Контракт хранит уже структурно проверенный REST-документ.
Каждый элемент массива:
- является Mapping;
- является immutable;
- сохраняет все поля документа;
- ещё не преобразован в transport model.
Parser получает именно этот контракт.
# Реализация Schema Validation
В Build реализована новая функция.
```python
validate_rest_agg_trades_schema(
document: object,
) -> ValidatedRestAggTradesDocument
```
Функция является единственной точкой входа для проверки структуры REST Trade документа.
Parser больше не должен принимать произвольный JSON.
Он обязан получать только результат работы Schema Validation.
---
# Проверка корневого объекта
Первым этапом валидируется корень документа.
Ожидается исключительно JSON-массив.
Допустимый пример:
```json
[
{
"a": 1,
"p": "1.25",
"q": "5",
"T": 123,
"m": false
}
]
```
Недопустимые варианты:
```json
{}
```
```json
123
```
```json
"text"
```
```json
null
```
Во всех подобных случаях возбуждается
```python
TradeSchemaError
```
Parser никогда не увидит подобный документ.
---
# Проверка элементов массива
После проверки корня валидируется каждый элемент массива.
Каждый элемент обязан быть JSON-объектом.
Допустимо:
```json
[
{
"a": 1
}
]
```
Недопустимо:
```json
[
1
]
```
```json
[
[]
]
```
```json
[
null
]
```
```json
[
"trade"
]
```
Подобные документы отклоняются ещё до Parser.
---
# Проверка ключей
После проверки структуры объекта производится проверка ключей.
Каждый ключ обязан быть строкой.
Например,
допустимо:
```json
{
"a": 1
}
```
недопустимо:
```python
{
1: "value"
}
```
Подобная ситуация невозможна для корректного JSON, однако тест присутствует специально.
Причины:
- защита от программного формирования словаря;
- единообразие со всеми остальными Schema Validator;
- гарантированный контракт Parser.
---
# Проверка обязательных полей
Для каждого Trade объекта проверяется наличие обязательных полей.
Обязательными являются:
```
a
p
q
T
m
```
Отсутствие любого поля считается ошибкой структуры документа.
Например,
```json
{
"a": 1,
"p": "10"
}
```
будет отклонён ещё на этапе Schema Validation.
---
# Дополнительные поля
Schema Validation не запрещает дополнительные поля.
Например,
```json
{
"a": 1,
"p": "100",
"q": "5",
"T": 123,
"m": false,
"exchange": "spot",
"custom": "value"
}
```
считается корректным документом.
Дополнительные поля полностью сохраняются.
Это решение принято намеренно.
Schema Layer отвечает только за минимальный контракт документа.
Любые дополнительные поля могут использоваться последующими Build без изменения существующей Schema Validation.
---
# Отсутствие проверки типов
Build принципиально не проверяет типы значений.
Следующий документ считается структурно корректным.
```python
[
{
"a": "invalid",
"p": None,
"q": [],
"T": False,
"m": 123,
}
]
```
Причина заключается в разделении ответственности Acquisition Pipeline.
Schema Validation отвечает только за структуру документа.
Проверка:
- int;
- bool;
- Decimal;
- строки;
- диапазоны значений;
- корректность timestamp;
будет выполняться исключительно Parser и Value Validation.
Это полностью соответствует архитектуре проекта.
---
# Immutable представление
После успешной проверки структура документа преобразуется в immutable-контракт.
Каждый элемент копируется в новый словарь.
После этого словарь оборачивается в
```python
MappingProxyType
```
Коллекция объектов преобразуется в
```python
tuple
```
Итоговый контракт становится полностью неизменяемым.
Это гарантирует:
- невозможность случайного изменения Parser;
- защиту между слоями Pipeline;
- детерминированность обработки;
- отсутствие побочных эффектов.
Подобная схема уже используется ExchangeInfo и Candles Schema Validation.
Trades Feed теперь полностью ей соответствует.
---
# Поведение при пустом ответе
REST endpoint может вернуть пустой массив.
Например,
```json
[]
```
Такой ответ считается полностью корректным.
После проверки возвращается
```python
ValidatedRestAggTradesDocument(
items=(),
)
```
Пустой документ не считается ошибкой.
Это позволяет корректно обрабатывать участки истории, где сделки отсутствуют.
---
# Unit-тесты
Для новой функциональности добавлен отдельный набор тестов.
Проверяются:
- корректный REST-документ;
- пустой массив;
- сохранение дополнительных полей;
- отсутствие проверки типов значений;
- отклонение некорректного корня документа;
- отклонение элементов, не являющихся JSON-объектами;
- отсутствие обязательных полей;
- нестроковые ключи.
Таким образом фиксируется полный публичный контракт нового Schema Validator.
Любое изменение поведения в будущем будет обнаружено автоматически.
---
# Результаты Build
После завершения реализации были выполнены проверки проекта.
Schema Tests
```
38 passed
```
Полный набор unit-тестов
```
1011 passed
```
Дополнительно успешно выполнены
```
python -m compileall src
```
и
```
git diff --check
```
Ошибок форматирования обнаружено не было.
---
# Изменённые файлы
В рамках Build изменены только три файла.
```
src/market_data/acquisition/exceptions.py
```
Добавлено исключение
```
TradeSchemaError
```
---
```
src/market_data/acquisition/validation/schema.py
```
Добавлены:
- ValidatedRestAggTradesDocument;
- validate_rest_agg_trades_schema();
- вспомогательные функции проверки структуры.
---
```
tests/unit/market_data/acquisition/validation/test_schema.py
```
Добавлен полный набор unit-тестов нового Schema Validator.
Никакие другие части проекта не изменялись.
---
# Итог
Build 060.3 завершает формирование первого уровня REST Trades Acquisition Pipeline.
После его завершения система получила полноценный слой структурной проверки документов.
Pipeline теперь имеет следующий вид.
```
REST JSON
Schema Validation
ValidatedRestAggTradesDocument
REST Trade Parser
DzengiRestAggTrade
Value Validation
Canonical Trade
```
Следующим этапом серии станет **Build 060.4 — REST Trade Parser**, который будет отвечать за преобразование структурно проверенного документа в transport-модель `DzengiRestAggTrade`, не нарушая границ ответственности, установленных данным Build.