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.

Files changed (50) hide show
  1. qubx/backtester/account.py +1 -1
  2. qubx/cli/commands.py +13 -1
  3. qubx/connectors/ccxt/account.py +3 -2
  4. qubx/connectors/ccxt/broker.py +19 -12
  5. qubx/connectors/ccxt/connection_manager.py +14 -0
  6. qubx/connectors/ccxt/exchanges/__init__.py +5 -2
  7. qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +3 -2
  8. qubx/connectors/ccxt/exchanges/hyperliquid/account.py +75 -0
  9. qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +232 -3
  10. qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +83 -0
  11. qubx/connectors/ccxt/handlers/base.py +2 -4
  12. qubx/connectors/ccxt/handlers/factory.py +4 -5
  13. qubx/connectors/ccxt/utils.py +8 -2
  14. qubx/core/account.py +54 -5
  15. qubx/core/basics.py +62 -2
  16. qubx/core/context.py +2 -2
  17. qubx/core/helpers.py +60 -1
  18. qubx/core/interfaces.py +4 -2
  19. qubx/core/mixins/processing.py +6 -1
  20. qubx/core/mixins/trading.py +2 -8
  21. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  22. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  23. qubx/data/__init__.py +10 -0
  24. qubx/data/composite.py +87 -60
  25. qubx/data/containers.py +234 -0
  26. qubx/data/readers.py +3 -0
  27. qubx/data/registry.py +122 -4
  28. qubx/data/storage.py +74 -0
  29. qubx/data/storages/csv.py +273 -0
  30. qubx/data/storages/questdb.py +554 -0
  31. qubx/data/storages/utils.py +115 -0
  32. qubx/data/transformers.py +491 -0
  33. qubx/exporters/redis_streams.py +3 -3
  34. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  35. qubx/utils/questdb.py +6 -7
  36. qubx/utils/runner/textual/__init__.py +60 -0
  37. qubx/utils/runner/textual/app.py +149 -0
  38. qubx/utils/runner/textual/handlers.py +72 -0
  39. qubx/utils/runner/textual/init_code.py +143 -0
  40. qubx/utils/runner/textual/kernel.py +110 -0
  41. qubx/utils/runner/textual/styles.tcss +100 -0
  42. qubx/utils/runner/textual/widgets/__init__.py +6 -0
  43. qubx/utils/runner/textual/widgets/positions_table.py +89 -0
  44. qubx/utils/runner/textual/widgets/repl_output.py +14 -0
  45. qubx/utils/time.py +7 -0
  46. {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/METADATA +1 -1
  47. {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/RECORD +50 -34
  48. {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/WHEEL +0 -0
  49. {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/entry_points.txt +0 -0
  50. {qubx-0.6.90.dist-info → qubx-0.6.91.dist-info}/licenses/LICENSE +0 -0
@@ -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)
@@ -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]],
@@ -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"Direct update successful for order {order_id}")
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(f"Fallback update successful for order {order_id} -> new order {updated_order.id}")
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
- result = await self._exchange_manager.exchange.edit_order(
559
- id=order_id, symbol=ccxt_symbol, type="limit", side=ccxt_side, amount=amount, price=price, params={}
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__ = ["HyperliquidF", "HyperliquidCcxtBroker"]
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.core.basics import Instrument, OrderSide
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.01, # 5% default slippage
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
- # Add slippage parameter to params if specified in options
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"]: