hotstuff-python-sdk 0.0.1b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,401 @@
1
+ """WebSocket transport implementation."""
2
+ import asyncio
3
+ import json
4
+ from typing import Optional, Dict, Any, Callable, List
5
+ import websockets
6
+ from websockets.client import WebSocketClientProtocol
7
+
8
+ from hotstuff.types import (
9
+ WebSocketTransportOptions,
10
+ JSONRPCMessage,
11
+ JSONRPCResponse,
12
+ JSONRPCNotification,
13
+ Subscription,
14
+ SubscriptionData,
15
+ WSMethod,
16
+ SubscribeResult,
17
+ UnsubscribeResult,
18
+ PongResult,
19
+ )
20
+ from hotstuff.utils import ENDPOINTS_URLS
21
+
22
+
23
+ class WebSocketTransport:
24
+ """WebSocket transport for real-time subscriptions."""
25
+
26
+ def __init__(self, options: Optional[WebSocketTransportOptions] = None):
27
+ """
28
+ Initialize WebSocket transport.
29
+
30
+ Args:
31
+ options: Transport configuration options
32
+ """
33
+ options = options or WebSocketTransportOptions()
34
+
35
+ self.is_testnet = options.is_testnet
36
+ self.timeout = options.timeout
37
+
38
+ # Setup server endpoints
39
+ self.server = {
40
+ "mainnet": ENDPOINTS_URLS["mainnet"]["ws"],
41
+ "testnet": ENDPOINTS_URLS["testnet"]["ws"],
42
+ }
43
+
44
+ if options.server:
45
+ if "mainnet" in options.server:
46
+ self.server["mainnet"] = options.server["mainnet"]
47
+ if "testnet" in options.server:
48
+ self.server["testnet"] = options.server["testnet"]
49
+
50
+ self.keep_alive = options.keep_alive or {
51
+ "interval": 30.0,
52
+ "timeout": 10.0,
53
+ }
54
+
55
+ self.ws: Optional[WebSocketClientProtocol] = None
56
+ self.reconnect_attempts = 0
57
+ self.max_reconnect_attempts = 5
58
+ self.reconnect_delay = 1.0
59
+
60
+ self.message_queue: Dict[str, asyncio.Future] = {}
61
+ self.message_id_counter = 0
62
+
63
+ self.subscriptions: Dict[str, Subscription] = {}
64
+ self.subscription_callbacks: Dict[str, Callable] = {}
65
+
66
+ self.connection_promise: Optional[asyncio.Task] = None
67
+ self.keep_alive_task: Optional[asyncio.Task] = None
68
+ self.receive_task: Optional[asyncio.Task] = None
69
+
70
+ self.auto_connect = options.auto_connect
71
+ if self.auto_connect:
72
+ # Don't await here, just schedule it
73
+ asyncio.create_task(self._auto_connect())
74
+
75
+ async def _auto_connect(self):
76
+ """Auto-connect on initialization."""
77
+ try:
78
+ await self.connect()
79
+ except Exception as e:
80
+ print(f"Auto-connect failed: {e}")
81
+
82
+ async def _ensure_connected(self):
83
+ """Ensure the WebSocket is connected."""
84
+ if self.ws and not self.ws.closed:
85
+ return
86
+
87
+ if self.connection_promise:
88
+ await self.connection_promise
89
+ return
90
+
91
+ await self.connect()
92
+
93
+ def _cleanup(self):
94
+ """Cleanup resources."""
95
+ if self.keep_alive_task:
96
+ self.keep_alive_task.cancel()
97
+ self.keep_alive_task = None
98
+
99
+ if self.receive_task:
100
+ self.receive_task.cancel()
101
+ self.receive_task = None
102
+
103
+ # Reject all pending messages
104
+ for future in self.message_queue.values():
105
+ if not future.done():
106
+ future.set_exception(Exception("WebSocket disconnected"))
107
+
108
+ self.message_queue.clear()
109
+
110
+ async def _start_keep_alive(self):
111
+ """Start keep-alive ping loop."""
112
+ interval = self.keep_alive.get("interval")
113
+ if not interval:
114
+ return
115
+
116
+ while True:
117
+ try:
118
+ await asyncio.sleep(interval)
119
+ await self.ping()
120
+ except asyncio.CancelledError:
121
+ break
122
+ except Exception as e:
123
+ print(f"Keep-alive error: {e}")
124
+ break
125
+
126
+ def _handle_incoming_message(self, message: dict):
127
+ """Handle incoming WebSocket message."""
128
+ # Check if it's a JSON-RPC response
129
+ if "id" in message and ("result" in message or "error" in message):
130
+ self._handle_jsonrpc_response(message)
131
+ return
132
+
133
+ # Check if it's a notification
134
+ if "method" in message and "params" in message and "id" not in message:
135
+ self._handle_jsonrpc_notification(message)
136
+ return
137
+
138
+ def _handle_jsonrpc_response(self, response: dict):
139
+ """Handle JSON-RPC response."""
140
+ msg_id = str(response.get("id"))
141
+ future = self.message_queue.pop(msg_id, None)
142
+
143
+ if future and not future.done():
144
+ if "error" in response:
145
+ error = response["error"]
146
+ future.set_exception(
147
+ Exception(f"JSON-RPC Error {error.get('code')}: {error.get('message')}")
148
+ )
149
+ else:
150
+ future.set_result(response.get("result"))
151
+
152
+ def _handle_jsonrpc_notification(self, notification: dict):
153
+ """Handle JSON-RPC notification."""
154
+ method = notification.get("method")
155
+ params = notification.get("params")
156
+
157
+ if method in ("subscription", "event") and params:
158
+ channel = params.get("channel")
159
+ data = params.get("data")
160
+
161
+ # Find matching subscriptions
162
+ for sub_id, subscription in self.subscriptions.items():
163
+ if subscription.channel == channel:
164
+ callback = self.subscription_callbacks.get(sub_id)
165
+ if callback:
166
+ subscription_data = SubscriptionData(
167
+ channel=channel,
168
+ data=data,
169
+ timestamp=asyncio.get_event_loop().time()
170
+ )
171
+ try:
172
+ callback(subscription_data)
173
+ except Exception as e:
174
+ print(f"Callback error: {e}")
175
+
176
+ async def _receive_messages(self):
177
+ """Receive messages from WebSocket."""
178
+ try:
179
+ async for message in self.ws:
180
+ try:
181
+ data = json.loads(message)
182
+ self._handle_incoming_message(data)
183
+ except json.JSONDecodeError as e:
184
+ print(f"Failed to parse message: {e}")
185
+ except Exception as e:
186
+ print(f"Error handling message: {e}")
187
+ except asyncio.CancelledError:
188
+ pass
189
+ except Exception as e:
190
+ print(f"Receive error: {e}")
191
+ # Trigger reconnection
192
+ if self.reconnect_attempts < self.max_reconnect_attempts:
193
+ asyncio.create_task(self._reconnect())
194
+
195
+ async def _reconnect(self):
196
+ """Reconnect to WebSocket."""
197
+ self._cleanup()
198
+ await asyncio.sleep(self.reconnect_delay * self.reconnect_attempts)
199
+ self.reconnect_attempts += 1
200
+ await self.connect()
201
+
202
+ async def _send_jsonrpc_message(self, message: dict) -> Any:
203
+ """Send a JSON-RPC message and wait for response."""
204
+ await self._ensure_connected()
205
+
206
+ # Assign message ID if not present
207
+ if "id" not in message or message["id"] is None:
208
+ self.message_id_counter += 1
209
+ message["id"] = str(self.message_id_counter)
210
+
211
+ msg_id = str(message["id"])
212
+
213
+ # Create future for response
214
+ future = asyncio.Future()
215
+ self.message_queue[msg_id] = future
216
+
217
+ # Set timeout
218
+ if self.timeout:
219
+ async def timeout_handler():
220
+ await asyncio.sleep(self.timeout)
221
+ if msg_id in self.message_queue:
222
+ self.message_queue.pop(msg_id)
223
+ if not future.done():
224
+ future.set_exception(Exception("Request timeout"))
225
+
226
+ asyncio.create_task(timeout_handler())
227
+
228
+ # Send message
229
+ await self.ws.send(json.dumps(message))
230
+
231
+ # Wait for response
232
+ return await future
233
+
234
+ def _format_subscription_params(
235
+ self,
236
+ channel: str,
237
+ payload: dict
238
+ ) -> dict:
239
+ """Format subscription parameters."""
240
+ subscription = {
241
+ "channel": channel,
242
+ **payload,
243
+ }
244
+ return subscription
245
+
246
+ async def _subscribe_to_channels(self, params: dict) -> SubscribeResult:
247
+ """Subscribe to channels."""
248
+ self.message_id_counter += 1
249
+ message = {
250
+ "jsonrpc": "2.0",
251
+ "method": WSMethod.SUBSCRIBE,
252
+ "params": params,
253
+ "id": str(self.message_id_counter),
254
+ }
255
+
256
+ result = await self._send_jsonrpc_message(message)
257
+ return SubscribeResult(**result) if isinstance(result, dict) else result
258
+
259
+ async def _unsubscribe_from_channels(self, channels: List[str]) -> UnsubscribeResult:
260
+ """Unsubscribe from channels."""
261
+ self.message_id_counter += 1
262
+ message = {
263
+ "jsonrpc": "2.0",
264
+ "method": WSMethod.UNSUBSCRIBE,
265
+ "params": channels,
266
+ "id": str(self.message_id_counter),
267
+ }
268
+
269
+ result = await self._send_jsonrpc_message(message)
270
+ return UnsubscribeResult(**result) if isinstance(result, dict) else result
271
+
272
+ def is_connected(self) -> bool:
273
+ """Check if WebSocket is connected."""
274
+ return self.ws is not None and not self.ws.closed
275
+
276
+ async def connect(self):
277
+ """Connect to WebSocket server."""
278
+ url = self.server["testnet" if self.is_testnet else "mainnet"]
279
+
280
+ try:
281
+ self.ws = await websockets.connect(url)
282
+ self.reconnect_attempts = 0
283
+
284
+ # Start keep-alive
285
+ if self.keep_alive.get("interval"):
286
+ self.keep_alive_task = asyncio.create_task(self._start_keep_alive())
287
+
288
+ # Start receiving messages
289
+ self.receive_task = asyncio.create_task(self._receive_messages())
290
+
291
+ except Exception as e:
292
+ raise Exception(f"Failed to connect: {e}")
293
+
294
+ async def disconnect(self):
295
+ """Disconnect from WebSocket server."""
296
+ self._cleanup()
297
+
298
+ if self.ws:
299
+ await self.ws.close()
300
+ self.ws = None
301
+
302
+ async def ping(self) -> PongResult:
303
+ """Send ping to server."""
304
+ self.message_id_counter += 1
305
+ message = {
306
+ "jsonrpc": "2.0",
307
+ "method": WSMethod.PING,
308
+ "id": str(self.message_id_counter),
309
+ }
310
+
311
+ result = await self._send_jsonrpc_message(message)
312
+ return PongResult(pong=True)
313
+
314
+ async def subscribe(
315
+ self,
316
+ channel: str,
317
+ payload: dict,
318
+ listener: Callable
319
+ ) -> Dict[str, Any]:
320
+ """
321
+ Subscribe to a channel.
322
+
323
+ Args:
324
+ channel: The channel to subscribe to
325
+ payload: Subscription parameters
326
+ listener: Callback function for updates
327
+
328
+ Returns:
329
+ Subscription result with unsubscribe method
330
+ """
331
+ await self._ensure_connected()
332
+
333
+ subscription_id = f"{channel}_{asyncio.get_event_loop().time()}"
334
+
335
+ subscription = Subscription(
336
+ id=subscription_id,
337
+ channel=channel,
338
+ symbol=payload.get("instrumentId") or payload.get("symbol"),
339
+ params=payload,
340
+ timestamp=asyncio.get_event_loop().time()
341
+ )
342
+
343
+ self.subscription_callbacks[subscription_id] = listener
344
+
345
+ try:
346
+ subscription_params = self._format_subscription_params(channel, payload)
347
+ result = await self._subscribe_to_channels(subscription_params)
348
+
349
+ if result.status == "subscribed" and result.channels:
350
+ server_channel = result.channels[0]
351
+ subscription.channel = server_channel
352
+ self.subscriptions[subscription_id] = subscription
353
+
354
+ return {
355
+ "subscriptionId": subscription_id,
356
+ "status": result.status,
357
+ "channels": result.channels,
358
+ "unsubscribe": lambda: self.unsubscribe(subscription_id),
359
+ }
360
+ else:
361
+ self.subscription_callbacks.pop(subscription_id, None)
362
+ error_msg = result.error or f"Subscription {result.status}"
363
+ raise Exception(f"Server rejected subscription: {error_msg}")
364
+
365
+ except Exception as e:
366
+ self.subscriptions.pop(subscription_id, None)
367
+ self.subscription_callbacks.pop(subscription_id, None)
368
+ raise e
369
+
370
+ async def unsubscribe(self, subscription_id: str):
371
+ """Unsubscribe from a channel."""
372
+ subscription = self.subscriptions.get(subscription_id)
373
+ if not subscription:
374
+ raise Exception(f"Subscription {subscription_id} not found")
375
+
376
+ try:
377
+ if self.is_connected():
378
+ await self._unsubscribe_from_channels([subscription.channel])
379
+
380
+ self.subscriptions.pop(subscription_id, None)
381
+ self.subscription_callbacks.pop(subscription_id, None)
382
+
383
+ except Exception as e:
384
+ print(f"Failed to unsubscribe: {e}")
385
+ self.subscriptions.pop(subscription_id, None)
386
+ self.subscription_callbacks.pop(subscription_id, None)
387
+ raise e
388
+
389
+ def get_subscriptions(self) -> List[Subscription]:
390
+ """Get all active subscriptions."""
391
+ return list(self.subscriptions.values())
392
+
393
+ async def __aenter__(self):
394
+ """Async context manager entry."""
395
+ await self.connect()
396
+ return self
397
+
398
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
399
+ """Async context manager exit."""
400
+ await self.disconnect()
401
+
@@ -0,0 +1,62 @@
1
+ """Type definitions package."""
2
+ from hotstuff.types.transports import (
3
+ HttpTransportOptions,
4
+ WebSocketTransportOptions,
5
+ JSONRPCMessage,
6
+ JSONRPCResponse,
7
+ JSONRPCNotification,
8
+ SubscriptionData,
9
+ Subscription,
10
+ WSMethod,
11
+ SubscribeResult,
12
+ UnsubscribeResult,
13
+ PongResult,
14
+ )
15
+ from hotstuff.types.clients import (
16
+ InfoClientParameters,
17
+ ExchangeClientParameters,
18
+ SubscriptionClientParameters,
19
+ ActionRequest,
20
+ )
21
+ from hotstuff.types.exchange import (
22
+ UnitOrder,
23
+ BrokerConfig,
24
+ PlaceOrderParams,
25
+ UnitCancelByOrderId,
26
+ CancelByOidParams,
27
+ UnitCancelByClOrderId,
28
+ CancelByCloidParams,
29
+ CancelAllParams,
30
+ AddAgentParams,
31
+ )
32
+
33
+ __all__ = [
34
+ # Transport types
35
+ "HttpTransportOptions",
36
+ "WebSocketTransportOptions",
37
+ "JSONRPCMessage",
38
+ "JSONRPCResponse",
39
+ "JSONRPCNotification",
40
+ "SubscriptionData",
41
+ "Subscription",
42
+ "WSMethod",
43
+ "SubscribeResult",
44
+ "UnsubscribeResult",
45
+ "PongResult",
46
+ # Client types
47
+ "InfoClientParameters",
48
+ "ExchangeClientParameters",
49
+ "SubscriptionClientParameters",
50
+ "ActionRequest",
51
+ # Exchange types
52
+ "UnitOrder",
53
+ "BrokerConfig",
54
+ "PlaceOrderParams",
55
+ "UnitCancelByOrderId",
56
+ "CancelByOidParams",
57
+ "UnitCancelByClOrderId",
58
+ "CancelByCloidParams",
59
+ "CancelAllParams",
60
+ "AddAgentParams",
61
+ ]
62
+
@@ -0,0 +1,34 @@
1
+ """Type definitions for client parameters."""
2
+ from typing import TypeVar, Generic, Optional, Callable, Awaitable, Any
3
+ from dataclasses import dataclass
4
+
5
+ # Type variable for transport
6
+ T = TypeVar('T')
7
+
8
+
9
+ @dataclass
10
+ class InfoClientParameters(Generic[T]):
11
+ """Parameters for InfoClient."""
12
+ transport: T
13
+
14
+
15
+ @dataclass
16
+ class ExchangeClientParameters(Generic[T]):
17
+ """Parameters for ExchangeClient."""
18
+ transport: T
19
+ wallet: Any
20
+ nonce: Optional[Callable[[], Awaitable[int]]] = None
21
+
22
+
23
+ @dataclass
24
+ class SubscriptionClientParameters(Generic[T]):
25
+ """Parameters for SubscriptionClient."""
26
+ transport: T
27
+
28
+
29
+ @dataclass
30
+ class ActionRequest:
31
+ """Action request."""
32
+ action: str
33
+ params: dict
34
+
@@ -0,0 +1,88 @@
1
+ """Type definitions for exchange methods."""
2
+ from typing import Optional, List
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class UnitOrder:
8
+ """Single order unit."""
9
+ instrument_id: int
10
+ side: str # 'b' or 's'
11
+ position_side: str # 'LONG', 'SHORT', or 'BOTH'
12
+ price: str
13
+ size: str
14
+ tif: str # 'GTC', 'IOC', or 'FOK'
15
+ ro: bool # reduce-only
16
+ po: bool # post-only
17
+ cloid: str # client order ID
18
+ trigger_px: Optional[str] = None
19
+ is_market: Optional[bool] = None
20
+ tpsl: Optional[str] = None # 'tp', 'sl', or ''
21
+ grouping: Optional[str] = None # 'position', 'normal', or ''
22
+
23
+
24
+ @dataclass
25
+ class BrokerConfig:
26
+ """Broker configuration."""
27
+ broker: str
28
+ fee: str
29
+
30
+
31
+ @dataclass
32
+ class PlaceOrderParams:
33
+ """Parameters for placing an order."""
34
+ orders: List[UnitOrder]
35
+ expiresAfter: int
36
+ broker_config: Optional[BrokerConfig] = None
37
+ nonce: Optional[int] = None
38
+
39
+
40
+ @dataclass
41
+ class UnitCancelByOrderId:
42
+ """Cancel by order ID unit."""
43
+ oid: int
44
+ instrument_id: int
45
+
46
+
47
+ @dataclass
48
+ class CancelByOidParams:
49
+ """Parameters for cancelling by order ID."""
50
+ cancels: List[UnitCancelByOrderId]
51
+ expiresAfter: int
52
+ nonce: Optional[int] = None
53
+
54
+
55
+ @dataclass
56
+ class UnitCancelByClOrderId:
57
+ """Cancel by client order ID unit."""
58
+ cloid: str
59
+ instrument_id: int
60
+
61
+
62
+ @dataclass
63
+ class CancelByCloidParams:
64
+ """Parameters for cancelling by client order ID."""
65
+ cancels: List[UnitCancelByClOrderId]
66
+ expiresAfter: int
67
+ nonce: Optional[int] = None
68
+
69
+
70
+ @dataclass
71
+ class CancelAllParams:
72
+ """Parameters for cancelling all orders."""
73
+ expiresAfter: int
74
+ nonce: Optional[int] = None
75
+
76
+
77
+ @dataclass
78
+ class AddAgentParams:
79
+ """Parameters for adding an agent."""
80
+ agent_name: str
81
+ agent: str
82
+ for_account: str
83
+ agent_private_key: str
84
+ signer: str
85
+ valid_until: int
86
+ signature: Optional[str] = None
87
+ nonce: Optional[int] = None
88
+
@@ -0,0 +1,110 @@
1
+ """Type definitions for transports."""
2
+ from typing import Optional, Dict, Any, Callable, Awaitable, Union
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class HttpTransportOptions:
8
+ """Options for HTTP transport configuration."""
9
+ is_testnet: bool = False
10
+ timeout: Optional[float] = 3.0
11
+ server: Optional[Dict[str, Dict[str, str]]] = None
12
+ headers: Optional[Dict[str, str]] = None
13
+ on_request: Optional[Callable] = None
14
+ on_response: Optional[Callable] = None
15
+
16
+
17
+ @dataclass
18
+ class WebSocketTransportOptions:
19
+ """Options for WebSocket transport configuration."""
20
+ is_testnet: bool = False
21
+ timeout: Optional[float] = 10.0
22
+ server: Optional[Dict[str, str]] = None
23
+ keep_alive: Optional[Dict[str, Optional[float]]] = None
24
+ auto_connect: bool = True
25
+
26
+
27
+ @dataclass
28
+ class JSONRPCMessage:
29
+ """JSON-RPC 2.0 message."""
30
+ jsonrpc: str = "2.0"
31
+ method: Optional[str] = None
32
+ params: Optional[Union[Dict[str, Any], list]] = None
33
+ id: Optional[Union[str, int]] = None
34
+ result: Optional[Any] = None
35
+ error: Optional[Dict[str, Any]] = None
36
+
37
+
38
+ @dataclass
39
+ class JSONRPCResponse:
40
+ """JSON-RPC 2.0 response."""
41
+ jsonrpc: str
42
+ id: Union[str, int]
43
+ result: Optional[Any] = None
44
+ error: Optional[Dict[str, Any]] = None
45
+
46
+
47
+ @dataclass
48
+ class JSONRPCNotification:
49
+ """JSON-RPC 2.0 notification."""
50
+ jsonrpc: str
51
+ method: str
52
+ params: Optional[Dict[str, Any]] = None
53
+
54
+
55
+ @dataclass
56
+ class SubscriptionData:
57
+ """Subscription data."""
58
+ channel: str
59
+ data: Any
60
+ timestamp: float
61
+
62
+
63
+ @dataclass
64
+ class Subscription:
65
+ """Subscription information."""
66
+ id: str
67
+ channel: str
68
+ symbol: Optional[str]
69
+ params: Dict[str, Any]
70
+ timestamp: float
71
+
72
+
73
+ class WSMethod:
74
+ """WebSocket method names."""
75
+ SUBSCRIBE = "subscribe"
76
+ UNSUBSCRIBE = "unsubscribe"
77
+ PING = "ping"
78
+
79
+
80
+ @dataclass
81
+ class SubscribeResult:
82
+ """Result of subscription."""
83
+ status: str
84
+ channels: Optional[list] = None
85
+ error: Optional[str] = None
86
+
87
+ def __init__(self, status: str, channels: Optional[list] = None, error: Optional[str] = None, **kwargs):
88
+ """Initialize SubscribeResult, ignoring extra fields like 'id'."""
89
+ self.status = status
90
+ self.channels = channels
91
+ self.error = error
92
+
93
+
94
+ @dataclass
95
+ class UnsubscribeResult:
96
+ """Result of unsubscription."""
97
+ status: str
98
+ channels: Optional[list] = None
99
+
100
+ def __init__(self, status: str, channels: Optional[list] = None, **kwargs):
101
+ """Initialize UnsubscribeResult, ignoring extra fields like 'id'."""
102
+ self.status = status
103
+ self.channels = channels
104
+
105
+
106
+ @dataclass
107
+ class PongResult:
108
+ """Pong response."""
109
+ pong: bool = True
110
+