diff --git a/app/src/market_data/acquisition/exceptions.py b/app/src/market_data/acquisition/exceptions.py index d962093..924bbac 100644 --- a/app/src/market_data/acquisition/exceptions.py +++ b/app/src/market_data/acquisition/exceptions.py @@ -88,6 +88,11 @@ class CandleWebSocketParseError(MarketDataAcquisitionError): pass +# Ошибка допустимости значений WebSocket OHLC-события. +class CandleWebSocketValueError(MarketDataAcquisitionError): + pass + + # Ошибка преобразования проверенного документа в raw-модели свечей. class CandleParseError(MarketDataAcquisitionError): pass diff --git a/app/src/market_data/acquisition/validation/values.py b/app/src/market_data/acquisition/validation/values.py index f1ddd00..9c7e30e 100644 --- a/app/src/market_data/acquisition/validation/values.py +++ b/app/src/market_data/acquisition/validation/values.py @@ -15,15 +15,34 @@ from src.market_data.acquisition.adapters.dzengi.models import ( DzengiUnknownFilter, DzengiKlinesResponse, DzengiTicker24hrResponse, + DzengiWebSocketOhlcEvent, DzengiWebSocketQuoteResponse, ) from src.market_data.acquisition.exceptions import ( CandleValueError, + CandleWebSocketValueError, InstrumentReferenceValueError, QuoteValueError, ) +_SUPPORTED_WEBSOCKET_OHLC_INTERVALS = frozenset( + { + "1m", + "5m", + "15m", + "1h", + } +) + +_SUPPORTED_WEBSOCKET_OHLC_TYPES = frozenset( + { + "classic", + "heikin-ashi", + } +) + + def validate_exchange_info_values( response: DzengiExchangeInfoResponse, ) -> None: @@ -553,6 +572,126 @@ def validate_dzengi_websocket_quote_values( "$.payload.timestamp должно быть больше нуля." ) + +def validate_dzengi_websocket_ohlc_values( + event: DzengiWebSocketOhlcEvent, +) -> None: + """ + Проверить предметную допустимость значений Dzengi WebSocket OHLC. + + Функция не изменяет transport-модель, не преобразует timestamp + в datetime и не выполняет mapping во внутреннюю модель Dzentra. + """ + + if not event.symbol.strip(): + raise CandleWebSocketValueError( + "$.payload.symbol не должен быть пустым." + ) + + if event.interval not in _SUPPORTED_WEBSOCKET_OHLC_INTERVALS: + supported = ", ".join( + sorted(_SUPPORTED_WEBSOCKET_OHLC_INTERVALS) + ) + raise CandleWebSocketValueError( + "$.payload.interval содержит неподдерживаемый интервал " + f"'{event.interval}'. Поддерживаются: {supported}." + ) + + if event.candle_type not in _SUPPORTED_WEBSOCKET_OHLC_TYPES: + supported = ", ".join( + sorted(_SUPPORTED_WEBSOCKET_OHLC_TYPES) + ) + raise CandleWebSocketValueError( + "$.payload.type содержит неподдерживаемый тип свечи " + f"'{event.candle_type}'. Поддерживаются: {supported}." + ) + + if event.open_time <= 0: + raise CandleWebSocketValueError( + "$.payload.t должно быть целым числом больше нуля." + ) + + open_price = _websocket_ohlc_positive_decimal( + event.open_price, + path="$.payload.o", + ) + high_price = _websocket_ohlc_positive_decimal( + event.high_price, + path="$.payload.h", + ) + low_price = _websocket_ohlc_positive_decimal( + event.low_price, + path="$.payload.l", + ) + close_price = _websocket_ohlc_positive_decimal( + event.close_price, + path="$.payload.c", + ) + + if high_price < low_price: + raise CandleWebSocketValueError( + "$.payload.h не должно быть меньше $.payload.l." + ) + + if high_price < open_price: + raise CandleWebSocketValueError( + "$.payload.h не должно быть меньше $.payload.o." + ) + + if high_price < close_price: + raise CandleWebSocketValueError( + "$.payload.h не должно быть меньше $.payload.c." + ) + + if low_price > open_price: + raise CandleWebSocketValueError( + "$.payload.l не должно превышать $.payload.o." + ) + + if low_price > close_price: + raise CandleWebSocketValueError( + "$.payload.l не должно превышать $.payload.c." + ) + + +def _websocket_ohlc_positive_decimal( + value: DzengiRawNumeric, + *, + path: str, +) -> Decimal: + decimal_value = _websocket_ohlc_decimal( + value, + path=path, + ) + + if decimal_value <= 0: + raise CandleWebSocketValueError( + f"{path} должно быть больше нуля." + ) + + return decimal_value + + +def _websocket_ohlc_decimal( + value: DzengiRawNumeric, + *, + path: str, +) -> Decimal: + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise CandleWebSocketValueError( + f"{path} должно быть корректным числом." + ) from exc + + if not decimal_value.is_finite(): + raise CandleWebSocketValueError( + f"{path} должно быть конечным числом." + ) + + return decimal_value + + def validate_candles_values( response: DzengiKlinesResponse, ) -> None: diff --git a/app/tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py b/app/tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py new file mode 100644 index 0000000..a004bc2 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py @@ -0,0 +1,387 @@ +# app/tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py + +from __future__ import annotations + +import pytest + +from src.market_data.acquisition.adapters.dzengi.models import ( + DzengiWebSocketOhlcEvent, +) +from src.market_data.acquisition.exceptions import ( + CandleWebSocketValueError, +) +from src.market_data.acquisition.validation.values import ( + validate_dzengi_websocket_ohlc_values, +) + + +def _event( + **overrides: object, +) -> DzengiWebSocketOhlcEvent: + values: dict[str, object] = { + "symbol": "BTC/USD_LEVERAGE", + "interval": "1m", + "candle_type": "classic", + "open_time": 1784224740000, + "open_price": "63992.00", + "high_price": "64032.55", + "low_price": "63984.00", + "close_price": "64032.55", + } + values.update(overrides) + + return DzengiWebSocketOhlcEvent( + symbol=values["symbol"], # type: ignore[arg-type] + interval=values["interval"], # type: ignore[arg-type] + candle_type=values["candle_type"], # type: ignore[arg-type] + open_time=values["open_time"], # type: ignore[arg-type] + open_price=values["open_price"], # type: ignore[arg-type] + high_price=values["high_price"], # type: ignore[arg-type] + low_price=values["low_price"], # type: ignore[arg-type] + close_price=values["close_price"], # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "interval", + [ + "1m", + "5m", + "15m", + "1h", + ], +) +def test_validate_websocket_ohlc_values_accepts_supported_intervals( + interval: str, +) -> None: + validate_dzengi_websocket_ohlc_values( + _event(interval=interval) + ) + + +@pytest.mark.parametrize( + "candle_type", + [ + "classic", + "heikin-ashi", + ], +) +def test_validate_websocket_ohlc_values_accepts_supported_types( + candle_type: str, +) -> None: + validate_dzengi_websocket_ohlc_values( + _event(candle_type=candle_type) + ) + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("open_price", "63992.00"), + ("open_price", 63992), + ("open_price", 63992.0), + ("high_price", "64032.55"), + ("low_price", 63984), + ("close_price", 64032.55), + ], +) +def test_validate_websocket_ohlc_values_accepts_numeric_representations( + field_name: str, + value: object, +) -> None: + validate_dzengi_websocket_ohlc_values( + _event(**{field_name: value}) + ) + + +@pytest.mark.parametrize( + "symbol", + [ + "", + " ", + "\t", + "\n", + ], +) +def test_validate_websocket_ohlc_values_rejects_empty_symbol( + symbol: str, +) -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.symbol не должен быть пустым", + ): + validate_dzengi_websocket_ohlc_values( + _event(symbol=symbol) + ) + + +@pytest.mark.parametrize( + "interval", + [ + "", + "1s", + "4h", + "1M", + " 1m ", + ], +) +def test_validate_websocket_ohlc_values_rejects_unsupported_interval( + interval: str, +) -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.interval содержит неподдерживаемый интервал", + ): + validate_dzengi_websocket_ohlc_values( + _event(interval=interval) + ) + + +@pytest.mark.parametrize( + "candle_type", + [ + "", + "bid", + "ask", + "Classic", + "unknown", + " classic ", + ], +) +def test_validate_websocket_ohlc_values_rejects_unsupported_type( + candle_type: str, +) -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.type содержит неподдерживаемый тип свечи", + ): + validate_dzengi_websocket_ohlc_values( + _event(candle_type=candle_type) + ) + + +@pytest.mark.parametrize( + "open_time", + [ + 0, + -1, + -1784224740000, + ], +) +def test_validate_websocket_ohlc_values_rejects_non_positive_open_time( + open_time: int, +) -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.t должно быть целым числом больше нуля", + ): + validate_dzengi_websocket_ohlc_values( + _event(open_time=open_time) + ) + + +@pytest.mark.parametrize( + "field_name", + [ + "open_price", + "high_price", + "low_price", + "close_price", + ], +) +@pytest.mark.parametrize( + "value", + [ + 0, + -1, + "-0.01", + ], +) +def test_validate_websocket_ohlc_values_rejects_non_positive_prices( + field_name: str, + value: object, +) -> None: + path_by_field = { + "open_price": "o", + "high_price": "h", + "low_price": "l", + "close_price": "c", + } + + with pytest.raises( + CandleWebSocketValueError, + match=( + rf"\$\.payload\.{path_by_field[field_name]} " + r"должно быть больше нуля" + ), + ): + validate_dzengi_websocket_ohlc_values( + _event(**{field_name: value}) + ) + + +@pytest.mark.parametrize( + "field_name", + [ + "open_price", + "high_price", + "low_price", + "close_price", + ], +) +@pytest.mark.parametrize( + "value", + [ + "NaN", + "Infinity", + "-Infinity", + float("nan"), + float("inf"), + float("-inf"), + ], +) +def test_validate_websocket_ohlc_values_rejects_non_finite_prices( + field_name: str, + value: object, +) -> None: + path_by_field = { + "open_price": "o", + "high_price": "h", + "low_price": "l", + "close_price": "c", + } + + with pytest.raises( + CandleWebSocketValueError, + match=( + rf"\$\.payload\.{path_by_field[field_name]} " + r"должно быть конечным числом" + ), + ): + validate_dzengi_websocket_ohlc_values( + _event(**{field_name: value}) + ) + + +@pytest.mark.parametrize( + "field_name", + [ + "open_price", + "high_price", + "low_price", + "close_price", + ], +) +@pytest.mark.parametrize( + "value", + [ + "", + "invalid", + "--1", + ], +) +def test_validate_websocket_ohlc_values_rejects_invalid_numeric_strings( + field_name: str, + value: str, +) -> None: + path_by_field = { + "open_price": "o", + "high_price": "h", + "low_price": "l", + "close_price": "c", + } + + with pytest.raises( + CandleWebSocketValueError, + match=( + rf"\$\.payload\.{path_by_field[field_name]} " + r"должно быть корректным числом" + ), + ): + validate_dzengi_websocket_ohlc_values( + _event(**{field_name: value}) + ) + + +def test_validate_websocket_ohlc_values_rejects_high_below_low() -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.h не должно быть меньше \$\.payload\.l", + ): + validate_dzengi_websocket_ohlc_values( + _event( + high_price="63980", + low_price="63984", + ) + ) + + +def test_validate_websocket_ohlc_values_rejects_high_below_open() -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.h не должно быть меньше \$\.payload\.o", + ): + validate_dzengi_websocket_ohlc_values( + _event( + open_price="64000", + high_price="63999", + low_price="63980", + close_price="63990", + ) + ) + + +def test_validate_websocket_ohlc_values_rejects_high_below_close() -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.h не должно быть меньше \$\.payload\.c", + ): + validate_dzengi_websocket_ohlc_values( + _event( + open_price="63990", + high_price="64000", + low_price="63980", + close_price="64001", + ) + ) + + +def test_validate_websocket_ohlc_values_rejects_low_above_open() -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.l не должно превышать \$\.payload\.o", + ): + validate_dzengi_websocket_ohlc_values( + _event( + open_price="63990", + high_price="64020", + low_price="64000", + close_price="64010", + ) + ) + + +def test_validate_websocket_ohlc_values_rejects_low_above_close() -> None: + with pytest.raises( + CandleWebSocketValueError, + match=r"\$\.payload\.l не должно превышать \$\.payload\.c", + ): + validate_dzengi_websocket_ohlc_values( + _event( + open_price="64010", + high_price="64020", + low_price="64000", + close_price="63999", + ) + ) + + +def test_validate_websocket_ohlc_values_accepts_flat_candle() -> None: + validate_dzengi_websocket_ohlc_values( + _event( + open_price="64000", + high_price="64000", + low_price="64000", + close_price="64000", + ) + ) \ No newline at end of file diff --git a/docs/migrations/build_059_4.md b/docs/migrations/build_059_4.md new file mode 100644 index 0000000..810fec0 --- /dev/null +++ b/docs/migrations/build_059_4.md @@ -0,0 +1,704 @@ +# Build 059.4 — WebSocket OHLC Value Validation + +**Проект:** Dzentra +**Подсистема:** Market Data Acquisition +**Этап:** 059.4 +**Статус:** Completed + +--- + +# Цель + +Добавить предметную проверку значений транспортной модели: + +```text +DzengiWebSocketOhlcEvent +``` + +После выполнения Build 059.3 проект умеет преобразовывать структурно проверенный WebSocket-документ в transport-модель адаптера. + +Build 059.4 проверяет, что значения этой модели допустимы для дальнейшей обработки. + +На данном этапе не выполняются: + +- преобразование timestamp в `datetime`; +- преобразование цен в `Decimal` для внутренней модели; +- создание внутреннего события закрытия свечи; +- mapping; +- REST reconciliation; +- создание канонической `Candle`; +- runtime-интеграция. + +--- + +# Причина изменения + +Parser проверяет только базовые транспортные типы. + +Например, он допускает следующие значения: + +```text +symbol = "" +interval = "unsupported" +candle_type = "unknown" +open_time = -1 +open_price = "NaN" +high_price = -10 +low_price = 100 +close_price = 0 +``` + +Они корректны с точки зрения базовых Python-типов, но недопустимы с точки зрения предметного контракта OHLC. + +Поэтому необходим отдельный слой Value Validation. + +--- + +# Реализованные изменения + +Изменён файл: + +```text +src/market_data/acquisition/validation/values.py +``` + +Добавлена функция: + +```text +validate_dzengi_websocket_ohlc_values() +``` + +Она принимает: + +```text +DzengiWebSocketOhlcEvent +``` + +и проверяет предметную допустимость всех его полей. + +--- + +# Новое исключение + +В файл: + +```text +src/market_data/acquisition/exceptions.py +``` + +добавлено специализированное исключение: + +```text +CandleWebSocketValueError +``` + +Оно используется только для ошибок значений WebSocket OHLC-событий. + +Ошибки WebSocket OHLC теперь разделены по слоям: + +```text +CandleWebSocketSchemaError +CandleWebSocketParseError +CandleWebSocketValueError +``` + +Это сохраняет явное разделение ответственности: + +```text +Schema Validation + ↓ +проверка структуры JSON + +Parser + ↓ +проверка транспортных типов + +Value Validation + ↓ +проверка предметной допустимости +``` + +--- + +# Поддерживаемые интервалы + +В Value Validation зафиксирован набор интервалов, который уже поддерживается действующим Candles Feed: + +```text +1m +5m +15m +1h +``` + +Допустимые значения хранятся в immutable-наборе: + +```text +_SUPPORTED_WEBSOCKET_OHLC_INTERVALS +``` + +Значения сравниваются без автоматической нормализации. + +Поэтому отклоняются: + +```text +"" +"1s" +"4h" +"1M" +" 1m " +``` + +Это преднамеренное поведение. + +Parser и validator не должны незаметно исправлять внешний контракт. + +--- + +# Поддерживаемые типы свечей + +По результатам runtime-исследования Build 058 подтверждены два допустимых значения: + +```text +classic +heikin-ashi +``` + +Они зафиксированы в immutable-наборе: + +```text +_SUPPORTED_WEBSOCKET_OHLC_TYPES +``` + +Отклоняются: + +```text +"" +"bid" +"ask" +"Classic" +"unknown" +" classic " +``` + +REST-параметр: + +```text +priceType = bid | ask +``` + +не является WebSocket-параметром типа свечи. + +WebSocket использует: + +```text +type = classic | heikin-ashi +``` + +--- + +# Проверка символа + +Поле: + +```text +symbol +``` + +должно содержать хотя бы один непробельный символ. + +Отклоняются: + +```text +"" +" " +"\t" +"\n" +``` + +Value Validation не выполняет: + +- нормализацию символа; +- проверку существования инструмента; +- преобразование alias; +- обращение к Instrument Registry. + +На этом слое проверяется только непустое значение. + +--- + +# Проверка timestamp + +Поле: + +```text +open_time +``` + +должно быть больше нуля. + +Отклоняются: + +```text +0 +-1 +-1784224740000 +``` + +Проверка базового типа `int` уже была выполнена parser-слоем в Build 059.3. + +Value Validation не преобразует timestamp в `datetime`. + +--- + +# Проверка OHLC + +Поля: + +```text +open_price +high_price +low_price +close_price +``` + +принимают транспортный тип: + +```text +DzengiRawNumeric +``` + +То есть могут быть представлены как: + +```text +str +int +float +``` + +На этапе Value Validation каждое значение временно преобразуется в `Decimal` только для проверки. + +Transport-модель при этом не изменяется. + +--- + +# Проверка корректности числовых значений + +Каждое поле OHLC должно быть корректным числом. + +Отклоняются: + +```text +"" +"invalid" +"--1" +``` + +Для таких значений выбрасывается: + +```text +CandleWebSocketValueError +``` + +с указанием точного JSON-пути. + +Пример: + +```text +$.payload.o должно быть корректным числом +``` + +--- + +# Проверка конечности + +Каждая цена должна быть конечным числом. + +Отклоняются: + +```text +"NaN" +"Infinity" +"-Infinity" +float("nan") +float("inf") +float("-inf") +``` + +Это предотвращает проникновение специальных значений в mapping и downstream-анализ. + +--- + +# Проверка положительности + +Все цены OHLC должны быть строго больше нуля. + +Отклоняются: + +```text +0 +-1 +"-0.01" +``` + +Проверка выполняется отдельно для: + +```text +$.payload.o +$.payload.h +$.payload.l +$.payload.c +``` + +--- + +# OHLC-инварианты + +После преобразования цен во временные `Decimal` проверяются стандартные инварианты свечи. + +## High не меньше Low + +Должно выполняться: + +```text +high >= low +``` + +Иначе: + +```text +$.payload.h не должно быть меньше $.payload.l +``` + +--- + +## High не меньше Open + +Должно выполняться: + +```text +high >= open +``` + +Иначе: + +```text +$.payload.h не должно быть меньше $.payload.o +``` + +--- + +## High не меньше Close + +Должно выполняться: + +```text +high >= close +``` + +Иначе: + +```text +$.payload.h не должно быть меньше $.payload.c +``` + +--- + +## Low не выше Open + +Должно выполняться: + +```text +low <= open +``` + +Иначе: + +```text +$.payload.l не должно превышать $.payload.o +``` + +--- + +## Low не выше Close + +Должно выполняться: + +```text +low <= close +``` + +Иначе: + +```text +$.payload.l не должно превышать $.payload.c +``` + +--- + +# Поддержка плоской свечи + +Допускается свеча, у которой: + +```text +open = high = low = close +``` + +Например: + +```text +64000 +64000 +64000 +64000 +``` + +Такая свеча удовлетворяет всем OHLC-инвариантам и является допустимой. + +--- + +# Отсутствие изменения transport-модели + +Value Validation использует `Decimal` только как временный тип проверки. + +Исходный объект: + +```text +DzengiWebSocketOhlcEvent +``` + +не изменяется. + +Сохраняются исходные значения и их транспортные типы: + +```text +str +int +float +``` + +Постоянное преобразование во внутренние типы будет выполнено позже на mapping-этапе. + +--- + +# Новый конвейер + +После Build 059.4 подготовленная часть конвейера выглядит так: + +```text +Raw WebSocket message + ↓ +validate_dzengi_websocket_ohlc_schema() + ↓ +ValidatedWebSocketOhlcDocument + ↓ +parse_dzengi_websocket_ohlc() + ↓ +DzengiWebSocketOhlcEvent + ↓ +validate_dzengi_websocket_ohlc_values() + ↓ +проверенная transport-модель + ↓ +Internal Close Event Model + ↓ +Mapper + ↓ +REST reconciliation + ↓ +Canonical Candle +``` + +--- + +# Unit Tests + +Создан файл: + +```text +tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py +``` + +Проверяются: + +- все поддерживаемые интервалы; +- оба поддерживаемых типа свечей; +- строковые, целые и вещественные представления цен; +- пустые символы; +- неподдерживаемые интервалы; +- неподдерживаемые типы свечей; +- неположительный timestamp; +- нулевые цены; +- отрицательные цены; +- `NaN`; +- положительная и отрицательная бесконечность; +- некорректные числовые строки; +- `high < low`; +- `high < open`; +- `high < close`; +- `low > open`; +- `low > close`; +- плоская свеча. + +Всего выполнено: + +```text +84 tests +``` + +--- + +# Проверка синтаксиса + +Выполнена команда: + +```bash +python -m compileall \ + src/market_data/acquisition/exceptions.py \ + src/market_data/acquisition/validation/values.py \ + tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py +``` + +Результат: + +```text +успешно +``` + +--- + +# Целевые тесты + +Выполнена команда: + +```bash +python -m pytest -q \ + tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py +``` + +Результат: + +```text +84 passed in 0.03s +``` + +--- + +# Регрессия value-validation слоя + +Выполнена команда: + +```bash +python -m pytest -q \ + tests/unit/market_data/acquisition/validation/test_values.py \ + tests/unit/market_data/acquisition/validation/test_quote_values.py \ + tests/unit/market_data/acquisition/validation/test_websocket_quote_values.py \ + tests/unit/market_data/acquisition/validation/test_candle_values.py \ + tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py +``` + +Результат: + +```text +150 passed in 0.05s +``` + +--- + +# Проверка форматирования + +Выполнена команда: + +```bash +git diff --check +``` + +Ошибок форматирования не обнаружено. + +--- + +# Изменённые файлы + +```text +src/market_data/acquisition/exceptions.py +src/market_data/acquisition/validation/values.py +``` + +Создан файл: + +```text +tests/unit/market_data/acquisition/validation/test_websocket_ohlc_values.py +``` + +Документация этапа: + +```text +docs/migrations/build_059_4.md +``` + +--- + +# Совместимость + +Изменение полностью обратно совместимо. + +Не изменены: + +- REST Candles Feed; +- REST Candle validation; +- Quote Feed; +- WebSocket Quote validation; +- parser WebSocket OHLC; +- ExchangeService; +- runtime; +- Market Analysis; +- Trading. + +Новая функция пока не подключена к рабочему WebSocket Adapter и не влияет на работающий бот. + +--- + +# Ограничения этапа + +Build 059.4 не выполняет: + +- преобразование timestamp в UTC `datetime`; +- преобразование цен в постоянные `Decimal`; +- создание внутреннего события закрытия свечи; +- добавление `source`; +- mapping; +- WebSocket Adapter; +- WebSocket subscription; +- REST reconciliation; +- проверку совпадения WebSocket и REST OHLC; +- создание канонической `Candle`; +- runtime-интеграцию. + +--- + +# Итог + +Build 059.4 завершает слой предметной проверки значений Dzengi WebSocket OHLC. + +Проект теперь умеет безопасно проверять: + +```text +DzengiWebSocketOhlcEvent +``` + +на: + +- допустимость символа; +- допустимость интервала; +- допустимость типа свечи; +- корректность timestamp; +- корректность чисел; +- конечность цен; +- положительность цен; +- соблюдение OHLC-инвариантов. + +Следующий этап: + +```text +Build 059.5 — Internal Close Event Model +``` \ No newline at end of file