Qubx 0.6.94__cp312-cp312-manylinux_2_39_x86_64.whl → 0.7.3__cp312-cp312-manylinux_2_39_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.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/backtester/utils.py +1 -1
- qubx/connectors/ccxt/data.py +32 -29
- qubx/connectors/xlighter/account.py +52 -33
- qubx/connectors/xlighter/client.py +41 -3
- qubx/connectors/xlighter/constants.py +6 -2
- qubx/connectors/xlighter/data.py +80 -12
- qubx/connectors/xlighter/factory.py +34 -3
- qubx/connectors/xlighter/handlers/base.py +12 -0
- qubx/connectors/xlighter/handlers/orderbook.py +228 -25
- qubx/connectors/xlighter/rate_limits.py +96 -0
- qubx/connectors/xlighter/websocket.py +57 -18
- qubx/core/basics.py +28 -2
- qubx/core/context.py +8 -4
- qubx/core/interfaces.py +1 -1
- qubx/core/metrics.py +12 -6
- qubx/core/mixins/processing.py +1 -1
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pyx +2 -2
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/data/readers.py +4 -1
- qubx/data/storages/questdb.py +1 -1
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pyi +6 -0
- qubx/ta/indicators.pyx +102 -1
- qubx/utils/hft/__init__.py +5 -0
- qubx/utils/hft/numba_utils.py +12 -0
- qubx/utils/hft/orderbook.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/hft/orderbook.pyi +177 -0
- qubx/utils/hft/orderbook.pyx +416 -0
- qubx/utils/misc.py +6 -1
- qubx/utils/orderbook.py +1 -101
- qubx/utils/rate_limiter.py +225 -0
- qubx/utils/runner/configs.py +14 -7
- qubx/utils/runner/runner.py +3 -0
- qubx/utils/runner/textual/app.py +20 -28
- qubx/utils/runner/textual/handlers.py +11 -4
- qubx/utils/runner/textual/init_code.py +23 -23
- qubx/utils/runner/textual/widgets/orders_table.py +209 -27
- qubx/utils/runner/textual/widgets/positions_table.py +288 -49
- qubx/utils/runner/textual/widgets/quotes_table.py +170 -29
- qubx/utils/runner/textual/widgets/repl_output.py +87 -82
- qubx/utils/time.py +97 -1
- qubx/utils/websocket_manager.py +278 -420
- {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/METADATA +2 -2
- {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/RECORD +48 -41
- {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/WHEEL +0 -0
- {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/entry_points.txt +0 -0
- {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/licenses/LICENSE +0 -0
qubx/backtester/utils.py
CHANGED
|
@@ -155,7 +155,7 @@ class SimulatedLogFormatter:
|
|
|
155
155
|
now = self.time_provider.time().astype("datetime64[us]").item().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
156
156
|
|
|
157
157
|
# prefix = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> [ <level>%s</level> ] " % record["level"].icon
|
|
158
|
-
prefix = f"<lc>{now}</lc> [<level>{record['level'].icon}</level>] "
|
|
158
|
+
prefix = f"<lc>{now}</lc> [<level>{record['level'].icon}</level>] <cyan>({{module}})</cyan> "
|
|
159
159
|
|
|
160
160
|
if record["exception"] is not None:
|
|
161
161
|
record["extra"]["stack"] = stackprinter.format(record["exception"], style="darkbg3")
|
qubx/connectors/ccxt/data.py
CHANGED
|
@@ -43,18 +43,15 @@ class CcxtDataProvider(IDataProvider):
|
|
|
43
43
|
):
|
|
44
44
|
# Store the exchange manager (always ExchangeManager now)
|
|
45
45
|
self._exchange_manager = exchange_manager
|
|
46
|
-
|
|
46
|
+
|
|
47
47
|
self.time_provider = time_provider
|
|
48
48
|
self.channel = channel
|
|
49
49
|
self.max_ws_retries = max_ws_retries
|
|
50
50
|
self._warmup_timeout = warmup_timeout
|
|
51
51
|
self._health_monitor = health_monitor or DummyHealthMonitor()
|
|
52
52
|
|
|
53
|
-
self._data_arrival_listeners: List[IDataArrivalListener] = [
|
|
54
|
-
|
|
55
|
-
self._exchange_manager
|
|
56
|
-
]
|
|
57
|
-
|
|
53
|
+
self._data_arrival_listeners: List[IDataArrivalListener] = [self._health_monitor, self._exchange_manager]
|
|
54
|
+
|
|
58
55
|
logger.debug(f"Registered {len(self._data_arrival_listeners)} data arrival listeners")
|
|
59
56
|
|
|
60
57
|
# Core components - access exchange directly via exchange_manager.exchange
|
|
@@ -106,10 +103,10 @@ class CcxtDataProvider(IDataProvider):
|
|
|
106
103
|
def _loop(self) -> AsyncThreadLoop:
|
|
107
104
|
"""Get current AsyncThreadLoop for the exchange."""
|
|
108
105
|
return AsyncThreadLoop(self._exchange_manager.exchange.asyncio_loop)
|
|
109
|
-
|
|
106
|
+
|
|
110
107
|
def notify_data_arrival(self, event_type: str, event_time: dt_64) -> None:
|
|
111
108
|
"""Notify all registered listeners about data arrival.
|
|
112
|
-
|
|
109
|
+
|
|
113
110
|
Args:
|
|
114
111
|
event_type: Type of data event (e.g., "ohlcv:BTC/USDT:1m")
|
|
115
112
|
event_time: Timestamp of the data event
|
|
@@ -120,10 +117,6 @@ class CcxtDataProvider(IDataProvider):
|
|
|
120
117
|
except Exception as e:
|
|
121
118
|
logger.error(f"Error notifying data arrival listener {type(listener).__name__}: {e}")
|
|
122
119
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
120
|
@property
|
|
128
121
|
def is_simulation(self) -> bool:
|
|
129
122
|
return False
|
|
@@ -219,7 +212,7 @@ class CcxtDataProvider(IDataProvider):
|
|
|
219
212
|
self._warmup_service.execute_warmup(warmups)
|
|
220
213
|
|
|
221
214
|
def get_quote(self, instrument: Instrument) -> Quote | None:
|
|
222
|
-
return self._last_quotes
|
|
215
|
+
return self._last_quotes.get(instrument, None)
|
|
223
216
|
|
|
224
217
|
def get_ohlc(self, instrument: Instrument, timeframe: str, nbarsback: int) -> List[Bar]:
|
|
225
218
|
"""Get historical OHLC data (delegated to OhlcDataHandler)."""
|
|
@@ -259,9 +252,9 @@ class CcxtDataProvider(IDataProvider):
|
|
|
259
252
|
except Exception as e:
|
|
260
253
|
logger.error(f"Error stopping subscription {subscription_type}: {e}")
|
|
261
254
|
|
|
262
|
-
# Stop ExchangeManager monitoring
|
|
255
|
+
# Stop ExchangeManager monitoring
|
|
263
256
|
self._exchange_manager.stop_monitoring()
|
|
264
|
-
|
|
257
|
+
|
|
265
258
|
# Close exchange connection
|
|
266
259
|
if hasattr(self._exchange_manager.exchange, "close"):
|
|
267
260
|
future = self._loop.submit(self._exchange_manager.exchange.close()) # type: ignore
|
|
@@ -277,47 +270,57 @@ class CcxtDataProvider(IDataProvider):
|
|
|
277
270
|
|
|
278
271
|
def _handle_exchange_recreation(self) -> None:
|
|
279
272
|
"""Handle exchange recreation by resubscribing to all active subscriptions."""
|
|
280
|
-
logger.info(
|
|
281
|
-
|
|
273
|
+
logger.info(
|
|
274
|
+
f"<yellow>{self._exchange_id}</yellow> Handling exchange recreation - resubscribing to active subscriptions"
|
|
275
|
+
)
|
|
276
|
+
|
|
282
277
|
# Get snapshot of current subscriptions before cleanup
|
|
283
278
|
active_subscriptions = self._subscription_manager.get_subscriptions()
|
|
284
|
-
|
|
279
|
+
|
|
285
280
|
resubscription_data = []
|
|
286
281
|
for subscription_type in active_subscriptions:
|
|
287
282
|
instruments = self._subscription_manager.get_subscribed_instruments(subscription_type)
|
|
288
283
|
if instruments:
|
|
289
284
|
resubscription_data.append((subscription_type, instruments))
|
|
290
|
-
|
|
291
|
-
logger.info(
|
|
292
|
-
|
|
285
|
+
|
|
286
|
+
logger.info(
|
|
287
|
+
f"<yellow>{self._exchange_id}</yellow> Found {len(resubscription_data)} active subscriptions to recreate"
|
|
288
|
+
)
|
|
289
|
+
|
|
293
290
|
# Track success/failure counts for reporting
|
|
294
291
|
successful_resubscriptions = 0
|
|
295
292
|
failed_resubscriptions = 0
|
|
296
|
-
|
|
293
|
+
|
|
297
294
|
# Clean resubscription: unsubscribe then subscribe for each subscription type
|
|
298
295
|
for subscription_type, instruments in resubscription_data:
|
|
299
296
|
try:
|
|
300
|
-
logger.info(
|
|
301
|
-
|
|
297
|
+
logger.info(
|
|
298
|
+
f"<yellow>{self._exchange_id}</yellow> Resubscribing to {subscription_type} with {len(instruments)} instruments"
|
|
299
|
+
)
|
|
300
|
+
|
|
302
301
|
self.unsubscribe(subscription_type, instruments)
|
|
303
302
|
|
|
304
303
|
# Resubscribe with reset=True to ensure clean state
|
|
305
304
|
self.subscribe(subscription_type, instruments, reset=True)
|
|
306
|
-
|
|
305
|
+
|
|
307
306
|
successful_resubscriptions += 1
|
|
308
307
|
logger.debug(f"<yellow>{self._exchange_id}</yellow> Successfully resubscribed to {subscription_type}")
|
|
309
|
-
|
|
308
|
+
|
|
310
309
|
except Exception as e:
|
|
311
310
|
failed_resubscriptions += 1
|
|
312
311
|
logger.error(f"<yellow>{self._exchange_id}</yellow> Failed to resubscribe to {subscription_type}: {e}")
|
|
313
312
|
# Continue with other subscriptions even if one fails
|
|
314
|
-
|
|
313
|
+
|
|
315
314
|
# Report final status
|
|
316
315
|
total_subscriptions = len(resubscription_data)
|
|
317
316
|
if failed_resubscriptions == 0:
|
|
318
|
-
logger.info(
|
|
317
|
+
logger.info(
|
|
318
|
+
f"<yellow>{self._exchange_id}</yellow> Exchange recreation resubscription completed successfully ({total_subscriptions}/{total_subscriptions})"
|
|
319
|
+
)
|
|
319
320
|
else:
|
|
320
|
-
logger.warning(
|
|
321
|
+
logger.warning(
|
|
322
|
+
f"<yellow>{self._exchange_id}</yellow> Exchange recreation resubscription completed with errors ({successful_resubscriptions}/{total_subscriptions} successful)"
|
|
323
|
+
)
|
|
321
324
|
|
|
322
325
|
@property
|
|
323
326
|
def subscribed_instruments(self) -> Set[Instrument]:
|
|
@@ -21,7 +21,6 @@ from qubx.core.account import BasicAccountProcessor
|
|
|
21
21
|
from qubx.core.basics import (
|
|
22
22
|
ZERO_COSTS,
|
|
23
23
|
CtrlChannel,
|
|
24
|
-
DataType,
|
|
25
24
|
Deal,
|
|
26
25
|
Instrument,
|
|
27
26
|
ITimeProvider,
|
|
@@ -121,8 +120,9 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
121
120
|
self._processed_tx_hashes: set[str] = set() # Track processed transaction hashes
|
|
122
121
|
|
|
123
122
|
self._account_stats_initialized = False
|
|
123
|
+
self._account_positions_initialized = False
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
self.__info(f"Initialized LighterAccountProcessor for account {account_id}")
|
|
126
126
|
|
|
127
127
|
def set_subscription_manager(self, manager: ISubscriptionManager) -> None:
|
|
128
128
|
"""Set the subscription manager (required by interface)"""
|
|
@@ -131,37 +131,37 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
131
131
|
def get_total_capital(self, exchange: str | None = None) -> float:
|
|
132
132
|
if not self._account_stats_initialized:
|
|
133
133
|
self._async_loop.submit(self._start_subscriptions())
|
|
134
|
-
self.
|
|
134
|
+
self._wait_for_account_initialized()
|
|
135
135
|
return super().get_total_capital(exchange)
|
|
136
136
|
|
|
137
137
|
def start(self):
|
|
138
138
|
"""Start WebSocket subscriptions for account data"""
|
|
139
139
|
if self._is_running:
|
|
140
|
-
|
|
140
|
+
self.__debug("Account processor is already running")
|
|
141
141
|
return
|
|
142
142
|
|
|
143
143
|
if not self.channel or not self.channel.control.is_set():
|
|
144
|
-
|
|
144
|
+
self.__warning("Channel not set or control not active, cannot start")
|
|
145
145
|
return
|
|
146
146
|
|
|
147
147
|
self._is_running = True
|
|
148
148
|
|
|
149
149
|
# Start subscription tasks using AsyncThreadLoop
|
|
150
|
-
|
|
150
|
+
self.__info("Starting Lighter account subscriptions")
|
|
151
151
|
|
|
152
152
|
if not self._account_stats_initialized:
|
|
153
153
|
# Submit connection and subscription tasks to the event loop
|
|
154
154
|
self._async_loop.submit(self._start_subscriptions())
|
|
155
|
-
self.
|
|
155
|
+
self._wait_for_account_initialized()
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
self.__info("Lighter account subscriptions started")
|
|
158
158
|
|
|
159
159
|
def stop(self):
|
|
160
160
|
"""Stop all WebSocket subscriptions"""
|
|
161
161
|
if not self._is_running:
|
|
162
162
|
return
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
self.__info("Stopping Lighter account subscriptions")
|
|
165
165
|
|
|
166
166
|
# Cancel all subscription tasks
|
|
167
167
|
for task in self._subscription_tasks:
|
|
@@ -170,7 +170,7 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
170
170
|
|
|
171
171
|
self._subscription_tasks.clear()
|
|
172
172
|
self._is_running = False
|
|
173
|
-
|
|
173
|
+
self.__info("Lighter account subscriptions stopped")
|
|
174
174
|
|
|
175
175
|
def process_deals(self, instrument: Instrument, deals: list[Deal], is_snapshot: bool = False) -> None:
|
|
176
176
|
"""
|
|
@@ -200,12 +200,12 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
200
200
|
# Add fee to position's commission tracking
|
|
201
201
|
position.commissions += deal.fee_amount
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
self.__debug(
|
|
204
204
|
f"Tracked fee for {instrument.symbol}: {deal.fee_amount:.6f} {deal.fee_currency} "
|
|
205
205
|
f"(total commissions: {position.commissions:.6f})"
|
|
206
206
|
)
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
self.__debug(
|
|
209
209
|
f"Processed {len(deals)} deal(s) for {instrument.symbol} - fees tracked, positions synced from account_all"
|
|
210
210
|
)
|
|
211
211
|
|
|
@@ -227,7 +227,7 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
227
227
|
# Get the existing order stored under client_id
|
|
228
228
|
existing_order = self._active_orders[order.client_id]
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
self.__debug(f"Migrating order: client_id={order.client_id} → server_id={order.id}")
|
|
231
231
|
|
|
232
232
|
# Remove from old location
|
|
233
233
|
self._active_orders.pop(order.client_id)
|
|
@@ -245,13 +245,13 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
245
245
|
# The base class will now find the existing order under order.id and merge in place
|
|
246
246
|
super().process_order(order, update_locked_value)
|
|
247
247
|
|
|
248
|
-
def
|
|
248
|
+
def _wait_for_account_initialized(self):
|
|
249
249
|
max_wait_time = 20.0 # seconds
|
|
250
250
|
elapsed = 0.0
|
|
251
251
|
interval = 0.1
|
|
252
|
-
while not self._account_stats_initialized:
|
|
252
|
+
while not self._account_stats_initialized or not self._account_positions_initialized:
|
|
253
253
|
if elapsed >= max_wait_time:
|
|
254
|
-
raise TimeoutError(f"Account
|
|
254
|
+
raise TimeoutError(f"Account was not initialized within {max_wait_time} seconds")
|
|
255
255
|
time.sleep(interval)
|
|
256
256
|
elapsed += interval
|
|
257
257
|
|
|
@@ -260,9 +260,9 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
260
260
|
try:
|
|
261
261
|
# Ensure WebSocket is connected
|
|
262
262
|
if not self.ws_manager.is_connected:
|
|
263
|
-
|
|
263
|
+
self.__info("Connecting to Lighter WebSocket...")
|
|
264
264
|
await self.ws_manager.connect()
|
|
265
|
-
|
|
265
|
+
self.__info("Connected to Lighter WebSocket")
|
|
266
266
|
|
|
267
267
|
# Start all subscriptions
|
|
268
268
|
await self._subscribe_account_all()
|
|
@@ -270,16 +270,16 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
270
270
|
await self._subscribe_user_stats()
|
|
271
271
|
|
|
272
272
|
except Exception as e:
|
|
273
|
-
|
|
273
|
+
self.__error(f"Failed to start subscriptions: {e}")
|
|
274
274
|
self._is_running = False
|
|
275
275
|
raise
|
|
276
276
|
|
|
277
277
|
async def _subscribe_account_all(self):
|
|
278
278
|
try:
|
|
279
279
|
await self.ws_manager.subscribe_account_all(self._lighter_account_index, self._handle_account_all_message)
|
|
280
|
-
|
|
280
|
+
self.__info(f"Subscribed to account_all for account {self._lighter_account_index}")
|
|
281
281
|
except Exception as e:
|
|
282
|
-
|
|
282
|
+
self.__error(f"Failed to subscribe to account_all for account {self._lighter_account_index}: {e}")
|
|
283
283
|
raise
|
|
284
284
|
|
|
285
285
|
async def _subscribe_account_all_orders(self):
|
|
@@ -287,17 +287,17 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
287
287
|
await self.ws_manager.subscribe_account_all_orders(
|
|
288
288
|
self._lighter_account_index, self._handle_account_all_orders_message
|
|
289
289
|
)
|
|
290
|
-
|
|
290
|
+
self.__info(f"Subscribed to account_all_orders for account {self._lighter_account_index}")
|
|
291
291
|
except Exception as e:
|
|
292
|
-
|
|
292
|
+
self.__error(f"Failed to subscribe to account_all_orders for account {self._lighter_account_index}: {e}")
|
|
293
293
|
raise
|
|
294
294
|
|
|
295
295
|
async def _subscribe_user_stats(self):
|
|
296
296
|
try:
|
|
297
297
|
await self.ws_manager.subscribe_user_stats(self._lighter_account_index, self._handle_user_stats_message)
|
|
298
|
-
|
|
298
|
+
self.__info(f"Subscribed to user_stats for account {self._lighter_account_index}")
|
|
299
299
|
except Exception as e:
|
|
300
|
-
|
|
300
|
+
self.__error(f"Failed to subscribe to user_stats for account {self._lighter_account_index}: {e}")
|
|
301
301
|
raise
|
|
302
302
|
|
|
303
303
|
async def _handle_account_all_message(self, message: dict):
|
|
@@ -335,11 +335,15 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
335
335
|
if position.last_update_price == 0 or np.isnan(position.last_update_price):
|
|
336
336
|
position.last_update_price = pos_state.avg_entry_price
|
|
337
337
|
|
|
338
|
-
|
|
338
|
+
self.__debug(
|
|
339
339
|
f"Synced position: {instrument.symbol} = {pos_state.quantity:+.4f} "
|
|
340
340
|
f"@ avg_price={pos_state.avg_entry_price:.4f}"
|
|
341
341
|
)
|
|
342
342
|
|
|
343
|
+
if not self._account_positions_initialized:
|
|
344
|
+
self._account_positions_initialized = True
|
|
345
|
+
self.__info("Account positions initialized")
|
|
346
|
+
|
|
343
347
|
# Send deals through channel for strategy notification
|
|
344
348
|
# Note: process_deals is overridden to track fees without updating positions
|
|
345
349
|
for instrument, deal in deals:
|
|
@@ -347,7 +351,7 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
347
351
|
# False means not a snapshot
|
|
348
352
|
self.channel.send((instrument, "deals", [deal], False))
|
|
349
353
|
|
|
350
|
-
|
|
354
|
+
self.__debug(
|
|
351
355
|
f"Sent deal: {instrument.symbol} {deal.amount:+.4f} @ {deal.price:.4f} "
|
|
352
356
|
f"fee={deal.fee_amount:.6f} (id={deal.id})"
|
|
353
357
|
)
|
|
@@ -363,7 +367,7 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
363
367
|
# )
|
|
364
368
|
|
|
365
369
|
except Exception as e:
|
|
366
|
-
|
|
370
|
+
self.__error(f"Error handling account_all message: {e}")
|
|
367
371
|
logger.exception(e)
|
|
368
372
|
|
|
369
373
|
async def _handle_account_all_orders_message(self, message: dict):
|
|
@@ -384,13 +388,13 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
384
388
|
# False means not a snapshot
|
|
385
389
|
self.channel.send((instrument, "order", order, False))
|
|
386
390
|
|
|
387
|
-
|
|
391
|
+
self.__debug(
|
|
388
392
|
f"Sent order: {instrument.symbol} {order.side} {order.quantity:+.4f} @ {order.price:.4f} "
|
|
389
393
|
f"[{order.status}] (order_id={order.id})"
|
|
390
394
|
)
|
|
391
395
|
|
|
392
396
|
except Exception as e:
|
|
393
|
-
|
|
397
|
+
self.__error(f"Error handling account_all_orders message: {e}")
|
|
394
398
|
logger.exception(e)
|
|
395
399
|
|
|
396
400
|
async def _handle_user_stats_message(self, message: dict):
|
|
@@ -407,15 +411,30 @@ class LighterAccountProcessor(BasicAccountProcessor):
|
|
|
407
411
|
for currency, balance in balances.items():
|
|
408
412
|
self.update_balance(currency, balance.total, balance.locked)
|
|
409
413
|
|
|
410
|
-
|
|
414
|
+
self.__debug(
|
|
411
415
|
f"Updated balance - {currency}: total={balance.total:.2f}, "
|
|
412
416
|
f"free={balance.free:.2f}, locked={balance.locked:.2f}"
|
|
413
417
|
)
|
|
414
418
|
|
|
415
419
|
if not self._account_stats_initialized:
|
|
416
420
|
self._account_stats_initialized = True
|
|
417
|
-
|
|
421
|
+
self.__info("Account stats initialized")
|
|
418
422
|
|
|
419
423
|
except Exception as e:
|
|
420
|
-
|
|
424
|
+
self.__error(f"Error handling user_stats message: {e}")
|
|
421
425
|
logger.exception(e)
|
|
426
|
+
|
|
427
|
+
def __info(self, msg: str):
|
|
428
|
+
logger.info(self.__format(msg))
|
|
429
|
+
|
|
430
|
+
def __debug(self, msg: str):
|
|
431
|
+
logger.debug(self.__format(msg))
|
|
432
|
+
|
|
433
|
+
def __warning(self, msg: str):
|
|
434
|
+
logger.warning(self.__format(msg))
|
|
435
|
+
|
|
436
|
+
def __error(self, msg: str):
|
|
437
|
+
logger.error(self.__format(msg))
|
|
438
|
+
|
|
439
|
+
def __format(self, msg: str):
|
|
440
|
+
return f"<green>[Lighter]</green> {msg}"
|
|
@@ -4,9 +4,11 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import threading
|
|
6
6
|
import time
|
|
7
|
-
from typing import Awaitable, Optional, TypeVar
|
|
7
|
+
from typing import Awaitable, Optional, TypeVar, cast
|
|
8
8
|
|
|
9
|
+
import pandas as pd
|
|
9
10
|
from lighter import ( # type: ignore
|
|
11
|
+
AccountApi,
|
|
10
12
|
ApiClient,
|
|
11
13
|
CandlestickApi,
|
|
12
14
|
Configuration,
|
|
@@ -20,8 +22,15 @@ from lighter import ( # type: ignore
|
|
|
20
22
|
logging.root.setLevel(logging.WARNING)
|
|
21
23
|
|
|
22
24
|
from qubx import logger
|
|
25
|
+
from qubx.utils.rate_limiter import rate_limited
|
|
23
26
|
|
|
24
27
|
from .constants import API_BASE_MAINNET, API_BASE_TESTNET
|
|
28
|
+
from .rate_limits import (
|
|
29
|
+
WEIGHT_CANDLESTICKS,
|
|
30
|
+
WEIGHT_DEFAULT,
|
|
31
|
+
WEIGHT_FUNDING,
|
|
32
|
+
create_lighter_rate_limiters,
|
|
33
|
+
)
|
|
25
34
|
|
|
26
35
|
T = TypeVar("T")
|
|
27
36
|
|
|
@@ -54,6 +63,7 @@ class LighterClient:
|
|
|
54
63
|
"""
|
|
55
64
|
|
|
56
65
|
_config: Configuration
|
|
66
|
+
_account_api: AccountApi
|
|
57
67
|
_api_client: ApiClient
|
|
58
68
|
_info_api: InfoApi
|
|
59
69
|
_order_api: OrderApi
|
|
@@ -69,6 +79,8 @@ class LighterClient:
|
|
|
69
79
|
api_key_index: int = 0,
|
|
70
80
|
testnet: bool = False,
|
|
71
81
|
loop: asyncio.AbstractEventLoop | None = None,
|
|
82
|
+
account_type: str = "premium",
|
|
83
|
+
rest_rate_limit: int | None = None,
|
|
72
84
|
):
|
|
73
85
|
"""
|
|
74
86
|
Initialize Lighter client.
|
|
@@ -79,6 +91,9 @@ class LighterClient:
|
|
|
79
91
|
account_index: Lighter account index
|
|
80
92
|
api_key_index: API key index for the account
|
|
81
93
|
testnet: If True, use testnet. Otherwise mainnet.
|
|
94
|
+
loop: Event loop to use (optional)
|
|
95
|
+
account_type: "premium" or "standard" for rate limiting (default: "premium")
|
|
96
|
+
rest_rate_limit: Override REST API rate limit in requests/minute (optional)
|
|
82
97
|
"""
|
|
83
98
|
self.api_key = api_key
|
|
84
99
|
self.private_key = private_key.replace("0x", "") # Remove 0x prefix if present
|
|
@@ -104,6 +119,7 @@ class LighterClient:
|
|
|
104
119
|
async def _init_sdks():
|
|
105
120
|
self._config = Configuration(host=self.api_url)
|
|
106
121
|
self._api_client = ApiClient(configuration=self._config)
|
|
122
|
+
self._account_api = AccountApi(self._api_client)
|
|
107
123
|
self._info_api = InfoApi(self._api_client)
|
|
108
124
|
self._order_api = OrderApi(self._api_client)
|
|
109
125
|
self._candlestick_api = CandlestickApi(self._api_client)
|
|
@@ -117,8 +133,15 @@ class LighterClient:
|
|
|
117
133
|
|
|
118
134
|
asyncio.run_coroutine_threadsafe(_init_sdks(), self._loop).result()
|
|
119
135
|
|
|
136
|
+
# Initialize rate limiters
|
|
137
|
+
self._rate_limiters = create_lighter_rate_limiters(
|
|
138
|
+
account_type=account_type,
|
|
139
|
+
rest_rate_limit=rest_rate_limit,
|
|
140
|
+
)
|
|
141
|
+
|
|
120
142
|
logger.info(
|
|
121
|
-
f"Initialized LighterClient (testnet={testnet}, account_index={account_index},
|
|
143
|
+
f"Initialized LighterClient (testnet={testnet}, account_index={account_index}, "
|
|
144
|
+
f"api_key_index={api_key_index}, account_type={account_type})"
|
|
122
145
|
)
|
|
123
146
|
|
|
124
147
|
def _run_event_loop(self):
|
|
@@ -140,6 +163,7 @@ class LighterClient:
|
|
|
140
163
|
# Make it awaitable from the *caller*'s loop:
|
|
141
164
|
return await asyncio.wrap_future(cfut)
|
|
142
165
|
|
|
166
|
+
@rate_limited("rest", weight=WEIGHT_DEFAULT)
|
|
143
167
|
async def get_markets(self) -> list[dict]:
|
|
144
168
|
"""
|
|
145
169
|
Get list of all markets.
|
|
@@ -184,6 +208,7 @@ class LighterClient:
|
|
|
184
208
|
return market
|
|
185
209
|
return None
|
|
186
210
|
|
|
211
|
+
@rate_limited("rest", weight=WEIGHT_CANDLESTICKS)
|
|
187
212
|
async def get_candlesticks(
|
|
188
213
|
self,
|
|
189
214
|
market_id: int,
|
|
@@ -218,8 +243,19 @@ class LighterClient:
|
|
|
218
243
|
resolution_ms = self._resolution_to_milliseconds(resolution)
|
|
219
244
|
start_timestamp = end_timestamp - (count_back * resolution_ms)
|
|
220
245
|
|
|
246
|
+
start_timestamp_str = (
|
|
247
|
+
cast(pd.Timestamp, pd.Timestamp(start_timestamp, unit="ms")).strftime("%Y-%m-%d %H:%M:%S")
|
|
248
|
+
if start_timestamp is not None
|
|
249
|
+
else None
|
|
250
|
+
)
|
|
251
|
+
end_timestamp_str = (
|
|
252
|
+
cast(pd.Timestamp, pd.Timestamp(end_timestamp, unit="ms")).strftime("%Y-%m-%d %H:%M:%S")
|
|
253
|
+
if end_timestamp is not None
|
|
254
|
+
else None
|
|
255
|
+
)
|
|
256
|
+
|
|
221
257
|
logger.debug(
|
|
222
|
-
f"Fetching candlesticks for market {market_id}: {resolution}, from {
|
|
258
|
+
f"[Lighter] Fetching candlesticks for market {market_id}: {resolution}, from {start_timestamp_str} to {end_timestamp_str}"
|
|
223
259
|
)
|
|
224
260
|
|
|
225
261
|
response = await self._run_on_client_loop(
|
|
@@ -245,6 +281,7 @@ class LighterClient:
|
|
|
245
281
|
logger.error(f"Failed to get candlesticks for market {market_id}: {e}")
|
|
246
282
|
raise
|
|
247
283
|
|
|
284
|
+
@rate_limited("rest", weight=WEIGHT_FUNDING)
|
|
248
285
|
async def get_fundings(
|
|
249
286
|
self,
|
|
250
287
|
market_id: int,
|
|
@@ -304,6 +341,7 @@ class LighterClient:
|
|
|
304
341
|
logger.error(f"Failed to get funding data for market {market_id}: {e}")
|
|
305
342
|
raise
|
|
306
343
|
|
|
344
|
+
@rate_limited("rest", weight=WEIGHT_DEFAULT)
|
|
307
345
|
async def get_funding_rates(self) -> dict[int, float]:
|
|
308
346
|
"""
|
|
309
347
|
Get current funding rates for all markets.
|
|
@@ -53,6 +53,8 @@ WS_CHANNEL_ACCOUNT_ALL = "account_all"
|
|
|
53
53
|
WS_CHANNEL_USER_STATS = "user_stats"
|
|
54
54
|
WS_CHANNEL_EXECUTED_TX = "executed_transaction"
|
|
55
55
|
|
|
56
|
+
WS_RESUBSCRIBE_DELAY = 2.0 # seconds to wait before resubscribing
|
|
57
|
+
|
|
56
58
|
# WebSocket message types
|
|
57
59
|
WS_MSG_TYPE_CONNECTED = "connected"
|
|
58
60
|
WS_MSG_TYPE_SUBSCRIBE = "subscribe"
|
|
@@ -70,9 +72,11 @@ API_BASE_TESTNET = "https://testnet.zklighter.elliot.ai"
|
|
|
70
72
|
WS_BASE_MAINNET = "wss://mainnet.zklighter.elliot.ai/stream"
|
|
71
73
|
WS_BASE_TESTNET = "wss://testnet.zklighter.elliot.ai/stream"
|
|
72
74
|
|
|
73
|
-
DEFAULT_PING_INTERVAL =
|
|
74
|
-
DEFAULT_PING_TIMEOUT =
|
|
75
|
+
DEFAULT_PING_INTERVAL = None
|
|
76
|
+
DEFAULT_PING_TIMEOUT = None
|
|
75
77
|
DEFAULT_MAX_RETRIES = 10
|
|
78
|
+
DEFAULT_MAX_SIZE = None
|
|
79
|
+
DEFAULT_MAX_QUEUE = 5000
|
|
76
80
|
|
|
77
81
|
|
|
78
82
|
# Enums for type safety (kept for backward compatibility)
|