diff --git a/app/src/market_data/acquisition/exceptions.py b/app/src/market_data/acquisition/exceptions.py index f1ae1f9..8d037c0 100644 --- a/app/src/market_data/acquisition/exceptions.py +++ b/app/src/market_data/acquisition/exceptions.py @@ -119,6 +119,11 @@ class CandleFeedRegistryError(MarketDataAcquisitionError): pass +# Ошибка структуры документа Trades Feed. +class TradeSchemaError(MarketDataAcquisitionError): + pass + + # Ошибка определения типа входящего WebSocket-сообщения # и выбора специализированного адаптера. class WebSocketMessageRoutingError(MarketDataAcquisitionError): diff --git a/app/src/market_data/acquisition/validation/schema.py b/app/src/market_data/acquisition/validation/schema.py index 55bb080..7a1ca1c 100644 --- a/app/src/market_data/acquisition/validation/schema.py +++ b/app/src/market_data/acquisition/validation/schema.py @@ -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: diff --git a/app/tests/unit/market_data/acquisition/validation/test_schema.py b/app/tests/unit/market_data/acquisition/validation/test_schema.py index 7a7d01c..f95e804 100644 --- a/app/tests/unit/market_data/acquisition/validation/test_schema.py +++ b/app/tests/unit/market_data/acquisition/validation/test_schema.py @@ -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) \ No newline at end of file + 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) diff --git a/docs/migrations/build_060_3.md b/docs/migrations/build_060_3.md new file mode 100644 index 0000000..c4690f6 --- /dev/null +++ b/docs/migrations/build_060_3.md @@ -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. +