scripts/hyperliquid_client.py
scripts/hyperliquid_client.pyBrowse 2 files
14,617 tokens
60,190 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3Hyperliquid CLI Tool for Hermes Agent4-------------------------------------5Queries the Hyperliquid info endpoint for market and account data.6Uses only Python standard library - no external packages required.7 8Usage:9 python3 hyperliquid_client.py dexs10 python3 hyperliquid_client.py markets [--dex DEX] [--limit N]11 python3 hyperliquid_client.py spots [--limit N]12 python3 hyperliquid_client.py candles <coin> [--interval 1h] [--hours 24]13 python3 hyperliquid_client.py funding <coin> [--hours 72]14 python3 hyperliquid_client.py l2 <coin> [--levels 10]15 python3 hyperliquid_client.py state [address] [--dex DEX]16 python3 hyperliquid_client.py spot-balances [address]17 python3 hyperliquid_client.py fills [address] [--hours N] [--limit N]18 python3 hyperliquid_client.py orders [address] [--limit N]19 python3 hyperliquid_client.py review [address] [--coin COIN] [--hours N]20 python3 hyperliquid_client.py export <coin> [--interval 1h] [--hours N]21 22Environment:23 HYPERLIQUID_API_URL Override API base URL24 (default: https://api.hyperliquid.xyz)25 HYPERLIQUID_USER_ADDRESS Default address for state/fills/orders/review commands26"""27 28from __future__ import annotations29 30import argparse31import datetime as dt32import json33import os34import sys35import time36import urllib.error37import urllib.request38from collections import Counter39from pathlib import Path40from typing import Any, Dict, Iterable, List, Optional41 42 43USER_AGENT = "HermesAgent/1.0"44DEFAULT_USER_ENV = "HYPERLIQUID_USER_ADDRESS"45DEFAULT_API_BASE = "https://api.hyperliquid.xyz"46 47 48def _hermes_home() -> Path:49 return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()50 51 52def _dotenv_paths() -> List[Path]:53 paths: List[Path] = []54 project_env = Path.cwd() / ".env"55 if project_env.exists():56 paths.append(project_env)57 58 user_env = _hermes_home() / ".env"59 if user_env.exists():60 paths.append(user_env)61 62 return paths63 64 65def _load_dotenv_values() -> Dict[str, str]:66 values: Dict[str, str] = {}67 for env_path in _dotenv_paths():68 try:69 lines = env_path.read_text(encoding="utf-8").splitlines()70 except UnicodeDecodeError:71 lines = env_path.read_text(encoding="latin-1").splitlines()72 73 for raw_line in lines:74 line = raw_line.strip()75 if not line or line.startswith("#") or "=" not in line:76 continue77 key, _, value = raw_line.partition("=")78 key = key.strip()79 value = value.strip()80 if value.startswith('"') and value.endswith('"') and len(value) >= 2:81 value = value[1:-1].replace('\\"', '"').replace('\\\\', '\\')82 values[key] = value83 return values84 85 86def _env_lookup(key: str, default: str = "") -> str:87 value = os.environ.get(key, "").strip()88 if value:89 return value90 dotenv_value = _load_dotenv_values().get(key, "").strip()91 if dotenv_value:92 return dotenv_value93 return default94 95 96def _api_base() -> str:97 return _env_lookup("HYPERLIQUID_API_URL", DEFAULT_API_BASE).rstrip("/")98 99 100def _info_url() -> str:101 api_base = _api_base()102 if api_base.endswith("/info"):103 return api_base104 return f"{api_base}/info"105 106 107def _resolve_user(user: Optional[str]) -> str:108 candidate = (user or "").strip()109 if candidate:110 return candidate111 112 env_value = _env_lookup(DEFAULT_USER_ENV, "")113 if env_value:114 return env_value115 116 sys.exit(117 "Missing Hyperliquid address. Pass <address> explicitly or set "118 f"{DEFAULT_USER_ENV} in your environment or {_hermes_home() / '.env'}."119 )120 121 122def _post_info(payload: Dict[str, Any], timeout: int = 20, retries: int = 2) -> Any:123 data = json.dumps(payload).encode("utf-8")124 headers = {125 "Content-Type": "application/json",126 "Accept": "application/json",127 "User-Agent": USER_AGENT,128 }129 130 for attempt in range(retries + 1):131 request = urllib.request.Request(_info_url(), data=data, headers=headers, method="POST")132 try:133 with urllib.request.urlopen(request, timeout=timeout) as response:134 body = json.load(response)135 return body136 except urllib.error.HTTPError as exc:137 if exc.code == 429 and attempt < retries:138 time.sleep(1.5 * (attempt + 1))139 continue140 sys.exit(f"Hyperliquid HTTP error: {exc}")141 except urllib.error.URLError as exc:142 sys.exit(f"Hyperliquid connection error: {exc}")143 except json.JSONDecodeError as exc:144 sys.exit(f"Hyperliquid response was not valid JSON: {exc}")145 146 return None147 148 149def _safe_float(value: Any) -> Optional[float]:150 try:151 if value is None or value == "":152 return None153 return float(value)154 except (TypeError, ValueError):155 return None156 157 158def _limit_items(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:159 if limit <= 0:160 return items161 return items[:limit]162 163 164def _hours_ago_ms(hours: float, now_ms: Optional[int] = None) -> int:165 end_ms = now_ms if now_ms is not None else int(time.time() * 1000)166 return end_ms - int(hours * 60 * 60 * 1000)167 168 169def _format_timestamp_ms(value: Any) -> str:170 try:171 ts_ms = int(value)172 except (TypeError, ValueError):173 return "-"174 return dt.datetime.utcfromtimestamp(ts_ms / 1000).strftime("%Y-%m-%d %H:%M:%S UTC")175 176 177def _compact_number(value: Any, decimals: int = 2) -> str:178 number = _safe_float(value)179 if number is None:180 return "-"181 sign = "-" if number < 0 else ""182 number = abs(number)183 if number >= 1_000_000_000:184 return f"{sign}{number / 1_000_000_000:.{decimals}f}B"185 if number >= 1_000_000:186 return f"{sign}{number / 1_000_000:.{decimals}f}M"187 if number >= 1_000:188 return f"{sign}{number / 1_000:.{decimals}f}K"189 if number >= 100:190 return f"{sign}{number:.2f}"191 if number >= 1:192 return f"{sign}{number:.4f}".rstrip("0").rstrip(".")193 return f"{sign}{number:.6f}".rstrip("0").rstrip(".")194 195 196def _format_price(value: Any) -> str:197 number = _safe_float(value)198 if number is None:199 return "-"200 if abs(number) >= 1000:201 return f"{number:,.2f}"202 if abs(number) >= 1:203 return f"{number:,.4f}".rstrip("0").rstrip(".")204 return f"{number:,.6f}".rstrip("0").rstrip(".")205 206 207def _format_percent(value: Any, decimals: int = 2) -> str:208 number = _safe_float(value)209 if number is None:210 return "-"211 return f"{number:+.{decimals}f}%"212 213 214def _format_fraction_percent(value: Any, decimals: int = 4) -> str:215 number = _safe_float(value)216 if number is None:217 return "-"218 return f"{number * 100:+.{decimals}f}%"219 220 221def _percent_change(current: Any, previous: Any) -> Optional[float]:222 curr = _safe_float(current)223 prev = _safe_float(previous)224 if curr is None or prev is None or prev == 0:225 return None226 return ((curr - prev) / prev) * 100227 228 229def _short_address(address: Any) -> str:230 if not isinstance(address, str) or len(address) < 12:231 return str(address)232 return f"{address[:6]}...{address[-4:]}"233 234 235def _render_table(headers: List[tuple[str, str]], rows: List[Dict[str, Any]]) -> str:236 if not rows:237 return "(no data)"238 239 prepared_rows: List[List[str]] = []240 widths = [len(label) for label, _ in headers]241 242 for row in rows:243 rendered = []244 for index, (_label, key) in enumerate(headers):245 value = row.get(key, "")246 text = str(value)247 rendered.append(text)248 if len(text) > widths[index]:249 widths[index] = len(text)250 prepared_rows.append(rendered)251 252 lines = []253 header_line = " ".join(label.ljust(widths[idx]) for idx, (label, _key) in enumerate(headers))254 separator = " ".join("-" * widths[idx] for idx in range(len(headers)))255 lines.extend([header_line, separator])256 257 for rendered in prepared_rows:258 lines.append(" ".join(rendered[idx].ljust(widths[idx]) for idx in range(len(rendered))))259 return "\n".join(lines)260 261 262def _normalize_dexs(payload: Any) -> List[Dict[str, Any]]:263 rows: List[Dict[str, Any]] = []264 if not isinstance(payload, list):265 return rows266 267 for index, item in enumerate(payload):268 if item is None:269 rows.append(270 {271 "index": index,272 "name": "",273 "label": "first-perp-dex",274 "full_name": "First perp dex",275 "deployer": "-",276 "asset_caps": 0,277 }278 )279 continue280 281 if not isinstance(item, dict):282 continue283 284 caps = item.get("assetToStreamingOiCap") or []285 rows.append(286 {287 "index": index,288 "name": item.get("name", ""),289 "label": item.get("name") or "first-perp-dex",290 "full_name": item.get("fullName") or "-",291 "deployer": item.get("deployer") or "-",292 "asset_caps": len(caps) if isinstance(caps, list) else 0,293 }294 )295 return rows296 297 298def _normalize_perp_markets(payload: Any) -> List[Dict[str, Any]]:299 if not isinstance(payload, list) or len(payload) < 2:300 return []301 302 meta = payload[0] if isinstance(payload[0], dict) else {}303 ctxs = payload[1] if isinstance(payload[1], list) else []304 universe = meta.get("universe") if isinstance(meta, dict) else []305 if not isinstance(universe, list):306 return []307 308 rows: List[Dict[str, Any]] = []309 for index, spec in enumerate(universe):310 if not isinstance(spec, dict):311 continue312 ctx = ctxs[index] if index < len(ctxs) and isinstance(ctxs[index], dict) else {}313 mark_px = ctx.get("markPx") or ctx.get("midPx") or ctx.get("oraclePx")314 row = {315 "coin": spec.get("name", f"asset-{index}"),316 "mark_px": mark_px,317 "mid_px": ctx.get("midPx"),318 "oracle_px": ctx.get("oraclePx"),319 "prev_day_px": ctx.get("prevDayPx"),320 "change_pct": _percent_change(mark_px, ctx.get("prevDayPx")),321 "funding": ctx.get("funding"),322 "premium": ctx.get("premium"),323 "open_interest": ctx.get("openInterest"),324 "day_ntl_vlm": ctx.get("dayNtlVlm"),325 "day_base_vlm": ctx.get("dayBaseVlm"),326 "max_leverage": spec.get("maxLeverage"),327 "sz_decimals": spec.get("szDecimals"),328 "is_delisted": bool(spec.get("isDelisted")),329 "only_isolated": bool(spec.get("onlyIsolated")),330 "margin_mode": spec.get("marginMode") or "-",331 }332 rows.append(row)333 return rows334 335 336def _normalize_spot_markets(payload: Any) -> List[Dict[str, Any]]:337 if not isinstance(payload, list) or len(payload) < 2:338 return []339 340 meta = payload[0] if isinstance(payload[0], dict) else {}341 ctxs = payload[1] if isinstance(payload[1], list) else []342 pairs = meta.get("universe") if isinstance(meta, dict) else []343 tokens = meta.get("tokens") if isinstance(meta, dict) else []344 token_lookup = {}345 if isinstance(tokens, list):346 for token in tokens:347 if isinstance(token, dict) and "index" in token:348 token_lookup[token["index"]] = token.get("name", str(token["index"]))349 350 rows: List[Dict[str, Any]] = []351 if not isinstance(pairs, list):352 return rows353 354 for index, pair in enumerate(pairs):355 if not isinstance(pair, dict):356 continue357 ctx = ctxs[index] if index < len(ctxs) and isinstance(ctxs[index], dict) else {}358 raw_name = pair.get("name", f"@{index}")359 tokens_for_pair = pair.get("tokens") if isinstance(pair.get("tokens"), list) else []360 display_name = raw_name361 if "/" not in raw_name and len(tokens_for_pair) == 2:362 base = token_lookup.get(tokens_for_pair[0], str(tokens_for_pair[0]))363 quote = token_lookup.get(tokens_for_pair[1], str(tokens_for_pair[1]))364 display_name = f"{base}/{quote} ({raw_name})"365 366 mark_px = ctx.get("markPx") or ctx.get("midPx")367 rows.append(368 {369 "pair": raw_name,370 "display_name": display_name,371 "mark_px": mark_px,372 "mid_px": ctx.get("midPx"),373 "prev_day_px": ctx.get("prevDayPx"),374 "change_pct": _percent_change(mark_px, ctx.get("prevDayPx")),375 "day_ntl_vlm": ctx.get("dayNtlVlm"),376 }377 )378 return rows379 380 381def _normalize_candles(payload: Any) -> List[Dict[str, Any]]:382 rows: List[Dict[str, Any]] = []383 if not isinstance(payload, list):384 return rows385 386 for candle in payload:387 if not isinstance(candle, dict):388 continue389 rows.append(390 {391 "time": candle.get("t") or candle.get("time"),392 "open": candle.get("o"),393 "high": candle.get("h"),394 "low": candle.get("l"),395 "close": candle.get("c"),396 "volume": candle.get("v"),397 "trades": candle.get("n"),398 }399 )400 401 rows.sort(key=lambda item: int(item.get("time") or 0))402 return rows403 404 405def _normalize_funding_history(payload: Any) -> List[Dict[str, Any]]:406 rows: List[Dict[str, Any]] = []407 if not isinstance(payload, list):408 return rows409 410 for item in payload:411 if not isinstance(item, dict):412 continue413 rows.append(414 {415 "coin": item.get("coin", "-"),416 "funding_rate": item.get("fundingRate"),417 "premium": item.get("premium"),418 "time": item.get("time"),419 }420 )421 422 rows.sort(key=lambda item: int(item.get("time") or 0))423 return rows424 425 426def _normalize_book_levels(payload: Any) -> Dict[str, List[Dict[str, Any]]]:427 if not isinstance(payload, dict):428 return {"bids": [], "asks": []}429 430 levels = payload.get("levels")431 if not isinstance(levels, list) or len(levels) < 2:432 return {"bids": [], "asks": []}433 434 def convert(side: Iterable[Any]) -> List[Dict[str, Any]]:435 converted = []436 for entry in side:437 if isinstance(entry, dict):438 converted.append(439 {440 "px": entry.get("px"),441 "sz": entry.get("sz"),442 "orders": entry.get("n"),443 }444 )445 elif isinstance(entry, (list, tuple)) and len(entry) >= 2:446 converted.append(447 {448 "px": entry[0],449 "sz": entry[1],450 "orders": entry[2] if len(entry) > 2 else None,451 }452 )453 return converted454 455 return {"bids": convert(levels[0]), "asks": convert(levels[1])}456 457 458def _normalize_positions(payload: Any) -> Dict[str, Any]:459 if not isinstance(payload, dict):460 return {"summary": {}, "positions": []}461 462 positions: List[Dict[str, Any]] = []463 for item in payload.get("assetPositions", []):464 if not isinstance(item, dict):465 continue466 position = item.get("position") if isinstance(item.get("position"), dict) else item467 if not isinstance(position, dict):468 continue469 leverage = position.get("leverage") if isinstance(position.get("leverage"), dict) else {}470 positions.append(471 {472 "coin": position.get("coin", "-"),473 "size": position.get("szi"),474 "entry_px": position.get("entryPx"),475 "position_value": position.get("positionValue"),476 "unrealized_pnl": position.get("unrealizedPnl"),477 "return_on_equity": position.get("returnOnEquity"),478 "liquidation_px": position.get("liquidationPx"),479 "margin_used": position.get("marginUsed"),480 "leverage": leverage.get("value"),481 "leverage_type": leverage.get("type"),482 }483 )484 485 positions.sort(486 key=lambda item: abs(_safe_float(item.get("position_value")) or 0.0),487 reverse=True,488 )489 490 summary = payload.get("marginSummary") if isinstance(payload.get("marginSummary"), dict) else {}491 cross_summary = (492 payload.get("crossMarginSummary") if isinstance(payload.get("crossMarginSummary"), dict) else {}493 )494 495 return {496 "summary": {497 "account_value": summary.get("accountValue"),498 "total_ntl_pos": summary.get("totalNtlPos"),499 "total_raw_usd": summary.get("totalRawUsd"),500 "withdrawable": payload.get("withdrawable"),501 "cross_account_value": cross_summary.get("accountValue"),502 },503 "positions": positions,504 }505 506 507def _normalize_spot_balances(payload: Any) -> List[Dict[str, Any]]:508 if not isinstance(payload, dict):509 return []510 511 rows: List[Dict[str, Any]] = []512 for item in payload.get("balances", []):513 if not isinstance(item, dict):514 continue515 rows.append(516 {517 "coin": item.get("coin", item.get("token", "-")),518 "total": item.get("total"),519 "hold": item.get("hold"),520 "entry_ntl": item.get("entryNtl"),521 }522 )523 524 rows.sort(key=lambda item: abs(_safe_float(item.get("entry_ntl")) or 0.0), reverse=True)525 return rows526 527 528def _normalize_fills(payload: Any) -> List[Dict[str, Any]]:529 rows: List[Dict[str, Any]] = []530 if not isinstance(payload, list):531 return rows532 533 for item in payload:534 if not isinstance(item, dict):535 continue536 fill = item.get("fill") if isinstance(item.get("fill"), dict) else item537 rows.append(538 {539 "coin": fill.get("coin", "-"),540 "dir": fill.get("dir") or fill.get("side") or "-",541 "px": fill.get("px"),542 "sz": fill.get("sz"),543 "closed_pnl": fill.get("closedPnl"),544 "fee": fill.get("fee"),545 "fee_token": fill.get("feeToken"),546 "start_position": fill.get("startPosition"),547 "time": fill.get("time"),548 "hash": fill.get("hash"),549 "oid": fill.get("oid"),550 "twap_id": item.get("twapId"),551 }552 )553 554 rows.sort(key=lambda item: int(item.get("time") or 0), reverse=True)555 return rows556 557 558def _normalize_orders(payload: Any) -> List[Dict[str, Any]]:559 rows: List[Dict[str, Any]] = []560 if not isinstance(payload, list):561 return rows562 563 for item in payload:564 if not isinstance(item, dict):565 continue566 order = item.get("order") if isinstance(item.get("order"), dict) else item567 rows.append(568 {569 "coin": order.get("coin", "-"),570 "side": order.get("side", "-"),571 "limit_px": order.get("limitPx") or order.get("px"),572 "size": order.get("sz") or order.get("origSz"),573 "timestamp": item.get("statusTimestamp")574 or order.get("timestamp")575 or order.get("time"),576 "status": item.get("status") or order.get("status") or "-",577 "oid": order.get("oid"),578 "order_type": order.get("orderType") or "-",579 }580 )581 582 rows.sort(key=lambda item: int(item.get("timestamp") or 0), reverse=True)583 return rows584 585 586def _direction_bucket(direction: Any) -> str:587 text = str(direction or "").strip().lower()588 if "open" in text and "long" in text:589 return "open_long"590 if "close" in text and "long" in text:591 return "close_long"592 if "open" in text and "short" in text:593 return "open_short"594 if "close" in text and "short" in text:595 return "close_short"596 if text in {"b", "buy"}:597 return "buy"598 if text in {"s", "sell"}:599 return "sell"600 return "other"601 602 603def _average(values: Iterable[Optional[float]]) -> Optional[float]:604 clean_values = [value for value in values if value is not None]605 if not clean_values:606 return None607 return round(sum(clean_values) / len(clean_values), 12)608 609 610def _is_spot_coin(coin: str) -> bool:611 return "/" in coin or coin.startswith("@")612 613 614def _safe_info_query(payload: Dict[str, Any]) -> Any:615 try:616 return _post_info(payload)617 except SystemExit:618 return None619 620 621def _market_context_for_coin(coin: str, interval: str, start_ms: int, end_ms: int) -> Dict[str, Any]:622 candles = _normalize_candles(623 _safe_info_query(624 {625 "type": "candleSnapshot",626 "req": {627 "coin": coin,628 "interval": interval,629 "startTime": start_ms,630 "endTime": end_ms,631 },632 }633 )634 )635 funding_history: List[Dict[str, Any]] = []636 if not _is_spot_coin(coin):637 funding_history = _normalize_funding_history(638 _safe_info_query(639 {640 "type": "fundingHistory",641 "coin": coin,642 "startTime": start_ms,643 "endTime": end_ms,644 }645 )646 )647 648 candle_change = None649 if candles:650 candle_change = _percent_change(candles[-1].get("close"), candles[0].get("open"))651 652 funding_average = _average(_safe_float(item.get("funding_rate")) for item in funding_history)653 return {654 "coin": coin,655 "interval": interval,656 "candle_count": len(candles),657 "price_change_pct": candle_change,658 "window_open": candles[0].get("open") if candles else None,659 "window_close": candles[-1].get("close") if candles else None,660 "average_funding_rate": funding_average,661 "funding_samples": len(funding_history),662 }663 664 665def _build_coin_review(coin: str, fills: List[Dict[str, Any]], interval: str, start_ms: int, end_ms: int) -> Dict[str, Any]:666 pnl_values = [_safe_float(fill.get("closed_pnl")) for fill in fills]667 fee_values = [_safe_float(fill.get("fee")) for fill in fills]668 scored = [value for value in pnl_values if value is not None]669 wins = [value for value in scored if value > 0]670 losses = [value for value in scored if value < 0]671 breakeven = [value for value in scored if value == 0]672 673 direction_counts = Counter(_direction_bucket(fill.get("dir")) for fill in fills)674 market_context = _market_context_for_coin(coin, interval, start_ms, end_ms)675 total_pnl = sum(value for value in pnl_values if value is not None)676 total_fees = sum(value for value in fee_values if value is not None)677 net_after_fees = total_pnl - total_fees678 679 if direction_counts["open_long"] > direction_counts["open_short"]:680 open_bias = "long"681 elif direction_counts["open_short"] > direction_counts["open_long"]:682 open_bias = "short"683 elif direction_counts["open_long"] or direction_counts["open_short"]:684 open_bias = "mixed"685 else:686 open_bias = "none"687 688 return {689 "coin": coin,690 "fill_count": len(fills),691 "realized_pnl": total_pnl,692 "total_fees": total_fees,693 "net_after_fees": net_after_fees,694 "wins": len(wins),695 "losses": len(losses),696 "breakeven": len(breakeven),697 "win_rate_pct": (len(wins) / (len(wins) + len(losses)) * 100) if (len(wins) + len(losses)) else None,698 "open_long_count": direction_counts["open_long"],699 "open_short_count": direction_counts["open_short"],700 "close_long_count": direction_counts["close_long"],701 "close_short_count": direction_counts["close_short"],702 "open_bias": open_bias,703 "market_context": market_context,704 }705 706 707def _review_findings(summary: Dict[str, Any], coin_reviews: List[Dict[str, Any]]) -> List[str]:708 findings: List[str] = []709 710 if summary["fill_count"] == 0:711 return ["No fills were found in the requested review window."]712 713 if summary["outcome_fill_count"] == 0:714 findings.append("Most fills in this window look like opens or adjustments, so realized-outcome review is limited until positions close.")715 716 if summary["net_after_fees"] < 0:717 findings.append(718 f"Net realized PnL after fees was negative ({_compact_number(summary['net_after_fees'])} USDC-equivalent units in reported fill terms)."719 )720 elif summary["net_after_fees"] > 0:721 findings.append(722 f"Net realized PnL after fees was positive ({_compact_number(summary['net_after_fees'])} USDC-equivalent units in reported fill terms)."723 )724 725 realized_abs = abs(summary["realized_pnl"])726 if summary["total_fees"] > 0:727 if realized_abs == 0:728 findings.append("Fees were non-trivial while realized PnL stayed flat, which usually means churn without enough edge.")729 elif summary["total_fees"] / realized_abs >= 0.25:730 ratio_pct = (summary["total_fees"] / realized_abs) * 100731 findings.append(f"Fees consumed about {ratio_pct:.1f}% of absolute realized PnL, so execution efficiency is materially affecting results.")732 733 if summary["fill_count"] >= 20 and summary["net_after_fees"] < 0:734 win_rate = summary.get("win_rate_pct")735 if win_rate is None or win_rate < 45:736 findings.append("Activity was high relative to results, which suggests overtrading in this review window.")737 738 if coin_reviews:739 worst_coin = min(coin_reviews, key=lambda item: item["net_after_fees"])740 best_coin = max(coin_reviews, key=lambda item: item["net_after_fees"])741 if worst_coin["net_after_fees"] < 0:742 findings.append(743 f"The weakest coin was {worst_coin['coin']} with net after fees of {_compact_number(worst_coin['net_after_fees'])}."744 )745 if best_coin["net_after_fees"] > 0 and best_coin["coin"] != worst_coin["coin"]:746 findings.append(747 f"The strongest coin was {best_coin['coin']} with net after fees of {_compact_number(best_coin['net_after_fees'])}."748 )749 750 for item in coin_reviews:751 market_change = item["market_context"].get("price_change_pct")752 if item["net_after_fees"] >= 0 or market_change is None:753 continue754 if market_change > 2 and item["open_short_count"] > item["open_long_count"]:755 findings.append(f"{item['coin']}: losses came while leaning short into a rising market window.")756 elif market_change < -2 and item["open_long_count"] > item["open_short_count"]:757 findings.append(f"{item['coin']}: losses came while leaning long into a falling market window.")758 759 deduped: List[str] = []760 for finding in findings:761 if finding not in deduped:762 deduped.append(finding)763 return deduped[:6]764 765 766def _recent_fill_rows(fills: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:767 rows = []768 for fill in _limit_items(fills, limit):769 rows.append(770 {771 "time": fill.get("time"),772 "coin": fill.get("coin"),773 "dir": fill.get("dir"),774 "px": fill.get("px"),775 "sz": fill.get("sz"),776 "closed_pnl": fill.get("closed_pnl"),777 "fee": fill.get("fee"),778 "fee_token": fill.get("fee_token"),779 }780 )781 return rows782 783 784def _coin_slug(coin: str) -> str:785 slug = str(coin or "market").strip().lower()786 for old, new in (("/", "-"), (":", "-"), ("@", "spot-"), (" ", "-")):787 slug = slug.replace(old, new)788 return slug or "market"789 790 791def _default_export_path(coin: str, interval: str, hours: float) -> Path:792 hour_label = str(int(hours)) if float(hours).is_integer() else str(hours).replace(".", "p")793 filename = f"hyperliquid-{_coin_slug(coin)}-{interval}-{hour_label}h.json"794 return Path.cwd() / filename795 796 797def _write_json_file(path: Path, payload: Dict[str, Any]) -> None:798 path.parent.mkdir(parents=True, exist_ok=True)799 path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")800 801 802def _export_summary(candles: List[Dict[str, Any]], funding_history: List[Dict[str, Any]]) -> Dict[str, Any]:803 candle_change = None804 if candles:805 candle_change = _percent_change(candles[-1].get("close"), candles[0].get("open"))806 return {807 "candle_count": len(candles),808 "funding_count": len(funding_history),809 "window_open": candles[0].get("open") if candles else None,810 "window_close": candles[-1].get("close") if candles else None,811 "price_change_pct": candle_change,812 "average_funding_rate": _average(_safe_float(item.get("funding_rate")) for item in funding_history),813 }814 815 816def run_dexs(_args: argparse.Namespace) -> Dict[str, Any]:817 payload = _post_info({"type": "perpDexs"})818 rows = _normalize_dexs(payload)819 return {"api_url": _info_url(), "count": len(rows), "dexs": rows}820 821 822def run_markets(args: argparse.Namespace) -> Dict[str, Any]:823 payload: Dict[str, Any] = {"type": "metaAndAssetCtxs"}824 if args.dex:825 payload["dex"] = args.dex826 rows = _normalize_perp_markets(_post_info(payload))827 828 if args.sort == "name":829 rows.sort(key=lambda item: item["coin"])830 elif args.sort == "oi":831 rows.sort(key=lambda item: _safe_float(item.get("open_interest")) or 0.0, reverse=True)832 elif args.sort == "funding_abs":833 rows.sort(key=lambda item: abs(_safe_float(item.get("funding")) or 0.0), reverse=True)834 elif args.sort == "change_abs":835 rows.sort(key=lambda item: abs(_safe_float(item.get("change_pct")) or 0.0), reverse=True)836 else:837 rows.sort(key=lambda item: _safe_float(item.get("day_ntl_vlm")) or 0.0, reverse=True)838 839 return {840 "dex": args.dex or "",841 "count": len(rows),842 "sort": args.sort,843 "markets": _limit_items(rows, args.limit),844 }845 846 847def run_spots(args: argparse.Namespace) -> Dict[str, Any]:848 rows = _normalize_spot_markets(_post_info({"type": "spotMetaAndAssetCtxs"}))849 850 if args.sort == "name":851 rows.sort(key=lambda item: item["display_name"])852 elif args.sort == "change_abs":853 rows.sort(key=lambda item: abs(_safe_float(item.get("change_pct")) or 0.0), reverse=True)854 else:855 rows.sort(key=lambda item: _safe_float(item.get("day_ntl_vlm")) or 0.0, reverse=True)856 857 return {"count": len(rows), "sort": args.sort, "pairs": _limit_items(rows, args.limit)}858 859 860def run_candles(args: argparse.Namespace) -> Dict[str, Any]:861 end_ms = int(time.time() * 1000)862 start_ms = _hours_ago_ms(args.hours, end_ms)863 payload = {864 "type": "candleSnapshot",865 "req": {866 "coin": args.coin,867 "interval": args.interval,868 "startTime": start_ms,869 "endTime": end_ms,870 },871 }872 candles = _normalize_candles(_post_info(payload))873 summary = {}874 if candles:875 highs = [_safe_float(item.get("high")) for item in candles]876 lows = [_safe_float(item.get("low")) for item in candles]877 clean_highs = [value for value in highs if value is not None]878 clean_lows = [value for value in lows if value is not None]879 summary = {880 "first_time": candles[0]["time"],881 "last_time": candles[-1]["time"],882 "open": candles[0]["open"],883 "close": candles[-1]["close"],884 "high": max(clean_highs) if clean_highs else None,885 "low": min(clean_lows) if clean_lows else None,886 "change_pct": _percent_change(candles[-1]["close"], candles[0]["open"]),887 }888 return {889 "coin": args.coin,890 "interval": args.interval,891 "hours": args.hours,892 "count": len(candles),893 "summary": summary,894 "candles": _limit_items(candles, args.limit),895 }896 897 898def run_funding(args: argparse.Namespace) -> Dict[str, Any]:899 end_ms = int(time.time() * 1000)900 start_ms = _hours_ago_ms(args.hours, end_ms)901 payload = {"type": "fundingHistory", "coin": args.coin, "startTime": start_ms, "endTime": end_ms}902 rows = _normalize_funding_history(_post_info(payload))903 avg_rate = None904 if rows:905 values = [_safe_float(item.get("funding_rate")) for item in rows]906 clean_values = [value for value in values if value is not None]907 if clean_values:908 avg_rate = sum(clean_values) / len(clean_values)909 return {910 "coin": args.coin,911 "hours": args.hours,912 "count": len(rows),913 "average_funding_rate": avg_rate,914 "history": _limit_items(list(reversed(rows)), args.limit),915 }916 917 918def run_l2(args: argparse.Namespace) -> Dict[str, Any]:919 payload: Dict[str, Any] = {"type": "l2Book", "coin": args.coin}920 if args.n_sig_figs is not None:921 payload["nSigFigs"] = args.n_sig_figs922 if args.mantissa is not None:923 payload["mantissa"] = args.mantissa924 raw = _post_info(payload)925 levels = _normalize_book_levels(raw)926 return {927 "coin": args.coin,928 "time": raw.get("time") if isinstance(raw, dict) else None,929 "bids": _limit_items(levels["bids"], args.levels),930 "asks": _limit_items(levels["asks"], args.levels),931 }932 933 934def run_state(args: argparse.Namespace) -> Dict[str, Any]:935 user = _resolve_user(args.user)936 payload: Dict[str, Any] = {"type": "clearinghouseState", "user": user}937 if args.dex:938 payload["dex"] = args.dex939 normalized = _normalize_positions(_post_info(payload))940 return {941 "user": user,942 "dex": args.dex or "",943 "summary": normalized["summary"],944 "positions": normalized["positions"],945 }946 947 948def run_spot_balances(args: argparse.Namespace) -> Dict[str, Any]:949 user = _resolve_user(args.user)950 payload = {"type": "spotClearinghouseState", "user": user}951 rows = _normalize_spot_balances(_post_info(payload))952 return {"user": user, "count": len(rows), "balances": _limit_items(rows, args.limit)}953 954 955def run_fills(args: argparse.Namespace) -> Dict[str, Any]:956 user = _resolve_user(args.user)957 payload: Dict[str, Any] = {"user": user}958 if args.hours is not None:959 payload["type"] = "userFillsByTime"960 payload["startTime"] = _hours_ago_ms(args.hours)961 else:962 payload["type"] = "userFills"963 if args.aggregate_by_time:964 payload["aggregateByTime"] = True965 rows = _normalize_fills(_post_info(payload))966 return {967 "user": user,968 "hours": args.hours,969 "aggregate_by_time": args.aggregate_by_time,970 "count": len(rows),971 "fills": _limit_items(rows, args.limit),972 }973 974 975def run_orders(args: argparse.Namespace) -> Dict[str, Any]:976 user = _resolve_user(args.user)977 payload = {"type": "historicalOrders", "user": user}978 rows = _normalize_orders(_post_info(payload))979 return {"user": user, "count": len(rows), "orders": _limit_items(rows, args.limit)}980 981 982def run_review(args: argparse.Namespace) -> Dict[str, Any]:983 user = _resolve_user(args.user)984 end_ms = int(time.time() * 1000)985 start_ms = _hours_ago_ms(args.hours, end_ms)986 payload: Dict[str, Any] = {"type": "userFillsByTime", "user": user, "startTime": start_ms}987 if args.aggregate_by_time:988 payload["aggregateByTime"] = True989 990 fills = _normalize_fills(_post_info(payload))991 if args.coin:992 target = args.coin.lower()993 fills = [fill for fill in fills if str(fill.get("coin", "")).lower() == target]994 fills = _limit_items(fills, args.fills)995 996 grouped: Dict[str, List[Dict[str, Any]]] = {}997 for fill in fills:998 grouped.setdefault(fill.get("coin", "-"), []).append(fill)999 1000 coin_reviews = [1001 _build_coin_review(coin, coin_fills, args.interval, start_ms, end_ms)1002 for coin, coin_fills in sorted(grouped.items(), key=lambda item: len(item[1]), reverse=True)1003 ]1004 1005 pnl_values = [_safe_float(fill.get("closed_pnl")) for fill in fills]1006 fee_values = [_safe_float(fill.get("fee")) for fill in fills]1007 scored = [value for value in pnl_values if value is not None]1008 wins = [value for value in scored if value > 0]1009 losses = [value for value in scored if value < 0]1010 direction_counts = Counter(_direction_bucket(fill.get("dir")) for fill in fills)1011 total_pnl = sum(value for value in pnl_values if value is not None)1012 total_fees = sum(value for value in fee_values if value is not None)1013 1014 summary = {1015 "fill_count": len(fills),1016 "scored_fill_count": len(scored),1017 "outcome_fill_count": len(wins) + len(losses),1018 "unique_coins": len(grouped),1019 "realized_pnl": total_pnl,1020 "total_fees": total_fees,1021 "net_after_fees": total_pnl - total_fees,1022 "wins": len(wins),1023 "losses": len(losses),1024 "breakeven": len([value for value in scored if value == 0]),1025 "win_rate_pct": (len(wins) / (len(wins) + len(losses)) * 100) if (len(wins) + len(losses)) else None,1026 "open_long_count": direction_counts["open_long"],1027 "open_short_count": direction_counts["open_short"],1028 "close_long_count": direction_counts["close_long"],1029 "close_short_count": direction_counts["close_short"],1030 }1031 1032 return {1033 "user": user,1034 "coin_filter": args.coin,1035 "hours": args.hours,1036 "interval": args.interval,1037 "fills_requested": args.fills,1038 "summary": summary,1039 "findings": _review_findings(summary, coin_reviews),1040 "coin_reviews": coin_reviews,1041 "recent_fills": _recent_fill_rows(fills, args.recent),1042 }1043 1044 1045def run_export(args: argparse.Namespace) -> Dict[str, Any]:1046 end_ms = args.end_time_ms if args.end_time_ms is not None else int(time.time() * 1000)1047 start_ms = _hours_ago_ms(args.hours, end_ms)1048 1049 candle_payload = {1050 "type": "candleSnapshot",1051 "req": {1052 "coin": args.coin,1053 "interval": args.interval,1054 "startTime": start_ms,1055 "endTime": end_ms,1056 },1057 }1058 candles = _normalize_candles(_post_info(candle_payload))1059 1060 funding_history: List[Dict[str, Any]] = []1061 if not _is_spot_coin(args.coin):1062 funding_history = _normalize_funding_history(1063 _safe_info_query(1064 {1065 "type": "fundingHistory",1066 "coin": args.coin,1067 "startTime": start_ms,1068 "endTime": end_ms,1069 }1070 )1071 )1072 1073 output_path = Path(args.output) if args.output else _default_export_path(args.coin, args.interval, args.hours)1074 payload = {1075 "schema_version": "hyperliquid-market-export-v1",1076 "source": {1077 "api_url": _info_url(),1078 "interval": args.interval,1079 "coin": args.coin,1080 "market_type": "spot" if _is_spot_coin(args.coin) else "perp",1081 },1082 "window": {1083 "start_time_ms": start_ms,1084 "end_time_ms": end_ms,1085 "hours": args.hours,1086 },1087 "summary": _export_summary(candles, funding_history),1088 "candles": candles,1089 "funding_history": funding_history,1090 }1091 _write_json_file(output_path, payload)1092 return {1093 "coin": args.coin,1094 "interval": args.interval,1095 "hours": args.hours,1096 "output_path": str(output_path),1097 "summary": payload["summary"],1098 "schema_version": payload["schema_version"],1099 }1100 1101 1102def render_dexs(data: Dict[str, Any]) -> str:1103 rows = [1104 {1105 "label": item["label"],1106 "full_name": item["full_name"],1107 "deployer": _short_address(item["deployer"]),1108 "asset_caps": item["asset_caps"],1109 }1110 for item in data["dexs"]1111 ]1112 return "\n".join(1113 [1114 f"API: {data['api_url']}",1115 f"Perp dexs: {data['count']}",1116 "",1117 _render_table(1118 [1119 ("Dex", "label"),1120 ("Full Name", "full_name"),1121 ("Deployer", "deployer"),1122 ("Asset Caps", "asset_caps"),1123 ],1124 rows,1125 ),1126 ]1127 )1128 1129 1130def render_markets(data: Dict[str, Any]) -> str:1131 rows = [1132 {1133 "coin": item["coin"],1134 "mark_px": _format_price(item["mark_px"]),1135 "change_pct": _format_percent(item["change_pct"]),1136 "funding": _format_fraction_percent(item["funding"]),1137 "open_interest": _compact_number(item["open_interest"]),1138 "day_ntl_vlm": _compact_number(item["day_ntl_vlm"]),1139 }1140 for item in data["markets"]1141 ]1142 lines = [1143 f"Dex: {data['dex'] or 'first-perp-dex'}",1144 f"Markets returned: {len(data['markets'])} of {data['count']}",1145 "",1146 _render_table(1147 [1148 ("Coin", "coin"),1149 ("Mark", "mark_px"),1150 ("Chg", "change_pct"),1151 ("Funding", "funding"),1152 ("OI", "open_interest"),1153 ("24h Vol", "day_ntl_vlm"),1154 ],1155 rows,1156 ),1157 ]1158 return "\n".join(lines)1159 1160 1161def render_spots(data: Dict[str, Any]) -> str:1162 rows = [1163 {1164 "pair": item["display_name"],1165 "mark_px": _format_price(item["mark_px"]),1166 "change_pct": _format_percent(item["change_pct"]),1167 "day_ntl_vlm": _compact_number(item["day_ntl_vlm"]),1168 }1169 for item in data["pairs"]1170 ]1171 return "\n".join(1172 [1173 f"Spot pairs returned: {len(data['pairs'])} of {data['count']}",1174 "",1175 _render_table(1176 [1177 ("Pair", "pair"),1178 ("Mark", "mark_px"),1179 ("Chg", "change_pct"),1180 ("24h Vol", "day_ntl_vlm"),1181 ],1182 rows,1183 ),1184 ]1185 )1186 1187 1188def render_candles(data: Dict[str, Any]) -> str:1189 rows = [1190 {1191 "time": _format_timestamp_ms(item["time"]),1192 "open": _format_price(item["open"]),1193 "high": _format_price(item["high"]),1194 "low": _format_price(item["low"]),1195 "close": _format_price(item["close"]),1196 "volume": _compact_number(item["volume"]),1197 }1198 for item in data["candles"]1199 ]1200 summary = data.get("summary") or {}1201 lines = [1202 f"Coin: {data['coin']}",1203 f"Interval: {data['interval']}",1204 f"Hours: {data['hours']}",1205 f"Candles returned: {len(data['candles'])} of {data['count']}",1206 ]1207 if summary:1208 lines.extend(1209 [1210 f"Open -> Close: {_format_price(summary.get('open'))} -> {_format_price(summary.get('close'))}",1211 f"Range: {_format_price(summary.get('low'))} to {_format_price(summary.get('high'))}",1212 f"Change: {_format_percent(summary.get('change_pct'))}",1213 ]1214 )1215 lines.extend(1216 [1217 "",1218 _render_table(1219 [1220 ("Time", "time"),1221 ("Open", "open"),1222 ("High", "high"),1223 ("Low", "low"),1224 ("Close", "close"),1225 ("Volume", "volume"),1226 ],1227 rows,1228 ),1229 ]1230 )1231 return "\n".join(lines)1232 1233 1234def render_funding(data: Dict[str, Any]) -> str:1235 rows = [1236 {1237 "time": _format_timestamp_ms(item["time"]),1238 "coin": item["coin"],1239 "funding": _format_fraction_percent(item["funding_rate"]),1240 "premium": _format_fraction_percent(item["premium"]),1241 }1242 for item in data["history"]1243 ]1244 lines = [1245 f"Coin: {data['coin']}",1246 f"Hours: {data['hours']}",1247 f"Entries returned: {len(data['history'])} of {data['count']}",1248 f"Average funding: {_format_fraction_percent(data['average_funding_rate'])}",1249 "",1250 _render_table(1251 [1252 ("Time", "time"),1253 ("Coin", "coin"),1254 ("Funding", "funding"),1255 ("Premium", "premium"),1256 ],1257 rows,1258 ),1259 ]1260 return "\n".join(lines)1261 1262 1263def render_l2(data: Dict[str, Any]) -> str:1264 bid_rows = [1265 {"px": _format_price(item["px"]), "sz": _compact_number(item["sz"]), "orders": item["orders"] or "-"}1266 for item in data["bids"]1267 ]1268 ask_rows = [1269 {"px": _format_price(item["px"]), "sz": _compact_number(item["sz"]), "orders": item["orders"] or "-"}1270 for item in data["asks"]1271 ]1272 lines = [1273 f"Coin: {data['coin']}",1274 f"Book time: {_format_timestamp_ms(data['time'])}",1275 "",1276 "Bids",1277 _render_table([("Price", "px"), ("Size", "sz"), ("Orders", "orders")], bid_rows),1278 "",1279 "Asks",1280 _render_table([("Price", "px"), ("Size", "sz"), ("Orders", "orders")], ask_rows),1281 ]1282 return "\n".join(lines)1283 1284 1285def render_state(data: Dict[str, Any]) -> str:1286 summary = data["summary"]1287 position_rows = [1288 {1289 "coin": item["coin"],1290 "size": item["size"],1291 "entry_px": _format_price(item["entry_px"]),1292 "position_value": _compact_number(item["position_value"]),1293 "unrealized_pnl": _compact_number(item["unrealized_pnl"]),1294 "roe": _format_fraction_percent(item["return_on_equity"], 2),1295 "liq": _format_price(item["liquidation_px"]),1296 "lev": f"{item['leverage'] or '-'}x",1297 }1298 for item in data["positions"]1299 ]1300 1301 lines = [1302 f"User: {data['user']}",1303 f"Dex: {data['dex'] or 'first-perp-dex'}",1304 f"Account value: {summary.get('account_value') or '-'}",1305 f"Total notional position: {summary.get('total_ntl_pos') or '-'}",1306 f"Withdrawable: {summary.get('withdrawable') or '-'}",1307 f"Positions: {len(data['positions'])}",1308 ]1309 if position_rows:1310 lines.extend(1311 [1312 "",1313 _render_table(1314 [1315 ("Coin", "coin"),1316 ("Size", "size"),1317 ("Entry", "entry_px"),1318 ("Pos Val", "position_value"),1319 ("uPnL", "unrealized_pnl"),1320 ("ROE", "roe"),1321 ("Liq", "liq"),1322 ("Lev", "lev"),1323 ],1324 position_rows,1325 ),1326 ]1327 )1328 return "\n".join(lines)1329 1330 1331def render_spot_balances(data: Dict[str, Any]) -> str:1332 rows = [1333 {1334 "coin": item["coin"],1335 "total": _compact_number(item["total"]),1336 "hold": _compact_number(item["hold"]),1337 "entry_ntl": _compact_number(item["entry_ntl"]),1338 }1339 for item in data["balances"]1340 ]1341 return "\n".join(1342 [1343 f"User: {data['user']}",1344 f"Balances returned: {len(data['balances'])} of {data['count']}",1345 "",1346 _render_table(1347 [1348 ("Coin", "coin"),1349 ("Total", "total"),1350 ("Hold", "hold"),1351 ("Entry Ntl", "entry_ntl"),1352 ],1353 rows,1354 ),1355 ]1356 )1357 1358 1359def render_fills(data: Dict[str, Any]) -> str:1360 rows = [1361 {1362 "time": _format_timestamp_ms(item["time"]),1363 "coin": item["coin"],1364 "dir": item["dir"],1365 "px": _format_price(item["px"]),1366 "sz": _compact_number(item["sz"]),1367 "closed_pnl": _compact_number(item["closed_pnl"]),1368 "fee": f"{_compact_number(item['fee'])} {item['fee_token'] or ''}".strip(),1369 }1370 for item in data["fills"]1371 ]1372 lines = [1373 f"User: {data['user']}",1374 f"Aggregate by time: {data['aggregate_by_time']}",1375 f"Fills returned: {len(data['fills'])} of {data['count']}",1376 "",1377 _render_table(1378 [1379 ("Time", "time"),1380 ("Coin", "coin"),1381 ("Dir", "dir"),1382 ("Px", "px"),1383 ("Sz", "sz"),1384 ("Closed PnL", "closed_pnl"),1385 ("Fee", "fee"),1386 ],1387 rows,1388 ),1389 ]1390 return "\n".join(lines)1391 1392 1393def render_orders(data: Dict[str, Any]) -> str:1394 rows = [1395 {1396 "time": _format_timestamp_ms(item["timestamp"]),1397 "coin": item["coin"],1398 "side": item["side"],1399 "limit_px": _format_price(item["limit_px"]),1400 "size": _compact_number(item["size"]),1401 "status": item["status"],1402 "oid": item["oid"] or "-",1403 }1404 for item in data["orders"]1405 ]1406 return "\n".join(1407 [1408 f"User: {data['user']}",1409 f"Orders returned: {len(data['orders'])} of {data['count']}",1410 "",1411 _render_table(1412 [1413 ("Time", "time"),1414 ("Coin", "coin"),1415 ("Side", "side"),1416 ("Px", "limit_px"),1417 ("Sz", "size"),1418 ("Status", "status"),1419 ("OID", "oid"),1420 ],1421 rows,1422 ),1423 ]1424 )1425 1426 1427def render_review(data: Dict[str, Any]) -> str:1428 summary = data["summary"]1429 coin_rows = [1430 {1431 "coin": item["coin"],1432 "fills": item["fill_count"],1433 "net": _compact_number(item["net_after_fees"]),1434 "win_rate": _format_percent(item["win_rate_pct"]),1435 "trend": _format_percent(item["market_context"].get("price_change_pct")),1436 "funding": _format_fraction_percent(item["market_context"].get("average_funding_rate")),1437 "bias": item["open_bias"],1438 }1439 for item in data["coin_reviews"]1440 ]1441 recent_rows = [1442 {1443 "time": _format_timestamp_ms(item["time"]),1444 "coin": item["coin"],1445 "dir": item["dir"],1446 "px": _format_price(item["px"]),1447 "sz": _compact_number(item["sz"]),1448 "closed_pnl": _compact_number(item["closed_pnl"]),1449 "fee": f"{_compact_number(item['fee'])} {item['fee_token'] or ''}".strip(),1450 }1451 for item in data["recent_fills"]1452 ]1453 1454 lines = [1455 f"User: {data['user']}",1456 f"Review window: {data['hours']} hours",1457 f"Coin filter: {data['coin_filter'] or 'all traded coins'}",1458 f"Fills analyzed: {summary['fill_count']}",1459 f"Unique coins: {summary['unique_coins']}",1460 f"Realized PnL: {_compact_number(summary['realized_pnl'])}",1461 f"Fees: {_compact_number(summary['total_fees'])}",1462 f"Net after fees: {_compact_number(summary['net_after_fees'])}",1463 f"Win rate: {_format_percent(summary['win_rate_pct'])}",1464 ]1465 1466 if data["findings"]:1467 lines.extend(["", "Findings"])1468 for finding in data["findings"]:1469 lines.append(f"- {finding}")1470 1471 if coin_rows:1472 lines.extend(1473 [1474 "",1475 "Coin Breakdown",1476 _render_table(1477 [1478 ("Coin", "coin"),1479 ("Fills", "fills"),1480 ("Net", "net"),1481 ("Win Rate", "win_rate"),1482 ("Trend", "trend"),1483 ("Funding", "funding"),1484 ("Bias", "bias"),1485 ],1486 coin_rows,1487 ),1488 ]1489 )1490 1491 if recent_rows:1492 lines.extend(1493 [1494 "",1495 "Recent Fills",1496 _render_table(1497 [1498 ("Time", "time"),1499 ("Coin", "coin"),1500 ("Dir", "dir"),1501 ("Px", "px"),1502 ("Sz", "sz"),1503 ("Closed PnL", "closed_pnl"),1504 ("Fee", "fee"),1505 ],1506 recent_rows,1507 ),1508 ]1509 )1510 1511 return "\n".join(lines)1512 1513 1514def render_export(data: Dict[str, Any]) -> str:1515 summary = data["summary"]1516 return "\n".join(1517 [1518 f"Coin: {data['coin']}",1519 f"Interval: {data['interval']}",1520 f"Hours: {data['hours']}",1521 f"Schema: {data['schema_version']}",1522 f"Output: {data['output_path']}",1523 f"Candles: {summary['candle_count']}",1524 f"Funding samples: {summary['funding_count']}",1525 f"Window open -> close: {_format_price(summary.get('window_open'))} -> {_format_price(summary.get('window_close'))}",1526 f"Price change: {_format_percent(summary.get('price_change_pct'))}",1527 f"Average funding: {_format_fraction_percent(summary.get('average_funding_rate'))}",1528 ]1529 )1530 1531 1532def _add_json_flag(parser: argparse.ArgumentParser) -> None:1533 parser.add_argument("--json", action="store_true", help="Print raw JSON output")1534 1535 1536def build_parser() -> argparse.ArgumentParser:1537 parser = argparse.ArgumentParser(description="Hyperliquid CLI Tool for Hermes Agent")1538 subparsers = parser.add_subparsers(dest="command", required=True)1539 1540 dexs = subparsers.add_parser("dexs", help="List available perpetual dexs")1541 _add_json_flag(dexs)1542 dexs.set_defaults(func=run_dexs, renderer=render_dexs)1543 1544 markets = subparsers.add_parser("markets", help="List perpetual market contexts")1545 markets.add_argument("--dex", default="", help="Perp dex name; empty means first perp dex")1546 markets.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all")1547 markets.add_argument(1548 "--sort",1549 choices=["volume", "oi", "funding_abs", "change_abs", "name"],1550 default="volume",1551 help="Sort mode",1552 )1553 _add_json_flag(markets)1554 markets.set_defaults(func=run_markets, renderer=render_markets)1555 1556 spots = subparsers.add_parser("spots", help="List spot market contexts")1557 spots.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all")1558 spots.add_argument(1559 "--sort",1560 choices=["volume", "change_abs", "name"],1561 default="volume",1562 help="Sort mode",1563 )1564 _add_json_flag(spots)1565 spots.set_defaults(func=run_spots, renderer=render_spots)1566 1567 candles = subparsers.add_parser("candles", help="Fetch candle history for a market")1568 candles.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC" or "mydex:BTC"')1569 candles.add_argument("--interval", default="1h", help="Candle interval, e.g. 1m, 15m, 1h, 4h, 1d")1570 candles.add_argument("--hours", type=float, default=24.0, help="Lookback window in hours")1571 candles.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all")1572 _add_json_flag(candles)1573 candles.set_defaults(func=run_candles, renderer=render_candles)1574 1575 funding = subparsers.add_parser("funding", help="Fetch funding history for a perp market")1576 funding.add_argument("coin", help='Coin name, e.g. "BTC" or "mydex:COIN"')1577 funding.add_argument("--hours", type=float, default=72.0, help="Lookback window in hours")1578 funding.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all")1579 _add_json_flag(funding)1580 funding.set_defaults(func=run_funding, renderer=render_funding)1581 1582 l2 = subparsers.add_parser("l2", help="Inspect the current L2 book for a market")1583 l2.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC"')1584 l2.add_argument("--levels", type=int, default=10, help="Levels per side to display")1585 l2.add_argument("--n-sig-figs", type=int, default=None, help="Optional server-side book aggregation")1586 l2.add_argument("--mantissa", type=int, default=None, help="Optional mantissa when using nSigFigs")1587 _add_json_flag(l2)1588 l2.set_defaults(func=run_l2, renderer=render_l2)1589 1590 state = subparsers.add_parser("state", help="Inspect a user's perp account state")1591 state.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}")1592 state.add_argument("--dex", default="", help="Perp dex name; empty means first perp dex")1593 _add_json_flag(state)1594 state.set_defaults(func=run_state, renderer=render_state)1595 1596 spot_balances = subparsers.add_parser("spot-balances", help="Inspect a user's spot token balances")1597 spot_balances.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}")1598 spot_balances.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all")1599 _add_json_flag(spot_balances)1600 spot_balances.set_defaults(func=run_spot_balances, renderer=render_spot_balances)1601 1602 fills = subparsers.add_parser("fills", help="Inspect a user's recent fills")1603 fills.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}")1604 fills.add_argument("--hours", type=float, default=None, help="Optional time window; uses userFillsByTime")1605 fills.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all")1606 fills.add_argument(1607 "--aggregate-by-time",1608 action="store_true",1609 help="Aggregate partial fills when the API supports it",1610 )1611 _add_json_flag(fills)1612 fills.set_defaults(func=run_fills, renderer=render_fills)1613 1614 orders = subparsers.add_parser("orders", help="Inspect a user's historical orders")1615 orders.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}")1616 orders.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all")1617 _add_json_flag(orders)1618 orders.set_defaults(func=run_orders, renderer=render_orders)1619 1620 review = subparsers.add_parser("review", help="Generate a lightweight post-trade review from recent fills")1621 review.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}")1622 review.add_argument("--coin", default="", help="Optional exact coin filter, e.g. BTC or PURR/USDC")1623 review.add_argument("--hours", type=float, default=72.0, help="Lookback window in hours")1624 review.add_argument("--fills", type=int, default=50, help="Maximum fills to analyze")1625 review.add_argument("--recent", type=int, default=10, help="Recent fills to display in the review")1626 review.add_argument("--interval", default="1h", help="Candle interval for market context")1627 review.add_argument(1628 "--aggregate-by-time",1629 action="store_true",1630 help="Aggregate partial fills when the API supports it",1631 )1632 _add_json_flag(review)1633 review.set_defaults(func=run_review, renderer=render_review)1634 1635 export = subparsers.add_parser("export", help="Export normalized candles and funding history to a JSON file")1636 export.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC" or "mydex:BTC"')1637 export.add_argument("--interval", default="1h", help="Candle interval for the exported dataset")1638 export.add_argument("--hours", type=float, default=168.0, help="Lookback window in hours")1639 export.add_argument("--end-time-ms", type=int, default=None, help="Optional fixed end time for reproducible exports")1640 export.add_argument("--output", default="", help="Path to the JSON export file")1641 _add_json_flag(export)1642 export.set_defaults(func=run_export, renderer=render_export)1643 1644 return parser1645 1646 1647def main(argv: Optional[List[str]] = None) -> int:1648 parser = build_parser()1649 args = parser.parse_args(argv)1650 1651 payload = args.func(args)1652 if args.json:1653 print(json.dumps(payload, indent=2))1654 else:1655 print(args.renderer(payload))1656 return 01657 1658 1659if __name__ == "__main__":1660 raise SystemExit(main())1661