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.

Files changed (48) hide show
  1. qubx/backtester/utils.py +1 -1
  2. qubx/connectors/ccxt/data.py +32 -29
  3. qubx/connectors/xlighter/account.py +52 -33
  4. qubx/connectors/xlighter/client.py +41 -3
  5. qubx/connectors/xlighter/constants.py +6 -2
  6. qubx/connectors/xlighter/data.py +80 -12
  7. qubx/connectors/xlighter/factory.py +34 -3
  8. qubx/connectors/xlighter/handlers/base.py +12 -0
  9. qubx/connectors/xlighter/handlers/orderbook.py +228 -25
  10. qubx/connectors/xlighter/rate_limits.py +96 -0
  11. qubx/connectors/xlighter/websocket.py +57 -18
  12. qubx/core/basics.py +28 -2
  13. qubx/core/context.py +8 -4
  14. qubx/core/interfaces.py +1 -1
  15. qubx/core/metrics.py +12 -6
  16. qubx/core/mixins/processing.py +1 -1
  17. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  18. qubx/core/series.pyx +2 -2
  19. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  20. qubx/data/readers.py +4 -1
  21. qubx/data/storages/questdb.py +1 -1
  22. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  23. qubx/ta/indicators.pyi +6 -0
  24. qubx/ta/indicators.pyx +102 -1
  25. qubx/utils/hft/__init__.py +5 -0
  26. qubx/utils/hft/numba_utils.py +12 -0
  27. qubx/utils/hft/orderbook.cpython-312-x86_64-linux-gnu.so +0 -0
  28. qubx/utils/hft/orderbook.pyi +177 -0
  29. qubx/utils/hft/orderbook.pyx +416 -0
  30. qubx/utils/misc.py +6 -1
  31. qubx/utils/orderbook.py +1 -101
  32. qubx/utils/rate_limiter.py +225 -0
  33. qubx/utils/runner/configs.py +14 -7
  34. qubx/utils/runner/runner.py +3 -0
  35. qubx/utils/runner/textual/app.py +20 -28
  36. qubx/utils/runner/textual/handlers.py +11 -4
  37. qubx/utils/runner/textual/init_code.py +23 -23
  38. qubx/utils/runner/textual/widgets/orders_table.py +209 -27
  39. qubx/utils/runner/textual/widgets/positions_table.py +288 -49
  40. qubx/utils/runner/textual/widgets/quotes_table.py +170 -29
  41. qubx/utils/runner/textual/widgets/repl_output.py +87 -82
  42. qubx/utils/time.py +97 -1
  43. qubx/utils/websocket_manager.py +278 -420
  44. {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/METADATA +2 -2
  45. {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/RECORD +48 -41
  46. {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/WHEEL +0 -0
  47. {qubx-0.6.94.dist-info → qubx-0.7.3.dist-info}/entry_points.txt +0 -0
  48. {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")
@@ -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
- self._health_monitor,
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[instrument]
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(f"<yellow>{self._exchange_id}</yellow> Handling exchange recreation - resubscribing to active subscriptions")
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(f"<yellow>{self._exchange_id}</yellow> Found {len(resubscription_data)} active subscriptions to recreate")
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(f"<yellow>{self._exchange_id}</yellow> Resubscribing to {subscription_type} with {len(instruments)} instruments")
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(f"<yellow>{self._exchange_id}</yellow> Exchange recreation resubscription completed successfully ({total_subscriptions}/{total_subscriptions})")
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(f"<yellow>{self._exchange_id}</yellow> Exchange recreation resubscription completed with errors ({successful_resubscriptions}/{total_subscriptions} successful)")
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
- logger.info(f"Initialized LighterAccountProcessor for account {account_id}")
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._wait_for_account_stats_initialized()
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
- logger.debug("Account processor is already running")
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
- logger.warning("Channel not set or control not active, cannot start")
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
- logger.info("Starting Lighter account subscriptions")
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._wait_for_account_stats_initialized()
155
+ self._wait_for_account_initialized()
156
156
 
157
- logger.info("Lighter account subscriptions started")
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
- logger.info("Stopping Lighter account subscriptions")
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
- logger.info("Lighter account subscriptions stopped")
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
- logger.debug(
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
- logger.debug(
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
- logger.debug(f"Migrating order: client_id={order.client_id} → server_id={order.id}")
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 _wait_for_account_stats_initialized(self):
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 stats were not initialized within {max_wait_time} seconds")
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
- logger.info("Connecting to Lighter WebSocket...")
263
+ self.__info("Connecting to Lighter WebSocket...")
264
264
  await self.ws_manager.connect()
265
- logger.info("Connected to Lighter WebSocket")
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
- logger.error(f"Failed to start subscriptions: {e}")
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
- logger.info(f"Subscribed to account_all for account {self._lighter_account_index}")
280
+ self.__info(f"Subscribed to account_all for account {self._lighter_account_index}")
281
281
  except Exception as e:
282
- logger.error(f"Failed to subscribe to account_all for account {self._lighter_account_index}: {e}")
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
- logger.info(f"Subscribed to account_all_orders for account {self._lighter_account_index}")
290
+ self.__info(f"Subscribed to account_all_orders for account {self._lighter_account_index}")
291
291
  except Exception as e:
292
- logger.error(f"Failed to subscribe to account_all_orders for account {self._lighter_account_index}: {e}")
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
- logger.info(f"Subscribed to user_stats for account {self._lighter_account_index}")
298
+ self.__info(f"Subscribed to user_stats for account {self._lighter_account_index}")
299
299
  except Exception as e:
300
- logger.error(f"Failed to subscribe to user_stats for account {self._lighter_account_index}: {e}")
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
- logger.debug(
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
- logger.debug(
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
- logger.error(f"Error handling account_all message: {e}")
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
- logger.debug(
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
- logger.error(f"Error handling account_all_orders message: {e}")
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
- logger.debug(
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
- logger.debug("Account stats initialized")
421
+ self.__info("Account stats initialized")
418
422
 
419
423
  except Exception as e:
420
- logger.error(f"Error handling user_stats message: {e}")
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}, api_key_index={api_key_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 {start_timestamp} to {end_timestamp}"
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 = 200
74
- DEFAULT_PING_TIMEOUT = 30
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)