hyperliquid-cli-python 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/commands/app.py ADDED
@@ -0,0 +1,1081 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import time
5
+ from queue import Empty, Queue
6
+ from datetime import datetime
7
+ from typing import Any, Optional
8
+
9
+ from eth_account import Account as EthAccount
10
+ from hyperliquid.info import Info
11
+
12
+ from ..cli.markets_tui import run_markets_tui
13
+ from ..cli.runtime import (
14
+ cli_command,
15
+ cli_context,
16
+ console,
17
+ confirm,
18
+ finish_command,
19
+ json_output_enabled,
20
+ render_table,
21
+ run_blocking,
22
+ )
23
+ from ..core.context import CLIContext
24
+ from ..infra.db import (
25
+ create_account,
26
+ delete_account,
27
+ get_account_by_alias,
28
+ get_account_count,
29
+ get_all_accounts,
30
+ is_alias_taken,
31
+ set_default_account,
32
+ )
33
+ from .order import (
34
+ _mids_for_coin,
35
+ _resolve_tradable_coin,
36
+ order_cancel,
37
+ order_cancel_all,
38
+ order_configure,
39
+ order_limit,
40
+ order_ls,
41
+ order_market,
42
+ order_market_close,
43
+ order_set_leverage,
44
+ order_tpsl,
45
+ order_twap,
46
+ order_twap_cancel,
47
+ )
48
+ from ..utils.output import out
49
+ from ..utils.validators import (
50
+ normalize_private_key,
51
+ validate_address,
52
+ )
53
+ from ..utils.watch import watch_loop
54
+
55
+ def _ctx(ctx: Any) -> CLIContext:
56
+ return cli_context(ctx)
57
+
58
+
59
+ def _json(ctx: Any) -> bool:
60
+ return json_output_enabled(ctx)
61
+
62
+
63
+ def _done(ctx: Any) -> None:
64
+ finish_command(ctx)
65
+
66
+
67
+ def _confirm(message: str, default: bool = False) -> bool:
68
+ return confirm(message, default)
69
+
70
+
71
+ def _format_address(addr: str) -> str:
72
+ return f"{addr[:6]}...{addr[-4:]}"
73
+
74
+
75
+ def _render_table(title: str, columns: list[str], rows: list[list[Any]]) -> None:
76
+ render_table(title, columns, rows)
77
+
78
+
79
+ def _format_usd(value: str | float | int | None) -> str:
80
+ try:
81
+ n = float(value) # type: ignore[arg-type]
82
+ return f"${n:,.2f}"
83
+ except Exception:
84
+ return f"${value}" if value is not None else "-"
85
+
86
+
87
+ def _format_price(value: str | float | int | None) -> str:
88
+ try:
89
+ n = float(value) # type: ignore[arg-type]
90
+ except Exception:
91
+ return f"${value}" if value is not None else "-"
92
+
93
+ abs_n = abs(n)
94
+ if abs_n >= 1000:
95
+ s = f"{n:,.2f}"
96
+ elif abs_n >= 1:
97
+ s = f"{n:,.4f}"
98
+ elif abs_n >= 0.01:
99
+ s = f"{n:,.4f}"
100
+ elif abs_n >= 0.0001:
101
+ s = f"{n:,.6f}"
102
+ else:
103
+ s = f"{n:,.8f}"
104
+
105
+ if "." in s:
106
+ s = s.rstrip("0").rstrip(".")
107
+ return f"${s}"
108
+
109
+
110
+ def _format_rate_pct(value: str | float | int | None) -> str:
111
+ try:
112
+ n = float(value) # type: ignore[arg-type]
113
+ except Exception:
114
+ return str(value) if value is not None else "-"
115
+
116
+ abs_n = abs(n)
117
+ if abs_n >= 1:
118
+ s = f"{n:+.2f}"
119
+ elif abs_n >= 0.01:
120
+ s = f"{n:+.4f}"
121
+ else:
122
+ s = f"{n:+.6f}"
123
+
124
+ if "." in s:
125
+ sign = s[0] if s[0] in "+-" else ""
126
+ digits = s[1:] if sign else s
127
+ digits = digits.rstrip("0").rstrip(".")
128
+ s = f"{sign}{digits}"
129
+ return f"{s}%"
130
+
131
+
132
+ MARKET_SORT_FIELDS = {"volume", "oi", "price", "change", "funding", "coin"}
133
+
134
+
135
+ def _normalize_market_sort(sort_by: str) -> str:
136
+ value = sort_by.strip().lower()
137
+ if value not in MARKET_SORT_FIELDS:
138
+ allowed = ", ".join(sorted(MARKET_SORT_FIELDS))
139
+ raise RuntimeError(f"invalid sort field: {sort_by} (expected one of: {allowed})")
140
+ return value
141
+
142
+
143
+ def _to_float(value: Any) -> float | None:
144
+ if value is None:
145
+ return None
146
+ try:
147
+ return float(value)
148
+ except Exception:
149
+ return None
150
+
151
+
152
+ def _sort_market_rows(rows: dict[str, list[dict[str, Any]]], sort_by: str) -> dict[str, list[dict[str, Any]]]:
153
+ sort_by = _normalize_market_sort(sort_by)
154
+
155
+ def numeric_value(row: dict[str, Any], key: str) -> float | None:
156
+ return _to_float(row.get(key))
157
+
158
+ def sort_rows(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
159
+ if sort_by == "coin":
160
+ return sorted(items, key=lambda row: str(row.get("coin", "")).lower())
161
+
162
+ field_map = {
163
+ "volume": "volumeUsd",
164
+ "oi": "openInterestUsd",
165
+ "price": "price",
166
+ "change": "priceChange",
167
+ "funding": "funding",
168
+ }
169
+ field = field_map[sort_by]
170
+
171
+ def key(row: dict[str, Any]) -> tuple[int, float]:
172
+ value = numeric_value(row, field)
173
+ if value is None:
174
+ return (1, 0.0)
175
+ return (0, -value)
176
+
177
+ return sorted(items, key=key)
178
+
179
+ return {
180
+ "perpMarkets": sort_rows(rows["perpMarkets"]),
181
+ "spotMarkets": sort_rows(rows["spotMarkets"]),
182
+ }
183
+
184
+
185
+ def _filter_market_rows_by_category(rows: dict[str, list[dict[str, Any]]], category: Optional[str]) -> dict[str, list[dict[str, Any]]]:
186
+ if category is None or category == "*":
187
+ return rows
188
+ needle = category.strip().lower()
189
+ if not needle:
190
+ return rows
191
+ return {
192
+ "perpMarkets": [
193
+ row for row in rows["perpMarkets"] if str(row.get("category", "")).lower() == needle
194
+ ],
195
+ "spotMarkets": [],
196
+ }
197
+
198
+
199
+ def _prepare_market_output(rows: dict[str, list[dict[str, Any]]], include_category: bool) -> dict[str, list[dict[str, Any]]]:
200
+ if include_category:
201
+ return rows
202
+
203
+ def strip_category(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
204
+ return [{k: v for k, v in row.items() if k not in {"category", "marketType"}} for row in items]
205
+
206
+ return {
207
+ "perpMarkets": strip_category(rows["perpMarkets"]),
208
+ "spotMarkets": strip_category(rows["spotMarkets"]),
209
+ }
210
+ def _watch_markets_prices(
211
+ context: CLIContext,
212
+ *,
213
+ spot_only: bool,
214
+ perp_only: bool,
215
+ category: Optional[str],
216
+ sort_by: str,
217
+ as_json: bool,
218
+ ) -> None:
219
+ include_category = category is not None
220
+ base_rows = _build_market_rows(context, spot_only, perp_only)
221
+ filtered_rows = _filter_market_rows_by_category(base_rows, category)
222
+ rows = _sort_market_rows(filtered_rows, sort_by)
223
+
224
+ info = context.get_public_client()
225
+ run_markets_tui(
226
+ console=console,
227
+ rows=rows,
228
+ include_category=include_category,
229
+ next_mids=lambda dex: info.all_mids(dex=dex) if dex else info.all_mids(),
230
+ sort_rows=lambda current: _sort_market_rows(current, sort_by),
231
+ prepare_output=lambda current: _prepare_market_output(current, include_category),
232
+ format_price=_format_price,
233
+ format_usd=_format_usd,
234
+ format_rate_pct=_format_rate_pct,
235
+ as_json=as_json,
236
+ )
237
+
238
+
239
+ def _extract_statuses(result: dict[str, Any]) -> list[dict[str, Any] | str]:
240
+ try:
241
+ statuses = result.get("response", {}).get("data", {}).get("statuses", [])
242
+ if isinstance(statuses, list):
243
+ return statuses
244
+ return []
245
+ except Exception:
246
+ return []
247
+
248
+
249
+ def _print_leverage_update(lev_result: Optional[dict[str, Any]], coin: str, leverage: Optional[int], is_cross: bool) -> None:
250
+ if not lev_result:
251
+ return
252
+ if lev_result.get("status") == "ok":
253
+ if leverage is not None:
254
+ print(f"⚙️ Leverage set: {coin} {leverage}x ({'cross' if is_cross else 'isolated'})")
255
+ else:
256
+ print(f"⚠️ Leverage update failed: {lev_result.get('response')}")
257
+
258
+
259
+ def _print_order_feedback(
260
+ *,
261
+ result: dict[str, Any],
262
+ coin: str,
263
+ side: str,
264
+ order_kind: str,
265
+ stake: Optional[float] = None,
266
+ ) -> None:
267
+ statuses = _extract_statuses(result)
268
+ if not statuses:
269
+ print("ℹ️ Request sent.")
270
+ return
271
+
272
+ first_error = None
273
+ first_filled = None
274
+ first_resting = None
275
+ for s in statuses:
276
+ if isinstance(s, dict) and "error" in s and first_error is None:
277
+ first_error = str(s["error"])
278
+ if isinstance(s, dict) and "filled" in s and first_filled is None:
279
+ first_filled = s["filled"]
280
+ if isinstance(s, dict) and "resting" in s and first_resting is None:
281
+ first_resting = s["resting"]
282
+
283
+ if first_error is not None:
284
+ print("❌ Order rejected")
285
+ print(f"\nReason: {first_error}")
286
+ if stake is not None:
287
+ print(f"Your stake (margin): {_format_usd(stake)}")
288
+ if "minimum value" in first_error.lower():
289
+ print("\nTip: Increase --stake or --leverage so position value is at least $10.")
290
+ return
291
+
292
+ if first_filled is not None:
293
+ print(f"✅ {order_kind} order executed")
294
+ print(f"\nAsset: {coin}")
295
+ print(f"Side: {side.upper()}")
296
+ print(f"Filled size: {first_filled.get('totalSz')} {coin}")
297
+ print(f"Average price: {_format_usd(first_filled.get('avgPx'))}")
298
+ print(f"Order ID: {first_filled.get('oid')}")
299
+ return
300
+
301
+ if first_resting is not None:
302
+ print(f"✅ {order_kind} order placed")
303
+ print(f"\nAsset: {coin}")
304
+ print(f"Side: {side.upper()}")
305
+ print(f"Order ID: {first_resting.get('oid')}")
306
+ return
307
+
308
+ print("ℹ️ Request completed.")
309
+
310
+
311
+ def _print_account_add_guide() -> None:
312
+ console.print("\n[bold]Wallet Setup Guide[/bold]")
313
+ console.print("1. To add an API wallet:")
314
+ console.print(" - Generate an API wallet private key on Hyperliquid")
315
+ console.print(" - Mainnet: https://app.hyperliquid.xyz/API")
316
+ console.print(" - Testnet: https://app.hyperliquid-testnet.xyz/API")
317
+ console.print(" - Run: [bold]hl account add[/bold] -> choose [bold]1[/bold]")
318
+ console.print("")
319
+ console.print("2. To add a read-only wallet:")
320
+ console.print(" - Run: [bold]hl account add[/bold] -> choose [bold]2[/bold]")
321
+ console.print(" - Enter the wallet address to monitor (0x...)")
322
+ console.print("")
323
+ console.print("Verification commands:")
324
+ console.print(" - [bold]hl account ls[/bold]")
325
+ console.print(" - [bold]hl account set-default <alias>[/bold]\n")
326
+
327
+
328
+ def _network_name(context: CLIContext) -> str:
329
+ return "testnet" if context.config.testnet else "mainnet"
330
+
331
+
332
+ @cli_command
333
+ def account_add(ctx: Any) -> None:
334
+ context = _ctx(ctx)
335
+ is_testnet = context.config.testnet
336
+ network = _network_name(context)
337
+
338
+ print("\n=== Add New Account ===\n")
339
+ _print_account_add_guide()
340
+ print("1) Use existing API wallet")
341
+ print("2) Add read-only account")
342
+ choice = input("Select setup method [1/2]: ").strip()
343
+
344
+ if choice == "1":
345
+ api_url = "https://app.hyperliquid-testnet.xyz/API" if is_testnet else "https://app.hyperliquid.xyz/API"
346
+ print(f"\nVisit {api_url} and generate an API wallet key.\n")
347
+ api_key = normalize_private_key(input("Enter API wallet private key: ").strip())
348
+
349
+ info = context.get_public_client()
350
+ api_wallet_addr = EthAccount.from_key(api_key).address
351
+ role = info.user_role(api_wallet_addr)
352
+ if role.get("role") != "agent":
353
+ raise RuntimeError("This key is not registered as an API wallet (agent) on Hyperliquid")
354
+ user_address = role["data"]["user"]
355
+
356
+ while True:
357
+ alias = input("Alias: ").strip()
358
+ if not alias:
359
+ print("Alias cannot be empty.")
360
+ continue
361
+ if is_alias_taken(alias, network):
362
+ print(f'Alias "{alias}" is already taken.')
363
+ continue
364
+ break
365
+
366
+ set_as_default = get_account_count(network) == 0 or _confirm("Set as default account?", True)
367
+ created = create_account(
368
+ alias=alias,
369
+ network=network,
370
+ user_address=user_address,
371
+ account_type="api_wallet",
372
+ api_wallet_private_key=api_key,
373
+ api_wallet_public_key=api_wallet_addr,
374
+ set_as_default=set_as_default,
375
+ )
376
+ data = created.__dict__.copy()
377
+ data["api_wallet_private_key"] = "[REDACTED]"
378
+ out(data, _json(ctx))
379
+ elif choice == "2":
380
+ user_address = validate_address(input("Wallet address to watch: ").strip())
381
+ while True:
382
+ alias = input("Alias: ").strip()
383
+ if not alias:
384
+ print("Alias cannot be empty.")
385
+ continue
386
+ if is_alias_taken(alias, network):
387
+ print(f'Alias "{alias}" is already taken.')
388
+ continue
389
+ break
390
+ set_as_default = get_account_count(network) == 0 or _confirm("Set as default account?", True)
391
+ created = create_account(
392
+ alias=alias,
393
+ network=network,
394
+ user_address=user_address,
395
+ account_type="readonly",
396
+ set_as_default=set_as_default,
397
+ )
398
+ out(created.__dict__, _json(ctx))
399
+ else:
400
+ raise RuntimeError("Invalid selection")
401
+ _done(ctx)
402
+
403
+
404
+ @cli_command
405
+ def account_ls(ctx: Any) -> None:
406
+ context = _ctx(ctx)
407
+ accounts = get_all_accounts(_network_name(context))
408
+ if _json(ctx):
409
+ out([a.__dict__ for a in accounts], True)
410
+ else:
411
+ if not accounts:
412
+ print("No accounts found. Run 'hl account add'.")
413
+ else:
414
+ _render_table(
415
+ "Accounts",
416
+ ["*", "Alias", "Address", "Type", "API Wallet"],
417
+ [
418
+ [
419
+ "*" if a.is_default else "",
420
+ a.alias,
421
+ _format_address(a.user_address),
422
+ a.type,
423
+ _format_address(a.api_wallet_public_key) if a.api_wallet_public_key else "-",
424
+ ]
425
+ for a in accounts
426
+ ],
427
+ )
428
+ _done(ctx)
429
+
430
+
431
+ @cli_command
432
+ def account_set_default(ctx: Any, alias: str) -> None:
433
+ context = _ctx(ctx)
434
+ network = _network_name(context)
435
+ if not get_account_by_alias(alias, network):
436
+ raise RuntimeError(f'Account with alias "{alias}" not found')
437
+ updated = set_default_account(alias, network)
438
+ out(updated.__dict__, _json(ctx))
439
+ _done(ctx)
440
+
441
+
442
+ @cli_command
443
+ def account_remove(
444
+ ctx: Any,
445
+ alias: str,
446
+ force: bool = False,
447
+ ) -> None:
448
+ context = _ctx(ctx)
449
+ network = _network_name(context)
450
+ existing = get_account_by_alias(alias, network)
451
+ if not existing:
452
+ raise RuntimeError(f'Account with alias "{alias}" not found')
453
+ if not force and not _confirm(f'Remove account "{alias}" ({existing.user_address})?', False):
454
+ print("Cancelled.")
455
+ raise SystemExit(0)
456
+ ok = delete_account(alias, network)
457
+ if not ok:
458
+ raise RuntimeError("Failed to remove account")
459
+ out({"deleted": True, "alias": alias}, _json(ctx))
460
+ _done(ctx)
461
+
462
+
463
+ def _fetch_positions(context: CLIContext, user: str) -> dict[str, Any]:
464
+ return run_blocking(_fetch_positions_async(context, user))
465
+
466
+
467
+ def _account_perp_dexs(context: CLIContext) -> list[str]:
468
+ # Testnet uses main perp only to avoid rate-limiting on bulk per-dex account reads.
469
+ if context.config.testnet:
470
+ return [""]
471
+ return context.get_perp_dexs()
472
+
473
+
474
+ async def _fetch_positions_async(context: CLIContext, user: str) -> dict[str, Any]:
475
+ info = context.get_public_client()
476
+ states = await asyncio.gather(
477
+ *(asyncio.to_thread(info.user_state, user, dex) for dex in _account_perp_dexs(context))
478
+ )
479
+ positions: list[dict[str, Any]] = []
480
+ summaries: list[dict[str, Any]] = []
481
+
482
+ for state in states:
483
+ summaries.append(state["marginSummary"])
484
+ positions.extend(
485
+ [
486
+ {
487
+ "coin": p["position"]["coin"],
488
+ "size": p["position"]["szi"],
489
+ "entryPx": p["position"].get("entryPx"),
490
+ "positionValue": p["position"].get("positionValue"),
491
+ "unrealizedPnl": p["position"].get("unrealizedPnl"),
492
+ "leverage": f"{p['position']['leverage']['value']}x {p['position']['leverage']['type']}",
493
+ "liquidationPx": p["position"].get("liquidationPx") or "-",
494
+ }
495
+ for p in state["assetPositions"]
496
+ if float(p["position"]["szi"]) != 0
497
+ ]
498
+ )
499
+
500
+ account_value = sum(float(s.get("accountValue", 0) or 0) for s in summaries)
501
+ margin_used = sum(float(s.get("totalMarginUsed", 0) or 0) for s in summaries)
502
+ return {
503
+ "positions": positions,
504
+ "marginSummary": {
505
+ "accountValue": f"{account_value:.8f}",
506
+ "totalMarginUsed": f"{margin_used:.8f}",
507
+ },
508
+ }
509
+
510
+
511
+ @cli_command
512
+ def account_positions(
513
+ ctx: Any,
514
+ user: Optional[str] = None,
515
+ watch: bool = False,
516
+ ) -> None:
517
+ context = _ctx(ctx)
518
+ address = validate_address(user) if user else context.get_wallet_address()
519
+ if watch:
520
+ watch_loop(
521
+ lambda: _fetch_positions(context, address),
522
+ lambda data: _render_table(
523
+ "Positions",
524
+ ["Coin", "Size", "Entry", "Value", "PnL", "Leverage", "Liq"],
525
+ [
526
+ [
527
+ p["coin"],
528
+ p["size"],
529
+ p["entryPx"],
530
+ p["positionValue"],
531
+ p["unrealizedPnl"],
532
+ p["leverage"],
533
+ p["liquidationPx"],
534
+ ]
535
+ for p in data["positions"]
536
+ ],
537
+ ),
538
+ as_json=_json(ctx),
539
+ )
540
+ return
541
+ out(_fetch_positions(context, address), _json(ctx))
542
+ _done(ctx)
543
+
544
+
545
+ def _fetch_orders(context: CLIContext, user: str) -> list[dict[str, Any]]:
546
+ orders = context.get_public_client().open_orders(user)
547
+ return [
548
+ {
549
+ "oid": o["oid"],
550
+ "coin": o["coin"],
551
+ "side": "Buy" if o["side"] in {"B", "buy", True} else "Sell",
552
+ "sz": o["sz"],
553
+ "limitPx": o["limitPx"],
554
+ "timestamp": datetime.fromtimestamp(o["timestamp"] / 1000).isoformat(),
555
+ }
556
+ for o in orders
557
+ ]
558
+
559
+
560
+ @cli_command
561
+ def account_orders(
562
+ ctx: Any,
563
+ user: Optional[str] = None,
564
+ watch: bool = False,
565
+ ) -> None:
566
+ context = _ctx(ctx)
567
+ address = validate_address(user) if user else context.get_wallet_address()
568
+ if watch:
569
+ watch_loop(
570
+ lambda: _fetch_orders(context, address),
571
+ lambda rows: _render_table(
572
+ "Open Orders",
573
+ ["OID", "Coin", "Side", "Size", "Price", "Time"],
574
+ [[r["oid"], r["coin"], r["side"], r["sz"], r["limitPx"], r["timestamp"]] for r in rows],
575
+ ),
576
+ as_json=_json(ctx),
577
+ )
578
+ return
579
+ out(_fetch_orders(context, address), _json(ctx))
580
+ _done(ctx)
581
+
582
+
583
+ def _fetch_balances(context: CLIContext, user: str) -> dict[str, Any]:
584
+ return run_blocking(_fetch_balances_async(context, user))
585
+
586
+
587
+ async def _fetch_balances_async(context: CLIContext, user: str) -> dict[str, Any]:
588
+ info = context.get_public_client()
589
+ perp_task = asyncio.to_thread(info.user_state, user)
590
+ spot_task = asyncio.to_thread(info.spot_user_state, user)
591
+ perp, spot = await asyncio.gather(perp_task, spot_task)
592
+ balances = []
593
+ for b in spot["balances"]:
594
+ if float(b["total"]) == 0:
595
+ continue
596
+ total = float(b["total"])
597
+ hold = float(b["hold"])
598
+ balances.append(
599
+ {
600
+ "token": b["coin"],
601
+ "total": b["total"],
602
+ "hold": b["hold"],
603
+ "available": f"{total - hold}",
604
+ }
605
+ )
606
+ return {"spotBalances": balances, "perpBalance": perp["marginSummary"]["accountValue"]}
607
+
608
+
609
+ async def _fetch_portfolio_async(context: CLIContext, user: str) -> dict[str, Any]:
610
+ info = context.get_public_client()
611
+ perp_tasks = [
612
+ asyncio.to_thread(info.user_state, user, dex)
613
+ for dex in _account_perp_dexs(context)
614
+ ]
615
+ spot_task = asyncio.to_thread(info.spot_user_state, user)
616
+ *perp_states, spot = await asyncio.gather(*perp_tasks, spot_task)
617
+
618
+ positions: list[dict[str, Any]] = []
619
+ for state in perp_states:
620
+ positions.extend(
621
+ [
622
+ {
623
+ "coin": p["position"]["coin"],
624
+ "size": p["position"]["szi"],
625
+ "entryPx": p["position"].get("entryPx"),
626
+ "positionValue": p["position"].get("positionValue"),
627
+ "unrealizedPnl": p["position"].get("unrealizedPnl"),
628
+ "leverage": f"{p['position']['leverage']['value']}x {p['position']['leverage']['type']}",
629
+ "liquidationPx": p["position"].get("liquidationPx") or "-",
630
+ }
631
+ for p in state["assetPositions"]
632
+ if float(p["position"]["szi"]) != 0
633
+ ]
634
+ )
635
+
636
+ spot_balances = []
637
+ for b in spot["balances"]:
638
+ if float(b["total"]) == 0:
639
+ continue
640
+ total = float(b["total"])
641
+ hold = float(b["hold"])
642
+ spot_balances.append(
643
+ {
644
+ "token": b["coin"],
645
+ "total": b["total"],
646
+ "hold": b["hold"],
647
+ "available": f"{total - hold}",
648
+ }
649
+ )
650
+
651
+ account_value = sum(float(s["marginSummary"]["accountValue"]) for s in perp_states)
652
+ margin_used = sum(float(s["marginSummary"]["totalMarginUsed"]) for s in perp_states)
653
+ return {
654
+ "positions": positions,
655
+ "spotBalances": spot_balances,
656
+ "accountValue": f"{account_value:.8f}",
657
+ "totalMarginUsed": f"{margin_used:.8f}",
658
+ }
659
+
660
+
661
+ @cli_command
662
+ def account_balances(
663
+ ctx: Any,
664
+ user: Optional[str] = None,
665
+ watch: bool = False,
666
+ ) -> None:
667
+ context = _ctx(ctx)
668
+ address = validate_address(user) if user else context.get_wallet_address()
669
+ if watch:
670
+ watch_loop(
671
+ lambda: _fetch_balances(context, address),
672
+ lambda data: _render_table(
673
+ f"Balances (Perp USD: {data['perpBalance']})",
674
+ ["Token", "Total", "Hold", "Available"],
675
+ [[b["token"], b["total"], b["hold"], b["available"]] for b in data["spotBalances"]],
676
+ ),
677
+ as_json=_json(ctx),
678
+ )
679
+ return
680
+ out(_fetch_balances(context, address), _json(ctx))
681
+ _done(ctx)
682
+
683
+
684
+ @cli_command
685
+ def account_portfolio(
686
+ ctx: Any,
687
+ user: Optional[str] = None,
688
+ watch: bool = False,
689
+ ) -> None:
690
+ context = _ctx(ctx)
691
+ address = validate_address(user) if user else context.get_wallet_address()
692
+
693
+ def fetch() -> dict[str, Any]:
694
+ return run_blocking(_fetch_portfolio_async(context, address))
695
+
696
+ if watch:
697
+ watch_loop(
698
+ fetch,
699
+ lambda d: (
700
+ _render_table(
701
+ f"Portfolio AccountValue={d['accountValue']} MarginUsed={d['totalMarginUsed']}",
702
+ ["Coin", "Size", "Entry", "Value", "PnL", "Leverage"],
703
+ [
704
+ [
705
+ p["coin"],
706
+ p["size"],
707
+ p["entryPx"],
708
+ p["positionValue"],
709
+ p["unrealizedPnl"],
710
+ p["leverage"],
711
+ ]
712
+ for p in d["positions"]
713
+ ],
714
+ ),
715
+ _render_table(
716
+ "Spot Balances",
717
+ ["Token", "Total", "Hold", "Available"],
718
+ [
719
+ [b["token"], b["total"], b["hold"], b.get("available", "-")]
720
+ for b in d["spotBalances"]
721
+ ],
722
+ ),
723
+ ),
724
+ as_json=_json(ctx),
725
+ )
726
+ return
727
+ out(fetch(), _json(ctx))
728
+ _done(ctx)
729
+
730
+
731
+ @cli_command
732
+ def asset_price(ctx: Any, coin: str, watch: bool = False) -> None:
733
+ context = _ctx(ctx)
734
+
735
+ def fetch() -> dict[str, str]:
736
+ resolved_coin = _resolve_tradable_coin(context, coin)
737
+ mids = _mids_for_coin(context, resolved_coin)
738
+ if resolved_coin not in mids:
739
+ raise RuntimeError(f"Coin not found: {coin}")
740
+ return {"coin": coin, "price": mids[resolved_coin]}
741
+
742
+ if watch:
743
+ watch_loop(fetch, lambda d: print(f"{d['coin']}: {_format_price(d['price'])}"), as_json=_json(ctx))
744
+ return
745
+ out(fetch(), _json(ctx))
746
+ _done(ctx)
747
+
748
+
749
+ @cli_command
750
+ def asset_book(ctx: Any, coin: str, watch: bool = False) -> None:
751
+ context = _ctx(ctx)
752
+
753
+ def fetch() -> dict[str, Any]:
754
+ book = context.get_public_client().l2_snapshot(coin)
755
+ return book
756
+
757
+ def render_book(book: dict[str, Any]) -> None:
758
+ bids = book.get("levels", [[], []])[0][:10]
759
+ asks = book.get("levels", [[], []])[1][:10]
760
+ _render_table("Asks", ["Price", "Size", "N"], [[x["px"], x["sz"], x["n"]] for x in asks[::-1]])
761
+ _render_table("Bids", ["Price", "Size", "N"], [[x["px"], x["sz"], x["n"]] for x in bids])
762
+
763
+ if watch:
764
+ stream_info = Info(context.base_url, skip_ws=False)
765
+ updates: Queue[dict[str, Any]] = Queue()
766
+ subscription = {"type": "l2Book", "coin": coin}
767
+ subscription_id = stream_info.subscribe(subscription, lambda msg: updates.put(msg["data"]))
768
+
769
+ try:
770
+ initial = fetch()
771
+ if _json(ctx):
772
+ print(json.dumps(initial, ensure_ascii=False))
773
+ else:
774
+ console.clear()
775
+ render_book(initial)
776
+
777
+ while True:
778
+ try:
779
+ book = updates.get(timeout=30.0)
780
+ except Empty:
781
+ continue
782
+ if _json(ctx):
783
+ print(json.dumps(book, ensure_ascii=False))
784
+ else:
785
+ console.clear()
786
+ render_book(book)
787
+ except KeyboardInterrupt:
788
+ return
789
+ finally:
790
+ try:
791
+ stream_info.unsubscribe(subscription, subscription_id)
792
+ except Exception:
793
+ pass
794
+ if stream_info.ws_manager is not None:
795
+ try:
796
+ stream_info.ws_manager.stop()
797
+ except Exception:
798
+ pass
799
+ return
800
+ out(fetch(), _json(ctx))
801
+ _done(ctx)
802
+
803
+
804
+ @cli_command
805
+ def asset_leverage(
806
+ ctx: Any,
807
+ coin: str,
808
+ user: Optional[str] = None,
809
+ watch: bool = False,
810
+ ) -> None:
811
+ context = _ctx(ctx)
812
+ address = validate_address(user) if user else context.get_wallet_address()
813
+
814
+ def fetch() -> dict[str, Any]:
815
+ info = context.get_public_client()
816
+ state, meta, mids = run_blocking(
817
+ _fetch_asset_leverage_inputs_async(info, address)
818
+ )
819
+ pos = next(
820
+ (p["position"] for p in state["assetPositions"] if p["position"]["coin"] == coin),
821
+ None,
822
+ )
823
+ m = next((m for m in meta["universe"] if m["name"] == coin), None)
824
+ account_value = float(state["marginSummary"]["accountValue"])
825
+ margin_used = float(state["marginSummary"]["totalMarginUsed"])
826
+ return {
827
+ "coin": coin,
828
+ "markPx": mids.get(coin),
829
+ "maxLeverage": (m or {}).get("maxLeverage", 0),
830
+ "position": pos,
831
+ "margin": {
832
+ "accountValue": state["marginSummary"]["accountValue"],
833
+ "totalMarginUsed": state["marginSummary"]["totalMarginUsed"],
834
+ "availableMargin": f"{max(0.0, account_value - margin_used):.2f}",
835
+ },
836
+ }
837
+
838
+ if watch:
839
+ watch_loop(fetch, lambda d: out(d, False), as_json=_json(ctx))
840
+ return
841
+ out(fetch(), _json(ctx))
842
+ _done(ctx)
843
+
844
+
845
+ async def _fetch_asset_leverage_inputs_async(info: Any, address: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
846
+ state_task = asyncio.to_thread(info.user_state, address)
847
+ meta_task = asyncio.to_thread(info.meta)
848
+ mids_task = asyncio.to_thread(info.all_mids)
849
+ state, meta, mids = await asyncio.gather(state_task, meta_task, mids_task)
850
+ return state, meta, mids
851
+
852
+
853
+ def _build_market_rows(context: CLIContext, spot_only: bool, perp_only: bool) -> dict[str, list[dict[str, Any]]]:
854
+ return run_blocking(_build_market_rows_async(context, spot_only, perp_only))
855
+
856
+
857
+ def _safe_token_name(tokens: list[dict[str, Any]], index: Any, default: str = "?") -> str:
858
+ if not isinstance(index, int) or index < 0 or index >= len(tokens):
859
+ return default
860
+ return str(tokens[index].get("name", default))
861
+
862
+
863
+ async def _build_market_rows_async(context: CLIContext, spot_only: bool, perp_only: bool) -> dict[str, list[dict[str, Any]]]:
864
+ info = context.get_public_client()
865
+ spot_task = asyncio.to_thread(info.spot_meta_and_asset_ctxs)
866
+ perp_categories_task = asyncio.to_thread(info.post, "/info", {"type": "perpCategories"})
867
+ (spot_meta, spot_ctxs), perp_categories_raw = await asyncio.gather(spot_task, perp_categories_task)
868
+ perp_categories = {
869
+ str(coin): str(category)
870
+ for coin, category in perp_categories_raw
871
+ if isinstance(coin, str) and isinstance(category, str)
872
+ }
873
+
874
+ spot_rows: list[dict[str, Any]] = []
875
+ perp_rows: list[dict[str, Any]] = []
876
+
877
+ if not perp_only:
878
+ ctx_map = {c["coin"]: c for c in spot_ctxs}
879
+ tokens = spot_meta.get("tokens", [])
880
+ for pair in spot_meta.get("universe", []):
881
+ refs = pair.get("tokens", [])
882
+ if not isinstance(refs, list) or len(refs) < 2:
883
+ continue
884
+ base = _safe_token_name(tokens, refs[0])
885
+ quote = _safe_token_name(tokens, refs[1])
886
+ # Testnet can return spot pairs whose token indexes do not exist.
887
+ if base == "?" or quote == "?":
888
+ continue
889
+ c = ctx_map.get(pair["name"], {})
890
+ prev = float(c.get("prevDayPx", 0) or 0)
891
+ mark = float(c.get("markPx", 0) or 0)
892
+ chg = ((mark - prev) / prev * 100) if prev else None
893
+ spot_rows.append(
894
+ {
895
+ "coin": pair["name"],
896
+ "marketType": "spot",
897
+ "category": None,
898
+ "pairName": f"[Spot] {base}/{quote}",
899
+ "price": c.get("markPx", "?"),
900
+ "priceChange": chg,
901
+ "volumeUsd": c.get("dayNtlVlm", "?"),
902
+ "funding": None,
903
+ "openInterest": None,
904
+ "openInterestUsd": None,
905
+ }
906
+ )
907
+
908
+ if not spot_only:
909
+ # Main perp dex (keeps richer fields like funding/openInterest).
910
+ perp_meta, perp_ctxs = await asyncio.to_thread(info.meta_and_asset_ctxs)
911
+ tokens = spot_meta.get("tokens", [])
912
+ collateral = _safe_token_name(tokens, perp_meta.get("collateralToken", 0), "USD")
913
+ for i, market in enumerate(perp_meta["universe"]):
914
+ if market.get("isDelisted"):
915
+ continue
916
+ c = perp_ctxs[i] if i < len(perp_ctxs) else {}
917
+ prev = float(c.get("prevDayPx", 0) or 0)
918
+ mark = float(c.get("markPx", 0) or 0)
919
+ oi_raw = _to_float(c.get("openInterest"))
920
+ chg = ((mark - prev) / prev * 100) if prev else None
921
+ perp_rows.append(
922
+ {
923
+ "coin": market["name"],
924
+ "marketType": "perp",
925
+ "category": perp_categories.get(str(market["name"])),
926
+ "pairName": f"{market['name']}/{collateral} {market.get('maxLeverage', '?')}x",
927
+ "price": c.get("markPx", "?"),
928
+ "priceChange": chg,
929
+ "volumeUsd": c.get("dayNtlVlm", "?"),
930
+ "funding": c.get("funding"),
931
+ "openInterest": c.get("openInterest"),
932
+ "openInterestUsd": (oi_raw * mark) if oi_raw is not None and mark > 0 else None,
933
+ }
934
+ )
935
+
936
+ # Testnet uses main perp + spot only to avoid rate-limiting on bulk builder fetches.
937
+ if not context.config.testnet:
938
+ # Builder perps (stocks and other external markets).
939
+ # These are dex-qualified symbols such as xyz:TSLA or flx:CRCL.
940
+ dexs = [dex for dex in context.get_perp_dexs() if dex]
941
+ builder_results = await asyncio.gather(
942
+ *(asyncio.to_thread(_fetch_builder_market_data, info, dex) for dex in dexs)
943
+ )
944
+ for meta, ctxs in builder_results:
945
+ dex = str(meta.get("dex", ""))
946
+ if not dex:
947
+ continue
948
+ coll_idx = meta.get("collateralToken", 0)
949
+ collateral = _safe_token_name(tokens, coll_idx, "USD")
950
+ for i, market in enumerate(meta.get("universe", [])):
951
+ coin = str(market.get("name"))
952
+ if not coin:
953
+ continue
954
+ if market.get("isDelisted"):
955
+ continue
956
+ c = ctxs[i] if i < len(ctxs) else {}
957
+ prev = float(c.get("prevDayPx", 0) or 0)
958
+ mark = float(c.get("markPx", 0) or 0)
959
+ oi_raw = _to_float(c.get("openInterest"))
960
+ chg = ((mark - prev) / prev * 100) if prev else None
961
+ perp_rows.append(
962
+ {
963
+ "coin": coin,
964
+ "marketType": "perp",
965
+ "category": perp_categories.get(coin),
966
+ "pairName": f"{coin}/{collateral} {market.get('maxLeverage', '?')}x",
967
+ "price": c.get("markPx", "?"),
968
+ "priceChange": chg,
969
+ "volumeUsd": c.get("dayNtlVlm", "?"),
970
+ "funding": c.get("funding"),
971
+ "openInterest": c.get("openInterest"),
972
+ "openInterestUsd": (oi_raw * mark) if oi_raw is not None and mark > 0 else None,
973
+ }
974
+ )
975
+
976
+ return {"perpMarkets": perp_rows, "spotMarkets": spot_rows}
977
+
978
+
979
+ def _fetch_builder_market_data(info: Any, dex: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
980
+ meta, ctxs = info.post("/info", {"type": "metaAndAssetCtxs", "dex": dex})
981
+ meta["dex"] = dex
982
+ return meta, ctxs
983
+
984
+
985
+ @cli_command
986
+ def markets_ls(
987
+ ctx: Any,
988
+ spot_only: bool = False,
989
+ perp_only: bool = False,
990
+ category: Optional[str] = None,
991
+ sort_by: str = "volume",
992
+ watch: bool = False,
993
+ ) -> None:
994
+ context = _ctx(ctx)
995
+ include_category = category is not None
996
+
997
+ if watch:
998
+ _watch_markets_prices(
999
+ context,
1000
+ spot_only=spot_only,
1001
+ perp_only=perp_only,
1002
+ category=category,
1003
+ sort_by=sort_by,
1004
+ as_json=_json(ctx),
1005
+ )
1006
+ return
1007
+
1008
+ out(
1009
+ _prepare_market_output(
1010
+ _sort_market_rows(
1011
+ _filter_market_rows_by_category(_build_market_rows(context, spot_only, perp_only), category),
1012
+ sort_by,
1013
+ ),
1014
+ include_category,
1015
+ ),
1016
+ _json(ctx),
1017
+ )
1018
+ _done(ctx)
1019
+
1020
+
1021
+ @cli_command
1022
+ def markets_search(
1023
+ ctx: Any,
1024
+ query: str,
1025
+ spot_only: bool = False,
1026
+ perp_only: bool = False,
1027
+ category: Optional[str] = None,
1028
+ sort_by: str = "volume",
1029
+ ) -> None:
1030
+ context = _ctx(ctx)
1031
+ include_category = category is not None
1032
+ q = query.strip().lower()
1033
+ if not q:
1034
+ raise RuntimeError("query must not be empty")
1035
+ rows = _prepare_market_output(
1036
+ _sort_market_rows(
1037
+ _filter_market_rows_by_category(_build_market_rows(context, spot_only, perp_only), category),
1038
+ sort_by,
1039
+ ),
1040
+ include_category,
1041
+ )
1042
+ perps = [
1043
+ x
1044
+ for x in rows["perpMarkets"]
1045
+ if q in str(x.get("coin", "")).lower()
1046
+ or q in str(x.get("pairName", "")).lower()
1047
+ or q in str(x.get("category", "")).lower()
1048
+ ]
1049
+ spots = [
1050
+ x
1051
+ for x in rows["spotMarkets"]
1052
+ if q in str(x.get("coin", "")).lower()
1053
+ or q in str(x.get("pairName", "")).lower()
1054
+ or q in str(x.get("category", "")).lower()
1055
+ ]
1056
+ out({"perpMarkets": perps, "spotMarkets": spots}, _json(ctx))
1057
+ _done(ctx)
1058
+
1059
+
1060
+ @cli_command
1061
+ def referral_set(ctx: Any, code: str) -> None:
1062
+ result = _ctx(ctx).get_wallet_client().set_referrer(code)
1063
+ out(result, _json(ctx))
1064
+ _done(ctx)
1065
+
1066
+
1067
+ @cli_command
1068
+ def referral_status(ctx: Any) -> None:
1069
+ context = _ctx(ctx)
1070
+ user = context.get_wallet_address()
1071
+ result = context.get_public_client().query_referral_state(user)
1072
+ out(result, _json(ctx))
1073
+ _done(ctx)
1074
+
1075
+
1076
+ def main() -> None:
1077
+ app()
1078
+
1079
+
1080
+ if __name__ == "__main__":
1081
+ main()