From f2e8ed2405362a6f13098293bb056acbdabedb5a Mon Sep 17 00:00:00 2001 From: Hugo Sqr Date: Wed, 18 Mar 2026 23:01:52 +0700 Subject: [PATCH] Add unit tests for hyperliquid skill functionality - Implement tests for normalizing perpetual markets and DEXs. - Validate JSON output for main commands including markets, candles, and review. - Ensure environment variable resolution and dotenv file reading are covered. - Test export functionality for market data with expected output structure. --- .env.example | 12 + .../blockchain/hyperliquid/SKILL.md | 294 +++ .../hyperliquid/scripts/hyperliquid_client.py | 1660 +++++++++++++++++ tests/skills/test_hyperliquid_skill.py | 358 ++++ 4 files changed, 2324 insertions(+) create mode 100644 optional-skills/blockchain/hyperliquid/SKILL.md create mode 100644 optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py create mode 100644 tests/skills/test_hyperliquid_skill.py diff --git a/.env.example b/.env.example index 5c08a4acd63..6dfcbdcc612 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,18 @@ # Also requires ~/.honcho/config.json with enabled=true (see README). # HONCHO_API_KEY= +# ============================================================================= +# HYPERLIQUID OPTIONAL SKILL +# ============================================================================= +# Optional defaults for the Hyperliquid skill in optional-skills/blockchain/hyperliquid +# +# Hyperliquid API base URL override +# Default: https://api.hyperliquid.xyz +# HYPERLIQUID_API_URL=https://api.hyperliquid-testnet.xyz +# +# Default address for account-level commands like state, fills, orders, and review +# HYPERLIQUID_USER_ADDRESS=0x0000000000000000000000000000000000000000 + # ============================================================================= # TERMINAL TOOL CONFIGURATION # ============================================================================= diff --git a/optional-skills/blockchain/hyperliquid/SKILL.md b/optional-skills/blockchain/hyperliquid/SKILL.md new file mode 100644 index 00000000000..3d13f1941ee --- /dev/null +++ b/optional-skills/blockchain/hyperliquid/SKILL.md @@ -0,0 +1,294 @@ +--- +name: hyperliquid +description: Query Hyperliquid market and account data - perp dexs, perp/spot market contexts, candles, funding history, L2 books, perp state, spot balances, fills, historical orders, trade review, and normalized market-data export. Uses the public info endpoint only and needs no API key. +version: 0.1.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [Hyperliquid, Blockchain, Crypto, Trading, Perpetuals, Spot, DeFi] + related_skills: [] +--- + +# Hyperliquid Skill + +Query Hyperliquid market data and user account history through the public +`/info` endpoint. + +12 commands: dexs, perp markets, spot markets, candle history, funding history, +L2 books, perp state, spot balances, fills, historical orders, trade review, +and normalized market-data export. + +No API key needed. Uses only Python standard library (`urllib`, `json`, +`argparse`). + +--- + +## When to Use + +- User asks for Hyperliquid perp or spot market data +- User wants historical candles for a Hyperliquid market +- User wants current funding, open interest, or 24h notional volume +- User wants to inspect an address's perp positions, spot balances, fills, or historical orders +- User wants a post-trade review using fills plus surrounding market context +- User wants to inspect builder-deployed perp dexs or HIP-3 markets + +--- + +## Prerequisites + +The helper script uses only Python standard library. +No external packages or API keys are required. +It automatically reads `~/.hermes/.env` for `HYPERLIQUID_API_URL` and +`HYPERLIQUID_USER_ADDRESS`. A project `.env` in the current working directory +is treated as a dev fallback when present. + +Default API base: + +```bash +https://api.hyperliquid.xyz +``` + +Optional testnet or custom override: + +```bash +export HYPERLIQUID_API_URL="https://api.hyperliquid-testnet.xyz" +# or save it in ~/.hermes/.env +``` + +Optional default account address: + +```bash +export HYPERLIQUID_USER_ADDRESS="0x0000000000000000000000000000000000000000" +# or save it in ~/.hermes/.env +``` + +Helper script path: + +```bash +~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +``` + +--- + +## Quick Reference + +```bash +python3 hyperliquid_client.py dexs +python3 hyperliquid_client.py markets [--dex DEX] [--limit N] [--sort volume|oi|funding_abs|change_abs|name] +python3 hyperliquid_client.py spots [--limit N] +python3 hyperliquid_client.py candles [--interval 1h] [--hours 24] [--limit N] +python3 hyperliquid_client.py funding [--hours 72] [--limit N] +python3 hyperliquid_client.py l2 [--levels N] +python3 hyperliquid_client.py state [address] [--dex DEX] +python3 hyperliquid_client.py spot-balances [address] [--limit N] +python3 hyperliquid_client.py fills [address] [--hours N] [--limit N] [--aggregate-by-time] +python3 hyperliquid_client.py orders [address] [--limit N] +python3 hyperliquid_client.py review [address] [--coin COIN] [--hours N] [--fills N] +python3 hyperliquid_client.py export [--interval 1h] [--hours N] [--output PATH] +``` + +Add `--json` to any command for structured output. +For `state`, `spot-balances`, `fills`, `orders`, and `review`, the address is optional if `HYPERLIQUID_USER_ADDRESS` is set. + +--- + +## Procedure + +### 0. Setup Check + +```bash +python3 --version + +# Optional: switch to testnet +export HYPERLIQUID_API_URL="https://api.hyperliquid-testnet.xyz" + +# Optional: set a default address for account-level commands +export HYPERLIQUID_USER_ADDRESS="0x0000000000000000000000000000000000000000" + +# Confirm connectivity by listing top perp markets +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --limit 5 +``` + +### 1. Discover DEXs and Markets + +Use `dexs` to inspect the first perp dex plus any builder-deployed perp dexs. +Use `markets` to inspect mark price, change, funding, open interest, and 24h +notional volume. Use `spots` for spot pairs. + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py dexs + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --limit 15 --sort volume + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --dex mydex --limit 15 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + spots --limit 15 +``` + +Tips: +- `--dex` is only for perp endpoints; omit it for the first perp dex. +- Spot pairs may appear as `PURR/USDC` or internal aliases like `@107`. +- For HIP-3 markets, coin strings may include a dex prefix such as `mydex:BTC`. + +### 2. Pull Historical Market Data + +Use `candles` for OHLCV snapshots and `funding` for historical funding data. +This is the best starting point for backtest prototypes and trade review. + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + candles BTC --interval 1h --hours 72 --limit 48 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + funding BTC --hours 168 --limit 30 +``` + +Notes: +- The info endpoint paginates time-range endpoints. If you need more than one + response window, repeat the query with a later `startTime`. +- This helper is for interactive inspection. If you later build a real + backtester, store the returned data in local files or a database. + +### 3. Inspect Live Microstructure + +Use `l2` to inspect the current order book around a market. + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + l2 BTC --levels 10 +``` + +This is useful when the user asks: +- whether the book looks thin +- where near-term liquidity sits +- whether a large order may move the market + +### 4. Review a User's Account State + +Use `state` for perp positions and `spot-balances` for spot inventory. + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + state 0x0000000000000000000000000000000000000000 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + state + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + spot-balances +``` + +Use these when the user asks: +- "How are my positions?" +- "What am I holding?" +- "How much is withdrawable?" + +### 5. Review Fills and Orders + +Use `fills` and `orders` for recent execution history. + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + fills 0x0000000000000000000000000000000000000000 --hours 72 --limit 25 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + orders --limit 25 +``` + +### 6. Generate A Lightweight Trade Review + +Use `review` to combine recent fills with candle and funding context for each +traded coin. + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + review 0x0000000000000000000000000000000000000000 --hours 72 --fills 50 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + review --coin BTC --hours 168 +``` + +The review reports: +- realized PnL, fees, and net after fees +- win/loss counts +- coin-by-coin breakdowns +- market trend and average funding for each traded perp +- heuristics like fee drag, concentration, and counter-trend losses + +Use it as a first-pass reviewer, not a final judge. It works best when paired +with the raw `fills`, `orders`, `candles`, and `funding` commands. + +For deeper post-trade review: +1. Start with `review` to identify problem coins or windows. +2. Pull recent fills for the address. +3. Pull recent orders for the same period. +4. Pull `candles` and `funding` for each traded coin over the relevant window. +5. Judge decision quality separately from outcome quality. + +Suggested review format: +- thesis at entry +- market context +- execution quality +- sizing quality +- exit quality +- what to repeat +- what to stop doing + +### 7. Export A Reusable Market Dataset + +Use `export` to write normalized candles plus funding history to a JSON file. +This is the clean handoff point for a future local backtester. + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + export BTC --interval 1h --hours 168 --output ./btc-1h-7d.json + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + export BTC --interval 15m --hours 72 --end-time-ms 1760000000000 +``` + +The export file contains: +- schema version +- source metadata +- exact time window +- normalized candle rows +- normalized funding rows +- summary stats like price change and average funding + +Use `--end-time-ms` when you want reproducible windows for comparisons, +debugging, or future backtests. + +--- + +## Pitfalls + +- Public info endpoints are rate-limited. Large historical queries can require + multiple calls and may only return a capped window of rows. +- `fills --hours ...` uses `userFillsByTime`, which only exposes a recent + rolling history window. +- `historicalOrders` returns the most recent orders only; it is not a full + archive export. +- The `review` command is heuristic. It cannot reconstruct exact intent, order + placement quality, or true slippage from fills alone. +- The `export` command writes a normalized dataset contract, not a full + backtest engine. You still need your own fill/slippage model later. +- Spot aliases like `@107` are valid market identifiers even if the app UI + shows a friendlier name. +- Order-book data from `l2` is a point-in-time snapshot, not a time series. +- Candle/funding history is useful for review and prototyping, but it is not a + full execution simulator. Be conservative about slippage assumptions. + +--- + +## Verification + +```bash +# Should print top Hyperliquid perp markets by 24h notional volume +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --limit 5 +``` diff --git a/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py b/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py new file mode 100644 index 00000000000..1079f6b6267 --- /dev/null +++ b/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py @@ -0,0 +1,1660 @@ +#!/usr/bin/env python3 +""" +Hyperliquid CLI Tool for Hermes Agent +------------------------------------- +Queries the Hyperliquid info endpoint for market and account data. +Uses only Python standard library - no external packages required. + +Usage: + python3 hyperliquid_client.py dexs + python3 hyperliquid_client.py markets [--dex DEX] [--limit N] + python3 hyperliquid_client.py spots [--limit N] + python3 hyperliquid_client.py candles [--interval 1h] [--hours 24] + python3 hyperliquid_client.py funding [--hours 72] + python3 hyperliquid_client.py l2 [--levels 10] + python3 hyperliquid_client.py state [address] [--dex DEX] + python3 hyperliquid_client.py spot-balances [address] + python3 hyperliquid_client.py fills [address] [--hours N] [--limit N] + python3 hyperliquid_client.py orders [address] [--limit N] + python3 hyperliquid_client.py review [address] [--coin COIN] [--hours N] + python3 hyperliquid_client.py export [--interval 1h] [--hours N] + +Environment: + HYPERLIQUID_API_URL Override API base URL + (default: https://api.hyperliquid.xyz) + HYPERLIQUID_USER_ADDRESS Default address for state/fills/orders/review commands +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +import time +import urllib.error +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + + +USER_AGENT = "HermesAgent/1.0" +DEFAULT_USER_ENV = "HYPERLIQUID_USER_ADDRESS" +DEFAULT_API_BASE = "https://api.hyperliquid.xyz" + + +def _hermes_home() -> Path: + return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser() + + +def _dotenv_paths() -> List[Path]: + paths: List[Path] = [] + project_env = Path.cwd() / ".env" + if project_env.exists(): + paths.append(project_env) + + user_env = _hermes_home() / ".env" + if user_env.exists(): + paths.append(user_env) + + return paths + + +def _load_dotenv_values() -> Dict[str, str]: + values: Dict[str, str] = {} + for env_path in _dotenv_paths(): + try: + lines = env_path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + lines = env_path.read_text(encoding="latin-1").splitlines() + + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = raw_line.partition("=") + key = key.strip() + value = value.strip() + if value.startswith('"') and value.endswith('"') and len(value) >= 2: + value = value[1:-1].replace('\\"', '"').replace('\\\\', '\\') + values[key] = value + return values + + +def _env_lookup(key: str, default: str = "") -> str: + value = os.environ.get(key, "").strip() + if value: + return value + dotenv_value = _load_dotenv_values().get(key, "").strip() + if dotenv_value: + return dotenv_value + return default + + +def _api_base() -> str: + return _env_lookup("HYPERLIQUID_API_URL", DEFAULT_API_BASE).rstrip("/") + + +def _info_url() -> str: + api_base = _api_base() + if api_base.endswith("/info"): + return api_base + return f"{api_base}/info" + + +def _resolve_user(user: Optional[str]) -> str: + candidate = (user or "").strip() + if candidate: + return candidate + + env_value = _env_lookup(DEFAULT_USER_ENV, "") + if env_value: + return env_value + + sys.exit( + "Missing Hyperliquid address. Pass
explicitly or set " + f"{DEFAULT_USER_ENV} in your environment or ~/.hermes/.env." + ) + + +def _post_info(payload: Dict[str, Any], timeout: int = 20, retries: int = 2) -> Any: + data = json.dumps(payload).encode("utf-8") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + + for attempt in range(retries + 1): + request = urllib.request.Request(_info_url(), data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.load(response) + return body + except urllib.error.HTTPError as exc: + if exc.code == 429 and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + sys.exit(f"Hyperliquid HTTP error: {exc}") + except urllib.error.URLError as exc: + sys.exit(f"Hyperliquid connection error: {exc}") + except json.JSONDecodeError as exc: + sys.exit(f"Hyperliquid response was not valid JSON: {exc}") + + return None + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _limit_items(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]: + if limit <= 0: + return items + return items[:limit] + + +def _hours_ago_ms(hours: float, now_ms: Optional[int] = None) -> int: + end_ms = now_ms if now_ms is not None else int(time.time() * 1000) + return end_ms - int(hours * 60 * 60 * 1000) + + +def _format_timestamp_ms(value: Any) -> str: + try: + ts_ms = int(value) + except (TypeError, ValueError): + return "-" + return dt.datetime.utcfromtimestamp(ts_ms / 1000).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def _compact_number(value: Any, decimals: int = 2) -> str: + number = _safe_float(value) + if number is None: + return "-" + sign = "-" if number < 0 else "" + number = abs(number) + if number >= 1_000_000_000: + return f"{sign}{number / 1_000_000_000:.{decimals}f}B" + if number >= 1_000_000: + return f"{sign}{number / 1_000_000:.{decimals}f}M" + if number >= 1_000: + return f"{sign}{number / 1_000:.{decimals}f}K" + if number >= 100: + return f"{sign}{number:.2f}" + if number >= 1: + return f"{sign}{number:.4f}".rstrip("0").rstrip(".") + return f"{sign}{number:.6f}".rstrip("0").rstrip(".") + + +def _format_price(value: Any) -> str: + number = _safe_float(value) + if number is None: + return "-" + if abs(number) >= 1000: + return f"{number:,.2f}" + if abs(number) >= 1: + return f"{number:,.4f}".rstrip("0").rstrip(".") + return f"{number:,.6f}".rstrip("0").rstrip(".") + + +def _format_percent(value: Any, decimals: int = 2) -> str: + number = _safe_float(value) + if number is None: + return "-" + return f"{number:+.{decimals}f}%" + + +def _format_fraction_percent(value: Any, decimals: int = 4) -> str: + number = _safe_float(value) + if number is None: + return "-" + return f"{number * 100:+.{decimals}f}%" + + +def _percent_change(current: Any, previous: Any) -> Optional[float]: + curr = _safe_float(current) + prev = _safe_float(previous) + if curr is None or prev is None or prev == 0: + return None + return ((curr - prev) / prev) * 100 + + +def _short_address(address: Any) -> str: + if not isinstance(address, str) or len(address) < 12: + return str(address) + return f"{address[:6]}...{address[-4:]}" + + +def _render_table(headers: List[tuple[str, str]], rows: List[Dict[str, Any]]) -> str: + if not rows: + return "(no data)" + + prepared_rows: List[List[str]] = [] + widths = [len(label) for label, _ in headers] + + for row in rows: + rendered = [] + for index, (_label, key) in enumerate(headers): + value = row.get(key, "") + text = str(value) + rendered.append(text) + if len(text) > widths[index]: + widths[index] = len(text) + prepared_rows.append(rendered) + + lines = [] + header_line = " ".join(label.ljust(widths[idx]) for idx, (label, _key) in enumerate(headers)) + separator = " ".join("-" * widths[idx] for idx in range(len(headers))) + lines.extend([header_line, separator]) + + for rendered in prepared_rows: + lines.append(" ".join(rendered[idx].ljust(widths[idx]) for idx in range(len(rendered)))) + return "\n".join(lines) + + +def _normalize_dexs(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for index, item in enumerate(payload): + if item is None: + rows.append( + { + "index": index, + "name": "", + "label": "first-perp-dex", + "full_name": "First perp dex", + "deployer": "-", + "asset_caps": 0, + } + ) + continue + + if not isinstance(item, dict): + continue + + caps = item.get("assetToStreamingOiCap") or [] + rows.append( + { + "index": index, + "name": item.get("name", ""), + "label": item.get("name") or "first-perp-dex", + "full_name": item.get("fullName") or "-", + "deployer": item.get("deployer") or "-", + "asset_caps": len(caps) if isinstance(caps, list) else 0, + } + ) + return rows + + +def _normalize_perp_markets(payload: Any) -> List[Dict[str, Any]]: + if not isinstance(payload, list) or len(payload) < 2: + return [] + + meta = payload[0] if isinstance(payload[0], dict) else {} + ctxs = payload[1] if isinstance(payload[1], list) else [] + universe = meta.get("universe") if isinstance(meta, dict) else [] + if not isinstance(universe, list): + return [] + + rows: List[Dict[str, Any]] = [] + for index, spec in enumerate(universe): + if not isinstance(spec, dict): + continue + ctx = ctxs[index] if index < len(ctxs) and isinstance(ctxs[index], dict) else {} + mark_px = ctx.get("markPx") or ctx.get("midPx") or ctx.get("oraclePx") + row = { + "coin": spec.get("name", f"asset-{index}"), + "mark_px": mark_px, + "mid_px": ctx.get("midPx"), + "oracle_px": ctx.get("oraclePx"), + "prev_day_px": ctx.get("prevDayPx"), + "change_pct": _percent_change(mark_px, ctx.get("prevDayPx")), + "funding": ctx.get("funding"), + "premium": ctx.get("premium"), + "open_interest": ctx.get("openInterest"), + "day_ntl_vlm": ctx.get("dayNtlVlm"), + "day_base_vlm": ctx.get("dayBaseVlm"), + "max_leverage": spec.get("maxLeverage"), + "sz_decimals": spec.get("szDecimals"), + "is_delisted": bool(spec.get("isDelisted")), + "only_isolated": bool(spec.get("onlyIsolated")), + "margin_mode": spec.get("marginMode") or "-", + } + rows.append(row) + return rows + + +def _normalize_spot_markets(payload: Any) -> List[Dict[str, Any]]: + if not isinstance(payload, list) or len(payload) < 2: + return [] + + meta = payload[0] if isinstance(payload[0], dict) else {} + ctxs = payload[1] if isinstance(payload[1], list) else [] + pairs = meta.get("universe") if isinstance(meta, dict) else [] + tokens = meta.get("tokens") if isinstance(meta, dict) else [] + token_lookup = {} + if isinstance(tokens, list): + for token in tokens: + if isinstance(token, dict) and "index" in token: + token_lookup[token["index"]] = token.get("name", str(token["index"])) + + rows: List[Dict[str, Any]] = [] + if not isinstance(pairs, list): + return rows + + for index, pair in enumerate(pairs): + if not isinstance(pair, dict): + continue + ctx = ctxs[index] if index < len(ctxs) and isinstance(ctxs[index], dict) else {} + raw_name = pair.get("name", f"@{index}") + tokens_for_pair = pair.get("tokens") if isinstance(pair.get("tokens"), list) else [] + display_name = raw_name + if "/" not in raw_name and len(tokens_for_pair) == 2: + base = token_lookup.get(tokens_for_pair[0], str(tokens_for_pair[0])) + quote = token_lookup.get(tokens_for_pair[1], str(tokens_for_pair[1])) + display_name = f"{base}/{quote} ({raw_name})" + + mark_px = ctx.get("markPx") or ctx.get("midPx") + rows.append( + { + "pair": raw_name, + "display_name": display_name, + "mark_px": mark_px, + "mid_px": ctx.get("midPx"), + "prev_day_px": ctx.get("prevDayPx"), + "change_pct": _percent_change(mark_px, ctx.get("prevDayPx")), + "day_ntl_vlm": ctx.get("dayNtlVlm"), + } + ) + return rows + + +def _normalize_candles(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for candle in payload: + if not isinstance(candle, dict): + continue + rows.append( + { + "time": candle.get("t") or candle.get("time"), + "open": candle.get("o"), + "high": candle.get("h"), + "low": candle.get("l"), + "close": candle.get("c"), + "volume": candle.get("v"), + "trades": candle.get("n"), + } + ) + + rows.sort(key=lambda item: int(item.get("time") or 0)) + return rows + + +def _normalize_funding_history(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for item in payload: + if not isinstance(item, dict): + continue + rows.append( + { + "coin": item.get("coin", "-"), + "funding_rate": item.get("fundingRate"), + "premium": item.get("premium"), + "time": item.get("time"), + } + ) + + rows.sort(key=lambda item: int(item.get("time") or 0)) + return rows + + +def _normalize_book_levels(payload: Any) -> Dict[str, List[Dict[str, Any]]]: + if not isinstance(payload, dict): + return {"bids": [], "asks": []} + + levels = payload.get("levels") + if not isinstance(levels, list) or len(levels) < 2: + return {"bids": [], "asks": []} + + def convert(side: Iterable[Any]) -> List[Dict[str, Any]]: + converted = [] + for entry in side: + if isinstance(entry, dict): + converted.append( + { + "px": entry.get("px"), + "sz": entry.get("sz"), + "orders": entry.get("n"), + } + ) + elif isinstance(entry, (list, tuple)) and len(entry) >= 2: + converted.append( + { + "px": entry[0], + "sz": entry[1], + "orders": entry[2] if len(entry) > 2 else None, + } + ) + return converted + + return {"bids": convert(levels[0]), "asks": convert(levels[1])} + + +def _normalize_positions(payload: Any) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {"summary": {}, "positions": []} + + positions: List[Dict[str, Any]] = [] + for item in payload.get("assetPositions", []): + if not isinstance(item, dict): + continue + position = item.get("position") if isinstance(item.get("position"), dict) else item + if not isinstance(position, dict): + continue + leverage = position.get("leverage") if isinstance(position.get("leverage"), dict) else {} + positions.append( + { + "coin": position.get("coin", "-"), + "size": position.get("szi"), + "entry_px": position.get("entryPx"), + "position_value": position.get("positionValue"), + "unrealized_pnl": position.get("unrealizedPnl"), + "return_on_equity": position.get("returnOnEquity"), + "liquidation_px": position.get("liquidationPx"), + "margin_used": position.get("marginUsed"), + "leverage": leverage.get("value"), + "leverage_type": leverage.get("type"), + } + ) + + positions.sort( + key=lambda item: abs(_safe_float(item.get("position_value")) or 0.0), + reverse=True, + ) + + summary = payload.get("marginSummary") if isinstance(payload.get("marginSummary"), dict) else {} + cross_summary = ( + payload.get("crossMarginSummary") if isinstance(payload.get("crossMarginSummary"), dict) else {} + ) + + return { + "summary": { + "account_value": summary.get("accountValue"), + "total_ntl_pos": summary.get("totalNtlPos"), + "total_raw_usd": summary.get("totalRawUsd"), + "withdrawable": payload.get("withdrawable"), + "cross_account_value": cross_summary.get("accountValue"), + }, + "positions": positions, + } + + +def _normalize_spot_balances(payload: Any) -> List[Dict[str, Any]]: + if not isinstance(payload, dict): + return [] + + rows: List[Dict[str, Any]] = [] + for item in payload.get("balances", []): + if not isinstance(item, dict): + continue + rows.append( + { + "coin": item.get("coin", item.get("token", "-")), + "total": item.get("total"), + "hold": item.get("hold"), + "entry_ntl": item.get("entryNtl"), + } + ) + + rows.sort(key=lambda item: abs(_safe_float(item.get("entry_ntl")) or 0.0), reverse=True) + return rows + + +def _normalize_fills(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for item in payload: + if not isinstance(item, dict): + continue + fill = item.get("fill") if isinstance(item.get("fill"), dict) else item + rows.append( + { + "coin": fill.get("coin", "-"), + "dir": fill.get("dir") or fill.get("side") or "-", + "px": fill.get("px"), + "sz": fill.get("sz"), + "closed_pnl": fill.get("closedPnl"), + "fee": fill.get("fee"), + "fee_token": fill.get("feeToken"), + "start_position": fill.get("startPosition"), + "time": fill.get("time"), + "hash": fill.get("hash"), + "oid": fill.get("oid"), + "twap_id": item.get("twapId"), + } + ) + + rows.sort(key=lambda item: int(item.get("time") or 0), reverse=True) + return rows + + +def _normalize_orders(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for item in payload: + if not isinstance(item, dict): + continue + order = item.get("order") if isinstance(item.get("order"), dict) else item + rows.append( + { + "coin": order.get("coin", "-"), + "side": order.get("side", "-"), + "limit_px": order.get("limitPx") or order.get("px"), + "size": order.get("sz") or order.get("origSz"), + "timestamp": item.get("statusTimestamp") + or order.get("timestamp") + or order.get("time"), + "status": item.get("status") or order.get("status") or "-", + "oid": order.get("oid"), + "order_type": order.get("orderType") or "-", + } + ) + + rows.sort(key=lambda item: int(item.get("timestamp") or 0), reverse=True) + return rows + + +def _direction_bucket(direction: Any) -> str: + text = str(direction or "").strip().lower() + if "open" in text and "long" in text: + return "open_long" + if "close" in text and "long" in text: + return "close_long" + if "open" in text and "short" in text: + return "open_short" + if "close" in text and "short" in text: + return "close_short" + if text in {"b", "buy"}: + return "buy" + if text in {"s", "sell"}: + return "sell" + return "other" + + +def _average(values: Iterable[Optional[float]]) -> Optional[float]: + clean_values = [value for value in values if value is not None] + if not clean_values: + return None + return round(sum(clean_values) / len(clean_values), 12) + + +def _is_spot_coin(coin: str) -> bool: + return "/" in coin or coin.startswith("@") + + +def _safe_info_query(payload: Dict[str, Any]) -> Any: + try: + return _post_info(payload) + except SystemExit: + return None + + +def _market_context_for_coin(coin: str, interval: str, start_ms: int, end_ms: int) -> Dict[str, Any]: + candles = _normalize_candles( + _safe_info_query( + { + "type": "candleSnapshot", + "req": { + "coin": coin, + "interval": interval, + "startTime": start_ms, + "endTime": end_ms, + }, + } + ) + ) + funding_history: List[Dict[str, Any]] = [] + if not _is_spot_coin(coin): + funding_history = _normalize_funding_history( + _safe_info_query( + { + "type": "fundingHistory", + "coin": coin, + "startTime": start_ms, + "endTime": end_ms, + } + ) + ) + + candle_change = None + if candles: + candle_change = _percent_change(candles[-1].get("close"), candles[0].get("open")) + + funding_average = _average(_safe_float(item.get("funding_rate")) for item in funding_history) + return { + "coin": coin, + "interval": interval, + "candle_count": len(candles), + "price_change_pct": candle_change, + "window_open": candles[0].get("open") if candles else None, + "window_close": candles[-1].get("close") if candles else None, + "average_funding_rate": funding_average, + "funding_samples": len(funding_history), + } + + +def _build_coin_review(coin: str, fills: List[Dict[str, Any]], interval: str, start_ms: int, end_ms: int) -> Dict[str, Any]: + pnl_values = [_safe_float(fill.get("closed_pnl")) for fill in fills] + fee_values = [_safe_float(fill.get("fee")) for fill in fills] + scored = [value for value in pnl_values if value is not None] + wins = [value for value in scored if value > 0] + losses = [value for value in scored if value < 0] + breakeven = [value for value in scored if value == 0] + + direction_counts = Counter(_direction_bucket(fill.get("dir")) for fill in fills) + market_context = _market_context_for_coin(coin, interval, start_ms, end_ms) + total_pnl = sum(value for value in pnl_values if value is not None) + total_fees = sum(value for value in fee_values if value is not None) + net_after_fees = total_pnl - total_fees + + if direction_counts["open_long"] > direction_counts["open_short"]: + open_bias = "long" + elif direction_counts["open_short"] > direction_counts["open_long"]: + open_bias = "short" + elif direction_counts["open_long"] or direction_counts["open_short"]: + open_bias = "mixed" + else: + open_bias = "none" + + return { + "coin": coin, + "fill_count": len(fills), + "realized_pnl": total_pnl, + "total_fees": total_fees, + "net_after_fees": net_after_fees, + "wins": len(wins), + "losses": len(losses), + "breakeven": len(breakeven), + "win_rate_pct": (len(wins) / (len(wins) + len(losses)) * 100) if (len(wins) + len(losses)) else None, + "open_long_count": direction_counts["open_long"], + "open_short_count": direction_counts["open_short"], + "close_long_count": direction_counts["close_long"], + "close_short_count": direction_counts["close_short"], + "open_bias": open_bias, + "market_context": market_context, + } + + +def _review_findings(summary: Dict[str, Any], coin_reviews: List[Dict[str, Any]]) -> List[str]: + findings: List[str] = [] + + if summary["fill_count"] == 0: + return ["No fills were found in the requested review window."] + + if summary["outcome_fill_count"] == 0: + findings.append("Most fills in this window look like opens or adjustments, so realized-outcome review is limited until positions close.") + + if summary["net_after_fees"] < 0: + findings.append( + f"Net realized PnL after fees was negative ({_compact_number(summary['net_after_fees'])} USDC-equivalent units in reported fill terms)." + ) + elif summary["net_after_fees"] > 0: + findings.append( + f"Net realized PnL after fees was positive ({_compact_number(summary['net_after_fees'])} USDC-equivalent units in reported fill terms)." + ) + + realized_abs = abs(summary["realized_pnl"]) + if summary["total_fees"] > 0: + if realized_abs == 0: + findings.append("Fees were non-trivial while realized PnL stayed flat, which usually means churn without enough edge.") + elif summary["total_fees"] / realized_abs >= 0.25: + ratio_pct = (summary["total_fees"] / realized_abs) * 100 + findings.append(f"Fees consumed about {ratio_pct:.1f}% of absolute realized PnL, so execution efficiency is materially affecting results.") + + if summary["fill_count"] >= 20 and summary["net_after_fees"] < 0: + win_rate = summary.get("win_rate_pct") + if win_rate is None or win_rate < 45: + findings.append("Activity was high relative to results, which suggests overtrading in this review window.") + + if coin_reviews: + worst_coin = min(coin_reviews, key=lambda item: item["net_after_fees"]) + best_coin = max(coin_reviews, key=lambda item: item["net_after_fees"]) + if worst_coin["net_after_fees"] < 0: + findings.append( + f"The weakest coin was {worst_coin['coin']} with net after fees of {_compact_number(worst_coin['net_after_fees'])}." + ) + if best_coin["net_after_fees"] > 0 and best_coin["coin"] != worst_coin["coin"]: + findings.append( + f"The strongest coin was {best_coin['coin']} with net after fees of {_compact_number(best_coin['net_after_fees'])}." + ) + + for item in coin_reviews: + market_change = item["market_context"].get("price_change_pct") + if item["net_after_fees"] >= 0 or market_change is None: + continue + if market_change > 2 and item["open_short_count"] > item["open_long_count"]: + findings.append(f"{item['coin']}: losses came while leaning short into a rising market window.") + elif market_change < -2 and item["open_long_count"] > item["open_short_count"]: + findings.append(f"{item['coin']}: losses came while leaning long into a falling market window.") + + deduped: List[str] = [] + for finding in findings: + if finding not in deduped: + deduped.append(finding) + return deduped[:6] + + +def _recent_fill_rows(fills: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]: + rows = [] + for fill in _limit_items(fills, limit): + rows.append( + { + "time": fill.get("time"), + "coin": fill.get("coin"), + "dir": fill.get("dir"), + "px": fill.get("px"), + "sz": fill.get("sz"), + "closed_pnl": fill.get("closed_pnl"), + "fee": fill.get("fee"), + "fee_token": fill.get("fee_token"), + } + ) + return rows + + +def _coin_slug(coin: str) -> str: + slug = str(coin or "market").strip().lower() + for old, new in (("/", "-"), (":", "-"), ("@", "spot-"), (" ", "-")): + slug = slug.replace(old, new) + return slug or "market" + + +def _default_export_path(coin: str, interval: str, hours: float) -> Path: + hour_label = str(int(hours)) if float(hours).is_integer() else str(hours).replace(".", "p") + filename = f"hyperliquid-{_coin_slug(coin)}-{interval}-{hour_label}h.json" + return Path.cwd() / filename + + +def _write_json_file(path: Path, payload: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _export_summary(candles: List[Dict[str, Any]], funding_history: List[Dict[str, Any]]) -> Dict[str, Any]: + candle_change = None + if candles: + candle_change = _percent_change(candles[-1].get("close"), candles[0].get("open")) + return { + "candle_count": len(candles), + "funding_count": len(funding_history), + "window_open": candles[0].get("open") if candles else None, + "window_close": candles[-1].get("close") if candles else None, + "price_change_pct": candle_change, + "average_funding_rate": _average(_safe_float(item.get("funding_rate")) for item in funding_history), + } + + +def run_dexs(_args: argparse.Namespace) -> Dict[str, Any]: + payload = _post_info({"type": "perpDexs"}) + rows = _normalize_dexs(payload) + return {"api_url": _info_url(), "count": len(rows), "dexs": rows} + + +def run_markets(args: argparse.Namespace) -> Dict[str, Any]: + payload: Dict[str, Any] = {"type": "metaAndAssetCtxs"} + if args.dex: + payload["dex"] = args.dex + rows = _normalize_perp_markets(_post_info(payload)) + + if args.sort == "name": + rows.sort(key=lambda item: item["coin"]) + elif args.sort == "oi": + rows.sort(key=lambda item: _safe_float(item.get("open_interest")) or 0.0, reverse=True) + elif args.sort == "funding_abs": + rows.sort(key=lambda item: abs(_safe_float(item.get("funding")) or 0.0), reverse=True) + elif args.sort == "change_abs": + rows.sort(key=lambda item: abs(_safe_float(item.get("change_pct")) or 0.0), reverse=True) + else: + rows.sort(key=lambda item: _safe_float(item.get("day_ntl_vlm")) or 0.0, reverse=True) + + return { + "dex": args.dex or "", + "count": len(rows), + "sort": args.sort, + "markets": _limit_items(rows, args.limit), + } + + +def run_spots(args: argparse.Namespace) -> Dict[str, Any]: + rows = _normalize_spot_markets(_post_info({"type": "spotMetaAndAssetCtxs"})) + + if args.sort == "name": + rows.sort(key=lambda item: item["display_name"]) + elif args.sort == "change_abs": + rows.sort(key=lambda item: abs(_safe_float(item.get("change_pct")) or 0.0), reverse=True) + else: + rows.sort(key=lambda item: _safe_float(item.get("day_ntl_vlm")) or 0.0, reverse=True) + + return {"count": len(rows), "sort": args.sort, "pairs": _limit_items(rows, args.limit)} + + +def run_candles(args: argparse.Namespace) -> Dict[str, Any]: + end_ms = int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + payload = { + "type": "candleSnapshot", + "req": { + "coin": args.coin, + "interval": args.interval, + "startTime": start_ms, + "endTime": end_ms, + }, + } + candles = _normalize_candles(_post_info(payload)) + summary = {} + if candles: + highs = [_safe_float(item.get("high")) for item in candles] + lows = [_safe_float(item.get("low")) for item in candles] + clean_highs = [value for value in highs if value is not None] + clean_lows = [value for value in lows if value is not None] + summary = { + "first_time": candles[0]["time"], + "last_time": candles[-1]["time"], + "open": candles[0]["open"], + "close": candles[-1]["close"], + "high": max(clean_highs) if clean_highs else None, + "low": min(clean_lows) if clean_lows else None, + "change_pct": _percent_change(candles[-1]["close"], candles[0]["open"]), + } + return { + "coin": args.coin, + "interval": args.interval, + "hours": args.hours, + "count": len(candles), + "summary": summary, + "candles": _limit_items(candles, args.limit), + } + + +def run_funding(args: argparse.Namespace) -> Dict[str, Any]: + end_ms = int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + payload = {"type": "fundingHistory", "coin": args.coin, "startTime": start_ms, "endTime": end_ms} + rows = _normalize_funding_history(_post_info(payload)) + avg_rate = None + if rows: + values = [_safe_float(item.get("funding_rate")) for item in rows] + clean_values = [value for value in values if value is not None] + if clean_values: + avg_rate = sum(clean_values) / len(clean_values) + return { + "coin": args.coin, + "hours": args.hours, + "count": len(rows), + "average_funding_rate": avg_rate, + "history": _limit_items(list(reversed(rows)), args.limit), + } + + +def run_l2(args: argparse.Namespace) -> Dict[str, Any]: + payload: Dict[str, Any] = {"type": "l2Book", "coin": args.coin} + if args.n_sig_figs is not None: + payload["nSigFigs"] = args.n_sig_figs + if args.mantissa is not None: + payload["mantissa"] = args.mantissa + raw = _post_info(payload) + levels = _normalize_book_levels(raw) + return { + "coin": args.coin, + "time": raw.get("time") if isinstance(raw, dict) else None, + "bids": _limit_items(levels["bids"], args.levels), + "asks": _limit_items(levels["asks"], args.levels), + } + + +def run_state(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload: Dict[str, Any] = {"type": "clearinghouseState", "user": user} + if args.dex: + payload["dex"] = args.dex + normalized = _normalize_positions(_post_info(payload)) + return { + "user": user, + "dex": args.dex or "", + "summary": normalized["summary"], + "positions": normalized["positions"], + } + + +def run_spot_balances(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload = {"type": "spotClearinghouseState", "user": user} + rows = _normalize_spot_balances(_post_info(payload)) + return {"user": user, "count": len(rows), "balances": _limit_items(rows, args.limit)} + + +def run_fills(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload: Dict[str, Any] = {"user": user} + if args.hours is not None: + payload["type"] = "userFillsByTime" + payload["startTime"] = _hours_ago_ms(args.hours) + else: + payload["type"] = "userFills" + if args.aggregate_by_time: + payload["aggregateByTime"] = True + rows = _normalize_fills(_post_info(payload)) + return { + "user": user, + "hours": args.hours, + "aggregate_by_time": args.aggregate_by_time, + "count": len(rows), + "fills": _limit_items(rows, args.limit), + } + + +def run_orders(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload = {"type": "historicalOrders", "user": user} + rows = _normalize_orders(_post_info(payload)) + return {"user": user, "count": len(rows), "orders": _limit_items(rows, args.limit)} + + +def run_review(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + end_ms = int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + payload: Dict[str, Any] = {"type": "userFillsByTime", "user": user, "startTime": start_ms} + if args.aggregate_by_time: + payload["aggregateByTime"] = True + + fills = _normalize_fills(_post_info(payload)) + if args.coin: + target = args.coin.lower() + fills = [fill for fill in fills if str(fill.get("coin", "")).lower() == target] + fills = _limit_items(fills, args.fills) + + grouped: Dict[str, List[Dict[str, Any]]] = {} + for fill in fills: + grouped.setdefault(fill.get("coin", "-"), []).append(fill) + + coin_reviews = [ + _build_coin_review(coin, coin_fills, args.interval, start_ms, end_ms) + for coin, coin_fills in sorted(grouped.items(), key=lambda item: len(item[1]), reverse=True) + ] + + pnl_values = [_safe_float(fill.get("closed_pnl")) for fill in fills] + fee_values = [_safe_float(fill.get("fee")) for fill in fills] + scored = [value for value in pnl_values if value is not None] + wins = [value for value in scored if value > 0] + losses = [value for value in scored if value < 0] + direction_counts = Counter(_direction_bucket(fill.get("dir")) for fill in fills) + total_pnl = sum(value for value in pnl_values if value is not None) + total_fees = sum(value for value in fee_values if value is not None) + + summary = { + "fill_count": len(fills), + "scored_fill_count": len(scored), + "outcome_fill_count": len(wins) + len(losses), + "unique_coins": len(grouped), + "realized_pnl": total_pnl, + "total_fees": total_fees, + "net_after_fees": total_pnl - total_fees, + "wins": len(wins), + "losses": len(losses), + "breakeven": len([value for value in scored if value == 0]), + "win_rate_pct": (len(wins) / (len(wins) + len(losses)) * 100) if (len(wins) + len(losses)) else None, + "open_long_count": direction_counts["open_long"], + "open_short_count": direction_counts["open_short"], + "close_long_count": direction_counts["close_long"], + "close_short_count": direction_counts["close_short"], + } + + return { + "user": user, + "coin_filter": args.coin, + "hours": args.hours, + "interval": args.interval, + "fills_requested": args.fills, + "summary": summary, + "findings": _review_findings(summary, coin_reviews), + "coin_reviews": coin_reviews, + "recent_fills": _recent_fill_rows(fills, args.recent), + } + + +def run_export(args: argparse.Namespace) -> Dict[str, Any]: + end_ms = args.end_time_ms if args.end_time_ms is not None else int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + + candle_payload = { + "type": "candleSnapshot", + "req": { + "coin": args.coin, + "interval": args.interval, + "startTime": start_ms, + "endTime": end_ms, + }, + } + candles = _normalize_candles(_post_info(candle_payload)) + + funding_history: List[Dict[str, Any]] = [] + if not _is_spot_coin(args.coin): + funding_history = _normalize_funding_history( + _safe_info_query( + { + "type": "fundingHistory", + "coin": args.coin, + "startTime": start_ms, + "endTime": end_ms, + } + ) + ) + + output_path = Path(args.output) if args.output else _default_export_path(args.coin, args.interval, args.hours) + payload = { + "schema_version": "hyperliquid-market-export-v1", + "source": { + "api_url": _info_url(), + "interval": args.interval, + "coin": args.coin, + "market_type": "spot" if _is_spot_coin(args.coin) else "perp", + }, + "window": { + "start_time_ms": start_ms, + "end_time_ms": end_ms, + "hours": args.hours, + }, + "summary": _export_summary(candles, funding_history), + "candles": candles, + "funding_history": funding_history, + } + _write_json_file(output_path, payload) + return { + "coin": args.coin, + "interval": args.interval, + "hours": args.hours, + "output_path": str(output_path), + "summary": payload["summary"], + "schema_version": payload["schema_version"], + } + + +def render_dexs(data: Dict[str, Any]) -> str: + rows = [ + { + "label": item["label"], + "full_name": item["full_name"], + "deployer": _short_address(item["deployer"]), + "asset_caps": item["asset_caps"], + } + for item in data["dexs"] + ] + return "\n".join( + [ + f"API: {data['api_url']}", + f"Perp dexs: {data['count']}", + "", + _render_table( + [ + ("Dex", "label"), + ("Full Name", "full_name"), + ("Deployer", "deployer"), + ("Asset Caps", "asset_caps"), + ], + rows, + ), + ] + ) + + +def render_markets(data: Dict[str, Any]) -> str: + rows = [ + { + "coin": item["coin"], + "mark_px": _format_price(item["mark_px"]), + "change_pct": _format_percent(item["change_pct"]), + "funding": _format_fraction_percent(item["funding"]), + "open_interest": _compact_number(item["open_interest"]), + "day_ntl_vlm": _compact_number(item["day_ntl_vlm"]), + } + for item in data["markets"] + ] + lines = [ + f"Dex: {data['dex'] or 'first-perp-dex'}", + f"Markets returned: {len(data['markets'])} of {data['count']}", + "", + _render_table( + [ + ("Coin", "coin"), + ("Mark", "mark_px"), + ("Chg", "change_pct"), + ("Funding", "funding"), + ("OI", "open_interest"), + ("24h Vol", "day_ntl_vlm"), + ], + rows, + ), + ] + return "\n".join(lines) + + +def render_spots(data: Dict[str, Any]) -> str: + rows = [ + { + "pair": item["display_name"], + "mark_px": _format_price(item["mark_px"]), + "change_pct": _format_percent(item["change_pct"]), + "day_ntl_vlm": _compact_number(item["day_ntl_vlm"]), + } + for item in data["pairs"] + ] + return "\n".join( + [ + f"Spot pairs returned: {len(data['pairs'])} of {data['count']}", + "", + _render_table( + [ + ("Pair", "pair"), + ("Mark", "mark_px"), + ("Chg", "change_pct"), + ("24h Vol", "day_ntl_vlm"), + ], + rows, + ), + ] + ) + + +def render_candles(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "open": _format_price(item["open"]), + "high": _format_price(item["high"]), + "low": _format_price(item["low"]), + "close": _format_price(item["close"]), + "volume": _compact_number(item["volume"]), + } + for item in data["candles"] + ] + summary = data.get("summary") or {} + lines = [ + f"Coin: {data['coin']}", + f"Interval: {data['interval']}", + f"Hours: {data['hours']}", + f"Candles returned: {len(data['candles'])} of {data['count']}", + ] + if summary: + lines.extend( + [ + f"Open -> Close: {_format_price(summary.get('open'))} -> {_format_price(summary.get('close'))}", + f"Range: {_format_price(summary.get('low'))} to {_format_price(summary.get('high'))}", + f"Change: {_format_percent(summary.get('change_pct'))}", + ] + ) + lines.extend( + [ + "", + _render_table( + [ + ("Time", "time"), + ("Open", "open"), + ("High", "high"), + ("Low", "low"), + ("Close", "close"), + ("Volume", "volume"), + ], + rows, + ), + ] + ) + return "\n".join(lines) + + +def render_funding(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "coin": item["coin"], + "funding": _format_fraction_percent(item["funding_rate"]), + "premium": _format_fraction_percent(item["premium"]), + } + for item in data["history"] + ] + lines = [ + f"Coin: {data['coin']}", + f"Hours: {data['hours']}", + f"Entries returned: {len(data['history'])} of {data['count']}", + f"Average funding: {_format_fraction_percent(data['average_funding_rate'])}", + "", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Funding", "funding"), + ("Premium", "premium"), + ], + rows, + ), + ] + return "\n".join(lines) + + +def render_l2(data: Dict[str, Any]) -> str: + bid_rows = [ + {"px": _format_price(item["px"]), "sz": _compact_number(item["sz"]), "orders": item["orders"] or "-"} + for item in data["bids"] + ] + ask_rows = [ + {"px": _format_price(item["px"]), "sz": _compact_number(item["sz"]), "orders": item["orders"] or "-"} + for item in data["asks"] + ] + lines = [ + f"Coin: {data['coin']}", + f"Book time: {_format_timestamp_ms(data['time'])}", + "", + "Bids", + _render_table([("Price", "px"), ("Size", "sz"), ("Orders", "orders")], bid_rows), + "", + "Asks", + _render_table([("Price", "px"), ("Size", "sz"), ("Orders", "orders")], ask_rows), + ] + return "\n".join(lines) + + +def render_state(data: Dict[str, Any]) -> str: + summary = data["summary"] + position_rows = [ + { + "coin": item["coin"], + "size": item["size"], + "entry_px": _format_price(item["entry_px"]), + "position_value": _compact_number(item["position_value"]), + "unrealized_pnl": _compact_number(item["unrealized_pnl"]), + "roe": _format_fraction_percent(item["return_on_equity"], 2), + "liq": _format_price(item["liquidation_px"]), + "lev": f"{item['leverage'] or '-'}x", + } + for item in data["positions"] + ] + + lines = [ + f"User: {data['user']}", + f"Dex: {data['dex'] or 'first-perp-dex'}", + f"Account value: {summary.get('account_value') or '-'}", + f"Total notional position: {summary.get('total_ntl_pos') or '-'}", + f"Withdrawable: {summary.get('withdrawable') or '-'}", + f"Positions: {len(data['positions'])}", + ] + if position_rows: + lines.extend( + [ + "", + _render_table( + [ + ("Coin", "coin"), + ("Size", "size"), + ("Entry", "entry_px"), + ("Pos Val", "position_value"), + ("uPnL", "unrealized_pnl"), + ("ROE", "roe"), + ("Liq", "liq"), + ("Lev", "lev"), + ], + position_rows, + ), + ] + ) + return "\n".join(lines) + + +def render_spot_balances(data: Dict[str, Any]) -> str: + rows = [ + { + "coin": item["coin"], + "total": _compact_number(item["total"]), + "hold": _compact_number(item["hold"]), + "entry_ntl": _compact_number(item["entry_ntl"]), + } + for item in data["balances"] + ] + return "\n".join( + [ + f"User: {data['user']}", + f"Balances returned: {len(data['balances'])} of {data['count']}", + "", + _render_table( + [ + ("Coin", "coin"), + ("Total", "total"), + ("Hold", "hold"), + ("Entry Ntl", "entry_ntl"), + ], + rows, + ), + ] + ) + + +def render_fills(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "coin": item["coin"], + "dir": item["dir"], + "px": _format_price(item["px"]), + "sz": _compact_number(item["sz"]), + "closed_pnl": _compact_number(item["closed_pnl"]), + "fee": f"{_compact_number(item['fee'])} {item['fee_token'] or ''}".strip(), + } + for item in data["fills"] + ] + lines = [ + f"User: {data['user']}", + f"Aggregate by time: {data['aggregate_by_time']}", + f"Fills returned: {len(data['fills'])} of {data['count']}", + "", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Dir", "dir"), + ("Px", "px"), + ("Sz", "sz"), + ("Closed PnL", "closed_pnl"), + ("Fee", "fee"), + ], + rows, + ), + ] + return "\n".join(lines) + + +def render_orders(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["timestamp"]), + "coin": item["coin"], + "side": item["side"], + "limit_px": _format_price(item["limit_px"]), + "size": _compact_number(item["size"]), + "status": item["status"], + "oid": item["oid"] or "-", + } + for item in data["orders"] + ] + return "\n".join( + [ + f"User: {data['user']}", + f"Orders returned: {len(data['orders'])} of {data['count']}", + "", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Side", "side"), + ("Px", "limit_px"), + ("Sz", "size"), + ("Status", "status"), + ("OID", "oid"), + ], + rows, + ), + ] + ) + + +def render_review(data: Dict[str, Any]) -> str: + summary = data["summary"] + coin_rows = [ + { + "coin": item["coin"], + "fills": item["fill_count"], + "net": _compact_number(item["net_after_fees"]), + "win_rate": _format_percent(item["win_rate_pct"]), + "trend": _format_percent(item["market_context"].get("price_change_pct")), + "funding": _format_fraction_percent(item["market_context"].get("average_funding_rate")), + "bias": item["open_bias"], + } + for item in data["coin_reviews"] + ] + recent_rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "coin": item["coin"], + "dir": item["dir"], + "px": _format_price(item["px"]), + "sz": _compact_number(item["sz"]), + "closed_pnl": _compact_number(item["closed_pnl"]), + "fee": f"{_compact_number(item['fee'])} {item['fee_token'] or ''}".strip(), + } + for item in data["recent_fills"] + ] + + lines = [ + f"User: {data['user']}", + f"Review window: {data['hours']} hours", + f"Coin filter: {data['coin_filter'] or 'all traded coins'}", + f"Fills analyzed: {summary['fill_count']}", + f"Unique coins: {summary['unique_coins']}", + f"Realized PnL: {_compact_number(summary['realized_pnl'])}", + f"Fees: {_compact_number(summary['total_fees'])}", + f"Net after fees: {_compact_number(summary['net_after_fees'])}", + f"Win rate: {_format_percent(summary['win_rate_pct'])}", + ] + + if data["findings"]: + lines.extend(["", "Findings"]) + for finding in data["findings"]: + lines.append(f"- {finding}") + + if coin_rows: + lines.extend( + [ + "", + "Coin Breakdown", + _render_table( + [ + ("Coin", "coin"), + ("Fills", "fills"), + ("Net", "net"), + ("Win Rate", "win_rate"), + ("Trend", "trend"), + ("Funding", "funding"), + ("Bias", "bias"), + ], + coin_rows, + ), + ] + ) + + if recent_rows: + lines.extend( + [ + "", + "Recent Fills", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Dir", "dir"), + ("Px", "px"), + ("Sz", "sz"), + ("Closed PnL", "closed_pnl"), + ("Fee", "fee"), + ], + recent_rows, + ), + ] + ) + + return "\n".join(lines) + + +def render_export(data: Dict[str, Any]) -> str: + summary = data["summary"] + return "\n".join( + [ + f"Coin: {data['coin']}", + f"Interval: {data['interval']}", + f"Hours: {data['hours']}", + f"Schema: {data['schema_version']}", + f"Output: {data['output_path']}", + f"Candles: {summary['candle_count']}", + f"Funding samples: {summary['funding_count']}", + f"Window open -> close: {_format_price(summary.get('window_open'))} -> {_format_price(summary.get('window_close'))}", + f"Price change: {_format_percent(summary.get('price_change_pct'))}", + f"Average funding: {_format_fraction_percent(summary.get('average_funding_rate'))}", + ] + ) + + +def _add_json_flag(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--json", action="store_true", help="Print raw JSON output") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Hyperliquid CLI Tool for Hermes Agent") + subparsers = parser.add_subparsers(dest="command", required=True) + + dexs = subparsers.add_parser("dexs", help="List available perpetual dexs") + _add_json_flag(dexs) + dexs.set_defaults(func=run_dexs, renderer=render_dexs) + + markets = subparsers.add_parser("markets", help="List perpetual market contexts") + markets.add_argument("--dex", default="", help="Perp dex name; empty means first perp dex") + markets.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + markets.add_argument( + "--sort", + choices=["volume", "oi", "funding_abs", "change_abs", "name"], + default="volume", + help="Sort mode", + ) + _add_json_flag(markets) + markets.set_defaults(func=run_markets, renderer=render_markets) + + spots = subparsers.add_parser("spots", help="List spot market contexts") + spots.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + spots.add_argument( + "--sort", + choices=["volume", "change_abs", "name"], + default="volume", + help="Sort mode", + ) + _add_json_flag(spots) + spots.set_defaults(func=run_spots, renderer=render_spots) + + candles = subparsers.add_parser("candles", help="Fetch candle history for a market") + candles.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC" or "mydex:BTC"') + candles.add_argument("--interval", default="1h", help="Candle interval, e.g. 1m, 15m, 1h, 4h, 1d") + candles.add_argument("--hours", type=float, default=24.0, help="Lookback window in hours") + candles.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(candles) + candles.set_defaults(func=run_candles, renderer=render_candles) + + funding = subparsers.add_parser("funding", help="Fetch funding history for a perp market") + funding.add_argument("coin", help='Coin name, e.g. "BTC" or "mydex:COIN"') + funding.add_argument("--hours", type=float, default=72.0, help="Lookback window in hours") + funding.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(funding) + funding.set_defaults(func=run_funding, renderer=render_funding) + + l2 = subparsers.add_parser("l2", help="Inspect the current L2 book for a market") + l2.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC"') + l2.add_argument("--levels", type=int, default=10, help="Levels per side to display") + l2.add_argument("--n-sig-figs", type=int, default=None, help="Optional server-side book aggregation") + l2.add_argument("--mantissa", type=int, default=None, help="Optional mantissa when using nSigFigs") + _add_json_flag(l2) + l2.set_defaults(func=run_l2, renderer=render_l2) + + state = subparsers.add_parser("state", help="Inspect a user's perp account state") + state.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + state.add_argument("--dex", default="", help="Perp dex name; empty means first perp dex") + _add_json_flag(state) + state.set_defaults(func=run_state, renderer=render_state) + + spot_balances = subparsers.add_parser("spot-balances", help="Inspect a user's spot token balances") + spot_balances.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + spot_balances.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(spot_balances) + spot_balances.set_defaults(func=run_spot_balances, renderer=render_spot_balances) + + fills = subparsers.add_parser("fills", help="Inspect a user's recent fills") + fills.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + fills.add_argument("--hours", type=float, default=None, help="Optional time window; uses userFillsByTime") + fills.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + fills.add_argument( + "--aggregate-by-time", + action="store_true", + help="Aggregate partial fills when the API supports it", + ) + _add_json_flag(fills) + fills.set_defaults(func=run_fills, renderer=render_fills) + + orders = subparsers.add_parser("orders", help="Inspect a user's historical orders") + orders.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + orders.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(orders) + orders.set_defaults(func=run_orders, renderer=render_orders) + + review = subparsers.add_parser("review", help="Generate a lightweight post-trade review from recent fills") + review.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + review.add_argument("--coin", default="", help="Optional exact coin filter, e.g. BTC or PURR/USDC") + review.add_argument("--hours", type=float, default=72.0, help="Lookback window in hours") + review.add_argument("--fills", type=int, default=50, help="Maximum fills to analyze") + review.add_argument("--recent", type=int, default=10, help="Recent fills to display in the review") + review.add_argument("--interval", default="1h", help="Candle interval for market context") + review.add_argument( + "--aggregate-by-time", + action="store_true", + help="Aggregate partial fills when the API supports it", + ) + _add_json_flag(review) + review.set_defaults(func=run_review, renderer=render_review) + + export = subparsers.add_parser("export", help="Export normalized candles and funding history to a JSON file") + export.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC" or "mydex:BTC"') + export.add_argument("--interval", default="1h", help="Candle interval for the exported dataset") + export.add_argument("--hours", type=float, default=168.0, help="Lookback window in hours") + export.add_argument("--end-time-ms", type=int, default=None, help="Optional fixed end time for reproducible exports") + export.add_argument("--output", default="", help="Path to the JSON export file") + _add_json_flag(export) + export.set_defaults(func=run_export, renderer=render_export) + + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + payload = args.func(args) + if args.json: + print(json.dumps(payload, indent=2)) + else: + print(args.renderer(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/skills/test_hyperliquid_skill.py b/tests/skills/test_hyperliquid_skill.py new file mode 100644 index 00000000000..56fe50ee4c4 --- /dev/null +++ b/tests/skills/test_hyperliquid_skill.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from unittest.mock import patch + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] + / "optional-skills" + / "blockchain" + / "hyperliquid" + / "scripts" + / "hyperliquid_client.py" +) + + +def load_module(): + spec = importlib.util.spec_from_file_location("hyperliquid_skill", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_normalize_perp_markets_extracts_change_and_volume(): + mod = load_module() + + payload = [ + { + "universe": [ + {"name": "BTC", "szDecimals": 5, "maxLeverage": 50}, + {"name": "ETH", "szDecimals": 4, "maxLeverage": 25, "isDelisted": True}, + ] + }, + [ + { + "markPx": "100000", + "prevDayPx": "95000", + "funding": "0.0001", + "openInterest": "123456789", + "dayNtlVlm": "999999999", + }, + { + "markPx": "2500", + "prevDayPx": "2600", + "funding": "-0.0002", + "openInterest": "20000000", + "dayNtlVlm": "11111111", + }, + ], + ] + + rows = mod._normalize_perp_markets(payload) + + assert len(rows) == 2 + assert rows[0]["coin"] == "BTC" + assert round(rows[0]["change_pct"], 2) == 5.26 + assert rows[0]["day_ntl_vlm"] == "999999999" + assert rows[1]["is_delisted"] is True + + +def test_normalize_dexs_includes_first_perp_dex_placeholder(): + mod = load_module() + + rows = mod._normalize_dexs( + [ + None, + { + "name": "test", + "fullName": "test dex", + "deployer": "0x1234567890abcdef1234567890abcdef12345678", + "assetToStreamingOiCap": [["COIN", "100"]], + }, + ] + ) + + assert rows[0]["label"] == "first-perp-dex" + assert rows[1]["label"] == "test" + assert rows[1]["asset_caps"] == 1 + + +def test_main_markets_json_prints_normalized_payload(capsys): + mod = load_module() + + payload = [ + {"universe": [{"name": "BTC", "szDecimals": 5, "maxLeverage": 50}]}, + [{"markPx": "101000", "prevDayPx": "100000", "dayNtlVlm": "10"}], + ] + + with patch.object(mod, "_post_info", return_value=payload): + exit_code = mod.main(["markets", "--limit", "1", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["count"] == 1 + assert rendered["markets"][0]["coin"] == "BTC" + assert round(rendered["markets"][0]["change_pct"], 2) == 1.0 + + +def test_main_candles_json_limits_rows(capsys): + mod = load_module() + + payload = [ + {"t": 1000, "o": "1", "h": "2", "l": "0.5", "c": "1.5", "v": "10", "n": 3}, + {"t": 2000, "o": "1.5", "h": "2.5", "l": "1.4", "c": "2.0", "v": "20", "n": 5}, + {"t": 3000, "o": "2.0", "h": "2.2", "l": "1.8", "c": "2.1", "v": "15", "n": 4}, + ] + + with patch.object(mod, "_post_info", return_value=payload): + exit_code = mod.main(["candles", "BTC", "--limit", "2", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["count"] == 3 + assert len(rendered["candles"]) == 2 + assert rendered["summary"]["open"] == "1" + assert rendered["summary"]["close"] == "2.1" + + +def test_main_review_json_builds_market_context_and_findings(capsys): + mod = load_module() + + def fake_post_info(payload): + payload_type = payload["type"] + if payload_type == "userFillsByTime": + return [ + {"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}}, + {"fill": {"coin": "BTC", "dir": "Open Long", "px": "100000", "sz": "0.1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 3000}}, + {"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}}, + {"fill": {"coin": "ETH", "dir": "Open Short", "px": "2000", "sz": "1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 1000}}, + ] + if payload_type == "candleSnapshot" and payload["req"]["coin"] == "BTC": + return [ + {"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3}, + ] + if payload_type == "candleSnapshot" and payload["req"]["coin"] == "ETH": + return [ + {"t": 1000, "o": "2000", "h": "2210", "l": "1990", "c": "2200", "v": "50", "n": 10}, + ] + if payload_type == "fundingHistory" and payload["coin"] == "BTC": + return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}] + if payload_type == "fundingHistory" and payload["coin"] == "ETH": + return [{"coin": "ETH", "fundingRate": "0.0002", "premium": "0.0003", "time": 1000}] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main(["review", "0xabc", "--hours", "72", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["summary"]["fill_count"] == 4 + assert rendered["summary"]["realized_pnl"] == 40.0 + assert rendered["summary"]["total_fees"] == 11.0 + assert rendered["summary"]["net_after_fees"] == 29.0 + assert len(rendered["coin_reviews"]) == 2 + eth_review = next(item for item in rendered["coin_reviews"] if item["coin"] == "ETH") + assert round(eth_review["market_context"]["price_change_pct"], 2) == 10.0 + assert eth_review["market_context"]["average_funding_rate"] == 0.0002 + assert any("ETH" in finding and "rising market" in finding for finding in rendered["findings"]) + + +def test_main_review_json_respects_coin_filter(capsys): + mod = load_module() + + def fake_post_info(payload): + if payload["type"] == "userFillsByTime": + return [ + {"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}}, + {"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}}, + ] + if payload["type"] == "candleSnapshot": + return [{"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3}] + if payload["type"] == "fundingHistory": + return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main(["review", "0xabc", "--coin", "BTC", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["summary"]["fill_count"] == 1 + assert rendered["summary"]["unique_coins"] == 1 + assert rendered["coin_reviews"][0]["coin"] == "BTC" + + +def test_resolve_user_uses_env_fallback(monkeypatch): + mod = load_module() + monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv123") + + assert mod._resolve_user("") == "0xenv123" + assert mod._resolve_user(None) == "0xenv123" + assert mod._resolve_user("0xcli456") == "0xcli456" + + +def test_resolve_user_errors_when_missing(monkeypatch, tmp_path): + mod = load_module() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False) + + try: + mod._resolve_user("") + except SystemExit as exc: + message = str(exc) + else: + raise AssertionError("Expected SystemExit when no user is provided") + + assert "HYPERLIQUID_USER_ADDRESS" in message + + +def test_main_state_json_uses_env_fallback(monkeypatch, capsys): + mod = load_module() + monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv999") + + with patch.object( + mod, + "_post_info", + return_value={"marginSummary": {"accountValue": "123"}, "assetPositions": [], "withdrawable": "50"}, + ) as mock_post: + exit_code = mod.main(["state", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["user"] == "0xenv999" + assert mock_post.call_args[0][0]["user"] == "0xenv999" + + +def test_env_lookup_reads_hermes_dotenv(tmp_path, monkeypatch): + mod = load_module() + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir(parents=True) + (hermes_home / ".env").write_text( + "HYPERLIQUID_USER_ADDRESS=0xdotenv123\nHYPERLIQUID_API_URL=https://api.hyperliquid-testnet.xyz\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False) + monkeypatch.delenv("HYPERLIQUID_API_URL", raising=False) + + assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xdotenv123" + assert mod._resolve_user("") == "0xdotenv123" + assert mod._info_url() == "https://api.hyperliquid-testnet.xyz/info" + + +def test_user_dotenv_overrides_project_dotenv(tmp_path, monkeypatch): + mod = load_module() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xproject\n", encoding="utf-8") + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xuserhome\n", encoding="utf-8") + + monkeypatch.chdir(project_dir) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False) + + assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xuserhome" + + +def test_main_export_json_writes_expected_contract(tmp_path, capsys): + mod = load_module() + output_path = tmp_path / "exports" / "btc-1h.json" + + def fake_post_info(payload): + if payload["type"] == "candleSnapshot": + return [ + {"t": 1000, "o": "100", "h": "110", "l": "95", "c": "108", "v": "50", "n": 4}, + {"t": 2000, "o": "108", "h": "115", "l": "107", "c": "112", "v": "60", "n": 5}, + ] + if payload["type"] == "fundingHistory": + return [ + {"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1500}, + {"coin": "BTC", "fundingRate": "0.0003", "premium": "0.0004", "time": 2000}, + ] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main( + [ + "export", + "BTC", + "--interval", + "1h", + "--hours", + "24", + "--end-time-ms", + "5000", + "--output", + str(output_path), + "--json", + ] + ) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + saved = json.loads(output_path.read_text(encoding="utf-8")) + + assert exit_code == 0 + assert rendered["output_path"] == str(output_path) + assert saved["schema_version"] == "hyperliquid-market-export-v1" + assert saved["source"]["coin"] == "BTC" + assert saved["window"]["start_time_ms"] == 5000 - 24 * 60 * 60 * 1000 + assert saved["window"]["end_time_ms"] == 5000 + assert saved["summary"]["candle_count"] == 2 + assert saved["summary"]["funding_count"] == 2 + assert round(saved["summary"]["price_change_pct"], 2) == 12.0 + assert saved["summary"]["average_funding_rate"] == 0.0002 + assert len(saved["candles"]) == 2 + assert len(saved["funding_history"]) == 2 + + +def test_main_export_json_skips_funding_for_spot(tmp_path, capsys): + mod = load_module() + output_path = tmp_path / "purr-usdc.json" + + def fake_post_info(payload): + if payload["type"] == "candleSnapshot": + return [{"t": 1000, "o": "1", "h": "1.2", "l": "0.9", "c": "1.1", "v": "100", "n": 10}] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main( + [ + "export", + "PURR/USDC", + "--end-time-ms", + "5000", + "--output", + str(output_path), + "--json", + ] + ) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + saved = json.loads(output_path.read_text(encoding="utf-8")) + + assert exit_code == 0 + assert rendered["summary"]["funding_count"] == 0 + assert saved["source"]["market_type"] == "spot" + assert saved["funding_history"] == []