Qubx 0.7.3__cp312-cp312-manylinux_2_39_x86_64.whl → 0.7.4__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 (47) hide show
  1. qubx/connectors/xlighter/account.py +49 -1
  2. qubx/connectors/xlighter/broker.py +117 -273
  3. qubx/connectors/xlighter/client.py +12 -0
  4. qubx/connectors/xlighter/handlers/trades.py +4 -4
  5. qubx/connectors/xlighter/nonce.py +21 -0
  6. qubx/connectors/xlighter/websocket.py +10 -0
  7. qubx/core/context.py +162 -44
  8. qubx/core/exceptions.py +4 -0
  9. qubx/core/interfaces.py +60 -7
  10. qubx/core/mixins/processing.py +16 -2
  11. qubx/core/mixins/trading.py +30 -5
  12. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  13. qubx/core/series.pyi +15 -1
  14. qubx/core/series.pyx +108 -0
  15. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  16. qubx/data/readers.py +112 -1
  17. qubx/emitters/composite.py +17 -1
  18. qubx/emitters/questdb.py +81 -15
  19. qubx/exporters/formatters/slack.py +6 -4
  20. qubx/exporters/slack.py +35 -74
  21. qubx/gathering/simplest.py +1 -1
  22. qubx/notifications/__init__.py +5 -5
  23. qubx/notifications/composite.py +29 -17
  24. qubx/notifications/slack.py +109 -132
  25. qubx/pandaz/ta.py +32 -0
  26. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  27. qubx/ta/indicators.pyi +5 -0
  28. qubx/ta/indicators.pyx +152 -1
  29. qubx/utils/hft/orderbook.cpython-312-x86_64-linux-gnu.so +0 -0
  30. qubx/utils/nonce.py +53 -0
  31. qubx/utils/ringbuffer.cpython-312-x86_64-linux-gnu.so +0 -0
  32. qubx/utils/ringbuffer.pxd +17 -0
  33. qubx/utils/ringbuffer.pyi +197 -0
  34. qubx/utils/ringbuffer.pyx +253 -0
  35. qubx/utils/runner/factory.py +11 -14
  36. qubx/utils/runner/runner.py +3 -3
  37. qubx/utils/runner/textual/app.py +21 -5
  38. qubx/utils/runner/textual/handlers.py +5 -1
  39. qubx/utils/runner/textual/init_code.py +3 -6
  40. qubx/utils/runner/textual/widgets/orders_table.py +4 -5
  41. qubx/utils/runner/textual/widgets/quotes_table.py +4 -10
  42. qubx/utils/slack.py +177 -0
  43. {qubx-0.7.3.dist-info → qubx-0.7.4.dist-info}/METADATA +1 -1
  44. {qubx-0.7.3.dist-info → qubx-0.7.4.dist-info}/RECORD +47 -40
  45. {qubx-0.7.3.dist-info → qubx-0.7.4.dist-info}/WHEEL +0 -0
  46. {qubx-0.7.3.dist-info → qubx-0.7.4.dist-info}/entry_points.txt +0 -0
  47. {qubx-0.7.3.dist-info → qubx-0.7.4.dist-info}/licenses/LICENSE +0 -0
@@ -12,9 +12,10 @@ through channel for strategy notification but do not update positions.
12
12
 
13
13
  import asyncio
14
14
  import time
15
- from typing import Optional
15
+ from typing import Awaitable, Callable, Optional
16
16
 
17
17
  import numpy as np
18
+ import pandas as pd
18
19
 
19
20
  from qubx import logger
20
21
  from qubx.core.account import BasicAccountProcessor
@@ -28,6 +29,7 @@ from qubx.core.basics import (
28
29
  TransactionCostsCalculator,
29
30
  )
30
31
  from qubx.core.interfaces import ISubscriptionManager
32
+ from qubx.core.utils import recognize_timeframe
31
33
  from qubx.utils.misc import AsyncThreadLoop
32
34
 
33
35
  from .client import LighterClient
@@ -268,6 +270,7 @@ class LighterAccountProcessor(BasicAccountProcessor):
268
270
  await self._subscribe_account_all()
269
271
  await self._subscribe_account_all_orders()
270
272
  await self._subscribe_user_stats()
273
+ await self._poller(name="sync_orders", coroutine=self._sync_orders, interval="1min")
271
274
 
272
275
  except Exception as e:
273
276
  self.__error(f"Failed to start subscriptions: {e}")
@@ -300,6 +303,51 @@ class LighterAccountProcessor(BasicAccountProcessor):
300
303
  self.__error(f"Failed to subscribe to user_stats for account {self._lighter_account_index}: {e}")
301
304
  raise
302
305
 
306
+ async def _sync_orders(self):
307
+ now = self.time_provider.time()
308
+ orders = self.get_orders()
309
+ remove_orders = []
310
+ for order_id, order in orders.items():
311
+ if order.status == "NEW" and order.time < now - recognize_timeframe("1min"):
312
+ remove_orders.append(order_id)
313
+ for order_id in remove_orders:
314
+ self.remove_order(order_id)
315
+
316
+ async def _poller(
317
+ self,
318
+ name: str,
319
+ coroutine: Callable[[], Awaitable],
320
+ interval: str,
321
+ backoff: str | None = None,
322
+ ):
323
+ sleep_time = pd.Timedelta(interval).total_seconds()
324
+ retries = 0
325
+
326
+ if backoff is not None:
327
+ sleep_time = pd.Timedelta(backoff).total_seconds()
328
+ await asyncio.sleep(sleep_time)
329
+
330
+ while self.channel.control.is_set():
331
+ try:
332
+ await coroutine()
333
+ retries = 0 # Reset retry counter on success
334
+ except Exception as e:
335
+ if not self.channel.control.is_set():
336
+ # If the channel is closed, then ignore all exceptions and exit
337
+ break
338
+ logger.error(f"Unexpected error during account polling: {e}")
339
+ logger.exception(e)
340
+ retries += 1
341
+ if retries >= self.max_retries:
342
+ logger.error(f"Max retries ({self.max_retries}) reached. Stopping poller.")
343
+ break
344
+ finally:
345
+ if not self.channel.control.is_set():
346
+ break
347
+ await asyncio.sleep(min(sleep_time * (2 ** (retries)), 60)) # Exponential backoff capped at 60s
348
+
349
+ logger.debug(f"{name} polling task has been stopped")
350
+
303
351
  async def _handle_account_all_message(self, message: dict):
304
352
  """
305
353
  Handle account_all WebSocket messages (primary channel).
@@ -1,20 +1,10 @@
1
- """
2
- LighterBroker - IBroker implementation for Lighter exchange.
3
-
4
- Handles order operations:
5
- - Order creation (market/limit)
6
- - Order cancellation
7
- - Order modification
8
- - Order tracking
9
- """
10
-
11
1
  import asyncio
12
2
  import uuid
13
3
  from typing import Any
14
4
 
15
5
  from qubx import logger
16
6
  from qubx.core.basics import CtrlChannel, Instrument, ITimeProvider, Order, OrderSide
17
- from qubx.core.errors import ErrorLevel, OrderCancellationError, OrderCreationError, create_error_event
7
+ from qubx.core.errors import BaseErrorEvent, ErrorLevel, OrderCreationError, create_error_event
18
8
  from qubx.core.exceptions import InvalidOrderParameters, OrderNotFound
19
9
  from qubx.core.interfaces import IAccountProcessor, IBroker, IDataProvider
20
10
  from qubx.utils.misc import AsyncThreadLoop
@@ -36,20 +26,8 @@ from .extensions import LighterExchangeAPI
36
26
  from .instruments import LighterInstrumentLoader
37
27
  from .websocket import LighterWebSocketManager
38
28
 
39
- # Utils imported as needed
40
-
41
29
 
42
30
  class LighterBroker(IBroker):
43
- """
44
- Broker for Lighter exchange.
45
-
46
- Supports:
47
- - Market and limit orders
48
- - Order cancellation
49
- - Native order modification (via sign_modify_order)
50
- - WebSocket order updates (via AccountProcessor)
51
- """
52
-
53
31
  def __init__(
54
32
  self,
55
33
  client: LighterClient,
@@ -90,34 +68,20 @@ class LighterBroker(IBroker):
90
68
  self.cancel_timeout = cancel_timeout
91
69
  self.cancel_retry_interval = cancel_retry_interval
92
70
  self.max_cancel_retries = max_cancel_retries
93
-
94
- # Async thread loop for submitting tasks to client's event loop
95
71
  self._async_loop = AsyncThreadLoop(loop)
96
-
97
- # Track client order IDs and indices
98
72
  self._client_order_ids: dict[str, str] = {} # client_id -> exchange_order_id
99
73
  self._client_order_indices: dict[str, int] = {} # client_id -> client_order_index
100
-
101
- # Initialize Lighter-specific extensions
102
74
  self._extensions = LighterExchangeAPI(client=self.client, broker=self)
103
75
 
104
76
  @property
105
77
  def is_simulated_trading(self) -> bool:
106
- """Check if broker is in simulation mode (always False for live)"""
107
78
  return False
108
79
 
109
80
  def exchange(self) -> str:
110
- """Return exchange name"""
111
81
  return "LIGHTER"
112
82
 
113
83
  @property
114
84
  def extensions(self) -> LighterExchangeAPI:
115
- """
116
- Access Lighter-specific API extensions.
117
-
118
- Returns:
119
- LighterExchangeAPI: Lighter exchange extensions
120
- """
121
85
  return self._extensions
122
86
 
123
87
  def send_order(
@@ -131,30 +95,18 @@ class LighterBroker(IBroker):
131
95
  time_in_force: str = "gtc",
132
96
  **options,
133
97
  ) -> Order:
134
- """
135
- Send order synchronously.
136
-
137
- Args:
138
- instrument: Instrument to trade
139
- order_side: "buy" or "sell"
140
- order_type: "market" or "limit"
141
- amount: Order amount (in base currency)
142
- price: Limit price (required for limit orders)
143
- client_id: Client-specified order ID
144
- time_in_force: "gtc" (default), "ioc", or "post_only"
145
- **options: Additional order parameters (reduce_only, etc.)
146
-
147
- Returns:
148
- Order: Created order object
149
-
150
- Raises:
151
- InvalidOrderParameters: If order parameters are invalid
152
- """
153
- # Submit async order creation to event loop and wait for result
154
- future = self._async_loop.submit(
155
- self._create_order(instrument, order_side, order_type, amount, price, client_id, time_in_force, **options)
156
- )
157
- return future.result()
98
+ return self._async_loop.submit(
99
+ self._create_order(
100
+ instrument=instrument,
101
+ order_side=order_side,
102
+ order_type=order_type,
103
+ amount=amount,
104
+ price=price,
105
+ client_id=client_id,
106
+ time_in_force=time_in_force,
107
+ **options,
108
+ )
109
+ ).result()
158
110
 
159
111
  def send_order_async(
160
112
  self,
@@ -167,15 +119,6 @@ class LighterBroker(IBroker):
167
119
  time_in_force: str = "gtc",
168
120
  **options,
169
121
  ) -> Any:
170
- """
171
- Send order asynchronously.
172
-
173
- Errors will be sent through the channel.
174
-
175
- Returns:
176
- Task/Future that will contain the order
177
- """
178
-
179
122
  async def _execute_order_with_channel_errors():
180
123
  try:
181
124
  order = await self._create_order(
@@ -188,7 +131,43 @@ class LighterBroker(IBroker):
188
131
  )
189
132
  return None
190
133
 
191
- return asyncio.create_task(_execute_order_with_channel_errors())
134
+ return self._async_loop.submit(_execute_order_with_channel_errors())
135
+
136
+ def cancel_order(self, order_id: str) -> bool:
137
+ order = self._find_order(order_id)
138
+ if order is None:
139
+ raise OrderNotFound(f"Order not found: {order_id}")
140
+ return self._async_loop.submit(self._cancel_order(order)).result()
141
+
142
+ def cancel_order_async(self, order_id: str) -> None:
143
+ order = self._find_order(order_id)
144
+ if order is None:
145
+ self._post_cancel_error_to_channel(OrderNotFound(f"Order not found: {order_id}"), order_id)
146
+ return
147
+
148
+ async def _cancel_with_errors():
149
+ try:
150
+ await self._cancel_order(order)
151
+ except Exception as error:
152
+ self._post_cancel_error_to_channel(error, order_id)
153
+
154
+ return self._async_loop.submit(_cancel_with_errors()).result()
155
+
156
+ def cancel_orders(self, instrument: Instrument) -> None:
157
+ orders = self.account.get_orders(instrument=instrument)
158
+
159
+ for order in orders.values():
160
+ try:
161
+ self.cancel_order_async(order.id)
162
+ except Exception as e:
163
+ logger.error(f"Failed to cancel order {order.id}: {e}")
164
+
165
+ def update_order(self, order_id: str, price: float, amount: float) -> Order:
166
+ order = self._find_order(order_id)
167
+ if order is None:
168
+ raise OrderNotFound(f"Order not found: {order_id}")
169
+ future = self._async_loop.submit(self._modify_order(order, price, amount))
170
+ return future.result()
192
171
 
193
172
  async def _create_order(
194
173
  self,
@@ -201,26 +180,6 @@ class LighterBroker(IBroker):
201
180
  time_in_force: str,
202
181
  **options,
203
182
  ) -> Order:
204
- """
205
- Create order via local signing + WebSocket submission.
206
-
207
- Args:
208
- instrument: Instrument to trade
209
- order_side: Order side
210
- order_type: Order type
211
- amount: Order amount
212
- price: Limit price
213
- client_id: Client order ID
214
- time_in_force: Time in force
215
- **options: Additional parameters
216
-
217
- Returns:
218
- Order object
219
-
220
- Raises:
221
- InvalidOrderParameters: If parameters are invalid
222
- """
223
- # Validate parameters
224
183
  if order_type not in ["market", "limit"]:
225
184
  raise InvalidOrderParameters(f"Invalid order type: {order_type}")
226
185
 
@@ -251,7 +210,13 @@ class LighterBroker(IBroker):
251
210
  lighter_tif = tif_map.get(time_in_force.lower(), ORDER_TIME_IN_FORCE_GOOD_TILL_TIME)
252
211
 
253
212
  # Extract additional options
254
- reduce_only = options.get("reduce_only", False)
213
+ order_sign = +1 if order_side == "BUY" else -1
214
+ reduce_only = options.get("reduce_only", None)
215
+ if reduce_only is None:
216
+ if self._is_position_reducing(instrument, amount * order_sign):
217
+ reduce_only = True
218
+ else:
219
+ reduce_only = False
255
220
 
256
221
  # Market orders MUST use IOC (Immediate or Cancel) time in force
257
222
  # This is a requirement of Lighter's API
@@ -312,12 +277,8 @@ class LighterBroker(IBroker):
312
277
 
313
278
  logger.info(
314
279
  f"Creating order: {order_side} {amount} {instrument.symbol} "
315
- f"@ {price if price else 'MARKET'} (type={order_type}, tif={time_in_force})"
280
+ f"@ {price if price else 'MARKET'} (type={order_type}, tif={time_in_force}, reduce_only={reduce_only})"
316
281
  )
317
- # logger.debug(
318
- # f"Decimal conversion: amount={amount} → {base_amount_int} (10^{instrument.size_precision}), "
319
- # f"price={price} → {price_int} (10^{instrument.price_precision})"
320
- # )
321
282
 
322
283
  try:
323
284
  # Step 1: Sign transaction locally
@@ -333,6 +294,7 @@ class LighterBroker(IBroker):
333
294
  reduce_only=int(reduce_only),
334
295
  trigger_price=0, # Not using trigger orders
335
296
  order_expiry=order_expiry,
297
+ nonce=await self.ws_manager.next_nonce(),
336
298
  )
337
299
 
338
300
  if error or tx_info is None:
@@ -374,204 +336,79 @@ class LighterBroker(IBroker):
374
336
  logger.error(f"Failed to create order: {e}")
375
337
  raise InvalidOrderParameters(f"Order creation failed: {e}") from e
376
338
 
377
- def cancel_order(self, order_id: str) -> bool:
378
- """
379
- Cancel order synchronously.
380
-
381
- Args:
382
- order_id: Order ID or client order ID to cancel
383
-
384
- Returns:
385
- True if cancellation successful
386
-
387
- Raises:
388
- OrderNotFound: If order not found
389
- """
390
- # Submit async cancellation to event loop and wait for result
391
- future = self._async_loop.submit(self._cancel_order(order_id))
392
- return future.result()
393
-
394
- def cancel_order_async(self, order_id: str) -> None:
395
- """
396
- Cancel order asynchronously.
397
-
398
- Args:
399
- order_id: Order ID or client order ID to cancel
400
- """
401
-
402
- async def _cancel_with_errors():
403
- try:
404
- await self._cancel_order(order_id)
405
- except Exception as error:
406
- self._post_cancel_error_to_channel(error, order_id)
407
-
408
- asyncio.create_task(_cancel_with_errors())
409
-
410
- async def _cancel_order(self, order_id: str) -> bool:
411
- """
412
- Cancel order via local signing + WebSocket submission.
413
-
414
- Args:
415
- order_id: Order ID to cancel
416
-
417
- Returns:
418
- True if successful
339
+ def _find_order(self, order_id: str) -> Order | None:
340
+ # Check if this is a client order ID
341
+ if order_id in self._client_order_ids:
342
+ exchange_order_id = self._client_order_ids[order_id]
343
+ client_id = order_id
344
+ else:
345
+ exchange_order_id = order_id
346
+ client_id = None
347
+
348
+ # Get order details to find market_id and client_id
349
+ orders = self.account.get_orders()
350
+ order = None
351
+ for ord in orders.values():
352
+ if ord.id == exchange_order_id or ord.client_id == order_id:
353
+ order = ord
354
+ if client_id is None and ord.client_id:
355
+ client_id = ord.client_id
356
+ break
357
+
358
+ return order
359
+
360
+ def _find_order_index(self, order: Order) -> int:
361
+ # Get the client_order_index we used during creation
362
+ # If not available, compute it the same way as during creation
363
+ if order.client_id and order.client_id in self._client_order_indices:
364
+ return self._client_order_indices[order.client_id]
365
+ elif order.client_id:
366
+ return abs(hash(order.client_id)) % (10**9)
367
+ elif order.id.isdigit():
368
+ return int(order.id)
369
+ else:
370
+ return abs(hash(order.id)) % (2**56)
419
371
 
420
- Raises:
421
- OrderNotFound: If order not found
422
- """
423
- logger.info(f"Canceling order: {order_id}")
372
+ async def _cancel_order(self, order: Order) -> bool:
373
+ logger.info(f"Canceling order: {order.id}")
424
374
 
425
375
  try:
426
- # Check if this is a client order ID
427
- if order_id in self._client_order_ids:
428
- exchange_order_id = self._client_order_ids[order_id]
429
- client_id = order_id
430
- else:
431
- exchange_order_id = order_id
432
- client_id = None
433
-
434
- # Get order details to find market_id and client_id
435
- orders = self.account.get_orders()
436
- order = None
437
- for ord in orders.values():
438
- if ord.id == exchange_order_id or ord.client_id == order_id:
439
- order = ord
440
- if client_id is None and ord.client_id:
441
- client_id = ord.client_id
442
- break
443
-
444
- if order is None:
445
- raise OrderNotFound(f"Order not found: {order_id}")
446
-
447
- # Get market_id
448
376
  market_id = self.instrument_loader.get_market_id(order.instrument.symbol)
449
377
  if market_id is None:
450
378
  raise OrderNotFound(f"Market ID not found for {order.instrument.symbol}")
451
379
 
452
- # Get the client_order_index we used during creation
453
- # If not available, compute it the same way as during creation
454
- if client_id and client_id in self._client_order_indices:
455
- order_index = self._client_order_indices[client_id]
456
- elif client_id:
457
- # Fallback: compute using same algorithm as creation
458
- order_index = abs(hash(client_id)) % (10**9)
459
- elif exchange_order_id.isdigit():
460
- order_index = int(exchange_order_id)
461
- else:
462
- # Last resort: hash the exchange_order_id but constrain to 56-bit limit
463
- order_index = abs(hash(exchange_order_id)) % (2**56)
464
-
465
- # Step 1: Sign cancellation transaction locally
380
+ order_index = self._find_order_index(order)
466
381
  signer = self.client.signer_client
467
- tx_info, error = signer.sign_cancel_order(market_index=market_id, order_index=order_index)
382
+ tx_info, error = signer.sign_cancel_order(
383
+ market_index=market_id, order_index=order_index, nonce=await self.ws_manager.next_nonce()
384
+ )
468
385
 
469
386
  if error or tx_info is None:
470
387
  logger.error(f"Order cancellation signing failed: {error}")
471
388
  return False
472
389
 
473
- # Step 2: Submit via WebSocket
474
- await self.ws_manager.send_tx(tx_type=TX_TYPE_CANCEL_ORDER, tx_info=tx_info, tx_id=f"cancel_{order_id}")
475
-
476
- logger.info(f"Order cancellation submitted via WebSocket: {order_id}")
390
+ await self.ws_manager.send_tx(tx_type=TX_TYPE_CANCEL_ORDER, tx_info=tx_info, tx_id=f"cancel_{order.id}")
391
+ logger.info(f"Order cancellation submitted via WebSocket: {order.id}")
477
392
  return True
478
393
 
479
394
  except Exception as e:
480
- logger.error(f"Failed to cancel order {order_id}: {e}")
395
+ logger.error(f"Failed to cancel order {order.id}: {e}")
481
396
  raise OrderNotFound(f"Order cancellation failed: {e}") from e
482
397
 
483
- def cancel_orders(self, instrument: Instrument) -> None:
484
- """
485
- Cancel all orders for an instrument.
486
-
487
- Args:
488
- instrument: Instrument to cancel orders for
489
- """
490
- orders = self.account.get_orders(instrument=instrument)
491
-
492
- for order_id in orders.keys():
493
- try:
494
- self.cancel_order_async(order_id)
495
- except Exception as e:
496
- logger.error(f"Failed to cancel order {order_id}: {e}")
497
-
498
- def update_order(self, order_id: str, price: float, amount: float) -> Order:
499
- """
500
- Update order via native order modification.
501
-
502
- Uses Lighter's sign_modify_order for atomic order updates.
503
-
504
- Args:
505
- order_id: Order ID to update
506
- price: New price
507
- amount: New amount
508
-
509
- Returns:
510
- Updated order object
511
-
512
- Raises:
513
- OrderNotFound: If order not found
514
- """
515
- # Submit async modification to event loop and wait for result
516
- future = self._async_loop.submit(self._modify_order(order_id, price, amount))
517
- return future.result()
518
-
519
- async def _modify_order(self, order_id: str, price: float, amount: float) -> Order:
520
- """
521
- Modify order via local signing + WebSocket submission.
522
-
523
- Args:
524
- order_id: Order ID to modify
525
- price: New price
526
- amount: New amount
527
-
528
- Returns:
529
- Updated order object
530
-
531
- Raises:
532
- OrderNotFound: If order not found
533
- """
398
+ async def _modify_order(self, order: Order, price: float, amount: float) -> Order:
534
399
  try:
535
- # Check if this is a client order ID
536
- if order_id in self._client_order_ids:
537
- exchange_order_id = self._client_order_ids[order_id]
538
- client_id = order_id
539
- else:
540
- exchange_order_id = order_id
541
- client_id = None
542
-
543
- # Get order details
544
- orders = self.account.get_orders()
545
- order = None
546
- for ord in orders.values():
547
- if ord.id == exchange_order_id or ord.client_id == order_id:
548
- order = ord
549
- if client_id is None and ord.client_id:
550
- client_id = ord.client_id
551
- break
552
-
553
- if order is None:
554
- raise OrderNotFound(f"Order not found: {order_id}")
555
-
556
- # Get market_id
557
400
  market_id = self.instrument_loader.get_market_id(order.instrument.symbol)
558
401
  if market_id is None:
559
402
  raise OrderNotFound(f"Market ID not found for {order.instrument.symbol}")
560
403
 
561
- # Get the order_index
562
- if client_id and client_id in self._client_order_indices:
563
- order_index = self._client_order_indices[client_id]
564
- elif order.id.isdigit():
565
- order_index = order.id
566
- else:
567
- raise OrderNotFound(f"Order index not found for {order_id}")
404
+ order_index = self._find_order_index(order)
568
405
 
569
406
  # Convert price and amount to Lighter's integer format
570
407
  instrument = order.instrument
571
408
  base_amount_int = int(amount * (10**instrument.size_precision))
572
409
  price_int = int(price * (10**instrument.price_precision))
573
410
 
574
- logger.debug(f"Modify order {order_id}: amount={order.quantity} → {amount}, price={order.price} → {price}")
411
+ logger.debug(f"Modify order {order.id}: amount={order.quantity} → {amount}, price={order.price} → {price}")
575
412
 
576
413
  # Step 1: Sign modification transaction locally
577
414
  signer = self.client.signer_client
@@ -581,13 +418,14 @@ class LighterBroker(IBroker):
581
418
  base_amount=base_amount_int,
582
419
  price=price_int,
583
420
  trigger_price=0, # Not using trigger orders
421
+ nonce=await self.ws_manager.next_nonce(),
584
422
  )
585
423
 
586
424
  if error or tx_info is None:
587
425
  raise OrderNotFound(f"Order modification signing failed: {error}")
588
426
 
589
427
  # Step 2: Submit via WebSocket
590
- await self.ws_manager.send_tx(tx_type=TX_TYPE_MODIFY_ORDER, tx_info=tx_info, tx_id=f"modify_{order_id}")
428
+ await self.ws_manager.send_tx(tx_type=TX_TYPE_MODIFY_ORDER, tx_info=tx_info, tx_id=f"modify_{order.id}")
591
429
 
592
430
  # Create updated Order object
593
431
  updated_order = Order(
@@ -604,11 +442,11 @@ class LighterBroker(IBroker):
604
442
  options=order.options,
605
443
  )
606
444
 
607
- logger.info(f"Order modification submitted via WebSocket: {order_id}")
445
+ logger.info(f"Order modification submitted via WebSocket: {order.id}")
608
446
  return updated_order
609
447
 
610
448
  except Exception as e:
611
- logger.error(f"Failed to modify order {order_id}: {e}")
449
+ logger.error(f"Failed to modify order {order.id}: {e}")
612
450
  raise OrderNotFound(f"Order modification failed: {e}") from e
613
451
 
614
452
  async def send_orders_batch(
@@ -762,6 +600,7 @@ class LighterBroker(IBroker):
762
600
  reduce_only=int(reduce_only),
763
601
  trigger_price=0,
764
602
  order_expiry=order_expiry,
603
+ nonce=await self.ws_manager.next_nonce(),
765
604
  )
766
605
 
767
606
  if error or tx_info is None:
@@ -812,7 +651,6 @@ class LighterBroker(IBroker):
812
651
  time_in_force: str,
813
652
  **options,
814
653
  ):
815
- """Post order creation error to channel"""
816
654
  level = ErrorLevel.MEDIUM
817
655
 
818
656
  if "insufficient" in str(error).lower():
@@ -838,7 +676,6 @@ class LighterBroker(IBroker):
838
676
  self.channel.send(create_error_event(error_event))
839
677
 
840
678
  def _post_cancel_error_to_channel(self, error: Exception, order_id: str):
841
- """Post order cancellation error to channel"""
842
679
  level = ErrorLevel.MEDIUM
843
680
 
844
681
  if "not found" in str(error).lower():
@@ -847,11 +684,18 @@ class LighterBroker(IBroker):
847
684
  else:
848
685
  logger.error(f"Order cancellation error: {error}")
849
686
 
850
- error_event = OrderCancellationError(
687
+ error_event = BaseErrorEvent(
851
688
  timestamp=self.time_provider.time(),
852
689
  message=f"Failed to cancel order {order_id}: {str(error)}",
853
690
  level=level,
854
- order_id=order_id,
855
691
  error=error,
856
692
  )
857
693
  self.channel.send(create_error_event(error_event))
694
+
695
+ def _is_position_reducing(self, instrument: Instrument, signed_amount: float) -> bool:
696
+ current_position = self.account.get_position(instrument)
697
+ return (
698
+ current_position.quantity > 0 and signed_amount < 0 and abs(signed_amount) <= abs(current_position.quantity)
699
+ ) or (
700
+ current_position.quantity < 0 and signed_amount > 0 and abs(signed_amount) <= abs(current_position.quantity)
701
+ )
@@ -16,6 +16,7 @@ from lighter import ( # type: ignore
16
16
  InfoApi,
17
17
  OrderApi,
18
18
  SignerClient,
19
+ TransactionApi,
19
20
  )
20
21
 
21
22
  # Reset logging - lighter SDK sets root logger to DEBUG on import
@@ -69,6 +70,7 @@ class LighterClient:
69
70
  _order_api: OrderApi
70
71
  _candlestick_api: CandlestickApi
71
72
  _funding_api: FundingApi
73
+ _transaction_api: TransactionApi
72
74
  signer_client: SignerClient
73
75
 
74
76
  def __init__(
@@ -124,6 +126,7 @@ class LighterClient:
124
126
  self._order_api = OrderApi(self._api_client)
125
127
  self._candlestick_api = CandlestickApi(self._api_client)
126
128
  self._funding_api = FundingApi(self._api_client)
129
+ self._transaction_api = TransactionApi(self._api_client)
127
130
  self.signer_client = SignerClient(
128
131
  url=self.api_url,
129
132
  private_key=self.private_key,
@@ -192,6 +195,15 @@ class LighterClient:
192
195
  logger.error(f"Failed to get markets: {e}")
193
196
  raise
194
197
 
198
+ async def next_nonce(self) -> int:
199
+ """
200
+ Get next nonce for the account.
201
+ """
202
+ response = await self._run_on_client_loop(
203
+ self._transaction_api.next_nonce(account_index=self.account_index, api_key_index=self.api_key_index)
204
+ )
205
+ return response.nonce
206
+
195
207
  async def get_market_info(self, market_id: int) -> Optional[dict]:
196
208
  """
197
209
  Get information for a specific market.