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.
- ddx/.gitignore +1 -0
- ddx/__init__.py +58 -0
- ddx/_rust/__init__.pyi +2685 -0
- ddx/_rust/common/__init__.pyi +17 -0
- ddx/_rust/common/accounting.pyi +6 -0
- ddx/_rust/common/enums.pyi +3 -0
- ddx/_rust/common/requests/__init__.pyi +23 -0
- ddx/_rust/common/requests/intents.pyi +19 -0
- ddx/_rust/common/specs.pyi +17 -0
- ddx/_rust/common/state/__init__.pyi +41 -0
- ddx/_rust/common/state/keys.pyi +29 -0
- ddx/_rust/common/transactions.pyi +7 -0
- ddx/_rust/decimal.pyi +3 -0
- ddx/_rust/h256.pyi +3 -0
- ddx/_rust.abi3.so +0 -0
- ddx/app_config/ethereum/addresses.json +526 -0
- ddx/auditor/README.md +32 -0
- ddx/auditor/__init__.py +0 -0
- ddx/auditor/auditor_driver.py +1043 -0
- ddx/auditor/websocket_message.py +54 -0
- ddx/common/__init__.py +0 -0
- ddx/common/epoch_params.py +28 -0
- ddx/common/fill_context.py +141 -0
- ddx/common/logging.py +184 -0
- ddx/common/market_aware_account.py +259 -0
- ddx/common/market_specs.py +64 -0
- ddx/common/trade_mining_params.py +19 -0
- ddx/common/transaction_utils.py +85 -0
- ddx/common/transactions/__init__.py +0 -0
- ddx/common/transactions/advance_epoch.py +91 -0
- ddx/common/transactions/advance_settlement_epoch.py +63 -0
- ddx/common/transactions/all_price_checkpoints.py +84 -0
- ddx/common/transactions/cancel.py +76 -0
- ddx/common/transactions/cancel_all.py +88 -0
- ddx/common/transactions/complete_fill.py +103 -0
- ddx/common/transactions/disaster_recovery.py +96 -0
- ddx/common/transactions/event.py +48 -0
- ddx/common/transactions/fee_distribution.py +119 -0
- ddx/common/transactions/funding.py +292 -0
- ddx/common/transactions/futures_expiry.py +123 -0
- ddx/common/transactions/genesis.py +108 -0
- ddx/common/transactions/inner/__init__.py +0 -0
- ddx/common/transactions/inner/adl_outcome.py +25 -0
- ddx/common/transactions/inner/fill.py +232 -0
- ddx/common/transactions/inner/liquidated_position.py +41 -0
- ddx/common/transactions/inner/liquidation_entry.py +41 -0
- ddx/common/transactions/inner/liquidation_fill.py +118 -0
- ddx/common/transactions/inner/outcome.py +32 -0
- ddx/common/transactions/inner/trade_fill.py +292 -0
- ddx/common/transactions/insurance_fund_update.py +138 -0
- ddx/common/transactions/insurance_fund_withdraw.py +100 -0
- ddx/common/transactions/liquidation.py +353 -0
- ddx/common/transactions/partial_fill.py +125 -0
- ddx/common/transactions/pnl_realization.py +120 -0
- ddx/common/transactions/post.py +72 -0
- ddx/common/transactions/post_order.py +95 -0
- ddx/common/transactions/price_checkpoint.py +97 -0
- ddx/common/transactions/signer_registered.py +62 -0
- ddx/common/transactions/specs_update.py +61 -0
- ddx/common/transactions/strategy_update.py +158 -0
- ddx/common/transactions/tradable_product_update.py +98 -0
- ddx/common/transactions/trade_mining.py +147 -0
- ddx/common/transactions/trader_update.py +131 -0
- ddx/common/transactions/withdraw.py +90 -0
- ddx/common/transactions/withdraw_ddx.py +74 -0
- ddx/common/utils.py +176 -0
- ddx/config.py +17 -0
- ddx/derivadex_client.py +270 -0
- ddx/models/__init__.py +0 -0
- ddx/models/base.py +132 -0
- ddx/py.typed +0 -0
- ddx/realtime_client/__init__.py +2 -0
- ddx/realtime_client/config.py +2 -0
- ddx/realtime_client/models/__init__.py +611 -0
- ddx/realtime_client/realtime_client.py +646 -0
- ddx/rest_client/__init__.py +0 -0
- ddx/rest_client/clients/__init__.py +0 -0
- ddx/rest_client/clients/base_client.py +60 -0
- ddx/rest_client/clients/market_client.py +1243 -0
- ddx/rest_client/clients/on_chain_client.py +439 -0
- ddx/rest_client/clients/signed_client.py +292 -0
- ddx/rest_client/clients/system_client.py +843 -0
- ddx/rest_client/clients/trade_client.py +357 -0
- ddx/rest_client/constants/__init__.py +0 -0
- ddx/rest_client/constants/endpoints.py +66 -0
- ddx/rest_client/contracts/__init__.py +0 -0
- ddx/rest_client/contracts/checkpoint/__init__.py +560 -0
- ddx/rest_client/contracts/ddx/__init__.py +1949 -0
- ddx/rest_client/contracts/dummy_token/__init__.py +1014 -0
- ddx/rest_client/contracts/i_collateral/__init__.py +1414 -0
- ddx/rest_client/contracts/i_stake/__init__.py +696 -0
- ddx/rest_client/exceptions/__init__.py +0 -0
- ddx/rest_client/exceptions/exceptions.py +32 -0
- ddx/rest_client/http/__init__.py +0 -0
- ddx/rest_client/http/http_client.py +336 -0
- ddx/rest_client/models/__init__.py +0 -0
- ddx/rest_client/models/market.py +693 -0
- ddx/rest_client/models/signed.py +61 -0
- ddx/rest_client/models/system.py +311 -0
- ddx/rest_client/models/trade.py +185 -0
- ddx/rest_client/utils/__init__.py +0 -0
- ddx/rest_client/utils/encryption_utils.py +26 -0
- ddx/utils/__init__.py +0 -0
- ddx_python-1.0.4.dist-info/METADATA +63 -0
- ddx_python-1.0.4.dist-info/RECORD +106 -0
- 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
|