mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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.
This commit is contained in:
parent
28b4fe6007
commit
f2e8ed2405
4 changed files with 2324 additions and 0 deletions
12
.env.example
12
.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
|
||||
# =============================================================================
|
||||
|
|
|
|||
294
optional-skills/blockchain/hyperliquid/SKILL.md
Normal file
294
optional-skills/blockchain/hyperliquid/SKILL.md
Normal file
|
|
@ -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 <coin> [--interval 1h] [--hours 24] [--limit N]
|
||||
python3 hyperliquid_client.py funding <coin> [--hours 72] [--limit N]
|
||||
python3 hyperliquid_client.py l2 <coin> [--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 <coin> [--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
|
||||
```
|
||||
1660
optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py
Normal file
1660
optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py
Normal file
File diff suppressed because it is too large
Load diff
358
tests/skills/test_hyperliquid_skill.py
Normal file
358
tests/skills/test_hyperliquid_skill.py
Normal file
|
|
@ -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"] == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue