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 +61 -249
- package/dist/__tests__/risk-assessment.test.js +5 -22
- package/dist/commands/arb-auto.js +222 -3
- package/dist/commands/portfolio.js +27 -0
- package/dist/commands/risk.js +12 -23
- package/dist/index.js +2 -0
- package/dist/risk.d.ts +0 -2
- package/dist/risk.js +12 -25
- package/dist/utils.js +71 -1
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +87 -52
- package/skills/perp-cli/references/strategies.md +4 -8
package/README.md
CHANGED
|
@@ -1,296 +1,108 @@
|
|
|
1
1
|
# perp-cli
|
|
2
2
|
|
|
3
|
-
Multi-DEX perpetual futures CLI
|
|
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
|
|
7
|
+
perp --json portfolio
|
|
10
8
|
```
|
|
11
9
|
|
|
12
10
|
## Features
|
|
13
11
|
|
|
14
|
-
- **3 Exchanges** —
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
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
|
-
|
|
19
|
+
## Setup
|
|
87
20
|
|
|
88
21
|
```bash
|
|
89
|
-
|
|
90
|
-
perp
|
|
91
|
-
perp
|
|
92
|
-
perp
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
Same EVM key works for both Hyperliquid and Lighter.
|
|
112
32
|
|
|
113
|
-
|
|
33
|
+
## Core Commands
|
|
114
34
|
|
|
115
35
|
```bash
|
|
116
|
-
|
|
117
|
-
perp
|
|
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
|
-
#
|
|
131
|
-
perp
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
perp
|
|
138
|
-
perp
|
|
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
|
-
|
|
53
|
+
# Risk
|
|
54
|
+
perp --json risk limits --max-leverage 5
|
|
55
|
+
perp --json risk liquidation-distance
|
|
143
56
|
|
|
144
|
-
|
|
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
|
-
|
|
62
|
+
Exchange flags: `-e hyperliquid`, `-e pacifica`, `-e lighter` (aliases: `hl`, `pac`, `lt`).
|
|
203
63
|
|
|
204
|
-
## AI Agent
|
|
64
|
+
## AI Agent Skill
|
|
205
65
|
|
|
206
|
-
|
|
66
|
+
Install as a skill for Claude Code, Cursor, Codex, Gemini CLI, etc.:
|
|
207
67
|
|
|
208
68
|
```bash
|
|
209
|
-
#
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
## Agent-First CLI Design
|
|
240
79
|
|
|
241
|
-
|
|
80
|
+
Built following [agent-first CLI principles](https://justin.poehnelt.com/posts/rewrite-your-cli-for-ai-agents/):
|
|
242
81
|
|
|
243
82
|
```bash
|
|
244
|
-
|
|
245
|
-
perp
|
|
246
|
-
|
|
247
|
-
```
|
|
83
|
+
# Every command returns structured JSON envelope
|
|
84
|
+
perp --json portfolio
|
|
85
|
+
# → { "ok": true, "data": {...}, "meta": { "timestamp": "..." } }
|
|
248
86
|
|
|
249
|
-
|
|
87
|
+
# Runtime schema introspection (don't guess commands — query this)
|
|
88
|
+
perp --json agent schema
|
|
250
89
|
|
|
251
|
-
|
|
90
|
+
# Filter output to save tokens
|
|
91
|
+
perp --json --fields totalEquity,risk portfolio
|
|
252
92
|
|
|
253
|
-
|
|
254
|
-
perp
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
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% →
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
});
|
package/dist/commands/risk.js
CHANGED
|
@@ -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
|
|
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 <
|
|
61
|
-
? chalk.
|
|
62
|
-
:
|
|
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>",
|
|
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:
|
|
115
|
-
dailyLossLimitUsd:
|
|
116
|
-
minLiquidationDistance:
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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)
|
|
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:
|
|
5
|
-
maxPositionUsd:
|
|
6
|
-
maxTotalExposureUsd:
|
|
7
|
-
dailyLossLimitUsd:
|
|
8
|
-
maxPositions:
|
|
9
|
-
maxLeverage:
|
|
10
|
-
maxMarginUtilization:
|
|
11
|
-
minLiquidationDistance:
|
|
12
|
-
maxDrawdownPct:
|
|
13
|
-
maxPositionPct:
|
|
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 <
|
|
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 <
|
|
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
|
-
|
|
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
package/skills/perp-cli/SKILL.md
CHANGED
|
@@ -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.
|
|
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. **
|
|
19
|
-
3. **
|
|
20
|
-
4. **NEVER
|
|
21
|
-
5. **
|
|
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.
|
|
39
|
+
Same EVM key works for both HL and Lighter.
|
|
39
40
|
|
|
40
|
-
##
|
|
41
|
+
## Key Commands
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
|
-
#
|
|
44
|
-
perp --json
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
#
|
|
66
|
-
perp --json
|
|
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
|
-
|
|
83
|
+
All string outputs are auto-sanitized (control chars stripped, prompt injection patterns blocked).
|
|
70
84
|
|
|
71
|
-
##
|
|
85
|
+
## Arb Workflow
|
|
72
86
|
|
|
73
87
|
```
|
|
74
|
-
1. perp --json
|
|
75
|
-
2. perp --json
|
|
76
|
-
3.
|
|
77
|
-
4. perp --json
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
Every
|
|
102
|
-
|
|
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
|
-
|
|
139
|
+
Responses: `{ "ok": true, "data": {...} }` or `{ "ok": false, "error": { "code": "...", "retryable": true/false } }`
|
|
107
140
|
|
|
108
|
-
Retryable (wait 5s,
|
|
109
|
-
|
|
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
|
|
115
|
-
- `references/strategies.md` — risk framework, arb strategy
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|