@@ -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
304416class 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 ]]:
0 commit comments