Qubx 0.6.16__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.17__cp312-cp312-manylinux_2_39_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/cli/commands.py +9 -17
- qubx/cli/release.py +24 -33
- qubx/connectors/ccxt/broker.py +68 -78
- qubx/connectors/ccxt/data.py +6 -2
- qubx/connectors/ccxt/exchanges/__init__.py +37 -0
- qubx/connectors/ccxt/exchanges/binance/broker.py +56 -0
- qubx/connectors/ccxt/{customizations.py → exchanges/binance/exchange.py} +101 -0
- qubx/connectors/ccxt/factory.py +20 -18
- qubx/connectors/ccxt/utils.py +2 -2
- qubx/core/context.py +3 -0
- qubx/core/exceptions.py +4 -0
- qubx/core/interfaces.py +16 -0
- qubx/core/mixins/universe.py +15 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/resources/_build.py +2 -2
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/runner/factory.py +209 -5
- qubx/utils/runner/runner.py +27 -231
- {qubx-0.6.16.dist-info → qubx-0.6.17.dist-info}/METADATA +1 -1
- {qubx-0.6.16.dist-info → qubx-0.6.17.dist-info}/RECORD +23 -21
- {qubx-0.6.16.dist-info → qubx-0.6.17.dist-info}/WHEEL +0 -0
- {qubx-0.6.16.dist-info → qubx-0.6.17.dist-info}/entry_points.txt +0 -0
qubx/cli/commands.py
CHANGED
|
@@ -140,16 +140,16 @@ def ls(directory: str):
|
|
|
140
140
|
callback=lambda ctx, param, value: os.path.abspath(os.path.expanduser(value)),
|
|
141
141
|
)
|
|
142
142
|
@click.option(
|
|
143
|
-
"--
|
|
144
|
-
"-
|
|
145
|
-
type=click.
|
|
146
|
-
help="
|
|
143
|
+
"--config",
|
|
144
|
+
"-c",
|
|
145
|
+
type=click.Path(exists=True, resolve_path=True),
|
|
146
|
+
help="Path to a config YAML file",
|
|
147
147
|
required=True,
|
|
148
148
|
)
|
|
149
149
|
@click.option(
|
|
150
150
|
"--output-dir",
|
|
151
151
|
"-o",
|
|
152
|
-
type=click.
|
|
152
|
+
type=click.Path(exists=False),
|
|
153
153
|
help="Output directory to put zip file.",
|
|
154
154
|
default=".releases",
|
|
155
155
|
show_default=True,
|
|
@@ -172,7 +172,6 @@ def ls(directory: str):
|
|
|
172
172
|
)
|
|
173
173
|
@click.option(
|
|
174
174
|
"--commit",
|
|
175
|
-
"-c",
|
|
176
175
|
is_flag=True,
|
|
177
176
|
default=False,
|
|
178
177
|
help="Commit changes and create tag in repo (default: False)",
|
|
@@ -180,7 +179,7 @@ def ls(directory: str):
|
|
|
180
179
|
)
|
|
181
180
|
def release(
|
|
182
181
|
directory: str,
|
|
183
|
-
|
|
182
|
+
config: str,
|
|
184
183
|
tag: str | None,
|
|
185
184
|
message: str | None,
|
|
186
185
|
commit: bool,
|
|
@@ -189,16 +188,9 @@ def release(
|
|
|
189
188
|
"""
|
|
190
189
|
Releases the strategy to a zip file.
|
|
191
190
|
|
|
192
|
-
The strategy
|
|
193
|
-
1. As a strategy name (class name) - strategies are scanned in the given directory (NOT SUPPORTED ANYMORE !)
|
|
194
|
-
2. As a path to a config YAML file containing the strategy configuration in StrategyConfig format
|
|
195
|
-
|
|
196
|
-
If a strategy name is provided, a default configuration will be generated with:
|
|
197
|
-
- The strategy parameters from the strategy class
|
|
198
|
-
- Default exchange, connector, and instruments from the command options
|
|
199
|
-
- Standard logging configuration
|
|
191
|
+
The strategy is specified by a path to a config YAML file containing the strategy configuration in StrategyConfig format.
|
|
200
192
|
|
|
201
|
-
|
|
193
|
+
The config file must follow the StrategyConfig structure with:
|
|
202
194
|
- strategy: The strategy name or path
|
|
203
195
|
- parameters: Dictionary of strategy parameters
|
|
204
196
|
- exchanges: Dictionary of exchange configurations
|
|
@@ -211,7 +203,7 @@ def release(
|
|
|
211
203
|
|
|
212
204
|
release_strategy(
|
|
213
205
|
directory=directory,
|
|
214
|
-
|
|
206
|
+
config_file=config,
|
|
215
207
|
tag=tag,
|
|
216
208
|
message=message,
|
|
217
209
|
commit=commit,
|
qubx/cli/release.py
CHANGED
|
@@ -16,7 +16,6 @@ from qubx import logger
|
|
|
16
16
|
from qubx.utils.misc import (
|
|
17
17
|
cyan,
|
|
18
18
|
generate_name,
|
|
19
|
-
get_local_qubx_folder,
|
|
20
19
|
green,
|
|
21
20
|
load_qubx_resources_as_text,
|
|
22
21
|
magenta,
|
|
@@ -265,7 +264,7 @@ def load_strategy_from_config(config_path: Path, directory: str) -> StrategyInfo
|
|
|
265
264
|
|
|
266
265
|
def release_strategy(
|
|
267
266
|
directory: str,
|
|
268
|
-
|
|
267
|
+
config_file: str,
|
|
269
268
|
tag: str | None,
|
|
270
269
|
message: str | None,
|
|
271
270
|
commit: bool,
|
|
@@ -276,7 +275,7 @@ def release_strategy(
|
|
|
276
275
|
|
|
277
276
|
Args:
|
|
278
277
|
directory: str - directory to scan for strategies
|
|
279
|
-
|
|
278
|
+
config_file: str - path to config file
|
|
280
279
|
tag: str - additional tag for this release
|
|
281
280
|
message: str - release message
|
|
282
281
|
commit: bool - commit changes and create tag in repo
|
|
@@ -288,29 +287,12 @@ def release_strategy(
|
|
|
288
287
|
|
|
289
288
|
try:
|
|
290
289
|
# - determine if strategy_name is a config file or a strategy name
|
|
291
|
-
if is_config_file(
|
|
292
|
-
|
|
293
|
-
logger.info(f"Loading strategy from config file: {strategy_name}")
|
|
294
|
-
stg_info = load_strategy_from_config(Path(strategy_name), directory)
|
|
295
|
-
else:
|
|
296
|
-
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
297
|
-
# TODO: generate default config from strategy class ? Do we really need it at all ?
|
|
298
|
-
# - find strategy by name
|
|
299
|
-
# logger.info(f"Looking for '{strategy_name}' strategy")
|
|
300
|
-
|
|
301
|
-
# strat_name = "_".join([x.split(".")[-1] for x in strategy_class_names])
|
|
302
|
-
# stg_info = StrategyInfo(name=strategy_name, classes=[find_class_by_name(directory, strategy_name)])
|
|
303
|
-
|
|
304
|
-
# stg_info = find_class_by_name(directory, strategy_name)
|
|
290
|
+
if not is_config_file(config_file):
|
|
291
|
+
raise ValueError("Try using yaml config file path")
|
|
305
292
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
# )
|
|
310
|
-
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
311
|
-
raise ValueError(
|
|
312
|
-
"!!! Release of strategy by name is not supported anymore ! Try to use config file instead !!!"
|
|
313
|
-
)
|
|
293
|
+
# - load strategy from config file
|
|
294
|
+
logger.info(f"Loading strategy from config file: {config_file}")
|
|
295
|
+
stg_info = load_strategy_from_config(Path(config_file), directory)
|
|
314
296
|
|
|
315
297
|
# - process git repo and pyproject.toml for each strategy component
|
|
316
298
|
repos_paths = set()
|
|
@@ -413,9 +395,8 @@ def _save_strategy_config(stg_name: str, strategy_config: StrategyConfig, releas
|
|
|
413
395
|
|
|
414
396
|
def _copy_strategy_file(strategy_path: str, pyproject_root: str, release_dir: str) -> None:
|
|
415
397
|
"""Copy the strategy file to the release directory."""
|
|
416
|
-
src_dir = os.path.basename(pyproject_root)
|
|
417
398
|
rel_path = os.path.relpath(strategy_path, pyproject_root)
|
|
418
|
-
dest_file_path = os.path.join(release_dir,
|
|
399
|
+
dest_file_path = os.path.join(release_dir, rel_path)
|
|
419
400
|
|
|
420
401
|
# Ensure the destination directory exists
|
|
421
402
|
os.makedirs(os.path.dirname(dest_file_path), exist_ok=True)
|
|
@@ -428,11 +409,9 @@ def _copy_strategy_file(strategy_path: str, pyproject_root: str, release_dir: st
|
|
|
428
409
|
def _try_copy_file(src_file: str, dest_dir: str, pyproject_root: str) -> None:
|
|
429
410
|
"""Try to copy the file to the release directory."""
|
|
430
411
|
if os.path.exists(src_file):
|
|
431
|
-
_src_dir = os.path.basename(pyproject_root)
|
|
432
|
-
|
|
433
412
|
# Get the relative path from pyproject_root
|
|
434
413
|
_rel_import_path = os.path.relpath(src_file, pyproject_root)
|
|
435
|
-
_dest_import_path = os.path.join(dest_dir,
|
|
414
|
+
_dest_import_path = os.path.join(dest_dir, _rel_import_path)
|
|
436
415
|
|
|
437
416
|
# Ensure the destination directory exists
|
|
438
417
|
os.makedirs(os.path.dirname(_dest_import_path), exist_ok=True)
|
|
@@ -446,10 +425,22 @@ def _copy_dependencies(strategy_path: str, pyproject_root: str, release_dir: str
|
|
|
446
425
|
"""Copy all dependencies required by the strategy."""
|
|
447
426
|
_src_dir = os.path.basename(pyproject_root)
|
|
448
427
|
_imports = _get_imports(strategy_path, pyproject_root, [_src_dir])
|
|
428
|
+
# find inside of the pyproject_root a folder with the same name as the _src_dir
|
|
429
|
+
# for instance it could be like macd_crossover/src/macd_crossover
|
|
430
|
+
# or macd_crossover/macd_crossover
|
|
431
|
+
# and assign this folder to _src_root
|
|
432
|
+
_src_root = None
|
|
433
|
+
for root, dirs, files in os.walk(pyproject_root):
|
|
434
|
+
if _src_dir in dirs:
|
|
435
|
+
_src_root = os.path.join(root, _src_dir)
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
if _src_root is None:
|
|
439
|
+
raise ValueError(f"Could not find the source root for {_src_dir} in {pyproject_root}")
|
|
449
440
|
|
|
450
441
|
for _imp in _imports:
|
|
451
442
|
# Construct source path
|
|
452
|
-
_base = os.path.join(
|
|
443
|
+
_base = os.path.join(_src_root, *[s for s in _imp.module if s != _src_dir])
|
|
453
444
|
|
|
454
445
|
# - try to copy all available files for satisfying the import
|
|
455
446
|
if os.path.isdir(_base):
|
|
@@ -521,7 +512,7 @@ def _modify_pyproject_toml(pyproject_path: str, package_name: str) -> None:
|
|
|
521
512
|
deps[d] = f">={version(d)}"
|
|
522
513
|
|
|
523
514
|
# Replace the packages section with the new one
|
|
524
|
-
pyproject_data["tool"]["poetry"]["packages"] = [{"include": package_name}]
|
|
515
|
+
# pyproject_data["tool"]["poetry"]["packages"] = [{"include": package_name}]
|
|
525
516
|
|
|
526
517
|
# Check if build section exists
|
|
527
518
|
if "build" not in pyproject_data["tool"]["poetry"]:
|
|
@@ -632,7 +623,7 @@ def _handle_project_files(pyproject_root: str, release_dir: str) -> None:
|
|
|
632
623
|
# Copy build.py if it exists
|
|
633
624
|
build_src = os.path.join(pyproject_root, "build.py")
|
|
634
625
|
if not os.path.exists(build_src):
|
|
635
|
-
logger.
|
|
626
|
+
logger.info(f"build.py not found in {pyproject_root} using default one")
|
|
636
627
|
build_src = load_qubx_resources_as_text("_build.py")
|
|
637
628
|
|
|
638
629
|
# - setup project's name in default build.py
|
qubx/connectors/ccxt/broker.py
CHANGED
|
@@ -14,7 +14,7 @@ from qubx.core.basics import (
|
|
|
14
14
|
Order,
|
|
15
15
|
)
|
|
16
16
|
from qubx.core.errors import OrderCancellationError, OrderCreationError, create_error_event
|
|
17
|
-
from qubx.core.exceptions import InvalidOrderParameters
|
|
17
|
+
from qubx.core.exceptions import BadRequest, InvalidOrderParameters
|
|
18
18
|
from qubx.core.interfaces import (
|
|
19
19
|
IAccountProcessor,
|
|
20
20
|
IBroker,
|
|
@@ -37,11 +37,11 @@ class CcxtBroker(IBroker):
|
|
|
37
37
|
time_provider: ITimeProvider,
|
|
38
38
|
account: IAccountProcessor,
|
|
39
39
|
data_provider: IDataProvider,
|
|
40
|
-
enable_price_match: bool = False,
|
|
41
|
-
price_match_ticks: int = 5,
|
|
42
40
|
cancel_timeout: int = 30,
|
|
43
41
|
cancel_retry_interval: int = 2,
|
|
44
42
|
max_cancel_retries: int = 10,
|
|
43
|
+
enable_create_order_ws: bool = False,
|
|
44
|
+
enable_cancel_order_ws: bool = False,
|
|
45
45
|
):
|
|
46
46
|
self._exchange = exchange
|
|
47
47
|
self.ccxt_exchange_id = str(exchange.name)
|
|
@@ -49,12 +49,12 @@ class CcxtBroker(IBroker):
|
|
|
49
49
|
self.time_provider = time_provider
|
|
50
50
|
self.account = account
|
|
51
51
|
self.data_provider = data_provider
|
|
52
|
-
self.enable_price_match = enable_price_match
|
|
53
|
-
self.price_match_ticks = price_match_ticks
|
|
54
52
|
self._loop = AsyncThreadLoop(exchange.asyncio_loop)
|
|
55
53
|
self.cancel_timeout = cancel_timeout
|
|
56
54
|
self.cancel_retry_interval = cancel_retry_interval
|
|
57
55
|
self.max_cancel_retries = max_cancel_retries
|
|
56
|
+
self.enable_create_order_ws = enable_create_order_ws
|
|
57
|
+
self.enable_cancel_order_ws = enable_cancel_order_ws
|
|
58
58
|
|
|
59
59
|
@property
|
|
60
60
|
def is_simulated_trading(self) -> bool:
|
|
@@ -209,61 +209,14 @@ class CcxtBroker(IBroker):
|
|
|
209
209
|
Returns:
|
|
210
210
|
tuple: (Order object if successful, Exception if failed)
|
|
211
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
212
|
try:
|
|
258
|
-
|
|
259
|
-
|
|
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,
|
|
213
|
+
payload = self._prepare_order_payload(
|
|
214
|
+
instrument, order_side, order_type, amount, price, client_id, time_in_force, **options
|
|
266
215
|
)
|
|
216
|
+
if self.enable_create_order_ws:
|
|
217
|
+
r = await self._exchange.create_order_ws(**payload)
|
|
218
|
+
else:
|
|
219
|
+
r = await self._exchange.create_order(**payload)
|
|
267
220
|
|
|
268
221
|
if r is None:
|
|
269
222
|
msg = "(::_create_order) No response from exchange"
|
|
@@ -278,25 +231,6 @@ class CcxtBroker(IBroker):
|
|
|
278
231
|
logger.error(
|
|
279
232
|
f"(::_create_order) [{instrument.symbol}] ORDER NOT FILLEABLE for {order_side} {amount} {order_type} : {exc}"
|
|
280
233
|
)
|
|
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
234
|
return None, exc
|
|
301
235
|
except ccxt.InvalidOrder as exc:
|
|
302
236
|
logger.error(
|
|
@@ -315,6 +249,59 @@ class CcxtBroker(IBroker):
|
|
|
315
249
|
logger.error(traceback.format_exc())
|
|
316
250
|
return None, err
|
|
317
251
|
|
|
252
|
+
def _prepare_order_payload(
|
|
253
|
+
self,
|
|
254
|
+
instrument: Instrument,
|
|
255
|
+
order_side: str,
|
|
256
|
+
order_type: str,
|
|
257
|
+
amount: float,
|
|
258
|
+
price: float | None = None,
|
|
259
|
+
client_id: str | None = None,
|
|
260
|
+
time_in_force: str = "gtc",
|
|
261
|
+
**options,
|
|
262
|
+
) -> dict[str, Any]:
|
|
263
|
+
params = {}
|
|
264
|
+
_is_trigger_order = order_type.startswith("stop_")
|
|
265
|
+
|
|
266
|
+
if order_type == "limit" or _is_trigger_order:
|
|
267
|
+
params["timeInForce"] = time_in_force.upper()
|
|
268
|
+
if price is None:
|
|
269
|
+
raise InvalidOrderParameters(f"Price must be specified for '{order_type}' order")
|
|
270
|
+
|
|
271
|
+
quote = self.data_provider.get_quote(instrument)
|
|
272
|
+
if quote is None:
|
|
273
|
+
logger.warning(f"[<y>{instrument.symbol}</y>] :: Quote is not available for order creation.")
|
|
274
|
+
raise BadRequest(f"Quote is not available for order creation for {instrument.symbol}")
|
|
275
|
+
|
|
276
|
+
# TODO: think about automatically setting reduce only when needed
|
|
277
|
+
if not options.get("reduceOnly", False):
|
|
278
|
+
min_notional = instrument.min_notional
|
|
279
|
+
if min_notional > 0 and abs(amount) * quote.mid_price() < min_notional:
|
|
280
|
+
raise InvalidOrderParameters(
|
|
281
|
+
f"[{instrument.symbol}] Order amount {amount} is too small. Minimum notional is {min_notional}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# - handle trigger (stop) orders
|
|
285
|
+
if _is_trigger_order:
|
|
286
|
+
params["triggerPrice"] = price
|
|
287
|
+
order_type = order_type.split("_")[1]
|
|
288
|
+
|
|
289
|
+
if client_id:
|
|
290
|
+
params["newClientOrderId"] = client_id
|
|
291
|
+
|
|
292
|
+
if instrument.is_futures():
|
|
293
|
+
params["type"] = "swap"
|
|
294
|
+
|
|
295
|
+
ccxt_symbol = instrument_to_ccxt_symbol(instrument)
|
|
296
|
+
return {
|
|
297
|
+
"symbol": ccxt_symbol,
|
|
298
|
+
"type": order_type,
|
|
299
|
+
"side": order_side,
|
|
300
|
+
"amount": amount,
|
|
301
|
+
"price": price,
|
|
302
|
+
"params": params,
|
|
303
|
+
}
|
|
304
|
+
|
|
318
305
|
async def _cancel_order_with_retry(self, order_id: str, instrument: Instrument) -> bool:
|
|
319
306
|
"""
|
|
320
307
|
Attempts to cancel an order with retries.
|
|
@@ -332,7 +319,10 @@ class CcxtBroker(IBroker):
|
|
|
332
319
|
|
|
333
320
|
while True:
|
|
334
321
|
try:
|
|
335
|
-
|
|
322
|
+
if self.enable_cancel_order_ws:
|
|
323
|
+
await self._exchange.cancel_order_ws(order_id, symbol=instrument_to_ccxt_symbol(instrument))
|
|
324
|
+
else:
|
|
325
|
+
await self._exchange.cancel_order(order_id, symbol=instrument_to_ccxt_symbol(instrument))
|
|
336
326
|
return True
|
|
337
327
|
except ccxt.OperationRejected as err:
|
|
338
328
|
err_msg = str(err).lower()
|
qubx/connectors/ccxt/data.py
CHANGED
|
@@ -224,7 +224,8 @@ class CcxtDataProvider(IDataProvider):
|
|
|
224
224
|
|
|
225
225
|
if sub_type in self._sub_to_coro:
|
|
226
226
|
logger.debug(f"Canceling existing {sub_type} subscription for {self._subscriptions[_sub_type]}")
|
|
227
|
-
|
|
227
|
+
# - wait for the subscriber to stop
|
|
228
|
+
self._loop.submit(self._stop_subscriber(sub_type, self._sub_to_name[sub_type])).result()
|
|
228
229
|
del self._sub_to_coro[sub_type]
|
|
229
230
|
del self._sub_to_name[sub_type]
|
|
230
231
|
del self._subscriptions[_sub_type]
|
|
@@ -533,7 +534,10 @@ class CcxtDataProvider(IDataProvider):
|
|
|
533
534
|
|
|
534
535
|
async def un_watch_quote(instruments: list[Instrument]):
|
|
535
536
|
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
536
|
-
|
|
537
|
+
if hasattr(self._exchange, "un_watch_bids_asks"):
|
|
538
|
+
await getattr(self._exchange, "un_watch_bids_asks")(symbols)
|
|
539
|
+
else:
|
|
540
|
+
await self._exchange.un_watch_tickers(symbols)
|
|
537
541
|
|
|
538
542
|
await self._listen_to_stream(
|
|
539
543
|
subscriber=self._call_by_market_type(watch_quote, instruments),
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the CCXT connectors for the exchanges.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from functools import partial
|
|
6
|
+
|
|
7
|
+
import ccxt.pro as cxp
|
|
8
|
+
|
|
9
|
+
from .binance.broker import BinanceCcxtBroker
|
|
10
|
+
from .binance.exchange import BINANCE_UM_MM, BinancePortfolioMargin, BinanceQV, BinanceQVUSDM
|
|
11
|
+
|
|
12
|
+
EXCHANGE_ALIASES = {
|
|
13
|
+
"binance": "binanceqv",
|
|
14
|
+
"binance.um": "binanceqv_usdm",
|
|
15
|
+
"binance.cm": "binancecoinm",
|
|
16
|
+
"binance.pm": "binancepm",
|
|
17
|
+
"kraken.f": "krakenfutures",
|
|
18
|
+
"binance.um.mm": "binance_um_mm",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
CUSTOM_BROKERS = {
|
|
22
|
+
"binance": partial(BinanceCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False),
|
|
23
|
+
"binance.um": partial(BinanceCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False),
|
|
24
|
+
"binance.cm": partial(BinanceCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False),
|
|
25
|
+
"binance.pm": partial(BinanceCcxtBroker, enable_create_order_ws=False, enable_cancel_order_ws=False),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
cxp.binanceqv = BinanceQV # type: ignore
|
|
29
|
+
cxp.binanceqv_usdm = BinanceQVUSDM # type: ignore
|
|
30
|
+
cxp.binancepm = BinancePortfolioMargin # type: ignore
|
|
31
|
+
cxp.binance_um_mm = BINANCE_UM_MM # type: ignore
|
|
32
|
+
|
|
33
|
+
cxp.exchanges.append("binanceqv")
|
|
34
|
+
cxp.exchanges.append("binanceqv_usdm")
|
|
35
|
+
cxp.exchanges.append("binancepm")
|
|
36
|
+
cxp.exchanges.append("binancepm_usdm")
|
|
37
|
+
cxp.exchanges.append("binance_um_mm")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from qubx import logger
|
|
4
|
+
from qubx.connectors.ccxt.broker import CcxtBroker
|
|
5
|
+
from qubx.core.basics import Instrument
|
|
6
|
+
from qubx.core.exceptions import BadRequest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BinanceCcxtBroker(CcxtBroker):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
*args,
|
|
13
|
+
enable_price_match: bool = False,
|
|
14
|
+
price_match_ticks: int = 5,
|
|
15
|
+
**kwargs,
|
|
16
|
+
):
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
self.enable_price_match = enable_price_match
|
|
19
|
+
self.price_match_ticks = price_match_ticks
|
|
20
|
+
|
|
21
|
+
def _prepare_order_payload(
|
|
22
|
+
self,
|
|
23
|
+
instrument: Instrument,
|
|
24
|
+
order_side: str,
|
|
25
|
+
order_type: str,
|
|
26
|
+
amount: float,
|
|
27
|
+
price: float | None = None,
|
|
28
|
+
client_id: str | None = None,
|
|
29
|
+
time_in_force: str = "gtc",
|
|
30
|
+
**options,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
payload = super()._prepare_order_payload(
|
|
33
|
+
instrument, order_side, order_type, amount, price, client_id, time_in_force, **options
|
|
34
|
+
)
|
|
35
|
+
params = payload.get("params", {})
|
|
36
|
+
if "priceMatch" in options:
|
|
37
|
+
params["priceMatch"] = options["priceMatch"]
|
|
38
|
+
|
|
39
|
+
quote = self.data_provider.get_quote(instrument)
|
|
40
|
+
if quote is None:
|
|
41
|
+
logger.warning(f"[<y>{instrument.symbol}</y>] :: Quote is not available for order creation.")
|
|
42
|
+
raise BadRequest(f"Quote is not available for price match for {instrument.symbol}")
|
|
43
|
+
|
|
44
|
+
if time_in_force == "gtx" and price is not None and self.enable_price_match:
|
|
45
|
+
if (order_side == "buy" and quote.bid - price < self.price_match_ticks * instrument.tick_size) or (
|
|
46
|
+
order_side == "sell" and price - quote.ask < self.price_match_ticks * instrument.tick_size
|
|
47
|
+
):
|
|
48
|
+
params["priceMatch"] = "QUEUE"
|
|
49
|
+
logger.debug(f"[<y>{instrument.symbol}</y>] :: Price match is set to QUEUE. Price will be ignored.")
|
|
50
|
+
|
|
51
|
+
if "priceMatch" in params:
|
|
52
|
+
# - if price match is set, we don't need to specify the price
|
|
53
|
+
payload["price"] = None
|
|
54
|
+
|
|
55
|
+
payload["params"] = params
|
|
56
|
+
return payload
|
|
@@ -3,13 +3,17 @@ from typing import Dict, List
|
|
|
3
3
|
import ccxt.pro as cxp
|
|
4
4
|
from ccxt.async_support.base.ws.cache import ArrayCache, ArrayCacheByTimestamp
|
|
5
5
|
from ccxt.async_support.base.ws.client import Client
|
|
6
|
+
from ccxt.base.errors import ArgumentsRequired, BadRequest, NotSupported
|
|
6
7
|
from ccxt.base.precise import Precise
|
|
7
8
|
from ccxt.base.types import (
|
|
9
|
+
Any,
|
|
8
10
|
Balances,
|
|
9
11
|
Num,
|
|
10
12
|
Order,
|
|
11
13
|
OrderSide,
|
|
12
14
|
OrderType,
|
|
15
|
+
Strings,
|
|
16
|
+
Tickers,
|
|
13
17
|
)
|
|
14
18
|
|
|
15
19
|
|
|
@@ -33,6 +37,80 @@ class BinanceQV(cxp.binance):
|
|
|
33
37
|
},
|
|
34
38
|
)
|
|
35
39
|
|
|
40
|
+
async def un_watch_bids_asks(self, symbols: Strings = None, params: dict = {}) -> Any:
|
|
41
|
+
"""
|
|
42
|
+
unwatches best bid & ask for symbols
|
|
43
|
+
|
|
44
|
+
https://developers.binance.com/docs/binance-spot-api-docs/web-socket-api#symbol-order-book-ticker
|
|
45
|
+
https://developers.binance.com/docs/derivatives/usds-margined-futures/websocket-market-streams/All-Book-Tickers-Stream
|
|
46
|
+
https://developers.binance.com/docs/derivatives/coin-margined-futures/websocket-market-streams/All-Book-Tickers-Stream
|
|
47
|
+
|
|
48
|
+
:param str[] symbols: unified symbol of the market to fetch the ticker for
|
|
49
|
+
:param dict [params]: extra parameters specific to the exchange API endpoint
|
|
50
|
+
:returns dict: a `ticker structure <https://docs.ccxt.com/#/?id=ticker-structure>`
|
|
51
|
+
"""
|
|
52
|
+
await self.load_markets()
|
|
53
|
+
methodName = "watchBidsAsks"
|
|
54
|
+
channelName = "bookTicker"
|
|
55
|
+
symbols = self.market_symbols(symbols, None, True, False, True)
|
|
56
|
+
firstMarket = None
|
|
57
|
+
marketType = None
|
|
58
|
+
symbolsDefined = symbols is not None
|
|
59
|
+
if symbolsDefined:
|
|
60
|
+
firstMarket = self.market(symbols[0])
|
|
61
|
+
marketType, params = self.handle_market_type_and_params(methodName, firstMarket, params)
|
|
62
|
+
subType = None
|
|
63
|
+
subType, params = self.handle_sub_type_and_params(methodName, firstMarket, params)
|
|
64
|
+
rawMarketType = None
|
|
65
|
+
if self.isLinear(marketType, subType):
|
|
66
|
+
rawMarketType = "future"
|
|
67
|
+
elif self.isInverse(marketType, subType):
|
|
68
|
+
rawMarketType = "delivery"
|
|
69
|
+
elif marketType == "spot":
|
|
70
|
+
rawMarketType = marketType
|
|
71
|
+
else:
|
|
72
|
+
raise NotSupported(str(self.id) + " " + methodName + "() does not support options markets")
|
|
73
|
+
isBidAsk = True
|
|
74
|
+
subscriptionArgs = []
|
|
75
|
+
subMessageHashes = []
|
|
76
|
+
messageHashes = []
|
|
77
|
+
if symbolsDefined:
|
|
78
|
+
for i in range(0, len(symbols)):
|
|
79
|
+
symbol = symbols[i]
|
|
80
|
+
market = self.market(symbol)
|
|
81
|
+
subscriptionArgs.append(market["lowercaseId"] + "@" + channelName)
|
|
82
|
+
subMessageHashes.append(self.get_message_hash(channelName, market["symbol"], isBidAsk))
|
|
83
|
+
messageHashes.append("unsubscribe:bidsasks:" + symbol)
|
|
84
|
+
else:
|
|
85
|
+
if marketType == "spot":
|
|
86
|
+
raise ArgumentsRequired(
|
|
87
|
+
str(self.id) + " " + methodName + "() requires symbols for this channel for spot markets"
|
|
88
|
+
)
|
|
89
|
+
subscriptionArgs.append("!" + channelName)
|
|
90
|
+
subMessageHashes.append(self.get_message_hash(channelName, None, isBidAsk))
|
|
91
|
+
messageHashes.append("unsubscribe:bidsasks")
|
|
92
|
+
streamHash = channelName
|
|
93
|
+
if symbolsDefined:
|
|
94
|
+
streamHash = channelName + "::" + ",".join(symbols)
|
|
95
|
+
url = self.urls["api"]["ws"][rawMarketType] + "/" + self.stream(rawMarketType, streamHash)
|
|
96
|
+
requestId = self.request_id(url)
|
|
97
|
+
request: dict = {
|
|
98
|
+
"method": "UNSUBSCRIBE",
|
|
99
|
+
"params": subscriptionArgs,
|
|
100
|
+
"id": requestId,
|
|
101
|
+
}
|
|
102
|
+
subscription: dict = {
|
|
103
|
+
"unsubscribe": True,
|
|
104
|
+
"id": str(requestId),
|
|
105
|
+
"subMessageHashes": subMessageHashes,
|
|
106
|
+
"messageHashes": subMessageHashes,
|
|
107
|
+
"symbols": symbols,
|
|
108
|
+
"topic": "bidsasks",
|
|
109
|
+
}
|
|
110
|
+
return await self.watch_multiple(
|
|
111
|
+
url, subMessageHashes, self.extend(request, params), subMessageHashes, subscription
|
|
112
|
+
)
|
|
113
|
+
|
|
36
114
|
def parse_ohlcv(self, ohlcv, market=None):
|
|
37
115
|
"""
|
|
38
116
|
[
|
|
@@ -397,3 +475,26 @@ class BinancePortfolioMargin(BinanceQVUSDM):
|
|
|
397
475
|
result["timestamp"] = timestamp
|
|
398
476
|
result["datetime"] = self.iso8601(timestamp)
|
|
399
477
|
return result if isolated else self.safe_balance(result)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class BINANCE_UM_MM(BinanceQVUSDM):
|
|
481
|
+
def describe(self):
|
|
482
|
+
return self.deep_extend(
|
|
483
|
+
super().describe(),
|
|
484
|
+
{
|
|
485
|
+
"urls": {
|
|
486
|
+
"api": {
|
|
487
|
+
"fapiPublic": "https://fapi-mm.binance.com/fapi/v1",
|
|
488
|
+
"fapiPublicV2": "https://fapi-mm.binance.com/fapi/v2",
|
|
489
|
+
"fapiPublicV3": "https://fapi-mm.binance.com/fapi/v3",
|
|
490
|
+
"fapiPrivate": "https://fapi-mm.binance.com/fapi/v1",
|
|
491
|
+
"fapiPrivateV2": "https://fapi-mm.binance.com/fapi/v2",
|
|
492
|
+
"fapiPrivateV3": "https://fapi-mm.binance.com/fapi/v3",
|
|
493
|
+
"future": "wss://fstream-mm.binance.com/ws",
|
|
494
|
+
"ws-api": {
|
|
495
|
+
"future": "wss://ws-fapi-mm.binance.com/ws-fapi/v1",
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
)
|