214 lines
5.6 KiB
Python
214 lines
5.6 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
import websockets
|
||
from websockets.typing import Subprotocol
|
||
|
||
|
||
SYMBOL = os.getenv(
|
||
"TRADE_TEST_SYMBOL",
|
||
"BTC/USD_LEVERAGE",
|
||
)
|
||
|
||
OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS = 30
|
||
OBSERVE_AFTER_UNSUBSCRIBE_SECONDS = 60
|
||
RECEIVE_TIMEOUT_SECONDS = 10
|
||
|
||
|
||
def build_ws_url() -> str:
|
||
raw_url = os.getenv(
|
||
"EXCHANGE_WS_URL",
|
||
"wss://api-adapter.dzengi.com",
|
||
).rstrip("/")
|
||
|
||
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)
|
||
|
||
if raw_url.endswith("/connect"):
|
||
return raw_url
|
||
|
||
return f"{raw_url}/connect"
|
||
|
||
|
||
def build_headers() -> dict[str, str]:
|
||
base_url = os.getenv(
|
||
"EXCHANGE_BASE_URL",
|
||
"https://api-adapter.dzengi.com",
|
||
).rstrip("/")
|
||
|
||
headers = {
|
||
"Origin": base_url,
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
api_key = os.getenv("EXCHANGE_API_KEY", "").strip()
|
||
|
||
if api_key:
|
||
headers["X-MBX-APIKEY"] = api_key
|
||
|
||
return headers
|
||
|
||
|
||
def build_request(destination: str) -> dict[str, Any]:
|
||
return {
|
||
"correlationId": str(uuid4()),
|
||
"destination": destination,
|
||
"payload": {
|
||
"symbols": [SYMBOL],
|
||
},
|
||
}
|
||
|
||
|
||
def decode_message(raw_message: str | bytes) -> Any:
|
||
if isinstance(raw_message, bytes):
|
||
raw_message = raw_message.decode("utf-8")
|
||
|
||
try:
|
||
return json.loads(raw_message)
|
||
except json.JSONDecodeError:
|
||
return raw_message
|
||
|
||
|
||
def print_message(label: str, message: Any) -> None:
|
||
print()
|
||
print("=" * 80)
|
||
print(label)
|
||
print("=" * 80)
|
||
|
||
if isinstance(message, (dict, list)):
|
||
print(json.dumps(message, indent=2, ensure_ascii=False))
|
||
else:
|
||
print(message)
|
||
|
||
|
||
def is_trade_event(message: Any) -> bool:
|
||
return (
|
||
isinstance(message, dict)
|
||
and message.get("destination") == "internal.trade"
|
||
)
|
||
|
||
|
||
async def receive_until(
|
||
websocket: Any,
|
||
*,
|
||
duration_seconds: int,
|
||
phase: str,
|
||
) -> int:
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + duration_seconds
|
||
trade_count = 0
|
||
|
||
while loop.time() < deadline:
|
||
remaining = deadline - loop.time()
|
||
timeout = min(RECEIVE_TIMEOUT_SECONDS, remaining)
|
||
|
||
try:
|
||
raw_message = await asyncio.wait_for(
|
||
websocket.recv(),
|
||
timeout=timeout,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
print(
|
||
f"[{phase}] За последние {timeout:.0f} секунд "
|
||
"сообщений не получено; соединение остаётся открытым."
|
||
)
|
||
continue
|
||
|
||
message = decode_message(raw_message)
|
||
print_message(f"[{phase}] Получено сообщение", message)
|
||
|
||
if is_trade_event(message):
|
||
trade_count += 1
|
||
|
||
return trade_count
|
||
|
||
|
||
async def main() -> None:
|
||
ws_url = build_ws_url()
|
||
headers = build_headers()
|
||
|
||
subscribe_request = build_request("trades.subscribe")
|
||
unsubscribe_request = build_request("trades.unsubscribe")
|
||
|
||
print(f"WebSocket URL: {ws_url}")
|
||
print(f"Symbol: {SYMBOL}")
|
||
|
||
async with websockets.connect(
|
||
ws_url,
|
||
extra_headers=headers,
|
||
subprotocols=[Subprotocol("json")],
|
||
ping_interval=20,
|
||
ping_timeout=20,
|
||
open_timeout=20,
|
||
) as websocket:
|
||
print_message(
|
||
"Отправляется trades.subscribe",
|
||
subscribe_request,
|
||
)
|
||
|
||
await websocket.send(
|
||
json.dumps(subscribe_request),
|
||
)
|
||
|
||
before_count = await receive_until(
|
||
websocket,
|
||
duration_seconds=OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS,
|
||
phase="BEFORE UNSUBSCRIBE",
|
||
)
|
||
|
||
print()
|
||
print(
|
||
"Количество Trade Event до unsubscribe: "
|
||
f"{before_count}"
|
||
)
|
||
|
||
print_message(
|
||
"Отправляется trades.unsubscribe",
|
||
unsubscribe_request,
|
||
)
|
||
|
||
await websocket.send(
|
||
json.dumps(unsubscribe_request),
|
||
)
|
||
|
||
after_count = await receive_until(
|
||
websocket,
|
||
duration_seconds=OBSERVE_AFTER_UNSUBSCRIBE_SECONDS,
|
||
phase="AFTER UNSUBSCRIBE",
|
||
)
|
||
|
||
print()
|
||
print("=" * 80)
|
||
print("РЕЗУЛЬТАТ НАБЛЮДЕНИЯ")
|
||
print("=" * 80)
|
||
print(f"Trade Event до unsubscribe: {before_count}")
|
||
print(f"Trade Event после unsubscribe: {after_count}")
|
||
|
||
if before_count == 0:
|
||
print(
|
||
"До unsubscribe не было получено ни одной сделки. "
|
||
"Эксперимент нельзя считать доказательным."
|
||
)
|
||
elif after_count == 0:
|
||
print(
|
||
"После trades.unsubscribe новые сделки не поступили. "
|
||
"Команда, вероятно, поддерживается, но необходимо "
|
||
"проверить ACK или повторить тест при высокой активности."
|
||
)
|
||
else:
|
||
print(
|
||
"После trades.unsubscribe сделки продолжили поступать. "
|
||
"Команда либо не поддерживается, либо была отклонена, "
|
||
"либо имеет другой формат."
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main()) |