build 059.4: validate websocket OHLC values
This commit is contained in:
@@ -88,6 +88,11 @@ class CandleWebSocketParseError(MarketDataAcquisitionError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка допустимости значений WebSocket OHLC-события.
|
||||||
|
class CandleWebSocketValueError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования проверенного документа в raw-модели свечей.
|
# Ошибка преобразования проверенного документа в raw-модели свечей.
|
||||||
class CandleParseError(MarketDataAcquisitionError):
|
class CandleParseError(MarketDataAcquisitionError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -15,15 +15,34 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
|||||||
DzengiUnknownFilter,
|
DzengiUnknownFilter,
|
||||||
DzengiKlinesResponse,
|
DzengiKlinesResponse,
|
||||||
DzengiTicker24hrResponse,
|
DzengiTicker24hrResponse,
|
||||||
|
DzengiWebSocketOhlcEvent,
|
||||||
DzengiWebSocketQuoteResponse,
|
DzengiWebSocketQuoteResponse,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
CandleValueError,
|
CandleValueError,
|
||||||
|
CandleWebSocketValueError,
|
||||||
InstrumentReferenceValueError,
|
InstrumentReferenceValueError,
|
||||||
QuoteValueError,
|
QuoteValueError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPORTED_WEBSOCKET_OHLC_INTERVALS = frozenset(
|
||||||
|
{
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"1h",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_SUPPORTED_WEBSOCKET_OHLC_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"classic",
|
||||||
|
"heikin-ashi",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_exchange_info_values(
|
def validate_exchange_info_values(
|
||||||
response: DzengiExchangeInfoResponse,
|
response: DzengiExchangeInfoResponse,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -553,6 +572,126 @@ def validate_dzengi_websocket_quote_values(
|
|||||||
"$.payload.timestamp должно быть больше нуля."
|
"$.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(
|
def validate_candles_values(
|
||||||
response: DzengiKlinesResponse,
|
response: DzengiKlinesResponse,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
)
|
||||||
704
docs/migrations/build_059_4.md
Normal file
704
docs/migrations/build_059_4.md
Normal 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user