Qubx 0.6.90__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.93__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 (84) hide show
  1. qubx/backtester/account.py +1 -1
  2. qubx/backtester/management.py +2 -1
  3. qubx/cli/commands.py +128 -1
  4. qubx/connectors/ccxt/account.py +3 -2
  5. qubx/connectors/ccxt/broker.py +19 -12
  6. qubx/connectors/ccxt/connection_manager.py +14 -0
  7. qubx/connectors/ccxt/exchanges/__init__.py +5 -2
  8. qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +3 -2
  9. qubx/connectors/ccxt/exchanges/hyperliquid/account.py +75 -0
  10. qubx/connectors/ccxt/exchanges/hyperliquid/broker.py +232 -3
  11. qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +83 -0
  12. qubx/connectors/ccxt/factory.py +15 -20
  13. qubx/connectors/ccxt/handlers/base.py +2 -4
  14. qubx/connectors/ccxt/handlers/factory.py +4 -5
  15. qubx/connectors/ccxt/utils.py +8 -2
  16. qubx/connectors/xlighter/__init__.py +83 -0
  17. qubx/connectors/xlighter/account.py +531 -0
  18. qubx/connectors/xlighter/broker.py +857 -0
  19. qubx/connectors/xlighter/client.py +378 -0
  20. qubx/connectors/xlighter/constants.py +126 -0
  21. qubx/connectors/xlighter/data.py +620 -0
  22. qubx/connectors/xlighter/extensions.py +248 -0
  23. qubx/connectors/xlighter/factory.py +330 -0
  24. qubx/connectors/xlighter/handlers/__init__.py +13 -0
  25. qubx/connectors/xlighter/handlers/base.py +104 -0
  26. qubx/connectors/xlighter/handlers/orderbook.py +207 -0
  27. qubx/connectors/xlighter/handlers/quote.py +158 -0
  28. qubx/connectors/xlighter/handlers/trades.py +146 -0
  29. qubx/connectors/xlighter/instruments.py +253 -0
  30. qubx/connectors/xlighter/orderbook_maintainer.py +314 -0
  31. qubx/connectors/xlighter/parsers.py +694 -0
  32. qubx/connectors/xlighter/reader.py +489 -0
  33. qubx/connectors/xlighter/utils.py +281 -0
  34. qubx/connectors/xlighter/websocket.py +359 -0
  35. qubx/core/account.py +57 -5
  36. qubx/core/basics.py +62 -2
  37. qubx/core/context.py +2 -2
  38. qubx/core/helpers.py +60 -1
  39. qubx/core/interfaces.py +201 -2
  40. qubx/core/mixins/processing.py +6 -1
  41. qubx/core/mixins/trading.py +75 -8
  42. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  43. qubx/core/series.pxd +8 -0
  44. qubx/core/series.pyi +46 -2
  45. qubx/core/series.pyx +134 -4
  46. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  47. qubx/data/__init__.py +10 -0
  48. qubx/data/composite.py +87 -60
  49. qubx/data/containers.py +234 -0
  50. qubx/data/readers.py +3 -0
  51. qubx/data/registry.py +122 -4
  52. qubx/data/storage.py +74 -0
  53. qubx/data/storages/csv.py +273 -0
  54. qubx/data/storages/questdb.py +566 -0
  55. qubx/data/storages/utils.py +115 -0
  56. qubx/data/transformers.py +526 -0
  57. qubx/exporters/redis_streams.py +3 -3
  58. qubx/pandaz/utils.py +1 -1
  59. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  60. qubx/utils/questdb.py +6 -7
  61. qubx/utils/runner/accounts.py +30 -1
  62. qubx/utils/runner/configs.py +2 -1
  63. qubx/utils/runner/kernel_service.py +195 -0
  64. qubx/utils/runner/runner.py +107 -7
  65. qubx/utils/runner/textual/__init__.py +177 -0
  66. qubx/utils/runner/textual/app.py +392 -0
  67. qubx/utils/runner/textual/handlers.py +90 -0
  68. qubx/utils/runner/textual/init_code.py +304 -0
  69. qubx/utils/runner/textual/kernel.py +269 -0
  70. qubx/utils/runner/textual/styles.tcss +134 -0
  71. qubx/utils/runner/textual/widgets/__init__.py +10 -0
  72. qubx/utils/runner/textual/widgets/command_input.py +105 -0
  73. qubx/utils/runner/textual/widgets/debug_log.py +97 -0
  74. qubx/utils/runner/textual/widgets/orders_table.py +61 -0
  75. qubx/utils/runner/textual/widgets/positions_table.py +91 -0
  76. qubx/utils/runner/textual/widgets/quotes_table.py +90 -0
  77. qubx/utils/runner/textual/widgets/repl_output.py +97 -0
  78. qubx/utils/time.py +7 -0
  79. qubx/utils/websocket_manager.py +442 -0
  80. {qubx-0.6.90.dist-info → qubx-0.6.93.dist-info}/METADATA +5 -2
  81. {qubx-0.6.90.dist-info → qubx-0.6.93.dist-info}/RECORD +84 -43
  82. {qubx-0.6.90.dist-info → qubx-0.6.93.dist-info}/WHEEL +0 -0
  83. {qubx-0.6.90.dist-info → qubx-0.6.93.dist-info}/entry_points.txt +0 -0
  84. {qubx-0.6.90.dist-info → qubx-0.6.93.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:
@@ -1,6 +1,7 @@
1
1
  import re
2
2
  import zipfile
3
3
  from collections import defaultdict
4
+ from os.path import expanduser
4
5
  from pathlib import Path
5
6
 
6
7
  import numpy as np
@@ -39,7 +40,7 @@ class BacktestsResultsManager:
39
40
  """
40
41
 
41
42
  def __init__(self, path: str):
42
- self.path = path
43
+ self.path = expanduser(path)
43
44
  self.reload()
44
45
 
45
46
  def reload(self) -> "BacktestsResultsManager":
qubx/cli/commands.py CHANGED
@@ -69,11 +69,32 @@ 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
+ )
75
+ @click.option(
76
+ "--textual-dev", is_flag=True, default=False, help="Enable Textual dev mode (use with 'textual console').", show_default=True
77
+ )
78
+ @click.option(
79
+ "--textual-web", is_flag=True, default=False, help="Serve Textual app in web browser.", show_default=True
80
+ )
81
+ @click.option(
82
+ "--textual-port", type=int, default=None, help="Port for Textual (web server: 8000, devtools: 8081).", show_default=False
83
+ )
84
+ @click.option(
85
+ "--textual-host", type=str, default="0.0.0.0", help="Host for Textual web server.", show_default=True
86
+ )
87
+ @click.option(
88
+ "--kernel-only", is_flag=True, default=False, help="Start kernel without UI (returns connection file).", show_default=True
89
+ )
90
+ @click.option(
91
+ "--connect", type=Path, default=None, help="Connect to existing kernel via connection file.", show_default=False
92
+ )
72
93
  @click.option(
73
94
  "--restore", "-r", is_flag=True, default=False, help="Restore strategy state from previous run.", show_default=True
74
95
  )
75
96
  @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):
97
+ def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool, textual: bool, textual_dev: bool, textual_web: bool, textual_port: int | None, textual_host: str, kernel_only: bool, connect: Path | None, restore: bool, no_color: bool):
77
98
  """
78
99
  Starts the strategy with the given configuration file. If paper mode is enabled, account is not required.
79
100
 
@@ -84,12 +105,54 @@ def run(config_file: Path, account_file: Path | None, paper: bool, jupyter: bool
84
105
  """
85
106
  from qubx.utils.misc import add_project_to_system_path, logo
86
107
  from qubx.utils.runner.runner import run_strategy_yaml, run_strategy_yaml_in_jupyter
108
+ from qubx.utils.runner.textual import run_strategy_yaml_in_textual
109
+
110
+ # Ensure jupyter and textual are mutually exclusive
111
+ if jupyter and textual:
112
+ click.echo("Error: --jupyter and --textual cannot be used together.", err=True)
113
+ raise click.Abort()
114
+
115
+ # Handle --kernel-only mode
116
+ if kernel_only:
117
+ import asyncio
118
+
119
+ from qubx.utils.runner.kernel_service import KernelService
120
+
121
+ add_project_to_system_path()
122
+ add_project_to_system_path(str(config_file.parent.parent))
123
+ add_project_to_system_path(str(config_file.parent))
124
+
125
+ click.echo("Starting persistent kernel...")
126
+ connection_file = asyncio.run(KernelService.start(config_file, account_file, paper, restore))
127
+ click.echo(click.style("✓ Kernel started successfully!", fg="green", bold=True))
128
+ click.echo(click.style(f"Connection file: {connection_file}", fg="cyan"))
129
+ click.echo()
130
+ click.echo("To connect a UI to this kernel:")
131
+ click.echo(f" qubx run --textual --connect {connection_file}")
132
+ click.echo()
133
+ click.echo("To stop this kernel:")
134
+ click.echo(f" qubx kernel stop {connection_file}")
135
+ click.echo()
136
+ click.echo("Press Ctrl+C to stop the kernel and exit...")
137
+
138
+ # Keep the process alive until interrupted
139
+ try:
140
+ import signal
141
+ signal.pause()
142
+ except KeyboardInterrupt:
143
+ click.echo("\nShutting down kernel...")
144
+ asyncio.run(KernelService.stop(connection_file))
145
+ click.echo("Kernel stopped.")
146
+ return
87
147
 
88
148
  add_project_to_system_path()
89
149
  add_project_to_system_path(str(config_file.parent.parent))
90
150
  add_project_to_system_path(str(config_file.parent))
151
+
91
152
  if jupyter:
92
153
  run_strategy_yaml_in_jupyter(config_file, account_file, paper, restore)
154
+ elif textual:
155
+ run_strategy_yaml_in_textual(config_file, account_file, paper, restore, textual_dev, textual_web, textual_port, textual_host, connect)
93
156
  else:
94
157
  logo()
95
158
  run_strategy_yaml(config_file, account_file, paper=paper, restore=restore, blocking=True, no_color=no_color)
@@ -449,5 +512,69 @@ def init(
449
512
  raise click.Abort()
450
513
 
451
514
 
515
+ @main.group()
516
+ def kernel():
517
+ """
518
+ Manage persistent Jupyter kernels for strategy execution.
519
+
520
+ Kernels can be started independently of the UI, allowing multiple
521
+ UI instances to connect to the same running strategy.
522
+ """
523
+ pass
524
+
525
+
526
+ @kernel.command("list")
527
+ def kernel_list():
528
+ """
529
+ List all active kernel sessions.
530
+
531
+ Shows connection files and associated strategy configurations
532
+ for all currently running kernels.
533
+ """
534
+ from qubx.utils.runner.kernel_service import KernelService
535
+
536
+ active = KernelService.list_active()
537
+
538
+ if not active:
539
+ click.echo("No active kernels found.")
540
+ return
541
+
542
+ import datetime
543
+
544
+ click.echo(click.style("Active Kernels:", fg="cyan", bold=True))
545
+ click.echo()
546
+ for i, kernel_info in enumerate(active, 1):
547
+ # Format timestamp
548
+ ts = datetime.datetime.fromtimestamp(kernel_info["timestamp"])
549
+ time_str = ts.strftime("%Y-%m-%d %H:%M:%S")
550
+
551
+ click.echo(f"{i}. {click.style('Strategy:', fg='yellow')} {kernel_info['strategy_name']}")
552
+ click.echo(f" {click.style('Started:', fg='yellow')} {time_str}")
553
+ click.echo(f" {click.style('Connection:', fg='yellow')} {kernel_info['connection_file']}")
554
+ click.echo()
555
+
556
+
557
+ @kernel.command("stop")
558
+ @click.argument("connection-file", type=Path, required=True)
559
+ def kernel_stop(connection_file: Path):
560
+ """
561
+ Stop a running kernel by its connection file.
562
+
563
+ This will gracefully shutdown the kernel and clean up
564
+ the connection file.
565
+ """
566
+ import asyncio
567
+
568
+ from qubx.utils.runner.kernel_service import KernelService
569
+
570
+ if not connection_file.exists():
571
+ click.echo(click.style(f"✗ Connection file not found: {connection_file}", fg="red"))
572
+ raise click.Abort()
573
+
574
+ click.echo(f"Stopping kernel: {connection_file}")
575
+ asyncio.run(KernelService.stop(str(connection_file)))
576
+ click.echo(click.style("✓ Kernel stopped successfully", fg="green"))
577
+
578
+
452
579
  if __name__ == "__main__":
453
580
  main()
@@ -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
+ )