hyperliquid-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hl_cli/__init__.py ADDED
File without changes
hl_cli/client.py ADDED
@@ -0,0 +1,68 @@
1
+ """SDK client factory — creates Info and Exchange instances."""
2
+
3
+ import os
4
+
5
+ import eth_account
6
+ from hyperliquid.exchange import Exchange
7
+ from hyperliquid.info import Info
8
+ from hyperliquid.utils.constants import MAINNET_API_URL
9
+
10
+ from hl_cli.output import die
11
+
12
+ TESTNET_API_URL = "https://api.hyperliquid-testnet.xyz"
13
+
14
+
15
+ def _base_url(testnet: bool) -> str:
16
+ return TESTNET_API_URL if testnet else MAINNET_API_URL
17
+
18
+
19
+ def _get_key():
20
+ key = os.environ.get("HL_PRIVATE_KEY")
21
+ if not key:
22
+ die(
23
+ "auth_error",
24
+ "HL_PRIVATE_KEY environment variable not set",
25
+ hint="Set HL_PRIVATE_KEY to your API wallet private key",
26
+ exit_code=2,
27
+ )
28
+ return key
29
+
30
+
31
+ def get_address(explicit: str = None) -> str:
32
+ """Get wallet address from explicit arg or HL_PRIVATE_KEY."""
33
+ if explicit:
34
+ return explicit
35
+ key = os.environ.get("HL_PRIVATE_KEY")
36
+ if key:
37
+ return eth_account.Account.from_key(key).address
38
+ die(
39
+ "auth_error",
40
+ "No address available",
41
+ hint="Set HL_PRIVATE_KEY or use --address",
42
+ exit_code=2,
43
+ )
44
+
45
+
46
+ def get_info(testnet: bool = False) -> Info:
47
+ """Create Info client (default perps only, dexes loaded lazily by resolver)."""
48
+ return Info(_base_url(testnet), skip_ws=True)
49
+
50
+
51
+ def get_exchange(testnet: bool = False) -> Exchange:
52
+ """Create Exchange client (requires HL_PRIVATE_KEY)."""
53
+ key = _get_key()
54
+ account = eth_account.Account.from_key(key)
55
+ return Exchange(account, _base_url(testnet), account_address=account.address)
56
+
57
+
58
+ def get_all_mids(info: Info) -> dict:
59
+ """Get all mid prices including HIP-3 deployer assets."""
60
+ mids = info.all_mids()
61
+ try:
62
+ dex_list = info.post("/info", {"type": "perpDexs"})
63
+ for dex_info in dex_list[1:]:
64
+ dex_mids = info.post("/info", {"type": "allMids", "dex": dex_info["name"]})
65
+ mids.update(dex_mids)
66
+ except Exception:
67
+ pass
68
+ return mids
File without changes
hl_cli/commands/act.py ADDED
@@ -0,0 +1,277 @@
1
+ """Act commands — order, cancel, cancel-all, leverage."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+
11
+ from hl_cli.client import get_address, get_exchange
12
+ from hl_cli.main import app
13
+ from hl_cli.output import die, format_timestamp, output
14
+ from hl_cli.resolver import get_resolver
15
+
16
+ SIDE_MAP = {"buy": True, "long": True, "sell": False, "short": False}
17
+
18
+
19
+ def _parse_order_response(resp, asset: str, side_str: str, size: str):
20
+ """Parse SDK order response into CLI output format."""
21
+ if resp.get("status") == "err":
22
+ die("order_rejected", resp.get("response", str(resp)), exit_code=5)
23
+
24
+ data = resp.get("response", {}).get("data", {})
25
+ statuses = data.get("statuses", [])
26
+ if not statuses:
27
+ die("order_rejected", "No status in response", exit_code=5)
28
+
29
+ st = statuses[0]
30
+ if "error" in st:
31
+ die("order_rejected", st["error"], exit_code=5)
32
+
33
+ if "filled" in st:
34
+ filled = st["filled"]
35
+ return {
36
+ "status": "filled",
37
+ "oid": filled.get("oid", 0),
38
+ "asset": asset,
39
+ "side": side_str,
40
+ "size": filled.get("totalSz", size),
41
+ "avg_price": filled.get("avgPx", ""),
42
+ }
43
+ elif "resting" in st:
44
+ resting = st["resting"]
45
+ return {
46
+ "status": "resting",
47
+ "oid": resting.get("oid", 0),
48
+ "asset": asset,
49
+ "side": side_str,
50
+ "size": size,
51
+ }
52
+ else:
53
+ return {"status": "unknown", "raw": st}
54
+
55
+
56
+ def _log_trade(result: dict, order_type: str, price: str = ""):
57
+ """Append trade record to data/trades.jsonl."""
58
+ trades_dir = Path("data")
59
+ trades_dir.mkdir(exist_ok=True)
60
+ record = {
61
+ "timestamp": format_timestamp(int(time.time() * 1000)),
62
+ "asset": result.get("asset", ""),
63
+ "side": result.get("side", ""),
64
+ "size": result.get("size", ""),
65
+ "price": result.get("avg_price", price),
66
+ "type": order_type,
67
+ "oid": result.get("oid", 0),
68
+ "status": result.get("status", ""),
69
+ }
70
+ with open(trades_dir / "trades.jsonl", "a") as f:
71
+ f.write(json.dumps(record, separators=(",", ":")) + "\n")
72
+
73
+
74
+ @app.command()
75
+ def order(
76
+ ctx: typer.Context,
77
+ asset: Annotated[str, typer.Argument(help="Asset name")],
78
+ side: Annotated[str, typer.Argument(help="buy/long or sell/short")],
79
+ size: Annotated[str, typer.Argument(help="Amount in base asset")],
80
+ price: Annotated[Optional[str], typer.Argument(help="Limit price")] = None,
81
+ market: Annotated[bool, typer.Option("--market", help="Market order (IOC)")] = False,
82
+ tif: Annotated[Optional[str], typer.Option("--tif", help="gtc|ioc|alo")] = None,
83
+ reduce_only: Annotated[bool, typer.Option("--reduce-only", help="Reduce only")] = False,
84
+ tp: Annotated[Optional[str], typer.Option("--tp", help="Take profit trigger price")] = None,
85
+ sl: Annotated[Optional[str], typer.Option("--sl", help="Stop loss trigger price")] = None,
86
+ cloid: Annotated[Optional[str], typer.Option("--cloid", help="Client order ID (hex)")] = None,
87
+ ):
88
+ """Place an order."""
89
+ opts = ctx.obj
90
+
91
+ side_lower = side.lower()
92
+ if side_lower not in SIDE_MAP:
93
+ die("input_error", f"Invalid side: {side}", hint="Use buy/long or sell/short")
94
+
95
+ is_buy = SIDE_MAP[side_lower]
96
+ sz = float(size)
97
+
98
+ exchange = get_exchange(opts["testnet"])
99
+ resolver = get_resolver(exchange.info)
100
+ resolved = resolver.resolve(asset)
101
+
102
+ from hyperliquid.utils.types import Cloid as SdkCloid
103
+ sdk_cloid = SdkCloid.from_str(cloid) if cloid else None
104
+
105
+ if market or price is None:
106
+ # Market order
107
+ resp = exchange.market_open(resolved, is_buy, sz, cloid=sdk_cloid)
108
+ result = _parse_order_response(resp, resolved, side_lower, size)
109
+ _log_trade(result, "market")
110
+ output(result, opts["fields"])
111
+ return
112
+
113
+ # Limit order
114
+ px = float(price)
115
+ tif_val = (tif or "gtc").capitalize()
116
+ if tif_val == "Gtc":
117
+ tif_val = "Gtc"
118
+ elif tif_val == "Ioc":
119
+ tif_val = "Ioc"
120
+ elif tif_val == "Alo":
121
+ tif_val = "Alo"
122
+ order_type = {"limit": {"tif": tif_val}}
123
+
124
+ if tp or sl:
125
+ # Parent + TP/SL as grouped order
126
+ order_requests = [
127
+ {
128
+ "coin": resolved,
129
+ "is_buy": is_buy,
130
+ "sz": sz,
131
+ "limit_px": px,
132
+ "order_type": order_type,
133
+ "reduce_only": reduce_only,
134
+ "cloid": sdk_cloid,
135
+ }
136
+ ]
137
+ if tp:
138
+ tp_type = {"trigger": {"triggerPx": float(tp), "isMarket": True, "tpsl": "tp"}}
139
+ order_requests.append({
140
+ "coin": resolved,
141
+ "is_buy": not is_buy,
142
+ "sz": sz,
143
+ "limit_px": float(tp),
144
+ "order_type": tp_type,
145
+ "reduce_only": True,
146
+ })
147
+ if sl:
148
+ sl_type = {"trigger": {"triggerPx": float(sl), "isMarket": True, "tpsl": "sl"}}
149
+ order_requests.append({
150
+ "coin": resolved,
151
+ "is_buy": not is_buy,
152
+ "sz": sz,
153
+ "limit_px": float(sl),
154
+ "order_type": sl_type,
155
+ "reduce_only": True,
156
+ })
157
+
158
+ resp = exchange.bulk_orders(
159
+ [exchange._order_request_to_wire(r) for r in order_requests]
160
+ if hasattr(exchange, "_order_request_to_wire")
161
+ else order_requests,
162
+ grouping="normalTpsl",
163
+ )
164
+ result = _parse_order_response(resp, resolved, side_lower, size)
165
+ _log_trade(result, "limit", price)
166
+ output(result, opts["fields"])
167
+ else:
168
+ resp = exchange.order(resolved, is_buy, sz, px, order_type, reduce_only, cloid=sdk_cloid)
169
+ result = _parse_order_response(resp, resolved, side_lower, size)
170
+ _log_trade(result, "limit", price)
171
+ output(result, opts["fields"])
172
+
173
+
174
+ @app.command()
175
+ def cancel(
176
+ ctx: typer.Context,
177
+ first: Annotated[str, typer.Argument(help="OID or asset name")],
178
+ second: Annotated[Optional[str], typer.Argument(help="OID (if first is asset)")] = None,
179
+ cloid: Annotated[Optional[str], typer.Option("--cloid", help="Cancel by client order ID")] = None,
180
+ asset_opt: Annotated[Optional[str], typer.Option("--asset", help="Asset for cloid cancel")] = None,
181
+ ):
182
+ """Cancel a specific order."""
183
+ opts = ctx.obj
184
+ exchange = get_exchange(opts["testnet"])
185
+
186
+ if cloid:
187
+ # Cancel by client order ID
188
+ if not asset_opt:
189
+ die("input_error", "Must specify --asset with --cloid", hint="hl cancel --cloid 0x... --asset BTC")
190
+ resolver = get_resolver(exchange.info)
191
+ resolved = resolver.resolve(asset_opt)
192
+ from hyperliquid.utils.types import Cloid as SdkCloid
193
+ resp = exchange.cancel_by_cloid(resolved, SdkCloid.from_str(cloid))
194
+ output({"status": "cancelled", "asset": resolved, "cloid": cloid}, opts["fields"])
195
+ return
196
+
197
+ if second is not None:
198
+ # hl cancel BTC 77738308
199
+ resolver = get_resolver(exchange.info)
200
+ resolved = resolver.resolve(first)
201
+ oid = int(second)
202
+ exchange.cancel(resolved, oid)
203
+ output({"status": "cancelled", "oid": oid, "asset": resolved}, opts["fields"])
204
+ else:
205
+ # hl cancel 77738308 — auto-lookup asset from open orders
206
+ oid = int(first)
207
+ addr = get_address()
208
+ open_orders = exchange.info.open_orders(addr)
209
+ coin = None
210
+ for o in open_orders:
211
+ if o["oid"] == oid:
212
+ coin = o["coin"]
213
+ break
214
+ if not coin:
215
+ die("input_error", f"Order {oid} not found in open orders", hint="Pass asset explicitly: hl cancel BTC {oid}")
216
+ exchange.cancel(coin, oid)
217
+ output({"status": "cancelled", "oid": oid, "asset": coin}, opts["fields"])
218
+
219
+
220
+ @app.command(name="cancel-all")
221
+ def cancel_all(
222
+ ctx: typer.Context,
223
+ asset: Annotated[Optional[str], typer.Argument(help="Cancel only this asset's orders")] = None,
224
+ ):
225
+ """Cancel all open orders."""
226
+ opts = ctx.obj
227
+ exchange = get_exchange(opts["testnet"])
228
+ addr = get_address()
229
+
230
+ open_orders = exchange.info.open_orders(addr)
231
+
232
+ if asset:
233
+ resolver = get_resolver(exchange.info)
234
+ resolved = resolver.resolve(asset)
235
+ open_orders = [o for o in open_orders if o["coin"] == resolved]
236
+
237
+ if not open_orders:
238
+ output({"cancelled": 0, "assets": []}, opts["fields"])
239
+ return
240
+
241
+ cancel_requests = [{"coin": o["coin"], "oid": o["oid"]} for o in open_orders]
242
+ exchange.bulk_cancel(cancel_requests)
243
+
244
+ assets_cancelled = sorted(set(o["coin"] for o in open_orders))
245
+ output({"cancelled": len(open_orders), "assets": assets_cancelled}, opts["fields"])
246
+
247
+
248
+ @app.command()
249
+ def leverage(
250
+ ctx: typer.Context,
251
+ asset: Annotated[str, typer.Argument(help="Asset name")],
252
+ lev: Annotated[int, typer.Argument(help="Leverage (integer)")],
253
+ cross: Annotated[bool, typer.Option("--cross", help="Cross margin")] = False,
254
+ isolated: Annotated[bool, typer.Option("--isolated", help="Isolated margin")] = False,
255
+ ):
256
+ """Set leverage for an asset."""
257
+ opts = ctx.obj
258
+
259
+ if cross and isolated:
260
+ die("input_error", "Cannot specify both --cross and --isolated")
261
+
262
+ exchange = get_exchange(opts["testnet"])
263
+ resolver = get_resolver(exchange.info)
264
+ resolved = resolver.resolve(asset)
265
+
266
+ is_cross = not isolated # default is cross
267
+ if is_cross and ":" in resolved:
268
+ print("Warning: HIP-3 assets support isolated margin only. Using isolated.", file=sys.stderr)
269
+ is_cross = False
270
+
271
+ exchange.update_leverage(lev, resolved, is_cross)
272
+
273
+ output({
274
+ "asset": resolved,
275
+ "leverage": lev,
276
+ "margin_mode": "cross" if is_cross else "isolated",
277
+ }, opts["fields"])
@@ -0,0 +1,174 @@
1
+ """Orient commands — status, balance, positions, orders."""
2
+
3
+ from typing import Annotated, Optional
4
+
5
+ import typer
6
+
7
+ from hl_cli.client import get_address, get_all_mids, get_info
8
+ from hl_cli.main import app
9
+ from hl_cli.output import format_timestamp, output
10
+ from hl_cli.resolver import get_resolver
11
+
12
+
13
+ @app.command()
14
+ def status(
15
+ ctx: typer.Context,
16
+ address: Annotated[Optional[str], typer.Option("--address", help="Check another address")] = None,
17
+ ):
18
+ """One-shot account overview: balance + positions + open orders."""
19
+ opts = ctx.obj
20
+ info = get_info(opts["testnet"])
21
+ addr = get_address(address)
22
+
23
+ state = info.user_state(addr)
24
+ orders = info.frontend_open_orders(addr)
25
+ mids = get_all_mids(info)
26
+
27
+ margin = state.get("crossMarginSummary", state.get("marginSummary", {}))
28
+
29
+ positions = []
30
+ for p in state.get("assetPositions", []):
31
+ pos = p["position"]
32
+ size = float(pos["szi"])
33
+ if size == 0:
34
+ continue
35
+ coin = pos["coin"]
36
+ lev = pos["leverage"]
37
+ positions.append({
38
+ "asset": coin,
39
+ "side": "long" if size > 0 else "short",
40
+ "size": str(abs(size)),
41
+ "entry_price": pos["entryPx"],
42
+ "mark_price": mids.get(coin, ""),
43
+ "unrealized_pnl": pos["unrealizedPnl"],
44
+ "liquidation_price": pos.get("liquidationPx", ""),
45
+ "leverage": str(lev["value"]) if isinstance(lev, dict) else str(lev),
46
+ "margin_used": pos["marginUsed"],
47
+ })
48
+
49
+ order_list = []
50
+ for o in orders:
51
+ order_list.append({
52
+ "oid": o["oid"],
53
+ "asset": o["coin"],
54
+ "side": "buy" if o["side"] == "B" else "sell",
55
+ "price": o["limitPx"],
56
+ "size": o["sz"],
57
+ "type": (o.get("orderType") or "Limit").lower(),
58
+ "tif": (o.get("tif") or "").lower() or None,
59
+ "reduce_only": o.get("reduceOnly", False),
60
+ "timestamp": format_timestamp(o.get("timestamp", 0)),
61
+ })
62
+
63
+ result = {
64
+ "account_value": margin.get("accountValue", "0"),
65
+ "withdrawable": state.get("withdrawable", "0"),
66
+ "total_margin_used": margin.get("totalMarginUsed", "0"),
67
+ "total_ntl_pos": margin.get("totalNtlPos", "0"),
68
+ "positions": positions,
69
+ "open_orders": order_list,
70
+ }
71
+ output(result, opts["fields"])
72
+
73
+
74
+ @app.command()
75
+ def balance(
76
+ ctx: typer.Context,
77
+ address: Annotated[Optional[str], typer.Option("--address", help="Check another address")] = None,
78
+ ):
79
+ """Account balance."""
80
+ opts = ctx.obj
81
+ info = get_info(opts["testnet"])
82
+ addr = get_address(address)
83
+
84
+ state = info.user_state(addr)
85
+ margin = state.get("crossMarginSummary", state.get("marginSummary", {}))
86
+
87
+ result = {
88
+ "account_value": margin.get("accountValue", "0"),
89
+ "total_margin_used": margin.get("totalMarginUsed", "0"),
90
+ "total_ntl_pos": margin.get("totalNtlPos", "0"),
91
+ "withdrawable": state.get("withdrawable", "0"),
92
+ }
93
+ output(result, opts["fields"])
94
+
95
+
96
+ @app.command()
97
+ def positions(
98
+ ctx: typer.Context,
99
+ asset: Annotated[Optional[str], typer.Argument(help="Filter by asset")] = None,
100
+ address: Annotated[Optional[str], typer.Option("--address", help="Check another address")] = None,
101
+ ):
102
+ """Open positions with P&L."""
103
+ opts = ctx.obj
104
+ info = get_info(opts["testnet"])
105
+ addr = get_address(address)
106
+
107
+ state = info.user_state(addr)
108
+ mids = get_all_mids(info)
109
+
110
+ resolved = None
111
+ if asset:
112
+ resolver = get_resolver(info)
113
+ resolved = resolver.resolve(asset)
114
+
115
+ result = []
116
+ for p in state.get("assetPositions", []):
117
+ pos = p["position"]
118
+ size = float(pos["szi"])
119
+ if size == 0:
120
+ continue
121
+ coin = pos["coin"]
122
+ if resolved and coin != resolved:
123
+ continue
124
+ lev = pos["leverage"]
125
+ result.append({
126
+ "asset": coin,
127
+ "side": "long" if size > 0 else "short",
128
+ "size": str(abs(size)),
129
+ "entry_price": pos["entryPx"],
130
+ "mark_price": mids.get(coin, ""),
131
+ "unrealized_pnl": pos["unrealizedPnl"],
132
+ "liquidation_price": pos.get("liquidationPx", ""),
133
+ "leverage": str(lev["value"]) if isinstance(lev, dict) else str(lev),
134
+ "margin_used": pos["marginUsed"],
135
+ })
136
+
137
+ output(result, opts["fields"])
138
+
139
+
140
+ @app.command()
141
+ def orders(
142
+ ctx: typer.Context,
143
+ asset: Annotated[Optional[str], typer.Argument(help="Filter by asset")] = None,
144
+ ):
145
+ """Open/pending orders."""
146
+ opts = ctx.obj
147
+ info = get_info(opts["testnet"])
148
+ addr = get_address()
149
+
150
+ raw = info.frontend_open_orders(addr)
151
+
152
+ resolved = None
153
+ if asset:
154
+ resolver = get_resolver(info)
155
+ resolved = resolver.resolve(asset)
156
+
157
+ result = []
158
+ for o in raw:
159
+ coin = o["coin"]
160
+ if resolved and coin != resolved:
161
+ continue
162
+ result.append({
163
+ "oid": o["oid"],
164
+ "asset": coin,
165
+ "side": "buy" if o["side"] == "B" else "sell",
166
+ "price": o["limitPx"],
167
+ "size": o["sz"],
168
+ "type": (o.get("orderType") or "Limit").lower(),
169
+ "tif": (o.get("tif") or "").lower() or None,
170
+ "reduce_only": o.get("reduceOnly", False),
171
+ "timestamp": format_timestamp(o.get("timestamp", 0)),
172
+ })
173
+
174
+ output(result, opts["fields"])
@@ -0,0 +1,287 @@
1
+ """Research commands — assets, prices, book, funding, candles."""
2
+
3
+ import time
4
+ from typing import Annotated, List, Optional
5
+
6
+ import typer
7
+
8
+ from hl_cli.client import get_all_mids, get_info
9
+ from hl_cli.main import app
10
+ from hl_cli.output import (
11
+ die,
12
+ format_timestamp,
13
+ interval_to_ms,
14
+ output,
15
+ parse_time,
16
+ )
17
+ from hl_cli.resolver import get_resolver
18
+
19
+
20
+ @app.command()
21
+ def assets(
22
+ ctx: typer.Context,
23
+ query: Annotated[Optional[str], typer.Argument(help="Search by name")] = None,
24
+ deployer: Annotated[Optional[str], typer.Option("--deployer", help="Filter by deployer")] = None,
25
+ sort: Annotated[str, typer.Option("--sort", help="Sort: volume|oi|name")] = "volume",
26
+ ):
27
+ """List and search all tradeable perps."""
28
+ opts = ctx.obj
29
+ info = get_info(opts["testnet"])
30
+
31
+ # Default perps with full context (volume, OI, funding, etc.)
32
+ meta_ctxs = info.meta_and_asset_ctxs()
33
+ meta = meta_ctxs[0]
34
+ ctxs = meta_ctxs[1]
35
+
36
+ result = []
37
+ for i, (asset_info, asset_ctx) in enumerate(zip(meta["universe"], ctxs)):
38
+ result.append({
39
+ "asset": asset_info["name"],
40
+ "asset_id": i,
41
+ "max_leverage": asset_info.get("maxLeverage", 0),
42
+ "mark_price": asset_ctx.get("markPx", ""),
43
+ "funding_rate": asset_ctx.get("funding", ""),
44
+ "open_interest": asset_ctx.get("openInterest", ""),
45
+ "volume_24h": asset_ctx.get("dayNtlVlm", ""),
46
+ })
47
+
48
+ # HIP-3 assets — load dex metadata and try to get contexts
49
+ try:
50
+ dex_list = info.post("/info", {"type": "perpDexs"})
51
+ mids = get_all_mids(info)
52
+
53
+ for idx, dex_info in enumerate(dex_list[1:]):
54
+ dex_name = dex_info["name"]
55
+ try:
56
+ # Try metaAndAssetCtxs with dex param for full data
57
+ dex_data = info.post("/info", {"type": "metaAndAssetCtxs", "dex": dex_name})
58
+ dex_meta = dex_data[0]
59
+ dex_ctxs = dex_data[1]
60
+ offset = 110000 + idx * 10000
61
+
62
+ for j, (ai, ac) in enumerate(zip(dex_meta["universe"], dex_ctxs)):
63
+ name = ai["name"]
64
+ result.append({
65
+ "asset": name,
66
+ "asset_id": j + offset,
67
+ "deployer": dex_name,
68
+ "max_leverage": ai.get("maxLeverage", 0),
69
+ "mark_price": ac.get("markPx", mids.get(name, "")),
70
+ "funding_rate": ac.get("funding", ""),
71
+ "open_interest": ac.get("openInterest", ""),
72
+ "volume_24h": ac.get("dayNtlVlm", ""),
73
+ })
74
+ except Exception:
75
+ # Fallback: use meta only + all_mids for price
76
+ try:
77
+ dex_meta = info.meta(dex=dex_name)
78
+ for ai in dex_meta["universe"]:
79
+ name = ai["name"]
80
+ result.append({
81
+ "asset": name,
82
+ "deployer": dex_name,
83
+ "max_leverage": ai.get("maxLeverage", 0),
84
+ "mark_price": mids.get(name, ""),
85
+ "funding_rate": "",
86
+ "open_interest": "",
87
+ "volume_24h": "",
88
+ })
89
+ except Exception:
90
+ continue
91
+ except Exception:
92
+ pass # no HIP-3 data available
93
+
94
+ # Filter
95
+ if query:
96
+ q = query.upper()
97
+ result = [a for a in result if q in a["asset"].upper()]
98
+ if deployer:
99
+ result = [a for a in result if a.get("deployer", "") == deployer]
100
+
101
+ # Sort
102
+ sort_key = {"volume": "volume_24h", "oi": "open_interest", "name": "asset"}.get(sort, "volume_24h")
103
+ if sort_key == "asset":
104
+ result.sort(key=lambda x: x["asset"])
105
+ else:
106
+ result.sort(key=lambda x: float(x.get(sort_key) or "0"), reverse=True)
107
+
108
+ output(result, opts["fields"])
109
+
110
+
111
+ @app.command()
112
+ def prices(
113
+ ctx: typer.Context,
114
+ assets: Annotated[Optional[List[str]], typer.Argument(help="Asset names")] = None,
115
+ ):
116
+ """Current prices."""
117
+ opts = ctx.obj
118
+ info = get_info(opts["testnet"])
119
+
120
+ if not assets:
121
+ # Lightweight: all mid prices (including HIP-3)
122
+ mids = get_all_mids(info)
123
+ result = [{"asset": k, "mid": v} for k, v in sorted(mids.items()) if not k.startswith("@")]
124
+ output(result, opts["fields"])
125
+ return
126
+
127
+ # Detailed: per-asset info
128
+ resolver = get_resolver(info)
129
+ mids = get_all_mids(info)
130
+
131
+ # Build context lookup from default perps + HIP-3 dexes
132
+ meta_ctxs = info.meta_and_asset_ctxs()
133
+ ctx_lookup = {}
134
+ for asset_info, asset_ctx in zip(meta_ctxs[0]["universe"], meta_ctxs[1]):
135
+ ctx_lookup[asset_info["name"]] = asset_ctx
136
+
137
+ # Load HIP-3 context for any HIP-3 assets requested
138
+ hip3_dexes_needed = set()
139
+ for name in assets:
140
+ resolved = resolver.resolve(name)
141
+ if ":" in resolved and resolved not in ctx_lookup:
142
+ hip3_dexes_needed.add(resolved.split(":")[0])
143
+ for dex in hip3_dexes_needed:
144
+ try:
145
+ dex_data = info.post("/info", {"type": "metaAndAssetCtxs", "dex": dex})
146
+ for ai, ac in zip(dex_data[0]["universe"], dex_data[1]):
147
+ ctx_lookup[ai["name"]] = ac
148
+ except Exception:
149
+ pass
150
+
151
+ result = []
152
+ for name in assets:
153
+ resolved = resolver.resolve(name)
154
+ ac = ctx_lookup.get(resolved, {})
155
+ mid = mids.get(resolved, "")
156
+ prev = ac.get("prevDayPx", "")
157
+ change = ""
158
+ if mid and prev:
159
+ try:
160
+ change = f"{(float(mid) / float(prev) - 1) * 100:.2f}"
161
+ except (ValueError, ZeroDivisionError):
162
+ pass
163
+ result.append({
164
+ "asset": resolved,
165
+ "mid": mid,
166
+ "mark": ac.get("markPx", mid),
167
+ "oracle": ac.get("oraclePx", ""),
168
+ "prev_day": prev,
169
+ "change_pct": change,
170
+ })
171
+
172
+ output(result, opts["fields"])
173
+
174
+
175
+ @app.command()
176
+ def book(
177
+ ctx: typer.Context,
178
+ asset: Annotated[str, typer.Argument(help="Asset name")],
179
+ depth: Annotated[int, typer.Option("--depth", help="Levels per side (max 20)")] = 5,
180
+ ):
181
+ """Order book depth."""
182
+ opts = ctx.obj
183
+ info = get_info(opts["testnet"])
184
+ resolver = get_resolver(info)
185
+ resolved = resolver.resolve(asset)
186
+
187
+ snapshot = info.l2_snapshot(resolved)
188
+ levels = snapshot.get("levels", [[], []])
189
+
190
+ bids = [{"price": lv["px"], "size": lv["sz"], "orders": lv["n"]} for lv in levels[0][:depth]]
191
+ asks = [{"price": lv["px"], "size": lv["sz"], "orders": lv["n"]} for lv in levels[1][:depth]]
192
+
193
+ result = {"asset": resolved, "bids": bids, "asks": asks}
194
+ output(result, opts["fields"])
195
+
196
+
197
+ @app.command()
198
+ def funding(
199
+ ctx: typer.Context,
200
+ assets: Annotated[Optional[List[str]], typer.Argument(help="Asset names")] = None,
201
+ history: Annotated[bool, typer.Option("--history", help="Show historical rates")] = False,
202
+ limit: Annotated[int, typer.Option("--limit", help="Number of periods")] = 20,
203
+ start: Annotated[Optional[str], typer.Option("--start", help="Start time (ISO 8601)")] = None,
204
+ end: Annotated[Optional[str], typer.Option("--end", help="End time (ISO 8601)")] = None,
205
+ ):
206
+ """Funding rates (current or historical)."""
207
+ opts = ctx.obj
208
+ info = get_info(opts["testnet"])
209
+
210
+ if history:
211
+ if not assets:
212
+ die("input_error", "Specify at least one asset for --history", hint="hl funding BTC --history")
213
+ resolver = get_resolver(info)
214
+ result = []
215
+ for name in assets:
216
+ resolved = resolver.resolve(name)
217
+ start_ms = parse_time(start) if start else int(time.time() * 1000) - limit * 8 * 3600 * 1000
218
+ end_ms = parse_time(end) if end else None
219
+ data = info.funding_history(resolved, start_ms, end_ms)
220
+ for h in data[-limit:]:
221
+ rate = h.get("fundingRate", "0")
222
+ result.append({
223
+ "asset": resolved,
224
+ "rate": rate,
225
+ "premium": h.get("premium", ""),
226
+ "annualized": f"{float(rate) * 3 * 365:.2f}",
227
+ "time": format_timestamp(h.get("time", 0)),
228
+ })
229
+ output(result, opts["fields"])
230
+ else:
231
+ # Current rates from metaAndAssetCtxs
232
+ meta_ctxs = info.meta_and_asset_ctxs()
233
+ asset_filter = {a.upper() for a in assets} if assets else None
234
+
235
+ result = []
236
+ for asset_info, asset_ctx in zip(meta_ctxs[0]["universe"], meta_ctxs[1]):
237
+ name = asset_info["name"]
238
+ if asset_filter and name.upper() not in asset_filter:
239
+ continue
240
+ rate = asset_ctx.get("funding", "0")
241
+ result.append({
242
+ "asset": name,
243
+ "rate": rate,
244
+ "premium": asset_ctx.get("premium", ""),
245
+ "annualized": f"{float(rate) * 3 * 365:.2f}",
246
+ })
247
+
248
+ output(result, opts["fields"])
249
+
250
+
251
+ @app.command()
252
+ def candles(
253
+ ctx: typer.Context,
254
+ asset: Annotated[str, typer.Argument(help="Asset name")],
255
+ interval: Annotated[str, typer.Argument(help="1m 5m 15m 1h 4h 1d etc.")],
256
+ limit: Annotated[int, typer.Option("--limit", help="Number of candles (max 5000)")] = 100,
257
+ start: Annotated[Optional[str], typer.Option("--start", help="Start time (ISO 8601)")] = None,
258
+ end: Annotated[Optional[str], typer.Option("--end", help="End time (ISO 8601)")] = None,
259
+ ):
260
+ """OHLCV price history."""
261
+ opts = ctx.obj
262
+ info = get_info(opts["testnet"])
263
+ resolver = get_resolver(info)
264
+ resolved = resolver.resolve(asset)
265
+
266
+ end_ms = parse_time(end) if end else int(time.time() * 1000)
267
+ if start:
268
+ start_ms = parse_time(start)
269
+ else:
270
+ ms_per = interval_to_ms(interval)
271
+ start_ms = end_ms - ms_per * limit
272
+
273
+ data = info.candles_snapshot(resolved, interval, start_ms, end_ms)
274
+
275
+ result = []
276
+ for c in data[-limit:]:
277
+ result.append({
278
+ "time": format_timestamp(c["t"]),
279
+ "open": c["o"],
280
+ "high": c["h"],
281
+ "low": c["l"],
282
+ "close": c["c"],
283
+ "volume": c["v"],
284
+ "trades": c["n"],
285
+ })
286
+
287
+ output(result, opts["fields"])
@@ -0,0 +1,84 @@
1
+ """Review commands — fills, trades."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from hl_cli.client import get_address, get_info
10
+ from hl_cli.main import app
11
+ from hl_cli.output import format_timestamp, output, parse_time
12
+ from hl_cli.resolver import get_resolver
13
+
14
+
15
+ @app.command()
16
+ def fills(
17
+ ctx: typer.Context,
18
+ asset: Annotated[Optional[str], typer.Argument(help="Filter by asset")] = None,
19
+ limit: Annotated[int, typer.Option("--limit", help="Number of fills (max 2000)")] = 20,
20
+ start: Annotated[Optional[str], typer.Option("--start", help="Start time (ISO 8601)")] = None,
21
+ end: Annotated[Optional[str], typer.Option("--end", help="End time (ISO 8601)")] = None,
22
+ ):
23
+ """Recent trade executions from the API."""
24
+ opts = ctx.obj
25
+ info = get_info(opts["testnet"])
26
+ addr = get_address()
27
+
28
+ resolved = None
29
+ if asset:
30
+ resolver = get_resolver(info)
31
+ resolved = resolver.resolve(asset)
32
+
33
+ if start or end:
34
+ start_ms = parse_time(start) if start else 0
35
+ end_ms = parse_time(end) if end else None
36
+ data = info.user_fills_by_time(addr, start_ms, end_ms)
37
+ else:
38
+ data = info.user_fills(addr)
39
+
40
+ result = []
41
+ for f in data:
42
+ coin = f["coin"]
43
+ if resolved and coin != resolved:
44
+ continue
45
+ result.append({
46
+ "asset": coin,
47
+ "side": "buy" if f["side"] == "B" else "sell",
48
+ "direction": f.get("dir", ""),
49
+ "price": f["px"],
50
+ "size": f["sz"],
51
+ "fee": f.get("fee", ""),
52
+ "closed_pnl": f.get("closedPnl", "0"),
53
+ "time": format_timestamp(f.get("time", 0)),
54
+ })
55
+
56
+ result = result[-limit:]
57
+ output(result, opts["fields"])
58
+
59
+
60
+ @app.command()
61
+ def trades(
62
+ ctx: typer.Context,
63
+ asset_name: Annotated[Optional[str], typer.Option("--asset", help="Filter by asset")] = None,
64
+ limit: Annotated[int, typer.Option("--limit", help="Number of trades")] = 20,
65
+ ):
66
+ """Local trade log (from data/trades.jsonl)."""
67
+ opts = ctx.obj
68
+ trades_file = Path("data/trades.jsonl")
69
+
70
+ if not trades_file.exists():
71
+ output([], opts["fields"])
72
+ return
73
+
74
+ result = []
75
+ for line in trades_file.read_text().strip().split("\n"):
76
+ if not line:
77
+ continue
78
+ trade = json.loads(line)
79
+ if asset_name and trade.get("asset") != asset_name:
80
+ continue
81
+ result.append(trade)
82
+
83
+ result = result[-limit:]
84
+ output(result, opts["fields"])
hl_cli/main.py ADDED
@@ -0,0 +1,30 @@
1
+ """hl — Agent-first CLI for Hyperliquid trading."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(
8
+ name="hl",
9
+ add_completion=False,
10
+ no_args_is_help=True,
11
+ help="Agent-first CLI for Hyperliquid perp trading.",
12
+ )
13
+
14
+
15
+ @app.callback()
16
+ def main(
17
+ ctx: typer.Context,
18
+ fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated field projection"),
19
+ verbose: bool = typer.Option(False, "--verbose", help="Debug info to stderr"),
20
+ testnet: bool = typer.Option(False, "--testnet", help="Use testnet endpoints"),
21
+ ):
22
+ """Hyperliquid perp trading CLI. JSON output by default."""
23
+ ctx.ensure_object(dict)
24
+ ctx.obj["fields"] = fields
25
+ ctx.obj["verbose"] = verbose
26
+ ctx.obj["testnet"] = testnet
27
+
28
+
29
+ # Register all commands — must be after app is defined
30
+ from hl_cli.commands import orient, research, act, review # noqa: E402, F401
hl_cli/output.py ADDED
@@ -0,0 +1,84 @@
1
+ """Output formatting, errors, and time utilities."""
2
+
3
+ import json
4
+ import sys
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ def output(data, fields=None):
9
+ """Write data to stdout as compact JSON."""
10
+ if fields:
11
+ data = _project(data, fields)
12
+ sys.stdout.write(json.dumps(data, separators=(",", ":")) + "\n")
13
+
14
+
15
+ def die(error_code: str, message: str, hint: str = None, exit_code: int = 1):
16
+ """Write structured error to stdout and exit."""
17
+ err = {"error": error_code, "message": message}
18
+ if hint:
19
+ err["hint"] = hint
20
+ sys.stdout.write(json.dumps(err, separators=(",", ":")) + "\n")
21
+ raise SystemExit(exit_code)
22
+
23
+
24
+ def debug(message: str, verbose: bool = False):
25
+ """Write debug info to stderr."""
26
+ if verbose:
27
+ sys.stderr.write(f"[debug] {message}\n")
28
+
29
+
30
+ def _project(data, fields_str: str):
31
+ """Filter data to only include specified fields."""
32
+ fields = {f.strip() for f in fields_str.split(",")}
33
+ if isinstance(data, list):
34
+ return [
35
+ {k: v for k, v in item.items() if k in fields}
36
+ for item in data
37
+ if isinstance(item, dict)
38
+ ]
39
+ elif isinstance(data, dict):
40
+ return {k: v for k, v in data.items() if k in fields}
41
+ return data
42
+
43
+
44
+ def parse_time(s: str) -> int:
45
+ """Parse time string to unix milliseconds."""
46
+ dt = datetime.fromisoformat(s)
47
+ if dt.tzinfo is None:
48
+ dt = dt.replace(tzinfo=timezone.utc)
49
+ return int(dt.timestamp() * 1000)
50
+
51
+
52
+ def format_timestamp(ms) -> str:
53
+ """Format unix milliseconds to ISO 8601."""
54
+ if isinstance(ms, (int, float)) and ms > 0:
55
+ dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
56
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
57
+ return str(ms)
58
+
59
+
60
+ INTERVAL_MS = {
61
+ "1m": 60_000,
62
+ "3m": 180_000,
63
+ "5m": 300_000,
64
+ "15m": 900_000,
65
+ "30m": 1_800_000,
66
+ "1h": 3_600_000,
67
+ "2h": 7_200_000,
68
+ "4h": 14_400_000,
69
+ "8h": 28_800_000,
70
+ "12h": 43_200_000,
71
+ "1d": 86_400_000,
72
+ "3d": 259_200_000,
73
+ "1w": 604_800_000,
74
+ "1M": 2_592_000_000,
75
+ }
76
+
77
+
78
+ def interval_to_ms(interval: str) -> int:
79
+ """Convert candle interval string to milliseconds."""
80
+ ms = INTERVAL_MS.get(interval)
81
+ if ms is None:
82
+ valid = ", ".join(INTERVAL_MS.keys())
83
+ die("input_error", f"Invalid interval: {interval}", hint=f"Valid intervals: {valid}")
84
+ return ms
hl_cli/resolver.py ADDED
@@ -0,0 +1,151 @@
1
+ """Asset name resolution — maps short tickers to full coin names."""
2
+
3
+ from hyperliquid.info import Info
4
+
5
+ from hl_cli.output import die
6
+
7
+
8
+ class Resolver:
9
+ """Resolves user-typed asset names to SDK coin names.
10
+
11
+ - Exact match on known coin (e.g. "BTC", "xyz:TSLA") -> use it
12
+ - Case-insensitive match on default perps -> use it
13
+ - Short HIP-3 ticker (e.g. "TSLA") -> resolve, error if ambiguous
14
+ - Lazy-loads HIP-3 dex metadata on first miss against default perps
15
+ """
16
+
17
+ def __init__(self, info: Info):
18
+ self.info = info
19
+ self._dexes_loaded = False
20
+ self._short_map: dict[str, list[str]] = {} # UPPER ticker -> [full names]
21
+ self._build_default_map()
22
+
23
+ def _build_default_map(self):
24
+ """Index default perps by uppercase name."""
25
+ for name in self.info.coin_to_asset:
26
+ if ":" not in name and "@" not in name:
27
+ key = name.upper()
28
+ self._short_map.setdefault(key, []).append(name)
29
+
30
+ def _load_dexes(self):
31
+ """Lazily load all HIP-3 deployer metadata into the Info client."""
32
+ if self._dexes_loaded:
33
+ return
34
+ self._dexes_loaded = True
35
+
36
+ try:
37
+ dex_list = self.info.post("/info", {"type": "perpDexs"})
38
+ except Exception:
39
+ return # can't load dexes, resolve will fail with unknown_asset
40
+
41
+ for idx, dex_info in enumerate(dex_list[1:]): # skip default dex
42
+ dex_name = dex_info["name"]
43
+ try:
44
+ dex_meta = self.info.meta(dex=dex_name)
45
+ offset = 110000 + idx * 10000
46
+ self.info.set_perp_meta(dex_meta, offset)
47
+
48
+ # Index by short ticker for disambiguation
49
+ for asset_info in dex_meta["universe"]:
50
+ full_name = asset_info["name"]
51
+ # The API may or may not prefix with dex name
52
+ if ":" in full_name:
53
+ _, ticker = full_name.split(":", 1)
54
+ else:
55
+ ticker = full_name
56
+ key = ticker.upper()
57
+ self._short_map.setdefault(key, []).append(full_name)
58
+ except Exception:
59
+ continue
60
+
61
+ def _find_exact(self, name: str) -> str | None:
62
+ """Case-insensitive exact match against all known coins."""
63
+ if name in self.info.coin_to_asset:
64
+ return name
65
+ upper = name.upper()
66
+ for known in self.info.coin_to_asset:
67
+ if known.upper() == upper:
68
+ return known
69
+ return None
70
+
71
+ def resolve(self, name: str) -> str:
72
+ """Resolve a user-typed asset name to the SDK coin name.
73
+
74
+ Returns the canonical coin name or calls die() on error.
75
+ """
76
+ # Exact match (already known to SDK)
77
+ exact = self._find_exact(name)
78
+ if exact:
79
+ return exact
80
+
81
+ # Short-name match against indexed tickers
82
+ key = name.upper()
83
+ matches = self._short_map.get(key)
84
+
85
+ if matches is None and not self._dexes_loaded:
86
+ # Try loading HIP-3 dexes
87
+ self._load_dexes()
88
+ # Re-check exact match (full names now loaded)
89
+ exact = self._find_exact(name)
90
+ if exact:
91
+ return exact
92
+ matches = self._short_map.get(key)
93
+
94
+ if not matches:
95
+ die(
96
+ "unknown_asset",
97
+ f"Unknown asset: {name}",
98
+ hint="Use 'hl assets' to list available assets, or specify full name like 'xyz:TSLA'",
99
+ exit_code=4,
100
+ )
101
+
102
+ if len(matches) == 1:
103
+ return matches[0]
104
+
105
+ # Multiple matches — prefer default perp (no colon) over HIP-3
106
+ defaults = [m for m in matches if ":" not in m]
107
+ if len(defaults) == 1:
108
+ return defaults[0]
109
+
110
+ # Ambiguous — build match info for the error
111
+ mids = {}
112
+ try:
113
+ mids = self.info.all_mids()
114
+ # Also fetch HIP-3 mids for the deployers involved
115
+ dex_list = self.info.post("/info", {"type": "perpDexs"})
116
+ deployers = {m.split(":")[0] for m in matches if ":" in m}
117
+ for dex_info in dex_list[1:]:
118
+ if dex_info["name"] in deployers:
119
+ dex_mids = self.info.post("/info", {"type": "allMids", "dex": dex_info["name"]})
120
+ mids.update(dex_mids)
121
+ except Exception:
122
+ pass
123
+
124
+ match_info = []
125
+ for m in matches:
126
+ deployer = m.split(":")[0] if ":" in m else ""
127
+ match_info.append({"asset": m, "deployer": deployer, "mid": mids.get(m, "")})
128
+
129
+ import json
130
+ import sys
131
+
132
+ err = {
133
+ "error": "ambiguous_asset",
134
+ "message": f"{name} matches multiple markets",
135
+ "matches": match_info,
136
+ "hint": f"Use full name: hl <command> {matches[0]} ...",
137
+ }
138
+ sys.stdout.write(json.dumps(err, separators=(",", ":")) + "\n")
139
+ raise SystemExit(4)
140
+
141
+
142
+ # Module-level singleton per Info instance
143
+ _resolvers: dict[int, Resolver] = {}
144
+
145
+
146
+ def get_resolver(info: Info) -> Resolver:
147
+ """Get or create a Resolver for the given Info client."""
148
+ key = id(info)
149
+ if key not in _resolvers:
150
+ _resolvers[key] = Resolver(info)
151
+ return _resolvers[key]
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperliquid-cli
3
+ Version: 0.1.0
4
+ Summary: Agent-first CLI for Hyperliquid trading
5
+ Author: tab55
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agent,cli,defi,hyperliquid,trading
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Office/Business :: Financial :: Investment
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: hyperliquid-python-sdk>=0.22.0
21
+ Requires-Dist: typer>=0.9.0
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8.0; extra == 'test'
24
+ Requires-Dist: syrupy>=4.0; extra == 'test'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # hyperliquid-cli
28
+
29
+ Agent-first CLI for Hyperliquid perpetual futures trading. Thin Python wrapper over [hyperliquid-python-sdk](https://github.com/hyperliquid-x/hyperliquid-python-sdk).
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ uv tool install hyperliquid-cli
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ # Read commands (no auth needed)
41
+ hl prices BTC ETH
42
+ hl book BTC --depth 10
43
+ hl assets TSLA
44
+ hl funding BTC --history
45
+ hl candles BTC 1h --limit 50
46
+
47
+ # Account commands (needs HL_PRIVATE_KEY or --address)
48
+ hl status
49
+ hl balance
50
+ hl positions
51
+ hl orders
52
+ hl fills
53
+
54
+ # Write commands (needs HL_PRIVATE_KEY)
55
+ hl order BTC buy 0.1 --market
56
+ hl order BTC buy 0.1 95000 --tp 100000 --sl 90000
57
+ hl cancel 77738308
58
+ hl cancel-all BTC
59
+ hl leverage BTC 10
60
+ ```
61
+
62
+ ## Auth
63
+
64
+ Set `HL_PRIVATE_KEY` env var with your Hyperliquid API wallet key (can trade but cannot withdraw).
65
+
66
+ Read-only commands work without auth using `--address`.
67
+
68
+ ## Design
69
+
70
+ - JSON-only output (stdout), debug/warnings to stderr
71
+ - Rich exit codes (0=success, 1=input, 2=auth, 3=API, 4=asset, 5=rejected)
72
+ - Structured errors with recovery hints
73
+ - Field projection via `--fields`
74
+ - HIP-3 asset auto-resolution (e.g. `TSLA` → `xyz:TSLA`)
75
+ - Trade logging to `data/trades.jsonl`
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,15 @@
1
+ hl_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ hl_cli/client.py,sha256=pCQ2flLJjsQBNt_X0OFDcNH-UQgXtL4RtEcwazMsAis,1975
3
+ hl_cli/main.py,sha256=R7kyEN-Upw0HDiA6yN-hXo2MfNd0H7ts2rW_sB-MK8Q,893
4
+ hl_cli/output.py,sha256=IDjZfAN-rlZFhpV_0RyAWQVFZc5xh4f-8wtiIy63qXo,2364
5
+ hl_cli/resolver.py,sha256=NrtOqd0KTF0HpfWcOV5bgbbqMSrpNQuEDAhMRo-My84,5335
6
+ hl_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ hl_cli/commands/act.py,sha256=d3mCYquw4Aawco6p9MaZi114J-NshsffYCjy74VRoTA,9901
8
+ hl_cli/commands/orient.py,sha256=cgx9ifxtAuaZWm-0jXJREF9CzhJ0MGvKWp67hoIh1tM,5442
9
+ hl_cli/commands/research.py,sha256=AZt6VNlcIyfAH9jBjGPL_RdOiwYyMeNcjpNdw9IfwBY,10382
10
+ hl_cli/commands/review.py,sha256=WHCTjbHzPmzuizYj82wQjfNdPK6WMuJo6-JaNT4gB6g,2535
11
+ hyperliquid_cli-0.1.0.dist-info/METADATA,sha256=JUWCtZaG2iJkRSJryPHVrNmPqSCuzqFHBKak0f0GsHs,2142
12
+ hyperliquid_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ hyperliquid_cli-0.1.0.dist-info/entry_points.txt,sha256=bhcbj-NYrC5Ehnxljd9-y51hFwLdBM14jyMofWxa0pQ,39
14
+ hyperliquid_cli-0.1.0.dist-info/licenses/LICENSE,sha256=cGXuzMAOqozvfDc6j_SY0yqCr5jABYqf1m0De5VQN_k,1062
15
+ hyperliquid_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hl = hl_cli.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tab55
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.