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.
- {qubx-0.6.14 → qubx-0.6.16}/PKG-INFO +1 -1
- {qubx-0.6.14 → qubx-0.6.16}/pyproject.toml +1 -1
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/broker.py +13 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/runner.py +3 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/simulator.py +25 -1
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/account.py +64 -16
- qubx-0.6.16/src/qubx/connectors/ccxt/broker.py +399 -0
- qubx-0.6.16/src/qubx/connectors/ccxt/customizations.py +399 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/data.py +3 -4
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/account.py +10 -2
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/context.py +9 -0
- qubx-0.6.16/src/qubx/core/errors.py +32 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/interfaces.py +73 -5
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/processing.py +5 -1
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/trading.py +65 -15
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/__init__.py +2 -1
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/base.py +2 -0
- qubx-0.6.16/src/qubx/emitters/csv.py +83 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/questdb.py +44 -10
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/redis_streams.py +10 -10
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restarts/state_resolvers.py +16 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/configs.py +4 -1
- qubx-0.6.16/src/qubx/utils/runner/factory.py +101 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/runner.py +18 -91
- qubx-0.6.14/src/qubx/connectors/ccxt/broker.py +0 -140
- qubx-0.6.14/src/qubx/connectors/ccxt/customizations.py +0 -193
- {qubx-0.6.14 → qubx-0.6.16}/README.md +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/build.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/_nb_magic.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/account.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/data.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/management.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/ome.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/optimization.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/simulated_data.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/backtester/utils.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/commands.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/deploy.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/misc.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/cli/release.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/exceptions.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/factory.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/reader.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/connectors/ccxt/utils.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/basics.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/exceptions.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/helpers.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/initializer.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/loggers.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/lookups.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/metrics.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/market.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/subscription.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/mixins/universe.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/series.pxd +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/series.pyi +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/series.pyx +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/utils.pyi +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/core/utils.pyx +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/composite.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/helpers.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/hft.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/readers.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/registry.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/data/tardis.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/composite.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/emitters/prometheus.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/composite.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/base.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/incremental.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/formatters/slack.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/exporters/slack.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/core.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/orderbook.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/price.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/trades.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/features/utils.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/gathering/simplest.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/math/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/math/stats.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/notifications/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/notifications/composite.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/notifications/slack.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/pandaz/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/pandaz/ta.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/pandaz/utils.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/_build.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-binance.cm.json +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-binance.json +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-binance.um.json +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-bitfinex.f.json +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-bitfinex.json +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-kraken.f.json +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/resources/instruments/symbols-kraken.json +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restarts/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restarts/time_finders.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/balance.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/factory.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/interfaces.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/position.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/signal.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/state.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/restorers/utils.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/indicators.pxd +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/indicators.pyi +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/ta/indicators.pyx +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/advanced.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/composite.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/rebalancers.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/riskctrl.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/trackers/sizers.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/_pyxreloader.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/charting/lookinglass.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/charting/mpl_helpers.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/marketdata/binance.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/marketdata/ccxt.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/marketdata/dukas.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/misc.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/ntp.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/numbers_utils.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/orderbook.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/dashboard.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/data.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/interfaces.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/renderers/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/plotting/renderers/plotly.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/__init__.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/_jupyter_runner.pyt +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/runner/accounts.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/time.py +0 -0
- {qubx-0.6.14 → qubx-0.6.16}/src/qubx/utils/version.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "Qubx"
|
|
7
|
-
version = "0.6.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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()
|