ddx-python 1.0.4__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.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.
Files changed (106) hide show
  1. ddx/.gitignore +1 -0
  2. ddx/__init__.py +58 -0
  3. ddx/_rust/__init__.pyi +2685 -0
  4. ddx/_rust/common/__init__.pyi +17 -0
  5. ddx/_rust/common/accounting.pyi +6 -0
  6. ddx/_rust/common/enums.pyi +3 -0
  7. ddx/_rust/common/requests/__init__.pyi +23 -0
  8. ddx/_rust/common/requests/intents.pyi +19 -0
  9. ddx/_rust/common/specs.pyi +17 -0
  10. ddx/_rust/common/state/__init__.pyi +41 -0
  11. ddx/_rust/common/state/keys.pyi +29 -0
  12. ddx/_rust/common/transactions.pyi +7 -0
  13. ddx/_rust/decimal.pyi +3 -0
  14. ddx/_rust/h256.pyi +3 -0
  15. ddx/_rust.abi3.so +0 -0
  16. ddx/app_config/ethereum/addresses.json +526 -0
  17. ddx/auditor/README.md +32 -0
  18. ddx/auditor/__init__.py +0 -0
  19. ddx/auditor/auditor_driver.py +1043 -0
  20. ddx/auditor/websocket_message.py +54 -0
  21. ddx/common/__init__.py +0 -0
  22. ddx/common/epoch_params.py +28 -0
  23. ddx/common/fill_context.py +141 -0
  24. ddx/common/logging.py +184 -0
  25. ddx/common/market_aware_account.py +259 -0
  26. ddx/common/market_specs.py +64 -0
  27. ddx/common/trade_mining_params.py +19 -0
  28. ddx/common/transaction_utils.py +85 -0
  29. ddx/common/transactions/__init__.py +0 -0
  30. ddx/common/transactions/advance_epoch.py +91 -0
  31. ddx/common/transactions/advance_settlement_epoch.py +63 -0
  32. ddx/common/transactions/all_price_checkpoints.py +84 -0
  33. ddx/common/transactions/cancel.py +76 -0
  34. ddx/common/transactions/cancel_all.py +88 -0
  35. ddx/common/transactions/complete_fill.py +103 -0
  36. ddx/common/transactions/disaster_recovery.py +96 -0
  37. ddx/common/transactions/event.py +48 -0
  38. ddx/common/transactions/fee_distribution.py +119 -0
  39. ddx/common/transactions/funding.py +292 -0
  40. ddx/common/transactions/futures_expiry.py +123 -0
  41. ddx/common/transactions/genesis.py +108 -0
  42. ddx/common/transactions/inner/__init__.py +0 -0
  43. ddx/common/transactions/inner/adl_outcome.py +25 -0
  44. ddx/common/transactions/inner/fill.py +232 -0
  45. ddx/common/transactions/inner/liquidated_position.py +41 -0
  46. ddx/common/transactions/inner/liquidation_entry.py +41 -0
  47. ddx/common/transactions/inner/liquidation_fill.py +118 -0
  48. ddx/common/transactions/inner/outcome.py +32 -0
  49. ddx/common/transactions/inner/trade_fill.py +292 -0
  50. ddx/common/transactions/insurance_fund_update.py +138 -0
  51. ddx/common/transactions/insurance_fund_withdraw.py +100 -0
  52. ddx/common/transactions/liquidation.py +353 -0
  53. ddx/common/transactions/partial_fill.py +125 -0
  54. ddx/common/transactions/pnl_realization.py +120 -0
  55. ddx/common/transactions/post.py +72 -0
  56. ddx/common/transactions/post_order.py +95 -0
  57. ddx/common/transactions/price_checkpoint.py +97 -0
  58. ddx/common/transactions/signer_registered.py +62 -0
  59. ddx/common/transactions/specs_update.py +61 -0
  60. ddx/common/transactions/strategy_update.py +158 -0
  61. ddx/common/transactions/tradable_product_update.py +98 -0
  62. ddx/common/transactions/trade_mining.py +147 -0
  63. ddx/common/transactions/trader_update.py +131 -0
  64. ddx/common/transactions/withdraw.py +90 -0
  65. ddx/common/transactions/withdraw_ddx.py +74 -0
  66. ddx/common/utils.py +176 -0
  67. ddx/config.py +17 -0
  68. ddx/derivadex_client.py +270 -0
  69. ddx/models/__init__.py +0 -0
  70. ddx/models/base.py +132 -0
  71. ddx/py.typed +0 -0
  72. ddx/realtime_client/__init__.py +2 -0
  73. ddx/realtime_client/config.py +2 -0
  74. ddx/realtime_client/models/__init__.py +611 -0
  75. ddx/realtime_client/realtime_client.py +646 -0
  76. ddx/rest_client/__init__.py +0 -0
  77. ddx/rest_client/clients/__init__.py +0 -0
  78. ddx/rest_client/clients/base_client.py +60 -0
  79. ddx/rest_client/clients/market_client.py +1243 -0
  80. ddx/rest_client/clients/on_chain_client.py +439 -0
  81. ddx/rest_client/clients/signed_client.py +292 -0
  82. ddx/rest_client/clients/system_client.py +843 -0
  83. ddx/rest_client/clients/trade_client.py +357 -0
  84. ddx/rest_client/constants/__init__.py +0 -0
  85. ddx/rest_client/constants/endpoints.py +66 -0
  86. ddx/rest_client/contracts/__init__.py +0 -0
  87. ddx/rest_client/contracts/checkpoint/__init__.py +560 -0
  88. ddx/rest_client/contracts/ddx/__init__.py +1949 -0
  89. ddx/rest_client/contracts/dummy_token/__init__.py +1014 -0
  90. ddx/rest_client/contracts/i_collateral/__init__.py +1414 -0
  91. ddx/rest_client/contracts/i_stake/__init__.py +696 -0
  92. ddx/rest_client/exceptions/__init__.py +0 -0
  93. ddx/rest_client/exceptions/exceptions.py +32 -0
  94. ddx/rest_client/http/__init__.py +0 -0
  95. ddx/rest_client/http/http_client.py +336 -0
  96. ddx/rest_client/models/__init__.py +0 -0
  97. ddx/rest_client/models/market.py +693 -0
  98. ddx/rest_client/models/signed.py +61 -0
  99. ddx/rest_client/models/system.py +311 -0
  100. ddx/rest_client/models/trade.py +185 -0
  101. ddx/rest_client/utils/__init__.py +0 -0
  102. ddx/rest_client/utils/encryption_utils.py +26 -0
  103. ddx/utils/__init__.py +0 -0
  104. ddx_python-1.0.4.dist-info/METADATA +63 -0
  105. ddx_python-1.0.4.dist-info/RECORD +106 -0
  106. ddx_python-1.0.4.dist-info/WHEEL +5 -0
@@ -0,0 +1,646 @@
1
+ import os
2
+ import asyncio
3
+ import json
4
+ import websockets
5
+ from websockets import WebSocketClientProtocol
6
+ import logging
7
+ from .models import (
8
+ TradeSide,
9
+ FeedWithParams,
10
+ MarkPriceParams,
11
+ OrderBookL2Filter,
12
+ OrderBookL2Params,
13
+ OrderBookL3Order,
14
+ SubscribePayload,
15
+ UnsubscribePayload,
16
+ AcknowledgePayload,
17
+ OrderBookL2Payload,
18
+ OrderBookL3Payload,
19
+ MarkPricePayload,
20
+ OrderUpdatePayload,
21
+ StrategyUpdatePayload,
22
+ TraderUpdatePayload,
23
+ OrderBookL2Contents,
24
+ OrderBookL3Contents,
25
+ MarkPriceContents,
26
+ Feed,
27
+ MessageType,
28
+ Action,
29
+ FeedPayload,
30
+ SubscriptionPayload,
31
+ )
32
+ from typing import Awaitable, Callable, Optional, AsyncGenerator
33
+
34
+ from ddx._rust.decimal import Decimal
35
+
36
+ from .config import DEFAULT_RETRY_DELAY, MAX_RETRY_DELAY
37
+
38
+
39
+ class RealtimeClient:
40
+ """
41
+ The DerivaDEX Realtime API client.
42
+
43
+ This client connects to the DerivaDEX realtime WebSockets API to subscribe to various
44
+ data feeds such as order book snapshots (L2 and L3), mark prices, as well as
45
+ updates for orders, strategies, and traders.
46
+ """
47
+
48
+ def __init__(self, ws_url: str):
49
+ self._ws_url = ws_url
50
+
51
+ self._connection: Optional[WebSocketClientProtocol] = None
52
+ self._pending = {}
53
+ self._update_queue = asyncio.Queue()
54
+ self._listener_task = None
55
+ self._nonce = 0
56
+ # Active subscriptions: maps feed kind -> FeedWithParams.
57
+ # Keys give O(1) membership checks, values keep the latest params.
58
+ self._subscriptions: dict[Feed, FeedWithParams] = {}
59
+ # Internal state for special feeds
60
+ # L2 order book: symbol -> side (TradeSide) -> price -> amount (str)
61
+ self._order_book_l2_state: dict[str, dict[TradeSide, dict[str, str]]] = {}
62
+ self._order_book_l3_state: dict[str, OrderBookL3Order] = {}
63
+ self._mark_price_state: dict[str, str] = {}
64
+ self._funding_rate_state: dict[str, str] = {}
65
+ # Dictionary mapping each feed to its registered callback.
66
+ # If no callback is provided for a given feed, it will default to no operation.
67
+ self._callbacks: dict[
68
+ Feed,
69
+ Callable[[FeedPayload], None] | Callable[[FeedPayload], Awaitable[None]],
70
+ ] = {}
71
+
72
+ def _get_next_nonce(self) -> str:
73
+ nonce = str(self._nonce)
74
+ self._nonce += 1
75
+ return nonce
76
+
77
+ def _dispatch_message(self, msg: dict):
78
+ nonce = msg.get("nonce")
79
+ if "result" in msg and nonce and nonce in self._pending:
80
+ future = self._pending.pop(nonce)
81
+ try:
82
+ ack = AcknowledgePayload.model_validate(msg)
83
+ except Exception as exc:
84
+ logging.exception(f"Failed to decode acknowledgement message; nonce={nonce} msg={msg}")
85
+ future.set_exception(exc)
86
+ return
87
+ future.set_result(ack)
88
+ logging.debug(f"Dispatched acknowledgement message with nonce: {nonce}")
89
+ return
90
+ feed = msg.get("feed")
91
+ if feed is not None:
92
+ try:
93
+ feed_enum = Feed(feed)
94
+ except Exception:
95
+ logging.error(f"Unknown feed value in message: {feed} msg={msg}")
96
+ return
97
+
98
+ try:
99
+ if feed_enum == Feed.ORDER_BOOK_L2:
100
+ payload = OrderBookL2Payload.model_validate(msg)
101
+ self._update_order_book_l2(payload.contents)
102
+ elif feed_enum == Feed.ORDER_BOOK_L3:
103
+ payload = OrderBookL3Payload.model_validate(msg)
104
+ self._update_order_book_l3(payload.contents)
105
+ elif feed_enum == Feed.MARK_PRICE:
106
+ payload = MarkPricePayload.model_validate(msg)
107
+ self._update_mark_price(payload.contents)
108
+ elif feed_enum == Feed.ORDER_UPDATE:
109
+ payload = OrderUpdatePayload.model_validate(msg)
110
+ elif feed_enum == Feed.STRATEGY_UPDATE:
111
+ payload = StrategyUpdatePayload.model_validate(msg)
112
+ elif feed_enum == Feed.TRADER_UPDATE:
113
+ payload = TraderUpdatePayload.model_validate(msg)
114
+ else:
115
+ logging.error(f"Unhandled feed enum in message: {feed_enum}")
116
+ return
117
+ except Exception as exc:
118
+ logging.exception(f"Failed to decode/handle message for feed {feed_enum}; msg={msg}")
119
+ return
120
+
121
+ if feed_enum in self._callbacks:
122
+ callback = self._callbacks[feed_enum]
123
+ try:
124
+ if asyncio.iscoroutinefunction(callback):
125
+ asyncio.create_task(callback(payload))
126
+ else:
127
+ callback(payload)
128
+ except Exception as exc:
129
+ logging.exception(f"Error in callback for feed {feed_enum}: {exc}")
130
+ else:
131
+ self._update_queue.put_nowait(payload)
132
+
133
+ async def _listen(self):
134
+ """
135
+ Internal method to continuously listen for incoming messages,
136
+ dispatching responses (with nonce) to pending requests,
137
+ and placing all other messages into the update queue.
138
+ """
139
+ assert (
140
+ self._connection is not None
141
+ ), "Connection must be established before listening."
142
+ while True:
143
+ logging.debug("Update queue size: %d", self._update_queue.qsize())
144
+ try:
145
+ msg_raw = await self._connection.recv()
146
+ except Exception as e:
147
+ logging.error(f"Error receiving message: {e}")
148
+ await self.disconnect()
149
+ await self.connect()
150
+ break
151
+ try:
152
+ msg = json.loads(msg_raw)
153
+ except Exception as e:
154
+ logging.exception(f"Failed to parse incoming WS message; raw={msg_raw}")
155
+ continue
156
+ try:
157
+ self._dispatch_message(msg)
158
+ except Exception as e:
159
+ logging.exception(f"Unhandled error dispatching WS message; raw={msg_raw} parsed={msg}")
160
+ continue
161
+
162
+ async def receive_message(self, feed_name: Feed) -> FeedPayload:
163
+ """
164
+ Wait for an update message with the specified feed.
165
+ """
166
+ while True:
167
+ payload = await self._update_queue.get()
168
+ if payload.feed == feed_name:
169
+ logging.debug(f"Retrieved message for feed: {feed_name} from queue.")
170
+ return payload
171
+
172
+ async def connect(self) -> None:
173
+ """
174
+ Establish a WebSocket connection with reconnection logic.
175
+ """
176
+ delay = DEFAULT_RETRY_DELAY
177
+ while True:
178
+ try:
179
+ self._connection = await websockets.connect(self._ws_url)
180
+ break
181
+ except Exception as e:
182
+ logging.error(f"Connection failed: {e}. Retrying in {delay} seconds...")
183
+ await asyncio.sleep(delay)
184
+ delay = min(delay * 2, MAX_RETRY_DELAY)
185
+ self._listener_task = asyncio.create_task(self._listen())
186
+ # Restore any subscriptions that were active before a disconnect
187
+ if self._subscriptions:
188
+ await self._resubscribe()
189
+
190
+ async def disconnect(self) -> None:
191
+ """
192
+ Close the WebSocket connection, cancel the listener task,
193
+ and cancel any pending request futures.
194
+ """
195
+ if self._listener_task:
196
+ self._listener_task.cancel()
197
+ try:
198
+ await self._listener_task
199
+ except asyncio.CancelledError:
200
+ logging.info("Listener task cancelled successfully.")
201
+ # Cancel any pending futures
202
+ for nonce, future in self._pending.items():
203
+ if not future.done():
204
+ future.cancel()
205
+ logging.info(f"Cancelled pending future for nonce: {nonce}")
206
+ self._pending.clear()
207
+ if self._connection:
208
+ await self._connection.close()
209
+ logging.info("Connection closed.")
210
+
211
+ async def _send(self, message) -> None:
212
+ """
213
+ Send a message over the WebSocket connection.
214
+ If the message is not a string, it is assumed to be a dict and will be JSON-serialized.
215
+ Raises RuntimeError if no active connection exists.
216
+ """
217
+ if not self._connection:
218
+ raise RuntimeError("No active connection to send message.")
219
+ if not isinstance(message, str):
220
+ message = json.dumps(message)
221
+ logging.info(f"Sending message: {message}")
222
+ await self._connection.send(message)
223
+
224
+ async def _send_request(self, payload: SubscriptionPayload) -> AcknowledgePayload:
225
+ """
226
+ Send a request payload and await an acknowledgement.
227
+
228
+ This method registers a future keyed by the payload's nonce, then sends the JSON-serialized
229
+ payload over the WebSocket connection. It waits (up to 10 seconds) for an acknowledgement.
230
+ In case of a timeout, the pending future is cancelled and a TimeoutError is raised.
231
+
232
+ Nonce generation for each request is handled automatically via _get_next_nonce.
233
+ """
234
+ future = asyncio.get_running_loop().create_future()
235
+ self._pending[payload.nonce] = future
236
+ try:
237
+ await self._send(payload.model_dump_json())
238
+ logging.info(f"Request sent with nonce: {payload.nonce}")
239
+ except Exception as exc:
240
+ logging.error(f"Failed to send payload with nonce {payload.nonce}: {exc}")
241
+ future.cancel()
242
+ raise
243
+ try:
244
+ ack = await asyncio.wait_for(future, timeout=10)
245
+ except asyncio.TimeoutError:
246
+ logging.error(
247
+ f"Timeout waiting for acknowledgement for nonce: {payload.nonce}"
248
+ )
249
+ self._pending.pop(payload.nonce, None)
250
+ raise
251
+ logging.debug(f"Received acknowledgement for nonce: {payload.nonce}")
252
+ return ack
253
+
254
+ async def __aenter__(self):
255
+ await self.connect()
256
+ return self
257
+
258
+ async def __aexit__(self, exc_type, exc, tb):
259
+ await self.disconnect()
260
+
261
+ async def subscribe(
262
+ self,
263
+ payload: SubscribePayload,
264
+ callbacks: Optional[
265
+ dict[
266
+ Feed,
267
+ Callable[[FeedPayload], None]
268
+ | Callable[[FeedPayload], Awaitable[None]],
269
+ ]
270
+ ] = None,
271
+ ) -> AcknowledgePayload:
272
+ """
273
+ Send a subscription request and wait for an acknowledgement.
274
+
275
+ Optionally, register a dictionary mapping feeds to callback functions. Callbacks provided
276
+ here are merged with any existing callback registrations. Callback functions can be either
277
+ synchronous or asynchronous; if asynchronous (i.e. a coroutine function), they will be scheduled
278
+ using asyncio.create_task to avoid blocking message dispatch.
279
+
280
+ The acknowledgement received will confirm the subscription, with the nonce matching the request.
281
+ """
282
+ ack = await self._send_request(payload)
283
+ if callbacks is not None:
284
+ self._callbacks.update(callbacks)
285
+ return ack
286
+
287
+ async def subscribe_feeds(
288
+ self,
289
+ feeds: list[
290
+ FeedWithParams
291
+ | tuple[
292
+ FeedWithParams,
293
+ Callable[[FeedPayload], None]
294
+ | Callable[[FeedPayload], Awaitable[None]],
295
+ ],
296
+ ],
297
+ ) -> AcknowledgePayload:
298
+ payload_feeds: list[FeedWithParams] = []
299
+ callbacks: dict[
300
+ Feed,
301
+ Callable[[FeedPayload], None] | Callable[[FeedPayload], Awaitable[None]],
302
+ ] = {}
303
+ for item in feeds:
304
+ if isinstance(item, tuple):
305
+ feed_with_params, callback = item
306
+ payload_feeds.append(feed_with_params)
307
+ callbacks[feed_with_params.feed] = callback
308
+ else:
309
+ payload_feeds.append(item)
310
+ nonce = self._get_next_nonce()
311
+ payload = SubscribePayload(
312
+ action=Action.SUBSCRIBE,
313
+ nonce=nonce,
314
+ feeds=payload_feeds,
315
+ )
316
+ ack = await self.subscribe(payload, callbacks)
317
+ if ack.result.error is not None:
318
+ raise RuntimeError(f"Subscription failed with error: {ack.result.error}")
319
+ # Persist/overwrite successful subscriptions
320
+ for new_fw in payload_feeds:
321
+ self._subscriptions[new_fw.feed] = new_fw
322
+ return ack
323
+
324
+ async def unsubscribe(self, payload: UnsubscribePayload) -> AcknowledgePayload:
325
+ """
326
+ Send an unsubscription request and wait for an acknowledgement.
327
+ """
328
+ return await self._send_request(payload)
329
+
330
+ async def unsubscribe_feeds(
331
+ self,
332
+ feeds: list[Feed],
333
+ ):
334
+ nonce = self._get_next_nonce()
335
+ payload = UnsubscribePayload(
336
+ action=Action.UNSUBSCRIBE,
337
+ nonce=nonce,
338
+ feeds=feeds,
339
+ )
340
+ ack = await self.unsubscribe(payload)
341
+ if ack.result.error is not None:
342
+ raise RuntimeError(f"Unsubscription failed with error: {ack.result.error}")
343
+ for feed in feeds:
344
+ self._subscriptions.pop(feed, None)
345
+ self._callbacks.pop(feed, None)
346
+
347
+ async def receive_order_book_l2(self) -> AsyncGenerator[OrderBookL2Payload, None]:
348
+ """
349
+ Listen continuously for Order Book L2 updates.
350
+ """
351
+ if Feed.ORDER_BOOK_L2 not in self.subscribed:
352
+ raise RuntimeError(
353
+ "Order Book L2 feed is not subscribed. Please subscribe first."
354
+ )
355
+ while True:
356
+ payload = await self.receive_message(Feed.ORDER_BOOK_L2)
357
+ assert isinstance(payload, OrderBookL2Payload)
358
+ yield payload
359
+
360
+ async def receive_order_book_l3(self) -> AsyncGenerator[OrderBookL3Payload, None]:
361
+ """
362
+ Listen continuously for Order Book L3 updates.
363
+ """
364
+ if Feed.ORDER_BOOK_L3 not in self.subscribed:
365
+ raise RuntimeError(
366
+ "Order Book L3 feed is not subscribed. Please subscribe first."
367
+ )
368
+ while True:
369
+ payload = await self.receive_message(Feed.ORDER_BOOK_L3)
370
+ assert isinstance(payload, OrderBookL3Payload)
371
+ yield payload
372
+
373
+ async def receive_mark_price(self) -> AsyncGenerator[MarkPricePayload, None]:
374
+ """
375
+ Listen continuously for Mark Price updates.
376
+ """
377
+ if Feed.MARK_PRICE not in self.subscribed:
378
+ raise RuntimeError(
379
+ "Mark Price feed is not subscribed. Please subscribe first."
380
+ )
381
+ while True:
382
+ payload = await self.receive_message(Feed.MARK_PRICE)
383
+ assert isinstance(payload, MarkPricePayload)
384
+ yield payload
385
+
386
+ async def receive_order_update(self) -> AsyncGenerator[OrderUpdatePayload, None]:
387
+ """
388
+ Listen continuously for Order Update messages.
389
+ """
390
+ if Feed.ORDER_UPDATE not in self.subscribed:
391
+ raise RuntimeError(
392
+ "Order Update feed is not subscribed. Please subscribe first."
393
+ )
394
+ while True:
395
+ payload = await self.receive_message(Feed.ORDER_UPDATE)
396
+ assert isinstance(payload, OrderUpdatePayload)
397
+ yield payload
398
+
399
+ async def receive_strategy_update(
400
+ self,
401
+ ) -> AsyncGenerator[StrategyUpdatePayload, None]:
402
+ """
403
+ Listen continuously for Strategy Update messages.
404
+ """
405
+ if Feed.STRATEGY_UPDATE not in self.subscribed:
406
+ raise RuntimeError(
407
+ "Strategy Update feed is not subscribed. Please subscribe first."
408
+ )
409
+ while True:
410
+ payload = await self.receive_message(Feed.STRATEGY_UPDATE)
411
+ assert isinstance(payload, StrategyUpdatePayload)
412
+ yield payload
413
+
414
+ async def receive_trader_update(self) -> AsyncGenerator[TraderUpdatePayload, None]:
415
+ """
416
+ Listen continuously for Trader Update messages.
417
+ """
418
+ if Feed.TRADER_UPDATE not in self.subscribed:
419
+ raise RuntimeError(
420
+ "Trader Update feed is not subscribed. Please subscribe first."
421
+ )
422
+ while True:
423
+ payload = await self.receive_message(Feed.TRADER_UPDATE)
424
+ assert isinstance(payload, TraderUpdatePayload)
425
+ yield payload
426
+
427
+ @property
428
+ def subscribed(self) -> set[Feed]:
429
+ """
430
+ Current set of feed kinds we are subscribed to.
431
+ """
432
+ return set(self._subscriptions.keys())
433
+
434
+ @property
435
+ def order_book_l2(self) -> dict[str, dict[TradeSide, dict[str, str]]]:
436
+ """
437
+ Returns a deep copy of the current Order Book L2 state:
438
+ { symbol: { side(TradeSide): { price: amount_str } } }
439
+ """
440
+ return {
441
+ s: {sd: lvls.copy() for sd, lvls in sides.items()}
442
+ for s, sides in self._order_book_l2_state.items()
443
+ }
444
+
445
+ def aggregated_order(
446
+ self, symbol: str
447
+ ) -> Optional[dict[TradeSide, dict[str, str]]]:
448
+ """
449
+ Return a snapshot for `symbol` (if available) in the same
450
+ nested-dict format used by `order_book_l2`:
451
+
452
+ { side(TradeSide): { price: amount_str } }
453
+ """
454
+ return self._order_book_l2_state.get(symbol)
455
+
456
+ @property
457
+ def order_book_l3(self) -> dict[str, OrderBookL3Order]:
458
+ """
459
+ Returns a copy of the current Order Book L3 state.
460
+ """
461
+ return self._order_book_l3_state.copy()
462
+
463
+ def order(self, order_hash: str) -> Optional[OrderBookL3Order]:
464
+ """
465
+ Returns the order for a specific order hash.
466
+ """
467
+ return self._order_book_l3_state.get(order_hash)
468
+
469
+ @property
470
+ def mark_prices(self) -> dict[str, str]:
471
+ """
472
+ Returns a copy of the current Mark Price state.
473
+ """
474
+ return self._mark_price_state.copy()
475
+
476
+ def mark_price(self, symbol: str) -> Optional[str]:
477
+ """
478
+ Returns the mark price for a specific symbol.
479
+ """
480
+ return self._mark_price_state.get(symbol)
481
+
482
+ @property
483
+ def funding_rates(self) -> dict[str, str]:
484
+ """
485
+ Returns a copy of the current Funding Rate state.
486
+ """
487
+ return self._funding_rate_state.copy()
488
+
489
+ def funding_rate(self, symbol: str) -> Optional[str]:
490
+ """
491
+ Returns the funding rate for a specific symbol.
492
+ """
493
+ return self._funding_rate_state.get(symbol)
494
+
495
+ # --- Internal state update methods for special feeds ---
496
+ def _update_order_book_l2(self, contents: OrderBookL2Contents):
497
+ """
498
+ Maintain an in-memory L2 book:
499
+ { symbol: { side(TradeSide): { price: amount_str } } }
500
+
501
+ A PARTIAL message replaces the entire book for the given symbol,
502
+ while an UPDATE message applies deltas. When the incoming
503
+ amount is the string "0" the corresponding price level is
504
+ removed. Empty side / symbol maps are pruned.
505
+ """
506
+ update_type = contents.message_type
507
+ data = contents.data
508
+
509
+ if update_type == MessageType.PARTIAL:
510
+ # Build fresh maps for only the symbols present in this snapshot
511
+ snapshot: dict[str, dict[TradeSide, dict[str, str]]] = {}
512
+ for lvl in data:
513
+ symbol, side = lvl.symbol, lvl.side
514
+ snapshot.setdefault(symbol, {}).setdefault(side, {})[
515
+ lvl.price
516
+ ] = lvl.amount
517
+ # Replace the symbols present in the snapshot; keep others intact
518
+ for symbol, book in snapshot.items():
519
+ self._order_book_l2_state[symbol] = book
520
+ elif update_type == MessageType.UPDATE:
521
+ for delta in data:
522
+ sym, side, price, amt = (
523
+ delta.symbol,
524
+ delta.side,
525
+ delta.price,
526
+ delta.amount,
527
+ )
528
+ if Decimal(amt) == Decimal("0"):
529
+ # remove level if it exists
530
+ side_levels = self._order_book_l2_state.get(sym, {}).get(side)
531
+ if side_levels is not None:
532
+ side_levels.pop(price, None)
533
+ # prune empty dicts
534
+ if not side_levels:
535
+ self._order_book_l2_state[sym].pop(side, None)
536
+ if not self._order_book_l2_state[sym]:
537
+ self._order_book_l2_state.pop(sym, None)
538
+ else:
539
+ self._order_book_l2_state.setdefault(sym, {}).setdefault(side, {})[
540
+ price
541
+ ] = amt
542
+
543
+ def _update_order_book_l3(self, contents: OrderBookL3Contents):
544
+ """
545
+ Update the internal order book L3 state with a PARTIAL snapshot or delta UPDATE.
546
+ Here we key by the unique orderHash.
547
+ """
548
+ update_type = contents.message_type
549
+ data = contents.data
550
+ if update_type == MessageType.PARTIAL:
551
+ state = {}
552
+ for order in data:
553
+ state[order.order_hash] = order
554
+ self._order_book_l3_state = state
555
+ elif update_type == MessageType.UPDATE:
556
+ for delta in data:
557
+ key = delta.order_hash
558
+ if Decimal(delta.amount) == Decimal("0"):
559
+ self._order_book_l3_state.pop(key, None)
560
+ else:
561
+ self._order_book_l3_state[key] = delta
562
+
563
+ def _update_mark_price(self, contents: MarkPriceContents):
564
+ """
565
+ Update the mark price state which is a dict mapping symbols to mark prices.
566
+ A PARTIAL sets the full state and an UPDATE modifies only the entries provided.
567
+ """
568
+ update_type = contents.message_type
569
+ data = contents.data
570
+ if update_type == MessageType.PARTIAL:
571
+ mark_price_state = {}
572
+ funding_rate_state = {}
573
+ for entry in data:
574
+ mark_price_state[entry.symbol] = entry.price
575
+ funding_rate_state[entry.symbol] = entry.funding_rate
576
+ self._mark_price_state = mark_price_state
577
+ self._funding_rate_state = funding_rate_state
578
+ elif update_type == MessageType.UPDATE:
579
+ for entry in data:
580
+ self._mark_price_state[entry.symbol] = entry.price
581
+ self._funding_rate_state[entry.symbol] = entry.funding_rate
582
+
583
+ async def _resubscribe(self) -> None:
584
+ """
585
+ Re-establish every subscription that existed before the last disconnect.
586
+ """
587
+ if not self._subscriptions:
588
+ return
589
+
590
+ feeds_spec = []
591
+ for fw in self._subscriptions.values():
592
+ cb = self._callbacks.get(fw.feed)
593
+ feeds_spec.append((fw, cb) if cb is not None else fw)
594
+
595
+ try:
596
+ await self.subscribe_feeds(feeds_spec)
597
+ logging.info("Resubscribed to previous feeds.")
598
+ except Exception as exc:
599
+ logging.error(f"Resubscription failed: {exc}")
600
+
601
+
602
+ if __name__ == "__main__":
603
+
604
+ async def main():
605
+ client = RealtimeClient(
606
+ os.environ.get(
607
+ "REALTIME_API_WS_URL", "wss://exchange.derivadex.com/realtime-api"
608
+ )
609
+ )
610
+ await client.connect()
611
+
612
+ def handle_mark_price(payload):
613
+ print(f"Mark Price Update: {payload.contents}")
614
+
615
+ ack = await client.subscribe_feeds(
616
+ [
617
+ FeedWithParams(
618
+ feed=Feed.ORDER_BOOK_L2,
619
+ params=OrderBookL2Params(
620
+ order_book_l2_filters=[
621
+ OrderBookL2Filter(symbol="ETHP", aggregation=1)
622
+ ]
623
+ ),
624
+ ),
625
+ (
626
+ FeedWithParams(
627
+ feed=Feed.MARK_PRICE,
628
+ params=MarkPriceParams(symbols=["ETHP", "BTCP"]),
629
+ ),
630
+ handle_mark_price,
631
+ ),
632
+ ]
633
+ )
634
+ print("Subscription Acknowledgement:", ack)
635
+
636
+ print("Waiting for Order Book L2 snapshot...")
637
+ update = await anext(client.receive_order_book_l2())
638
+ print("Received Order Book L2 snapshot:", update)
639
+
640
+ print("Waiting for Mark Price update...")
641
+ mark_price_update = await anext(client.receive_mark_price())
642
+ print("Received Mark Price update:", mark_price_update)
643
+
644
+ await client.disconnect()
645
+
646
+ asyncio.run(main())
File without changes
File without changes