Qubx 0.6.13__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.16__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/broker.py CHANGED
@@ -57,6 +57,19 @@ class SimulatedBroker(IBroker):
57
57
  self._send_exec_report(instrument, report)
58
58
  return report.order
59
59
 
60
+ def send_order_async(
61
+ self,
62
+ instrument: Instrument,
63
+ order_side: str,
64
+ order_type: str,
65
+ amount: float,
66
+ price: float | None = None,
67
+ client_id: str | None = None,
68
+ time_in_force: str = "gtc",
69
+ **optional,
70
+ ) -> None:
71
+ self.send_order(instrument, order_side, order_type, amount, price, client_id, time_in_force, **optional)
72
+
60
73
  def cancel_order(self, order_id: str) -> Order | None:
61
74
  instrument = self._account.order_to_instrument.get(order_id)
62
75
  if instrument is None:
qubx/backtester/runner.py CHANGED
@@ -248,6 +248,9 @@ class SimulationRunner:
248
248
  initializer=self.initializer,
249
249
  )
250
250
 
251
+ if self.emitter is not None:
252
+ self.emitter.set_time_provider(simulated_clock)
253
+
251
254
  # - setup base subscription from spec
252
255
  if ctx.get_base_subscription() == DataType.NONE:
253
256
  logger.debug(
@@ -8,6 +8,8 @@ from qubx.core.exceptions import SimulationError
8
8
  from qubx.core.metrics import TradingSessionResult
9
9
  from qubx.data.readers import DataReader
10
10
  from qubx.utils.misc import ProgressParallel, Stopwatch, get_current_user
11
+ from qubx.utils.runner.configs import EmissionConfig
12
+ from qubx.utils.runner.factory import create_metric_emitters
11
13
  from qubx.utils.time import handle_start_stop
12
14
 
13
15
  from .runner import SimulationRunner
@@ -45,6 +47,7 @@ def simulate(
45
47
  show_latency_report: bool = False,
46
48
  portfolio_log_freq: str = "5Min",
47
49
  parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
50
+ emission: EmissionConfig | None = None,
48
51
  ) -> list[TradingSessionResult]:
49
52
  """
50
53
  Backtest utility for trading strategies or signals using historical data.
@@ -67,6 +70,9 @@ def simulate(
67
70
  - open_close_time_indent_secs (int): Time indent in seconds for open/close times, default is 1.
68
71
  - debug (Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None): Logging level for debugging.
69
72
  - show_latency_report: If True, shows simulator's latency report.
73
+ - portfolio_log_freq (str): Frequency for portfolio logging, default is "5Min".
74
+ - parallel_backend (Literal["loky", "multiprocessing"]): Backend for parallel processing, default is "multiprocessing".
75
+ - emission (EmissionConfig | None): Configuration for metric emitters, default is None.
70
76
 
71
77
  Returns:
72
78
  - list[TradingSessionResult]: A list of TradingSessionResult objects containing the results of each simulation setup.
@@ -139,6 +145,7 @@ def simulate(
139
145
  show_latency_report=show_latency_report,
140
146
  portfolio_log_freq=portfolio_log_freq,
141
147
  parallel_backend=parallel_backend,
148
+ emission=emission,
142
149
  )
143
150
 
144
151
 
@@ -152,6 +159,7 @@ def _run_setups(
152
159
  show_latency_report: bool = False,
153
160
  portfolio_log_freq: str = "5Min",
154
161
  parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
162
+ emission: EmissionConfig | None = None,
155
163
  ) -> list[TradingSessionResult]:
156
164
  # loggers don't work well with joblib and multiprocessing in general because they contain
157
165
  # open file handlers that cannot be pickled. I found a solution which requires the usage of enqueue=True
@@ -165,7 +173,16 @@ def _run_setups(
165
173
  n_jobs=n_jobs, total=len(strategies_setups), silent=_main_loop_silent, backend=parallel_backend
166
174
  )(
167
175
  delayed(_run_setup)(
168
- id, f"Simulated-{id}", setup, data_setup, start, stop, silent, show_latency_report, portfolio_log_freq
176
+ id,
177
+ f"Simulated-{id}",
178
+ setup,
179
+ data_setup,
180
+ start,
181
+ stop,
182
+ silent,
183
+ show_latency_report,
184
+ portfolio_log_freq,
185
+ emission,
169
186
  )
170
187
  for id, setup in enumerate(strategies_setups)
171
188
  )
@@ -182,7 +199,13 @@ def _run_setup(
182
199
  silent: bool,
183
200
  show_latency_report: bool,
184
201
  portfolio_log_freq: str,
202
+ emission: EmissionConfig | None = None,
185
203
  ) -> TradingSessionResult:
204
+ # Create metric emitter if configured
205
+ emitter = None
206
+ if emission is not None:
207
+ emitter = create_metric_emitters(emission, setup.name)
208
+
186
209
  runner = SimulationRunner(
187
210
  setup=setup,
188
211
  data_config=data_setup,
@@ -190,6 +213,7 @@ def _run_setup(
190
213
  stop=stop,
191
214
  account_id=account_id,
192
215
  portfolio_log_freq=portfolio_log_freq,
216
+ emitter=emitter,
193
217
  )
194
218
 
195
219
  # - we want to see simulate time in log messages
@@ -77,6 +77,8 @@ class CcxtAccountProcessor(BasicAccountProcessor):
77
77
  balance_interval: str = "30Sec",
78
78
  position_interval: str = "30Sec",
79
79
  subscription_interval: str = "10Sec",
80
+ open_order_interval: str = "1Min",
81
+ open_order_backoff: str = "1Min",
80
82
  max_position_restore_days: int = 30,
81
83
  max_retries: int = 10,
82
84
  ):
@@ -93,6 +95,8 @@ class CcxtAccountProcessor(BasicAccountProcessor):
93
95
  self.balance_interval = balance_interval
94
96
  self.position_interval = position_interval
95
97
  self.subscription_interval = subscription_interval
98
+ self.open_order_interval = open_order_interval
99
+ self.open_order_backoff = open_order_backoff
96
100
  self.max_position_restore_days = max_position_restore_days
97
101
  self._loop = AsyncThreadLoop(exchange.asyncio_loop)
98
102
  self._is_running = False
@@ -140,11 +144,17 @@ class CcxtAccountProcessor(BasicAccountProcessor):
140
144
  logger.info("Account polling tasks have been initialized")
141
145
 
142
146
  # - start subscription polling task
143
- self._polling_tasks["subscription"] = self._loop.submit(
144
- self._poller("subscription", self._update_subscriptions, self.subscription_interval)
145
- )
147
+ # self._polling_tasks["subscription"] = self._loop.submit(
148
+ # self._poller("subscription", self._update_subscriptions, self.subscription_interval)
149
+ # )
146
150
  # - subscribe to order executions
147
151
  self._polling_tasks["executions"] = self._loop.submit(self._subscribe_executions("executions", channel))
152
+ # - sync open orders
153
+ self._polling_tasks["open_orders"] = self._loop.submit(
154
+ self._poller(
155
+ "open_orders", self._sync_open_orders, self.open_order_interval, backoff=self.open_order_backoff
156
+ )
157
+ )
148
158
 
149
159
  def stop(self):
150
160
  """Stop all polling tasks"""
@@ -188,10 +198,15 @@ class CcxtAccountProcessor(BasicAccountProcessor):
188
198
  name: str,
189
199
  coroutine: Callable[[], Awaitable],
190
200
  interval: str,
201
+ backoff: str | None = None,
191
202
  ):
192
203
  sleep_time = pd.Timedelta(interval).total_seconds()
193
204
  retries = 0
194
205
 
206
+ if backoff is not None:
207
+ sleep_time = pd.Timedelta(backoff).total_seconds()
208
+ await asyncio.sleep(sleep_time)
209
+
195
210
  while self.channel.control.is_set():
196
211
  try:
197
212
  await coroutine()
@@ -276,7 +291,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
276
291
  async def _update_positions(self) -> None:
277
292
  # fetch and update positions from exchange
278
293
  ccxt_positions = await self.exchange.fetch_positions()
279
- positions = ccxt_convert_positions(ccxt_positions, self.exchange.name, self.exchange.markets)
294
+ positions = ccxt_convert_positions(ccxt_positions, self.exchange.name, self.exchange.markets) # type: ignore
280
295
  # update required instruments that we need to subscribe to
281
296
  self._required_instruments.update([p.instrument for p in positions])
282
297
  # update positions
@@ -388,7 +403,10 @@ class CcxtAccountProcessor(BasicAccountProcessor):
388
403
  async def _init_open_orders(self) -> None:
389
404
  # wait for balances and positions to be initialized
390
405
  await self._wait(lambda: all([self._polling_to_init[task] for task in ["balance", "position"]]))
391
- logger.debug("Fetching open orders ...")
406
+ await self._sync_open_orders(initial_call=True)
407
+
408
+ async def _sync_open_orders(self, initial_call: bool = False) -> None:
409
+ logger.debug("[SYNC] Fetching open orders ...")
392
410
 
393
411
  # in order to minimize order requests we only fetch open orders for instruments that we have positions in
394
412
  _nonzero_balances = {
@@ -405,20 +423,50 @@ class CcxtAccountProcessor(BasicAccountProcessor):
405
423
  _orders = await self._fetch_orders(instrument, is_open=True)
406
424
  _open_orders.update(_orders)
407
425
  except Exception as e:
408
- logger.warning(f"Error fetching open orders for {instrument}: {e}")
426
+ logger.warning(f"[SYNC] Error fetching open orders for {instrument}: {e}")
409
427
 
410
428
  await asyncio.gather(*[_add_open_orders(i) for i in _instruments])
411
429
 
412
- self.add_active_orders(_open_orders)
413
-
414
- logger.debug(f"Found {len(_open_orders)} open orders ->")
415
- _instr_to_open_orders: dict[Instrument, list[Order]] = defaultdict(list)
416
- for od in _open_orders.values():
417
- _instr_to_open_orders[od.instrument].append(od)
418
- for instr, orders in _instr_to_open_orders.items():
419
- logger.debug(f" :: {instr} ->")
420
- for order in orders:
421
- logger.debug(f" :: {order.side} {order.quantity} @ {order.price} ({order.status})")
430
+ if initial_call:
431
+ # - when it's the initial call, we add the open orders to the account
432
+ self.add_active_orders(_open_orders)
433
+ logger.debug(f"[SYNC] Found {len(_open_orders)} open orders ->")
434
+ _instr_to_open_orders: dict[Instrument, list[Order]] = defaultdict(list)
435
+ for od in _open_orders.values():
436
+ _instr_to_open_orders[od.instrument].append(od)
437
+ for instr, orders in _instr_to_open_orders.items():
438
+ logger.debug(f" :: [SYNC] {instr} ->")
439
+ for order in orders:
440
+ logger.debug(f" :: [SYNC] {order.side} {order.quantity} @ {order.price} ({order.status})")
441
+ else:
442
+ # TODO: think if this should actually be here
443
+ # - we need to cancel the unexpected orders
444
+ await self._cancel_unexpected_orders(_open_orders)
445
+
446
+ async def _cancel_unexpected_orders(self, open_orders: dict[str, Order]) -> None:
447
+ _expected_orders = set(self._active_orders.keys())
448
+ _unexpected_orders = set(open_orders.keys()) - _expected_orders
449
+ if _unexpected_orders:
450
+ logger.info(f"[SYNC] Canceling {len(_unexpected_orders)} unexpected open orders ...")
451
+ _instr_to_orders = defaultdict(list)
452
+ for _id in _unexpected_orders:
453
+ _order = open_orders[_id]
454
+ _instr_to_orders[_order.instrument].append(_order)
455
+
456
+ async def _cancel_order(order: Order) -> None:
457
+ try:
458
+ await self.exchange.cancel_order(order.id, symbol=instrument_to_ccxt_symbol(order.instrument))
459
+ logger.debug(
460
+ f" :: [SYNC] Canceled {order.id} {order.instrument.symbol} {order.side} {order.quantity} @ {order.price} ({order.status})"
461
+ )
462
+ except Exception as e:
463
+ logger.warning(f"[SYNC] Error canceling order {order.id}: {e}")
464
+
465
+ for instr, orders in _instr_to_orders.items():
466
+ logger.debug(
467
+ f"[SYNC] Canceling {len(orders)} (out of {len(open_orders)}) unexpected open orders for {instr}"
468
+ )
469
+ await asyncio.gather(*[_cancel_order(order) for order in orders])
422
470
 
423
471
  async def _fetch_orders(
424
472
  self, instrument: Instrument, days_before: int = 30, limit: int | None = None, is_open: bool = False
@@ -1,6 +1,9 @@
1
+ import asyncio
1
2
  import traceback
2
3
  from typing import Any
3
4
 
5
+ import pandas as pd
6
+
4
7
  import ccxt
5
8
  import ccxt.pro as cxp
6
9
  from ccxt.base.errors import ExchangeError
@@ -9,12 +12,13 @@ from qubx.core.basics import (
9
12
  CtrlChannel,
10
13
  Instrument,
11
14
  Order,
12
- Position,
13
15
  )
16
+ from qubx.core.errors import OrderCancellationError, OrderCreationError, create_error_event
14
17
  from qubx.core.exceptions import InvalidOrderParameters
15
18
  from qubx.core.interfaces import (
16
19
  IAccountProcessor,
17
20
  IBroker,
21
+ IDataProvider,
18
22
  ITimeProvider,
19
23
  )
20
24
  from qubx.utils.misc import AsyncThreadLoop
@@ -24,8 +28,6 @@ from .utils import ccxt_convert_order_info, instrument_to_ccxt_symbol
24
28
 
25
29
  class CcxtBroker(IBroker):
26
30
  _exchange: cxp.Exchange
27
-
28
- _positions: dict[Instrument, Position]
29
31
  _loop: AsyncThreadLoop
30
32
 
31
33
  def __init__(
@@ -34,18 +36,94 @@ class CcxtBroker(IBroker):
34
36
  channel: CtrlChannel,
35
37
  time_provider: ITimeProvider,
36
38
  account: IAccountProcessor,
39
+ data_provider: IDataProvider,
40
+ enable_price_match: bool = False,
41
+ price_match_ticks: int = 5,
42
+ cancel_timeout: int = 30,
43
+ cancel_retry_interval: int = 2,
44
+ max_cancel_retries: int = 10,
37
45
  ):
38
46
  self._exchange = exchange
39
47
  self.ccxt_exchange_id = str(exchange.name)
40
48
  self.channel = channel
41
49
  self.time_provider = time_provider
42
50
  self.account = account
51
+ self.data_provider = data_provider
52
+ self.enable_price_match = enable_price_match
53
+ self.price_match_ticks = price_match_ticks
43
54
  self._loop = AsyncThreadLoop(exchange.asyncio_loop)
55
+ self.cancel_timeout = cancel_timeout
56
+ self.cancel_retry_interval = cancel_retry_interval
57
+ self.max_cancel_retries = max_cancel_retries
44
58
 
45
59
  @property
46
60
  def is_simulated_trading(self) -> bool:
47
61
  return False
48
62
 
63
+ def send_order_async(
64
+ self,
65
+ instrument: Instrument,
66
+ order_side: str,
67
+ order_type: str,
68
+ amount: float,
69
+ price: float | None = None,
70
+ client_id: str | None = None,
71
+ time_in_force: str = "gtc",
72
+ **options,
73
+ ) -> Any: # Return type as Any to avoid Future/Task typing issues
74
+ """
75
+ Submit an order asynchronously. Errors will be sent through the channel.
76
+
77
+ Returns:
78
+ Future-like object that will eventually contain the result
79
+ """
80
+
81
+ async def _execute_order_with_channel_errors():
82
+ try:
83
+ order, error = await self._create_order(
84
+ instrument=instrument,
85
+ order_side=order_side,
86
+ order_type=order_type,
87
+ amount=amount,
88
+ price=price,
89
+ client_id=client_id,
90
+ time_in_force=time_in_force,
91
+ **options,
92
+ )
93
+
94
+ if error:
95
+ # Create and send an error event through the channel
96
+ error_event = OrderCreationError(
97
+ timestamp=self.time_provider.time(),
98
+ message=str(error),
99
+ instrument=instrument,
100
+ amount=amount,
101
+ price=price,
102
+ order_type=order_type,
103
+ side=order_side,
104
+ )
105
+ self.channel.send(create_error_event(error_event))
106
+ return None
107
+ return order
108
+ except Exception as err:
109
+ # Catch any unexpected errors and send them through the channel as well
110
+ logger.error(f"Unexpected error in async order creation: {err}")
111
+ logger.error(traceback.format_exc())
112
+ error_event = OrderCreationError(
113
+ timestamp=self.time_provider.time(),
114
+ message=f"Unexpected error: {str(err)}",
115
+ instrument=instrument,
116
+ amount=amount,
117
+ price=price,
118
+ order_type=order_type,
119
+ side=order_side,
120
+ )
121
+ self.channel.send(create_error_event(error_event))
122
+ return None
123
+
124
+ # Submit the task to the async loop
125
+ return self._loop.submit(_execute_order_with_channel_errors())
126
+
49
127
  def send_order(
50
128
  self,
51
129
  instrument: Instrument,
@@ -57,13 +135,97 @@ class CcxtBroker(IBroker):
57
135
  time_in_force: str = "gtc",
58
136
  **options,
59
137
  ) -> Order:
138
+ """
139
+ Submit an order and wait for the result. Exceptions will be raised on errors.
140
+
141
+ Returns:
142
+ Order: The created order object
143
+
144
+ Raises:
145
+ Various exceptions based on the error that occurred
146
+ """
147
+ try:
148
+ # Create a task that executes the order creation
149
+ future = self._loop.submit(
150
+ self._create_order(
151
+ instrument=instrument,
152
+ order_side=order_side,
153
+ order_type=order_type,
154
+ amount=amount,
155
+ price=price,
156
+ client_id=client_id,
157
+ time_in_force=time_in_force,
158
+ **options,
159
+ )
160
+ )
161
+
162
+ # Wait for the result
163
+ order, error = future.result()
164
+
165
+ # If there was an error, raise it
166
+ if error:
167
+ raise error
168
+
169
+ # If there was no error but also no order, something went wrong
170
+ if not order:
171
+ raise ExchangeError("Order creation failed with no specific error")
172
+
173
+ return order
174
+
175
+ except Exception as err:
176
+ # This will catch any errors from future.result() or if we explicitly raise an error
177
+ logger.error(f"Error in send_order: {err}")
178
+ raise
179
+
180
+ def cancel_order(self, order_id: str) -> Order | None:
181
+ orders = self.account.get_orders()
182
+ if order_id not in orders:
183
+ logger.warning(f"Order {order_id} not found in active orders")
184
+ return None
185
+
186
+ order = orders[order_id]
187
+ logger.info(f"Canceling order {order_id} ...")
188
+
189
+ # Submit the cancellation task to the async loop without waiting for the result
190
+ self._loop.submit(self._cancel_order_with_retry(order_id, order.instrument))
191
+
192
+ # Always return None as requested
193
+ return None
194
+
195
+ async def _create_order(
196
+ self,
197
+ instrument: Instrument,
198
+ order_side: str,
199
+ order_type: str,
200
+ amount: float,
201
+ price: float | None = None,
202
+ client_id: str | None = None,
203
+ time_in_force: str = "gtc",
204
+ **options,
205
+ ) -> tuple[Order | None, Exception | None]:
206
+ """
207
+ Asynchronously create an order with the exchange.
208
+
209
+ Returns:
210
+ tuple: (Order object if successful, Exception if failed)
211
+ """
60
212
  params = {}
61
213
  _is_trigger_order = order_type.startswith("stop_")
62
214
 
63
215
  if order_type == "limit" or _is_trigger_order:
64
216
  params["timeInForce"] = time_in_force.upper()
65
217
  if price is None:
66
- raise InvalidOrderParameters(f"Price must be specified for '{order_type}' order")
218
+ return None, InvalidOrderParameters(f"Price must be specified for '{order_type}' order")
219
+
220
+ quote = self.data_provider.get_quote(instrument)
221
+
222
+ # TODO: think about automatically setting reduce only when needed
223
+ if not options.get("reduceOnly", False):
224
+ min_notional = instrument.min_notional
225
+ if min_notional > 0 and abs(amount) * quote.mid_price() < min_notional:
226
+ return None, InvalidOrderParameters(
227
+ f"[{instrument.symbol}] Order amount {amount} is too small. Minimum notional is {min_notional}"
228
+ )
67
229
 
68
230
  # - handle trigger (stop) orders
69
231
  if _is_trigger_order:
@@ -73,62 +235,159 @@ class CcxtBroker(IBroker):
73
235
  if client_id:
74
236
  params["newClientOrderId"] = client_id
75
237
 
238
+ if "priceMatch" in options:
239
+ params["priceMatch"] = options["priceMatch"]
240
+
76
241
  if instrument.is_futures():
77
242
  params["type"] = "swap"
78
243
 
244
+ if time_in_force == "gtx" and price is not None and self.enable_price_match:
245
+ if (order_side == "buy" and quote.bid - price < self.price_match_ticks * instrument.tick_size) or (
246
+ order_side == "sell" and price - quote.ask < self.price_match_ticks * instrument.tick_size
247
+ ):
248
+ params["priceMatch"] = "QUEUE"
249
+ logger.debug(f"[<y>{instrument.symbol}</y>] :: Price match is set to QUEUE. Price will be ignored.")
250
+
251
+ if "priceMatch" in params:
252
+ # - if price match is set, we don't need to specify the price
253
+ price = None
254
+
79
255
  ccxt_symbol = instrument_to_ccxt_symbol(instrument)
80
256
 
81
- r: dict[str, Any] | None = None
82
257
  try:
83
- r = self._loop.submit(
84
- self._exchange.create_order(
85
- symbol=ccxt_symbol,
86
- type=order_type, # type: ignore
87
- side=order_side, # type: ignore
258
+ # Type annotation issue: We need to use type ignore for CCXT API compatibility
259
+ r = await self._exchange.create_order(
260
+ symbol=ccxt_symbol,
261
+ type=order_type, # type: ignore
262
+ side=order_side, # type: ignore
263
+ amount=amount,
264
+ price=price,
265
+ params=params,
266
+ )
267
+
268
+ if r is None:
269
+ msg = "(::_create_order) No response from exchange"
270
+ logger.error(msg)
271
+ return None, ExchangeError(msg)
272
+
273
+ order = ccxt_convert_order_info(instrument, r)
274
+ logger.info(f"New order {order}")
275
+ return order, None
276
+
277
+ except ccxt.OrderNotFillable as exc:
278
+ logger.error(
279
+ f"(::_create_order) [{instrument.symbol}] ORDER NOT FILLEABLE for {order_side} {amount} {order_type} : {exc}"
280
+ )
281
+ exc_msg = str(exc)
282
+ if (
283
+ self.enable_price_match
284
+ and "priceMatch" not in options
285
+ and ("-5022" in exc_msg or "Post Only order will be rejected" in exc_msg)
286
+ ):
287
+ logger.debug(f"(::_create_order) [{instrument.symbol}] Trying again with price match ...")
288
+ options_with_price_match = options.copy()
289
+ options_with_price_match["priceMatch"] = "QUEUE"
290
+ return await self._create_order(
291
+ instrument=instrument,
292
+ order_side=order_side,
293
+ order_type=order_type,
88
294
  amount=amount,
89
295
  price=price,
90
- params=params,
296
+ client_id=client_id,
297
+ time_in_force=time_in_force,
298
+ **options_with_price_match,
91
299
  )
92
- ).result()
300
+ return None, exc
301
+ except ccxt.InvalidOrder as exc:
302
+ logger.error(
303
+ f"(::_create_order) INVALID ORDER for {order_side} {amount} {order_type} for {instrument.symbol} : {exc}"
304
+ )
305
+ return None, exc
93
306
  except ccxt.BadRequest as exc:
94
307
  logger.error(
95
- f"(::send_order) BAD REQUEST for {order_side} {amount} {order_type} for {instrument.symbol} : {exc}"
308
+ f"(::_create_order) BAD REQUEST for {order_side} {amount} {order_type} for {instrument.symbol} : {exc}"
96
309
  )
97
- raise exc
310
+ return None, exc
98
311
  except Exception as err:
99
- logger.error(f"(::send_order) {order_side} {amount} {order_type} for {instrument.symbol} exception : {err}")
312
+ logger.error(
313
+ f"(::_create_order) {order_side} {amount} {order_type} for {instrument.symbol} exception : {err}"
314
+ )
100
315
  logger.error(traceback.format_exc())
101
- raise err
316
+ return None, err
102
317
 
103
- if r is None:
104
- msg = "(::send_order) No response from exchange"
105
- logger.error(msg)
106
- raise ExchangeError(msg)
318
+ async def _cancel_order_with_retry(self, order_id: str, instrument: Instrument) -> bool:
319
+ """
320
+ Attempts to cancel an order with retries.
107
321
 
108
- order = ccxt_convert_order_info(instrument, r)
109
- logger.info(f"New order {order}")
110
- return order
322
+ Args:
323
+ order_id: The ID of the order to cancel
324
+ symbol: The symbol of the instrument
111
325
 
112
- def cancel_order(self, order_id: str) -> Order | None:
113
- order = None
114
- orders = self.account.get_orders()
115
- if order_id in orders:
116
- order = orders[order_id]
326
+ Returns:
327
+ bool: True if cancellation was successful, False otherwise
328
+ """
329
+ start_time = self.time_provider.time()
330
+ timeout_delta = self.cancel_timeout
331
+ retries = 0
332
+
333
+ while True:
117
334
  try:
118
- logger.info(f"Canceling order {order_id} ...")
119
- result = self._loop.submit(
120
- self._exchange.cancel_order(order_id, symbol=instrument_to_ccxt_symbol(order.instrument))
121
- ).result()
122
- logger.debug(f"Cancel order result: {result}")
123
- return order
335
+ await self._exchange.cancel_order_ws(order_id, symbol=instrument_to_ccxt_symbol(instrument))
336
+ return True
337
+ except ccxt.OperationRejected as err:
338
+ err_msg = str(err).lower()
339
+ # Check if the error is about an unknown order or non-existent order
340
+ if "unknown order" in err_msg or "order does not exist" in err_msg or "order not found" in err_msg:
341
+ # These errors might be temporary if the order is still being processed, so retry
342
+ logger.debug(f"[{order_id}] Order not found for cancellation, might retry: {err}")
343
+ # Continue with the retry logic instead of returning immediately
344
+ else:
345
+ # For other operation rejected errors, don't retry
346
+ logger.debug(f"[{order_id}] Could not cancel order: {err}")
347
+ return False
348
+ except (ccxt.NetworkError, ccxt.ExchangeError, ccxt.ExchangeNotAvailable) as e:
349
+ logger.debug(f"[{order_id}] Network or exchange error while cancelling: {e}")
350
+ # Continue with retry logic
124
351
  except Exception as err:
125
- logger.error(f"Canceling [{order}] exception : {err}")
352
+ logger.error(f"Unexpected error canceling order {order_id}: {err}")
126
353
  logger.error(traceback.format_exc())
127
- raise err
128
- return order
354
+ return False
355
+
356
+ # Common retry logic for all retryable errors
357
+ current_time = self.time_provider.time()
358
+ elapsed_seconds = pd.Timedelta(current_time - start_time).total_seconds()
359
+ retries += 1
360
+
361
+ if elapsed_seconds >= timeout_delta or retries >= self.max_cancel_retries:
362
+ logger.error(f"Timeout reached for canceling order {order_id}")
363
+ self.channel.send(
364
+ create_error_event(
365
+ OrderCancellationError(
366
+ timestamp=self.time_provider.time(),
367
+ order_id=order_id,
368
+ message=f"Timeout reached for canceling order {order_id}",
369
+ instrument=instrument,
370
+ )
371
+ )
372
+ )
373
+ return False
374
+
375
+ # Wait before retrying with exponential backoff
376
+ backoff_time = min(self.cancel_retry_interval * (2 ** (retries - 1)), 30)
377
+ logger.debug(f"Retrying order cancellation for {order_id} in {backoff_time} seconds (retry {retries})")
378
+ await asyncio.sleep(backoff_time)
379
+
380
+ # This should never be reached due to the return statements above,
381
+ # but it's here to satisfy the type checker
382
+ return False
129
383
 
130
384
  def cancel_orders(self, instrument: Instrument) -> None:
131
- raise NotImplementedError("Not implemented yet")
385
+ orders = self.account.get_orders()
386
+ instrument_orders = [order_id for order_id, order in orders.items() if order.instrument == instrument]
387
+
388
+ # Submit all cancellations without waiting for results
389
+ for order_id in instrument_orders:
390
+ self.cancel_order(order_id)
132
391
 
133
392
  def update_order(self, order_id: str, price: float | None = None, amount: float | None = None) -> Order:
134
393
  raise NotImplementedError("Not implemented yet")