diff --git a/app/src/market_data/acquisition/adapters/dzengi/parser.py b/app/src/market_data/acquisition/adapters/dzengi/parser.py index c2ef7fd..f144f02 100644 --- a/app/src/market_data/acquisition/adapters/dzengi/parser.py +++ b/app/src/market_data/acquisition/adapters/dzengi/parser.py @@ -19,10 +19,12 @@ from src.market_data.acquisition.adapters.dzengi.models import ( DzengiRawNumeric, DzengiUnknownFilter, DzengiTicker24hrResponse, + DzengiWebSocketOhlcEvent, DzengiWebSocketQuoteResponse, ) from src.market_data.acquisition.exceptions import ( CandleParseError, + CandleWebSocketParseError, InstrumentReferenceParseError, QuoteParseError, ) @@ -30,6 +32,7 @@ from src.market_data.acquisition.validation.schema import ( ValidatedCandlesDocument, ValidatedExchangeInfoDocument, ValidatedQuoteDocument, + ValidatedWebSocketOhlcDocument, ValidatedWebSocketQuoteDocument, ) @@ -719,6 +722,101 @@ def _websocket_optional_timestamp( return _quote_required_int(value, path=path) + +# Преобразовать структурно проверенное событие WebSocket OHLC +# в транспортную модель адаптера Dzengi. +def parse_dzengi_websocket_ohlc( + document: ValidatedWebSocketOhlcDocument, +) -> DzengiWebSocketOhlcEvent: + """ + Преобразовать проверенный payload события ``ohlc.event`` + в transport-модель Dzengi. + + Функция не выполняет предметную проверку значений, не проверяет + допустимость интервала или типа свечи, не преобразует timestamp + в datetime и не выполняет mapping во внутреннюю модель Dzentra. + """ + + payload = document.payload + + return DzengiWebSocketOhlcEvent( + symbol=_websocket_ohlc_required_string( + payload.get("symbol"), + path="$.payload.symbol", + ), + interval=_websocket_ohlc_required_string( + payload.get("interval"), + path="$.payload.interval", + ), + candle_type=_websocket_ohlc_required_string( + payload.get("type"), + path="$.payload.type", + ), + open_time=_websocket_ohlc_required_int( + payload.get("t"), + path="$.payload.t", + ), + open_price=_websocket_ohlc_required_raw_numeric( + payload.get("o"), + path="$.payload.o", + ), + high_price=_websocket_ohlc_required_raw_numeric( + payload.get("h"), + path="$.payload.h", + ), + low_price=_websocket_ohlc_required_raw_numeric( + payload.get("l"), + path="$.payload.l", + ), + close_price=_websocket_ohlc_required_raw_numeric( + payload.get("c"), + path="$.payload.c", + ), + ) + + +def _websocket_ohlc_required_string( + value: object, + *, + path: str, +) -> str: + if not isinstance(value, str): + raise CandleWebSocketParseError( + f"{path} должен быть строкой, " + f"получен {type(value).__name__}." + ) + + return value + + +def _websocket_ohlc_required_int( + value: object, + *, + path: str, +) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise CandleWebSocketParseError( + f"{path} должен быть целым числом, " + f"получен {type(value).__name__}." + ) + + return value + + +def _websocket_ohlc_required_raw_numeric( + value: object, + *, + path: str, +) -> DzengiRawNumeric: + if isinstance(value, bool) or not isinstance(value, (str, int, float)): + raise CandleWebSocketParseError( + f"{path} должен быть строкой или числом, " + f"получен {type(value).__name__}." + ) + + return value + + # Преобразовать структурно проверенный ответ klines в raw-модели Dzengi. def parse_candles( document: ValidatedCandlesDocument, diff --git a/app/src/market_data/acquisition/exceptions.py b/app/src/market_data/acquisition/exceptions.py index 68d8b7f..d962093 100644 --- a/app/src/market_data/acquisition/exceptions.py +++ b/app/src/market_data/acquisition/exceptions.py @@ -83,6 +83,11 @@ class CandleWebSocketSchemaError(MarketDataAcquisitionError): pass +# Ошибка преобразования проверенного WebSocket OHLC в raw-модель адаптера. +class CandleWebSocketParseError(MarketDataAcquisitionError): + pass + + # Ошибка преобразования проверенного документа в raw-модели свечей. class CandleParseError(MarketDataAcquisitionError): pass diff --git a/app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py b/app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py new file mode 100644 index 0000000..c0ca122 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py @@ -0,0 +1,200 @@ +# app/tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py + +from __future__ import annotations + +from types import MappingProxyType + +import pytest + +from src.market_data.acquisition.adapters.dzengi.models import ( + DzengiWebSocketOhlcEvent, +) +from src.market_data.acquisition.adapters.dzengi.parser import ( + parse_dzengi_websocket_ohlc, +) +from src.market_data.acquisition.exceptions import ( + CandleWebSocketParseError, +) +from src.market_data.acquisition.validation.schema import ( + ValidatedWebSocketOhlcDocument, +) + + +def _validated_document( + **payload_overrides: object, +) -> ValidatedWebSocketOhlcDocument: + payload: dict[str, object] = { + "symbol": "BTC/USD_LEVERAGE", + "interval": "1m", + "type": "classic", + "t": 1784224740000, + "o": 63992.0, + "h": 64032.55, + "l": 63984.0, + "c": 64032.55, + } + payload.update(payload_overrides) + + return ValidatedWebSocketOhlcDocument( + payload=MappingProxyType(payload), + status="OK", + destination="ohlc.event", + correlation_id=None, + ) + + +def test_parse_websocket_ohlc_returns_transport_model() -> None: + result = parse_dzengi_websocket_ohlc( + _validated_document() + ) + + assert result == DzengiWebSocketOhlcEvent( + symbol="BTC/USD_LEVERAGE", + interval="1m", + candle_type="classic", + open_time=1784224740000, + open_price=63992.0, + high_price=64032.55, + low_price=63984.0, + close_price=64032.55, + ) + + +def test_parse_websocket_ohlc_preserves_heikin_ashi_type() -> None: + result = parse_dzengi_websocket_ohlc( + _validated_document(type="heikin-ashi") + ) + + assert result.candle_type == "heikin-ashi" + + +def test_parse_websocket_ohlc_preserves_raw_numeric_types() -> None: + result = parse_dzengi_websocket_ohlc( + _validated_document( + o="63992.00", + h=64033, + l=63984.0, + c="64032.55", + ) + ) + + assert result.open_price == "63992.00" + assert result.high_price == 64033 + assert result.low_price == 63984.0 + assert result.close_price == "64032.55" + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("symbol", None), + ("symbol", 123), + ("interval", None), + ("interval", []), + ("type", None), + ("type", True), + ], +) +def test_parse_websocket_ohlc_rejects_non_string_fields( + field_name: str, + value: object, +) -> None: + with pytest.raises( + CandleWebSocketParseError, + match=rf"\$\.payload\.{field_name} должен быть строкой", + ): + parse_dzengi_websocket_ohlc( + _validated_document(**{field_name: value}) + ) + + +@pytest.mark.parametrize( + "value", + [ + None, + True, + 1784224740000.0, + "1784224740000", + [], + ], +) +def test_parse_websocket_ohlc_rejects_invalid_open_time( + value: object, +) -> None: + with pytest.raises( + CandleWebSocketParseError, + match=r"\$\.payload\.t должен быть целым числом", + ): + parse_dzengi_websocket_ohlc( + _validated_document(t=value) + ) + + +@pytest.mark.parametrize( + "field_name", + [ + "o", + "h", + "l", + "c", + ], +) +@pytest.mark.parametrize( + "value", + [ + None, + True, + [], + {}, + object(), + ], +) +def test_parse_websocket_ohlc_rejects_invalid_raw_numeric( + field_name: str, + value: object, +) -> None: + with pytest.raises( + CandleWebSocketParseError, + match=rf"\$\.payload\.{field_name} должен быть строкой или числом", + ): + parse_dzengi_websocket_ohlc( + _validated_document(**{field_name: value}) + ) + + +def test_parse_websocket_ohlc_does_not_validate_subject_values() -> None: + result = parse_dzengi_websocket_ohlc( + _validated_document( + symbol="", + interval="unsupported", + type="unknown", + t=-1, + o="NaN", + h=-10, + l=100, + c=0, + ) + ) + + assert result.symbol == "" + assert result.interval == "unsupported" + assert result.candle_type == "unknown" + assert result.open_time == -1 + assert result.open_price == "NaN" + assert result.high_price == -10 + assert result.low_price == 100 + assert result.close_price == 0 + + +def test_parse_websocket_ohlc_ignores_envelope_metadata() -> None: + document = ValidatedWebSocketOhlcDocument( + payload=_validated_document().payload, + status=123, + destination=None, + correlation_id={"unexpected": "value"}, + ) + + result = parse_dzengi_websocket_ohlc(document) + + assert result.symbol == "BTC/USD_LEVERAGE" + assert result.interval == "1m" \ No newline at end of file diff --git a/docs/migrations/build_059_3.md b/docs/migrations/build_059_3.md new file mode 100644 index 0000000..2af3f4f --- /dev/null +++ b/docs/migrations/build_059_3.md @@ -0,0 +1,593 @@ +# Build 059.3 — WebSocket OHLC Parser + +**Проект:** Dzentra +**Подсистема:** Market Data Acquisition +**Этап:** 059.3 +**Статус:** Completed + +--- + +# Цель + +Добавить parser для структурно проверенного события Dzengi WebSocket OHLC. + +Parser должен преобразовывать: + +```text +ValidatedWebSocketOhlcDocument +``` + +в: + +```text +DzengiWebSocketOhlcEvent +``` + +При этом parser не должен выполнять: + +- предметную проверку значений; +- проверку поддерживаемых интервалов; +- проверку допустимого типа свечи; +- преобразование timestamp в `datetime`; +- преобразование цен в `Decimal`; +- проверку OHLC-инвариантов; +- mapping во внутреннюю модель Dzentra. + +--- + +# Причина изменения + +После Build 059.2 проект умеет проверять структуру сообщения Dzengi WebSocket: + +```json +{ + "status": "OK", + "destination": "ohlc.event", + "payload": { + "symbol": "BTC/USD_LEVERAGE", + "interval": "1m", + "type": "classic", + "t": 1784224740000, + "o": 63992.0, + "h": 64032.55, + "l": 63984.0, + "c": 64032.55 + } +} +``` + +Однако структурно проверенный документ ещё не преобразовывался в transport-модель адаптера Dzengi. + +Build 059.3 добавляет отдельный parsing-слой между schema validation и value validation. + +--- + +# Реализованные изменения + +## Parser + +В файл: + +```text +src/market_data/acquisition/adapters/dzengi/parser.py +``` + +добавлена функция: + +```text +parse_dzengi_websocket_ohlc() +``` + +Она принимает: + +```text +ValidatedWebSocketOhlcDocument +``` + +и возвращает: + +```text +DzengiWebSocketOhlcEvent +``` + +--- + +## Исключение parser-слоя + +В файл: + +```text +src/market_data/acquisition/exceptions.py +``` + +добавлено исключение: + +```text +CandleWebSocketParseError +``` + +Оно используется только при невозможности преобразовать структурно проверенный WebSocket OHLC-документ в transport-модель адаптера. + +--- + +## Unit-тесты + +Создан файл: + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py +``` + +Он покрывает успешное преобразование и ошибки транспортных типов. + +--- + +# Соответствие полей + +Parser выполняет следующее преобразование: + +| WebSocket payload | Transport model | +|---|---| +| `symbol` | `symbol` | +| `interval` | `interval` | +| `type` | `candle_type` | +| `t` | `open_time` | +| `o` | `open_price` | +| `h` | `high_price` | +| `l` | `low_price` | +| `c` | `close_price` | + +Parser не переименовывает и не интерпретирует значения предметно. Он только переносит их в явную transport-модель адаптера. + +--- + +# Проверка базовых транспортных типов + +## Строковые поля + +Следующие поля обязаны быть строками: + +```text +symbol +interval +type +``` + +Если одно из них не является строкой, выбрасывается: + +```text +CandleWebSocketParseError +``` + +Пример ошибки: + +```text +$.payload.symbol должен быть строкой +``` + +--- + +## Timestamp + +Поле: + +```text +t +``` + +должно быть целым числом Python: + +```text +int +``` + +Не принимаются: + +```text +None +bool +float +str +list +``` + +`bool` отклоняется отдельно, несмотря на то что в Python он является подклассом `int`. + +Parser не проверяет, является ли timestamp положительным или реалистичным. Это относится к Value Validation. + +--- + +## Поля OHLC + +Поля: + +```text +o +h +l +c +``` + +могут быть представлены как: + +```text +str +int +float +``` + +Это соответствует существующему транспортному типу: + +```text +DzengiRawNumeric +``` + +Такой контракт необходим, поскольку внешнее API может передавать числовые значения как JSON-числа или строки. + +Не принимаются: + +```text +None +bool +list +dict +object +``` + +--- + +# Сохранение исходных числовых типов + +Parser не преобразует цены. + +Например, документ: + +```json +{ + "o": "63992.00", + "h": 64033, + "l": 63984.0, + "c": "64032.55" +} +``` + +преобразуется в transport-модель с теми же исходными типами: + +```text +open_price = str +high_price = int +low_price = float +close_price = str +``` + +Преобразование в `Decimal` будет выполняться только на mapping-этапе. + +--- + +# Поддержка типов свечей + +Parser сохраняет значение поля: + +```text +type +``` + +в поле transport-модели: + +```text +candle_type +``` + +Поддерживаются на уровне базового транспортного типа любые строки. + +Например: + +```text +classic +heikin-ashi +unknown +``` + +Parser не определяет допустимость значения. + +Проверка, что значение входит в подтверждённый runtime-набор: + +```text +classic +heikin-ashi +``` + +будет реализована в Build 059.4 — Value Validation. + +--- + +# Отсутствие предметной валидации + +Parser намеренно пропускает значения, которые имеют допустимый транспортный тип, но являются предметно некорректными. + +Пример: + +```text +symbol = "" +interval = "unsupported" +candle_type = "unknown" +open_time = -1 +open_price = "NaN" +high_price = -10 +low_price = 100 +close_price = 0 +``` + +Такой документ успешно преобразуется в `DzengiWebSocketOhlcEvent`. + +Это ожидаемое поведение. + +Следующий слой должен проверить: + +- непустой символ; +- допустимый интервал; +- допустимый тип свечи; +- положительный timestamp; +- конечность чисел; +- положительность цен; +- OHLC-инварианты. + +--- + +# Игнорирование metadata envelope + +Parser использует только: + +```text +document.payload +``` + +Поля внешнего envelope: + +```text +status +destination +correlation_id +``` + +не участвуют в создании `DzengiWebSocketOhlcEvent`. + +Это обеспечивает разделение ответственности: + +```text +Schema Validation + ↓ +проверяет структуру envelope и payload + +Parser + ↓ +преобразует payload в transport-модель + +Value Validation + ↓ +проверяет предметную допустимость значений +``` + +--- + +# Новый конвейер + +После Build 059.3 подготовленная часть конвейера выглядит так: + +```text +Raw WebSocket message + ↓ +validate_dzengi_websocket_ohlc_schema() + ↓ +ValidatedWebSocketOhlcDocument + ↓ +parse_dzengi_websocket_ohlc() + ↓ +DzengiWebSocketOhlcEvent + ↓ +Value Validation + ↓ +Internal Close Event + ↓ +REST reconciliation + ↓ +Canonical Candle +``` + +На текущем этапе реализована часть до `DzengiWebSocketOhlcEvent` включительно. + +--- + +# Unit Tests + +Добавлен файл: + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py +``` + +Проверяются следующие сценарии: + +- успешное создание transport-модели; +- корректное соответствие всех полей; +- сохранение типа `classic`; +- сохранение типа `heikin-ashi`; +- сохранение исходных числовых типов; +- отклонение нестрокового `symbol`; +- отклонение нестрокового `interval`; +- отклонение нестрокового `type`; +- отклонение `None` в timestamp; +- отклонение `bool` в timestamp; +- отклонение `float` в timestamp; +- отклонение строкового timestamp; +- отклонение коллекции вместо timestamp; +- отклонение некорректных типов для `o`; +- отклонение некорректных типов для `h`; +- отклонение некорректных типов для `l`; +- отклонение некорректных типов для `c`; +- отсутствие предметной валидации; +- игнорирование metadata внешнего envelope. + +Всего выполнено: + +```text +36 tests +``` + +--- + +# Проверка синтаксиса + +Выполнена команда: + +```bash +python -m compileall \ + src/market_data/acquisition/exceptions.py \ + src/market_data/acquisition/adapters/dzengi/parser.py \ + tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py +``` + +Результат: + +```text +успешно +``` + +--- + +# Целевые тесты + +Выполнена команда: + +```bash +python -m pytest -q \ + tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py +``` + +Результат: + +```text +36 passed in 0.03s +``` + +--- + +# Регрессия parser-слоя + +Выполнена команда: + +```bash +python -m pytest -q \ + tests/unit/market_data/acquisition/adapters/dzengi/test_parser.py \ + tests/unit/market_data/acquisition/adapters/dzengi/test_quote_parser.py \ + tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_quote_parser.py \ + tests/unit/market_data/acquisition/adapters/dzengi/test_candle_parser.py \ + tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py +``` + +Результат: + +```text +80 passed in 0.05s +``` + +--- + +# Проверка форматирования + +Выполнена команда: + +```bash +git diff --check +``` + +Ошибок форматирования не обнаружено. + +--- + +# Изменённые файлы + +```text +src/market_data/acquisition/adapters/dzengi/parser.py +src/market_data/acquisition/exceptions.py +``` + +Создан файл: + +```text +tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_ohlc_parser.py +``` + +Документация этапа: + +```text +docs/migrations/build_059_3.md +``` + +--- + +# Совместимость + +Изменение полностью обратно совместимо. + +Не изменены: + +- REST Candles Feed; +- REST parser свечей; +- Quotes Feed; +- WebSocket Quote parser; +- WebSocket Quote Adapter; +- ExchangeService; +- Market Analysis; +- Trading; +- runtime. + +Новый parser пока не подключён к рабочему WebSocket runtime и не влияет на действующий бот. + +--- + +# Ограничения этапа + +Build 059.3 не выполняет: + +- проверку значения `status`; +- проверку значения `destination`; +- проверку допустимых интервалов; +- проверку допустимого типа свечи; +- проверку timestamp; +- проверку конечности OHLC; +- проверку положительности OHLC; +- проверку OHLC-инвариантов; +- преобразование в `Decimal`; +- преобразование времени в UTC `datetime`; +- создание внутреннего close event; +- REST reconciliation; +- выпуск канонической `Candle`. + +Эти обязанности остаются за последующими подпунктами Build 059. + +--- + +# Итог + +Build 059.3 завершает parsing-слой Dzengi WebSocket OHLC. + +Проект теперь умеет преобразовывать структурно проверенный документ: + +```text +ValidatedWebSocketOhlcDocument +``` + +в transport-модель: + +```text +DzengiWebSocketOhlcEvent +``` + +без смешивания parsing, value validation и domain mapping. + +Следующий этап: + +```text +Build 059.4 — Value Validation +``` \ No newline at end of file