build 059.3: parse websocket OHLC events

This commit is contained in:
2026-07-16 23:21:14 +03:00
parent e0aa04b32a
commit c8c0bb6951
4 changed files with 896 additions and 0 deletions

View File

@@ -19,10 +19,12 @@ from src.market_data.acquisition.adapters.dzengi.models import (
DzengiRawNumeric, DzengiRawNumeric,
DzengiUnknownFilter, DzengiUnknownFilter,
DzengiTicker24hrResponse, DzengiTicker24hrResponse,
DzengiWebSocketOhlcEvent,
DzengiWebSocketQuoteResponse, DzengiWebSocketQuoteResponse,
) )
from src.market_data.acquisition.exceptions import ( from src.market_data.acquisition.exceptions import (
CandleParseError, CandleParseError,
CandleWebSocketParseError,
InstrumentReferenceParseError, InstrumentReferenceParseError,
QuoteParseError, QuoteParseError,
) )
@@ -30,6 +32,7 @@ from src.market_data.acquisition.validation.schema import (
ValidatedCandlesDocument, ValidatedCandlesDocument,
ValidatedExchangeInfoDocument, ValidatedExchangeInfoDocument,
ValidatedQuoteDocument, ValidatedQuoteDocument,
ValidatedWebSocketOhlcDocument,
ValidatedWebSocketQuoteDocument, ValidatedWebSocketQuoteDocument,
) )
@@ -719,6 +722,101 @@ def _websocket_optional_timestamp(
return _quote_required_int(value, path=path) 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. # Преобразовать структурно проверенный ответ klines в raw-модели Dzengi.
def parse_candles( def parse_candles(
document: ValidatedCandlesDocument, document: ValidatedCandlesDocument,

View File

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

View File

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

View File

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