Qubx 0.6.14__tar.gz → 0.6.16__tar.gz

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 (146) hide show
  1. {qubx-0.6.14 → qubx-0.6.16}/PKG-INFO +1 -1
  2. {qubx-0.6.14 → qubx-0.6.16}/pyproject.toml +1 -1
  3. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/broker.py +13 -0
  4. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/runner.py +3 -0
  5. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/simulator.py +25 -1
  6. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/account.py +64 -16
  7. qubx-0.6.16/src/qubx/connectors/ccxt/broker.py +399 -0
  8. qubx-0.6.16/src/qubx/connectors/ccxt/customizations.py +399 -0
  9. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/data.py +3 -4
  10. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/account.py +10 -2
  11. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/context.py +9 -0
  12. qubx-0.6.16/src/qubx/core/errors.py +32 -0
  13. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/interfaces.py +73 -5
  14. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/processing.py +5 -1
  15. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/trading.py +65 -15
  16. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/__init__.py +2 -1
  17. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/base.py +2 -0
  18. qubx-0.6.16/src/qubx/emitters/csv.py +83 -0
  19. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/questdb.py +44 -10
  20. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/redis_streams.py +10 -10
  21. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restarts/state_resolvers.py +16 -0
  22. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/configs.py +4 -1
  23. qubx-0.6.16/src/qubx/utils/runner/factory.py +101 -0
  24. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/runner.py +18 -91
  25. qubx-0.6.14/src/qubx/connectors/ccxt/broker.py +0 -140
  26. qubx-0.6.14/src/qubx/connectors/ccxt/customizations.py +0 -193
  27. {qubx-0.6.14 → qubx-0.6.16}/README.md +0 -0
  28. {qubx-0.6.14 → qubx-0.6.16}/build.py +0 -0
  29. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/__init__.py +0 -0
  30. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/_nb_magic.py +0 -0
  31. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/__init__.py +0 -0
  32. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/account.py +0 -0
  33. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/data.py +0 -0
  34. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/management.py +0 -0
  35. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/ome.py +0 -0
  36. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/optimization.py +0 -0
  37. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/simulated_data.py +0 -0
  38. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/utils.py +0 -0
  39. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/__init__.py +0 -0
  40. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/commands.py +0 -0
  41. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/deploy.py +0 -0
  42. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/misc.py +0 -0
  43. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/release.py +0 -0
  44. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/__init__.py +0 -0
  45. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/exceptions.py +0 -0
  46. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/factory.py +0 -0
  47. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/reader.py +0 -0
  48. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/utils.py +0 -0
  49. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/__init__.py +0 -0
  50. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/basics.py +0 -0
  51. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/exceptions.py +0 -0
  52. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/helpers.py +0 -0
  53. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/initializer.py +0 -0
  54. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/loggers.py +0 -0
  55. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/lookups.py +0 -0
  56. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/metrics.py +0 -0
  57. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/__init__.py +0 -0
  58. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/market.py +0 -0
  59. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/subscription.py +0 -0
  60. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/universe.py +0 -0
  61. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/series.pxd +0 -0
  62. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/series.pyi +0 -0
  63. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/series.pyx +0 -0
  64. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/utils.pyi +0 -0
  65. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/utils.pyx +0 -0
  66. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/__init__.py +0 -0
  67. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/composite.py +0 -0
  68. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/helpers.py +0 -0
  69. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/hft.py +0 -0
  70. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/readers.py +0 -0
  71. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/registry.py +0 -0
  72. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/tardis.py +0 -0
  73. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/composite.py +0 -0
  74. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/prometheus.py +0 -0
  75. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/__init__.py +0 -0
  76. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/composite.py +0 -0
  77. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/__init__.py +0 -0
  78. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/base.py +0 -0
  79. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/incremental.py +0 -0
  80. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/slack.py +0 -0
  81. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/slack.py +0 -0
  82. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/__init__.py +0 -0
  83. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/core.py +0 -0
  84. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/orderbook.py +0 -0
  85. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/price.py +0 -0
  86. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/trades.py +0 -0
  87. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/utils.py +0 -0
  88. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/gathering/simplest.py +0 -0
  89. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/math/__init__.py +0 -0
  90. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/math/stats.py +0 -0
  91. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/notifications/__init__.py +0 -0
  92. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/notifications/composite.py +0 -0
  93. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/notifications/slack.py +0 -0
  94. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/pandaz/__init__.py +0 -0
  95. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/pandaz/ta.py +0 -0
  96. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/pandaz/utils.py +0 -0
  97. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/_build.py +0 -0
  98. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-binance.cm.json +0 -0
  99. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-binance.json +0 -0
  100. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-binance.um.json +0 -0
  101. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-bitfinex.f.json +0 -0
  102. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-bitfinex.json +0 -0
  103. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-kraken.f.json +0 -0
  104. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-kraken.json +0 -0
  105. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restarts/__init__.py +0 -0
  106. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restarts/time_finders.py +0 -0
  107. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/__init__.py +0 -0
  108. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/balance.py +0 -0
  109. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/factory.py +0 -0
  110. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/interfaces.py +0 -0
  111. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/position.py +0 -0
  112. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/signal.py +0 -0
  113. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/state.py +0 -0
  114. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/utils.py +0 -0
  115. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/__init__.py +0 -0
  116. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/indicators.pxd +0 -0
  117. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/indicators.pyi +0 -0
  118. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/indicators.pyx +0 -0
  119. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/__init__.py +0 -0
  120. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/advanced.py +0 -0
  121. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/composite.py +0 -0
  122. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/rebalancers.py +0 -0
  123. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/riskctrl.py +0 -0
  124. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/sizers.py +0 -0
  125. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/__init__.py +0 -0
  126. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/_pyxreloader.py +0 -0
  127. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/charting/lookinglass.py +0 -0
  128. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/charting/mpl_helpers.py +0 -0
  129. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/marketdata/binance.py +0 -0
  130. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/marketdata/ccxt.py +0 -0
  131. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/marketdata/dukas.py +0 -0
  132. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/misc.py +0 -0
  133. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/ntp.py +0 -0
  134. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/numbers_utils.py +0 -0
  135. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/orderbook.py +0 -0
  136. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/__init__.py +0 -0
  137. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/dashboard.py +0 -0
  138. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/data.py +0 -0
  139. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/interfaces.py +0 -0
  140. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/renderers/__init__.py +0 -0
  141. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/renderers/plotly.py +0 -0
  142. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/__init__.py +0 -0
  143. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/_jupyter_runner.pyt +0 -0
  144. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/accounts.py +0 -0
  145. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/time.py +0 -0
  146. {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: Qubx
3
- Version: 0.6.14
3
+ Version: 0.6.16
4
4
  Summary: Qubx - Quantitative Trading Framework
5
5
  Author: Dmitry Marienko
6
6
  Author-email: dmitry.marienko@xlydian.com
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "Qubx"
7
- version = "0.6.14"
7
+ version = "0.6.16"
8
8
  description = "Qubx - Quantitative Trading Framework"
9
9
  authors = [ "Dmitry Marienko <dmitry.marienko@xlydian.com>", "Yuriy Arabskyy <yuriy.arabskyy@xlydian.com>",]
10
10
  readme = "README.md"
@@ -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:
@@ -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
@@ -0,0 +1,399 @@
1
+ import asyncio
2
+ import traceback
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+
7
+ import ccxt
8
+ import ccxt.pro as cxp
9
+ from ccxt.base.errors import ExchangeError
10
+ from qubx import logger
11
+ from qubx.core.basics import (
12
+ CtrlChannel,
13
+ Instrument,
14
+ Order,
15
+ )
16
+ from qubx.core.errors import OrderCancellationError, OrderCreationError, create_error_event
17
+ from qubx.core.exceptions import InvalidOrderParameters
18
+ from qubx.core.interfaces import (
19
+ IAccountProcessor,
20
+ IBroker,
21
+ IDataProvider,
22
+ ITimeProvider,
23
+ )
24
+ from qubx.utils.misc import AsyncThreadLoop
25
+
26
+ from .utils import ccxt_convert_order_info, instrument_to_ccxt_symbol
27
+
28
+
29
+ class CcxtBroker(IBroker):
30
+ _exchange: cxp.Exchange
31
+ _loop: AsyncThreadLoop
32
+
33
+ def __init__(
34
+ self,
35
+ exchange: cxp.Exchange,
36
+ channel: CtrlChannel,
37
+ time_provider: ITimeProvider,
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,
45
+ ):
46
+ self._exchange = exchange
47
+ self.ccxt_exchange_id = str(exchange.name)
48
+ self.channel = channel
49
+ self.time_provider = time_provider
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
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
58
+
59
+ @property
60
+ def is_simulated_trading(self) -> bool:
61
+ return False
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
+
127
+ def send_order(
128
+ self,
129
+ instrument: Instrument,
130
+ order_side: str,
131
+ order_type: str,
132
+ amount: float,
133
+ price: float | None = None,
134
+ client_id: str | None = None,
135
+ time_in_force: str = "gtc",
136
+ **options,
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
+ """
212
+ params = {}
213
+ _is_trigger_order = order_type.startswith("stop_")
214
+
215
+ if order_type == "limit" or _is_trigger_order:
216
+ params["timeInForce"] = time_in_force.upper()
217
+ if price is None:
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
+ )
229
+
230
+ # - handle trigger (stop) orders
231
+ if _is_trigger_order:
232
+ params["triggerPrice"] = price
233
+ order_type = order_type.split("_")[1]
234
+
235
+ if client_id:
236
+ params["newClientOrderId"] = client_id
237
+
238
+ if "priceMatch" in options:
239
+ params["priceMatch"] = options["priceMatch"]
240
+
241
+ if instrument.is_futures():
242
+ params["type"] = "swap"
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
+
255
+ ccxt_symbol = instrument_to_ccxt_symbol(instrument)
256
+
257
+ try:
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,
294
+ amount=amount,
295
+ price=price,
296
+ client_id=client_id,
297
+ time_in_force=time_in_force,
298
+ **options_with_price_match,
299
+ )
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
306
+ except ccxt.BadRequest as exc:
307
+ logger.error(
308
+ f"(::_create_order) BAD REQUEST for {order_side} {amount} {order_type} for {instrument.symbol} : {exc}"
309
+ )
310
+ return None, exc
311
+ except Exception as err:
312
+ logger.error(
313
+ f"(::_create_order) {order_side} {amount} {order_type} for {instrument.symbol} exception : {err}"
314
+ )
315
+ logger.error(traceback.format_exc())
316
+ return None, err
317
+
318
+ async def _cancel_order_with_retry(self, order_id: str, instrument: Instrument) -> bool:
319
+ """
320
+ Attempts to cancel an order with retries.
321
+
322
+ Args:
323
+ order_id: The ID of the order to cancel
324
+ symbol: The symbol of the instrument
325
+
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:
334
+ try:
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
351
+ except Exception as err:
352
+ logger.error(f"Unexpected error canceling order {order_id}: {err}")
353
+ logger.error(traceback.format_exc())
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
383
+
384
+ def cancel_orders(self, instrument: Instrument) -> None:
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)
391
+
392
+ def update_order(self, order_id: str, price: float | None = None, amount: float | None = None) -> Order:
393
+ raise NotImplementedError("Not implemented yet")
394
+
395
+ def exchange(self) -> str:
396
+ """
397
+ Return the name of the exchange this broker is connected to.
398
+ """
399
+ return self.ccxt_exchange_id.upper()