build 059.4: validate websocket OHLC values

This commit is contained in:
2026-07-16 23:40:46 +03:00
parent c8c0bb6951
commit 925b447d45
4 changed files with 1235 additions and 0 deletions

View File

@@ -88,6 +88,11 @@ class CandleWebSocketParseError(MarketDataAcquisitionError):
pass
# Ошибка допустимости значений WebSocket OHLC-события.
class CandleWebSocketValueError(MarketDataAcquisitionError):
pass
# Ошибка преобразования проверенного документа в raw-модели свечей.
class CandleParseError(MarketDataAcquisitionError):
pass

View File

@@ -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:

View File

@@ -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",
)
)

View File

@@ -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
```