hypertrader 0.1.0__py3-none-any.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.
utils/helpers.py ADDED
@@ -0,0 +1,988 @@
1
+ import asyncio
2
+ import decimal
3
+ import logging
4
+ import os
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import List, Any, Tuple, Optional, Dict, Awaitable
8
+
9
+ import eth_account
10
+ import numpy as np
11
+ import talib
12
+ from dotenv import load_dotenv
13
+ from eth_account.signers.local import LocalAccount
14
+ from hyperliquid.exchange import Exchange
15
+ from hyperliquid.info import Info
16
+ from hyperliquid.utils import constants
17
+
18
+ from utils.constants import INTERVAL_TO_MS, WATCH_RETRY_SLEEP_SECONDS
19
+ from utils.worker import AsyncTaskQueue
20
+
21
+ decimal.getcontext().prec = 4
22
+
23
+
24
+
25
+
26
+
27
+ def parse_interval_list(intervals_value: str) -> List[str]:
28
+ """Parse comma/space separated candle intervals."""
29
+ intervals = [item.strip() for item in intervals_value.replace(",", " ").split() if item.strip()]
30
+ if not intervals:
31
+ raise RuntimeError("At least one interval must be specified.")
32
+ unknown = [interval for interval in intervals if interval not in INTERVAL_TO_MS]
33
+ if unknown:
34
+ raise RuntimeError(f"Unsupported interval(s) {unknown}. Valid: {sorted(INTERVAL_TO_MS.keys())}")
35
+ return intervals
36
+
37
+
38
+ def parse_fractional_pct(value: Any, *, field_name: str) -> float:
39
+ """Accept 10, 10%, and 0.10 style inputs and normalize to a fraction."""
40
+ if value is None:
41
+ raise RuntimeError(f"{field_name} is required.")
42
+
43
+ raw = str(value).strip()
44
+ if not raw:
45
+ raise RuntimeError(f"{field_name} cannot be empty.")
46
+
47
+ had_percent = raw.endswith("%")
48
+ if had_percent:
49
+ raw = raw[:-1].strip()
50
+
51
+ try:
52
+ parsed = decimal.Decimal(raw)
53
+ except decimal.InvalidOperation as exc:
54
+ raise RuntimeError(f"{field_name} must be a number or percent string. Got: {value!r}") from exc
55
+
56
+ if parsed <= 0:
57
+ raise RuntimeError(f"{field_name} must be > 0.")
58
+
59
+ if had_percent or parsed > decimal.Decimal("1"):
60
+ parsed = parsed / decimal.Decimal("100")
61
+
62
+ if parsed <= 0 or parsed > 1:
63
+ raise RuntimeError(f"{field_name} must resolve to a fraction between 0 and 1.")
64
+
65
+ return float(parsed)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Credentials / clients
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def load_credentials() -> Tuple[str, str]:
73
+ """Load Hyperliquid credentials from environment variables."""
74
+ load_dotenv()
75
+
76
+ secret_key = os.getenv("HYPERLIQUID_SECRET_KEY")
77
+ account_address = os.getenv("HYPERLIQUID_ACCOUNT_ADDRESS")
78
+
79
+ if not secret_key:
80
+ raise RuntimeError(
81
+ "HYPERLIQUID_SECRET_KEY is not set. Add it to your environment or .env file."
82
+ )
83
+ if not account_address:
84
+ raise RuntimeError(
85
+ "HYPERLIQUID_ACCOUNT_ADDRESS is not set. Add it to your environment or .env file."
86
+ )
87
+
88
+ return secret_key, account_address
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Websocket market-data cache
93
+ # ---------------------------------------------------------------------------
94
+
95
+ class HyperliquidWebsocketCache:
96
+ """Small websocket-backed cache with HTTP fallback at call sites.
97
+
98
+ The async SDK starts the WebsocketManager when Info is created with
99
+ skip_ws=False. This cache subscribes to:
100
+ * allMids -> fast mid-price reads for monitors/trailing logic
101
+ * bbo per coin -> fast top-of-book reads for entry/repost/market maker logic
102
+ * userEvents -> cached for diagnostics / future event-driven position refresh
103
+ * orderUpdates -> cached for diagnostics / future order-state refresh
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.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ info: Info,
112
+ account_address: str,
113
+ enabled: bool = True,
114
+ max_age_seconds: float = 5.0,
115
+ warmup_timeout: float = 0.5,
116
+ ) -> None:
117
+ self.info = info
118
+ self.account_address = account_address
119
+ self.enabled = enabled
120
+ self.max_age_seconds = max_age_seconds
121
+ self.warmup_timeout = warmup_timeout
122
+
123
+ self._mids: Dict[str, float] = {}
124
+ self._mids_updated_at = 0.0
125
+ self._mids_ready = asyncio.Event()
126
+ self._mids_sub_id: Optional[int] = None
127
+
128
+ self._bbo: Dict[str, Tuple[Optional[float], Optional[float]]] = {}
129
+ self._bbo_updated_at: Dict[str, float] = {}
130
+ self._bbo_ready: Dict[str, asyncio.Event] = {}
131
+ self._bbo_sub_ids: Dict[str, int] = {}
132
+
133
+ self._user_events_sub_id: Optional[int] = None
134
+ self._order_updates_sub_id: Optional[int] = None
135
+ self.last_user_event: Optional[Dict[str, Any]] = None
136
+ self.last_order_update: Optional[Dict[str, Any]] = None
137
+ self._candle_buffers: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
138
+ self._candle_requested_periods: Dict[Tuple[str, str], int] = {}
139
+ self._candle_updated_at: Dict[Tuple[str, str], float] = {}
140
+ self._candle_ready: Dict[Tuple[str, str], asyncio.Event] = {}
141
+ self._candle_sub_ids: Dict[Tuple[str, str], int] = {}
142
+ self._candle_locks: Dict[Tuple[str, str], asyncio.Lock] = {}
143
+
144
+ @staticmethod
145
+ def _coin_keys(coin: str) -> Tuple[str, str]:
146
+ raw = str(coin)
147
+ return raw, raw.upper()
148
+
149
+ @staticmethod
150
+ def _is_fresh(updated_at: float, max_age_seconds: float) -> bool:
151
+ return updated_at > 0.0 and (time.monotonic() - updated_at) <= max_age_seconds
152
+
153
+ @staticmethod
154
+ def _extract_level_px(level: Any) -> Optional[float]:
155
+ if level is None:
156
+ return None
157
+ try:
158
+ if isinstance(level, dict):
159
+ px = level.get("px")
160
+ return None if px is None else float(px)
161
+ if isinstance(level, (list, tuple)) and level:
162
+ first = level[0]
163
+ if isinstance(first, dict):
164
+ px = first.get("px")
165
+ return None if px is None else float(px)
166
+ return float(first)
167
+ except (TypeError, ValueError):
168
+ return None
169
+ return None
170
+
171
+ @staticmethod
172
+ def _normalize_candle_key(coin: str, interval: str) -> Tuple[str, str]:
173
+ return str(coin).upper(), str(interval)
174
+
175
+ @staticmethod
176
+ def _extract_candle_start_ms(candle: Dict[str, Any]) -> Optional[int]:
177
+ try:
178
+ return int(candle["t"])
179
+ except (KeyError, TypeError, ValueError):
180
+ return None
181
+
182
+ def _merge_candles(
183
+ self,
184
+ key: Tuple[str, str],
185
+ candles: List[Dict[str, Any]],
186
+ ) -> None:
187
+ if not candles:
188
+ return
189
+
190
+ merged: Dict[int, Dict[str, Any]] = {}
191
+ for existing in self._candle_buffers.get(key, []):
192
+ start_ms = self._extract_candle_start_ms(existing)
193
+ if start_ms is not None:
194
+ merged[start_ms] = existing
195
+
196
+ for candle in candles:
197
+ start_ms = self._extract_candle_start_ms(candle)
198
+ if start_ms is not None:
199
+ merged[start_ms] = dict(candle)
200
+
201
+ keep = max(1, self._candle_requested_periods.get(key, len(merged)))
202
+ ordered = [merged[start_ms] for start_ms in sorted(merged)]
203
+ self._candle_buffers[key] = ordered[-keep:]
204
+ self._candle_updated_at[key] = time.monotonic()
205
+ self._candle_ready.setdefault(key, asyncio.Event()).set()
206
+
207
+ async def start(self) -> None:
208
+ """Start default websocket subscriptions. Safe to call even when disabled."""
209
+ if not self.enabled:
210
+ return
211
+ if getattr(self.info, "ws_manager", None) is None:
212
+ print("[WS] Info client has no websocket manager; websocket cache disabled.")
213
+ self.enabled = False
214
+ return
215
+
216
+ try:
217
+ self._mids_sub_id = await self.info.subscribe({"type": "allMids"}, self._on_all_mids)
218
+ print(f"[WS] Subscribed to allMids (subscription id {self._mids_sub_id}).")
219
+ except Exception as exc:
220
+ print(f"[WS-WARN] allMids subscription failed; HTTP mid fallback will be used: {exc}")
221
+
222
+ # These are cached for observability / future event-driven refreshes. The
223
+ # authoritative position and order reconciliation still uses HTTP snapshots.
224
+ for sub, attr_name, label, callback in (
225
+ ({"type": "userEvents", "user": self.account_address}, "_user_events_sub_id", "userEvents",
226
+ self._on_user_event),
227
+ ({"type": "orderUpdates", "user": self.account_address}, "_order_updates_sub_id", "orderUpdates",
228
+ self._on_order_update),
229
+ ):
230
+ try:
231
+ sub_id = await self.info.subscribe(dict(sub), callback)
232
+ setattr(self, attr_name, sub_id)
233
+ print(f"[WS] Subscribed to {label} (subscription id {sub_id}).")
234
+ except Exception as exc:
235
+ print(f"[WS-WARN] {label} subscription failed; continuing without it: {exc}")
236
+
237
+ async def stop(self) -> None:
238
+ """Best-effort unsubscribe. Closing the Info client also closes the websocket."""
239
+ if not self.enabled or getattr(self.info, "ws_manager", None) is None:
240
+ return
241
+
242
+ unsubscribe_specs: List[Tuple[Dict[str, Any], Optional[int], str]] = [
243
+ ({"type": "allMids"}, self._mids_sub_id, "allMids"),
244
+ ({"type": "userEvents", "user": self.account_address}, self._user_events_sub_id, "userEvents"),
245
+ ({"type": "orderUpdates", "user": self.account_address}, self._order_updates_sub_id, "orderUpdates"),
246
+ ]
247
+ for coin, sub_id in list(self._bbo_sub_ids.items()):
248
+ unsubscribe_specs.append(({"type": "bbo", "coin": coin}, sub_id, f"bbo:{coin}"))
249
+ for (coin, interval), sub_id in list(self._candle_sub_ids.items()):
250
+ unsubscribe_specs.append(
251
+ ({"type": "candle", "coin": coin, "interval": interval}, sub_id, f"candle:{coin},{interval}")
252
+ )
253
+
254
+ for subscription, sub_id, label in unsubscribe_specs:
255
+ if sub_id is None:
256
+ continue
257
+ try:
258
+ await self.info.unsubscribe(subscription, sub_id)
259
+ print(f"[WS] Unsubscribed from {label}.")
260
+ except Exception:
261
+ # Shutdown should be quiet and best effort; Info.aclose closes the socket.
262
+ pass
263
+
264
+ def _on_all_mids(self, msg: Dict[str, Any]) -> None:
265
+ data = msg.get("data", {}) if isinstance(msg, dict) else {}
266
+ mids_raw: Any = data.get("mids") if isinstance(data, dict) else None
267
+ if mids_raw is None and isinstance(data, dict):
268
+ mids_raw = data
269
+ if not isinstance(mids_raw, dict):
270
+ return
271
+
272
+ now = time.monotonic()
273
+ for coin, px in mids_raw.items():
274
+ try:
275
+ value = float(px)
276
+ except (TypeError, ValueError):
277
+ continue
278
+ raw_key, upper_key = self._coin_keys(str(coin))
279
+ self._mids[raw_key] = value
280
+ self._mids[upper_key] = value
281
+ self._mids_updated_at = now
282
+ self._mids_ready.set()
283
+
284
+ def _on_bbo(self, requested_coin: str, msg: Dict[str, Any]) -> None:
285
+ data = msg.get("data", {}) if isinstance(msg, dict) else {}
286
+ if not isinstance(data, dict):
287
+ return
288
+
289
+ coin = str(data.get("coin") or requested_coin)
290
+ raw_bbo = data.get("bbo")
291
+ bid_px: Optional[float] = None
292
+ ask_px: Optional[float] = None
293
+ if isinstance(raw_bbo, (list, tuple)):
294
+ if len(raw_bbo) > 0:
295
+ bid_px = self._extract_level_px(raw_bbo[0])
296
+ if len(raw_bbo) > 1:
297
+ ask_px = self._extract_level_px(raw_bbo[1])
298
+
299
+ raw_key, upper_key = self._coin_keys(coin)
300
+ requested_raw, requested_upper = self._coin_keys(requested_coin)
301
+ now = time.monotonic()
302
+ for key in {raw_key, upper_key, requested_raw, requested_upper}:
303
+ self._bbo[key] = (bid_px, ask_px)
304
+ self._bbo_updated_at[key] = now
305
+ self._bbo_ready.setdefault(key, asyncio.Event()).set()
306
+
307
+ def _on_user_event(self, msg: Dict[str, Any]) -> None:
308
+ self.last_user_event = msg
309
+
310
+ def _on_order_update(self, msg: Dict[str, Any]) -> None:
311
+ self.last_order_update = msg
312
+
313
+ def _on_candle(self, requested_coin: str, requested_interval: str, msg: Dict[str, Any]) -> None:
314
+ data = msg.get("data", {}) if isinstance(msg, dict) else {}
315
+ if not isinstance(data, dict):
316
+ return
317
+ interval = str(data.get("i") or requested_interval)
318
+ if interval != requested_interval:
319
+ return
320
+ candle = dict(data)
321
+ candle["s"] = str(data.get("s") or requested_coin).upper()
322
+ candle["i"] = interval
323
+ key = self._normalize_candle_key(requested_coin, requested_interval)
324
+ self._merge_candles(key, [candle])
325
+
326
+ async def get_all_mids(self) -> Optional[Dict[str, float]]:
327
+ if not self.enabled:
328
+ return None
329
+ if self._is_fresh(self._mids_updated_at, self.max_age_seconds) and self._mids:
330
+ return dict(self._mids)
331
+ if not self._mids_ready.is_set():
332
+ try:
333
+ await asyncio.wait_for(self._mids_ready.wait(), timeout=self.warmup_timeout)
334
+ except asyncio.TimeoutError:
335
+ return None
336
+ if self._is_fresh(self._mids_updated_at, self.max_age_seconds) and self._mids:
337
+ return dict(self._mids)
338
+ return None
339
+
340
+ async def ensure_bbo_subscription(self, coin: str) -> None:
341
+ if not self.enabled:
342
+ return
343
+ raw_key, upper_key = self._coin_keys(coin)
344
+ if raw_key in self._bbo_sub_ids or upper_key in self._bbo_sub_ids:
345
+ return
346
+ self._bbo_ready.setdefault(raw_key, asyncio.Event())
347
+ self._bbo_ready.setdefault(upper_key, asyncio.Event())
348
+ try:
349
+ sub_id = await self.info.subscribe({"type": "bbo", "coin": coin}, lambda msg, c=coin: self._on_bbo(c, msg))
350
+ self._bbo_sub_ids[raw_key] = sub_id
351
+ self._bbo_sub_ids[upper_key] = sub_id
352
+ print(f"[WS] Subscribed to bbo:{coin} (subscription id {sub_id}).")
353
+ except Exception as exc:
354
+ print(f"[WS-WARN] bbo subscription failed for {coin}; HTTP L2 fallback will be used: {exc}")
355
+
356
+ async def get_bbo(self, coin: str) -> Optional[Tuple[Optional[float], Optional[float]]]:
357
+ if not self.enabled:
358
+ return None
359
+ await self.ensure_bbo_subscription(coin)
360
+ raw_key, upper_key = self._coin_keys(coin)
361
+ for key in (raw_key, upper_key):
362
+ updated_at = self._bbo_updated_at.get(key, 0.0)
363
+ if self._is_fresh(updated_at, self.max_age_seconds) and key in self._bbo:
364
+ return self._bbo[key]
365
+
366
+ event = self._bbo_ready.setdefault(upper_key, asyncio.Event())
367
+ if not event.is_set():
368
+ try:
369
+ await asyncio.wait_for(event.wait(), timeout=self.warmup_timeout)
370
+ except asyncio.TimeoutError:
371
+ return None
372
+
373
+ for key in (raw_key, upper_key):
374
+ updated_at = self._bbo_updated_at.get(key, 0.0)
375
+ if self._is_fresh(updated_at, self.max_age_seconds) and key in self._bbo:
376
+ return self._bbo[key]
377
+ return None
378
+
379
+ async def ensure_candle_subscription(self, coin: str, interval: str, periods: int) -> None:
380
+ if not self.enabled:
381
+ return
382
+
383
+ key = self._normalize_candle_key(coin, interval)
384
+ self._candle_requested_periods[key] = max(periods, self._candle_requested_periods.get(key, 0))
385
+ self._candle_ready.setdefault(key, asyncio.Event())
386
+ lock = self._candle_locks.setdefault(key, asyncio.Lock())
387
+ async with lock:
388
+ if key not in self._candle_sub_ids:
389
+ try:
390
+ sub_id = await self.info.subscribe(
391
+ {"type": "candle", "coin": coin, "interval": interval},
392
+ lambda msg, c=coin, i=interval: self._on_candle(c, i, msg),
393
+ )
394
+ self._candle_sub_ids[key] = sub_id
395
+ print(f"[WS] Subscribed to candle:{coin},{interval} (subscription id {sub_id}).")
396
+ except Exception as exc:
397
+ print(
398
+ f"[WS-WARN] candle subscription failed for {coin} {interval}; "
399
+ f"HTTP candle fallback will be used: {exc}"
400
+ )
401
+ return
402
+
403
+ if len(self._candle_buffers.get(key, [])) >= periods:
404
+ return
405
+
406
+ now_ms = int(time.time() * 1000)
407
+ interval_ms = INTERVAL_TO_MS[interval]
408
+ window_ms = interval_ms * periods * 2
409
+ start_time = now_ms - window_ms
410
+ end_time = now_ms
411
+ data = await self.info.candles_snapshot(coin, interval, start_time, end_time)
412
+ if not isinstance(data, list) or not data:
413
+ raise RuntimeError(f"No candle data returned for {coin} {interval}.")
414
+ self._merge_candles(key, data[-periods:])
415
+
416
+ async def get_recent_candles(self, coin: str, interval: str, periods: int) -> Optional[List[Dict[str, Any]]]:
417
+ if not self.enabled:
418
+ return None
419
+ await self.ensure_candle_subscription(coin, interval, periods)
420
+ key = self._normalize_candle_key(coin, interval)
421
+ candles = self._candle_buffers.get(key, [])
422
+ if len(candles) < periods:
423
+ return None
424
+ return [dict(candle) for candle in candles[-periods:]]
425
+
426
+
427
+ def get_ws_cache(info: Info) -> Optional[HyperliquidWebsocketCache]:
428
+ cache = getattr(info, "ws_cache", None)
429
+ if isinstance(cache, HyperliquidWebsocketCache):
430
+ return cache
431
+ return None
432
+
433
+
434
+ async def init_clients(use_testnet: bool, use_websocket: bool = True) -> Tuple[str, Info, Exchange]:
435
+ """Initialize async Info and Exchange clients for Hyperliquid."""
436
+ secret_key, account_address = load_credentials()
437
+ api_url = constants.TESTNET_API_URL if use_testnet else constants.MAINNET_API_URL
438
+ account: LocalAccount = eth_account.Account.from_key(secret_key)
439
+
440
+ info = await Info.create(api_url, skip_ws=(not use_websocket))
441
+ exchange = await Exchange.create(account, api_url, account_address=account_address)
442
+
443
+ ws_cache = HyperliquidWebsocketCache(info, account_address, enabled=use_websocket)
444
+ setattr(info, "ws_cache", ws_cache)
445
+ await ws_cache.start()
446
+ return account_address, info, exchange
447
+
448
+
449
+ async def close_clients(info: Optional[Info], exchange: Optional[Exchange]) -> None:
450
+ """Best-effort close for async SDK HTTP/websocket sessions."""
451
+ if info is not None:
452
+ cache = get_ws_cache(info)
453
+ if cache is not None:
454
+ await cache.stop()
455
+ for client in (exchange, info):
456
+ if client is None:
457
+ continue
458
+ close = getattr(client, "aclose", None)
459
+ if close is None:
460
+ continue
461
+ try:
462
+ await close()
463
+ except Exception as exc:
464
+ print(f"[WARN] Failed to close async client cleanly: {exc}")
465
+
466
+
467
+ def _try_float(value: Any) -> Optional[float]:
468
+ try:
469
+ if value is None:
470
+ return None
471
+ return float(value)
472
+ except (TypeError, ValueError):
473
+ return None
474
+
475
+
476
+
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # Order / precision helpers
481
+ # ---------------------------------------------------------------------------
482
+
483
+ async def get_asset_id(info: Info, coin: str) -> int:
484
+ """Return Hyperliquid asset id for a coin/symbol name."""
485
+ try:
486
+ return int(await info.name_to_asset(coin)) # type: ignore[attr-defined]
487
+ except Exception as exc:
488
+ raise RuntimeError(f"Could not resolve Hyperliquid asset id for {coin}: {exc}") from exc
489
+
490
+
491
+ async def get_size_decimals(info: Info, coin: str) -> int:
492
+ """Return allowed size decimals for the given Hyperliquid asset."""
493
+ asset_id = await get_asset_id(info, coin)
494
+ try:
495
+ return int(info.asset_to_sz_decimals[asset_id])
496
+ except Exception as exc:
497
+ raise RuntimeError(f"Could not resolve size decimals for {coin}: {exc}") from exc
498
+
499
+
500
+ async def round_size_for_hyperliquid(info: Info, coin: str, size: float) -> float:
501
+ """Round order size to the precision accepted by Hyperliquid."""
502
+ if size <= 0.0:
503
+ return 0.0
504
+ decimals_count = await get_size_decimals(info, coin)
505
+ rounded = round(float(size), decimals_count)
506
+ if rounded <= 0.0:
507
+ raise RuntimeError(
508
+ f"Rounded size for {coin} became zero. Input size={size}, szDecimals={decimals_count}."
509
+ )
510
+ return rounded
511
+
512
+
513
+ async def round_price_for_hyperliquid(info: Info, coin: str, price: float) -> float:
514
+ """Round price to Hyperliquid's accepted precision."""
515
+ if price <= 0.0:
516
+ raise RuntimeError(f"Price must be positive for {coin}. Got: {price}")
517
+
518
+ asset_id = await get_asset_id(info, coin)
519
+ sz_decimals = await get_size_decimals(info, coin)
520
+ max_decimals = 8 if asset_id >= 10_000 else 6
521
+
522
+ if price > 100_000:
523
+ return float(round(price))
524
+
525
+ decimals_allowed = max(0, max_decimals - sz_decimals)
526
+ return round(float(f"{float(price):.5g}"), decimals_allowed)
527
+
528
+
529
+ def extract_order_error(resp: Any) -> Optional[str]:
530
+ """Extract error string from an exchange.order response, if any."""
531
+ try:
532
+ if not isinstance(resp, dict):
533
+ return None
534
+ response = resp.get("response") or {}
535
+ data = response.get("data") or {}
536
+ statuses = data.get("statuses") or []
537
+ if not statuses:
538
+ return None
539
+ status0 = statuses[0]
540
+ if isinstance(status0, dict) and "error" in status0:
541
+ return str(status0["error"])
542
+ except Exception:
543
+ return None
544
+ return None
545
+
546
+
547
+
548
+
549
+
550
+
551
+ async def get_best_bid_ask(info: Info, coin: str) -> Tuple[Optional[float], Optional[float]]:
552
+ """Return (best_bid, best_ask), preferring websocket bbo and falling back to HTTP L2."""
553
+ cache = get_ws_cache(info)
554
+ if cache is not None:
555
+ cached_bbo = await cache.get_bbo(coin)
556
+ if cached_bbo is not None:
557
+ return cached_bbo
558
+
559
+ try:
560
+ snap = await info.l2_snapshot(coin)
561
+ except Exception as exc:
562
+ print(f"[WARN] Failed to fetch l2 snapshot for {coin}: {exc}")
563
+ return None, None
564
+
565
+ levels = snap.get("levels", [])
566
+ if not isinstance(levels, list) or not levels:
567
+ return None, None
568
+
569
+ bids = levels[0] if len(levels) > 0 else []
570
+ asks = levels[1] if len(levels) > 1 else []
571
+ best_bid = None
572
+ best_ask = None
573
+
574
+ if bids:
575
+ try:
576
+ best_bid = float(bids[0]["px"])
577
+ except (KeyError, TypeError, ValueError):
578
+ best_bid = None
579
+ if asks:
580
+ try:
581
+ best_ask = float(asks[0]["px"])
582
+ except (KeyError, TypeError, ValueError):
583
+ best_ask = None
584
+
585
+ return best_bid, best_ask
586
+
587
+
588
+ async def get_open_orders_for_coin(info: Info, account_address: str, coin: str) -> List[Dict[str, Any]]:
589
+ """Return open orders for a specific coin."""
590
+ try:
591
+ open_orders = await info.open_orders(account_address)
592
+ except Exception:
593
+ logging.getLogger("hypertrader").exception(
594
+ "[WARN] Failed to fetch open orders for %s.",
595
+ coin,
596
+ )
597
+ return []
598
+
599
+ coin_orders: List[Dict[str, Any]] = []
600
+ for order in open_orders:
601
+ try:
602
+ if str(order.get("coin", "")).lower() == coin.lower():
603
+ coin_orders.append(order)
604
+ except Exception:
605
+ continue
606
+ return coin_orders
607
+
608
+
609
+
610
+
611
+ def is_rate_limit_error(exc: BaseException) -> bool:
612
+ """Return True when the SDK surfaced a 429/rate-limit style response."""
613
+ for arg in getattr(exc, "args", ()):
614
+ if arg == 429:
615
+ return True
616
+ if isinstance(arg, str) and "429" in arg:
617
+ return True
618
+ if isinstance(arg, tuple) and arg and arg[0] == 429:
619
+ return True
620
+ return "429" in str(exc)
621
+
622
+
623
+ async def get_user_state_with_retry(
624
+ info: Info,
625
+ account_address: str,
626
+ *,
627
+ context_label: str,
628
+ coin: Optional[str] = None,
629
+ retry_sleep: float = WATCH_RETRY_SLEEP_SECONDS,
630
+ ) -> Dict[str, Any]:
631
+ """Fetch user_state and keep retrying on Hyperliquid rate limits."""
632
+ coin_label = coin.upper() if coin else "ALL"
633
+ attempt = 0
634
+ while True:
635
+ attempt += 1
636
+ try:
637
+ user_state = await info.user_state(account_address)
638
+ except asyncio.CancelledError:
639
+ raise
640
+ except Exception as exc:
641
+ if not is_rate_limit_error(exc):
642
+ raise
643
+ print(
644
+ f"[RATE-LIMIT] {context_label} coin={coin_label} attempt={attempt} "
645
+ f"hit Hyperliquid rate limit ({exc}). Sleeping {retry_sleep:.1f}s before retry."
646
+ )
647
+ await asyncio.sleep(retry_sleep)
648
+ continue
649
+
650
+ if not isinstance(user_state, dict):
651
+ raise RuntimeError(f"{context_label} user_state response was not a dictionary.")
652
+ return user_state
653
+
654
+
655
+ # ---------------------------------------------------------------------------
656
+ # Position helpers
657
+ # ---------------------------------------------------------------------------
658
+
659
+ async def get_all_open_positions(info: Info, account_address: str) -> List[Dict[str, Any]]:
660
+ """Return a list of open perp positions for the account."""
661
+ user_state = await get_user_state_with_retry(
662
+ info,
663
+ account_address,
664
+ context_label="get_all_open_positions",
665
+ )
666
+ asset_positions = user_state.get("assetPositions", [])
667
+ open_positions: List[Dict[str, Any]] = []
668
+
669
+ for asset_pos in asset_positions:
670
+ position = asset_pos.get("position", {})
671
+ try:
672
+ size = float(position.get("szi", "0"))
673
+ except (TypeError, ValueError):
674
+ continue
675
+ if size != 0.0:
676
+ open_positions.append(position)
677
+
678
+ return open_positions
679
+
680
+
681
+ async def get_position_size_for_coin(info: Info, account_address: str, coin: str) -> float:
682
+ """Return signed position size for a coin, or 0.0 if flat/not found."""
683
+ user_state = await get_user_state_with_retry(
684
+ info,
685
+ account_address,
686
+ context_label="get_position_size_for_coin",
687
+ coin=coin,
688
+ )
689
+ asset_positions = user_state.get("assetPositions", [])
690
+ for asset_pos in asset_positions:
691
+ position = asset_pos.get("position", {})
692
+ if str(position.get("coin", "")).lower() == coin.lower():
693
+ try:
694
+ return float(position.get("szi", "0"))
695
+ except (TypeError, ValueError):
696
+ return 0.0
697
+ return 0.0
698
+
699
+
700
+ async def get_position_for_coin(
701
+ info: Info,
702
+ account_address: str,
703
+ coin: str,
704
+ ) -> Optional[Dict[str, Any]]:
705
+ """Return full position dict for a coin if the account has a non-zero position."""
706
+ user_state = await get_user_state_with_retry(
707
+ info,
708
+ account_address,
709
+ context_label="get_position_for_coin",
710
+ coin=coin,
711
+ )
712
+ for asset_pos in user_state.get("assetPositions", []):
713
+ pos = asset_pos.get("position", {})
714
+ if str(pos.get("coin", "")).lower() != coin.lower():
715
+ continue
716
+ try:
717
+ if float(pos.get("szi", "0")) != 0.0:
718
+ return pos
719
+ except (TypeError, ValueError):
720
+ return None
721
+ return None
722
+
723
+
724
+ async def get_all_mids(info: Info) -> Dict[str, float]:
725
+ """Fetch mid prices, preferring websocket allMids and falling back to HTTP."""
726
+ cache = get_ws_cache(info)
727
+ if cache is not None:
728
+ cached_mids = await cache.get_all_mids()
729
+ if cached_mids is not None:
730
+ return cached_mids
731
+
732
+ mids_raw = await info.all_mids()
733
+ mids: Dict[str, float] = {}
734
+ for coin, px in mids_raw.items():
735
+ try:
736
+ value = float(px)
737
+ except (TypeError, ValueError):
738
+ continue
739
+ coin_str = str(coin)
740
+ mids[coin_str] = value
741
+ mids[coin_str.upper()] = value
742
+ return mids
743
+
744
+
745
+ # ---------------------------------------------------------------------------
746
+ # Candle / volatility helpers
747
+ # ---------------------------------------------------------------------------
748
+
749
+ async def fetch_recent_candles(
750
+ info: Info,
751
+ coin: str,
752
+ interval: str,
753
+ periods: int,
754
+ use_websocket_candles: bool = False,
755
+ ) -> List[Dict[str, Any]]:
756
+ """Fetch the last `periods` candles using the async SDK candleSnapshot helper."""
757
+ if interval not in INTERVAL_TO_MS:
758
+ raise RuntimeError(f"Unsupported interval {interval}. Valid: {sorted(INTERVAL_TO_MS.keys())}")
759
+ if periods <= 0:
760
+ raise RuntimeError("periods must be > 0")
761
+
762
+ if use_websocket_candles:
763
+ cache = get_ws_cache(info)
764
+ if cache is not None and cache.enabled:
765
+ cached_candles = await cache.get_recent_candles(coin, interval, periods)
766
+ if cached_candles is not None:
767
+ return cached_candles
768
+
769
+ now_ms = int(time.time() * 1000)
770
+ interval_ms = INTERVAL_TO_MS[interval]
771
+ window_ms = interval_ms * periods * 2
772
+ start_time = now_ms - window_ms
773
+ end_time = now_ms
774
+
775
+ data = await info.candles_snapshot(coin, interval, start_time, end_time)
776
+ if not isinstance(data, list) or not data:
777
+ raise RuntimeError(f"No candle data returned for {coin} {interval}.")
778
+
779
+ return data[-periods:]
780
+
781
+
782
+
783
+
784
+
785
+ def parse_position_snapshot(position: Dict[str, Any]) -> Tuple[str, float, float, str, float]:
786
+ """Parse live position fields needed for management loops."""
787
+ try:
788
+ coin = str(position["coin"]).upper().strip()
789
+ signed_size = float(position["szi"])
790
+ entry_px = float(position["entryPx"])
791
+ except (KeyError, TypeError, ValueError) as exc:
792
+ raise RuntimeError(f"Could not parse position snapshot: {position}") from exc
793
+
794
+ if signed_size == 0.0:
795
+ raise RuntimeError(f"Position snapshot is flat and cannot be managed: {position}")
796
+
797
+ side = "long" if signed_size > 0.0 else "short"
798
+ return coin, signed_size, entry_px, side, abs(signed_size)
799
+
800
+
801
+ def position_is_directional_add(
802
+ previous_signed_size: float,
803
+ current_signed_size: float,
804
+ size_epsilon: float = 1e-12,
805
+ ) -> bool:
806
+ """Return True when the live position increased in the same direction."""
807
+ if previous_signed_size == 0.0 or current_signed_size == 0.0:
808
+ return False
809
+ if previous_signed_size * current_signed_size <= 0.0:
810
+ return False
811
+ return abs(current_signed_size) > abs(previous_signed_size) + size_epsilon
812
+
813
+
814
+ def compute_position_unrealized_pnl(position: Dict[str, Any], mid_price: float) -> Optional[float]:
815
+ """Return current unrealized PnL, preferring exchange-reported fields when available."""
816
+ for key in ("unrealizedPnl", "unrealized_pnl"):
817
+ value = position.get(key)
818
+ if value is None:
819
+ continue
820
+ try:
821
+ return float(value)
822
+ except (TypeError, ValueError):
823
+ pass
824
+
825
+ try:
826
+ signed_size = float(position["szi"])
827
+ entry_px = float(position["entryPx"])
828
+ except (KeyError, TypeError, ValueError):
829
+ return None
830
+
831
+ return signed_size * (mid_price - entry_px)
832
+
833
+
834
+ @dataclass
835
+ class AccountRuntimeMetrics:
836
+ """Runtime account metrics displayed by active management loops."""
837
+
838
+ account_balance: Optional[float]
839
+ realized_pnl: Optional[float]
840
+
841
+
842
+ def extract_closed_pnl_from_fill(fill: Dict[str, Any]) -> float:
843
+ """Extract realized/closed PnL from a user fill response, if present."""
844
+ for key in ("closedPnl", "closedPnL", "realizedPnl", "realizedPnL", "realized_pnl"):
845
+ value = _try_float(fill.get(key))
846
+ if value is not None:
847
+ return value
848
+ return 0.0
849
+
850
+
851
+ async def get_realized_pnl_since(
852
+ info: Info,
853
+ account_address: str,
854
+ start_time_ms: Optional[int],
855
+ coin: Optional[str] = None,
856
+ ) -> Optional[float]:
857
+ """Return closed/realized PnL since command start when available from fills."""
858
+ if start_time_ms is None:
859
+ return None
860
+
861
+ try:
862
+ fills = await info.user_fills_by_time(account_address, start_time_ms)
863
+ except Exception:
864
+ logging.getLogger("hypertrader").exception(
865
+ "[METRICS] Failed to fetch user fills since %s for coin=%s.",
866
+ start_time_ms,
867
+ coin.upper() if coin else "ALL",
868
+ )
869
+ return None
870
+
871
+ if not isinstance(fills, list):
872
+ return None
873
+
874
+ coin_upper = coin.upper() if coin else None
875
+ total = 0.0
876
+ found = False
877
+ for fill in fills:
878
+ if not isinstance(fill, dict):
879
+ continue
880
+ if coin_upper is not None and str(fill.get("coin", "")).upper() != coin_upper:
881
+ continue
882
+ pnl = extract_closed_pnl_from_fill(fill)
883
+ total += pnl
884
+ if pnl != 0.0 or any(
885
+ k in fill for k in ("closedPnl", "closedPnL", "realizedPnl", "realizedPnL", "realized_pnl")):
886
+ found = True
887
+ return total if found or fills else 0.0
888
+
889
+ def extract_account_balance_from_user_state(user_state: Dict[str, Any]) -> Optional[float]:
890
+ """Extract current Hyperliquid account value from a clearinghouseState response."""
891
+ candidate_paths = (
892
+ ("marginSummary", "accountValue"),
893
+ ("crossMarginSummary", "accountValue"),
894
+ ("portfolio", "accountValue"),
895
+ )
896
+ for outer, inner in candidate_paths:
897
+ section = user_state.get(outer)
898
+ if isinstance(section, dict):
899
+ value = _try_float(section.get(inner))
900
+ if value is not None:
901
+ return value
902
+
903
+ for key in ("accountValue", "totalAccountValue", "balance", "withdrawable"):
904
+ value = _try_float(user_state.get(key))
905
+ if value is not None:
906
+ return value
907
+ return None
908
+
909
+
910
+ async def get_account_runtime_metrics(
911
+ info: Info,
912
+ account_address: str,
913
+ start_time_ms: Optional[int],
914
+ coin: Optional[str] = None,
915
+ ) -> AccountRuntimeMetrics:
916
+ """Fetch current balance and command-session realized PnL for display."""
917
+ balance: Optional[float] = None
918
+ try:
919
+ user_state = await get_user_state_with_retry(
920
+ info,
921
+ account_address,
922
+ context_label="get_account_runtime_metrics",
923
+ coin=coin,
924
+ )
925
+ balance = extract_account_balance_from_user_state(user_state)
926
+ except Exception:
927
+ logging.getLogger("hypertrader").exception(
928
+ "[METRICS] Failed to fetch user_state for coin=%s.",
929
+ coin.upper() if coin else "ALL",
930
+ )
931
+ balance = None
932
+
933
+ realized_pnl = await get_realized_pnl_since(info, account_address, start_time_ms, coin=coin)
934
+ return AccountRuntimeMetrics(account_balance=balance, realized_pnl=realized_pnl)
935
+
936
+
937
+ def fmt_optional_float(value: Optional[float], decimals: int = 8) -> str:
938
+ return f"{value:.{decimals}f}" if value is not None else "N/A"
939
+
940
+
941
+ def format_account_metrics(metrics: AccountRuntimeMetrics) -> str:
942
+ return (
943
+ f"rpnl={fmt_optional_float(metrics.realized_pnl)} "
944
+ f"balance={fmt_optional_float(metrics.account_balance)}"
945
+ )
946
+
947
+
948
+ def compute_default_stop_loss_pct(
949
+ take_profit_pct: Optional[float],
950
+ stop_loss_pct: Optional[float],
951
+ ) -> Optional[float]:
952
+ """Apply default SL rule: TP without SL => SL = TP * 0.5."""
953
+ if stop_loss_pct is not None:
954
+ return stop_loss_pct
955
+ if take_profit_pct is not None:
956
+ return take_profit_pct * 0.5
957
+ return None
958
+
959
+
960
+
961
+
962
+
963
+
964
+
965
+
966
+ ###
967
+
968
+ async def run_current_coros(coro_list: list[Awaitable]):
969
+ jobs = []
970
+ for x, coro in enumerate(coro_list):
971
+ jobs.append((x, coro))
972
+ await asyncio.gather(*jobs)
973
+
974
+ queue = AsyncTaskQueue(jobs=jobs, concurrency=10)
975
+
976
+ async for result in queue:
977
+ if result.ok:
978
+ print(
979
+ f"[ok] index={result.index:02d} "
980
+ f"elapsed={result.elapsed:.2f}s "
981
+ f"value={result.value}"
982
+ )
983
+ else:
984
+ print(
985
+ f"[fail] index={result.index:02d} "
986
+ f"elapsed={result.elapsed:.2f}s "
987
+ f"error={type(result.error).__name__}: {result.error}"
988
+ )