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.
- hypertrader-0.1.0.dist-info/METADATA +416 -0
- hypertrader-0.1.0.dist-info/RECORD +16 -0
- hypertrader-0.1.0.dist-info/WHEEL +5 -0
- hypertrader-0.1.0.dist-info/entry_points.txt +2 -0
- hypertrader-0.1.0.dist-info/top_level.txt +3 -0
- hypertrader.py +741 -0
- modes/auto_trader.py +2141 -0
- modes/market_maker.py +397 -0
- modes/position_management.py +1911 -0
- modes/position_watcher.py +411 -0
- modes/trailing_stop.py +277 -0
- utils/constants.py +31 -0
- utils/helpers.py +988 -0
- utils/style.py +173 -0
- utils/timer.py +14 -0
- utils/worker.py +233 -0
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
|
+
)
|