Skip to content

Commit bd169b4

Browse files
HenryHenry
authored andcommitted
fix: stabilize private execution streams
1 parent 6e4b07c commit bd169b4

4 files changed

Lines changed: 289 additions & 9 deletions

File tree

‎backend_api_python/app/services/execution_streams/adapters.py‎

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(
8787
self._connected = False
8888
self._reconnects = 0
8989
self._last_error = ""
90+
self._last_message_at = 0.0
9091

9192
@property
9293
def stream_key(self) -> str:
@@ -150,6 +151,34 @@ def handle_control(self, ws: Any, payload: Dict[str, Any]) -> bool:
150151
return True
151152
return False
152153

154+
def application_heartbeat_interval(self) -> float:
155+
"""Return the venue-level heartbeat interval, or 0 when not required."""
156+
return 0.0
157+
158+
def application_heartbeat_payload(self) -> Any:
159+
"""Return the venue-level heartbeat payload sent after an idle interval."""
160+
return None
161+
162+
def _application_heartbeat_loop(self, ws: Any, connection_stop: threading.Event) -> None:
163+
interval = max(0.0, float(self.application_heartbeat_interval() or 0.0))
164+
if interval <= 0:
165+
return
166+
while not self._stop.is_set() and not connection_stop.wait(interval):
167+
if time.monotonic() - self._last_message_at < interval:
168+
continue
169+
payload = self.application_heartbeat_payload()
170+
if payload is None:
171+
return
172+
try:
173+
if isinstance(payload, str):
174+
ws.send(payload)
175+
else:
176+
ws.send(json.dumps(payload, separators=(",", ":")))
177+
except Exception:
178+
# run_forever owns reconnect/error reporting; a failed heartbeat
179+
# only means this connection is already on its way down.
180+
return
181+
153182
def prepare(self) -> None:
154183
return None
155184

@@ -161,20 +190,33 @@ def _run(self) -> None:
161190
return
162191
backoff = 1.0
163192
while not self._stop.is_set():
193+
heartbeat_stop = threading.Event()
194+
heartbeat_thread: Optional[threading.Thread] = None
164195
try:
165196
self.prepare()
166197
url = self.url()
167198

168199
def _open(ws):
200+
nonlocal heartbeat_thread
201+
self._last_message_at = time.monotonic()
169202
if self.ready_on_open():
170203
self.mark_ready()
171204
else:
172205
self._connected = False
173206
self.on_state("authenticating", "", self._reconnects > 0)
174207
for message in self.on_open_messages():
175208
ws.send(json.dumps(message, separators=(",", ":")))
209+
if self.application_heartbeat_interval() > 0:
210+
heartbeat_thread = threading.Thread(
211+
target=self._application_heartbeat_loop,
212+
args=(ws, heartbeat_stop),
213+
name=f"ExecHeartbeat-{self.stream_key}",
214+
daemon=True,
215+
)
216+
heartbeat_thread.start()
176217

177218
def _message(ws, raw):
219+
self._last_message_at = time.monotonic()
178220
payload = _json_message(raw)
179221
if not payload or self.handle_control(ws, payload):
180222
return
@@ -210,12 +252,17 @@ def _close(_ws, _code, reason):
210252
sslopt = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False}
211253
elif isinstance(verify, str):
212254
sslopt = {"ca_certs": verify}
213-
self._ws.run_forever(
214-
ping_interval=20,
215-
ping_timeout=10,
216-
sslopt=sslopt,
217-
skip_utf8_validation=True,
218-
)
255+
try:
256+
self._ws.run_forever(
257+
ping_interval=20,
258+
ping_timeout=10,
259+
sslopt=sslopt,
260+
skip_utf8_validation=True,
261+
)
262+
finally:
263+
heartbeat_stop.set()
264+
if heartbeat_thread and heartbeat_thread.is_alive():
265+
heartbeat_thread.join(timeout=1.0)
219266
except Exception as exc:
220267
self._connected = False
221268
self.on_state("error", str(exc), False)
@@ -233,6 +280,36 @@ def __init__(self, **kwargs: Any) -> None:
233280
self._listen_key_base = ""
234281
self._listen_key_path = ""
235282
self._keepalive_thread: Optional[threading.Thread] = None
283+
self._subscription_request_id = ""
284+
285+
def _uses_spot_ws_api(self) -> bool:
286+
return self.market_type == "spot" and exchange_trading_environment(
287+
self.config, "binance"
288+
) != "live"
289+
290+
def ready_on_open(self) -> bool:
291+
return not self._uses_spot_ws_api()
292+
293+
def on_open_messages(self) -> List[Dict[str, Any]]:
294+
if not self._uses_spot_ws_api():
295+
return []
296+
timestamp = int(time.time() * 1000)
297+
api_key = str(self.config.get("api_key") or self.config.get("apiKey") or "")
298+
secret = str(self.config.get("secret_key") or self.config.get("secret") or "")
299+
params: Dict[str, Any] = {
300+
"apiKey": api_key,
301+
"timestamp": timestamp,
302+
}
303+
signature_payload = urlencode(sorted(params.items()))
304+
params["signature"] = hmac.new(
305+
secret.encode(), signature_payload.encode(), hashlib.sha256
306+
).hexdigest()
307+
self._subscription_request_id = f"qd-user-stream-{self.credential_id}-{timestamp}"
308+
return [{
309+
"id": self._subscription_request_id,
310+
"method": "userDataStream.subscribe.signature",
311+
"params": params,
312+
}]
236313

237314
def stop(self, timeout: float = 5.0) -> bool:
238315
main_stopped = super().stop(timeout=timeout)
@@ -242,6 +319,9 @@ def stop(self, timeout: float = 5.0) -> bool:
242319
return main_stopped and not bool(keepalive and keepalive.is_alive())
243320

244321
def prepare(self) -> None:
322+
if self._uses_spot_ws_api():
323+
self._listen_key = ""
324+
return
245325
api_key = str(self.config.get("api_key") or self.config.get("apiKey") or "")
246326
environment = exchange_trading_environment(self.config, "binance")
247327
if self.market_type == "spot":
@@ -291,20 +371,59 @@ def _keepalive_loop(self) -> None:
291371

292372
def url(self) -> str:
293373
environment = exchange_trading_environment(self.config, "binance")
374+
if self._uses_spot_ws_api():
375+
if environment == "testnet":
376+
return "wss://ws-api.testnet.binance.vision/ws-api/v3"
377+
return "wss://demo-ws-api.binance.com/ws-api/v3"
294378
if self.market_type == "spot":
295379
host = "wss://demo-stream.binance.com/ws" if environment != "live" else "wss://stream.binance.com:9443/ws"
296380
else:
297381
host = "wss://fstream.binancefuture.com/ws" if environment != "live" else "wss://fstream.binance.com/ws"
298382
return f"{host}/{self._listen_key}"
299383

384+
def handle_control(self, ws: Any, payload: Dict[str, Any]) -> bool:
385+
if self._uses_spot_ws_api() and payload.get("id") == self._subscription_request_id:
386+
try:
387+
status = int(payload.get("status") or 0)
388+
except (TypeError, ValueError):
389+
status = 0
390+
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
391+
if status == 200 and result.get("subscriptionId") is not None:
392+
self.mark_ready()
393+
else:
394+
error = payload.get("error")
395+
message = error.get("msg") if isinstance(error, dict) else error
396+
self.on_state(
397+
"error",
398+
f"Binance user stream subscription failed: {message or payload}",
399+
False,
400+
)
401+
try:
402+
ws.close()
403+
except Exception:
404+
pass
405+
return True
406+
return super().handle_control(ws, payload)
407+
300408
def parse(self, payload: Dict[str, Any]) -> List[ExecutionEvent]:
301-
return parse_binance(payload, market_type=self.market_type)
409+
event = payload.get("event")
410+
return parse_binance(
411+
event if isinstance(event, dict) else payload,
412+
market_type=self.market_type,
413+
)
302414

303415

304416
class OkxExecutionAdapter(PrivateWebSocketAdapter):
305417
def ready_on_open(self) -> bool:
306418
return False
307419

420+
def application_heartbeat_interval(self) -> float:
421+
# OKX requires the literal text frame "ping" before 30 seconds idle.
422+
return 20.0
423+
424+
def application_heartbeat_payload(self) -> str:
425+
return "ping"
426+
308427
def url(self) -> str:
309428
if exchange_trading_environment(self.config, "okx") != "live":
310429
return "wss://wspap.okx.com:8443/ws/v5/private"
@@ -344,6 +463,12 @@ class BybitExecutionAdapter(PrivateWebSocketAdapter):
344463
def ready_on_open(self) -> bool:
345464
return False
346465

466+
def application_heartbeat_interval(self) -> float:
467+
return 20.0
468+
469+
def application_heartbeat_payload(self) -> Dict[str, str]:
470+
return {"op": "ping"}
471+
347472
def url(self) -> str:
348473
environment = exchange_trading_environment(self.config, "bybit")
349474
if environment == "demo":
@@ -366,6 +491,8 @@ def on_open_messages(self) -> List[Dict[str, Any]]:
366491
}]
367492

368493
def handle_control(self, ws: Any, payload: Dict[str, Any]) -> bool:
494+
if payload.get("op") == "ping" and str(payload.get("ret_msg") or "").lower() == "pong":
495+
return True
369496
if payload.get("op") == "auth":
370497
if bool(payload.get("success")):
371498
self.mark_ready()
@@ -383,7 +510,17 @@ class BitgetExecutionAdapter(PrivateWebSocketAdapter):
383510
def ready_on_open(self) -> bool:
384511
return False
385512

513+
def application_heartbeat_interval(self) -> float:
514+
# Bitget recommends a literal text "ping" every 30 seconds. Use a
515+
# small safety margin so scheduling jitter cannot cross the deadline.
516+
return 25.0
517+
518+
def application_heartbeat_payload(self) -> str:
519+
return "ping"
520+
386521
def url(self) -> str:
522+
if exchange_trading_environment(self.config, "bitget") != "live":
523+
return "wss://wspap.bitget.com/v2/ws/private"
387524
return "wss://ws.bitget.com/v2/ws/private"
388525

389526
def on_open_messages(self) -> List[Dict[str, Any]]:

‎backend_api_python/app/services/execution_streams/repository.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ def update_health(
376376
rest_fallback = EXCLUDED.rest_fallback,
377377
last_error = EXCLUDED.last_error,
378378
updated_at = NOW()
379+
RETURNING stream_key
379380
""",
380381
(
381382
str(stream_key),

‎backend_api_python/tests/test_db_log_noise_fixes.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from contextlib import contextmanager
22

33
from app.services import mfa_service
4+
from app.services.execution_streams.repository import ExecutionEventRepository
45
from app.services.strategy_command_repository import StrategyCommandRepository
56
from app.utils import strategy_runtime_logs
67

@@ -104,3 +105,26 @@ def test_worker_heartbeat_returns_text_primary_key(monkeypatch):
104105
assert "RETURNING id" not in sql
105106
assert params[0:2] == ("worker-1", "trading")
106107
assert conn.committed
108+
109+
110+
def test_execution_stream_health_returns_stream_key(monkeypatch):
111+
conn = _CaptureConn()
112+
monkeypatch.setattr(
113+
"app.services.execution_streams.repository.get_db_connection",
114+
lambda: _capture_connection(conn),
115+
)
116+
117+
ExecutionEventRepository().update_health(
118+
stream_key="okx:9:all",
119+
credential_id=9,
120+
exchange_id="okx",
121+
market_type="all",
122+
state="connected",
123+
)
124+
125+
sql, params = conn.cursor_obj.calls[0]
126+
assert "RETURNING stream_key" in sql
127+
assert "RETURNING id" not in sql
128+
assert params[0] == "okx:9:all"
129+
assert conn.committed
130+
assert conn.cursor_obj.closed

0 commit comments

Comments
 (0)