hypertrader 0.1.4__tar.gz → 0.1.6__tar.gz

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.
Files changed (21) hide show
  1. {hypertrader-0.1.4 → hypertrader-0.1.6}/PKG-INFO +1 -1
  2. {hypertrader-0.1.4 → hypertrader-0.1.6}/hypertrader.egg-info/PKG-INFO +1 -1
  3. {hypertrader-0.1.4 → hypertrader-0.1.6}/hypertrader.py +12 -10
  4. {hypertrader-0.1.4 → hypertrader-0.1.6}/modes/auto_trader.py +15 -5
  5. {hypertrader-0.1.4 → hypertrader-0.1.6}/modes/position_management.py +55 -15
  6. {hypertrader-0.1.4 → hypertrader-0.1.6}/modes/position_watcher.py +2 -2
  7. {hypertrader-0.1.4 → hypertrader-0.1.6}/setup.py +1 -1
  8. {hypertrader-0.1.4 → hypertrader-0.1.6}/utils/helpers.py +274 -14
  9. {hypertrader-0.1.4 → hypertrader-0.1.6}/README.md +0 -0
  10. {hypertrader-0.1.4 → hypertrader-0.1.6}/hypertrader.egg-info/SOURCES.txt +0 -0
  11. {hypertrader-0.1.4 → hypertrader-0.1.6}/hypertrader.egg-info/dependency_links.txt +0 -0
  12. {hypertrader-0.1.4 → hypertrader-0.1.6}/hypertrader.egg-info/entry_points.txt +0 -0
  13. {hypertrader-0.1.4 → hypertrader-0.1.6}/hypertrader.egg-info/requires.txt +0 -0
  14. {hypertrader-0.1.4 → hypertrader-0.1.6}/hypertrader.egg-info/top_level.txt +0 -0
  15. {hypertrader-0.1.4 → hypertrader-0.1.6}/modes/market_maker.py +0 -0
  16. {hypertrader-0.1.4 → hypertrader-0.1.6}/modes/trailing_stop.py +0 -0
  17. {hypertrader-0.1.4 → hypertrader-0.1.6}/setup.cfg +0 -0
  18. {hypertrader-0.1.4 → hypertrader-0.1.6}/utils/constants.py +0 -0
  19. {hypertrader-0.1.4 → hypertrader-0.1.6}/utils/style.py +0 -0
  20. {hypertrader-0.1.4 → hypertrader-0.1.6}/utils/timer.py +0 -0
  21. {hypertrader-0.1.4 → hypertrader-0.1.6}/utils/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypertrader
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Async Hyperliquid trading helper built on the async SDK.
5
5
  Author: darkerego
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypertrader
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Async Hyperliquid trading helper built on the async SDK.
5
5
  Author: darkerego
6
6
  License: MIT
@@ -23,10 +23,9 @@ import os
23
23
  import sys
24
24
  from typing import Any, Dict, List, Optional
25
25
 
26
- import uvloop
26
+
27
27
  from hyperliquid.info import Info
28
28
 
29
- from modes.auto_trader import run_auto_trader
30
29
  from modes.market_maker import run_market_maker
31
30
  from modes.position_management import run_bracket_entry
32
31
  from modes.position_watcher import run_position_watcher
@@ -35,18 +34,16 @@ from utils.constants import DEFAULT_LOG_FILE, INTERVAL_TO_MS
35
34
  from utils.helpers import parse_fractional_pct
36
35
  from utils.style import install_pretty_stdout
37
36
 
38
- try:
39
- import numpy as np
40
- import talib
41
- except Exception: # TA-Lib is only required for the auto command.
42
- np = None # type: ignore[assignment]
43
- talib = None # type: ignore[assignment]
44
-
45
37
  try:
46
38
  os.mkdir("logs")
47
39
  except FileExistsError:
48
40
  pass
49
41
 
42
+ try:
43
+ import uvloop
44
+ except ImportError:
45
+ uvloop = None
46
+
50
47
  decimal.getcontext().prec = 4
51
48
  install_pretty_stdout()
52
49
 
@@ -525,6 +522,8 @@ async def async_main(argv: Optional[List[str]] = None) -> None:
525
522
  return
526
523
 
527
524
  if args.command == "auto":
525
+ from modes.auto_trader import run_auto_trader
526
+
528
527
  if args.size is not None and args.size <= 0.0:
529
528
  print("[ERROR] --size must be > 0.")
530
529
  sys.exit(1)
@@ -744,5 +743,8 @@ def main(argv: Optional[List[str]] = None) -> None:
744
743
 
745
744
 
746
745
  if __name__ == "__main__":
747
- uvloop.install()
746
+ if uvloop:
747
+ uvloop.install()
748
+ else:
749
+ print('[WARN]: Please install uvloop for increased performance. Reverting to standard event loop.')
748
750
  main()
@@ -5,8 +5,6 @@ import logging
5
5
  import math
6
6
  import os
7
7
  import time
8
- import numpy as np
9
- import talib
10
8
  from dataclasses import dataclass
11
9
  from typing import Optional, List, Dict, Any, Tuple
12
10
 
@@ -18,11 +16,18 @@ from utils.constants import AUTO_TRADES_LOG_FILE, INTERVAL_TO_MS, cp
18
16
  from utils.helpers import parse_interval_list, init_clients, _try_float, parse_fractional_pct, \
19
17
  extract_account_balance_from_user_state, round_size_for_hyperliquid, get_user_state_with_retry, get_all_mids, \
20
18
  fetch_recent_candles, compute_position_unrealized_pnl, close_clients, compute_default_stop_loss_pct, \
21
- extract_closed_pnl_from_fill, is_rate_limit_error
19
+ extract_closed_pnl_from_fill, is_rate_limit_error, get_user_fills_since
22
20
 
23
21
  logger = logging.getLogger(__name__)
24
22
  _AUTO_TRADES_LOGGER: Optional[logging.Logger] = None
25
23
 
24
+ try:
25
+ import numpy as np
26
+ import talib
27
+ except Exception: # TA-Lib is only required for the auto command.
28
+ np = None # type: ignore[assignment]
29
+ talib = None # type: ignore[assignment]
30
+
26
31
 
27
32
  def get_auto_trades_logger() -> logging.Logger:
28
33
  """Return the dedicated auto-trade completion logger."""
@@ -825,7 +830,7 @@ async def build_auto_scan_loop_snapshot(
825
830
  account_balance = extract_account_balance_from_user_state(user_state)
826
831
 
827
832
  mids_task = asyncio.create_task(get_all_mids(info))
828
- fills_task = asyncio.create_task(info.user_fills_by_time(account_address, metrics_start_time_ms))
833
+ fills_task = asyncio.create_task(get_user_fills_since(info, account_address, metrics_start_time_ms))
829
834
 
830
835
  mids = await mids_task
831
836
 
@@ -2410,7 +2415,12 @@ async def run_auto_trader(
2410
2415
  launch_specs.append((candidate.coin, candidate.decision, resolved_size))
2411
2416
  reserved_coins.add(candidate.coin)
2412
2417
  if len(launch_specs) >= available_slots:
2413
- continue
2418
+ print(
2419
+ f"[AUTO] Reached max new launches for this scan batch "
2420
+ f"({len(launch_specs)}/{available_slots} available slot(s)); "
2421
+ f"deferring remaining candidates."
2422
+ )
2423
+ break
2414
2424
  except Exception as exc:
2415
2425
  logger.error(exc)
2416
2426
 
@@ -13,7 +13,7 @@ from utils.helpers import init_clients, get_position_for_coin, \
13
13
  position_is_directional_add, \
14
14
  fmt_optional_float, format_account_metrics, is_rate_limit_error, \
15
15
  round_size_for_hyperliquid, get_position_size_for_coin, AccountRuntimeMetrics, get_best_bid_ask, \
16
- round_price_for_hyperliquid, extract_order_error, close_clients, fetch_recent_candles
16
+ round_price_for_hyperliquid, extract_order_error, close_clients, fetch_recent_candles, get_ws_cache
17
17
 
18
18
  try:
19
19
  import numpy as np
@@ -57,10 +57,17 @@ async def get_frontend_open_orders_for_coin(
57
57
  info: Info,
58
58
  account_address: str,
59
59
  coin: str,
60
+ *,
61
+ force_http: bool = False,
60
62
  ) -> List[Dict[str, Any]]:
61
63
  """Return frontend open orders for a coin, including reduceOnly/isTrigger fields."""
62
64
  try:
63
- orders = await info.frontend_open_orders(account_address)
65
+ cache = get_ws_cache(info)
66
+ if cache is not None and not force_http:
67
+ cached_orders = await cache.get_frontend_open_orders(force_refresh=False)
68
+ orders = cached_orders if cached_orders is not None else await info.frontend_open_orders(account_address)
69
+ else:
70
+ orders = await info.frontend_open_orders(account_address)
64
71
  except Exception:
65
72
  logging.getLogger("hypertrader").exception(
66
73
  "[WARN] Failed to fetch frontend open orders for %s.",
@@ -308,7 +315,7 @@ async def cancel_entry_orders_for_coin(
308
315
  order snapshot and removes any leftover non-reduce-only orders for the same
309
316
  coin/side. Reduce-only TP orders are intentionally left alone.
310
317
  """
311
- orders = await get_frontend_open_orders_for_coin(info, account_address, coin)
318
+ orders = await get_frontend_open_orders_for_coin(info, account_address, coin, force_http=True)
312
319
  oids: List[int] = []
313
320
  for order in orders:
314
321
  try:
@@ -327,7 +334,7 @@ async def cancel_entry_orders_for_coin(
327
334
  # limit orders, so anything found here before market fallback is treated as
328
335
  # an entry-order candidate.
329
336
  if not oids:
330
- fallback_orders = await get_open_orders_for_coin(info, account_address, coin)
337
+ fallback_orders = await get_open_orders_for_coin(info, account_address, coin, force_http=True)
331
338
  for order in fallback_orders:
332
339
  try:
333
340
  if is_buy is not None and not order_side_matches(order, is_buy):
@@ -395,7 +402,7 @@ async def cancel_reduce_only_orders_for_coin(
395
402
  only_tpsl: bool = False,
396
403
  ) -> None:
397
404
  """Cancel reduce-only orders for a coin, optionally limiting to TP/SL trigger orders."""
398
- orders = await get_frontend_open_orders_for_coin(info, account_address, coin)
405
+ orders = await get_frontend_open_orders_for_coin(info, account_address, coin, force_http=True)
399
406
  oids: List[int] = []
400
407
  for order in orders:
401
408
  try:
@@ -418,7 +425,7 @@ async def get_open_reduce_only_take_profit_orders_for_coin(
418
425
  coin: str,
419
426
  ) -> List[Dict[str, Any]]:
420
427
  """Return open reduce-only non-trigger TP limit orders for a coin."""
421
- orders = await get_frontend_open_orders_for_coin(info, account_address, coin)
428
+ orders = await get_frontend_open_orders_for_coin(info, account_address, coin, force_http=False)
422
429
  tp_orders: List[Dict[str, Any]] = []
423
430
  for order in orders:
424
431
  try:
@@ -532,7 +539,7 @@ async def exit_on_tp_reversal(
532
539
  limit_oids = extract_resting_oids(limit_resp)
533
540
 
534
541
  await asyncio.sleep(min(max(poll_interval * 0.25, 0.1), 0.5))
535
- live_pos = await get_position_for_coin(info, account_address, coin)
542
+ live_pos = await get_position_for_coin(info, account_address, coin, force_http=True)
536
543
  if live_pos is None:
537
544
  print(f"[TP-REVERSAL EXIT] {coin} fully closed on the limit order.")
538
545
  return True
@@ -568,7 +575,7 @@ async def exit_on_tp_reversal(
568
575
  deadline = time.time() + wait_seconds
569
576
  while time.time() < deadline:
570
577
  await asyncio.sleep(min(max(poll_interval, 0.2), 1.0))
571
- live_pos = await get_position_for_coin(info, account_address, coin)
578
+ live_pos = await get_position_for_coin(info, account_address, coin, force_http=True)
572
579
  if live_pos is None:
573
580
  print(f"[TP-REVERSAL EXIT] {coin} flat after paired limit/stop exit.")
574
581
  return True
@@ -587,7 +594,7 @@ async def exit_on_tp_reversal(
587
594
  if stop_oid is not None:
588
595
  await cancel_oids(exchange, coin, [stop_oid], "tp reversal stop orders")
589
596
 
590
- live_pos = await get_position_for_coin(info, account_address, coin)
597
+ live_pos = await get_position_for_coin(info, account_address, coin, force_http=True)
591
598
  if live_pos is None:
592
599
  print(f"[TP-REVERSAL EXIT] {coin} flat after canceling remaining exit orders.")
593
600
  return True
@@ -621,7 +628,7 @@ async def close_position_remainder_with_market_retries(
621
628
  await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
622
629
  await asyncio.sleep(settle_before_close)
623
630
 
624
- remainder_pos = await get_position_for_coin(info, account_address, coin)
631
+ remainder_pos = await get_position_for_coin(info, account_address, coin, force_http=True)
625
632
  if remainder_pos is None:
626
633
  print(f"[{label}] {coin} is already flat after cleanup.")
627
634
  return True
@@ -651,7 +658,7 @@ async def close_position_remainder_with_market_retries(
651
658
  continue
652
659
 
653
660
  await asyncio.sleep(settle_after_close)
654
- final_remainder_pos = await get_position_for_coin(info, account_address, coin)
661
+ final_remainder_pos = await get_position_for_coin(info, account_address, coin, force_http=True)
655
662
  if final_remainder_pos is None:
656
663
  print(f"[{label}] {coin} fully closed after residual market exit.")
657
664
  return True
@@ -763,7 +770,7 @@ async def enter_position_with_reposting_limit(
763
770
  raise RuntimeError("entry_tif must be Alo or Gtc.")
764
771
 
765
772
  size = await round_size_for_hyperliquid(info, coin, size)
766
- initial_pos = await get_position_size_for_coin(info, account_address, coin)
773
+ initial_pos = await get_position_size_for_coin(info, account_address, coin, force_http=True)
767
774
  direction_label = "LONG" if is_buy else "SHORT"
768
775
 
769
776
  if initial_pos != 0.0:
@@ -789,11 +796,11 @@ async def enter_position_with_reposting_limit(
789
796
 
790
797
  async def get_entry_progress() -> Tuple[float, float, float, str, AccountRuntimeMetrics]:
791
798
  """Return current_pos, filled_delta, remaining_size, formatted uPnL, account metrics."""
792
- _current_pos = await get_position_size_for_coin(info, account_address, coin)
799
+ _current_pos = await get_position_size_for_coin(info, account_address, coin, force_http=True)
793
800
  filled_delta = signed_position_delta(initial_pos, _current_pos, is_buy)
794
801
  remaining_size = max(0.0, size - filled_delta)
795
802
 
796
- pos_snapshot = await get_position_for_coin(info, account_address, coin)
803
+ pos_snapshot = await get_position_for_coin(info, account_address, coin, force_http=True)
797
804
  mids = await get_all_mids(info)
798
805
  mid_price_raw = mids.get(coin)
799
806
  _upnl_str = "N/A"
@@ -872,6 +879,7 @@ async def enter_position_with_reposting_limit(
872
879
  f"[ENTRY {attempt}/{retries}] modifying oid={active_oid} -> "
873
880
  f"{side} {remaining:.8f} {coin} @ {limit_px:.8f} ({entry_tif}, top-of-book)"
874
881
  )
882
+ prev_active_oid = active_oid
875
883
  try:
876
884
  resp = await exchange.modify_order(
877
885
  active_oid,
@@ -899,6 +907,24 @@ async def enter_position_with_reposting_limit(
899
907
  await asyncio.sleep(repost_interval)
900
908
  continue
901
909
 
910
+ resting_oids = extract_resting_oids(resp)
911
+ if resting_oids:
912
+ new_oid = int(resting_oids[0])
913
+ if new_oid != active_oid:
914
+ print(f"[ENTRY {attempt}] active entry oid updated {active_oid} -> {new_oid}")
915
+ active_oid = new_oid
916
+ else:
917
+ filled_now = extract_filled_qty(resp)
918
+ if filled_now > 0.0:
919
+ print(f"[ENTRY {attempt}] modify filled {filled_now:.8f}; clearing active oid.")
920
+ active_oid = None
921
+ else:
922
+ print(
923
+ f"[ENTRY {attempt}] modify returned no resting oid or fill for prior oid={prev_active_oid}; "
924
+ "will re-check position before another modify."
925
+ )
926
+ active_oid = None
927
+
902
928
  await asyncio.sleep(repost_interval)
903
929
 
904
930
  current_pos, filled, remaining, upnl_str, metrics = await get_entry_progress()
@@ -965,7 +991,7 @@ async def enter_position_with_reposting_limit(
965
991
  )
966
992
 
967
993
  await asyncio.sleep(max(0.25, repost_interval))
968
- final_pos = await get_position_for_coin(info, account_address, coin)
994
+ final_pos = await get_position_for_coin(info, account_address, coin, force_http=True)
969
995
  if final_pos is None:
970
996
  raise RuntimeError(f"No open {coin} position found after entry attempts.")
971
997
 
@@ -1213,6 +1239,8 @@ async def monitor_bracket_position(
1213
1239
  favorable_extreme: Optional[float] = None
1214
1240
  tp_zone_seen = False
1215
1241
  tp_remainder_close_in_progress = False
1242
+ tp_remainder_baseline_abs = abs(managed_signed_size)
1243
+ tp_remainder_fill_confirmed = False
1216
1244
 
1217
1245
  try:
1218
1246
  while True:
@@ -1284,6 +1312,8 @@ async def monitor_bracket_position(
1284
1312
  hidden_tp_oids.clear()
1285
1313
  tp_level_to_oid = build_tp_level_to_oid_map(tp_orders, tp_oids)
1286
1314
  tp_remainder_close_in_progress = False
1315
+ tp_remainder_baseline_abs = live_pos_abs
1316
+ tp_remainder_fill_confirmed = False
1287
1317
  if hide_orders:
1288
1318
  local_stop_px, stop_source = resolve_stop_loss_trigger_px(
1289
1319
  side,
@@ -1295,6 +1325,9 @@ async def monitor_bracket_position(
1295
1325
  managed_signed_size = live_signed_size
1296
1326
  entry_px = live_entry_px
1297
1327
 
1328
+ if live_pos_abs + PRICE_EPS < tp_remainder_baseline_abs:
1329
+ tp_remainder_fill_confirmed = True
1330
+
1298
1331
  if dynamic_auto_sar_stop_enabled:
1299
1332
  try:
1300
1333
  updated_sar_stop_px, latest_sar_px, latest_sar_close_px, sar_flip_exit = await compute_auto_sar_stop_state(
@@ -1495,6 +1528,13 @@ async def monitor_bracket_position(
1495
1528
  tp_levels_exhausted = len(open_tp_orders) == 0 and not trailing_tp_armed
1496
1529
 
1497
1530
  if tp_levels_exhausted and not tp_remainder_close_in_progress:
1531
+ if not tp_remainder_fill_confirmed:
1532
+ print(
1533
+ f"[TP-REMAINDER-WARN] {coin} TP ladder is absent but no TP fill was confirmed "
1534
+ f"(live_abs={live_pos_abs:.8f}, baseline_abs={tp_remainder_baseline_abs:.8f}). "
1535
+ "Leaving the position open."
1536
+ )
1537
+ continue
1498
1538
  tp_remainder_close_in_progress = True
1499
1539
  print(
1500
1540
  f"[TP-REMAINDER] {coin} take-profit ladder is exhausted; "
@@ -6,11 +6,11 @@ from typing import Optional, List, Dict, Any
6
6
  from hyperliquid.exchange import Exchange
7
7
  from hyperliquid.info import Info
8
8
 
9
- from modes.auto_trader import compute_default_stop_loss_pct
10
9
  from modes.position_management import monitor_bracket_position, rebuild_bracket_orders
11
10
  from utils.constants import WATCH_RETRY_SLEEP_SECONDS
12
11
  from utils.helpers import init_clients, get_all_open_positions, get_all_mids, compute_position_unrealized_pnl, \
13
- get_account_runtime_metrics, format_account_metrics, is_rate_limit_error, close_clients
12
+ get_account_runtime_metrics, format_account_metrics, is_rate_limit_error, close_clients, \
13
+ compute_default_stop_loss_pct
14
14
 
15
15
 
16
16
  async def attach_bracket_to_existing_position(
@@ -9,7 +9,7 @@ README = ROOT / "README.md"
9
9
 
10
10
  setup(
11
11
  name="hypertrader",
12
- version="0.1.4",
12
+ version="0.1.6",
13
13
  description="Async Hyperliquid trading helper built on the async SDK.",
14
14
  long_description=README.read_text(encoding="utf-8"),
15
15
  long_description_content_type="text/markdown",
@@ -7,8 +7,6 @@ from dataclasses import dataclass
7
7
  from typing import List, Any, Tuple, Optional, Dict, Awaitable
8
8
 
9
9
  import eth_account
10
- import numpy as np
11
- import talib
12
10
  from dotenv import load_dotenv
13
11
  from eth_account.signers.local import LocalAccount
14
12
  from hyperliquid.exchange import Exchange
@@ -69,11 +67,13 @@ def parse_fractional_pct(value: Any, *, field_name: str) -> float:
69
67
  # Credentials / clients
70
68
  # ---------------------------------------------------------------------------
71
69
 
72
- def load_credentials() -> Tuple[str, str]:
70
+ def load_credentials(testnet: bool =False) -> Tuple[str, str]:
73
71
  """Load Hyperliquid credentials from environment variables."""
74
72
  load_dotenv()
75
-
76
- secret_key = os.getenv("HYPERLIQUID_SECRET_KEY")
73
+ if testnet:
74
+ secret_key = os.getenv("HYPERLIQUID_TESTNET_SECRET_KEY")
75
+ else:
76
+ secret_key = os.getenv("HYPERLIQUID_SECRET_KEY")
77
77
  account_address = os.getenv("HYPERLIQUID_ACCOUNT_ADDRESS")
78
78
 
79
79
  if not secret_key:
@@ -102,8 +102,9 @@ class HyperliquidWebsocketCache:
102
102
  * userEvents -> cached for diagnostics / future event-driven position refresh
103
103
  * orderUpdates -> cached for diagnostics / future order-state refresh
104
104
 
105
- Position snapshots still use user_state over HTTP because the bracket logic needs
106
- authoritative size and average-entry data before rebuilding TP/SL orders.
105
+ There is no native websocket subscription for clearinghouseState or openOrders in
106
+ the async SDK. For those, this class keeps REST-backed snapshots that are marked
107
+ dirty by userEvents / userFills / orderUpdates and refreshed on demand.
107
108
  """
108
109
 
109
110
  def __init__(
@@ -131,9 +132,36 @@ class HyperliquidWebsocketCache:
131
132
  self._bbo_sub_ids: Dict[str, int] = {}
132
133
 
133
134
  self._user_events_sub_id: Optional[int] = None
135
+ self._user_fills_sub_id: Optional[int] = None
134
136
  self._order_updates_sub_id: Optional[int] = None
135
137
  self.last_user_event: Optional[Dict[str, Any]] = None
138
+ self.last_user_fills: Optional[Dict[str, Any]] = None
136
139
  self.last_order_update: Optional[Dict[str, Any]] = None
140
+
141
+ self._user_state: Optional[Dict[str, Any]] = None
142
+ self._user_state_updated_at = 0.0
143
+ self._user_state_ready = asyncio.Event()
144
+ self._user_state_lock = asyncio.Lock()
145
+ self._user_state_dirty = True
146
+
147
+ self._open_orders: List[Dict[str, Any]] = []
148
+ self._open_orders_updated_at = 0.0
149
+ self._open_orders_ready = asyncio.Event()
150
+ self._open_orders_lock = asyncio.Lock()
151
+ self._open_orders_dirty = True
152
+
153
+ self._frontend_open_orders: List[Dict[str, Any]] = []
154
+ self._frontend_open_orders_updated_at = 0.0
155
+ self._frontend_open_orders_ready = asyncio.Event()
156
+ self._frontend_open_orders_lock = asyncio.Lock()
157
+ self._frontend_open_orders_dirty = True
158
+
159
+ self._user_fills: List[Dict[str, Any]] = []
160
+ self._user_fill_keys: set[Tuple[Any, ...]] = set()
161
+ self._user_fills_updated_at = 0.0
162
+ self._user_fills_ready = asyncio.Event()
163
+ self._user_fills_lock = asyncio.Lock()
164
+ self._user_fills_seed_start_ms: Optional[int] = None
137
165
  self._candle_buffers: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
138
166
  self._candle_requested_periods: Dict[Tuple[str, str], int] = {}
139
167
  self._candle_updated_at: Dict[Tuple[str, str], float] = {}
@@ -224,6 +252,8 @@ class HyperliquidWebsocketCache:
224
252
  for sub, attr_name, label, callback in (
225
253
  ({"type": "userEvents", "user": self.account_address}, "_user_events_sub_id", "userEvents",
226
254
  self._on_user_event),
255
+ ({"type": "userFills", "user": self.account_address}, "_user_fills_sub_id", "userFills",
256
+ self._on_user_fills),
227
257
  ({"type": "orderUpdates", "user": self.account_address}, "_order_updates_sub_id", "orderUpdates",
228
258
  self._on_order_update),
229
259
  ):
@@ -242,6 +272,7 @@ class HyperliquidWebsocketCache:
242
272
  unsubscribe_specs: List[Tuple[Dict[str, Any], Optional[int], str]] = [
243
273
  ({"type": "allMids"}, self._mids_sub_id, "allMids"),
244
274
  ({"type": "userEvents", "user": self.account_address}, self._user_events_sub_id, "userEvents"),
275
+ ({"type": "userFills", "user": self.account_address}, self._user_fills_sub_id, "userFills"),
245
276
  ({"type": "orderUpdates", "user": self.account_address}, self._order_updates_sub_id, "orderUpdates"),
246
277
  ]
247
278
  for coin, sub_id in list(self._bbo_sub_ids.items()):
@@ -306,9 +337,30 @@ class HyperliquidWebsocketCache:
306
337
 
307
338
  def _on_user_event(self, msg: Dict[str, Any]) -> None:
308
339
  self.last_user_event = msg
340
+ data = msg.get("data", {}) if isinstance(msg, dict) else {}
341
+ if isinstance(data, dict):
342
+ fills = data.get("fills")
343
+ if isinstance(fills, list) and fills:
344
+ self._merge_user_fills(fills, replace=False)
345
+ self._user_state_dirty = True
346
+ self._open_orders_dirty = True
347
+ self._frontend_open_orders_dirty = True
348
+
349
+ def _on_user_fills(self, msg: Dict[str, Any]) -> None:
350
+ self.last_user_fills = msg
351
+ data = msg.get("data", {}) if isinstance(msg, dict) else {}
352
+ if not isinstance(data, dict):
353
+ return
354
+ fills = data.get("fills")
355
+ if isinstance(fills, list):
356
+ self._merge_user_fills(fills, replace=bool(data.get("isSnapshot")))
357
+ self._user_state_dirty = True
309
358
 
310
359
  def _on_order_update(self, msg: Dict[str, Any]) -> None:
311
360
  self.last_order_update = msg
361
+ self._open_orders_dirty = True
362
+ self._frontend_open_orders_dirty = True
363
+ self._user_state_dirty = True
312
364
 
313
365
  def _on_candle(self, requested_coin: str, requested_interval: str, msg: Dict[str, Any]) -> None:
314
366
  data = msg.get("data", {}) if isinstance(msg, dict) else {}
@@ -423,6 +475,157 @@ class HyperliquidWebsocketCache:
423
475
  return None
424
476
  return [dict(candle) for candle in candles[-periods:]]
425
477
 
478
+ @staticmethod
479
+ def _extract_fill_time_ms(fill: Dict[str, Any]) -> int:
480
+ for key in ("time", "timestamp"):
481
+ try:
482
+ value = fill.get(key)
483
+ if value is not None:
484
+ return int(float(value))
485
+ except (AttributeError, TypeError, ValueError):
486
+ continue
487
+ return 0
488
+
489
+ @classmethod
490
+ def _fill_cache_key(cls, fill: Dict[str, Any]) -> Tuple[Any, ...]:
491
+ return (
492
+ fill.get("tid"),
493
+ fill.get("hash"),
494
+ fill.get("oid"),
495
+ fill.get("coin"),
496
+ fill.get("px"),
497
+ fill.get("sz"),
498
+ cls._extract_fill_time_ms(fill),
499
+ )
500
+
501
+ def _merge_user_fills(self, fills: List[Dict[str, Any]], replace: bool) -> None:
502
+ if replace:
503
+ self._user_fills = []
504
+ self._user_fill_keys.clear()
505
+
506
+ changed = False
507
+ for fill in fills:
508
+ if not isinstance(fill, dict):
509
+ continue
510
+ fill_copy = dict(fill)
511
+ cache_key = self._fill_cache_key(fill_copy)
512
+ if cache_key in self._user_fill_keys:
513
+ continue
514
+ self._user_fill_keys.add(cache_key)
515
+ self._user_fills.append(fill_copy)
516
+ changed = True
517
+
518
+ if changed or replace:
519
+ self._user_fills.sort(key=self._extract_fill_time_ms)
520
+ self._user_fills_updated_at = time.monotonic()
521
+ self._user_fills_ready.set()
522
+
523
+ async def get_user_state(self, *, force_refresh: bool = False) -> Optional[Dict[str, Any]]:
524
+ if not self.enabled:
525
+ return None
526
+ if (
527
+ not force_refresh
528
+ and not self._user_state_dirty
529
+ and self._is_fresh(self._user_state_updated_at, self.max_age_seconds)
530
+ and self._user_state is not None
531
+ ):
532
+ return dict(self._user_state)
533
+
534
+ async with self._user_state_lock:
535
+ if (
536
+ not force_refresh
537
+ and not self._user_state_dirty
538
+ and self._is_fresh(self._user_state_updated_at, self.max_age_seconds)
539
+ and self._user_state is not None
540
+ ):
541
+ return dict(self._user_state)
542
+
543
+ user_state = await self.info.user_state(self.account_address)
544
+ if not isinstance(user_state, dict):
545
+ return None
546
+ self._user_state = dict(user_state)
547
+ self._user_state_updated_at = time.monotonic()
548
+ self._user_state_dirty = False
549
+ self._user_state_ready.set()
550
+ return dict(self._user_state)
551
+
552
+ async def get_open_orders(self, *, force_refresh: bool = False) -> Optional[List[Dict[str, Any]]]:
553
+ if not self.enabled:
554
+ return None
555
+ if (
556
+ not force_refresh
557
+ and not self._open_orders_dirty
558
+ and self._is_fresh(self._open_orders_updated_at, self.max_age_seconds)
559
+ ):
560
+ return [dict(order) for order in self._open_orders]
561
+
562
+ async with self._open_orders_lock:
563
+ if (
564
+ not force_refresh
565
+ and not self._open_orders_dirty
566
+ and self._is_fresh(self._open_orders_updated_at, self.max_age_seconds)
567
+ ):
568
+ return [dict(order) for order in self._open_orders]
569
+
570
+ open_orders = await self.info.open_orders(self.account_address)
571
+ if not isinstance(open_orders, list):
572
+ return None
573
+ self._open_orders = [dict(order) for order in open_orders if isinstance(order, dict)]
574
+ self._open_orders_updated_at = time.monotonic()
575
+ self._open_orders_dirty = False
576
+ self._open_orders_ready.set()
577
+ return [dict(order) for order in self._open_orders]
578
+
579
+ async def get_frontend_open_orders(self, *, force_refresh: bool = False) -> Optional[List[Dict[str, Any]]]:
580
+ if not self.enabled:
581
+ return None
582
+ if (
583
+ not force_refresh
584
+ and not self._frontend_open_orders_dirty
585
+ and self._is_fresh(self._frontend_open_orders_updated_at, self.max_age_seconds)
586
+ ):
587
+ return [dict(order) for order in self._frontend_open_orders]
588
+
589
+ async with self._frontend_open_orders_lock:
590
+ if (
591
+ not force_refresh
592
+ and not self._frontend_open_orders_dirty
593
+ and self._is_fresh(self._frontend_open_orders_updated_at, self.max_age_seconds)
594
+ ):
595
+ return [dict(order) for order in self._frontend_open_orders]
596
+
597
+ frontend_orders = await self.info.frontend_open_orders(self.account_address)
598
+ if not isinstance(frontend_orders, list):
599
+ return None
600
+ self._frontend_open_orders = [dict(order) for order in frontend_orders if isinstance(order, dict)]
601
+ self._frontend_open_orders_updated_at = time.monotonic()
602
+ self._frontend_open_orders_dirty = False
603
+ self._frontend_open_orders_ready.set()
604
+ return [dict(order) for order in self._frontend_open_orders]
605
+
606
+ async def get_user_fills_since(self, start_time_ms: int) -> Optional[List[Dict[str, Any]]]:
607
+ if not self.enabled:
608
+ return None
609
+
610
+ async with self._user_fills_lock:
611
+ need_seed = (
612
+ self._user_fills_seed_start_ms is None
613
+ or start_time_ms < self._user_fills_seed_start_ms
614
+ or not self._user_fills_ready.is_set()
615
+ )
616
+ if need_seed:
617
+ fills = await self.info.user_fills_by_time(self.account_address, start_time_ms)
618
+ if not isinstance(fills, list):
619
+ return None
620
+ self._user_fills_seed_start_ms = start_time_ms
621
+ self._merge_user_fills([dict(fill) for fill in fills if isinstance(fill, dict)], replace=True)
622
+
623
+ return [
624
+ dict(fill)
625
+ for fill in self._user_fills
626
+ if self._extract_fill_time_ms(fill) >= start_time_ms
627
+ ]
628
+
426
629
 
427
630
  def get_ws_cache(info: Info) -> Optional[HyperliquidWebsocketCache]:
428
631
  cache = getattr(info, "ws_cache", None)
@@ -433,7 +636,7 @@ def get_ws_cache(info: Info) -> Optional[HyperliquidWebsocketCache]:
433
636
 
434
637
  async def init_clients(use_testnet: bool, use_websocket: bool = True) -> Tuple[str, Info, Exchange]:
435
638
  """Initialize async Info and Exchange clients for Hyperliquid."""
436
- secret_key, account_address = load_credentials()
639
+ secret_key, account_address = load_credentials(use_testnet)
437
640
  api_url = constants.TESTNET_API_URL if use_testnet else constants.MAINNET_API_URL
438
641
  account: LocalAccount = eth_account.Account.from_key(secret_key)
439
642
 
@@ -585,10 +788,22 @@ async def get_best_bid_ask(info: Info, coin: str) -> Tuple[Optional[float], Opti
585
788
  return best_bid, best_ask
586
789
 
587
790
 
588
- async def get_open_orders_for_coin(info: Info, account_address: str, coin: str) -> List[Dict[str, Any]]:
791
+ async def get_open_orders_for_coin(
792
+ info: Info,
793
+ account_address: str,
794
+ coin: str,
795
+ *,
796
+ force_http: bool = False,
797
+ ) -> List[Dict[str, Any]]:
589
798
  """Return open orders for a specific coin."""
590
799
  try:
591
- open_orders = await info.open_orders(account_address)
800
+ open_orders: Any
801
+ cache = get_ws_cache(info)
802
+ if cache is not None and not force_http:
803
+ cached_orders = await cache.get_open_orders(force_refresh=False)
804
+ open_orders = cached_orders if cached_orders is not None else await info.open_orders(account_address)
805
+ else:
806
+ open_orders = await info.open_orders(account_address)
592
807
  except Exception:
593
808
  logging.getLogger("hypertrader").exception(
594
809
  "[WARN] Failed to fetch open orders for %s.",
@@ -627,6 +842,7 @@ async def get_user_state_with_retry(
627
842
  context_label: str,
628
843
  coin: Optional[str] = None,
629
844
  retry_sleep: float = WATCH_RETRY_SLEEP_SECONDS,
845
+ force_http: bool = False,
630
846
  ) -> Dict[str, Any]:
631
847
  """Fetch user_state and keep retrying on Hyperliquid rate limits."""
632
848
  coin_label = coin.upper() if coin else "ALL"
@@ -634,7 +850,12 @@ async def get_user_state_with_retry(
634
850
  while True:
635
851
  attempt += 1
636
852
  try:
637
- user_state = await info.user_state(account_address)
853
+ cache = get_ws_cache(info)
854
+ if cache is not None and not force_http:
855
+ cached_state = await cache.get_user_state(force_refresh=False)
856
+ user_state = cached_state if cached_state is not None else await info.user_state(account_address)
857
+ else:
858
+ user_state = await info.user_state(account_address)
638
859
  except asyncio.CancelledError:
639
860
  raise
640
861
  except Exception as exc:
@@ -656,12 +877,18 @@ async def get_user_state_with_retry(
656
877
  # Position helpers
657
878
  # ---------------------------------------------------------------------------
658
879
 
659
- async def get_all_open_positions(info: Info, account_address: str) -> List[Dict[str, Any]]:
880
+ async def get_all_open_positions(
881
+ info: Info,
882
+ account_address: str,
883
+ *,
884
+ force_http: bool = False,
885
+ ) -> List[Dict[str, Any]]:
660
886
  """Return a list of open perp positions for the account."""
661
887
  user_state = await get_user_state_with_retry(
662
888
  info,
663
889
  account_address,
664
890
  context_label="get_all_open_positions",
891
+ force_http=force_http,
665
892
  )
666
893
  asset_positions = user_state.get("assetPositions", [])
667
894
  open_positions: List[Dict[str, Any]] = []
@@ -678,13 +905,20 @@ async def get_all_open_positions(info: Info, account_address: str) -> List[Dict[
678
905
  return open_positions
679
906
 
680
907
 
681
- async def get_position_size_for_coin(info: Info, account_address: str, coin: str) -> float:
908
+ async def get_position_size_for_coin(
909
+ info: Info,
910
+ account_address: str,
911
+ coin: str,
912
+ *,
913
+ force_http: bool = False,
914
+ ) -> float:
682
915
  """Return signed position size for a coin, or 0.0 if flat/not found."""
683
916
  user_state = await get_user_state_with_retry(
684
917
  info,
685
918
  account_address,
686
919
  context_label="get_position_size_for_coin",
687
920
  coin=coin,
921
+ force_http=force_http,
688
922
  )
689
923
  asset_positions = user_state.get("assetPositions", [])
690
924
  for asset_pos in asset_positions:
@@ -701,6 +935,8 @@ async def get_position_for_coin(
701
935
  info: Info,
702
936
  account_address: str,
703
937
  coin: str,
938
+ *,
939
+ force_http: bool = False,
704
940
  ) -> Optional[Dict[str, Any]]:
705
941
  """Return full position dict for a coin if the account has a non-zero position."""
706
942
  user_state = await get_user_state_with_retry(
@@ -708,6 +944,7 @@ async def get_position_for_coin(
708
944
  account_address,
709
945
  context_label="get_position_for_coin",
710
946
  coin=coin,
947
+ force_http=force_http,
711
948
  )
712
949
  for asset_pos in user_state.get("assetPositions", []):
713
950
  pos = asset_pos.get("position", {})
@@ -859,7 +1096,7 @@ async def get_realized_pnl_since(
859
1096
  return None
860
1097
 
861
1098
  try:
862
- fills = await info.user_fills_by_time(account_address, start_time_ms)
1099
+ fills = await get_user_fills_since(info, account_address, start_time_ms)
863
1100
  except Exception:
864
1101
  logging.getLogger("hypertrader").exception(
865
1102
  "[METRICS] Failed to fetch user fills since %s for coin=%s.",
@@ -886,6 +1123,29 @@ async def get_realized_pnl_since(
886
1123
  found = True
887
1124
  return total if found or fills else 0.0
888
1125
 
1126
+
1127
+ async def get_user_fills_since(
1128
+ info: Info,
1129
+ account_address: str,
1130
+ start_time_ms: int,
1131
+ *,
1132
+ end_time_ms: Optional[int] = None,
1133
+ aggregate_by_time: bool = False,
1134
+ force_http: bool = False,
1135
+ ) -> Any:
1136
+ """Return fills since start_time_ms, using the websocket-backed cache when safe."""
1137
+ cache = get_ws_cache(info)
1138
+ if cache is not None and not force_http and end_time_ms is None and not aggregate_by_time:
1139
+ cached_fills = await cache.get_user_fills_since(start_time_ms)
1140
+ if cached_fills is not None:
1141
+ return cached_fills
1142
+ return await info.user_fills_by_time(
1143
+ account_address,
1144
+ start_time_ms,
1145
+ end_time_ms,
1146
+ aggregate_by_time=aggregate_by_time,
1147
+ )
1148
+
889
1149
  def extract_account_balance_from_user_state(user_state: Dict[str, Any]) -> Optional[float]:
890
1150
  """Extract current Hyperliquid account value from a clearinghouseState response."""
891
1151
  candidate_paths = (
File without changes
File without changes
File without changes
File without changes
File without changes