Qubx 0.5.7__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 (100) hide show
  1. qubx/__init__.py +207 -0
  2. qubx/_nb_magic.py +100 -0
  3. qubx/backtester/__init__.py +5 -0
  4. qubx/backtester/account.py +145 -0
  5. qubx/backtester/broker.py +87 -0
  6. qubx/backtester/data.py +296 -0
  7. qubx/backtester/management.py +378 -0
  8. qubx/backtester/ome.py +296 -0
  9. qubx/backtester/optimization.py +201 -0
  10. qubx/backtester/simulated_data.py +558 -0
  11. qubx/backtester/simulator.py +362 -0
  12. qubx/backtester/utils.py +780 -0
  13. qubx/cli/__init__.py +0 -0
  14. qubx/cli/commands.py +67 -0
  15. qubx/connectors/ccxt/__init__.py +0 -0
  16. qubx/connectors/ccxt/account.py +495 -0
  17. qubx/connectors/ccxt/broker.py +132 -0
  18. qubx/connectors/ccxt/customizations.py +193 -0
  19. qubx/connectors/ccxt/data.py +612 -0
  20. qubx/connectors/ccxt/exceptions.py +17 -0
  21. qubx/connectors/ccxt/factory.py +93 -0
  22. qubx/connectors/ccxt/utils.py +307 -0
  23. qubx/core/__init__.py +0 -0
  24. qubx/core/account.py +251 -0
  25. qubx/core/basics.py +850 -0
  26. qubx/core/context.py +420 -0
  27. qubx/core/exceptions.py +38 -0
  28. qubx/core/helpers.py +480 -0
  29. qubx/core/interfaces.py +1150 -0
  30. qubx/core/loggers.py +514 -0
  31. qubx/core/lookups.py +475 -0
  32. qubx/core/metrics.py +1512 -0
  33. qubx/core/mixins/__init__.py +13 -0
  34. qubx/core/mixins/market.py +94 -0
  35. qubx/core/mixins/processing.py +428 -0
  36. qubx/core/mixins/subscription.py +203 -0
  37. qubx/core/mixins/trading.py +88 -0
  38. qubx/core/mixins/universe.py +270 -0
  39. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  40. qubx/core/series.pxd +125 -0
  41. qubx/core/series.pyi +118 -0
  42. qubx/core/series.pyx +988 -0
  43. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  44. qubx/core/utils.pyi +6 -0
  45. qubx/core/utils.pyx +62 -0
  46. qubx/data/__init__.py +25 -0
  47. qubx/data/helpers.py +416 -0
  48. qubx/data/readers.py +1562 -0
  49. qubx/data/tardis.py +100 -0
  50. qubx/gathering/simplest.py +88 -0
  51. qubx/math/__init__.py +3 -0
  52. qubx/math/stats.py +129 -0
  53. qubx/pandaz/__init__.py +23 -0
  54. qubx/pandaz/ta.py +2757 -0
  55. qubx/pandaz/utils.py +638 -0
  56. qubx/resources/instruments/symbols-binance.cm.json +1 -0
  57. qubx/resources/instruments/symbols-binance.json +1 -0
  58. qubx/resources/instruments/symbols-binance.um.json +1 -0
  59. qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
  60. qubx/resources/instruments/symbols-bitfinex.json +1 -0
  61. qubx/resources/instruments/symbols-kraken.f.json +1 -0
  62. qubx/resources/instruments/symbols-kraken.json +1 -0
  63. qubx/ta/__init__.py +0 -0
  64. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  65. qubx/ta/indicators.pxd +149 -0
  66. qubx/ta/indicators.pyi +41 -0
  67. qubx/ta/indicators.pyx +787 -0
  68. qubx/trackers/__init__.py +3 -0
  69. qubx/trackers/abvanced.py +236 -0
  70. qubx/trackers/composite.py +146 -0
  71. qubx/trackers/rebalancers.py +129 -0
  72. qubx/trackers/riskctrl.py +641 -0
  73. qubx/trackers/sizers.py +235 -0
  74. qubx/utils/__init__.py +5 -0
  75. qubx/utils/_pyxreloader.py +281 -0
  76. qubx/utils/charting/lookinglass.py +1057 -0
  77. qubx/utils/charting/mpl_helpers.py +1183 -0
  78. qubx/utils/marketdata/binance.py +284 -0
  79. qubx/utils/marketdata/ccxt.py +90 -0
  80. qubx/utils/marketdata/dukas.py +130 -0
  81. qubx/utils/misc.py +541 -0
  82. qubx/utils/ntp.py +63 -0
  83. qubx/utils/numbers_utils.py +7 -0
  84. qubx/utils/orderbook.py +491 -0
  85. qubx/utils/plotting/__init__.py +0 -0
  86. qubx/utils/plotting/dashboard.py +150 -0
  87. qubx/utils/plotting/data.py +137 -0
  88. qubx/utils/plotting/interfaces.py +25 -0
  89. qubx/utils/plotting/renderers/__init__.py +0 -0
  90. qubx/utils/plotting/renderers/plotly.py +0 -0
  91. qubx/utils/runner/__init__.py +1 -0
  92. qubx/utils/runner/_jupyter_runner.pyt +60 -0
  93. qubx/utils/runner/accounts.py +88 -0
  94. qubx/utils/runner/configs.py +65 -0
  95. qubx/utils/runner/runner.py +470 -0
  96. qubx/utils/time.py +312 -0
  97. qubx-0.5.7.dist-info/METADATA +105 -0
  98. qubx-0.5.7.dist-info/RECORD +100 -0
  99. qubx-0.5.7.dist-info/WHEEL +4 -0
  100. qubx-0.5.7.dist-info/entry_points.txt +3 -0
qubx/cli/__init__.py ADDED
File without changes
qubx/cli/commands.py ADDED
@@ -0,0 +1,67 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from qubx.utils.misc import add_project_to_system_path, logo
6
+ from qubx.utils.runner.runner import run_strategy_yaml, run_strategy_yaml_in_jupyter, simulate_strategy
7
+
8
+
9
+ @click.group()
10
+ def main():
11
+ """
12
+ Qubx CLI.
13
+ """
14
+ pass
15
+
16
+
17
+ @main.command()
18
+ @click.argument("config-file", type=Path, required=True)
19
+ @click.option(
20
+ "--account-file",
21
+ "-a",
22
+ type=Path,
23
+ help="Account configuration file path.",
24
+ required=False,
25
+ )
26
+ @click.option("--paper", "-p", is_flag=True, default=False, help="Use paper trading mode.", show_default=True)
27
+ @click.option(
28
+ "--jupyter", "-j", is_flag=True, default=False, help="Run strategy in jupyter console.", show_default=True
29
+ )
30
+ def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool):
31
+ """
32
+ Starts the strategy with the given configuration file. If paper mode is enabled, account is not required.
33
+
34
+ Account configurations are searched in the following priority:\n
35
+ - If provided, the account file is searched first.\n
36
+ - If exists, accounts.toml located in the same folder with the config searched.\n
37
+ - If neither of the above are provided, the accounts.toml in the ~/qubx/accounts.toml path is searched.
38
+ """
39
+ add_project_to_system_path()
40
+ add_project_to_system_path(str(config_file.parent))
41
+ if jupyter:
42
+ run_strategy_yaml_in_jupyter(config_file, account_file, paper)
43
+ else:
44
+ logo()
45
+ run_strategy_yaml(config_file, account_file, paper, blocking=True)
46
+
47
+
48
+ @main.command()
49
+ @click.argument("config-file", type=Path, required=True)
50
+ @click.option(
51
+ "--start", "-s", default=None, type=str, help="Override simulation start date from config.", show_default=True
52
+ )
53
+ @click.option(
54
+ "--end", "-e", default=None, type=str, help="Override simulation end date from config.", show_default=True
55
+ )
56
+ @click.option(
57
+ "--output", "-o", default="results", type=str, help="Output directory for simulation results.", show_default=True
58
+ )
59
+ def simulate(config_file: Path, start: str | None, end: str | None, output: str | None):
60
+ add_project_to_system_path()
61
+ add_project_to_system_path(str(config_file.parent))
62
+ logo()
63
+ simulate_strategy(config_file, output, start, end)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
File without changes
@@ -0,0 +1,495 @@
1
+ import asyncio
2
+ import concurrent.futures
3
+ from asyncio.exceptions import CancelledError
4
+ from collections import defaultdict
5
+ from typing import Awaitable, Callable
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ import ccxt.pro as cxp
11
+ from ccxt import ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable, NetworkError
12
+ from qubx import logger
13
+ from qubx.core.account import BasicAccountProcessor
14
+ from qubx.core.basics import (
15
+ CtrlChannel,
16
+ Deal,
17
+ Instrument,
18
+ ITimeProvider,
19
+ Order,
20
+ Position,
21
+ TransactionCostsCalculator,
22
+ dt_64,
23
+ )
24
+ from qubx.core.interfaces import ISubscriptionManager
25
+ from qubx.utils.marketdata.ccxt import ccxt_symbol_to_instrument
26
+ from qubx.utils.misc import AsyncThreadLoop
27
+
28
+ from .exceptions import CcxtSymbolNotRecognized
29
+ from .utils import (
30
+ ccxt_convert_balance,
31
+ ccxt_convert_deal_info,
32
+ ccxt_convert_order_info,
33
+ ccxt_convert_positions,
34
+ ccxt_convert_ticker,
35
+ ccxt_extract_deals_from_exec,
36
+ ccxt_find_instrument,
37
+ ccxt_restore_position_from_deals,
38
+ instrument_to_ccxt_symbol,
39
+ )
40
+
41
+
42
+ class CcxtAccountProcessor(BasicAccountProcessor):
43
+ """
44
+ Subscribes to account information from the exchange.
45
+ """
46
+
47
+ exchange: cxp.Exchange
48
+ channel: CtrlChannel
49
+ base_currency: str
50
+ balance_interval: str
51
+ position_interval: str
52
+ subscription_interval: str
53
+ max_position_restore_days: int
54
+ max_retries: int
55
+
56
+ _loop: AsyncThreadLoop
57
+ _polling_tasks: dict[str, concurrent.futures.Future]
58
+ _subscription_manager: ISubscriptionManager | None
59
+ _polling_to_init: dict[str, bool]
60
+ _required_instruments: set[Instrument]
61
+ _latest_instruments: set[Instrument]
62
+
63
+ _free_capital: float = np.nan
64
+ _total_capital: float = np.nan
65
+ _instrument_to_last_price: dict[Instrument, tuple[dt_64, float]]
66
+
67
+ def __init__(
68
+ self,
69
+ account_id: str,
70
+ exchange: cxp.Exchange,
71
+ channel: CtrlChannel,
72
+ time_provider: ITimeProvider,
73
+ base_currency: str,
74
+ tcc: TransactionCostsCalculator,
75
+ balance_interval: str = "30Sec",
76
+ position_interval: str = "30Sec",
77
+ subscription_interval: str = "10Sec",
78
+ max_position_restore_days: int = 30,
79
+ max_retries: int = 10,
80
+ ):
81
+ super().__init__(
82
+ account_id=account_id,
83
+ time_provider=time_provider,
84
+ base_currency=base_currency,
85
+ tcc=tcc,
86
+ initial_capital=0,
87
+ )
88
+ self.exchange = exchange
89
+ self.channel = channel
90
+ self.max_retries = max_retries
91
+ self.balance_interval = balance_interval
92
+ self.position_interval = position_interval
93
+ self.subscription_interval = subscription_interval
94
+ self.max_position_restore_days = max_position_restore_days
95
+ self._loop = AsyncThreadLoop(exchange.asyncio_loop)
96
+ self._is_running = False
97
+ self._polling_tasks = {}
98
+ self._polling_to_init = defaultdict(bool)
99
+ self._instrument_to_last_price = {}
100
+ self._required_instruments = set()
101
+ self._latest_instruments = set()
102
+ self._subscription_manager = None
103
+
104
+ def set_subscription_manager(self, manager: ISubscriptionManager) -> None:
105
+ self._subscription_manager = manager
106
+
107
+ def start(self):
108
+ """Start the balance and position polling tasks"""
109
+ channel = self.channel
110
+ if channel is None or not channel.control.is_set():
111
+ return
112
+ if self._subscription_manager is None:
113
+ return
114
+ if self._is_running:
115
+ logger.debug("Account polling is already running")
116
+ return
117
+
118
+ self._is_running = True
119
+
120
+ if not self.exchange.isSandboxModeEnabled:
121
+ # - start polling tasks
122
+ self._polling_tasks["balance"] = self._loop.submit(
123
+ self._poller("balance", self._update_balance, self.balance_interval)
124
+ )
125
+ self._polling_tasks["position"] = self._loop.submit(
126
+ self._poller("position", self._update_positions, self.position_interval)
127
+ )
128
+
129
+ # - start initialization tasks
130
+ _init_tasks = [
131
+ self._loop.submit(self._init_spot_positions()), # restore spot positions
132
+ self._loop.submit(self._init_open_orders()), # fetch open orders
133
+ ]
134
+
135
+ logger.info("Waiting for account polling tasks to be initialized")
136
+ _waiter = self._loop.submit(self._wait_for_init(*_init_tasks))
137
+ _waiter.result()
138
+ logger.info("Account polling tasks have been initialized")
139
+
140
+ # - start subscription polling task
141
+ self._polling_tasks["subscription"] = self._loop.submit(
142
+ self._poller("subscription", self._update_subscriptions, self.subscription_interval)
143
+ )
144
+ # - subscribe to order executions
145
+ self._polling_tasks["executions"] = self._loop.submit(self._subscribe_executions("executions", channel))
146
+
147
+ def stop(self):
148
+ """Stop all polling tasks"""
149
+ for task in self._polling_tasks.values():
150
+ if not task.done():
151
+ task.cancel()
152
+ self._polling_tasks.clear()
153
+ self._is_running = False
154
+
155
+ def update_position_price(self, time: dt_64, instrument: Instrument, price: float) -> None:
156
+ self._instrument_to_last_price[instrument] = (time, price)
157
+ super().update_position_price(time, instrument, price)
158
+
159
+ def get_total_capital(self) -> float:
160
+ # sum of balances + market value of all positions on non spot/margin
161
+ _currency_to_value = {c: self._get_currency_value(b.total, c) for c, b in self._balances.items()}
162
+ _positions_value = sum([p.market_value_funds for p in self._positions.values() if p.instrument.is_futures()])
163
+ return sum(_currency_to_value.values()) + _positions_value
164
+
165
+ def _get_instrument_for_currency(self, currency: str) -> Instrument:
166
+ symbol = f"{currency}/{self.base_currency}"
167
+ market = self.exchange.market(symbol)
168
+ exchange_name = self.exchange.name
169
+ assert exchange_name is not None
170
+ return ccxt_symbol_to_instrument(exchange_name, market)
171
+
172
+ def _get_currency_value(self, amount: float, currency: str) -> float:
173
+ if not amount:
174
+ return 0.0
175
+ if currency == self.base_currency:
176
+ return amount
177
+ instr = self._get_instrument_for_currency(currency)
178
+ _dt, _price = self._instrument_to_last_price.get(instr, (None, None))
179
+ if not _dt or not _price:
180
+ logger.warning(f"Price for {instr} not available. Using 0.")
181
+ return 0.0
182
+ return amount * _price
183
+
184
+ async def _poller(
185
+ self,
186
+ name: str,
187
+ coroutine: Callable[[], Awaitable],
188
+ interval: str,
189
+ ):
190
+ sleep_time = pd.Timedelta(interval).total_seconds()
191
+ retries = 0
192
+
193
+ while self.channel.control.is_set():
194
+ try:
195
+ await coroutine()
196
+
197
+ if not self._polling_to_init[name]:
198
+ logger.info(f"{name} polling task has been initialized")
199
+ self._polling_to_init[name] = True
200
+
201
+ retries = 0 # Reset retry counter on success
202
+ except CancelledError:
203
+ logger.info(f"{name} listening has been cancelled")
204
+ break
205
+ except ExchangeClosedByUser:
206
+ logger.info(f"{name} listening has been stopped")
207
+ break
208
+ except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
209
+ logger.error(f"Error polling account data: {e}")
210
+ retries += 1
211
+ if retries >= self.max_retries:
212
+ logger.error(f"Max retries ({self.max_retries}) reached. Stopping poller.")
213
+ break
214
+ except Exception as e:
215
+ if not self.channel.control.is_set():
216
+ # If the channel is closed, then ignore all exceptions and exit
217
+ break
218
+ logger.error(f"Unexpected error during account polling: {e}")
219
+ logger.exception(e)
220
+ retries += 1
221
+ if retries >= self.max_retries:
222
+ logger.error(f"Max retries ({self.max_retries}) reached. Stopping poller.")
223
+ break
224
+ finally:
225
+ if not self.channel.control.is_set():
226
+ break
227
+ await asyncio.sleep(min(sleep_time * (2 ** (retries)), 60)) # Exponential backoff capped at 60s
228
+
229
+ logger.debug(f"{name} polling task has been stopped")
230
+
231
+ async def _wait(self, condition: Callable[[], bool], sleep: float = 0.1) -> None:
232
+ while not condition():
233
+ await asyncio.sleep(sleep)
234
+
235
+ async def _wait_for_init(self, *futures: concurrent.futures.Future) -> None:
236
+ await self._wait(lambda: all(self._polling_to_init.values()))
237
+ await self._wait(lambda: all([f.done() for f in futures]))
238
+
239
+ async def _update_subscriptions(self) -> None:
240
+ """Subscribe to required instruments"""
241
+ assert self._subscription_manager is not None
242
+ await asyncio.sleep(pd.Timedelta(self.subscription_interval).total_seconds())
243
+
244
+ # if required instruments have changed, subscribe to them
245
+ if not self._latest_instruments.issuperset(self._required_instruments):
246
+ await self._subscribe_instruments(list(self._required_instruments))
247
+ self._latest_instruments.update(self._required_instruments)
248
+
249
+ async def _update_balance(self) -> None:
250
+ """Fetch and update balances from exchange"""
251
+ balances_raw = await self.exchange.fetch_balance()
252
+ balances = ccxt_convert_balance(balances_raw)
253
+ current_balances = self.get_balances()
254
+
255
+ # remove balances that are not there anymore
256
+ _removed_currencies = set(current_balances.keys()) - set(balances.keys())
257
+ for currency in _removed_currencies:
258
+ self.update_balance(currency, 0, 0)
259
+
260
+ # update current balances
261
+ for currency, data in balances.items():
262
+ self.update_balance(currency=currency, total=data.total, locked=data.locked)
263
+
264
+ # update required instruments that we need to subscribe to
265
+ currencies = list(self.get_balances().keys())
266
+ instruments = [
267
+ self._get_instrument_for_currency(c) for c in currencies if c.upper() != self.base_currency.upper()
268
+ ]
269
+ self._required_instruments.update(instruments)
270
+
271
+ # fetch tickers for instruments that don't have recent price updates
272
+ await self._fetch_missing_tickers(instruments)
273
+
274
+ async def _update_positions(self) -> None:
275
+ # fetch and update positions from exchange
276
+ ccxt_positions = await self.exchange.fetch_positions()
277
+ positions = ccxt_convert_positions(ccxt_positions, self.exchange.name, self.exchange.markets)
278
+ # update required instruments that we need to subscribe to
279
+ self._required_instruments.update([p.instrument for p in positions])
280
+ # update positions
281
+ _instrument_to_position = {p.instrument: p for p in positions}
282
+ _current_instruments = set(self._positions.keys())
283
+ _new_instruments = set([p.instrument for p in positions])
284
+ # - spot positions should not be updated here, because exchanges don't provide spot positions
285
+ # - so we have to trust deal updates to update spot positions
286
+ _to_remove = {instr for instr in _current_instruments - _new_instruments if instr.is_futures()}
287
+ _to_add = _new_instruments - _current_instruments
288
+ _to_modify = _current_instruments.intersection(_new_instruments)
289
+ _update_positions = [Position(i) for i in _to_remove] + [_instrument_to_position[i] for i in _to_modify]
290
+ # - add new positions
291
+ for i in _to_add:
292
+ self._positions[i] = _instrument_to_position[i]
293
+ # - modify existing positions
294
+ _time = self.time_provider.time()
295
+ for pos in _update_positions:
296
+ self._update_instrument_position(_time, self._positions[pos.instrument], pos)
297
+
298
+ def _update_instrument_position(self, timestamp: dt_64, current_pos: Position, new_pos: Position) -> None:
299
+ instrument = current_pos.instrument
300
+ quantity_diff = new_pos.quantity - current_pos.quantity
301
+ if abs(quantity_diff) < instrument.lot_size:
302
+ return
303
+ _current_price = current_pos.last_update_price
304
+ current_pos.change_position_by(timestamp, quantity_diff, _current_price)
305
+
306
+ def _get_start_time_in_ms(self, days_before: int) -> int:
307
+ return (self.time_provider.time() - days_before * pd.Timedelta("1d")).asm8.item() // 1000000
308
+
309
+ def _is_our_order(self, order: Order) -> bool:
310
+ if order.client_id is None:
311
+ return False
312
+ return order.client_id.startswith("qubx_")
313
+
314
+ def _is_base_currency(self, currency: str) -> bool:
315
+ return currency.upper() == self.base_currency
316
+
317
+ async def _subscribe_instruments(self, instruments: list[Instrument]) -> None:
318
+ assert self._subscription_manager is not None
319
+
320
+ # find missing subscriptions
321
+ _base_sub = self._subscription_manager.get_base_subscription()
322
+ _subscribed_instruments = self._subscription_manager.get_subscribed_instruments(_base_sub)
323
+ _add_instruments = list(set(instruments) - set(_subscribed_instruments))
324
+
325
+ if _add_instruments:
326
+ # subscribe to instruments
327
+ self._subscription_manager.subscribe(_base_sub, _add_instruments)
328
+ self._subscription_manager.commit()
329
+
330
+ async def _fetch_missing_tickers(self, instruments: list[Instrument]) -> None:
331
+ _current_time = self.time_provider.time()
332
+ _fetch_instruments: list[Instrument] = []
333
+ for instr in instruments:
334
+ _dt, _ = self._instrument_to_last_price.get(instr, (None, None))
335
+ if _dt is None or pd.Timedelta(_current_time - _dt) > pd.Timedelta(self.balance_interval):
336
+ _fetch_instruments.append(instr)
337
+
338
+ _symbol_to_instrument = {instr.symbol: instr for instr in instruments}
339
+ if _fetch_instruments:
340
+ logger.debug(f"Fetching missing tickers for {_fetch_instruments}")
341
+ _fetch_symbols = [instrument_to_ccxt_symbol(instr) for instr in _fetch_instruments]
342
+ tickers: dict[str, dict] = await self.exchange.fetch_tickers(_fetch_symbols)
343
+ for symbol, ticker in tickers.items():
344
+ instr = _symbol_to_instrument.get(symbol)
345
+ if instr is not None:
346
+ quote = ccxt_convert_ticker(ticker)
347
+ self.update_position_price(_current_time, instr, quote.mid_price())
348
+
349
+ async def _init_spot_positions(self) -> None:
350
+ # - wait for balance to be initialized
351
+ await self._wait(lambda: self._polling_to_init["balance"])
352
+ logger.debug("Restoring spot positions ...")
353
+
354
+ # - get nonzero balances
355
+ _nonzero_balances = {
356
+ c: b.total for c, b in self._balances.items() if b.total > 0 and not self._is_base_currency(c)
357
+ }
358
+ _positions = []
359
+
360
+ async def _restore_pos(currency: str, balance: float) -> None:
361
+ try:
362
+ _instrument = self._get_instrument_for_currency(currency)
363
+ # - get latest order for instrument and check client id
364
+ _latest_orders = await self._fetch_orders(_instrument, limit=1)
365
+ if not _latest_orders:
366
+ return
367
+ _latest_order = list(_latest_orders.values())[-1]
368
+ if self._is_our_order(_latest_order):
369
+ # - if it's our order, then we fetch the deals and restore position
370
+ _deals = await self._fetch_deals(_instrument, self.max_position_restore_days)
371
+ _position = ccxt_restore_position_from_deals(Position(_instrument), balance, _deals)
372
+ _positions.append(_position)
373
+ except Exception as e:
374
+ logger.warning(f"Error restoring position for {currency}: {e}")
375
+
376
+ # - restore positions
377
+ await asyncio.gather(*[_restore_pos(c, b) for c, b in _nonzero_balances.items()])
378
+
379
+ # - attach positions
380
+ if _positions:
381
+ self.attach_positions(*_positions)
382
+ logger.debug("Restored positions ->")
383
+ for p in _positions:
384
+ logger.debug(f" :: {p}")
385
+
386
+ async def _init_open_orders(self) -> None:
387
+ # wait for balances and positions to be initialized
388
+ await self._wait(lambda: all([self._polling_to_init[task] for task in ["balance", "position"]]))
389
+ logger.debug("Fetching open orders ...")
390
+
391
+ # in order to minimize order requests we only fetch open orders for instruments that we have positions in
392
+ _nonzero_balances = {
393
+ c: b.total for c, b in self._balances.items() if b.total > 0 and not self._is_base_currency(c)
394
+ }
395
+ _balance_instruments = [self._get_instrument_for_currency(c) for c in _nonzero_balances.keys()]
396
+ _position_instruments = list(self._positions.keys())
397
+ _instruments = list(set(_balance_instruments + _position_instruments))
398
+
399
+ _open_orders: dict[str, Order] = {}
400
+
401
+ async def _add_open_orders(instrument: Instrument) -> None:
402
+ try:
403
+ _orders = await self._fetch_orders(instrument, is_open=True)
404
+ _open_orders.update(_orders)
405
+ except Exception as e:
406
+ logger.warning(f"Error fetching open orders for {instrument}: {e}")
407
+
408
+ await asyncio.gather(*[_add_open_orders(i) for i in _instruments])
409
+
410
+ self.add_active_orders(_open_orders)
411
+
412
+ logger.debug(f"Found {len(_open_orders)} open orders ->")
413
+ _instr_to_open_orders: dict[Instrument, list[Order]] = defaultdict(list)
414
+ for od in _open_orders.values():
415
+ _instr_to_open_orders[od.instrument].append(od)
416
+ for instr, orders in _instr_to_open_orders.items():
417
+ logger.debug(f" :: {instr} ->")
418
+ for order in orders:
419
+ logger.debug(f" :: {order.side} {order.quantity} @ {order.price} ({order.status})")
420
+
421
+ async def _fetch_orders(
422
+ self, instrument: Instrument, days_before: int = 30, limit: int | None = None, is_open: bool = False
423
+ ) -> dict[str, Order]:
424
+ _start_ms = self._get_start_time_in_ms(days_before) if limit is None else None
425
+ _ccxt_symbol = instrument_to_ccxt_symbol(instrument)
426
+ _fetcher = self.exchange.fetch_open_orders if is_open else self.exchange.fetch_orders
427
+ _raw_orders = await _fetcher(_ccxt_symbol, since=_start_ms, limit=limit)
428
+ _orders = [ccxt_convert_order_info(instrument, o) for o in _raw_orders]
429
+ _id_to_order = {o.id: o for o in _orders}
430
+ return dict(sorted(_id_to_order.items(), key=lambda x: x[1].time, reverse=False))
431
+
432
+ async def _fetch_deals(self, instrument: Instrument, days_before: int = 30) -> list[Deal]:
433
+ _start_ms = self._get_start_time_in_ms(days_before)
434
+ _ccxt_symbol = instrument_to_ccxt_symbol(instrument)
435
+ deals_data = await self.exchange.fetch_my_trades(_ccxt_symbol, since=_start_ms)
436
+ deals: list[Deal] = [ccxt_convert_deal_info(o) for o in deals_data]
437
+ return sorted(deals, key=lambda x: x.time) if deals else []
438
+
439
+ async def _listen_to_stream(
440
+ self,
441
+ subscriber: Callable[[], Awaitable[None]],
442
+ exchange: cxp.Exchange,
443
+ channel: CtrlChannel,
444
+ name: str,
445
+ ):
446
+ logger.info(f"Listening to {name}")
447
+ n_retry = 0
448
+ while channel.control.is_set():
449
+ try:
450
+ await subscriber()
451
+ n_retry = 0
452
+ except CcxtSymbolNotRecognized:
453
+ continue
454
+ except CancelledError:
455
+ break
456
+ except ExchangeClosedByUser:
457
+ # - we closed connection so just stop it
458
+ logger.info(f"{name} listening has been stopped")
459
+ break
460
+ except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
461
+ logger.error(f"Error in {name} : {e}")
462
+ await asyncio.sleep(1)
463
+ continue
464
+ except Exception as e:
465
+ if not channel.control.is_set():
466
+ # If the channel is closed, then ignore all exceptions and exit
467
+ break
468
+ logger.error(f"exception in {name} : {e}")
469
+ logger.exception(e)
470
+ n_retry += 1
471
+ if n_retry >= self.max_retries:
472
+ logger.error(f"Max retries reached for {name}. Closing connection.")
473
+ del exchange
474
+ break
475
+ await asyncio.sleep(min(2**n_retry, 60)) # Exponential backoff with a cap at 60 seconds
476
+
477
+ async def _subscribe_executions(self, name: str, channel: CtrlChannel):
478
+ _symbol_to_instrument = {}
479
+
480
+ async def _watch_executions():
481
+ exec = await self.exchange.watch_orders()
482
+ for report in exec:
483
+ instrument = ccxt_find_instrument(report["symbol"], self.exchange, _symbol_to_instrument)
484
+ order = ccxt_convert_order_info(instrument, report)
485
+ deals = ccxt_extract_deals_from_exec(report)
486
+ channel.send((instrument, "order", order, False))
487
+ if deals:
488
+ channel.send((instrument, "deals", deals, False))
489
+
490
+ await self._listen_to_stream(
491
+ subscriber=_watch_executions,
492
+ exchange=self.exchange,
493
+ channel=channel,
494
+ name=name,
495
+ )
@@ -0,0 +1,132 @@
1
+ import traceback
2
+ from typing import Any
3
+
4
+ import ccxt
5
+ import ccxt.pro as cxp
6
+ from ccxt.base.errors import ExchangeError
7
+ from qubx import logger
8
+ from qubx.core.basics import (
9
+ CtrlChannel,
10
+ Instrument,
11
+ Order,
12
+ Position,
13
+ )
14
+ from qubx.core.interfaces import (
15
+ IAccountProcessor,
16
+ IBroker,
17
+ ITimeProvider,
18
+ )
19
+ from qubx.utils.misc import AsyncThreadLoop
20
+
21
+ from .utils import ccxt_convert_order_info, instrument_to_ccxt_symbol
22
+
23
+
24
+ class CcxtBroker(IBroker):
25
+ _exchange: cxp.Exchange
26
+
27
+ _positions: dict[Instrument, Position]
28
+ _loop: AsyncThreadLoop
29
+
30
+ def __init__(
31
+ self,
32
+ exchange: cxp.Exchange,
33
+ channel: CtrlChannel,
34
+ time_provider: ITimeProvider,
35
+ account: IAccountProcessor,
36
+ ):
37
+ self._exchange = exchange
38
+ self.ccxt_exchange_id = str(exchange.name)
39
+ self.channel = channel
40
+ self.time_provider = time_provider
41
+ self.account = account
42
+ self._loop = AsyncThreadLoop(exchange.asyncio_loop)
43
+
44
+ @property
45
+ def is_simulated_trading(self) -> bool:
46
+ return False
47
+
48
+ def send_order(
49
+ self,
50
+ instrument: Instrument,
51
+ order_side: str,
52
+ order_type: str,
53
+ amount: float,
54
+ price: float | None = None,
55
+ client_id: str | None = None,
56
+ time_in_force: str = "gtc",
57
+ ) -> Order:
58
+ params = {}
59
+
60
+ if order_type == "limit":
61
+ params["timeInForce"] = time_in_force.upper()
62
+ if price is None:
63
+ raise ValueError("Price must be specified for limit order")
64
+
65
+ if client_id:
66
+ params["newClientOrderId"] = client_id
67
+
68
+ if instrument.is_futures():
69
+ params["type"] = "swap"
70
+
71
+ ccxt_symbol = instrument_to_ccxt_symbol(instrument)
72
+
73
+ r: dict[str, Any] | None = None
74
+ try:
75
+ r = self._loop.submit(
76
+ self._exchange.create_order(
77
+ symbol=ccxt_symbol,
78
+ type=order_type, # type: ignore
79
+ side=order_side, # type: ignore
80
+ amount=amount,
81
+ price=price,
82
+ params=params,
83
+ )
84
+ ).result()
85
+ except ccxt.BadRequest as exc:
86
+ logger.error(
87
+ f"(::send_order) BAD REQUEST for {order_side} {amount} {order_type} for {instrument.symbol} : {exc}"
88
+ )
89
+ raise exc
90
+ except Exception as err:
91
+ logger.error(f"(::send_order) {order_side} {amount} {order_type} for {instrument.symbol} exception : {err}")
92
+ logger.error(traceback.format_exc())
93
+ raise err
94
+
95
+ if r is None:
96
+ msg = "(::send_order) No response from exchange"
97
+ logger.error(msg)
98
+ raise ExchangeError(msg)
99
+
100
+ order = ccxt_convert_order_info(instrument, r)
101
+ logger.info(f"New order {order}")
102
+ return order
103
+
104
+ def cancel_order(self, order_id: str) -> Order | None:
105
+ order = None
106
+ orders = self.account.get_orders()
107
+ if order_id in orders:
108
+ order = orders[order_id]
109
+ try:
110
+ logger.info(f"Canceling order {order_id} ...")
111
+ result = self._loop.submit(
112
+ self._exchange.cancel_order(order_id, symbol=instrument_to_ccxt_symbol(order.instrument))
113
+ ).result()
114
+ logger.debug(f"Cancel order result: {result}")
115
+ return order
116
+ except Exception as err:
117
+ logger.error(f"Canceling [{order}] exception : {err}")
118
+ logger.error(traceback.format_exc())
119
+ raise err
120
+ return order
121
+
122
+ def cancel_orders(self, instrument: Instrument) -> None:
123
+ raise NotImplementedError("Not implemented yet")
124
+
125
+ def update_order(self, order_id: str, price: float | None = None, amount: float | None = None) -> Order:
126
+ raise NotImplementedError("Not implemented yet")
127
+
128
+ def exchange(self) -> str:
129
+ """
130
+ Return the name of the exchange this broker is connected to.
131
+ """
132
+ return self.ccxt_exchange_id.upper()