Qubx 0.6.90__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/cli/commands.py +13 -1
- qubx/connectors/ccxt/account.py +3 -2
- qubx/connectors/ccxt/broker.py +19 -12
- qubx/connectors/ccxt/connection_manager.py +14 -0
- 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/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 +2 -2
- qubx/core/helpers.py +60 -1
- qubx/core/interfaces.py +4 -2
- qubx/core/mixins/processing.py +6 -1
- qubx/core/mixins/trading.py +2 -8
- 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/redis_streams.py +3 -3
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- 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.90.dist-info → qubx-0.6.91.dist-info}/METADATA +1 -1
- {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/RECORD +50 -34
- {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/WHEEL +0 -0
- {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/entry_points.txt +0 -0
- {qubx-0.6.90.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/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
|
@@ -42,6 +42,7 @@ class CcxtBroker(IBroker):
|
|
|
42
42
|
max_cancel_retries: int = 10,
|
|
43
43
|
enable_create_order_ws: bool = False,
|
|
44
44
|
enable_cancel_order_ws: bool = False,
|
|
45
|
+
enable_edit_order_ws: bool = False,
|
|
45
46
|
):
|
|
46
47
|
self._exchange_manager = exchange_manager
|
|
47
48
|
self.ccxt_exchange_id = str(self._exchange_manager.exchange.name)
|
|
@@ -54,6 +55,7 @@ class CcxtBroker(IBroker):
|
|
|
54
55
|
self.max_cancel_retries = max_cancel_retries
|
|
55
56
|
self.enable_create_order_ws = enable_create_order_ws
|
|
56
57
|
self.enable_cancel_order_ws = enable_cancel_order_ws
|
|
58
|
+
self.enable_edit_order_ws = enable_edit_order_ws
|
|
57
59
|
|
|
58
60
|
@property
|
|
59
61
|
def _loop(self) -> AsyncThreadLoop:
|
|
@@ -476,8 +478,6 @@ class CcxtBroker(IBroker):
|
|
|
476
478
|
BadRequest: If the order is not a limit order
|
|
477
479
|
ExchangeError: If the exchange operation fails
|
|
478
480
|
"""
|
|
479
|
-
logger.debug(f"Updating order {order_id} with price={price}, amount={amount}")
|
|
480
|
-
|
|
481
481
|
active_orders = self.account.get_orders()
|
|
482
482
|
if order_id not in active_orders:
|
|
483
483
|
raise OrderNotFound(f"Order {order_id} not found in active orders")
|
|
@@ -496,7 +496,7 @@ class CcxtBroker(IBroker):
|
|
|
496
496
|
|
|
497
497
|
logger.debug(
|
|
498
498
|
f"[<g>{instrument.symbol}</g>] :: Updating order {order_id}: "
|
|
499
|
-
f"{amount} @ {price} (was: {existing_order.quantity} @ {existing_order.price})"
|
|
499
|
+
f"{amount} @ {price} (was: {existing_order.quantity} @ {existing_order.price} ({existing_order.time_in_force}))"
|
|
500
500
|
)
|
|
501
501
|
|
|
502
502
|
try:
|
|
@@ -511,8 +511,6 @@ class CcxtBroker(IBroker):
|
|
|
511
511
|
|
|
512
512
|
def _update_order_direct(self, order_id: str, existing_order: Order, price: float, amount: float) -> Order:
|
|
513
513
|
"""Update order using exchange's native edit functionality."""
|
|
514
|
-
logger.debug(f"Using direct order update for {order_id}")
|
|
515
|
-
|
|
516
514
|
future_result = self._loop.submit(self._edit_order_async(order_id, existing_order, price, amount))
|
|
517
515
|
updated_order, error = future_result.result()
|
|
518
516
|
|
|
@@ -521,15 +519,13 @@ class CcxtBroker(IBroker):
|
|
|
521
519
|
|
|
522
520
|
if updated_order is not None:
|
|
523
521
|
self.account.process_order(updated_order)
|
|
524
|
-
logger.debug(f"
|
|
522
|
+
logger.debug(f"[<g>{existing_order.instrument.symbol}</g>] :: Successfully updated order {order_id}")
|
|
525
523
|
return updated_order
|
|
526
524
|
else:
|
|
527
525
|
raise Exception("Order update returned None without error")
|
|
528
526
|
|
|
529
527
|
def _update_order_fallback(self, order_id: str, existing_order: Order, price: float, amount: float) -> Order:
|
|
530
528
|
"""Update order using cancel+recreate strategy for exchanges without editOrder support."""
|
|
531
|
-
logger.debug(f"Using fallback (cancel+recreate) strategy for order {order_id}")
|
|
532
|
-
|
|
533
529
|
success = self.cancel_order(order_id)
|
|
534
530
|
if not success:
|
|
535
531
|
raise Exception(f"Failed to cancel order {order_id} during update")
|
|
@@ -544,7 +540,9 @@ class CcxtBroker(IBroker):
|
|
|
544
540
|
time_in_force=existing_order.time_in_force or "gtc",
|
|
545
541
|
)
|
|
546
542
|
|
|
547
|
-
logger.debug(
|
|
543
|
+
logger.debug(
|
|
544
|
+
f"[<g>{existing_order.instrument.symbol}</g>] :: Successfully updated order {order_id} -> new order {updated_order.id}"
|
|
545
|
+
)
|
|
548
546
|
return updated_order
|
|
549
547
|
|
|
550
548
|
async def _edit_order_async(
|
|
@@ -555,9 +553,18 @@ class CcxtBroker(IBroker):
|
|
|
555
553
|
ccxt_symbol = instrument_to_ccxt_symbol(existing_order.instrument)
|
|
556
554
|
ccxt_side = "buy" if existing_order.side == "BUY" else "sell"
|
|
557
555
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
+
)
|
|
561
568
|
|
|
562
569
|
# Convert the result back to our Order format
|
|
563
570
|
updated_order = ccxt_convert_order_info(existing_order.instrument, result)
|
|
@@ -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.
|
|
@@ -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"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from qubx import logger
|
|
4
|
+
from qubx.connectors.ccxt.account import CcxtAccountProcessor
|
|
5
|
+
from qubx.connectors.ccxt.utils import (
|
|
6
|
+
ccxt_convert_order_info,
|
|
7
|
+
ccxt_extract_deals_from_exec,
|
|
8
|
+
ccxt_find_instrument,
|
|
9
|
+
)
|
|
10
|
+
from qubx.core.basics import CtrlChannel, Instrument
|
|
11
|
+
from qubx.utils.time import now_utc, timestamp_to_ms
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HyperliquidAccountProcessor(CcxtAccountProcessor):
|
|
15
|
+
"""
|
|
16
|
+
Hyperliquid-specific account processor.
|
|
17
|
+
|
|
18
|
+
Hyperliquid uses separate WebSocket channels:
|
|
19
|
+
- orderUpdates: for order status (watch_orders)
|
|
20
|
+
- userFills: for trade/fill updates (watch_my_trades)
|
|
21
|
+
|
|
22
|
+
Unlike Binance, Hyperliquid's watch_orders does NOT include trades,
|
|
23
|
+
so we must subscribe to both channels separately.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def _subscribe_instruments(self, instruments: list[Instrument]):
|
|
27
|
+
"""Override to filter out instruments from other exchanges (e.g., spot vs futures)."""
|
|
28
|
+
# Filter instruments to only those belonging to this exchange
|
|
29
|
+
exchange_name = self.exchange_manager.exchange.name
|
|
30
|
+
matching_instruments = [instr for instr in instruments if instr.exchange == exchange_name]
|
|
31
|
+
|
|
32
|
+
if len(matching_instruments) < len(instruments):
|
|
33
|
+
skipped = [instr for instr in instruments if instr.exchange != exchange_name]
|
|
34
|
+
logger.debug(f"Skipping subscription for {len(skipped)} instruments from other exchanges: {skipped}")
|
|
35
|
+
|
|
36
|
+
# Call parent with filtered instruments
|
|
37
|
+
if matching_instruments:
|
|
38
|
+
await super()._subscribe_instruments(matching_instruments)
|
|
39
|
+
|
|
40
|
+
async def _subscribe_executions(self, name: str, channel: CtrlChannel):
|
|
41
|
+
logger.info("<yellow>[Hyperliquid]</yellow> Subscribing to executions")
|
|
42
|
+
_symbol_to_instrument = {}
|
|
43
|
+
|
|
44
|
+
async def _watch_orders():
|
|
45
|
+
orders = await self.exchange_manager.exchange.watch_orders()
|
|
46
|
+
for order in orders:
|
|
47
|
+
instrument = ccxt_find_instrument(
|
|
48
|
+
order["symbol"], self.exchange_manager.exchange, _symbol_to_instrument
|
|
49
|
+
)
|
|
50
|
+
order = ccxt_convert_order_info(instrument, order)
|
|
51
|
+
channel.send((instrument, "order", order, False))
|
|
52
|
+
|
|
53
|
+
async def _watch_my_trades():
|
|
54
|
+
trades = await self.exchange_manager.exchange.watch_my_trades(since=timestamp_to_ms(now_utc()))
|
|
55
|
+
for trade in trades: # type: ignore
|
|
56
|
+
instrument = ccxt_find_instrument(
|
|
57
|
+
trade["symbol"], self.exchange_manager.exchange, _symbol_to_instrument
|
|
58
|
+
)
|
|
59
|
+
deals = ccxt_extract_deals_from_exec({"trades": [trade]})
|
|
60
|
+
channel.send((instrument, "deals", deals, False))
|
|
61
|
+
|
|
62
|
+
await asyncio.gather(
|
|
63
|
+
self._listen_to_stream(
|
|
64
|
+
subscriber=_watch_orders,
|
|
65
|
+
exchange=self.exchange_manager.exchange,
|
|
66
|
+
channel=channel,
|
|
67
|
+
name=f"{name}_orders",
|
|
68
|
+
),
|
|
69
|
+
self._listen_to_stream(
|
|
70
|
+
subscriber=_watch_my_trades,
|
|
71
|
+
exchange=self.exchange_manager.exchange,
|
|
72
|
+
channel=channel,
|
|
73
|
+
name=f"{name}_trades",
|
|
74
|
+
),
|
|
75
|
+
)
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
3
5
|
from qubx import logger
|
|
4
6
|
from qubx.connectors.ccxt.broker import CcxtBroker
|
|
5
|
-
from qubx.
|
|
7
|
+
from qubx.connectors.ccxt.utils import ccxt_convert_order_info, instrument_to_ccxt_symbol
|
|
8
|
+
from qubx.core.basics import (
|
|
9
|
+
Instrument,
|
|
10
|
+
Order,
|
|
11
|
+
OrderSide,
|
|
12
|
+
)
|
|
6
13
|
from qubx.core.exceptions import BadRequest
|
|
7
14
|
|
|
8
15
|
|
|
@@ -17,12 +24,164 @@ class HyperliquidCcxtBroker(CcxtBroker):
|
|
|
17
24
|
def __init__(
|
|
18
25
|
self,
|
|
19
26
|
*args,
|
|
20
|
-
market_order_slippage: float = 0.
|
|
27
|
+
market_order_slippage: float = 0.05, # 5% default slippage
|
|
21
28
|
**kwargs,
|
|
22
29
|
):
|
|
23
30
|
super().__init__(*args, **kwargs)
|
|
24
31
|
self.market_order_slippage = market_order_slippage
|
|
25
32
|
|
|
33
|
+
def _enrich_order_response(
|
|
34
|
+
self,
|
|
35
|
+
response: dict[str, Any],
|
|
36
|
+
symbol: str,
|
|
37
|
+
order_type: str,
|
|
38
|
+
side: str,
|
|
39
|
+
amount: float,
|
|
40
|
+
price: float | None = None,
|
|
41
|
+
client_id: str | None = None,
|
|
42
|
+
params: dict[str, Any] | None = None,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
"""
|
|
45
|
+
Fill in missing fields in HyperLiquid order response.
|
|
46
|
+
|
|
47
|
+
HyperLiquid often returns minimal order information (sometimes only order ID),
|
|
48
|
+
so we need to reconstruct the full order from the request parameters.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
response: The raw response from HyperLiquid
|
|
52
|
+
symbol: CCXT symbol (e.g., "BTC/USDC:USDC")
|
|
53
|
+
order_type: Order type ("limit" or "market")
|
|
54
|
+
side: Order side ("buy" or "sell")
|
|
55
|
+
amount: Order amount
|
|
56
|
+
price: Order price (optional for market orders)
|
|
57
|
+
client_id: Client order ID (optional)
|
|
58
|
+
params: Additional params (timeInForce, reduceOnly, etc.)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Enriched response dictionary ready for ccxt_convert_order_info
|
|
62
|
+
"""
|
|
63
|
+
if params is None:
|
|
64
|
+
params = {}
|
|
65
|
+
|
|
66
|
+
# Fill in missing fields from the request parameters
|
|
67
|
+
if not response.get("symbol"):
|
|
68
|
+
response["symbol"] = symbol
|
|
69
|
+
if not response.get("type"):
|
|
70
|
+
response["type"] = order_type
|
|
71
|
+
# Always set side from request params (CCXT can't parse from resting/filled structures)
|
|
72
|
+
if not response.get("side") or response.get("side") == "unknown":
|
|
73
|
+
response["side"] = side
|
|
74
|
+
if not response.get("amount") or response.get("amount") == 0:
|
|
75
|
+
response["amount"] = amount
|
|
76
|
+
if not response.get("price") or response.get("price") == 0:
|
|
77
|
+
if price is not None:
|
|
78
|
+
response["price"] = price
|
|
79
|
+
if not response.get("timestamp"):
|
|
80
|
+
response["timestamp"] = pd.Timestamp.now(tz="UTC").value // 1_000_000 # Convert to milliseconds
|
|
81
|
+
if not response.get("status"):
|
|
82
|
+
response["status"] = "open"
|
|
83
|
+
if not response.get("timeInForce"):
|
|
84
|
+
response["timeInForce"] = params.get("timeInForce")
|
|
85
|
+
if not response.get("clientOrderId") and client_id:
|
|
86
|
+
response["clientOrderId"] = client_id
|
|
87
|
+
if not response.get("cost"):
|
|
88
|
+
response["cost"] = 0.0
|
|
89
|
+
|
|
90
|
+
# Ensure reduceOnly is propagated
|
|
91
|
+
if params.get("reduceOnly"):
|
|
92
|
+
response["reduceOnly"] = True
|
|
93
|
+
|
|
94
|
+
return response
|
|
95
|
+
|
|
96
|
+
def _translate_time_in_force(self, tif: str | None) -> str | None:
|
|
97
|
+
"""
|
|
98
|
+
Translate standard timeInForce values to HyperLiquid format.
|
|
99
|
+
|
|
100
|
+
HyperLiquid uses:
|
|
101
|
+
- "Alo" for Add Liquidity Only (post-only/maker)
|
|
102
|
+
- "Ioc" for Immediate or Cancel
|
|
103
|
+
- "Gtc" for Good til Cancelled
|
|
104
|
+
|
|
105
|
+
This method translates common aliases:
|
|
106
|
+
- GTX (Binance post-only) → Alo
|
|
107
|
+
- FOK (Fill or Kill) → Ioc
|
|
108
|
+
- GTC, IOC, ALO (any case) → proper HyperLiquid format
|
|
109
|
+
"""
|
|
110
|
+
if tif is None:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
# If already in correct HyperLiquid format, return as-is
|
|
114
|
+
if tif in ["Alo", "Ioc", "Gtc"]:
|
|
115
|
+
return tif
|
|
116
|
+
|
|
117
|
+
# Translation map for timeInForce values
|
|
118
|
+
tif_map = {
|
|
119
|
+
"GTX": "Alo", # Binance post-only → Hyperliquid post-only
|
|
120
|
+
"FOK": "Ioc", # Fill or Kill → Immediate or Cancel
|
|
121
|
+
"GTC": "Gtc", # Good til Cancelled (normalize case)
|
|
122
|
+
"IOC": "Ioc", # Immediate or Cancel (normalize case)
|
|
123
|
+
"ALO": "Alo", # Add Liquidity Only (normalize case)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Normalize to uppercase for lookup
|
|
127
|
+
tif_upper = tif.upper()
|
|
128
|
+
return tif_map.get(tif_upper, tif) # Return translated or original if not in map
|
|
129
|
+
|
|
130
|
+
async def _create_order(
|
|
131
|
+
self,
|
|
132
|
+
instrument: Instrument,
|
|
133
|
+
order_side: OrderSide,
|
|
134
|
+
order_type: str,
|
|
135
|
+
amount: float,
|
|
136
|
+
price: float | None = None,
|
|
137
|
+
client_id: str | None = None,
|
|
138
|
+
time_in_force: str = "gtc",
|
|
139
|
+
**options,
|
|
140
|
+
) -> tuple[Order | None, Exception | None]:
|
|
141
|
+
"""
|
|
142
|
+
Override _create_order to fill missing order details from request.
|
|
143
|
+
|
|
144
|
+
HyperLiquid returns minimal order information (only order ID),
|
|
145
|
+
so we need to reconstruct the full order from the request parameters.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
payload = self._prepare_order_payload(
|
|
149
|
+
instrument, order_side, order_type, amount, price, client_id, time_in_force, **options
|
|
150
|
+
)
|
|
151
|
+
if self.enable_create_order_ws:
|
|
152
|
+
r = await self._exchange_manager.exchange.create_order_ws(**payload)
|
|
153
|
+
else:
|
|
154
|
+
r = await self._exchange_manager.exchange.create_order(**payload)
|
|
155
|
+
|
|
156
|
+
if r is None:
|
|
157
|
+
msg = "(::_create_order) No response from exchange"
|
|
158
|
+
logger.error(msg)
|
|
159
|
+
from ccxt.base.errors import ExchangeError
|
|
160
|
+
|
|
161
|
+
return None, ExchangeError(msg)
|
|
162
|
+
|
|
163
|
+
if r["id"] is None:
|
|
164
|
+
return None, None
|
|
165
|
+
|
|
166
|
+
# Fill in missing fields from the request parameters
|
|
167
|
+
r = self._enrich_order_response(
|
|
168
|
+
response=r,
|
|
169
|
+
symbol=payload["symbol"],
|
|
170
|
+
order_type=payload["type"],
|
|
171
|
+
side=payload["side"],
|
|
172
|
+
amount=payload["amount"],
|
|
173
|
+
price=payload["price"],
|
|
174
|
+
client_id=client_id,
|
|
175
|
+
params=payload["params"],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
order = ccxt_convert_order_info(instrument, r)
|
|
179
|
+
logger.info(f"New order {order}")
|
|
180
|
+
return order, None
|
|
181
|
+
|
|
182
|
+
except Exception as err:
|
|
183
|
+
return None, err
|
|
184
|
+
|
|
26
185
|
def _prepare_order_payload(
|
|
27
186
|
self,
|
|
28
187
|
instrument: Instrument,
|
|
@@ -67,11 +226,81 @@ class HyperliquidCcxtBroker(CcxtBroker):
|
|
|
67
226
|
instrument, order_side, order_type, amount, price, client_id, time_in_force, **options
|
|
68
227
|
)
|
|
69
228
|
|
|
70
|
-
#
|
|
229
|
+
# Translate timeInForce to HyperLiquid format (GTX → Alo, etc.)
|
|
71
230
|
params = payload.get("params", {})
|
|
231
|
+
if "timeInForce" in params:
|
|
232
|
+
params["timeInForce"] = self._translate_time_in_force(params["timeInForce"])
|
|
233
|
+
|
|
234
|
+
# Add slippage parameter to params if specified in options
|
|
72
235
|
if "slippage" in options:
|
|
73
236
|
# HyperLiquid accepts slippage as a percentage (e.g., 0.05 for 5%)
|
|
74
237
|
params["px"] = price # Explicit price for slippage calculation
|
|
75
238
|
|
|
76
239
|
payload["params"] = params
|
|
77
240
|
return payload
|
|
241
|
+
|
|
242
|
+
async def _edit_order_async(
|
|
243
|
+
self, order_id: str, existing_order: Order, price: float, amount: float
|
|
244
|
+
) -> tuple[Order | None, Exception | None]:
|
|
245
|
+
"""
|
|
246
|
+
Override _edit_order_async with WebSocket support and response enrichment.
|
|
247
|
+
|
|
248
|
+
HyperLiquid requires params to match the original order and may return
|
|
249
|
+
minimal order information, so we fill missing fields from the request.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
ccxt_symbol = instrument_to_ccxt_symbol(existing_order.instrument)
|
|
253
|
+
ccxt_side = "buy" if existing_order.side == "BUY" else "sell"
|
|
254
|
+
|
|
255
|
+
params = {}
|
|
256
|
+
if existing_order.time_in_force:
|
|
257
|
+
# Translate TIF to HyperLiquid format (GTX → Alo, etc.)
|
|
258
|
+
params["timeInForce"] = self._translate_time_in_force(existing_order.time_in_force)
|
|
259
|
+
else:
|
|
260
|
+
# If no TIF is set on the existing order, default to Alo (post-only) for limit orders
|
|
261
|
+
# This is the safest default for market making
|
|
262
|
+
params["timeInForce"] = "Alo"
|
|
263
|
+
if existing_order.options.get("reduceOnly", False):
|
|
264
|
+
params["reduceOnly"] = True
|
|
265
|
+
|
|
266
|
+
# Use WebSocket if enabled, otherwise use REST API
|
|
267
|
+
if self.enable_edit_order_ws:
|
|
268
|
+
result = await self._exchange_manager.exchange.edit_order_ws(
|
|
269
|
+
id=order_id,
|
|
270
|
+
symbol=ccxt_symbol,
|
|
271
|
+
type="limit",
|
|
272
|
+
side=ccxt_side,
|
|
273
|
+
amount=amount,
|
|
274
|
+
price=price,
|
|
275
|
+
params=params,
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
result = await self._exchange_manager.exchange.edit_order(
|
|
279
|
+
id=order_id,
|
|
280
|
+
symbol=ccxt_symbol,
|
|
281
|
+
type="limit",
|
|
282
|
+
side=ccxt_side,
|
|
283
|
+
amount=amount,
|
|
284
|
+
price=price,
|
|
285
|
+
params=params,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Fill in missing fields from the request parameters
|
|
289
|
+
result = self._enrich_order_response(
|
|
290
|
+
response=result,
|
|
291
|
+
symbol=ccxt_symbol,
|
|
292
|
+
order_type="limit",
|
|
293
|
+
side=ccxt_side,
|
|
294
|
+
amount=amount,
|
|
295
|
+
price=price,
|
|
296
|
+
client_id=existing_order.client_id,
|
|
297
|
+
params=params,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Convert the result back to our Order format
|
|
301
|
+
updated_order = ccxt_convert_order_info(existing_order.instrument, result)
|
|
302
|
+
return updated_order, None
|
|
303
|
+
|
|
304
|
+
except Exception as err:
|
|
305
|
+
logger.error(f"Async edit order failed for {order_id}: {err}")
|
|
306
|
+
return None, err
|
|
@@ -2,6 +2,8 @@ import math
|
|
|
2
2
|
from typing import Any, Dict, List, Optional
|
|
3
3
|
|
|
4
4
|
import ccxt.pro as cxp
|
|
5
|
+
from ccxt.async_support.base.ws.client import Client
|
|
6
|
+
from ccxt.base.errors import ExchangeError, InvalidOrder
|
|
5
7
|
from qubx import logger
|
|
6
8
|
|
|
7
9
|
from ...adapters.polling_adapter import PollingConfig, PollingToWebSocketAdapter
|
|
@@ -50,6 +52,67 @@ class HyperliquidEnhanced(CcxtFuturePatchMixin, cxp.hyperliquid):
|
|
|
50
52
|
0.0, # bought_volume_quote (not provided by Hyperliquid)
|
|
51
53
|
]
|
|
52
54
|
|
|
55
|
+
def handle_error_message(self, client: Client, message) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Override CCXT's handle_error_message to fix the bug where error strings
|
|
58
|
+
are passed to client.reject() instead of Exception objects.
|
|
59
|
+
|
|
60
|
+
This method also adds detailed logging for debugging order failures.
|
|
61
|
+
|
|
62
|
+
CCXT Bug: Lines 908, 919, 924 in ccxt/pro/hyperliquid.py pass error strings
|
|
63
|
+
to client.reject(), but Future.set_exception() requires Exception objects.
|
|
64
|
+
"""
|
|
65
|
+
# Log the raw message for debugging
|
|
66
|
+
# logger.debug(f"[Hyperliquid WS Error] Raw message: {self.json(message)}")
|
|
67
|
+
|
|
68
|
+
# Check for direct error channel
|
|
69
|
+
channel = self.safe_string(message, "channel", "")
|
|
70
|
+
if channel == "error":
|
|
71
|
+
ret_msg = self.safe_string(message, "data", "")
|
|
72
|
+
error_msg = f"{self.id} {ret_msg}"
|
|
73
|
+
logger.error(f"[Hyperliquid WS Error] Channel error: {error_msg}")
|
|
74
|
+
# FIX: Wrap in Exception instead of passing string
|
|
75
|
+
client.reject(ExchangeError(error_msg))
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# Check response payload for errors
|
|
79
|
+
data = self.safe_dict(message, "data", {})
|
|
80
|
+
id = self.safe_string(message, "id")
|
|
81
|
+
if id is None:
|
|
82
|
+
id = self.safe_string(data, "id")
|
|
83
|
+
|
|
84
|
+
response = self.safe_dict(data, "response", {})
|
|
85
|
+
payload = self.safe_dict(response, "payload", {})
|
|
86
|
+
|
|
87
|
+
# Check for status != 'ok'
|
|
88
|
+
status = self.safe_string(payload, "status")
|
|
89
|
+
if status is not None and status != "ok":
|
|
90
|
+
error_msg = f"{self.id} {self.json(payload)}"
|
|
91
|
+
logger.error(f"[Hyperliquid WS Error] Status not ok: {error_msg}")
|
|
92
|
+
# FIX: Wrap in Exception instead of passing string
|
|
93
|
+
client.reject(InvalidOrder(error_msg), id)
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
# Check for explicit error type
|
|
97
|
+
type = self.safe_string(payload, "type")
|
|
98
|
+
if type == "error":
|
|
99
|
+
error_msg = f"{self.id} {self.json(payload)}"
|
|
100
|
+
logger.error(f"[Hyperliquid WS Error] Explicit error type: {error_msg}")
|
|
101
|
+
# FIX: Wrap in Exception instead of passing string
|
|
102
|
+
client.reject(ExchangeError(error_msg), id)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
# Try standard error handling
|
|
106
|
+
try:
|
|
107
|
+
self.handle_errors(0, "", "", "", {}, self.json(payload), payload, {}, {})
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"[Hyperliquid WS Error] handle_errors exception: {e}")
|
|
110
|
+
# This is already an Exception, so it's safe to pass
|
|
111
|
+
client.reject(e, id)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
return False
|
|
115
|
+
|
|
53
116
|
async def watch_funding_rates(
|
|
54
117
|
self, symbols: Optional[List[str]] = None, params: Optional[Dict[str, Any]] = None
|
|
55
118
|
) -> Dict[str, Any]:
|
|
@@ -200,6 +263,26 @@ class HyperliquidEnhanced(CcxtFuturePatchMixin, cxp.hyperliquid):
|
|
|
200
263
|
if cloid and parsed.get("clientOrderId") is None:
|
|
201
264
|
parsed["clientOrderId"] = cloid
|
|
202
265
|
|
|
266
|
+
# Fix timeInForce from tif field
|
|
267
|
+
# HyperLiquid uses: "Gtc", "Ioc", "Alo"
|
|
268
|
+
tif = order_info.get("tif")
|
|
269
|
+
if tif and not parsed.get("timeInForce"):
|
|
270
|
+
parsed["timeInForce"] = tif
|
|
271
|
+
# logger.debug(f"[HL parse_order] Extracted timeInForce='{tif}' from order {order_info.get('oid')}")
|
|
272
|
+
# elif not tif:
|
|
273
|
+
# oid = order_info.get("oid", "unknown")
|
|
274
|
+
# logger.warning(
|
|
275
|
+
# f"[HL parse_order] No 'tif' field found in order {oid}, raw order_info keys: {list(order_info.keys()) if isinstance(order_info, dict) else 'not a dict'}"
|
|
276
|
+
# )
|
|
277
|
+
|
|
278
|
+
# Fix reduceOnly from reduceOnly field
|
|
279
|
+
reduce_only = order_info.get("reduceOnly")
|
|
280
|
+
if reduce_only is not None and not parsed.get("reduceOnly"):
|
|
281
|
+
parsed["reduceOnly"] = bool(reduce_only)
|
|
282
|
+
logger.debug(
|
|
283
|
+
f"[HL parse_order] Extracted reduceOnly={reduce_only} from order {order_info.get('oid')}"
|
|
284
|
+
)
|
|
285
|
+
|
|
203
286
|
# Fix status from HyperLiquid status field
|
|
204
287
|
hl_status = info.get("status")
|
|
205
288
|
if hl_status and parsed.get("status") in [None, "open"]:
|