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.
@@ -0,0 +1,918 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ from decimal import Decimal, ROUND_DOWN
4
+ from typing import Any, Optional
5
+
6
+ from hyperliquid.utils.constants import MAINNET_API_URL
7
+ from hyperliquid.utils.signing import float_to_wire, get_timestamp_ms, sign_l1_action
8
+
9
+ from ..cli.runtime import cli_command, cli_context, confirm, finish_command, json_output_enabled, render_table
10
+ from ..cli.runtime import run_blocking
11
+ from ..core.context import CLIContext
12
+ from ..core.order_config import get_order_config, update_order_config
13
+ from ..utils.output import out, out_success
14
+ from ..utils.validators import (
15
+ normalize_side,
16
+ normalize_tif,
17
+ validate_positive_integer,
18
+ validate_positive_number,
19
+ )
20
+ from ..utils.watch import watch_loop
21
+
22
+
23
+ def _ctx(ctx: Any) -> CLIContext:
24
+ return cli_context(ctx)
25
+
26
+
27
+ def _json(ctx: Any) -> bool:
28
+ return json_output_enabled(ctx)
29
+
30
+
31
+ def _done(ctx: Any) -> None:
32
+ finish_command(ctx)
33
+
34
+
35
+ def _wallet_perp_dexs_for_coin(coin: str) -> Optional[list[str]]:
36
+ if ":" not in coin:
37
+ return None
38
+ return [coin.split(":", 1)[0]]
39
+
40
+
41
+ def _confirm(message: str, default: bool = False) -> bool:
42
+ return confirm(message, default)
43
+
44
+
45
+ def _render_table(title: str, columns: list[str], rows: list[list[Any]]) -> None:
46
+ render_table(title, columns, rows)
47
+
48
+
49
+ def _format_usd(value: str | float | int | None) -> str:
50
+ try:
51
+ n = float(value) # type: ignore[arg-type]
52
+ return f"${n:,.2f}"
53
+ except Exception:
54
+ return f"${value}" if value is not None else "-"
55
+
56
+
57
+ def _extract_statuses(result: dict[str, Any]) -> list[dict[str, Any] | str]:
58
+ try:
59
+ statuses = result.get("response", {}).get("data", {}).get("statuses", [])
60
+ if isinstance(statuses, list):
61
+ return statuses
62
+ return []
63
+ except Exception:
64
+ return []
65
+
66
+
67
+ def _print_leverage_update(lev_result: Optional[dict[str, Any]], coin: str, leverage: Optional[int], is_cross: bool) -> None:
68
+ if not lev_result:
69
+ return
70
+ if lev_result.get("status") == "ok":
71
+ if leverage is not None:
72
+ print(f"⚙️ Leverage set: {coin} {leverage}x ({'cross' if is_cross else 'isolated'})")
73
+ else:
74
+ print(f"⚠️ Leverage update failed: {lev_result.get('response')}")
75
+
76
+
77
+ def _print_order_feedback(
78
+ *,
79
+ result: dict[str, Any],
80
+ coin: str,
81
+ side: str,
82
+ order_kind: str,
83
+ stake: Optional[float] = None,
84
+ ) -> None:
85
+ statuses = _extract_statuses(result)
86
+ if not statuses:
87
+ print("ℹ️ Request sent.")
88
+ return
89
+
90
+ first_error = None
91
+ first_filled = None
92
+ first_resting = None
93
+ for s in statuses:
94
+ if isinstance(s, dict) and "error" in s and first_error is None:
95
+ first_error = str(s["error"])
96
+ if isinstance(s, dict) and "filled" in s and first_filled is None:
97
+ first_filled = s["filled"]
98
+ if isinstance(s, dict) and "resting" in s and first_resting is None:
99
+ first_resting = s["resting"]
100
+
101
+ if first_error is not None:
102
+ print("❌ Order rejected")
103
+ print(f"\nReason: {first_error}")
104
+ if stake is not None:
105
+ print(f"Your stake (margin): {_format_usd(stake)}")
106
+ if "minimum value" in first_error.lower():
107
+ print("\nTip: Increase --stake or --leverage so position value is at least $10.")
108
+ return
109
+
110
+ if first_filled is not None:
111
+ print(f"✅ {order_kind} order executed")
112
+ print(f"\nAsset: {coin}")
113
+ print(f"Side: {side.upper()}")
114
+ print(f"Filled size: {first_filled.get('totalSz')} {coin}")
115
+ print(f"Average price: {_format_usd(first_filled.get('avgPx'))}")
116
+ print(f"Order ID: {first_filled.get('oid')}")
117
+ return
118
+
119
+ if first_resting is not None:
120
+ print(f"✅ {order_kind} order placed")
121
+ print(f"\nAsset: {coin}")
122
+ print(f"Side: {side.upper()}")
123
+ print(f"Order ID: {first_resting.get('oid')}")
124
+ return
125
+
126
+ print("ℹ️ Request completed.")
127
+
128
+
129
+ def _parse_twap_interval(value: str) -> tuple[int, int]:
130
+ parts = [x.strip() for x in value.split(",")]
131
+ if len(parts) == 1:
132
+ minutes = int(parts[0])
133
+ if minutes <= 0:
134
+ raise ValueError("minutes must be a positive integer")
135
+ return minutes, 1
136
+ if len(parts) == 2:
137
+ minutes = int(parts[0])
138
+ orders = int(parts[1])
139
+ if minutes <= 0 or orders <= 0:
140
+ raise ValueError("minutes and orders must be positive integers")
141
+ return minutes * orders, orders
142
+ raise ValueError("interval must be '<minutes>' or '<slice_minutes>,<orders>' (e.g. 30 or 5,10)")
143
+
144
+
145
+ def _get_max_leverage_for_coin(context: CLIContext, coin: str) -> int:
146
+ info = context.get_public_client()
147
+
148
+ dex = ""
149
+ search_names = [coin]
150
+ if ":" in coin:
151
+ dex = coin.split(":", 1)[0]
152
+ base = coin.split(":", 1)[1]
153
+ search_names.append(base)
154
+
155
+ meta = info.meta(dex=dex)
156
+ for m in meta.get("universe", []):
157
+ if m.get("name") in search_names:
158
+ max_lev = m.get("maxLeverage")
159
+ if max_lev is None:
160
+ continue
161
+ return int(max_lev)
162
+ raise RuntimeError(f"Could not resolve max leverage for {coin}")
163
+
164
+
165
+ def _is_invalid_leverage_response(resp: Any) -> bool:
166
+ if not isinstance(resp, dict):
167
+ return False
168
+ if str(resp.get("status", "")).lower() != "err":
169
+ return False
170
+ return "invalid leverage value" in str(resp.get("response", "")).lower()
171
+
172
+
173
+ def _update_leverage_with_fallback(
174
+ *,
175
+ context: CLIContext,
176
+ coin: str,
177
+ leverage: int,
178
+ is_cross: bool,
179
+ emit_warning: bool = True,
180
+ ) -> dict[str, Any]:
181
+ wallet = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(coin))
182
+ result = wallet.update_leverage(leverage, coin, is_cross=is_cross)
183
+ if not _is_invalid_leverage_response(result):
184
+ return result
185
+
186
+ max_lev = _get_max_leverage_for_coin(context, coin)
187
+ if emit_warning:
188
+ print(
189
+ f"Warning: Invalid leverage value ({leverage}) for {coin}. "
190
+ f"Retrying with max leverage {max_lev}."
191
+ )
192
+ return wallet.update_leverage(max_lev, coin, is_cross=is_cross)
193
+
194
+
195
+ def _maybe_update_leverage(
196
+ *,
197
+ context: CLIContext,
198
+ coin: str,
199
+ leverage: Optional[int],
200
+ cross: bool,
201
+ isolated: bool,
202
+ emit_warning: bool = True,
203
+ ) -> Optional[dict[str, Any]]:
204
+ if cross and isolated:
205
+ raise RuntimeError("Use only one of --cross or --isolated")
206
+ if leverage is None:
207
+ if cross or isolated:
208
+ raise RuntimeError("--cross/--isolated requires --leverage")
209
+ return None
210
+ if leverage <= 0:
211
+ raise RuntimeError("leverage must be a positive integer")
212
+ is_cross = cross or not isolated
213
+ return _update_leverage_with_fallback(
214
+ context=context,
215
+ coin=coin,
216
+ leverage=leverage,
217
+ is_cross=is_cross,
218
+ emit_warning=emit_warning,
219
+ )
220
+
221
+
222
+ def _normalize_size_for_coin(context: CLIContext, coin: str, raw_size: float) -> float:
223
+ if raw_size <= 0:
224
+ raise RuntimeError("size must be a positive number")
225
+ exchange = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(coin))
226
+ asset = exchange.info.name_to_asset(coin)
227
+ sz_decimals = int(exchange.info.asset_to_sz_decimals[asset])
228
+
229
+ q = Decimal(1).scaleb(-sz_decimals)
230
+ d = Decimal(str(raw_size)).quantize(q, rounding=ROUND_DOWN)
231
+ if d <= 0:
232
+ raise RuntimeError(f"size too small for {coin}; minimum unit is 1e-{sz_decimals}")
233
+ return float(d)
234
+
235
+
236
+ def _resolve_tradable_coin(context: CLIContext, coin: str) -> str:
237
+ info = context.get_public_client()
238
+ target = coin.strip()
239
+ if not target:
240
+ raise RuntimeError("coin must not be empty")
241
+
242
+ mids = info.all_mids()
243
+ if target in mids:
244
+ return target
245
+ up = target.upper()
246
+ if up in mids:
247
+ return up
248
+ if ":" in target:
249
+ dex, sym = target.split(":", 1)
250
+ norm = f"{dex.lower()}:{sym.upper()}"
251
+ if norm in mids:
252
+ return norm
253
+
254
+ for m in info.meta().get("universe", []):
255
+ name = str(m.get("name", ""))
256
+ if name == target or name.upper() == up:
257
+ return name
258
+
259
+ perp_candidates: list[tuple[str, int]] = []
260
+ dex_names = [
261
+ str(dex_item.get("name", ""))
262
+ for dex_item in info.perp_dexs()
263
+ if isinstance(dex_item, dict) and dex_item.get("name")
264
+ ]
265
+ builder_market_data = run_blocking(_fetch_all_builder_resolution_data(info, dex_names))
266
+ for meta, dex_mids in builder_market_data:
267
+ for m in meta.get("universe", []):
268
+ full_name = str(m.get("name", ""))
269
+ if not full_name:
270
+ continue
271
+ suffix = full_name.split(":", 1)[1] if ":" in full_name else full_name
272
+ if full_name.upper() == up or suffix.upper() == up:
273
+ if full_name not in dex_mids:
274
+ continue
275
+ lev = int(m.get("maxLeverage", 0) or 0)
276
+ perp_candidates.append((full_name, lev))
277
+
278
+ if perp_candidates:
279
+ perp_candidates.sort(key=lambda x: (x[1], x[0]), reverse=True)
280
+ return perp_candidates[0][0]
281
+
282
+ spot_meta = info.spot_meta()
283
+ tokens = spot_meta.get("tokens", [])
284
+ universe = spot_meta.get("universe", [])
285
+ usdc_index = next((t.get("index") for t in tokens if str(t.get("name", "")).upper() == "USDC"), 0)
286
+ token_index = next(
287
+ (
288
+ int(t.get("index"))
289
+ for t in tokens
290
+ if str(t.get("name", "")).upper() == up or str(t.get("fullName", "")).upper() == up
291
+ ),
292
+ None,
293
+ )
294
+ if token_index is not None:
295
+ preferred = next(
296
+ (
297
+ str(p.get("name"))
298
+ for p in universe
299
+ if isinstance(p.get("tokens"), list)
300
+ and len(p["tokens"]) >= 2
301
+ and int(p["tokens"][0]) == token_index
302
+ and int(p["tokens"][1]) == int(usdc_index)
303
+ ),
304
+ None,
305
+ )
306
+ if preferred and preferred in mids:
307
+ return preferred
308
+ fallback = next(
309
+ (
310
+ str(p.get("name"))
311
+ for p in universe
312
+ if isinstance(p.get("tokens"), list)
313
+ and token_index in [int(x) for x in p["tokens"]]
314
+ ),
315
+ None,
316
+ )
317
+ if fallback and fallback in mids:
318
+ return fallback
319
+
320
+ raise RuntimeError(f"Coin not found: {coin}")
321
+
322
+
323
+ def _mids_for_coin(context: CLIContext, coin: str) -> dict[str, str]:
324
+ info = context.get_public_client()
325
+ if ":" in coin:
326
+ dex = coin.split(":", 1)[0]
327
+ return info.all_mids(dex=dex)
328
+ return info.all_mids()
329
+
330
+
331
+ def _stake_to_position_notional(stake: float, leverage: Optional[int]) -> float:
332
+ if stake <= 0:
333
+ raise RuntimeError("stake must be a positive number")
334
+ lev = 1 if leverage is None else leverage
335
+ if lev <= 0:
336
+ raise RuntimeError("leverage must be a positive integer when used with --stake")
337
+ return stake * float(lev)
338
+
339
+
340
+ def _place_native_twap(
341
+ *,
342
+ context: CLIContext,
343
+ coin: str,
344
+ is_buy: bool,
345
+ size: float,
346
+ minutes: int,
347
+ reduce_only: bool,
348
+ randomize: bool,
349
+ ) -> dict[str, Any]:
350
+ exchange = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(coin))
351
+ asset = exchange.info.name_to_asset(coin)
352
+ action = {
353
+ "type": "twapOrder",
354
+ "twap": {
355
+ "a": asset,
356
+ "b": is_buy,
357
+ "s": float_to_wire(size),
358
+ "r": reduce_only,
359
+ "m": minutes,
360
+ "t": randomize,
361
+ },
362
+ }
363
+ nonce = get_timestamp_ms()
364
+ signature = sign_l1_action(
365
+ exchange.wallet,
366
+ action,
367
+ exchange.vault_address,
368
+ nonce,
369
+ exchange.expires_after,
370
+ exchange.base_url == MAINNET_API_URL,
371
+ )
372
+ return exchange._post_action(action, signature, nonce) # noqa: SLF001
373
+
374
+
375
+ def _cancel_native_twap(*, context: CLIContext, coin: str, twap_id: int) -> dict[str, Any]:
376
+ exchange = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(coin))
377
+ asset = exchange.info.name_to_asset(coin)
378
+ action = {"type": "twapCancel", "a": asset, "t": twap_id}
379
+ nonce = get_timestamp_ms()
380
+ signature = sign_l1_action(
381
+ exchange.wallet,
382
+ action,
383
+ exchange.vault_address,
384
+ nonce,
385
+ exchange.expires_after,
386
+ exchange.base_url == MAINNET_API_URL,
387
+ )
388
+ return exchange._post_action(action, signature, nonce) # noqa: SLF001
389
+
390
+
391
+ def _resolve_position_for_close(context: CLIContext, coin: str) -> tuple[str, float, bool]:
392
+ user = context.get_wallet_address()
393
+ info = context.get_public_client()
394
+ target = coin.strip()
395
+ if not target:
396
+ raise RuntimeError("coin must not be empty")
397
+
398
+ with_prefix = ":" in target
399
+ up = target.upper()
400
+ matches: list[tuple[str, float]] = []
401
+ states = run_blocking(_fetch_all_perp_states(info, user, context.get_perp_dexs()))
402
+ for state in states:
403
+ for row in state.get("assetPositions", []):
404
+ pos = row.get("position", {})
405
+ pos_coin = str(pos.get("coin", ""))
406
+ if not pos_coin:
407
+ continue
408
+ szi = float(pos.get("szi", 0) or 0)
409
+ if szi == 0:
410
+ continue
411
+ suffix = pos_coin.split(":", 1)[1] if ":" in pos_coin else pos_coin
412
+ if with_prefix:
413
+ if pos_coin.lower() == target.lower():
414
+ matches.append((pos_coin, szi))
415
+ elif pos_coin.upper() == up or suffix.upper() == up:
416
+ matches.append((pos_coin, szi))
417
+
418
+ if not matches:
419
+ raise RuntimeError(f"No open position found for {coin}")
420
+ if not with_prefix and len(matches) > 1:
421
+ coins = ", ".join(sorted({m[0] for m in matches}))
422
+ raise RuntimeError(
423
+ f"Multiple open positions matched '{coin}': {coins}. "
424
+ "Please specify the dex-prefixed symbol (e.g. xyz:TSLA)."
425
+ )
426
+ resolved_coin, szi = matches[0]
427
+ return resolved_coin, abs(szi), (szi < 0)
428
+
429
+
430
+ def _fetch_builder_resolution_data(info: Any, dex_name: str) -> tuple[dict[str, Any], dict[str, Any]]:
431
+ return info.meta(dex=dex_name), info.all_mids(dex=dex_name)
432
+
433
+
434
+ async def _fetch_all_builder_resolution_data(
435
+ info: Any,
436
+ dex_names: list[str],
437
+ ) -> list[tuple[dict[str, Any], dict[str, Any]]]:
438
+ return await asyncio.gather(
439
+ *(asyncio.to_thread(_fetch_builder_resolution_data, info, dex_name) for dex_name in dex_names)
440
+ )
441
+
442
+
443
+ async def _fetch_all_perp_states(info: Any, user: str, dexs: list[str]) -> list[dict[str, Any]]:
444
+ return await asyncio.gather(
445
+ *(asyncio.to_thread(info.user_state, user, dex) for dex in dexs)
446
+ )
447
+
448
+
449
+ def _fetch_orders(context: CLIContext, user: str) -> list[dict[str, Any]]:
450
+ orders = context.get_public_client().open_orders(user)
451
+ return [
452
+ {
453
+ "oid": o["oid"],
454
+ "coin": o["coin"],
455
+ "side": "Buy" if o["side"] in {"B", "buy", True} else "Sell",
456
+ "sz": o["sz"],
457
+ "limitPx": o["limitPx"],
458
+ "timestamp": datetime.fromtimestamp(o["timestamp"] / 1000).isoformat(),
459
+ }
460
+ for o in orders
461
+ ]
462
+
463
+
464
+ @cli_command
465
+ def order_ls(
466
+ ctx: Any,
467
+ user: Optional[str] = None,
468
+ watch: bool = False,
469
+ ) -> None:
470
+ context = _ctx(ctx)
471
+ address = user if user else context.get_wallet_address()
472
+ if watch:
473
+ watch_loop(
474
+ lambda: _fetch_orders(context, address),
475
+ lambda rows: _render_table(
476
+ "Open Orders",
477
+ ["OID", "Coin", "Side", "Size", "Price", "Time"],
478
+ [[r["oid"], r["coin"], r["side"], r["sz"], r["limitPx"], r["timestamp"]] for r in rows],
479
+ ),
480
+ as_json=_json(ctx),
481
+ )
482
+ return
483
+ out(_fetch_orders(context, address), _json(ctx))
484
+ _done(ctx)
485
+
486
+
487
+ @cli_command
488
+ def order_limit(
489
+ ctx: Any,
490
+ side: str,
491
+ size: str,
492
+ coin: str,
493
+ price: str,
494
+ tif: str = "Gtc",
495
+ reduce_only: bool = False,
496
+ stake: Optional[float] = None,
497
+ leverage: Optional[int] = None,
498
+ cross: bool = False,
499
+ isolated: bool = False,
500
+ ) -> None:
501
+ context = _ctx(ctx)
502
+ resolved_coin = _resolve_tradable_coin(context, coin)
503
+ client = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(resolved_coin))
504
+ is_buy = normalize_side(side) == "buy"
505
+ limit_price = validate_positive_number(price, "price")
506
+ if stake is not None:
507
+ position_notional = _stake_to_position_notional(stake, leverage)
508
+ order_size = position_notional / limit_price
509
+ else:
510
+ order_size = validate_positive_number(size, "size")
511
+ order_size = _normalize_size_for_coin(context, resolved_coin, order_size)
512
+ lev_result = _maybe_update_leverage(
513
+ context=context,
514
+ coin=resolved_coin,
515
+ leverage=leverage,
516
+ cross=cross,
517
+ isolated=isolated,
518
+ emit_warning=not _json(ctx),
519
+ )
520
+ result = client.order(
521
+ resolved_coin,
522
+ is_buy,
523
+ order_size,
524
+ limit_price,
525
+ {"limit": {"tif": normalize_tif(tif)}},
526
+ reduce_only=reduce_only,
527
+ )
528
+ if _json(ctx):
529
+ out({"leverageUpdate": lev_result, "order": result} if lev_result is not None else result, True)
530
+ else:
531
+ _print_leverage_update(lev_result, coin, leverage, cross or not isolated)
532
+ _print_order_feedback(
533
+ result=result,
534
+ coin=coin,
535
+ side="buy" if is_buy else "sell",
536
+ order_kind="Limit",
537
+ stake=stake,
538
+ )
539
+ _done(ctx)
540
+
541
+
542
+ @cli_command
543
+ def order_market(
544
+ ctx: Any,
545
+ side: str,
546
+ size: str,
547
+ coin: str,
548
+ reduce_only: bool = False,
549
+ slippage: Optional[float] = None,
550
+ stake: Optional[float] = None,
551
+ leverage: Optional[int] = None,
552
+ cross: bool = False,
553
+ isolated: bool = False,
554
+ ) -> None:
555
+ context = _ctx(ctx)
556
+ resolved_coin = _resolve_tradable_coin(context, coin)
557
+ client = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(resolved_coin))
558
+ is_buy = normalize_side(side) == "buy"
559
+ cfg = get_order_config()
560
+ slippage_pct = (slippage if slippage is not None else float(cfg["slippage"])) / 100
561
+ mids_cache: Optional[dict[str, str]] = None
562
+ if stake is not None:
563
+ mids_cache = _mids_for_coin(context, resolved_coin)
564
+ mid = float(mids_cache[resolved_coin])
565
+ position_notional = _stake_to_position_notional(stake, leverage)
566
+ order_size = position_notional / mid
567
+ else:
568
+ order_size = validate_positive_number(size, "size")
569
+ order_size = _normalize_size_for_coin(context, resolved_coin, order_size)
570
+ lev_result = _maybe_update_leverage(
571
+ context=context,
572
+ coin=resolved_coin,
573
+ leverage=leverage,
574
+ cross=cross,
575
+ isolated=isolated,
576
+ emit_warning=not _json(ctx),
577
+ )
578
+
579
+ if reduce_only:
580
+ mids = mids_cache if mids_cache is not None else _mids_for_coin(context, resolved_coin)
581
+ mid = float(mids[resolved_coin])
582
+ price = mid * (1 + slippage_pct) if is_buy else mid * (1 - slippage_pct)
583
+ result = client.order(
584
+ resolved_coin,
585
+ is_buy,
586
+ order_size,
587
+ price,
588
+ {"limit": {"tif": "Ioc"}},
589
+ reduce_only=True,
590
+ )
591
+ else:
592
+ result = client.market_open(
593
+ resolved_coin,
594
+ is_buy,
595
+ order_size,
596
+ slippage=slippage_pct,
597
+ )
598
+
599
+ if _json(ctx):
600
+ out({"leverageUpdate": lev_result, "order": result} if lev_result is not None else result, True)
601
+ else:
602
+ _print_leverage_update(lev_result, coin, leverage, cross or not isolated)
603
+ _print_order_feedback(
604
+ result=result,
605
+ coin=coin,
606
+ side="buy" if is_buy else "sell",
607
+ order_kind="Market",
608
+ stake=stake,
609
+ )
610
+ _done(ctx)
611
+
612
+
613
+ @cli_command
614
+ def order_market_close(
615
+ ctx: Any,
616
+ coin: str,
617
+ slippage: Optional[float] = None,
618
+ ratio: float = 1.0,
619
+ ) -> None:
620
+ if ratio <= 0 or ratio > 1:
621
+ raise RuntimeError("ratio must be > 0 and <= 1")
622
+ context = _ctx(ctx)
623
+ resolved_coin, order_size, is_buy = _resolve_position_for_close(context, coin)
624
+ client = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(resolved_coin))
625
+ close_size = order_size * ratio
626
+ cfg = get_order_config()
627
+ slippage_pct = (slippage if slippage is not None else float(cfg["slippage"])) / 100
628
+ result = client.market_close(
629
+ resolved_coin,
630
+ sz=_normalize_size_for_coin(context, resolved_coin, close_size),
631
+ slippage=slippage_pct,
632
+ )
633
+ if _json(ctx):
634
+ out(result, True)
635
+ else:
636
+ _print_order_feedback(
637
+ result=result,
638
+ coin=coin,
639
+ side="buy" if is_buy else "sell",
640
+ order_kind="Market close",
641
+ stake=None,
642
+ )
643
+ _done(ctx)
644
+
645
+
646
+ @cli_command
647
+ def order_tpsl(
648
+ ctx: Any,
649
+ coin: str,
650
+ tp: Optional[float] = None,
651
+ sl: Optional[float] = None,
652
+ ratio: float = 1.0,
653
+ ) -> None:
654
+ if tp is None and sl is None:
655
+ raise RuntimeError("Specify at least one of --tp or --sl")
656
+ if tp is not None and tp <= 0:
657
+ raise RuntimeError("tp must be a positive number")
658
+ if sl is not None and sl <= 0:
659
+ raise RuntimeError("sl must be a positive number")
660
+ if ratio <= 0 or ratio > 1:
661
+ raise RuntimeError("ratio must be > 0 and <= 1")
662
+
663
+ context = _ctx(ctx)
664
+ resolved_coin, position_size, is_buy_to_close = _resolve_position_for_close(context, coin)
665
+ client = context.get_wallet_client(perp_dexs=_wallet_perp_dexs_for_coin(resolved_coin))
666
+ protected_size = _normalize_size_for_coin(context, resolved_coin, position_size * ratio)
667
+
668
+ results: dict[str, Any] = {
669
+ "coin": coin,
670
+ "resolvedCoin": resolved_coin,
671
+ "closeSide": "buy" if is_buy_to_close else "sell",
672
+ "size": protected_size,
673
+ "ratio": ratio,
674
+ }
675
+ if tp is not None:
676
+ tp_order_type = {"trigger": {"triggerPx": tp, "isMarket": True, "tpsl": "tp"}}
677
+ results["tp"] = client.order(
678
+ resolved_coin,
679
+ is_buy_to_close,
680
+ protected_size,
681
+ tp,
682
+ tp_order_type,
683
+ reduce_only=True,
684
+ )
685
+ if sl is not None:
686
+ sl_order_type = {"trigger": {"triggerPx": sl, "isMarket": True, "tpsl": "sl"}}
687
+ results["sl"] = client.order(
688
+ resolved_coin,
689
+ is_buy_to_close,
690
+ protected_size,
691
+ sl,
692
+ sl_order_type,
693
+ reduce_only=True,
694
+ )
695
+
696
+ if _json(ctx):
697
+ out({"tpsl": results}, True)
698
+ else:
699
+ print("✅ TP/SL orders submitted")
700
+ print(f"\nAsset: {coin}")
701
+ print(f"Close side: {'BUY' if is_buy_to_close else 'SELL'}")
702
+ print(f"Protected size: {protected_size}")
703
+ if tp is not None:
704
+ print(f"TP trigger: {tp}")
705
+ if sl is not None:
706
+ print(f"SL trigger: {sl}")
707
+ _done(ctx)
708
+
709
+
710
+ @cli_command
711
+ def order_twap(
712
+ ctx: Any,
713
+ side: str,
714
+ size: str,
715
+ coin: str,
716
+ interval: str,
717
+ stake: Optional[float] = None,
718
+ reduce_only: bool = False,
719
+ randomize: bool = False,
720
+ leverage: Optional[int] = None,
721
+ cross: bool = False,
722
+ isolated: bool = False,
723
+ ) -> None:
724
+ context = _ctx(ctx)
725
+ resolved_coin = _resolve_tradable_coin(context, coin)
726
+ is_buy = normalize_side(side) == "buy"
727
+ if stake is not None:
728
+ mids = _mids_for_coin(context, resolved_coin)
729
+ mid = float(mids[resolved_coin])
730
+ position_notional = _stake_to_position_notional(stake, leverage)
731
+ total_size = position_notional / mid
732
+ else:
733
+ total_size = validate_positive_number(size, "size")
734
+ total_size = _normalize_size_for_coin(context, resolved_coin, total_size)
735
+ minutes, compatibility_orders = _parse_twap_interval(interval)
736
+ lev_result = _maybe_update_leverage(
737
+ context=context,
738
+ coin=resolved_coin,
739
+ leverage=leverage,
740
+ cross=cross,
741
+ isolated=isolated,
742
+ emit_warning=not _json(ctx),
743
+ )
744
+ response = _place_native_twap(
745
+ context=context,
746
+ coin=resolved_coin,
747
+ is_buy=is_buy,
748
+ size=total_size,
749
+ minutes=minutes,
750
+ reduce_only=reduce_only,
751
+ randomize=randomize,
752
+ )
753
+ result = {
754
+ "twap": {
755
+ "side": "buy" if is_buy else "sell",
756
+ "coin": coin,
757
+ "totalSize": total_size,
758
+ "stake": stake,
759
+ "durationMinutes": minutes,
760
+ "compatibilityInput": interval if compatibility_orders > 1 else None,
761
+ "randomize": randomize,
762
+ "reduceOnly": reduce_only,
763
+ "leverageUpdate": lev_result,
764
+ "response": response,
765
+ }
766
+ }
767
+ if _json(ctx):
768
+ out(result, True)
769
+ else:
770
+ _print_leverage_update(lev_result, coin, leverage, cross or not isolated)
771
+ status = response.get("response", {}).get("data", {}).get("status", {})
772
+ if isinstance(status, dict) and "error" in status:
773
+ print("❌ TWAP order rejected")
774
+ print(f"\nReason: {status.get('error')}")
775
+ else:
776
+ print("✅ TWAP order submitted")
777
+ print(f"\nAsset: {coin}")
778
+ print(f"Side: {'BUY' if is_buy else 'SELL'}")
779
+ print(f"Total size: {total_size} {coin}")
780
+ print(f"Duration: {minutes} min")
781
+ print(f"Randomize: {'on' if randomize else 'off'}")
782
+ _done(ctx)
783
+
784
+
785
+ @cli_command
786
+ def order_twap_cancel(ctx: Any, coin: str, twap_id: str) -> None:
787
+ context = _ctx(ctx)
788
+ twap_num = validate_positive_integer(twap_id, "twap_id")
789
+ response = _cancel_native_twap(context=context, coin=coin, twap_id=twap_num)
790
+ out({"twapCancel": {"coin": coin, "twapId": twap_num, "response": response}}, _json(ctx))
791
+ _done(ctx)
792
+
793
+
794
+ @cli_command
795
+ def order_cancel(ctx: Any, oid: Optional[str] = None) -> None:
796
+ context = _ctx(ctx)
797
+ user = context.get_wallet_address()
798
+ exchange = context.get_wallet_client()
799
+ orders = context.get_public_client().open_orders(user)
800
+
801
+ if not orders:
802
+ if _json(ctx):
803
+ out(
804
+ {
805
+ "cancelled": False,
806
+ "reason": "no_open_orders",
807
+ "message": "No open orders to cancel",
808
+ },
809
+ True,
810
+ )
811
+ else:
812
+ out_success("No open orders to cancel")
813
+ _done(ctx)
814
+ return
815
+
816
+ if oid is None:
817
+ if _json(ctx):
818
+ latest = max(orders, key=lambda x: int(x.get("timestamp", 0)))
819
+ oid = str(latest["oid"])
820
+ else:
821
+ _render_table(
822
+ "Open Orders",
823
+ ["OID", "Coin", "Side", "Size", "Price"],
824
+ [[o["oid"], o["coin"], o["side"], o["sz"], o["limitPx"]] for o in orders],
825
+ )
826
+ oid = input("Select OID to cancel: ").strip()
827
+
828
+ order_id = validate_positive_integer(oid, "oid")
829
+ target = next((o for o in orders if int(o["oid"]) == order_id), None)
830
+ if not target:
831
+ raise RuntimeError(f"Order {order_id} not found")
832
+
833
+ result = exchange.cancel(target["coin"], order_id)
834
+ out(result, _json(ctx))
835
+ _done(ctx)
836
+
837
+
838
+ @cli_command
839
+ def order_cancel_all(
840
+ ctx: Any,
841
+ yes: bool = False,
842
+ coin: Optional[str] = None,
843
+ ) -> None:
844
+ context = _ctx(ctx)
845
+ user = context.get_wallet_address()
846
+ exchange = context.get_wallet_client()
847
+ orders = context.get_public_client().open_orders(user)
848
+ if coin:
849
+ orders = [o for o in orders if o["coin"] == coin]
850
+ if not orders:
851
+ if _json(ctx):
852
+ out(
853
+ {
854
+ "cancelled": 0,
855
+ "reason": "no_open_orders",
856
+ "message": "No open orders to cancel",
857
+ },
858
+ True,
859
+ )
860
+ else:
861
+ out_success("No open orders to cancel")
862
+ _done(ctx)
863
+ return
864
+ if not yes and not _confirm(f"Cancel {len(orders)} orders?", False):
865
+ if _json(ctx):
866
+ out({"cancelled": 0, "reason": "user_cancelled", "message": "Cancelled"}, True)
867
+ else:
868
+ out_success("Cancelled")
869
+ _done(ctx)
870
+ return
871
+ result = exchange.bulk_cancel([{"coin": o["coin"], "oid": int(o["oid"])} for o in orders])
872
+ out(result, _json(ctx))
873
+ _done(ctx)
874
+
875
+
876
+ @cli_command
877
+ def order_set_leverage(
878
+ ctx: Any,
879
+ coin: str,
880
+ leverage: str,
881
+ cross: bool = False,
882
+ isolated: bool = False,
883
+ ) -> None:
884
+ context = _ctx(ctx)
885
+ if cross and isolated:
886
+ raise RuntimeError("Use only one of --cross or --isolated")
887
+ is_cross = cross or not isolated
888
+ requested = validate_positive_integer(leverage, "leverage")
889
+ result = _update_leverage_with_fallback(
890
+ context=context,
891
+ coin=coin,
892
+ leverage=requested,
893
+ is_cross=is_cross,
894
+ emit_warning=not _json(ctx),
895
+ )
896
+ if _json(ctx):
897
+ out({"requestedLeverage": requested, "result": result}, True)
898
+ else:
899
+ if result.get("status") == "ok":
900
+ print("✅ Leverage updated")
901
+ print(f"\nAsset: {coin}")
902
+ print(f"Leverage: {requested}x")
903
+ print(f"Margin type: {'cross' if is_cross else 'isolated'}")
904
+ else:
905
+ print("❌ Leverage update failed")
906
+ print(f"\nReason: {result.get('response')}")
907
+ _done(ctx)
908
+
909
+
910
+ @cli_command
911
+ def order_configure(ctx: Any, slippage: Optional[float] = None) -> None:
912
+ if slippage is None:
913
+ out(get_order_config(), _json(ctx))
914
+ else:
915
+ if slippage < 0:
916
+ raise RuntimeError("Slippage must be a non-negative number")
917
+ out(update_order_config(slippage=slippage), _json(ctx))
918
+ _done(ctx)