Qubx 0.6.89__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.91__cp312-cp312-manylinux_2_39_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/backtester/account.py +1 -1
- qubx/backtester/broker.py +52 -6
- qubx/cli/commands.py +13 -1
- qubx/connectors/ccxt/account.py +3 -2
- qubx/connectors/ccxt/broker.py +166 -19
- qubx/connectors/ccxt/connection_manager.py +14 -0
- qubx/connectors/ccxt/exchange_manager.py +6 -37
- qubx/connectors/ccxt/exchanges/__init__.py +5 -2
- qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +3 -2
- qubx/connectors/ccxt/exchanges/hyperliquid/account.py +75 -0
- qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +232 -3
- qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +83 -0
- qubx/connectors/ccxt/factory.py +1 -9
- qubx/connectors/ccxt/handlers/base.py +2 -4
- qubx/connectors/ccxt/handlers/factory.py +4 -5
- qubx/connectors/ccxt/utils.py +8 -2
- qubx/core/account.py +54 -5
- qubx/core/basics.py +62 -2
- qubx/core/context.py +14 -4
- qubx/core/helpers.py +71 -4
- qubx/core/interfaces.py +55 -9
- qubx/core/mixins/processing.py +6 -1
- qubx/core/mixins/trading.py +63 -3
- 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/data/__init__.py +10 -0
- qubx/data/composite.py +87 -60
- qubx/data/containers.py +234 -0
- qubx/data/readers.py +3 -0
- qubx/data/registry.py +122 -4
- qubx/data/storage.py +74 -0
- qubx/data/storages/csv.py +273 -0
- qubx/data/storages/questdb.py +554 -0
- qubx/data/storages/utils.py +115 -0
- qubx/data/transformers.py +491 -0
- qubx/exporters/formatters/target_position.py +2 -6
- qubx/exporters/redis_streams.py +3 -3
- qubx/loggers/csv.py +4 -4
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/marketdata/ccxt.py +38 -6
- qubx/utils/questdb.py +6 -7
- qubx/utils/runner/textual/__init__.py +60 -0
- qubx/utils/runner/textual/app.py +149 -0
- qubx/utils/runner/textual/handlers.py +72 -0
- qubx/utils/runner/textual/init_code.py +143 -0
- qubx/utils/runner/textual/kernel.py +110 -0
- qubx/utils/runner/textual/styles.tcss +100 -0
- qubx/utils/runner/textual/widgets/__init__.py +6 -0
- qubx/utils/runner/textual/widgets/positions_table.py +89 -0
- qubx/utils/runner/textual/widgets/repl_output.py +14 -0
- qubx/utils/time.py +7 -0
- {qubx-0.6.89.dist-info → qubx-0.6.91.dist-info}/METADATA +1 -1
- {qubx-0.6.89.dist-info → qubx-0.6.91.dist-info}/RECORD +56 -40
- {qubx-0.6.89.dist-info → qubx-0.6.91.dist-info}/WHEEL +0 -0
- {qubx-0.6.89.dist-info → qubx-0.6.91.dist-info}/entry_points.txt +0 -0
- {qubx-0.6.89.dist-info → qubx-0.6.91.dist-info}/licenses/LICENSE +0 -0
qubx/backtester/account.py
CHANGED
|
@@ -42,7 +42,7 @@ class SimulatedAccountProcessor(BasicAccountProcessor):
|
|
|
42
42
|
_pos = self.get_position(instrument)
|
|
43
43
|
_pos.reset_by_position(position)
|
|
44
44
|
|
|
45
|
-
def get_orders(self, instrument: Instrument | None = None) -> dict[str, Order]:
|
|
45
|
+
def get_orders(self, instrument: Instrument | None = None, exchange: str | None = None) -> dict[str, Order]:
|
|
46
46
|
return self._exchange.get_open_orders(instrument)
|
|
47
47
|
|
|
48
48
|
def get_position(self, instrument: Instrument) -> Position:
|
qubx/backtester/broker.py
CHANGED
|
@@ -6,7 +6,7 @@ from qubx.core.basics import (
|
|
|
6
6
|
Instrument,
|
|
7
7
|
Order,
|
|
8
8
|
)
|
|
9
|
-
from qubx.core.exceptions import OrderNotFound
|
|
9
|
+
from qubx.core.exceptions import BadRequest, OrderNotFound
|
|
10
10
|
from qubx.core.interfaces import IBroker
|
|
11
11
|
|
|
12
12
|
from .account import SimulatedAccountProcessor
|
|
@@ -64,20 +64,66 @@ class SimulatedBroker(IBroker):
|
|
|
64
64
|
) -> None:
|
|
65
65
|
self.send_order(instrument, order_side, order_type, amount, price, client_id, time_in_force, **optional)
|
|
66
66
|
|
|
67
|
-
def cancel_order(self, order_id: str) ->
|
|
67
|
+
def cancel_order(self, order_id: str) -> bool:
|
|
68
|
+
"""Cancel an order synchronously and return success status."""
|
|
68
69
|
try:
|
|
69
70
|
self._send_execution_report(order_update := self._exchange.cancel_order(order_id))
|
|
70
|
-
return order_update
|
|
71
|
+
return order_update is not None
|
|
71
72
|
except OrderNotFound:
|
|
72
73
|
# Order was already cancelled or doesn't exist
|
|
73
74
|
logger.debug(f"Order {order_id} not found")
|
|
74
|
-
return
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def cancel_order_async(self, order_id: str) -> None:
|
|
78
|
+
"""Cancel an order asynchronously (fire-and-forget)."""
|
|
79
|
+
# For simulation, async is same as sync since it's fast
|
|
80
|
+
self.cancel_order(order_id)
|
|
75
81
|
|
|
76
82
|
def cancel_orders(self, instrument: Instrument) -> None:
|
|
77
83
|
raise NotImplementedError("Not implemented yet")
|
|
78
84
|
|
|
79
|
-
def update_order(self, order_id: str, price: float
|
|
80
|
-
|
|
85
|
+
def update_order(self, order_id: str, price: float, amount: float) -> Order:
|
|
86
|
+
"""Update an existing limit order using cancel+recreate strategy.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
order_id: The ID of the order to update
|
|
90
|
+
price: New price for the order
|
|
91
|
+
amount: New amount for the order
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Order: The updated (newly created) order object
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
OrderNotFound: If the order is not found
|
|
98
|
+
BadRequest: If the order is not a limit order
|
|
99
|
+
"""
|
|
100
|
+
# Get the existing order from account
|
|
101
|
+
active_orders = self._account.get_orders()
|
|
102
|
+
existing_order = active_orders.get(order_id)
|
|
103
|
+
if not existing_order:
|
|
104
|
+
raise OrderNotFound(f"Order {order_id} not found")
|
|
105
|
+
|
|
106
|
+
# Validate that it's a limit order
|
|
107
|
+
if existing_order.type.lower() != "limit":
|
|
108
|
+
raise BadRequest(
|
|
109
|
+
f"Order {order_id} is not a limit order (type: {existing_order.type}). Only limit orders can be updated."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Cancel the existing order first
|
|
113
|
+
self.cancel_order(order_id)
|
|
114
|
+
|
|
115
|
+
# Create a new order with updated parameters, preserving original properties
|
|
116
|
+
updated_order = self.send_order(
|
|
117
|
+
instrument=existing_order.instrument,
|
|
118
|
+
order_side=existing_order.side,
|
|
119
|
+
order_type="limit",
|
|
120
|
+
amount=abs(amount),
|
|
121
|
+
price=price,
|
|
122
|
+
client_id=existing_order.client_id, # Preserve original client_id for tracking
|
|
123
|
+
time_in_force=existing_order.time_in_force or "gtc",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return updated_order
|
|
81
127
|
|
|
82
128
|
def _send_execution_report(self, report: SimulatedExecutionReport | None):
|
|
83
129
|
if report is None:
|
qubx/cli/commands.py
CHANGED
|
@@ -69,11 +69,14 @@ def main(debug: bool, debug_port: int, log_level: str):
|
|
|
69
69
|
@click.option(
|
|
70
70
|
"--jupyter", "-j", is_flag=True, default=False, help="Run strategy in jupyter console.", show_default=True
|
|
71
71
|
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--textual", "-t", is_flag=True, default=False, help="Run strategy in textual TUI.", show_default=True
|
|
74
|
+
)
|
|
72
75
|
@click.option(
|
|
73
76
|
"--restore", "-r", is_flag=True, default=False, help="Restore strategy state from previous run.", show_default=True
|
|
74
77
|
)
|
|
75
78
|
@click.option("--no-color", is_flag=True, default=False, help="Disable colored logging output.", show_default=True)
|
|
76
|
-
def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool, restore: bool, no_color: bool):
|
|
79
|
+
def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool, textual: bool, restore: bool, no_color: bool):
|
|
77
80
|
"""
|
|
78
81
|
Starts the strategy with the given configuration file. If paper mode is enabled, account is not required.
|
|
79
82
|
|
|
@@ -84,12 +87,21 @@ def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool
|
|
|
84
87
|
"""
|
|
85
88
|
from qubx.utils.misc import add_project_to_system_path, logo
|
|
86
89
|
from qubx.utils.runner.runner import run_strategy_yaml, run_strategy_yaml_in_jupyter
|
|
90
|
+
from qubx.utils.runner.textual import run_strategy_yaml_in_textual
|
|
91
|
+
|
|
92
|
+
# Ensure jupyter and textual are mutually exclusive
|
|
93
|
+
if jupyter and textual:
|
|
94
|
+
click.echo("Error: --jupyter and --textual cannot be used together.", err=True)
|
|
95
|
+
raise click.Abort()
|
|
87
96
|
|
|
88
97
|
add_project_to_system_path()
|
|
89
98
|
add_project_to_system_path(str(config_file.parent.parent))
|
|
90
99
|
add_project_to_system_path(str(config_file.parent))
|
|
100
|
+
|
|
91
101
|
if jupyter:
|
|
92
102
|
run_strategy_yaml_in_jupyter(config_file, account_file, paper, restore)
|
|
103
|
+
elif textual:
|
|
104
|
+
run_strategy_yaml_in_textual(config_file, account_file, paper, restore)
|
|
93
105
|
else:
|
|
94
106
|
logo()
|
|
95
107
|
run_strategy_yaml(config_file, account_file, paper=paper, restore=restore, blocking=True, no_color=no_color)
|
qubx/connectors/ccxt/account.py
CHANGED
|
@@ -337,7 +337,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
337
337
|
current_pos.change_position_by(timestamp, quantity_diff, _current_price)
|
|
338
338
|
|
|
339
339
|
def _get_start_time_in_ms(self, days_before: int) -> int:
|
|
340
|
-
return (self.time_provider.time() - days_before * pd.Timedelta("1d")).asm8.item() // 1000000
|
|
340
|
+
return (self.time_provider.time() - days_before * pd.Timedelta("1d")).asm8.item() // 1000000 # type: ignore
|
|
341
341
|
|
|
342
342
|
def _is_our_order(self, order: Order) -> bool:
|
|
343
343
|
if order.client_id is None:
|
|
@@ -365,7 +365,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
365
365
|
_fetch_instruments: list[Instrument] = []
|
|
366
366
|
for instr in instruments:
|
|
367
367
|
_dt, _ = self._instrument_to_last_price.get(instr, (None, None))
|
|
368
|
-
if _dt is None or pd.Timedelta(_current_time - _dt) > pd.Timedelta(self.balance_interval):
|
|
368
|
+
if _dt is None or pd.Timedelta(_current_time - _dt) > pd.Timedelta(self.balance_interval): # type: ignore
|
|
369
369
|
_fetch_instruments.append(instr)
|
|
370
370
|
|
|
371
371
|
_symbol_to_instrument = {instr.symbol: instr for instr in instruments}
|
|
@@ -506,6 +506,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
506
506
|
deals: list[Deal] = [ccxt_convert_deal_info(o) for o in deals_data]
|
|
507
507
|
return sorted(deals, key=lambda x: x.time) if deals else []
|
|
508
508
|
|
|
509
|
+
# TODO: this should take the exchange manager instead of cxp.Exchange
|
|
509
510
|
async def _listen_to_stream(
|
|
510
511
|
self,
|
|
511
512
|
subscriber: Callable[[], Awaitable[None]],
|
qubx/connectors/ccxt/broker.py
CHANGED
|
@@ -5,7 +5,6 @@ from typing import Any
|
|
|
5
5
|
import pandas as pd
|
|
6
6
|
|
|
7
7
|
import ccxt
|
|
8
|
-
|
|
9
8
|
from ccxt.base.errors import ExchangeError
|
|
10
9
|
from qubx import logger
|
|
11
10
|
from qubx.core.basics import (
|
|
@@ -15,7 +14,7 @@ from qubx.core.basics import (
|
|
|
15
14
|
OrderSide,
|
|
16
15
|
)
|
|
17
16
|
from qubx.core.errors import ErrorLevel, OrderCancellationError, OrderCreationError, create_error_event
|
|
18
|
-
from qubx.core.exceptions import BadRequest, InvalidOrderParameters
|
|
17
|
+
from qubx.core.exceptions import BadRequest, InvalidOrderParameters, OrderNotFound
|
|
19
18
|
from qubx.core.interfaces import (
|
|
20
19
|
IAccountProcessor,
|
|
21
20
|
IBroker,
|
|
@@ -43,6 +42,7 @@ class CcxtBroker(IBroker):
|
|
|
43
42
|
max_cancel_retries: int = 10,
|
|
44
43
|
enable_create_order_ws: bool = False,
|
|
45
44
|
enable_cancel_order_ws: bool = False,
|
|
45
|
+
enable_edit_order_ws: bool = False,
|
|
46
46
|
):
|
|
47
47
|
self._exchange_manager = exchange_manager
|
|
48
48
|
self.ccxt_exchange_id = str(self._exchange_manager.exchange.name)
|
|
@@ -55,13 +55,13 @@ class CcxtBroker(IBroker):
|
|
|
55
55
|
self.max_cancel_retries = max_cancel_retries
|
|
56
56
|
self.enable_create_order_ws = enable_create_order_ws
|
|
57
57
|
self.enable_cancel_order_ws = enable_cancel_order_ws
|
|
58
|
+
self.enable_edit_order_ws = enable_edit_order_ws
|
|
58
59
|
|
|
59
60
|
@property
|
|
60
61
|
def _loop(self) -> AsyncThreadLoop:
|
|
61
62
|
"""Get current AsyncThreadLoop for the exchange."""
|
|
62
63
|
return AsyncThreadLoop(self._exchange_manager.exchange.asyncio_loop)
|
|
63
64
|
|
|
64
|
-
|
|
65
65
|
@property
|
|
66
66
|
def is_simulated_trading(self) -> bool:
|
|
67
67
|
return False
|
|
@@ -178,7 +178,7 @@ class CcxtBroker(IBroker):
|
|
|
178
178
|
client_id: str | None = None,
|
|
179
179
|
time_in_force: str = "gtc",
|
|
180
180
|
**options,
|
|
181
|
-
) -> Order
|
|
181
|
+
) -> Order:
|
|
182
182
|
"""
|
|
183
183
|
Submit an order and wait for the result. Exceptions will be raised on errors.
|
|
184
184
|
|
|
@@ -221,22 +221,38 @@ class CcxtBroker(IBroker):
|
|
|
221
221
|
self._post_order_error_to_databus(
|
|
222
222
|
err, instrument, order_side, order_type, amount, price, client_id, time_in_force, **options
|
|
223
223
|
)
|
|
224
|
-
|
|
224
|
+
raise err
|
|
225
225
|
|
|
226
|
-
def cancel_order(self, order_id: str) ->
|
|
226
|
+
def cancel_order(self, order_id: str) -> bool:
|
|
227
|
+
"""Cancel an order synchronously and return success status."""
|
|
227
228
|
orders = self.account.get_orders()
|
|
228
229
|
if order_id not in orders:
|
|
229
230
|
logger.warning(f"Order {order_id} not found in active orders")
|
|
230
|
-
return
|
|
231
|
+
return False
|
|
231
232
|
|
|
232
233
|
order = orders[order_id]
|
|
233
|
-
logger.info(f"Canceling order {order_id} ...")
|
|
234
|
+
logger.info(f"Canceling order {order_id} synchronously...")
|
|
234
235
|
|
|
235
|
-
|
|
236
|
-
|
|
236
|
+
try:
|
|
237
|
+
# Submit the task and wait for result
|
|
238
|
+
future = self._loop.submit(self._cancel_order_with_retry(order_id, order.instrument))
|
|
239
|
+
return future.result() # This will block until completion or timeout
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f"Error during synchronous order cancellation: {e}")
|
|
242
|
+
return False # Return False on any error for simplicity
|
|
243
|
+
|
|
244
|
+
def cancel_order_async(self, order_id: str) -> None:
|
|
245
|
+
"""Cancel an order asynchronously (non blocking)."""
|
|
246
|
+
orders = self.account.get_orders()
|
|
247
|
+
if order_id not in orders:
|
|
248
|
+
logger.warning(f"Order {order_id} not found in active orders")
|
|
249
|
+
return
|
|
237
250
|
|
|
238
|
-
|
|
239
|
-
|
|
251
|
+
order = orders[order_id]
|
|
252
|
+
logger.info(f"Canceling order {order_id} asynchronously...")
|
|
253
|
+
|
|
254
|
+
# Submit the task without waiting for result
|
|
255
|
+
self._loop.submit(self._cancel_order_with_retry(order_id, order.instrument))
|
|
240
256
|
|
|
241
257
|
async def _create_order(
|
|
242
258
|
self,
|
|
@@ -298,8 +314,22 @@ class CcxtBroker(IBroker):
|
|
|
298
314
|
logger.warning(f"[<y>{instrument.symbol}</y>] :: Quote is not available for order creation.")
|
|
299
315
|
raise BadRequest(f"Quote is not available for order creation for {instrument.symbol}")
|
|
300
316
|
|
|
301
|
-
#
|
|
302
|
-
|
|
317
|
+
# Auto-detect if order reduces existing position
|
|
318
|
+
reduce_only = options.get("reduceOnly", False)
|
|
319
|
+
if not reduce_only:
|
|
320
|
+
positions = self.account.get_positions()
|
|
321
|
+
if instrument in positions:
|
|
322
|
+
position_qty = positions[instrument].quantity
|
|
323
|
+
# Check if order closes position AND doesn't exceed position size (which would flip to opposite side)
|
|
324
|
+
if (position_qty > 0 and order_side == "SELL" and abs(amount) <= abs(position_qty)) or (
|
|
325
|
+
position_qty < 0 and order_side == "BUY" and abs(amount) <= abs(position_qty)
|
|
326
|
+
):
|
|
327
|
+
reduce_only = True
|
|
328
|
+
logger.debug(
|
|
329
|
+
f"[{instrument.symbol}] Auto-setting reduceOnly=True ({order_side}, position: {position_qty})"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if not reduce_only:
|
|
303
333
|
min_notional = instrument.min_notional
|
|
304
334
|
if min_notional > 0 and abs(amount) * quote.mid_price() < min_notional:
|
|
305
335
|
raise InvalidOrderParameters(
|
|
@@ -364,9 +394,13 @@ class CcxtBroker(IBroker):
|
|
|
364
394
|
while True:
|
|
365
395
|
try:
|
|
366
396
|
if self.enable_cancel_order_ws:
|
|
367
|
-
await self._exchange_manager.exchange.cancel_order_ws(
|
|
397
|
+
await self._exchange_manager.exchange.cancel_order_ws(
|
|
398
|
+
order_id, symbol=instrument_to_ccxt_symbol(instrument)
|
|
399
|
+
)
|
|
368
400
|
else:
|
|
369
|
-
await self._exchange_manager.exchange.cancel_order(
|
|
401
|
+
await self._exchange_manager.exchange.cancel_order(
|
|
402
|
+
order_id, symbol=instrument_to_ccxt_symbol(instrument)
|
|
403
|
+
)
|
|
370
404
|
return True
|
|
371
405
|
except ccxt.OperationRejected as err:
|
|
372
406
|
err_msg = str(err).lower()
|
|
@@ -375,8 +409,12 @@ class CcxtBroker(IBroker):
|
|
|
375
409
|
# These errors might be temporary if the order is still being processed, so retry
|
|
376
410
|
logger.debug(f"[{order_id}] Order not found for cancellation, might retry: {err}")
|
|
377
411
|
# Continue with the retry logic instead of returning immediately
|
|
412
|
+
# Order cannot be cancelled (e.g., already filled)
|
|
413
|
+
elif "filled" in err_msg or "partially filled" in err_msg:
|
|
414
|
+
logger.debug(f"[{order_id}] Order cannot be cancelled - already executed: {err}")
|
|
415
|
+
return False # FAILURE: Order cannot be cancelled
|
|
416
|
+
# Other operation rejected errors - don't retry
|
|
378
417
|
else:
|
|
379
|
-
# For other operation rejected errors, don't retry
|
|
380
418
|
logger.debug(f"[{order_id}] Could not cancel order: {err}")
|
|
381
419
|
return False
|
|
382
420
|
except (ccxt.NetworkError, ccxt.ExchangeError, ccxt.ExchangeNotAvailable) as e:
|
|
@@ -424,8 +462,117 @@ class CcxtBroker(IBroker):
|
|
|
424
462
|
for order_id in instrument_orders:
|
|
425
463
|
self.cancel_order(order_id)
|
|
426
464
|
|
|
427
|
-
def update_order(self, order_id: str, price: float
|
|
428
|
-
|
|
465
|
+
def update_order(self, order_id: str, price: float, amount: float) -> Order:
|
|
466
|
+
"""Update an existing limit order with new price and amount.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
order_id: The ID of the order to update
|
|
470
|
+
price: New price for the order (already adjusted by TradingManager)
|
|
471
|
+
amount: New amount for the order (already adjusted by TradingManager)
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
Order: The updated Order object if successful
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
OrderNotFound: If the order is not found
|
|
478
|
+
BadRequest: If the order is not a limit order
|
|
479
|
+
ExchangeError: If the exchange operation fails
|
|
480
|
+
"""
|
|
481
|
+
active_orders = self.account.get_orders()
|
|
482
|
+
if order_id not in active_orders:
|
|
483
|
+
raise OrderNotFound(f"Order {order_id} not found in active orders")
|
|
484
|
+
|
|
485
|
+
existing_order = active_orders[order_id]
|
|
486
|
+
|
|
487
|
+
# Validate that the order can still be updated (not fully filled/closed)
|
|
488
|
+
updatable_statuses = ["OPEN", "NEW", "PENDING"]
|
|
489
|
+
if existing_order.status not in updatable_statuses:
|
|
490
|
+
raise BadRequest(
|
|
491
|
+
f"Order {order_id} with status '{existing_order.status}' cannot be updated. "
|
|
492
|
+
f"Only orders with status {updatable_statuses} can be updated."
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
instrument = existing_order.instrument
|
|
496
|
+
|
|
497
|
+
logger.debug(
|
|
498
|
+
f"[<g>{instrument.symbol}</g>] :: Updating order {order_id}: "
|
|
499
|
+
f"{amount} @ {price} (was: {existing_order.quantity} @ {existing_order.price} ({existing_order.time_in_force}))"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
# Check if exchange supports order editing
|
|
504
|
+
if self._exchange_manager.exchange.has.get("editOrder", False):
|
|
505
|
+
return self._update_order_direct(order_id, existing_order, price, amount)
|
|
506
|
+
else:
|
|
507
|
+
return self._update_order_fallback(order_id, existing_order, price, amount)
|
|
508
|
+
except Exception as err:
|
|
509
|
+
logger.error(f"Failed to update order {order_id}: {err}")
|
|
510
|
+
raise
|
|
511
|
+
|
|
512
|
+
def _update_order_direct(self, order_id: str, existing_order: Order, price: float, amount: float) -> Order:
|
|
513
|
+
"""Update order using exchange's native edit functionality."""
|
|
514
|
+
future_result = self._loop.submit(self._edit_order_async(order_id, existing_order, price, amount))
|
|
515
|
+
updated_order, error = future_result.result()
|
|
516
|
+
|
|
517
|
+
if error is not None:
|
|
518
|
+
raise error
|
|
519
|
+
|
|
520
|
+
if updated_order is not None:
|
|
521
|
+
self.account.process_order(updated_order)
|
|
522
|
+
logger.debug(f"[<g>{existing_order.instrument.symbol}</g>] :: Successfully updated order {order_id}")
|
|
523
|
+
return updated_order
|
|
524
|
+
else:
|
|
525
|
+
raise Exception("Order update returned None without error")
|
|
526
|
+
|
|
527
|
+
def _update_order_fallback(self, order_id: str, existing_order: Order, price: float, amount: float) -> Order:
|
|
528
|
+
"""Update order using cancel+recreate strategy for exchanges without editOrder support."""
|
|
529
|
+
success = self.cancel_order(order_id)
|
|
530
|
+
if not success:
|
|
531
|
+
raise Exception(f"Failed to cancel order {order_id} during update")
|
|
532
|
+
|
|
533
|
+
updated_order = self.send_order(
|
|
534
|
+
instrument=existing_order.instrument,
|
|
535
|
+
order_side=existing_order.side,
|
|
536
|
+
order_type=existing_order.type,
|
|
537
|
+
amount=amount,
|
|
538
|
+
price=price,
|
|
539
|
+
client_id=existing_order.client_id, # Preserve original client_id for tracking
|
|
540
|
+
time_in_force=existing_order.time_in_force or "gtc",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
logger.debug(
|
|
544
|
+
f"[<g>{existing_order.instrument.symbol}</g>] :: Successfully updated order {order_id} -> new order {updated_order.id}"
|
|
545
|
+
)
|
|
546
|
+
return updated_order
|
|
547
|
+
|
|
548
|
+
async def _edit_order_async(
|
|
549
|
+
self, order_id: str, existing_order: Order, price: float, amount: float
|
|
550
|
+
) -> tuple[Order | None, Exception | None]:
|
|
551
|
+
"""Async helper for direct order editing."""
|
|
552
|
+
try:
|
|
553
|
+
ccxt_symbol = instrument_to_ccxt_symbol(existing_order.instrument)
|
|
554
|
+
ccxt_side = "buy" if existing_order.side == "BUY" else "sell"
|
|
555
|
+
|
|
556
|
+
# CCXT requires positive amount (side determines direction)
|
|
557
|
+
abs_amount = abs(amount)
|
|
558
|
+
|
|
559
|
+
# Use WebSocket if enabled, otherwise use REST API
|
|
560
|
+
if self.enable_edit_order_ws:
|
|
561
|
+
result = await self._exchange_manager.exchange.edit_order_ws(
|
|
562
|
+
id=order_id, symbol=ccxt_symbol, type="limit", side=ccxt_side, amount=abs_amount, price=price, params={}
|
|
563
|
+
)
|
|
564
|
+
else:
|
|
565
|
+
result = await self._exchange_manager.exchange.edit_order(
|
|
566
|
+
id=order_id, symbol=ccxt_symbol, type="limit", side=ccxt_side, amount=abs_amount, price=price, params={}
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Convert the result back to our Order format
|
|
570
|
+
updated_order = ccxt_convert_order_info(existing_order.instrument, result)
|
|
571
|
+
return updated_order, None
|
|
572
|
+
|
|
573
|
+
except Exception as err:
|
|
574
|
+
logger.error(f"Async edit order failed for {order_id}: {err}")
|
|
575
|
+
return None, err
|
|
429
576
|
|
|
430
577
|
def exchange(self) -> str:
|
|
431
578
|
"""
|
|
@@ -13,6 +13,7 @@ from collections import defaultdict
|
|
|
13
13
|
from typing import Awaitable, Callable
|
|
14
14
|
|
|
15
15
|
from ccxt import ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable, NetworkError
|
|
16
|
+
from ccxt.async_support.base.ws.client import Client as _WsClient
|
|
16
17
|
from ccxt.pro import Exchange
|
|
17
18
|
from qubx import logger
|
|
18
19
|
from qubx.core.basics import CtrlChannel
|
|
@@ -23,6 +24,19 @@ from .exchange_manager import ExchangeManager
|
|
|
23
24
|
from .subscription_manager import SubscriptionManager
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
def _safe_buffer(self):
|
|
28
|
+
conn = getattr(self.connection, "_conn", None)
|
|
29
|
+
if not conn or not getattr(conn, "protocol", None):
|
|
30
|
+
return b""
|
|
31
|
+
payload = getattr(conn.protocol, "_payload", None)
|
|
32
|
+
buf = getattr(payload, "_buffer", None)
|
|
33
|
+
return buf if buf is not None else b""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# SAFETY PATCH: make ccxt WS buffer access resilient to closed connections
|
|
37
|
+
_WsClient.buffer = property(_safe_buffer) # type: ignore
|
|
38
|
+
|
|
39
|
+
|
|
26
40
|
class ConnectionManager:
|
|
27
41
|
"""
|
|
28
42
|
Manages WebSocket connections and stream lifecycle for CCXT data provider.
|
|
@@ -18,8 +18,6 @@ from qubx.core.interfaces import IDataArrivalListener
|
|
|
18
18
|
|
|
19
19
|
# Constants for better maintainability
|
|
20
20
|
DEFAULT_CHECK_INTERVAL_SECONDS = 60.0
|
|
21
|
-
DEFAULT_MAX_RECREATIONS = 5
|
|
22
|
-
DEFAULT_RESET_INTERVAL_HOURS = 6.0
|
|
23
21
|
SECONDS_PER_HOUR = 3600
|
|
24
22
|
|
|
25
23
|
# Custom stall detection thresholds (in seconds)
|
|
@@ -30,7 +28,7 @@ STALL_THRESHOLDS = {
|
|
|
30
28
|
"trade": 60 * 60, # 60 minutes = 3,600s
|
|
31
29
|
"liquidation": 7 * 24 * SECONDS_PER_HOUR, # 7 days = 604,800s
|
|
32
30
|
"ohlc": 5 * 60, # 5 minutes = 300s
|
|
33
|
-
"quote":
|
|
31
|
+
"quote": 2 * 60, # 2 minutes = 120s
|
|
34
32
|
}
|
|
35
33
|
DEFAULT_STALL_THRESHOLD_SECONDS = 2 * SECONDS_PER_HOUR # 2 hours = 7,200s
|
|
36
34
|
|
|
@@ -45,7 +43,7 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
45
43
|
Key Features:
|
|
46
44
|
- Explicit .exchange property for CCXT access
|
|
47
45
|
- Self-contained stall detection and recreation triggering
|
|
48
|
-
-
|
|
46
|
+
- Automatic recreation without limits when data stalls
|
|
49
47
|
- Atomic exchange transitions during recreation
|
|
50
48
|
- Background monitoring thread for stall detection
|
|
51
49
|
"""
|
|
@@ -57,8 +55,6 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
57
55
|
exchange_name: str,
|
|
58
56
|
factory_params: dict[str, Any],
|
|
59
57
|
initial_exchange: Optional[cxp.Exchange] = None,
|
|
60
|
-
max_recreations: int = DEFAULT_MAX_RECREATIONS,
|
|
61
|
-
reset_interval_hours: float = DEFAULT_RESET_INTERVAL_HOURS,
|
|
62
58
|
check_interval_seconds: float = DEFAULT_CHECK_INTERVAL_SECONDS,
|
|
63
59
|
):
|
|
64
60
|
"""Initialize ExchangeManager with underlying CCXT exchange.
|
|
@@ -67,19 +63,14 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
67
63
|
exchange_name: Exchange name for factory (e.g., "binance.um")
|
|
68
64
|
factory_params: Parameters for get_ccxt_exchange()
|
|
69
65
|
initial_exchange: Pre-created exchange instance (from factory)
|
|
70
|
-
max_recreations: Maximum recreation attempts before giving up
|
|
71
|
-
reset_interval_hours: Hours between recreation count resets
|
|
72
66
|
check_interval_seconds: How often to check for stalls (default: 60.0)
|
|
73
67
|
"""
|
|
74
68
|
self._exchange_name = exchange_name
|
|
75
69
|
self._factory_params = factory_params.copy()
|
|
76
|
-
self._max_recreations = max_recreations
|
|
77
|
-
self._reset_interval_hours = reset_interval_hours
|
|
78
70
|
|
|
79
71
|
# Recreation state
|
|
80
|
-
self._recreation_count = 0
|
|
81
72
|
self._recreation_lock = threading.RLock()
|
|
82
|
-
self.
|
|
73
|
+
self._recreation_count = 0 # Track for logging purposes only
|
|
83
74
|
|
|
84
75
|
# Stall detection state
|
|
85
76
|
self._check_interval = check_interval_seconds
|
|
@@ -142,28 +133,19 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
142
133
|
|
|
143
134
|
def force_recreation(self) -> bool:
|
|
144
135
|
"""
|
|
145
|
-
Force recreation due to data stalls
|
|
136
|
+
Force recreation due to data stalls.
|
|
146
137
|
|
|
147
138
|
Returns:
|
|
148
|
-
True if recreation successful, False if failed
|
|
139
|
+
True if recreation successful, False if failed
|
|
149
140
|
"""
|
|
150
141
|
with self._recreation_lock:
|
|
151
|
-
# Check recreation limit
|
|
152
|
-
if self._recreation_count >= self._max_recreations:
|
|
153
|
-
logger.error(
|
|
154
|
-
f"Cannot recreate {self._exchange_name}: recreation limit ({self._max_recreations}) exceeded"
|
|
155
|
-
)
|
|
156
|
-
return False
|
|
157
|
-
|
|
158
142
|
logger.info(f"Stall-triggered recreation for {self._exchange_name}")
|
|
159
143
|
return self._recreate_exchange()
|
|
160
144
|
|
|
161
145
|
def _recreate_exchange(self) -> bool:
|
|
162
146
|
"""Recreate the underlying exchange (must be called with _recreation_lock held)."""
|
|
163
147
|
self._recreation_count += 1
|
|
164
|
-
logger.warning(
|
|
165
|
-
f"Recreating {self._exchange_name} exchange (attempt {self._recreation_count}/{self._max_recreations})"
|
|
166
|
-
)
|
|
148
|
+
logger.warning(f"Recreating {self._exchange_name} exchange (attempt {self._recreation_count})")
|
|
167
149
|
|
|
168
150
|
# Create new exchange
|
|
169
151
|
try:
|
|
@@ -190,18 +172,6 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
190
172
|
|
|
191
173
|
return True
|
|
192
174
|
|
|
193
|
-
def reset_recreation_count_if_needed(self) -> None:
|
|
194
|
-
"""Reset recreation count periodically (called by monitoring loop)."""
|
|
195
|
-
reset_interval_seconds = self._reset_interval_hours * SECONDS_PER_HOUR
|
|
196
|
-
|
|
197
|
-
current_time = time.time()
|
|
198
|
-
time_since_reset = current_time - self._last_successful_reset
|
|
199
|
-
|
|
200
|
-
if time_since_reset >= reset_interval_seconds and self._recreation_count > 0:
|
|
201
|
-
logger.info(f"Resetting recreation count for {self._exchange_name} (was {self._recreation_count})")
|
|
202
|
-
self._recreation_count = 0
|
|
203
|
-
self._last_successful_reset = current_time
|
|
204
|
-
|
|
205
175
|
def on_data_arrival(self, event_type: str, event_time: dt_64) -> None:
|
|
206
176
|
"""Record data arrival for stall detection.
|
|
207
177
|
|
|
@@ -254,7 +224,6 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
254
224
|
while self._monitoring_enabled:
|
|
255
225
|
try:
|
|
256
226
|
self._check_and_handle_stalls()
|
|
257
|
-
self.reset_recreation_count_if_needed()
|
|
258
227
|
time.sleep(self._check_interval)
|
|
259
228
|
except Exception as e:
|
|
260
229
|
logger.error(f"Error in ExchangeManager stall detection: {e}")
|
|
@@ -12,6 +12,7 @@ from .binance.broker import BinanceCcxtBroker
|
|
|
12
12
|
from .binance.exchange import BINANCE_UM_MM, BinancePortfolioMargin, BinanceQV, BinanceQVUSDM
|
|
13
13
|
from .bitfinex.bitfinex import BitfinexF
|
|
14
14
|
from .bitfinex.bitfinex_account import BitfinexAccountProcessor
|
|
15
|
+
from .hyperliquid.account import HyperliquidAccountProcessor
|
|
15
16
|
from .hyperliquid.broker import HyperliquidCcxtBroker
|
|
16
17
|
from .hyperliquid.hyperliquid import Hyperliquid, HyperliquidF
|
|
17
18
|
from .kraken.kraken import CustomKrakenFutures
|
|
@@ -45,12 +46,14 @@ CUSTOM_BROKERS = {
|
|
|
45
46
|
"binance.cm": partial(BinanceCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False),
|
|
46
47
|
"binance.pm": partial(BinanceCcxtBroker, enable_create_order_ws=False, enable_cancel_order_ws=False),
|
|
47
48
|
"bitfinex.f": partial(CcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=True),
|
|
48
|
-
"hyperliquid": partial(HyperliquidCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False),
|
|
49
|
-
"hyperliquid.f": partial(HyperliquidCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False),
|
|
49
|
+
"hyperliquid": partial(HyperliquidCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False, enable_edit_order_ws=True),
|
|
50
|
+
"hyperliquid.f": partial(HyperliquidCcxtBroker, enable_create_order_ws=True, enable_cancel_order_ws=False, enable_edit_order_ws=True),
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
CUSTOM_ACCOUNTS = {
|
|
53
54
|
"bitfinex.f": BitfinexAccountProcessor,
|
|
55
|
+
"hyperliquid": HyperliquidAccountProcessor,
|
|
56
|
+
"hyperliquid.f": HyperliquidAccountProcessor,
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
READER_CAPABILITIES = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Hyperliquid exchange overrides
|
|
2
2
|
|
|
3
|
+
from .account import HyperliquidAccountProcessor
|
|
3
4
|
from .broker import HyperliquidCcxtBroker
|
|
4
|
-
from .hyperliquid import HyperliquidF
|
|
5
|
+
from .hyperliquid import Hyperliquid, HyperliquidF
|
|
5
6
|
|
|
6
|
-
__all__ = ["
|
|
7
|
+
__all__ = ["HyperliquidAccountProcessor", "HyperliquidCcxtBroker", "Hyperliquid", "HyperliquidF"]
|