perp-cli 0.3.15 → 0.3.17

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/README.md CHANGED
@@ -1,296 +1,108 @@
1
1
  # perp-cli
2
2
 
3
- Multi-DEX perpetual futures CLI for **Pacifica**, **Hyperliquid**, and **Lighter**.
4
-
5
- Trade, bridge, arbitrage, and manage positions across 3 exchanges from a single command line.
3
+ Multi-DEX perpetual futures CLI — **Pacifica** (Solana), **Hyperliquid** (HyperEVM), **Lighter** (Ethereum).
6
4
 
7
5
  ```bash
8
6
  npm install -g perp-cli
9
- perp status --json
7
+ perp --json portfolio
10
8
  ```
11
9
 
12
10
  ## Features
13
11
 
14
- - **3 Exchanges** — Pacifica (Solana), Hyperliquid (HyperEVM), Lighter (Ethereum)
15
- - **Live Dashboard** — Real-time multi-exchange dashboard with WebSocket feeds
16
- - **CCTP V2 Bridge** — $0 fee USDC bridging across Solana, Arbitrum, Base (6 routes)
17
- - **Funding Rate Arb** — Cross-exchange funding rate arbitrage scanner + auto-executor
18
- - **Grid & DCA Bots** — Automated strategies with background job management
19
- - **AI Agent Mode** — JSON schema + structured I/O for LLM-powered trading agents
20
- - **Full JSON Output** — Every command supports `--json` for programmatic access
21
-
22
- ## Quick Start
23
-
24
- ```bash
25
- # Interactive setup (recommended for first-time users)
26
- perp init
27
-
28
- # Or configure exchange keys manually
29
- export HL_PRIVATE_KEY=<evm-hex-key> # Hyperliquid
30
- export PRIVATE_KEY=<solana-base58-key> # Pacifica
31
- export LIGHTER_PRIVATE_KEY=<evm-hex-key> # Lighter
32
-
33
- # Or use wallet management
34
- perp wallet import evm <key> # Import & auto-bind to HL + Lighter
35
- perp wallet import solana <key> # Import & auto-bind to Pacifica
36
-
37
- # Set default exchange (skip -e flag)
38
- perp settings set default-exchange hyperliquid
39
-
40
- # Check status
41
- perp status
42
- perp -e pacifica status
43
- ```
44
-
45
- ## Commands
46
-
47
- ### Market Data
48
-
49
- ```bash
50
- perp market list # All available markets
51
- perp market mid BTC # Mid price
52
- perp market info BTC # Market details (tick size, max leverage)
53
- perp market book BTC # Orderbook
54
- perp market funding BTC # Funding rate
55
- ```
56
-
57
- ### Account
58
-
59
- ```bash
60
- perp account info # Balance & margin
61
- perp account positions # Open positions
62
- perp account orders # Open orders
63
- perp account history # Trade history
64
- perp account margin BTC # Position-level margin info
65
- ```
66
-
67
- ### Trading
68
-
69
- ```bash
70
- perp trade buy BTC 0.01 # Market buy
71
- perp trade sell BTC 0.01 # Market sell
72
- perp trade buy BTC 0.01 -p 60000 # Limit buy @ $60,000
73
- perp trade close BTC # Close position
74
- perp trade cancel <orderId> # Cancel order
75
- perp trade tp BTC 65000 # Take-profit
76
- perp trade sl BTC 58000 # Stop-loss
77
- ```
78
-
79
- ### Position Management
80
-
81
- ```bash
82
- perp manage leverage BTC 10 # Set leverage
83
- perp manage margin-mode BTC cross # Cross/isolated margin
84
- ```
12
+ - **3 Exchanges** — trade, bridge, arbitrage across Pacifica, Hyperliquid, Lighter
13
+ - **Funding Rate Arb** — scan spreads + one-command dual-leg execution (`arb exec`)
14
+ - **Portfolio** — single call returns balances, positions, risk level across all exchanges
15
+ - **CCTP V2 Bridge** — $0 fee USDC bridging (Solana, Arbitrum, Base)
16
+ - **Agent-First Design** — `--json`, `--fields`, `--ndjson`, `--dry-run`, runtime schema introspection
17
+ - **Safety** — pre-trade validation, response sanitization, client-id deduplication
85
18
 
86
- ### Deposit & Withdraw
19
+ ## Setup
87
20
 
88
21
  ```bash
89
- perp deposit pacifica 100 # Deposit USDC into Pacifica
90
- perp deposit hyperliquid 100 # Deposit USDC into Hyperliquid (Arbitrum)
91
- perp deposit lighter ethereum 100 # Deposit via Ethereum L1
92
- perp deposit lighter cctp arb 100 # Deposit via CCTP (Arbitrum/Base/Avalanche)
93
- perp deposit lighter info # Show all Lighter deposit routes
94
- perp withdraw pacifica 50 # Withdraw USDC
95
- perp withdraw hyperliquid 50
96
- perp deposit info # All deposit instructions
97
- ```
98
-
99
- ### Cross-Chain Bridge (CCTP V2)
22
+ # Set exchange keys
23
+ perp wallet set hl <EVM_KEY> # Hyperliquid
24
+ perp wallet set pac <SOLANA_KEY> # Pacifica
25
+ perp wallet set lt <EVM_KEY> # Lighter (API key auto-generated)
100
26
 
101
- Zero-fee USDC bridging powered by Circle CCTP V2.
102
-
103
- ```bash
104
- perp bridge chains # Supported chains
105
- perp bridge quote --from solana --to arbitrum --amount 100
106
- perp bridge send --from solana --to arbitrum --amount 100
107
- perp bridge exchange --from pacifica --to hyperliquid --amount 100
108
- perp bridge status <orderId>
27
+ # Verify
28
+ perp wallet show
109
29
  ```
110
30
 
111
- **Supported routes:** Solana ↔ Arbitrum ↔ Base (all 6 directions, $0 CCTP fee)
31
+ Same EVM key works for both Hyperliquid and Lighter.
112
32
 
113
- ### Funding Rate Arbitrage
33
+ ## Core Commands
114
34
 
115
35
  ```bash
116
- perp arb rates # Compare rates across exchanges
117
- perp arb scan --min 10 # Find opportunities (>10 bps spread)
118
- perp arb auto --min-spread 30 # Auto-execute arb (daemon mode)
119
- ```
120
-
121
- ### Automated Strategies
122
-
123
- ```bash
124
- # Grid trading
125
- perp run grid BTC --range 5 --grids 10 --size 100
126
-
127
- # DCA (Dollar Cost Average)
128
- perp run dca BTC --interval 1h --size 50 --side buy
36
+ # Portfolio (balances + positions + risk across all exchanges)
37
+ perp --json portfolio
129
38
 
130
- # Funding arb bot
131
- perp run arb --min-spread 20
132
- ```
39
+ # Market data
40
+ perp --json -e <EX> market list
41
+ perp --json -e <EX> market book <SYM>
42
+ perp --json -e <EX> market funding <SYM>
133
43
 
134
- ### Real-Time Streams
44
+ # Trading
45
+ perp --json -e <EX> trade market <SYM> buy <SIZE>
46
+ perp --json -e <EX> trade close <SYM>
47
+ perp --json -e <EX> trade leverage <SYM> <N>
135
48
 
136
- ```bash
137
- perp stream book BTC # Live orderbook
138
- perp stream trades BTC # Live trades
139
- perp stream funding # Live funding rates
140
- ```
49
+ # Funding rate arbitrage
50
+ perp --json arb scan --min 5 # find opportunities
51
+ perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated # execute both legs
141
52
 
142
- ### Live Dashboard
53
+ # Risk
54
+ perp --json risk limits --max-leverage 5
55
+ perp --json risk liquidation-distance
143
56
 
144
- ```bash
145
- perp dashboard # Real-time multi-exchange dashboard
146
- perp dashboard --port 3457 # Custom port
147
- perp dashboard --exchanges hl,pac # Specific exchanges only
148
- ```
149
-
150
- WebSocket-based real-time monitoring: positions, orders, balances, funding rates, arb opportunities across all exchanges. Includes HIP-3 dex position tracking.
151
-
152
- ### Cross-Exchange Tools
153
-
154
- ```bash
155
- perp gap show # Price gaps between exchanges
156
- perp gap watch --min 0.05 # Live gap monitor
157
- perp portfolio # Unified portfolio view
158
- perp risk overview # Cross-exchange risk assessment
159
- perp analytics pnl # P&L analytics
160
- ```
161
-
162
- ### Background Jobs
163
-
164
- ```bash
165
- perp jobs list # Running background jobs
166
- perp jobs start arb # Start arb in background (tmux)
167
- perp jobs stop <id> # Stop a job
168
- perp jobs logs <id> # View job logs
169
- ```
170
-
171
- ### Alerts
172
-
173
- ```bash
174
- perp alert add -t price -s BTC --above 100000 --telegram
175
- perp alert add -t funding -s ETH --spread 30
176
- perp alert daemon --interval 30
177
- ```
178
-
179
- ## Claude Code Agent Skill
180
-
181
- Install as a Claude Code plugin or via the universal skills CLI:
182
-
183
- ```bash
184
- # Universal skill installer (works with Claude Code, Cursor, Codex, Gemini CLI, etc.)
185
- npx skills add hypurrquant/perp-cli
186
-
187
- # Or in Claude Code directly
188
- /plugin marketplace add hypurrquant/perp-cli
189
- /plugin install perp-cli
190
- ```
191
-
192
- Once installed, Claude can trade for you via natural language:
193
-
194
- ```
195
- perp --json wallet show # Check configured wallets
196
- perp --json -e hl market list # Browse markets
197
- perp --json -e hl trade buy BTC 0.01 # Execute trades
198
- perp --json arb rates # Find funding rate arb
57
+ # Bridge
199
58
  perp --json bridge quote --from solana --to arbitrum --amount 100
59
+ perp --json bridge send --from solana --to arbitrum --amount 100
200
60
  ```
201
61
 
202
- The skill includes safety guardrails (balance checks, user confirmation before trades, error handling with retries).
62
+ Exchange flags: `-e hyperliquid`, `-e pacifica`, `-e lighter` (aliases: `hl`, `pac`, `lt`).
203
63
 
204
- ## AI Agent Integration
64
+ ## AI Agent Skill
205
65
 
206
- Every command supports `--json` for structured output:
66
+ Install as a skill for Claude Code, Cursor, Codex, Gemini CLI, etc.:
207
67
 
208
68
  ```bash
209
- # Discover capabilities
210
- perp api-spec --json # Full command spec for agent discovery
211
- perp schema --json # CLI schema with error codes
212
-
213
- # Machine-readable trading
214
- perp --json -e hyperliquid market mid BTC
215
- perp --json trade buy ETH 0.1
216
- perp --json account positions
217
- ```
218
-
219
- All JSON responses follow a consistent envelope:
69
+ # Using npx (recommended)
70
+ npx skills add hypurrquant/perp-cli
220
71
 
221
- ```json
222
- {
223
- "ok": true,
224
- "data": { ... },
225
- "meta": { "timestamp": "2026-03-08T..." }
226
- }
72
+ # Or via Claude Code slash command
73
+ /install-skill hypurrquant/perp-cli
227
74
  ```
228
75
 
229
- Error responses:
230
-
231
- ```json
232
- {
233
- "ok": false,
234
- "error": { "code": "INSUFFICIENT_BALANCE", "message": "...", "retryable": false },
235
- "meta": { "timestamp": "..." }
236
- }
237
- ```
76
+ See [`skills/perp-cli/SKILL.md`](skills/perp-cli/SKILL.md) for the full agent guide.
238
77
 
239
- ### Composite Plans
78
+ ## Agent-First CLI Design
240
79
 
241
- Multi-step atomic operations for agents:
80
+ Built following [agent-first CLI principles](https://justin.poehnelt.com/posts/rewrite-your-cli-for-ai-agents/):
242
81
 
243
82
  ```bash
244
- perp plan example # Example plan JSON
245
- perp plan validate plan.json # Validate a plan
246
- perp plan execute plan.json # Execute multi-step plan
247
- ```
83
+ # Every command returns structured JSON envelope
84
+ perp --json portfolio
85
+ # → { "ok": true, "data": {...}, "meta": { "timestamp": "..." } }
248
86
 
249
- ## Configuration
87
+ # Runtime schema introspection (don't guess commands — query this)
88
+ perp --json agent schema
250
89
 
251
- ### Wallet Management (Recommended)
90
+ # Filter output to save tokens
91
+ perp --json --fields totalEquity,risk portfolio
252
92
 
253
- ```bash
254
- perp init # Interactive setup wizard
255
- perp wallet generate evm # Generate new EVM wallet
256
- perp wallet import solana <key> # Import existing key
257
- perp wallet use <name> # Auto-bind to exchanges by type
258
- perp wallet list # List all wallets
259
- perp wallet balance # On-chain balances
260
- ```
261
-
262
- Wallets are stored in `~/.perp/wallets.json` (file permission 0600).
93
+ # Stream large lists as NDJSON (one JSON per line)
94
+ perp --json --ndjson -e hl market list
263
95
 
264
- ### Environment Variables
96
+ # Pre-validate before executing
97
+ perp --json -e hl trade check BTC buy 0.01
98
+ perp --json --dry-run -e hl trade market BTC buy 0.01
265
99
 
266
- ```bash
267
- # Exchange keys (alternative to wallet management)
268
- PRIVATE_KEY= # Default key (Solana base58 or EVM hex)
269
- PACIFICA_PRIVATE_KEY= # Pacifica (Solana)
270
- HL_PRIVATE_KEY= # Hyperliquid (EVM)
271
- LIGHTER_PRIVATE_KEY= # Lighter (EVM)
272
- LIGHTER_API_KEY= # Lighter API key (40-byte)
100
+ # Idempotent orders with client ID
101
+ perp --json -e hl trade market BTC buy 0.01 --client-id my-unique-id
273
102
  ```
274
103
 
275
- Key resolution order: `--private-key` flag → exchange-specific env var → `PRIVATE_KEY` → wallet store → `~/.perp/<exchange>.key`
276
-
277
- ### Settings
278
-
279
- ```bash
280
- perp settings show # Current settings
281
- perp settings set default-exchange hyperliquid # Set default exchange
282
- perp settings referrals on # Enable referral codes
283
- perp settings set referralCodes.hyperliquid MYCODE
284
- ```
285
-
286
- ## Tech Stack
287
-
288
- - **TypeScript** + Node.js (ESM)
289
- - **Solana**: `@solana/web3.js`, `tweetnacl`, `bs58`
290
- - **EVM**: `ethers` v6
291
- - **Exchanges**: `hyperliquid` SDK, `lighter-ts-sdk` (WASM), `@pacifica/sdk`
292
- - **Bridge**: Circle CCTP V2 (Solana ↔ EVM), deBridge DLN (fallback)
293
- - **Testing**: Vitest (860+ tests)
104
+ All responses are auto-sanitized (control chars stripped, prompt injection patterns blocked).
105
+ Errors include `retryable` flag — only retry when `true`.
294
106
 
295
107
  ## License
296
108
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, effectiveLimit, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
2
+ import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, effectiveLimit } from "../risk.js";
3
3
  const defaultLimits = {
4
4
  maxDrawdownUsd: 500,
5
5
  maxPositionUsd: 5000,
@@ -186,13 +186,13 @@ describe("getLiquidationDistances", () => {
186
186
  it("should assign correct status based on limits", () => {
187
187
  const limits = { ...defaultLimits, minLiquidationDistance: 30 };
188
188
  const positions = [
189
- { exchange: "a", position: makePosition("A", "long", 1, 100, 2, 0, "85") }, // 15% → critical (< 20% hard cap)
189
+ { exchange: "a", position: makePosition("A", "long", 1, 100, 2, 0, "85") }, // 15% → danger (< 30% user limit)
190
190
  { exchange: "b", position: makePosition("B", "long", 1, 100, 2, 0, "78") }, // 22% → danger (< 30% user limit)
191
191
  { exchange: "c", position: makePosition("C", "long", 1, 100, 2, 0, "65") }, // 35% → warning (< 30% * 1.5 = 45%)
192
192
  { exchange: "d", position: makePosition("D", "long", 1, 100, 2, 0, "40") }, // 60% → safe
193
193
  ];
194
194
  const distances = getLiquidationDistances(positions, limits);
195
- expect(distances.find(d => d.symbol === "A").status).toBe("critical");
195
+ expect(distances.find(d => d.symbol === "A").status).toBe("danger");
196
196
  expect(distances.find(d => d.symbol === "B").status).toBe("danger");
197
197
  expect(distances.find(d => d.symbol === "C").status).toBe("warning");
198
198
  expect(distances.find(d => d.symbol === "D").status).toBe("safe");
@@ -209,30 +209,18 @@ describe("getLiquidationDistances", () => {
209
209
  });
210
210
  });
211
211
  describe("Liquidation Distance in assessRisk", () => {
212
- it("should add critical violation when position is below hard cap (20%)", () => {
212
+ it("should add violation when position is below user limit", () => {
213
213
  const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
214
- // BTC long at $100000, liq at $90000 → 10% distance (below 20% hard cap)
214
+ // BTC long at $100000, liq at $90000 → 10% distance (below 30% user limit)
215
215
  const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 10, 0, "90000") }];
216
216
  const result = assessRisk(balances, positions, defaultLimits);
217
- expect(result.violations.some(v => v.rule === "liquidation_distance_hard_cap")).toBe(true);
218
- expect(result.level).toBe("critical");
219
- expect(result.canTrade).toBe(false);
220
- });
221
- it("should add high violation when below user limit but above hard cap", () => {
222
- const limits = { ...defaultLimits, minLiquidationDistance: 30 };
223
- const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
224
- // BTC long at $100000, liq at $75000 → 25% distance (above 20% hard cap, below 30% user limit)
225
- const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 4, 0, "75000") }];
226
- const result = assessRisk(balances, positions, limits);
227
217
  expect(result.violations.some(v => v.rule === "min_liquidation_distance")).toBe(true);
228
- expect(result.violations.some(v => v.rule === "liquidation_distance_hard_cap")).toBe(false);
229
218
  });
230
219
  it("should not add violation when distance is above user limit", () => {
231
220
  const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
232
221
  // BTC long at $100000, liq at $50000 → 50% distance (safe)
233
222
  const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 2, 0, "50000") }];
234
223
  const result = assessRisk(balances, positions, defaultLimits);
235
- expect(result.violations.some(v => v.rule === "liquidation_distance_hard_cap")).toBe(false);
236
224
  expect(result.violations.some(v => v.rule === "min_liquidation_distance")).toBe(false);
237
225
  });
238
226
  it("should report minLiquidationDistancePct in metrics", () => {
@@ -259,11 +247,6 @@ describe("Liquidation Distance in assessRisk", () => {
259
247
  expect(result.liquidationDistances[0].status).toBe("safe");
260
248
  });
261
249
  });
262
- describe("LIQUIDATION_DISTANCE_HARD_CAP", () => {
263
- it("should be 20", () => {
264
- expect(LIQUIDATION_DISTANCE_HARD_CAP).toBe(20);
265
- });
266
- });
267
250
  describe("Percentage-based Risk Limits", () => {
268
251
  it("effectiveLimit should return min of USD and pct-of-equity", () => {
269
252
  expect(effectiveLimit(500, 10, 1000)).toBe(100); // 10% of $1000 = $100 < $500
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { formatUsd, printJson, jsonOk } from "../utils.js";
3
+ import { computeExecutableSize } from "../liquidity.js";
3
4
  import { computeAnnualSpread, toHourlyRate } from "../funding.js";
4
5
  import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
5
6
  import { scanDexArb } from "../dex-asset-map.js";
@@ -729,6 +730,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
729
730
  .command("scan")
730
731
  .description("Scan current funding rate spreads")
731
732
  .option("--min <pct>", "Min annual spread to show", "10")
733
+ .option("--top <n>", "Return only top N results (for JSON output)")
732
734
  .option("--hold-days <days>", "Expected hold period for cost calc", "7")
733
735
  .option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
734
736
  .option("--size <usd>", "Position size per leg ($) for cost calc", "100")
@@ -742,12 +744,14 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
742
744
  const spreads = await fetchFundingSpreads();
743
745
  const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
744
746
  if (isJson()) {
745
- const enriched = filtered.map(s => {
747
+ let enriched = filtered.map(s => {
746
748
  const grossSpread = Math.abs(s.spread);
747
749
  const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
748
750
  const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
749
- return { ...s, grossSpread, netSpread: net, estFeesPct: rtCost };
750
- });
751
+ return { symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost };
752
+ }).sort((a, b) => b.netSpread - a.netSpread);
753
+ if (opts.top)
754
+ enriched = enriched.slice(0, parseInt(opts.top));
751
755
  return printJson(jsonOk(enriched));
752
756
  }
753
757
  if (filtered.length === 0) {
@@ -783,6 +787,221 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
783
787
  console.log(chalk.gray(` * Spreads are estimates based on current rates — actual may vary`));
784
788
  console.log(chalk.gray(` Use 'perp arb auto --min-spread ${minSpread}' to auto-trade\n`));
785
789
  });
790
+ // ── arb exec ── (validate orderbook + simultaneous dual-leg entry)
791
+ arb
792
+ .command("exec")
793
+ .description("Execute arb: validate orderbook depth on both exchanges, then enter both legs simultaneously")
794
+ .argument("<symbol>", "Symbol (e.g. BTC, ETH, ICP)")
795
+ .argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter)")
796
+ .argument("<shortExch>", "Exchange to go SHORT on (hl, pac, lighter)")
797
+ .argument("<sizeUsd>", "Position size per leg in USD")
798
+ .option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
799
+ .option("--leverage <n>", "Set leverage before entry (both exchanges)")
800
+ .option("--isolated", "Use isolated margin mode")
801
+ .action(async (symbol, longExch, shortExch, sizeUsdStr, opts) => {
802
+ const sym = symbol.toUpperCase();
803
+ const sizeUsd = parseFloat(sizeUsdStr);
804
+ const maxSlippage = parseFloat(opts.maxSlippage);
805
+ // Resolve exchange aliases
806
+ const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
807
+ longExch = aliasMap[longExch.toLowerCase()] || longExch.toLowerCase();
808
+ shortExch = aliasMap[shortExch.toLowerCase()] || shortExch.toLowerCase();
809
+ if (longExch === shortExch) {
810
+ if (isJson())
811
+ return printJson(jsonOk({ error: "longExch and shortExch must be different" }));
812
+ console.log(chalk.red(" Long and short exchange must be different."));
813
+ return;
814
+ }
815
+ const longAdapter = await getAdapterForExchange(longExch);
816
+ const shortAdapter = await getAdapterForExchange(shortExch);
817
+ // 1. Set leverage if requested
818
+ if (opts.leverage) {
819
+ const lev = parseInt(opts.leverage);
820
+ const mode = opts.isolated ? "isolated" : "cross";
821
+ if (!isJson())
822
+ console.log(chalk.gray(` Setting leverage ${lev}x ${mode} on both exchanges...`));
823
+ const [longLev, shortLev] = await Promise.allSettled([
824
+ longAdapter.setLeverage(sym, lev, mode),
825
+ shortAdapter.setLeverage(sym, lev, mode),
826
+ ]);
827
+ if (longLev.status === "rejected") {
828
+ const msg = `Failed to set leverage on ${longExch}: ${longLev.reason instanceof Error ? longLev.reason.message : longLev.reason}`;
829
+ if (isJson())
830
+ return printJson(jsonOk({ error: msg }));
831
+ console.log(chalk.red(` ${msg}`));
832
+ return;
833
+ }
834
+ if (shortLev.status === "rejected") {
835
+ const msg = `Failed to set leverage on ${shortExch}: ${shortLev.reason instanceof Error ? shortLev.reason.message : shortLev.reason}`;
836
+ if (isJson())
837
+ return printJson(jsonOk({ error: msg }));
838
+ console.log(chalk.red(` ${msg}`));
839
+ return;
840
+ }
841
+ }
842
+ // 2. Fetch orderbooks simultaneously
843
+ if (!isJson())
844
+ console.log(chalk.gray(` Checking orderbook depth on both exchanges...`));
845
+ const [longBook, shortBook] = await Promise.all([
846
+ longAdapter.getOrderbook(sym),
847
+ shortAdapter.getOrderbook(sym),
848
+ ]);
849
+ // 3. Validate: long side buys from asks, short side sells into bids
850
+ const longCheck = computeExecutableSize(longBook.asks, sizeUsd, maxSlippage);
851
+ const shortCheck = computeExecutableSize(shortBook.bids, sizeUsd, maxSlippage);
852
+ const validation = {
853
+ long: { exchange: longExch, side: "buy", depthUsd: longCheck.depthUsd, canFill: longCheck.canFillFull, slippagePct: longCheck.slippagePct, maxSizeBase: longCheck.maxSize },
854
+ short: { exchange: shortExch, side: "sell", depthUsd: shortCheck.depthUsd, canFill: shortCheck.canFillFull, slippagePct: shortCheck.slippagePct, maxSizeBase: shortCheck.maxSize },
855
+ };
856
+ if (!longCheck.canFillFull || !shortCheck.canFillFull) {
857
+ // Use the smaller fillable amount
858
+ const fillableUsd = Math.min(longCheck.maxSize * longCheck.avgFillPrice, shortCheck.maxSize * shortCheck.avgFillPrice);
859
+ const result = {
860
+ error: "Insufficient orderbook depth",
861
+ requestedUsd: sizeUsd,
862
+ fillableUsd: Math.round(fillableUsd * 100) / 100,
863
+ validation,
864
+ };
865
+ if (isJson())
866
+ return printJson(jsonOk(result));
867
+ console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
868
+ console.log(chalk.gray(` ${longExch} asks: $${longCheck.depthUsd.toFixed(0)} depth, ${longCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
869
+ console.log(chalk.gray(` ${shortExch} bids: $${shortCheck.depthUsd.toFixed(0)} depth, ${shortCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
870
+ console.log(chalk.yellow(` Max fillable: $${fillableUsd.toFixed(2)}\n`));
871
+ return;
872
+ }
873
+ // 4. Compute matched size in base asset (use the smaller side)
874
+ // Round down to each exchange's lot size (inferred from orderbook)
875
+ const inferDecimals = (levels) => {
876
+ for (const [, s] of levels) {
877
+ const dot = s.indexOf(".");
878
+ if (dot >= 0)
879
+ return s.length - dot - 1;
880
+ }
881
+ return 0;
882
+ };
883
+ const longDecimals = inferDecimals(longBook.asks);
884
+ const shortDecimals = inferDecimals(shortBook.bids);
885
+ const decimals = Math.min(longDecimals, shortDecimals);
886
+ const matchedBase = Math.min(longCheck.recommendedSize, shortCheck.recommendedSize);
887
+ const matchedSize = (Math.floor(matchedBase * 10 ** decimals) / 10 ** decimals).toFixed(decimals);
888
+ const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
889
+ const matchedUsd = matchedBase * avgPrice;
890
+ if (!isJson()) {
891
+ console.log(chalk.cyan(`\n Arb Exec: ${sym}`));
892
+ console.log(chalk.gray(` LONG ${longExch}: buy ${matchedSize} (~$${(matchedBase * longCheck.avgFillPrice).toFixed(2)}, slippage ${longCheck.slippagePct.toFixed(3)}%)`));
893
+ console.log(chalk.gray(` SHORT ${shortExch}: sell ${matchedSize} (~$${(matchedBase * shortCheck.avgFillPrice).toFixed(2)}, slippage ${shortCheck.slippagePct.toFixed(3)}%)`));
894
+ console.log(chalk.gray(` Executing both legs simultaneously...\n`));
895
+ }
896
+ // 5. Execute both legs simultaneously
897
+ const [longResult, shortResult] = await Promise.allSettled([
898
+ longAdapter.marketOrder(sym, "buy", matchedSize),
899
+ shortAdapter.marketOrder(sym, "sell", matchedSize),
900
+ ]);
901
+ const longOk = longResult.status === "fulfilled";
902
+ const shortOk = shortResult.status === "fulfilled";
903
+ if (longOk && shortOk) {
904
+ // 6. Verify both positions actually exist (some exchanges return success but silently reject)
905
+ const [longPositions, shortPositions] = await Promise.all([
906
+ longAdapter.getPositions().catch(() => []),
907
+ shortAdapter.getPositions().catch(() => []),
908
+ ]);
909
+ const longPos = longPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
910
+ const shortPos = shortPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
911
+ if (!longPos && !shortPos) {
912
+ // Both silently rejected
913
+ const result = { status: "both_rejected", symbol: sym, size: matchedSize, message: "Both exchanges accepted order but no positions opened. Check min order size." };
914
+ if (isJson())
915
+ return printJson(jsonOk(result));
916
+ console.log(chalk.red(` Both orders accepted but no positions opened. Min order size not met?\n`));
917
+ return;
918
+ }
919
+ if (!longPos || !shortPos) {
920
+ // One side silently rejected — close the other
921
+ const openSide = longPos ? "long" : "short";
922
+ const openAdapter = longPos ? longAdapter : shortAdapter;
923
+ if (!isJson())
924
+ console.log(chalk.yellow(` ${openSide} opened but other side silently rejected — closing ${openSide}...`));
925
+ try {
926
+ await openAdapter.marketOrder(sym, longPos ? "sell" : "buy", matchedSize);
927
+ }
928
+ catch { /* best effort */ }
929
+ const result = { status: "silent_reject", symbol: sym, openedSide: openSide, closedSide: longPos ? "short" : "long", message: "One leg silently rejected (likely min order size). Rolled back." };
930
+ if (isJson())
931
+ return printJson(jsonOk(result));
932
+ console.log(chalk.yellow(` Rolled back ${openSide} leg. Check exchange min order sizes.\n`));
933
+ return;
934
+ }
935
+ logExecution({
936
+ type: "arb_entry", exchange: `${longExch}+${shortExch}`,
937
+ symbol: sym, side: "entry", size: matchedSize,
938
+ status: "success", dryRun: false,
939
+ meta: { longExch, shortExch, matchedUsd, longSlippage: longCheck.slippagePct, shortSlippage: shortCheck.slippagePct },
940
+ });
941
+ const result = {
942
+ status: "filled", symbol: sym, size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
943
+ long: { exchange: longExch, side: "buy", size: longPos.size, slippagePct: longCheck.slippagePct },
944
+ short: { exchange: shortExch, side: "sell", size: shortPos.size, slippagePct: shortCheck.slippagePct },
945
+ };
946
+ if (isJson())
947
+ return printJson(jsonOk(result));
948
+ console.log(chalk.green(` FILLED: ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
949
+ console.log(chalk.green(` LONG ${longExch} ✓ (${longPos.size})`));
950
+ console.log(chalk.green(` SHORT ${shortExch} ✓ (${shortPos.size})\n`));
951
+ }
952
+ else if (longOk !== shortOk) {
953
+ // One leg failed — rollback
954
+ const filledSide = longOk ? "long" : "short";
955
+ const failedSide = longOk ? "short" : "long";
956
+ const filledAdapter = longOk ? longAdapter : shortAdapter;
957
+ const rollbackAction = longOk ? "sell" : "buy";
958
+ const failedErr = longOk
959
+ ? shortResult.reason
960
+ : longResult.reason;
961
+ if (!isJson())
962
+ console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
963
+ let rollbackOk = false;
964
+ for (let attempt = 1; attempt <= 2; attempt++) {
965
+ try {
966
+ await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
967
+ rollbackOk = true;
968
+ break;
969
+ }
970
+ catch { /* retry */ }
971
+ }
972
+ logExecution({
973
+ type: "arb_entry", exchange: `${longExch}+${shortExch}`,
974
+ symbol: sym, side: "entry", size: matchedSize,
975
+ status: "failed", dryRun: false,
976
+ error: `Partial: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Rollback: ${rollbackOk ? "ok" : "FAILED"}`,
977
+ });
978
+ const result = {
979
+ status: "partial_fill", symbol: sym, filledSide, failedSide,
980
+ failedError: failedErr instanceof Error ? failedErr.message : String(failedErr),
981
+ rollback: rollbackOk ? "success" : "FAILED — MANUAL CLOSE REQUIRED",
982
+ };
983
+ if (isJson())
984
+ return printJson(jsonOk(result));
985
+ console.log(rollbackOk
986
+ ? chalk.yellow(` Rollback OK — no open exposure.\n`)
987
+ : chalk.red.bold(` ROLLBACK FAILED — ${filledSide} leg still open. Close manually!\n`));
988
+ }
989
+ else {
990
+ // Both failed
991
+ const longErr = longResult.reason;
992
+ const shortErr = shortResult.reason;
993
+ const result = {
994
+ status: "both_failed", symbol: sym,
995
+ longError: longErr instanceof Error ? longErr.message : String(longErr),
996
+ shortError: shortErr instanceof Error ? shortErr.message : String(shortErr),
997
+ };
998
+ if (isJson())
999
+ return printJson(jsonOk(result));
1000
+ console.log(chalk.red(` Both legs failed:`));
1001
+ console.log(chalk.red(` LONG: ${result.longError}`));
1002
+ console.log(chalk.red(` SHORT: ${result.shortError}\n`));
1003
+ }
1004
+ });
786
1005
  // ── arb pnl ── (check arb position PnL)
787
1006
  arb
788
1007
  .command("pnl")
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { printJson, jsonOk, makeTable, formatUsd, formatPnl, withJsonErrors } from "../utils.js";
3
+ import { assessRisk } from "../risk.js";
3
4
  const EXCHANGES = ["pacifica", "hyperliquid", "lighter"];
4
5
  async function fetchExchangeSnapshot(name, getAdapter) {
5
6
  try {
@@ -65,6 +66,12 @@ function buildSummary(snapshots) {
65
66
  pct: totalEquity > 0 ? (Number(s.balance.equity) / totalEquity) * 100 : 0,
66
67
  }))
67
68
  .sort((a, b) => b.pct - a.pct);
69
+ // Full risk assessment (reuses same data, no extra API calls)
70
+ const riskBalances = snapshots
71
+ .filter(s => s.balance)
72
+ .map(s => ({ exchange: s.exchange, balance: s.balance }));
73
+ const riskPositions = allPositions.map(p => ({ exchange: p.exchange, position: p }));
74
+ const assessment = assessRisk(riskBalances, riskPositions);
68
75
  return {
69
76
  totalEquity,
70
77
  totalAvailable,
@@ -79,6 +86,11 @@ function buildSummary(snapshots) {
79
86
  largestPosition,
80
87
  exchangeConcentration,
81
88
  },
89
+ risk: {
90
+ level: assessment.level,
91
+ canTrade: assessment.canTrade,
92
+ violations: assessment.violations,
93
+ },
82
94
  };
83
95
  }
84
96
  export function registerPortfolioCommands(program, getAdapterForExchange, isJson) {
@@ -149,6 +161,14 @@ export function registerPortfolioCommands(program, getAdapterForExchange, isJson
149
161
  }
150
162
  // ── Risk Metrics ──
151
163
  console.log(chalk.white.bold("\n Risk Metrics"));
164
+ const levelColor = {
165
+ low: chalk.green,
166
+ medium: chalk.yellow,
167
+ high: chalk.red,
168
+ critical: chalk.bgRed.white,
169
+ }[summary.risk.level];
170
+ console.log(` Risk Level: ${levelColor(summary.risk.level.toUpperCase())}`);
171
+ console.log(` Can Trade: ${summary.risk.canTrade ? chalk.green("YES") : chalk.red("NO")}`);
152
172
  const mu = summary.riskMetrics.marginUtilization;
153
173
  const muColor = mu < 30 ? chalk.green : mu < 60 ? chalk.yellow : chalk.red;
154
174
  console.log(` Margin Utilization: ${muColor(`${mu.toFixed(1)}%`)}`);
@@ -163,6 +183,13 @@ export function registerPortfolioCommands(program, getAdapterForExchange, isJson
163
183
  console.log(` ${ec.exchange.padEnd(12)} ${bar} ${ec.pct.toFixed(1)}%`);
164
184
  }
165
185
  }
186
+ if (summary.risk.violations.length > 0) {
187
+ console.log(chalk.red.bold("\n Risk Violations"));
188
+ for (const v of summary.risk.violations) {
189
+ const sevColor = { low: chalk.green, medium: chalk.yellow, high: chalk.red, critical: chalk.bgRed.white }[v.severity];
190
+ console.log(` ${sevColor(v.severity.toUpperCase().padEnd(8))} ${v.message}`);
191
+ }
192
+ }
166
193
  console.log();
167
194
  });
168
195
  });
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { printJson, jsonOk, makeTable, formatUsd, withJsonErrors } from "../utils.js";
3
- import { loadRiskLimits, saveRiskLimits, assessRisk, getLiquidationDistances, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
3
+ import { loadRiskLimits, saveRiskLimits, assessRisk, getLiquidationDistances } from "../risk.js";
4
4
  const EXCHANGES = ["pacifica", "hyperliquid", "lighter"];
5
5
  export function registerRiskCommands(program, getAdapterForExchange, isJson) {
6
6
  const risk = program.command("risk").description("Risk management and guardrails");
@@ -57,11 +57,9 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
57
57
  console.log(` Largest Position: $${formatUsd(assessment.metrics.largestPositionUsd)}`);
58
58
  console.log(` Max Leverage Used: ${assessment.metrics.maxLeverageUsed}x`);
59
59
  if (assessment.metrics.minLiquidationDistancePct >= 0) {
60
- const ldColor = assessment.metrics.minLiquidationDistancePct < LIQUIDATION_DISTANCE_HARD_CAP
61
- ? chalk.bgRed.white
62
- : assessment.metrics.minLiquidationDistancePct < assessment.limits.minLiquidationDistance
63
- ? chalk.red
64
- : chalk.green;
60
+ const ldColor = assessment.metrics.minLiquidationDistancePct < assessment.limits.minLiquidationDistance
61
+ ? chalk.red
62
+ : chalk.green;
65
63
  console.log(` Min Liq Distance: ${ldColor(`${assessment.metrics.minLiquidationDistancePct.toFixed(1)}%`)}`);
66
64
  }
67
65
  if (assessment.violations.length > 0) {
@@ -101,7 +99,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
101
99
  .option("--max-positions <n>", "Max number of simultaneous positions")
102
100
  .option("--max-leverage <n>", "Max leverage per position")
103
101
  .option("--max-margin <pct>", "Max margin utilization %")
104
- .option("--min-liq-distance <pct>", `Min liquidation distance % (hard cap: >=${LIQUIDATION_DISTANCE_HARD_CAP}%)`)
102
+ .option("--min-liq-distance <pct>", "Min liquidation distance %")
105
103
  .option("--reset", "Reset all limits to defaults")
106
104
  .action(async (opts) => {
107
105
  let limits = loadRiskLimits();
@@ -111,9 +109,9 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
111
109
  opts.minLiqDistance || opts.reset;
112
110
  if (opts.reset) {
113
111
  limits = {
114
- maxDrawdownUsd: 500, maxPositionUsd: 5000, maxTotalExposureUsd: 20000,
115
- dailyLossLimitUsd: 200, maxPositions: 10, maxLeverage: 20, maxMarginUtilization: 80,
116
- minLiquidationDistance: 30, maxDrawdownPct: 10, maxPositionPct: 25,
112
+ maxDrawdownUsd: 100000, maxPositionUsd: 100000, maxTotalExposureUsd: 500000,
113
+ dailyLossLimitUsd: 50000, maxPositions: 50, maxLeverage: 50, maxMarginUtilization: 95,
114
+ minLiquidationDistance: 5,
117
115
  };
118
116
  }
119
117
  if (opts.maxDrawdown)
@@ -139,15 +137,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
139
137
  if (opts.maxMargin)
140
138
  limits.maxMarginUtilization = parseFloat(opts.maxMargin);
141
139
  if (opts.minLiqDistance) {
142
- const val = parseFloat(opts.minLiqDistance);
143
- if (val < LIQUIDATION_DISTANCE_HARD_CAP) {
144
- const msg = `Cannot set min liquidation distance below ${LIQUIDATION_DISTANCE_HARD_CAP}% (hard cap). Got: ${val}%`;
145
- if (isJson())
146
- return printJson(jsonOk({ error: msg, hardCap: LIQUIDATION_DISTANCE_HARD_CAP }));
147
- console.log(chalk.red(`\n ${msg}\n`));
148
- return;
149
- }
150
- limits.minLiquidationDistance = val;
140
+ limits.minLiquidationDistance = parseFloat(opts.minLiqDistance);
151
141
  }
152
142
  if (hasUpdate)
153
143
  saveRiskLimits(limits);
@@ -167,7 +157,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
167
157
  console.log(` Max Positions: ${limits.maxPositions}`);
168
158
  console.log(` Max Leverage: ${limits.maxLeverage}x`);
169
159
  console.log(` Max Margin Util: ${limits.maxMarginUtilization}%`);
170
- console.log(` Min Liq Distance: ${limits.minLiquidationDistance}% ${chalk.gray(`(hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`)}`);
160
+ console.log(` Min Liq Distance: ${limits.minLiquidationDistance}%`);
171
161
  console.log(chalk.gray(`\n When both USD and % are set, the stricter limit applies.\n`));
172
162
  console.log(chalk.gray(` Config file: ~/.perp/risk.json\n`));
173
163
  });
@@ -248,12 +238,11 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
248
238
  positions: distances,
249
239
  limits: {
250
240
  minLiquidationDistance: limits.minLiquidationDistance,
251
- hardCap: LIQUIDATION_DISTANCE_HARD_CAP,
252
241
  },
253
242
  }));
254
243
  }
255
244
  console.log(chalk.cyan.bold("\n Liquidation Distance Report\n"));
256
- console.log(chalk.gray(` Your limit: ${limits.minLiquidationDistance}% | Hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%\n`));
245
+ console.log(chalk.gray(` Your limit: ${limits.minLiquidationDistance}%\n`));
257
246
  const rows = distances.map(d => {
258
247
  const statusColor = {
259
248
  safe: chalk.green,
@@ -276,7 +265,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
276
265
  const critical = distances.filter(d => d.status === "critical");
277
266
  const danger = distances.filter(d => d.status === "danger");
278
267
  if (critical.length > 0) {
279
- console.log(chalk.bgRed.white.bold(` ⚠ ${critical.length} position(s) BELOW HARD CAP (${LIQUIDATION_DISTANCE_HARD_CAP}%) — REDUCE IMMEDIATELY`));
268
+ console.log(chalk.bgRed.white.bold(` ⚠ ${critical.length} position(s) critically close to liquidation — REDUCE IMMEDIATELY`));
280
269
  }
281
270
  if (danger.length > 0) {
282
271
  console.log(chalk.red.bold(` ⚠ ${danger.length} position(s) below your limit (${limits.minLiquidationDistance}%) — action recommended`));
package/dist/index.js CHANGED
@@ -56,6 +56,8 @@ program
56
56
  .option("-n, --network <network>", "Network: mainnet or testnet", "mainnet")
57
57
  .option("-k, --private-key <key>", "Private key")
58
58
  .option("--json", "Output raw JSON (for piping)")
59
+ .option("--fields <fields>", "Comma-separated fields to include in JSON output (e.g. totalEquity,positions)")
60
+ .option("--ndjson", "Output newline-delimited JSON (one object per line for streaming)")
59
61
  .option("--dry-run", "Simulate trades without executing (log as simulated)")
60
62
  .option("--dex <name>", "HIP-3 deployed perp dex name (Hyperliquid only)")
61
63
  .configureOutput({
package/dist/risk.d.ts CHANGED
@@ -15,8 +15,6 @@ export interface RiskLimits {
15
15
  }
16
16
  /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
17
17
  export declare function effectiveLimit(usdLimit: number, pctLimit: number | undefined, totalEquity: number): number;
18
- /** Hard cap: liquidation distance can NEVER be set below this % */
19
- export declare const LIQUIDATION_DISTANCE_HARD_CAP = 20;
20
18
  export declare function loadRiskLimits(): RiskLimits;
21
19
  export declare function saveRiskLimits(limits: RiskLimits): void;
22
20
  export type RiskLevel = "low" | "medium" | "high" | "critical";
package/dist/risk.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
2
  import { resolve } from "path";
3
3
  const DEFAULT_LIMITS = {
4
- maxDrawdownUsd: 500,
5
- maxPositionUsd: 5000,
6
- maxTotalExposureUsd: 20000,
7
- dailyLossLimitUsd: 200,
8
- maxPositions: 10,
9
- maxLeverage: 20,
10
- maxMarginUtilization: 80,
11
- minLiquidationDistance: 30,
12
- maxDrawdownPct: 10,
13
- maxPositionPct: 25,
4
+ maxDrawdownUsd: 100000,
5
+ maxPositionUsd: 100000,
6
+ maxTotalExposureUsd: 500000,
7
+ dailyLossLimitUsd: 50000,
8
+ maxPositions: 50,
9
+ maxLeverage: 50,
10
+ maxMarginUtilization: 95,
11
+ minLiquidationDistance: 5,
12
+ maxDrawdownPct: undefined,
13
+ maxPositionPct: undefined,
14
14
  };
15
15
  /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
16
16
  export function effectiveLimit(usdLimit, pctLimit, totalEquity) {
@@ -19,8 +19,6 @@ export function effectiveLimit(usdLimit, pctLimit, totalEquity) {
19
19
  const pctInUsd = (pctLimit / 100) * totalEquity;
20
20
  return Math.min(usdLimit, pctInUsd);
21
21
  }
22
- /** Hard cap: liquidation distance can NEVER be set below this % */
23
- export const LIQUIDATION_DISTANCE_HARD_CAP = 20;
24
22
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
25
23
  const RISK_FILE = resolve(PERP_DIR, "risk.json");
26
24
  export function loadRiskLimits() {
@@ -61,9 +59,7 @@ export function getLiquidationDistances(positions, limits) {
61
59
  const liquidationPrice = Number(p.liquidationPrice);
62
60
  const distancePct = calcLiquidationDistance(markPrice, liquidationPrice, p.side);
63
61
  let status = "safe";
64
- if (distancePct < LIQUIDATION_DISTANCE_HARD_CAP)
65
- status = "critical";
66
- else if (distancePct < lim.minLiquidationDistance)
62
+ if (distancePct < lim.minLiquidationDistance)
67
63
  status = "danger";
68
64
  else if (distancePct < lim.minLiquidationDistance * 1.5)
69
65
  status = "warning";
@@ -162,16 +158,7 @@ export function assessRisk(balances, positions, limits) {
162
158
  if (ld.distancePct < minLiquidationDistancePct) {
163
159
  minLiquidationDistancePct = ld.distancePct;
164
160
  }
165
- if (ld.distancePct < LIQUIDATION_DISTANCE_HARD_CAP) {
166
- violations.push({
167
- rule: "liquidation_distance_hard_cap",
168
- severity: "critical",
169
- message: `${ld.exchange}:${ld.symbol} is ${ld.distancePct.toFixed(1)}% from liquidation (hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`,
170
- current: ld.distancePct,
171
- limit: LIQUIDATION_DISTANCE_HARD_CAP,
172
- });
173
- }
174
- else if (ld.distancePct < lim.minLiquidationDistance) {
161
+ if (ld.distancePct < lim.minLiquidationDistance) {
175
162
  violations.push({
176
163
  rule: "min_liquidation_distance",
177
164
  severity: "high",
package/dist/utils.js CHANGED
@@ -51,8 +51,78 @@ export function makeTable(head, rows) {
51
51
  rows.forEach((r) => table.push(r));
52
52
  return table.toString();
53
53
  }
54
+ /** Pick only specified keys from an object (shallow). Supports nested paths via dot notation. */
55
+ function pickFields(obj, fields) {
56
+ if (obj == null || typeof obj !== "object")
57
+ return obj;
58
+ const result = {};
59
+ for (const field of fields) {
60
+ const parts = field.split(".");
61
+ let src = obj;
62
+ for (const p of parts) {
63
+ if (src == null || typeof src !== "object") {
64
+ src = undefined;
65
+ break;
66
+ }
67
+ src = src[p];
68
+ }
69
+ if (src !== undefined) {
70
+ result[field] = src;
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+ // ── Response Sanitization ──
76
+ // Strips control characters and potential prompt-injection patterns from all string values.
77
+ // Applied automatically to all JSON output to prevent agent confusion from external data.
78
+ const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
79
+ const PROMPT_INJECTION_RE = /(?:^|\n)\s*(?:SYSTEM:|ASSISTANT:|HUMAN:|<\/?(?:system|prompt|instruction|tool_use)>|ignore (?:previous|above|all) instructions?)/gi;
80
+ function sanitizeValue(val) {
81
+ if (typeof val === "string") {
82
+ let s = val.replace(CONTROL_CHAR_RE, "");
83
+ if (PROMPT_INJECTION_RE.test(s)) {
84
+ s = s.replace(PROMPT_INJECTION_RE, "[SANITIZED]");
85
+ }
86
+ return s;
87
+ }
88
+ if (Array.isArray(val))
89
+ return val.map(sanitizeValue);
90
+ if (val && typeof val === "object") {
91
+ const out = {};
92
+ for (const [k, v] of Object.entries(val)) {
93
+ out[k] = sanitizeValue(v);
94
+ }
95
+ return out;
96
+ }
97
+ return val;
98
+ }
99
+ // ── JSON Output ──
100
+ const _isNdjson = () => process.argv.includes("--ndjson");
54
101
  export function printJson(data) {
55
- console.log(JSON.stringify(data, null, 2));
102
+ // Sanitize all string values in output
103
+ let output = sanitizeValue(data);
104
+ // Apply --fields filter
105
+ const fieldsArg = process.argv.indexOf("--fields");
106
+ if (fieldsArg !== -1 && process.argv[fieldsArg + 1] && output && typeof output === "object") {
107
+ const fields = process.argv[fieldsArg + 1].split(",").map(f => f.trim());
108
+ const envelope = output;
109
+ if (envelope.ok && envelope.data && typeof envelope.data === "object") {
110
+ envelope.data = pickFields(envelope.data, fields);
111
+ }
112
+ output = envelope;
113
+ }
114
+ // NDJSON mode: if data.data is an array, emit one JSON line per element
115
+ if (_isNdjson() && output && typeof output === "object") {
116
+ const envelope = output;
117
+ if (envelope.ok && Array.isArray(envelope.data)) {
118
+ const meta = envelope.meta;
119
+ for (const item of envelope.data) {
120
+ console.log(JSON.stringify({ ok: true, data: item, meta }));
121
+ }
122
+ return;
123
+ }
124
+ }
125
+ console.log(JSON.stringify(output, null, _isNdjson() ? undefined : 2));
56
126
  }
57
127
  export function errorAndExit(msg) {
58
128
  if (process.argv.includes("--json")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
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.14"
8
+ version: "0.3.17"
9
9
  ---
10
10
 
11
11
  # perp-cli Agent Guide
@@ -15,10 +15,11 @@ Multi-DEX perpetual futures CLI — Pacifica (Solana), Hyperliquid (HyperEVM), L
15
15
  ## Rules
16
16
 
17
17
  1. **Always use `--json`** on every command.
18
- 2. **NEVER use `perp init`** — interactive, will hang. Use `wallet set` instead.
19
- 3. **NEVER trade without user confirmation.** Show order details and wait for approval.
20
- 4. **NEVER read ~/.perp/.env or key files.** Use `perp --json wallet show`.
21
- 5. **Risk first.** Run `perp --json risk status` before and during any operation.
18
+ 2. **Always use `--dry-run`** before any mutating trade (then execute without it after user confirms).
19
+ 3. **Always use `--fields`** to reduce output when you only need specific data (saves tokens).
20
+ 4. **NEVER use `perp init`** — interactive, will hang.
21
+ 5. **NEVER trade without user confirmation.**
22
+ 6. **NEVER read ~/.perp/.env or key files.**
22
23
 
23
24
  ## Install
24
25
 
@@ -35,81 +36,115 @@ perp --json wallet set pac <SOLANA_KEY> # Pacifica
35
36
  perp --json wallet set lt <EVM_KEY> # Lighter (API key auto-generated)
36
37
  perp --json wallet show # verify
37
38
  ```
38
- Same EVM key works for both HL and Lighter. Lighter API key is created automatically on first use.
39
+ Same EVM key works for both HL and Lighter.
39
40
 
40
- ## Core Commands
41
+ ## Key Commands
41
42
 
42
43
  ```bash
43
- # Account
44
- perp --json -e <EX> account info # balance, equity, margin
45
- perp --json -e <EX> account positions # open positions
46
- perp --json portfolio # unified multi-exchange view
47
-
48
- # Market
49
- perp --json -e <EX> market list # available markets
50
- perp --json -e <EX> market mid <SYM> # mid price
51
- perp --json -e <EX> market book <SYM> # orderbook
52
-
53
- # Trading
54
- perp --json -e <EX> trade leverage <SYM> <N> --isolated # set BEFORE trading
44
+ # Status (ONE call = balances, positions, risk level/violations/canTrade)
45
+ perp --json portfolio
46
+
47
+ # Arb workflow
48
+ perp --json arb scan --min 5 # find opportunities
49
+ perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage <N> --isolated # execute
50
+
51
+ # Single exchange trading (when not doing arb)
55
52
  perp --json -e <EX> trade market <SYM> buy <SIZE>
56
- perp --json -e <EX> trade market <SYM> sell <SIZE>
57
53
  perp --json -e <EX> trade close <SYM>
58
- perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L>
59
54
 
60
- # Risk
61
- perp --json risk status # risk level + violations
62
- perp --json risk liquidation-distance # % from liq for all positions
63
- perp --json risk check --notional <$> --leverage <L>
55
+ # Risk (only if you need to set limits — portfolio already includes risk)
56
+ perp --json risk limits --max-leverage 5
57
+ ```
58
+
59
+ Exchange aliases: `hl`, `pac`, `lt`. Symbols auto-resolve (`BTC`, `SOL`, `ICP`).
60
+
61
+ ## Agent Tools
62
+
63
+ ```bash
64
+ # Discover all commands and parameters at runtime (don't guess — query this)
65
+ perp --json agent schema
66
+
67
+ # Pre-validate a trade before execution (checks balance, liquidity, risk limits)
68
+ perp --json -e <EX> trade check <SYM> <SIDE> <SIZE>
69
+
70
+ # Dry-run: simulate trade without executing
71
+ perp --json --dry-run -e <EX> trade market <SYM> buy <SIZE>
64
72
 
65
- # Arb
66
- perp --json arb scan --min 5 # find funding arb opportunities
73
+ # Filter output to specific fields (saves tokens)
74
+ perp --json --fields totalEquity,positions portfolio
75
+
76
+ # Stream large lists as NDJSON (one JSON per line, no buffering)
77
+ perp --json --ndjson -e <EX> market list
78
+
79
+ # Prevent duplicate orders with client ID
80
+ perp --json -e <EX> trade market <SYM> buy <SIZE> --client-id <UNIQUE_ID>
67
81
  ```
68
82
 
69
- Exchange aliases: `hl`/`hyperliquid`, `pac`/`pacifica`, `lt`/`lighter`. Symbols auto-resolve (use bare: `BTC`, `SOL`, `ICP`).
83
+ All string outputs are auto-sanitized (control chars stripped, prompt injection patterns blocked).
70
84
 
71
- ## Trade Execution Checklist
85
+ ## Arb Workflow
72
86
 
73
87
  ```
74
- 1. perp --json risk status → STOP if critical
75
- 2. perp --json -e <EX> account info → verify balance
76
- 3. perp --json -e <EX> trade leverage <SYM> <N> --isolated
77
- 4. perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L>
78
- 5. [Show details to user, get confirmation]
79
- 6. perp --json -e <EX> trade market <SYM> <SIDE> <SIZE>
80
- 7. perp --json -e <EX> account positions → verify + check liq price
88
+ 1. perp --json portfolio → check balances across all exchanges
89
+ 2. perp --json arb scan --min 5 → find opportunity (longExch, shortExch, netSpread)
90
+ 3. [Show opportunity to user, get confirmation]
91
+ 4. perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated
92
+ → validates orderbook depth on both sides
93
+ → rounds size to each exchange's lot size
94
+ → executes BOTH legs simultaneously
95
+ → verifies positions exist after execution
96
+ → auto-rollback if one leg fails
97
+ 5. perp --json portfolio → verify positions
81
98
  ```
82
99
 
83
100
  ## Position Sizing
84
101
 
85
- - Single position notional < **80%** of that exchange's available balance
86
- - Use ISOLATED margin for arb
87
- - Leverage 1-3x for arb, never exceed 5x
88
- - Both arb legs MUST have exact same size
102
+ - Single position < **80%** of that exchange's available balance
103
+ - Leverage 1-3x for arb, max 5x
104
+ - `arb exec` auto-matches both legs to the same size
105
+
106
+ ## Arb Direction (CRITICAL)
107
+
108
+ `arb scan` returns `longExch`, `shortExch`, `netSpread`.
109
+ ALWAYS follow exactly. NEVER reverse. NEVER enter if `netSpread <= 0`.
89
110
 
90
- ## Funding Arb Direction
111
+ ## Slippage Check (CRITICAL)
91
112
 
113
+ `arb scan` returns `netSpread` in bps (already fee-adjusted).
114
+ Before executing, compare against estimated slippage:
92
115
  ```
93
- arb scan returns: longExch, shortExch, netSpread
94
- → ALWAYS follow longExch/shortExch exactly. NEVER reverse.
95
- → NEVER enter if netSpread <= 0
116
+ Slippage ≈ 2-5 bps per leg for top pairs (BTC, ETH, SOL)
117
+ ≈ 5-15 bps per leg for low-liquidity (altcoins)
118
+ Total round-trip slippage = (entry + exit) × 2 legs
96
119
  ```
120
+ **NEVER enter if:** `netSpread < total round-trip slippage`
121
+ Example: netSpread=12 bps, estimated slippage=4 bps/leg × 4 = 16 bps → SKIP.
122
+
123
+ ## Portfolio Response
124
+
125
+ `portfolio` includes `risk: { level, canTrade, violations[] }` — no need for separate `risk status`.
126
+ Check `canTrade` before any order. If `false`, do NOT trade.
97
127
 
98
128
  ## Monitoring (while positions open)
99
129
 
100
- Every 15 min: `risk status` + `risk liquidation-distance` + `account positions`
101
- Every 1 hour: `arb scan --min 5` + `portfolio`
102
- Exit if: spread < breakeven, risk critical, one leg closed unexpectedly.
130
+ ```
131
+ Every 15 min: perp --json portfolio
132
+ Every 1 hour: perp --json arb scan --min 5
133
+ 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>
135
+ ```
103
136
 
104
137
  ## Error Handling
105
138
 
106
- All responses: `{ "ok": true, "data": {...} }` or `{ "ok": false, "error": { "code": "...", "retryable": true/false } }`
139
+ Responses: `{ "ok": true, "data": {...} }` or `{ "ok": false, "error": { "code": "...", "retryable": true/false } }`
107
140
 
108
- Retryable (wait 5s, retry once): `RATE_LIMITED`, `EXCHANGE_UNREACHABLE`, `TIMEOUT`
109
- Not retryable: `INSUFFICIENT_BALANCE`, `MARGIN_INSUFFICIENT`, `SYMBOL_NOT_FOUND`
141
+ - **Retryable** (wait 5s, max 3 retries): `RATE_LIMITED`, `EXCHANGE_UNREACHABLE`, `TIMEOUT`
142
+ - **Non-retryable**: `INSUFFICIENT_BALANCE`, `SYMBOL_NOT_FOUND`, `RISK_VIOLATION` — fix the cause, don't retry
143
+ - **Lighter `invalid signature`**: `perp --json -e lighter manage setup-api-key`
144
+ - If `error.retryable` is `false`, do NOT retry. Report to user.
110
145
 
111
146
  ## References
112
147
 
113
148
  - `references/commands.md` — full command reference
114
- - `references/agent-operations.md` — setup flows, deposit/withdraw, idempotency
115
- - `references/strategies.md` — risk framework, arb strategy, opportunity cost
149
+ - `references/agent-operations.md` — setup flows, deposit/withdraw
150
+ - `references/strategies.md` — risk framework, arb strategy
@@ -257,17 +257,14 @@ perp --json -e <EX> account margin <SYM> # detailed: liquidationPrice, margi
257
257
 
258
258
  **Liquidation distance is configurable by the user.** You MUST ask the user what risk tolerance they want:
259
259
  ```bash
260
- # Ask user: "What minimum liquidation distance do you want? (default: 30%, hard minimum: 20%)"
260
+ # Ask user: "What minimum liquidation distance do you want? (default: 5%)"
261
261
  perp --json risk limits --min-liq-distance <USER_CHOICE>
262
262
  ```
263
263
 
264
- **Hard cap: 20%.** No matter what the user says, the system will NEVER allow a position to get within 20% of liquidation. This is non-negotiable and enforced at the system level. If a user tries to set it below 20%, the command will reject it.
265
-
266
264
  **Action rules based on `risk liquidation-distance` output:**
267
265
  - `status: "safe"` → no action needed
268
266
  - `status: "warning"` → monitor more frequently (every 5 minutes)
269
267
  - `status: "danger"` → alert user, recommend reducing position size
270
- - `status: "critical"` (below 20% hard cap) → REDUCE IMMEDIATELY, `canTrade` becomes `false`
271
268
 
272
269
  ### Leverage and Margin Mode
273
270
 
@@ -309,14 +306,13 @@ Limits can be set in **USD or % of equity** (when both are set, the stricter one
309
306
  ```bash
310
307
  perp --json risk limits \
311
308
  --max-leverage 5 \
312
- --max-margin 60 \
313
- --max-drawdown-pct 10 \
309
+ --max-margin 95 \
314
310
  --max-position-pct 80 \
315
311
  --max-drawdown 500 \
316
312
  --max-position 50000 \
317
313
  --max-exposure 100000 \
318
314
  --daily-loss 200 \
319
- --min-liq-distance 30
315
+ --min-liq-distance 5
320
316
  ```
321
317
 
322
318
  **Use % limits for small portfolios.** A $65 portfolio with `--max-drawdown 500` is meaningless.
@@ -325,7 +321,7 @@ With `--max-drawdown-pct 10`, the effective limit auto-adjusts to $6.50.
325
321
  **IMPORTANT: Ask the user about their risk tolerance BEFORE setting limits.** Key questions:
326
322
  - "How much leverage are you comfortable with?" (default: 5x for arb)
327
323
  - "What's your maximum acceptable loss?" (default: $500)
328
- - "How close to liquidation are you willing to get?" (default: 30%, minimum: 20%)
324
+ - "How close to liquidation are you willing to get?" (default: 5%)
329
325
 
330
326
  These limits are enforced by `perp risk check`. Always run it before trades:
331
327
  ```bash