perp-cli 0.3.19 → 0.4.2

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.
package/dist/index.js CHANGED
@@ -297,7 +297,7 @@ registerPortfolioCommands(program, getAdapterForExchange, isJson);
297
297
  registerRiskCommands(program, getAdapterForExchange, isJson);
298
298
  registerHistoryCommands(program, isJson);
299
299
  registerAnalyticsCommands(program, getAdapterForExchange, isJson);
300
- registerSettingsCommands(program, isJson);
300
+ registerSettingsCommands(program, isJson, getAdapterForExchange);
301
301
  registerDexCommands(program, getAdapter, isJson);
302
302
  registerPlanCommands(program, getAdapter, isJson);
303
303
  registerFundingCommands(program, isJson);
@@ -388,20 +388,20 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
388
388
  process.env.LIGHTER_PRIVATE_KEY);
389
389
  if (!status.hasWallets && !hasEnvKey && !settings.defaultExchange) {
390
390
  // Fresh install — onboarding
391
- console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(" v0.3.7\n"));
391
+ console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(` v${_pkg.version}\n`));
392
392
  console.log(" Multi-DEX perpetual futures CLI for Pacifica, Hyperliquid, and Lighter.\n");
393
- console.log(` Get started: ${chalk.cyan("perp init")}`);
393
+ console.log(` Get started: ${chalk.cyan("perp wallet set <exchange> <key>")}`);
394
394
  console.log(chalk.gray(`\n Or explore without a wallet:`));
395
395
  console.log(` ${chalk.green("perp market list")} available markets`);
396
396
  console.log(` ${chalk.green("perp -e hyperliquid market list")} Hyperliquid markets`);
397
- console.log(` ${chalk.green("perp arb rates")} funding rate comparison`);
397
+ console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
398
398
  console.log(` ${chalk.green("perp --help")} all commands\n`);
399
399
  }
400
400
  else {
401
401
  // Configured — show status overview
402
402
  const defaultEx = settings.defaultExchange || "pacifica";
403
403
  const activeEntries = Object.entries(status.active);
404
- console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(" v0.3.7\n"));
404
+ console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(` v${_pkg.version}\n`));
405
405
  console.log(` Default exchange: ${chalk.cyan(defaultEx)}`);
406
406
  if (activeEntries.length > 0) {
407
407
  console.log(chalk.white.bold("\n Wallets:"));
@@ -432,10 +432,10 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
432
432
  }
433
433
  }
434
434
  console.log(chalk.white.bold("\n Quick commands:"));
435
- console.log(` ${chalk.green("perp status")} account overview`);
435
+ console.log(` ${chalk.green("perp portfolio")} balances + positions + risk`);
436
436
  console.log(` ${chalk.green("perp market list")} available markets`);
437
+ console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
437
438
  console.log(` ${chalk.green("perp dashboard")} live monitoring`);
438
- console.log(` ${chalk.green("perp arb rates")} funding rate comparison`);
439
439
  console.log(` ${chalk.green("perp --help")} all commands\n`);
440
440
  }
441
441
  }
@@ -333,7 +333,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
333
333
  steps.push({ step: 1, command: `perp -e ${ex} --json account orders`, description: "List open orders to find order ID" }, { step: 2, command: `perp -e ${ex} --json trade edit <symbol> <orderId> <newPrice> <newSize>`, description: "Modify the order", dangerous: true });
334
334
  }
335
335
  else if (g.includes("arb") || g.includes("arbitrage")) {
336
- steps.push({ step: 1, command: "perp --json arb rates", description: "Compare funding rates across exchanges" }, { step: 2, command: "perp --json arb scan", description: "Find high-spread opportunities" }, { step: 3, command: "perp --json gap show", description: "Check cross-exchange price gaps" }, { step: 4, command: "perp --json arb dex", description: "HIP-3 cross-dex arb opportunities (Hyperliquid)" });
336
+ steps.push({ step: 1, command: "perp --json arb scan --min 5", description: "Scan funding rate arbitrage opportunities" }, { step: 2, command: "perp --json arb scan", description: "Find high-spread opportunities" }, { step: 3, command: "perp --json gap show", description: "Check cross-exchange price gaps" }, { step: 4, command: "perp --json arb dex", description: "HIP-3 cross-dex arb opportunities (Hyperliquid)" });
337
337
  }
338
338
  else if (g.includes("funding")) {
339
339
  const symbol = extractSymbol(g);
@@ -712,7 +712,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
712
712
  parameters: [],
713
713
  risks: [],
714
714
  category: "read",
715
- relatedCommands: ["perp status", "perp portfolio"],
715
+ relatedCommands: ["perp portfolio", "perp portfolio"],
716
716
  };
717
717
  }
718
718
  else if (category === "arb") {
@@ -731,7 +731,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
731
731
  parameters: [],
732
732
  risks: ["auto" === sub ? "Auto-executes trades on both exchanges" : ""].filter(Boolean),
733
733
  category: sub === "auto" || sub === "close" ? "write" : "analysis",
734
- relatedCommands: ["perp arb rates", "perp arb scan", "perp gap show"],
734
+ relatedCommands: ["perp arb scan", "perp arb scan", "perp gap show"],
735
735
  };
736
736
  }
737
737
  else if (category === "bridge") {
@@ -837,7 +837,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
837
837
  parameters: [],
838
838
  risks: [],
839
839
  category: "read",
840
- relatedCommands: ["perp funding rates", "perp arb rates"],
840
+ relatedCommands: ["perp funding rates", "perp arb scan"],
841
841
  };
842
842
  }
843
843
  else if (category === "backtest") {
@@ -1028,7 +1028,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1028
1028
  arb: {
1029
1029
  description: "Funding rate arbitrage & basis trading",
1030
1030
  subcommands: {
1031
- rates: { usage: "perp arb rates", description: "Compare funding rates" },
1031
+ rates: { usage: "perp arb scan", description: "Compare funding rates" },
1032
1032
  scan: { usage: "perp arb scan --min <bps>", description: "Find arb opportunities" },
1033
1033
  funding: { usage: "perp arb funding", description: "Detailed funding analysis" },
1034
1034
  dex: { usage: "perp arb dex", description: "HIP-3 cross-dex arb" },
@@ -1167,7 +1167,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1167
1167
  deposit: { usage: "perp deposit <exchange> <amount>", description: "Deposit USDC to exchange" },
1168
1168
  withdraw: { usage: "perp withdraw <exchange> <amount>", description: "Withdraw USDC from exchange" },
1169
1169
  portfolio: { usage: "perp portfolio", description: "Cross-exchange portfolio summary" },
1170
- status: { usage: "perp status", description: "Full account overview" },
1170
+ status: { usage: "perp portfolio", description: "Full account overview" },
1171
1171
  health: { usage: "perp health", description: "Exchange connectivity check" },
1172
1172
  settings: {
1173
1173
  description: "CLI settings",
@@ -1,3 +1,7 @@
1
+ export interface ExchangeFees {
2
+ taker: number;
3
+ maker: number;
4
+ }
1
5
  export interface Settings {
2
6
  /** Default exchange when -e flag is omitted */
3
7
  defaultExchange: string;
@@ -14,6 +18,8 @@ export interface Settings {
14
18
  hyperliquid: boolean;
15
19
  lighter: boolean;
16
20
  };
21
+ /** Per-exchange fee tiers (fetched from exchange APIs) */
22
+ fees: Record<string, ExchangeFees>;
17
23
  }
18
24
  export declare function loadSettings(): Settings;
19
25
  export declare function saveSettings(settings: Settings): void;
package/dist/settings.js CHANGED
@@ -14,12 +14,27 @@ const DEFAULTS = {
14
14
  hyperliquid: false,
15
15
  lighter: false,
16
16
  },
17
+ fees: {
18
+ hyperliquid: { taker: 0.00035, maker: 0.00002 },
19
+ pacifica: { taker: 0.00035, maker: 0.0001 },
20
+ lighter: { taker: 0, maker: 0 },
21
+ },
17
22
  };
18
23
  export function loadSettings() {
19
24
  if (!existsSync(SETTINGS_FILE))
20
- return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes } };
25
+ return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes }, fees: { ...DEFAULTS.fees } };
21
26
  try {
22
27
  const stored = JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
28
+ // Merge stored fees with defaults (so new exchanges get defaults)
29
+ const fees = { ...DEFAULTS.fees };
30
+ if (stored.fees && typeof stored.fees === "object") {
31
+ for (const [ex, f] of Object.entries(stored.fees)) {
32
+ const fee = f;
33
+ if (fee && typeof fee.taker === "number" && typeof fee.maker === "number") {
34
+ fees[ex] = { taker: fee.taker, maker: fee.maker };
35
+ }
36
+ }
37
+ }
23
38
  return {
24
39
  defaultExchange: stored.defaultExchange ?? DEFAULTS.defaultExchange,
25
40
  referrals: stored.referrals ?? DEFAULTS.referrals,
@@ -32,10 +47,11 @@ export function loadSettings() {
32
47
  hyperliquid: stored.referralApplied?.hyperliquid ?? DEFAULTS.referralApplied.hyperliquid,
33
48
  lighter: stored.referralApplied?.lighter ?? DEFAULTS.referralApplied.lighter,
34
49
  },
50
+ fees,
35
51
  };
36
52
  }
37
53
  catch {
38
- return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes } };
54
+ return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes }, fees: { ...DEFAULTS.fees } };
39
55
  }
40
56
  }
41
57
  export function saveSettings(settings) {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Smart Order Execution — best bid/ask + 1 tick pricing.
3
+ *
4
+ * Instead of `marketOrder()` which can sweep deep into the book (Hyperliquid
5
+ * DEX uses 5% slippage by default), this fetches the orderbook and places an
6
+ * order at exactly the best available price plus one tick.
7
+ *
8
+ * For buys: limit price = best ask + 1 tick (fills at best ask or better)
9
+ * For sells: limit price = best bid - 1 tick (fills at best bid or better)
10
+ *
11
+ * Uses IOC (Immediate-Or-Cancel) so the order fills instantly at the best
12
+ * level and any unfilled remainder is cancelled — no resting orders left.
13
+ *
14
+ * Benefits:
15
+ * - Fills only at the top-of-book price (no multi-level sweep)
16
+ * - Saves 5-50+ bps vs raw market orders on thin books
17
+ * - Predictable execution price
18
+ *
19
+ * Fallback: if the IOC limit fails, falls back to market order.
20
+ */
21
+ import type { ExchangeAdapter } from "./exchanges/interface.js";
22
+ export interface SmartOrderOpts {
23
+ /** Extra ticks of tolerance beyond best price. Default: 1 */
24
+ tickTolerance?: number;
25
+ /** Fall back to market order if IOC limit fails. Default: true */
26
+ fallback?: boolean;
27
+ /** Reduce only flag for closing positions. Default: false */
28
+ reduceOnly?: boolean;
29
+ }
30
+ export interface SmartOrderResult {
31
+ result: unknown;
32
+ method: "limit_ioc" | "market_fallback";
33
+ /** The actual limit price used for the order */
34
+ price: string;
35
+ /** The best price from the orderbook */
36
+ bestBookPrice: string;
37
+ /** Tick size inferred from orderbook */
38
+ tickSize: string;
39
+ }
40
+ /**
41
+ * Execute a smart order at best bid/ask + 1 tick.
42
+ * Drop-in replacement for `adapter.marketOrder()`.
43
+ */
44
+ export declare function smartOrder(adapter: ExchangeAdapter, symbol: string, side: "buy" | "sell", size: string, opts?: SmartOrderOpts): Promise<SmartOrderResult>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Smart Order Execution — best bid/ask + 1 tick pricing.
3
+ *
4
+ * Instead of `marketOrder()` which can sweep deep into the book (Hyperliquid
5
+ * DEX uses 5% slippage by default), this fetches the orderbook and places an
6
+ * order at exactly the best available price plus one tick.
7
+ *
8
+ * For buys: limit price = best ask + 1 tick (fills at best ask or better)
9
+ * For sells: limit price = best bid - 1 tick (fills at best bid or better)
10
+ *
11
+ * Uses IOC (Immediate-Or-Cancel) so the order fills instantly at the best
12
+ * level and any unfilled remainder is cancelled — no resting orders left.
13
+ *
14
+ * Benefits:
15
+ * - Fills only at the top-of-book price (no multi-level sweep)
16
+ * - Saves 5-50+ bps vs raw market orders on thin books
17
+ * - Predictable execution price
18
+ *
19
+ * Fallback: if the IOC limit fails, falls back to market order.
20
+ */
21
+ /**
22
+ * Infer tick size from orderbook prices.
23
+ * Uses the minimum price increment visible in the book.
24
+ */
25
+ function inferTickSize(levels) {
26
+ if (levels.length < 2) {
27
+ // Single level: infer from decimal places (e.g., "1.234" → tick = 0.001)
28
+ const price = levels[0]?.[0] ?? "1";
29
+ const decimals = price.includes(".") ? price.split(".")[1].length : 0;
30
+ return decimals > 0 ? Math.pow(10, -decimals) : 1;
31
+ }
32
+ // Use minimum difference between adjacent price levels
33
+ let minDiff = Infinity;
34
+ for (let i = 1; i < Math.min(levels.length, 5); i++) {
35
+ const diff = Math.abs(Number(levels[i][0]) - Number(levels[i - 1][0]));
36
+ if (diff > 0 && diff < minDiff)
37
+ minDiff = diff;
38
+ }
39
+ return minDiff === Infinity
40
+ ? Math.pow(10, -(levels[0][0].includes(".") ? levels[0][0].split(".")[1].length : 0))
41
+ : minDiff;
42
+ }
43
+ /**
44
+ * Get price precision (decimal places) from a price string.
45
+ */
46
+ function pricePrecision(priceStr) {
47
+ return priceStr.includes(".") ? priceStr.split(".")[1].length : 0;
48
+ }
49
+ /**
50
+ * Check if an order response contains an embedded error (not thrown).
51
+ * Some exchanges (e.g., Hyperliquid) return success at HTTP level but include
52
+ * error statuses inside the response body.
53
+ */
54
+ function hasOrderError(result) {
55
+ if (!result || typeof result !== "object")
56
+ return false;
57
+ const r = result;
58
+ // Hyperliquid: { response: { data: { statuses: [{ error: "..." }] } } }
59
+ const resp = r.response;
60
+ const data = resp?.data;
61
+ const statuses = data?.statuses;
62
+ if (Array.isArray(statuses) && statuses.length > 0 && statuses[0].error)
63
+ return true;
64
+ // Generic: { ok: false } or { success: false }
65
+ if (r.ok === false || r.success === false)
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Execute a smart order at best bid/ask + 1 tick.
71
+ * Drop-in replacement for `adapter.marketOrder()`.
72
+ */
73
+ export async function smartOrder(adapter, symbol, side, size, opts = {}) {
74
+ const { tickTolerance = 1, fallback = true, reduceOnly = false } = opts;
75
+ // 1. Fetch orderbook
76
+ const book = await adapter.getOrderbook(symbol);
77
+ // 2. Validate book has liquidity on the relevant side
78
+ const relevantSide = side === "buy" ? book.asks : book.bids;
79
+ if (relevantSide.length === 0) {
80
+ if (!fallback)
81
+ throw new Error(`No ${side === "buy" ? "asks" : "bids"} in orderbook for ${symbol}`);
82
+ const result = await adapter.marketOrder(symbol, side, size);
83
+ return { result, method: "market_fallback", price: "0", bestBookPrice: "0", tickSize: "0" };
84
+ }
85
+ // 3. Get best price and tick size
86
+ const bestEntry = relevantSide[0];
87
+ const bestPrice = Number(bestEntry[0]);
88
+ const tick = inferTickSize(relevantSide);
89
+ const decimals = pricePrecision(bestEntry[0]);
90
+ // 4. Calculate limit price: best price + N ticks in favorable direction
91
+ const limitPrice = side === "buy"
92
+ ? bestPrice + tick * tickTolerance // 1 tick above best ask
93
+ : bestPrice - tick * tickTolerance; // 1 tick below best bid
94
+ const formattedPrice = limitPrice.toFixed(decimals);
95
+ // 5. Place IOC limit order at calculated price
96
+ try {
97
+ const result = await adapter.limitOrder(symbol, side, formattedPrice, size, {
98
+ tif: "IOC",
99
+ reduceOnly,
100
+ });
101
+ // Check for exchange-level rejection inside a "success" response
102
+ // (e.g., Hyperliquid returns { status: "ok", response: { data: { statuses: [{ error: "..." }] } } })
103
+ if (fallback && hasOrderError(result)) {
104
+ const fallbackResult = await adapter.marketOrder(symbol, side, size);
105
+ return {
106
+ result: fallbackResult,
107
+ method: "market_fallback",
108
+ price: formattedPrice,
109
+ bestBookPrice: bestEntry[0],
110
+ tickSize: tick.toFixed(decimals),
111
+ };
112
+ }
113
+ return {
114
+ result,
115
+ method: "limit_ioc",
116
+ price: formattedPrice,
117
+ bestBookPrice: bestEntry[0],
118
+ tickSize: tick.toFixed(decimals),
119
+ };
120
+ }
121
+ catch (err) {
122
+ if (!fallback)
123
+ throw err;
124
+ // Fallback to raw market order for reliability
125
+ const result = await adapter.marketOrder(symbol, side, size);
126
+ return {
127
+ result,
128
+ method: "market_fallback",
129
+ price: formattedPrice,
130
+ bestBookPrice: bestEntry[0],
131
+ tickSize: tick.toFixed(decimals),
132
+ };
133
+ }
134
+ }
@@ -1,5 +1,6 @@
1
1
  import { symbolMatch } from "./utils.js";
2
- const DEFAULT_TAKER_FEE = 0.0005; // 0.05%
2
+ import { DEFAULT_TAKER_FEE } from "./constants.js";
3
+ // Use centralized DEFAULT_TAKER_FEE from constants.ts (0.035%)
3
4
  const DEFAULT_MAX_SLIPPAGE = 0.005; // 0.5%
4
5
  /**
5
6
  * Validate a trade before execution.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.3.19",
3
+ "version": "0.4.2",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -5,7 +5,7 @@ allowed-tools: "Bash(perp:*), Bash(npx perp-cli:*), Bash(npx -y perp-cli:*)"
5
5
  license: MIT
6
6
  metadata:
7
7
  author: hypurrquant
8
- version: "0.3.19"
8
+ version: "0.4.2"
9
9
  ---
10
10
 
11
11
  # perp-cli Agent Guide
@@ -21,12 +21,21 @@ Multi-DEX perpetual futures CLI — Pacifica (Solana), Hyperliquid (HyperEVM), L
21
21
  5. **NEVER trade without user confirmation.**
22
22
  6. **NEVER read ~/.perp/.env or key files.**
23
23
 
24
- ## Install
24
+ ## Install (run this FIRST)
25
25
 
26
26
  ```bash
27
- npm install -g perp-cli
27
+ # 1. Check if perp exists and is current (must be ≥ 0.3.19)
28
+ perp --version 2>/dev/null
29
+
30
+ # 2. If not found or outdated:
31
+ npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
32
+
33
+ # 3. If version is still old (npx cache), clear cache first:
34
+ npx -y clear-npx-cache 2>/dev/null; npx -y perp-cli@latest --json --version
28
35
  ```
29
- No sudo: `npm install -g perp-cli --prefix ~/.npm-global && export PATH="$HOME/.npm-global/bin:$PATH"`
36
+
37
+ **IMPORTANT:** Always use `@latest` with npx — without it, npx uses a stale cached version.
38
+ Use `perp` if global install works, otherwise `npx -y perp-cli@latest` as prefix for all commands.
30
39
 
31
40
  ## Wallet Setup
32
41
 
@@ -50,7 +59,9 @@ perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage <N> --isolated # e
50
59
 
51
60
  # Single exchange trading (when not doing arb)
52
61
  perp --json -e <EX> trade market <SYM> buy <SIZE>
62
+ perp --json -e <EX> trade market <SYM> buy <SIZE> --smart # IOC limit at best ask + 1 tick (less slippage)
53
63
  perp --json -e <EX> trade close <SYM>
64
+ perp --json -e <EX> trade close <SYM> --smart # smart close at best bid/ask
54
65
 
55
66
  # Risk (only if you need to set limits — portfolio already includes risk)
56
67
  perp --json risk limits --max-leverage 5
@@ -78,6 +89,13 @@ perp --json --ndjson -e <EX> market list
78
89
 
79
90
  # Prevent duplicate orders with client ID
80
91
  perp --json -e <EX> trade market <SYM> buy <SIZE> --client-id <UNIQUE_ID>
92
+
93
+ # Smart order: IOC limit at best bid/ask + 1 tick instead of raw market order
94
+ # Fills only at top-of-book price — no multi-level sweep slippage
95
+ perp --json -e <EX> trade market <SYM> buy <SIZE> --smart
96
+ perp --json -e <EX> trade close <SYM> --smart
97
+ perp --json arb exec <SYM> <longEx> <shortEx> <$> --smart # smart arb entry
98
+ perp --json arb close <SYM> --smart # smart arb close
81
99
  ```
82
100
 
83
101
  All string outputs are auto-sanitized (control chars stripped, prompt injection patterns blocked).
@@ -94,7 +112,11 @@ All string outputs are auto-sanitized (control chars stripped, prompt injection
94
112
  → executes BOTH legs simultaneously
95
113
  → verifies positions exist after execution
96
114
  → auto-rollback if one leg fails
97
- 5. perp --json portfolio → verify positions
115
+ 5. perp --json arb status → monitor: PnL, funding income, daily estimate, breakeven
116
+ 6. perp --json arb funding-earned → actual funding payments received/paid + APR
117
+ 7. perp --json arb close <SYM> → close both legs simultaneously (retry + verify)
118
+ → --dry-run: preview without executing
119
+ → --pair <longEx>:<shortEx>: specify which pair if multiple
98
120
  ```
99
121
 
100
122
  ## Position Sizing
@@ -128,10 +150,11 @@ Check `canTrade` before any order. If `false`, do NOT trade.
128
150
  ## Monitoring (while positions open)
129
151
 
130
152
  ```
131
- Every 15 min: perp --json portfolio
132
- Every 1 hour: perp --json arb scan --min 5
153
+ Every 15 min: perp --json arb status → PnL, daily income, breakeven analysis
154
+ Every 1 hour: perp --json arb scan --min 5 → check if spread still profitable
155
+ Daily: perp --json arb funding-earned → verify actual funding payments
133
156
  Exit if: spread < breakeven or one leg closed unexpectedly.
134
- Close both: perp --json -e <EX1> trade close <SYM> & perp --json -e <EX2> trade close <SYM>
157
+ Close: perp --json arb close <SYM> → close both legs with retry + verification
135
158
  ```
136
159
 
137
160
  ## Error Handling