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/__init__.py +1 -0
- hl_cli/cli/__init__.py +1 -0
- hl_cli/cli/argparse_main.py +814 -0
- hl_cli/cli/markets_tui.py +399 -0
- hl_cli/cli/runtime.py +82 -0
- hl_cli/commands/__init__.py +1 -0
- hl_cli/commands/app.py +1081 -0
- hl_cli/commands/order.py +918 -0
- hl_cli/core/__init__.py +1 -0
- hl_cli/core/context.py +156 -0
- hl_cli/core/order_config.py +23 -0
- hl_cli/infra/__init__.py +1 -0
- hl_cli/infra/db.py +277 -0
- hl_cli/infra/paths.py +5 -0
- hl_cli/utils/__init__.py +1 -0
- hl_cli/utils/market_table.py +66 -0
- hl_cli/utils/output.py +476 -0
- hl_cli/utils/validators.py +45 -0
- hl_cli/utils/watch.py +28 -0
- hyperliquid_cli_python-0.1.0.dist-info/METADATA +269 -0
- hyperliquid_cli_python-0.1.0.dist-info/RECORD +25 -0
- hyperliquid_cli_python-0.1.0.dist-info/WHEEL +5 -0
- hyperliquid_cli_python-0.1.0.dist-info/entry_points.txt +2 -0
- hyperliquid_cli_python-0.1.0.dist-info/licenses/LICENSE +32 -0
- hyperliquid_cli_python-0.1.0.dist-info/top_level.txt +1 -0
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()
|