From ebb578db35a25511dae3687d81dc23b089154e5f Mon Sep 17 00:00:00 2001 From: Sergey Date: Thu, 16 Jul 2026 21:41:09 +0300 Subject: [PATCH] build 058: validate websocket OHLC market data --- app/scripts/check_ohlc_websocket.py | 328 +++++++ docs/migrations/build_058.md | 1268 +++++++++++++++++++++++++++ 2 files changed, 1596 insertions(+) create mode 100644 app/scripts/check_ohlc_websocket.py create mode 100644 docs/migrations/build_058.md diff --git a/app/scripts/check_ohlc_websocket.py b/app/scripts/check_ohlc_websocket.py new file mode 100644 index 0000000..a92405a --- /dev/null +++ b/app/scripts/check_ohlc_websocket.py @@ -0,0 +1,328 @@ +# app/scripts/check_ohlc_websocket.py + +from __future__ import annotations + +import asyncio +import json +from typing import Any +from uuid import uuid4 + +import websockets +from websockets.typing import Subprotocol + +from src.core.config import load_settings + + +SYMBOLS = ( + "BTC/USD_LEVERAGE", +) + +INTERVALS = ( + "1m", + "5m", +) + +CANDLE_TYPE = "classic" + +MAX_MESSAGES = 30 +TOTAL_TIMEOUT_SECONDS = 300.0 +RECEIVE_TIMEOUT_SECONDS = 15.0 + + +def build_ws_url() -> str: + settings = load_settings() + + raw_url = settings.exchange_ws_url or settings.exchange_base_url + + if raw_url.startswith("https://"): + raw_url = raw_url.replace("https://", "wss://", 1) + elif raw_url.startswith("http://"): + raw_url = raw_url.replace("http://", "ws://", 1) + + raw_url = raw_url.rstrip("/") + + if raw_url.endswith("/connect"): + return raw_url + + return f"{raw_url}/connect" + + +def build_headers() -> dict[str, str]: + settings = load_settings() + + headers = { + "Origin": settings.exchange_base_url.rstrip("/"), + "Content-Type": "application/json", + } + + if settings.exchange_api_key: + headers["X-MBX-APIKEY"] = settings.exchange_api_key + + return headers + + +def build_subscribe_request( + symbols: tuple[str, ...], + intervals: tuple[str, ...], + candle_type: str, +) -> dict[str, Any]: + return { + "correlationId": str(uuid4()), + "destination": "OHLCMarketData.subscribe", + "payload": { + "intervals": list(intervals), + "symbols": list(symbols), + "type": candle_type, + }, + } + + +def message_to_text( + raw_message: str | bytes | bytearray | memoryview, +) -> str: + if isinstance(raw_message, str): + return raw_message + + if isinstance(raw_message, memoryview): + return raw_message.tobytes().decode( + "utf-8", + errors="replace", + ) + + return bytes(raw_message).decode( + "utf-8", + errors="replace", + ) + + +def decode_document( + raw_message: str | bytes | bytearray | memoryview, +) -> object | None: + text = message_to_text(raw_message) + + try: + return json.loads(text) + except json.JSONDecodeError: + return None + + +def format_json( + raw_message: str | bytes | bytearray | memoryview, +) -> str: + text = message_to_text(raw_message) + + try: + document = json.loads(text) + except json.JSONDecodeError: + return text + + return json.dumps( + document, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + +def destination_from_document(document: object) -> str | None: + if not isinstance(document, dict): + return None + + direct_destination = document.get("destination") + + if isinstance(direct_destination, str): + return direct_destination + + upper_destination = document.get("Destination") + + if isinstance(upper_destination, str): + return upper_destination + + payload = document.get("payload") + + if isinstance(payload, dict): + nested_destination = payload.get("destination") + + if isinstance(nested_destination, str): + return nested_destination + + nested_upper_destination = payload.get("Destination") + + if isinstance(nested_upper_destination, str): + return nested_upper_destination + + upper_payload = document.get("Payload") + + if isinstance(upper_payload, dict): + nested_destination = upper_payload.get("destination") + + if isinstance(nested_destination, str): + return nested_destination + + nested_upper_destination = upper_payload.get("Destination") + + if isinstance(nested_upper_destination, str): + return nested_upper_destination + + return None + + +def is_ohlc_event(document: object) -> bool: + destination = destination_from_document(document) + + if destination is None: + return False + + return destination.lower() == "ohlc.event" + + +def elapsed_seconds( + loop: asyncio.AbstractEventLoop, + started_at: float, +) -> float: + return max(0.0, loop.time() - started_at) + + +async def receive_ohlc() -> None: + settings = load_settings() + + ws_url = build_ws_url() + headers = build_headers() + + request = build_subscribe_request( + SYMBOLS, + INTERVALS, + CANDLE_TYPE, + ) + + print(f"WebSocket URL: {ws_url}") + print(f"Symbols: {', '.join(SYMBOLS)}") + print(f"Intervals: {', '.join(INTERVALS)}") + print(f"Candle type: {CANDLE_TYPE}") + print(f"Maximum messages: {MAX_MESSAGES}") + print(f"Total timeout: {TOTAL_TIMEOUT_SECONDS:.0f} seconds") + print() + + print("Subscription request:") + print( + json.dumps( + request, + ensure_ascii=False, + indent=2, + ) + ) + print() + + async with websockets.connect( + ws_url, + extra_headers=headers, + subprotocols=[Subprotocol("json")], + ping_interval=20, + ping_timeout=float(settings.exchange_timeout_sec), + open_timeout=float(settings.exchange_timeout_sec), + close_timeout=float(settings.exchange_timeout_sec), + ) as websocket: + await websocket.send( + json.dumps( + request, + ensure_ascii=False, + ) + ) + + print("Subscription request sent.") + print("Waiting for acknowledgement and OHLC events...") + print() + + loop = asyncio.get_running_loop() + started_at = loop.time() + deadline = started_at + TOTAL_TIMEOUT_SECONDS + + received_count = 0 + ohlc_event_count = 0 + timeout_count = 0 + + while received_count < MAX_MESSAGES: + remaining_seconds = deadline - loop.time() + + if remaining_seconds <= 0: + print( + "Total timeout reached after " + f"{elapsed_seconds(loop, started_at):.1f} seconds." + ) + break + + receive_timeout = min( + RECEIVE_TIMEOUT_SECONDS, + remaining_seconds, + ) + + try: + raw_message = await asyncio.wait_for( + websocket.recv(), + timeout=receive_timeout, + ) + except asyncio.TimeoutError: + timeout_count += 1 + + print( + "No OHLC event yet; connection is alive. " + f"Elapsed: {elapsed_seconds(loop, started_at):.1f}s, " + f"remaining: {max(0.0, remaining_seconds):.1f}s." + ) + continue + + if not isinstance( + raw_message, + (str, bytes, bytearray, memoryview), + ): + print( + "Skipped unsupported WebSocket message type: " + f"{type(raw_message).__name__}" + ) + continue + + received_count += 1 + document = decode_document(raw_message) + + if is_ohlc_event(document): + ohlc_event_count += 1 + message_kind = "OHLC EVENT" + else: + message_kind = "CONTROL / ACKNOWLEDGEMENT" + + print("=" * 80) + print( + f"Message #{received_count} — {message_kind} — " + f"elapsed {elapsed_seconds(loop, started_at):.1f}s" + ) + print(format_json(raw_message)) + print() + + print("=" * 80) + print("Diagnostic summary") + print(f"Received messages: {received_count}") + print(f"OHLC events: {ohlc_event_count}") + print(f"Receive timeouts: {timeout_count}") + print( + "Elapsed time: " + f"{elapsed_seconds(loop, started_at):.1f} seconds" + ) + + +def main() -> None: + try: + asyncio.run(receive_ohlc()) + except KeyboardInterrupt: + print() + print("Stopped by user.") + except Exception as exc: + print() + print( + "WebSocket diagnostic failed: " + f"{type(exc).__name__}: {exc}" + ) + raise + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docs/migrations/build_058.md b/docs/migrations/build_058.md new file mode 100644 index 0000000..d99a1c9 --- /dev/null +++ b/docs/migrations/build_058.md @@ -0,0 +1,1268 @@ +# Build 058 — Runtime исследование Dzengi WebSocket OHLC Market Data + +--- + +# Контроль документа + +| Свойство | Значение | +|----------|-----------| +| Build | 058 | +| Название | Runtime исследование Dzengi WebSocket OHLC Market Data | +| Статус | Completed | +| Проект | Dzentra | +| Подсистема | Market Data Acquisition | +| Источник | Dzengi Exchange | +| Дата | 2026-07-16 | + +--- + +# Цель Build + +Провести полное runtime-исследование контракта WebSocket + +``` +OHLCMarketData.subscribe +``` + +и определить возможность его использования в качестве +канонического источника Candles Feed. + +Необходимо было подтвердить: + +- соответствие Swagger реальному API; +- формат подписки; +- формат ACK; +- формат событий; +- поддерживаемые параметры; +- поддержку нескольких символов; +- поддержку нескольких интервалов; +- наличие или отсутствие Volume; +- соответствие REST API; +- возможность использования в Build 059. + +--- + +# Исходные материалы + +Были исследованы: + +REST + +``` +GET /api/v1/klines +``` + +WebSocket + +``` +OHLCMarketData.subscribe +``` + +Swagger WebSocket + +``` +wss://.../connect +``` + +Также были выполнены реальные подключения к: + +``` +demo-api-adapter.dzengi.com +``` + +и + +``` +api-adapter.dzengi.com +``` + +--- + +# Вывод по Demo + +Во время исследования обнаружено: + +Demo WebSocket корректно принимает подписку: + +``` +OHLCMarketData.subscribe +``` + +но поток данных практически отсутствует. + +Поэтому дальнейшее исследование проводилось исключительно +на Production API. + +--- + +# Production WebSocket + +Используемый URL + +``` +wss://api-adapter.dzengi.com/connect +``` + +Подписка выполняется сообщением + +```json +{ + "correlationId": "", + "destination": "OHLCMarketData.subscribe", + "payload": { + "intervals": [ + "1m" + ], + "symbols": [ + "BTC/USD_LEVERAGE" + ], + "type": "classic" + } +} +``` + +После отправки сервер возвращает ACK. + +--- + +# ACK + +Фактический ответ сервера + +```json +{ + "correlationId": "...", + "destination": "OHLCMarketData.subscribe", + "payload": { + "subscriptions": { + "BTC/USD_LEVERAGE:1m": "PROCESSED" + } + }, + "status": "OK" +} +``` + +Подтверждено: + +- destination совпадает со Swagger; +- status = OK; +- подписки перечислены внутри payload.subscriptions; +- ключ имеет вид + +``` +symbol:interval +``` + +например + +``` +BTC/USD_LEVERAGE:1m +``` + +--- + +# Поддержка нескольких интервалов + +Проверена подписка + +```json +{ + "payload": { + "symbols":[ + "BTC/USD_LEVERAGE" + ], + "intervals":[ + "1m", + "5m" + ], + "type":"classic" + } +} +``` + +Полученный ACK + +```json +{ + "subscriptions": { + "BTC/USD_LEVERAGE:1m":"PROCESSED", + "BTC/USD_LEVERAGE:5m":"PROCESSED" + } +} +``` + +Таким образом подтверждено, +что одна подписка может одновременно обслуживать +несколько таймфреймов. + +Каждая комбинация + +``` +symbol + interval +``` + +имеет собственный статус. + +--- + +# Формат события + +После закрытия свечи сервер публикует + +```json +{ + "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 + }, + "status":"OK" +} +``` + +Полученные поля + +| Поле | Значение | +|------|----------| +| symbol | инструмент | +| interval | таймфрейм | +| type | тип свечи | +| t | open time | +| o | Open | +| h | High | +| l | Low | +| c | Close | + +Volume отсутствует. + +Это подтверждено всеми полученными событиями. + +--- + +# Исследование типа свечей + +Swagger указывает, +что поле + +``` +type +``` + +используется для выбора разновидности свечей. + +Во время runtime-исследования были проверены все возможные значения. + +--- + +# Попытка использовать bid + +Подписка + +```json +{ + "payload":{ + "symbols":[ + "BTC/USD_LEVERAGE" + ], + "intervals":[ + "1m" + ], + "type":"bid" + } +} +``` + +Ответ сервера + +```json +{ + "subscriptions":{ + "bid":"ERROR: Invalid type. Valid: classic, heikin-ashi" + } +} +``` + +Таким образом подтверждено, +что WebSocket не использует параметр + +``` +priceType +``` + +аналогичный REST. + +--- + +# Поддерживаемые значения type + +Во время исследования подтверждены два допустимых значения + +``` +classic +``` + +и + +``` +heikin-ashi +``` + +Других поддерживаемых вариантов обнаружено не было. + +--- + +# Исследование classic + +Подписка + +```json +{ + "payload":{ + "symbols":[ + "BTC/USD_LEVERAGE" + ], + "intervals":[ + "1m" + ], + "type":"classic" + } +} +``` + +События поступают регулярно +после закрытия каждой минутной свечи. + +Пример + +```json +{ + "destination":"ohlc.event", + "payload":{ + "symbol":"BTC/USD_LEVERAGE", + "interval":"1m", + "type":"classic", + "t":1784224860000, + "o":64047.45, + "h":64099.10, + "l":64042.25, + "c":64098.00 + } +} +``` + +--- + +# Сравнение с REST + +Для той же свечи был выполнен запрос + +``` +GET /api/v1/klines +``` + +``` +priceType=bid +``` + +Получен ответ + +```json +[ + [ + 1784224860000, + "64047.45", + "64099.10", + "64042.25", + "64098.00", + 165 + ] +] +``` + +Полное сравнение + +| Поле | REST | WebSocket | +|------|------|-----------| +| Open Time | ✔ | ✔ | +| Open | ✔ | ✔ | +| High | ✔ | ✔ | +| Low | ✔ | ✔ | +| Close | ✔ | ✔ | +| Volume | ✔ | ✖ | + +OHLC совпадают полностью. + +--- + +# Вывод + +Runtime подтвердил, +что + +``` +classic +``` + +представляет собой классические рыночные свечи, +идентичные + +``` +REST /klines +priceType=bid +``` + +за исключением отсутствующего объёма. + +--- + +# Исследование Heikin-Ashi + +Подписка + +```json +{ + "payload":{ + "symbols":[ + "BTC/USD_LEVERAGE" + ], + "intervals":[ + "1m" + ], + "type":"heikin-ashi" + } +} +``` + +Получен ACK + +```json +{ + "subscriptions":{ + "BTC/USD_LEVERAGE:1m":"PROCESSED" + } +} +``` + +После этого сервер начал публиковать события + +```json +{ + "destination":"ohlc.event", + "payload":{ + "symbol":"BTC/USD_LEVERAGE", + "interval":"1m", + "type":"heikin-ashi", + + "t":1784225280000, + + "o":64068.18, + "h":64128.80, + "l":64068.18, + "c":64100.85 + } +} +``` + +Получены несколько последовательных свечей. + +--- + +# Вывод по Heikin-Ashi + +Контракт полностью совпадает +с форматом + +``` +classic +``` + +Изменяется только способ расчёта OHLC. + +Следовательно + +``` +classic +``` + +и + +``` +heikin-ashi +``` + +представляют две различные серии свечей, +имеющие одинаковый транспортный контракт. + +--- + +# Исследование времени публикации + +Во время наблюдения было подтверждено, +что событие публикуется только после завершения интервала. + +Например + +``` +t = 1784224860000 +``` + +соответствует времени открытия свечи. + +Само событие было получено примерно через одну минуту, +после её закрытия. + +Таким образом + +``` +t +``` + +является временем открытия свечи, +а не временем публикации события. + +--- + +# Исследование нескольких интервалов + +Одновременно были подписаны + +``` +1m +``` + +и + +``` +5m +``` + +После закрытия пятиминутного интервала +сервер практически одновременно прислал + +``` +1m +``` + +и + +``` +5m +``` + +события. + +Полученное пятиминутное событие + +```json +{ + "payload":{ + "interval":"5m", + "type":"classic", + + "t":1784225400000, + + "o":64130.80, + "h":64184.70, + "l":64118.20, + "c":64151.00 + } +} +``` + +Тем самым подтверждено, +что сервер самостоятельно агрегирует свечи +для всех запрошенных интервалов. + +Клиенту не требуется самостоятельно строить +5m, 15m или другие свечи +из минутных данных. + +--- + +# Сравнение REST и WebSocket + +Во время исследования одновременно выполнялись: + +- периодические запросы REST; +- постоянная подписка WebSocket. + +Это позволило определить различия поведения. + +--- + +# Поведение REST + +Запрос + +``` +GET /api/v1/klines +``` + +возвращает последние свечи. + +Последняя свеча является текущей, +поэтому её значения изменяются +по мере поступления новых сделок. + +Например + +21:01:41 + +```text +Open 64047.45 + +High 64074.90 + +Low 64042.25 + +Close 64074.90 + +Volume 111 +``` + +Через некоторое время + +21:01:56 + +```text +High 64095.45 + +Close 64095.45 + +Volume 153 +``` + +Ещё позже + +21:02:11 + +```text +High 64099.10 + +Close 64098.00 + +Volume 165 +``` + +Таким образом REST показывает +формирующуюся свечу. + +--- + +# Поведение WebSocket + +Для этой же свечи был получен + +```json +{ + "t":1784224860000, + + "o":64047.45, + "h":64099.10, + "l":64042.25, + "c":64098.00 +} +``` + +WebSocket опубликовал событие +только после окончания интервала. + +Таким образом опубликована +уже окончательная свеча. + +--- + +# Главное различие + +REST + +``` +может вернуть +ещё незавершённую свечу +``` + +WebSocket + +``` +публикует только +закрытую свечу +``` + +Именно это делает WebSocket +очень полезным источником событий. + +--- + +# Исследование Volume + +Во всех событиях WebSocket отсутствует + +``` +volume +``` + +Например + +```json +{ + "payload":{ + "o":64096.20, + "h":64110.60, + "l":64045.45, + "c":64052.50 + } +} +``` + +Поле + +``` +volume +``` + +не передаётся. + +Однако REST возвращает + +```text +389 +``` + +для этой же свечи. + +Следовательно + +WebSocket публикует + +``` +OHLC +``` + +но не + +``` +OHLCV +``` + +--- + +# Ограничения использования + +Текущая каноническая модель проекта + +``` +Candle +``` + +содержит + +```python +volume: Decimal +``` + +Следовательно WebSocket-событие +не может напрямую быть преобразовано +в канонический Candle. + +Заполнение + +``` +volume = 0 +``` + +является архитектурно неверным, +поскольку означает +"объём равен нулю", +хотя на самом деле +объём неизвестен. + +Поэтому WebSocket не должен +непосредственно создавать +канонический Candle. + +--- + +# Архитектурное решение + +На основании проведённого исследования +для Build 059 принято решение +использовать гибридную схему. + +``` +OHLCMarketData.subscribe + + │ + + ▼ + +получено событие закрытия свечи + + │ + + ▼ + +REST GET /klines + +(symbol + interval + open_time) + + │ + + ▼ + +получена окончательная OHLCV свеча + + │ + + ▼ + +Validation + + │ + + ▼ + +Parser + + │ + + ▼ + +Mapper + + │ + + ▼ + +Canonical Candle +``` + +Таким образом + +WebSocket становится + +``` +источником события +о закрытии свечи +``` + +а REST + +остаётся + +``` +авторитетным источником +полной OHLCV модели. +``` + +--- + +# Новая транспортная модель + +Для Build 059 рекомендуется +создать отдельную transport-модель + +``` +DzengiWebSocketOhlcEvent +``` + +например + +```python +@dataclass(frozen=True, slots=True) +class DzengiWebSocketOhlcEvent: + + symbol: str + + interval: str + + candle_type: str + + open_time: int + + open_price: DzengiRawNumeric + + high_price: DzengiRawNumeric + + low_price: DzengiRawNumeric + + close_price: DzengiRawNumeric +``` + +Она не должна заменять +каноническую модель Candle. + +Её назначение — + +представление транспортного +WebSocket-события. + +--- + +# Итоги исследования + +Подтверждено + +✓ WebSocket полностью работоспособен. + +✓ Поддерживает несколько инструментов. + +✓ Поддерживает несколько интервалов. + +✓ Поддерживает + +``` +classic +``` + +и + +``` +heikin-ashi +``` + +✓ Публикует только завершённые свечи. + +✓ Формат classic совпадает +с REST bid OHLC. + +✓ Передаёт только OHLC. + +✓ Не содержит Volume. + +✓ Может использоваться +как триггер появления +новой закрытой свечи. + +--- + +# Вывод Build 058 + +Исследование показало, +что + +``` +OHLCMarketData.subscribe +``` + +не является заменой REST Candles Feed. + +Однако данный поток является +идеальным механизмом +для своевременного обнаружения +момента закрытия новой свечи. + +Именно такая схема +будет реализована +в следующем этапе проекта. + +--- + +# Следующий Build + +``` +Build 059 + +Интеграция WebSocket OHLC +в Candles Feed +``` + +Основные задачи Build 059 + +- создать транспортную модель + DzengiWebSocketOhlcEvent; + +- реализовать schema validation; + +- реализовать parser; + +- реализовать value validation; + +- реализовать mapper; + +- добавить WebSocket Adapter; + +- интегрировать новый источник + в Candles Feed; + +- использовать WebSocket + как источник события + закрытия свечи; + +- использовать REST + для получения окончательной + OHLCV свечи; + +- сохранить существующую + каноническую модель Candle + без изменений. + +--- + +# Работа с диагностическим скриптом + +В рамках Build 058 был разработан отдельный диагностический инструмент + +``` +scripts/check_ohlc_websocket.py +``` + +Назначение скрипта: + +- проверка доступности WebSocket; +- проверка подписки OHLCMarketData.subscribe; +- получение ACK; +- получение реальных OHLC-событий; +- проверка нескольких символов; +- проверка нескольких интервалов; +- проверка типа свечей; +- исследование поведения API после изменений на стороне биржи. + +Скрипт не является частью Production-кода +и предназначен исключительно +для инженерной диагностики. + +--- + +# Проверка синтаксиса + +```bash +python -m compileall \ + scripts/check_ohlc_websocket.py +``` + +--- + +# Проверка оформления + +```bash +git diff --check +``` + +--- + +# Запуск + +```bash +EXCHANGE_BASE_URL=https://api-adapter.dzengi.com \ +EXCHANGE_WS_URL=wss://api-adapter.dzengi.com \ +EXCHANGE_API_KEY="" \ +PYTHONPATH=. \ +python scripts/check_ohlc_websocket.py +``` + +--- + +# Настраиваемые параметры + +В начале файла можно изменять + +```python +SYMBOLS = ( + "BTC/USD_LEVERAGE", +) +``` + +для исследования нескольких инструментов. + +Например + +```python +SYMBOLS = ( + "BTC/USD_LEVERAGE", + "ETH/USD_LEVERAGE", +) +``` + +--- + +Можно изменять интервалы + +```python +INTERVALS = ( + "1m", +) +``` + +например + +```python +INTERVALS = ( + "1m", + "5m", +) +``` + +--- + +Можно выбирать тип свечей + +```python +CANDLE_TYPE = "classic" +``` + +или + +```python +CANDLE_TYPE = "heikin-ashi" +``` + +--- + +Также можно изменять + +```python +MAX_MESSAGES +``` + +и + +```python +TOTAL_TIMEOUT_SECONDS +``` + +для длительного наблюдения. + +--- + +# Что выводит скрипт + +После запуска отображаются + +- URL подключения; +- список инструментов; +- интервалы; +- тип свечей; +- параметры исследования. + +Затем выводится отправляемый JSON подписки. + +После подключения отображается ACK. + +Например + +```json +{ + "subscriptions": { + "BTC/USD_LEVERAGE:1m": "PROCESSED" + } +} +``` + +После этого выводятся все события + +``` +ohlc.event +``` + +с указанием + +- номера сообщения; +- времени от начала подключения; +- полного JSON. + +--- + +# Проверка нескольких интервалов + +При использовании + +```python +INTERVALS = ( + "1m", + "5m", +) +``` + +ожидается ACK + +```json +{ + "subscriptions": { + "BTC/USD_LEVERAGE:1m": "PROCESSED", + "BTC/USD_LEVERAGE:5m": "PROCESSED" + } +} +``` + +и последующее получение событий + +```text +interval = 1m +``` + +и + +```text +interval = 5m +``` + +в одном WebSocket-соединении. + +--- + +# Проверка Heikin-Ashi + +Для исследования синтетических свечей достаточно изменить + +```python +CANDLE_TYPE = "heikin-ashi" +``` + +После этого ожидаются события + +```json +{ + "type":"heikin-ashi" +} +``` + +Контракт полностью совпадает +с классическими свечами, +однако значения OHLC рассчитываются +по алгоритму Heikin-Ashi. + +--- + +# Использование в дальнейшем + +Скрипт рекомендуется сохранять +в репозитории проекта. + +Он может использоваться + +- после изменений API Dzengi; +- после обновления WebSocket; +- при регрессионном тестировании; +- при диагностике Production; +- при разработке новых адаптеров; +- при интеграции новых таймфреймов. + +Наличие отдельного диагностического инструмента +значительно упрощает проверку реального контракта биржи +без запуска всего приложения Dzentra. + +--- + +# Итог Build 058 + +Build 058 полностью подтвердил +контракт WebSocket OHLCMarketData.subscribe +на реальном Production API Dzengi. + +Полученные результаты являются основанием +для перехода к следующему этапу проекта — + +``` +Build 059 + +Интеграция WebSocket OHLC +в канонический Candles Feed. +``` \ No newline at end of file