perp-cli 0.3.3 → 0.3.5

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
@@ -178,21 +178,25 @@ perp alert daemon --interval 30
178
178
 
179
179
  ## Claude Code Agent Skill
180
180
 
181
- Install as a Claude Code plugin to let Claude trade for you:
181
+ Install as a Claude Code plugin or via the universal skills CLI:
182
182
 
183
183
  ```bash
184
- # In Claude Code
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
185
188
  /plugin marketplace add hypurrquant/perp-cli
186
- /plugin install perp-trading@hypurrquant-perp-cli
189
+ /plugin install perp-cli
187
190
  ```
188
191
 
189
- Once installed, use `/perp-trading` in Claude Code:
192
+ Once installed, Claude can trade for you via natural language:
190
193
 
191
194
  ```
192
- /perp-trading status # Check all exchanges
193
- /perp-trading BTC 0.01 long on hyperliquid # Natural language trading
194
- /perp-trading scan arb opportunities # Find funding rate arb
195
- /perp-trading bridge 100 USDC solana to arb # Cross-chain bridge
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
199
+ perp --json bridge quote --from solana --to arbitrum --amount 100
196
200
  ```
197
201
 
198
202
  The skill includes safety guardrails (balance checks, user confirmation before trades, error handling with retries).
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { assessRisk, preTradeCheck } from "../risk.js";
2
+ import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
3
3
  const defaultLimits = {
4
4
  maxDrawdownUsd: 500,
5
5
  maxPositionUsd: 5000,
@@ -8,12 +8,13 @@ const defaultLimits = {
8
8
  maxPositions: 10,
9
9
  maxLeverage: 20,
10
10
  maxMarginUtilization: 80,
11
+ minLiquidationDistance: 30,
11
12
  };
12
13
  function makeBalance(equity, available, marginUsed, pnl) {
13
14
  return { equity: String(equity), available: String(available), marginUsed: String(marginUsed), unrealizedPnl: String(pnl) };
14
15
  }
15
- function makePosition(symbol, side, size, markPrice, leverage, pnl) {
16
- return { symbol, side, size: String(size), entryPrice: String(markPrice), markPrice: String(markPrice), liquidationPrice: "0", unrealizedPnl: String(pnl), leverage };
16
+ function makePosition(symbol, side, size, markPrice, leverage, pnl, liquidationPrice = "0") {
17
+ return { symbol, side, size: String(size), entryPrice: String(markPrice), markPrice: String(markPrice), liquidationPrice, unrealizedPnl: String(pnl), leverage };
17
18
  }
18
19
  describe("Risk Assessment", () => {
19
20
  it("should return low risk when everything is within limits", () => {
@@ -143,3 +144,123 @@ describe("Pre-Trade Check", () => {
143
144
  expect(result.reason).toContain("Leverage");
144
145
  });
145
146
  });
147
+ describe("calcLiquidationDistance", () => {
148
+ it("should calculate distance for long position (liq below mark)", () => {
149
+ // Long at $100000, liq at $80000 → 20% distance
150
+ const dist = calcLiquidationDistance(100000, 80000, "long");
151
+ expect(dist).toBeCloseTo(20, 1);
152
+ });
153
+ it("should calculate distance for short position (liq above mark)", () => {
154
+ // Short at $100000, liq at $120000 → 20% distance
155
+ const dist = calcLiquidationDistance(100000, 120000, "short");
156
+ expect(dist).toBeCloseTo(20, 1);
157
+ });
158
+ it("should return Infinity when liquidation price is 0 or N/A", () => {
159
+ expect(calcLiquidationDistance(100000, 0, "long")).toBe(Infinity);
160
+ expect(calcLiquidationDistance(0, 80000, "long")).toBe(Infinity);
161
+ });
162
+ it("should handle very close liquidation (dangerous)", () => {
163
+ // Long at $100000, liq at $95000 → 5% distance
164
+ const dist = calcLiquidationDistance(100000, 95000, "long");
165
+ expect(dist).toBeCloseTo(5, 1);
166
+ });
167
+ it("should handle very safe distance", () => {
168
+ // Long at $100000, liq at $50000 → 50% distance
169
+ const dist = calcLiquidationDistance(100000, 50000, "long");
170
+ expect(dist).toBeCloseTo(50, 1);
171
+ });
172
+ });
173
+ describe("getLiquidationDistances", () => {
174
+ it("should return sorted by distance (closest first)", () => {
175
+ const positions = [
176
+ { exchange: "hl", position: makePosition("ETH", "long", 1, 3500, 5, 0, "3000") }, // ~14.3%
177
+ { exchange: "pac", position: makePosition("BTC", "long", 0.01, 100000, 2, 0, "50000") }, // 50%
178
+ { exchange: "hl", position: makePosition("SOL", "short", 10, 150, 10, 0, "165") }, // 10%
179
+ ];
180
+ const distances = getLiquidationDistances(positions, defaultLimits);
181
+ expect(distances.length).toBe(3);
182
+ expect(distances[0].symbol).toBe("SOL"); // closest to liq
183
+ expect(distances[1].symbol).toBe("ETH");
184
+ expect(distances[2].symbol).toBe("BTC"); // safest
185
+ });
186
+ it("should assign correct status based on limits", () => {
187
+ const limits = { ...defaultLimits, minLiquidationDistance: 30 };
188
+ const positions = [
189
+ { exchange: "a", position: makePosition("A", "long", 1, 100, 2, 0, "85") }, // 15% → critical (< 20% hard cap)
190
+ { exchange: "b", position: makePosition("B", "long", 1, 100, 2, 0, "78") }, // 22% → danger (< 30% user limit)
191
+ { exchange: "c", position: makePosition("C", "long", 1, 100, 2, 0, "65") }, // 35% → warning (< 30% * 1.5 = 45%)
192
+ { exchange: "d", position: makePosition("D", "long", 1, 100, 2, 0, "40") }, // 60% → safe
193
+ ];
194
+ const distances = getLiquidationDistances(positions, limits);
195
+ expect(distances.find(d => d.symbol === "A").status).toBe("critical");
196
+ expect(distances.find(d => d.symbol === "B").status).toBe("danger");
197
+ expect(distances.find(d => d.symbol === "C").status).toBe("warning");
198
+ expect(distances.find(d => d.symbol === "D").status).toBe("safe");
199
+ });
200
+ it("should skip positions with liquidationPrice 0 or N/A", () => {
201
+ const positions = [
202
+ { exchange: "a", position: makePosition("BTC", "long", 1, 100000, 5, 0, "0") },
203
+ { exchange: "b", position: makePosition("ETH", "long", 1, 3500, 5, 0, "N/A") },
204
+ { exchange: "c", position: makePosition("SOL", "long", 1, 150, 5, 0, "120") }, // valid
205
+ ];
206
+ const distances = getLiquidationDistances(positions, defaultLimits);
207
+ expect(distances.length).toBe(1);
208
+ expect(distances[0].symbol).toBe("SOL");
209
+ });
210
+ });
211
+ describe("Liquidation Distance in assessRisk", () => {
212
+ it("should add critical violation when position is below hard cap (20%)", () => {
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)
215
+ const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 10, 0, "90000") }];
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
+ 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
+ });
230
+ it("should not add violation when distance is above user limit", () => {
231
+ const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
232
+ // BTC long at $100000, liq at $50000 → 50% distance (safe)
233
+ const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 2, 0, "50000") }];
234
+ const result = assessRisk(balances, positions, defaultLimits);
235
+ expect(result.violations.some(v => v.rule === "liquidation_distance_hard_cap")).toBe(false);
236
+ expect(result.violations.some(v => v.rule === "min_liquidation_distance")).toBe(false);
237
+ });
238
+ it("should report minLiquidationDistancePct in metrics", () => {
239
+ const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
240
+ const positions = [
241
+ { exchange: "a", position: makePosition("BTC", "long", 0.01, 100000, 2, 0, "60000") }, // 40%
242
+ { exchange: "b", position: makePosition("ETH", "short", 1, 3500, 5, 0, "4200") }, // 20%
243
+ ];
244
+ const result = assessRisk(balances, positions, defaultLimits);
245
+ expect(result.metrics.minLiquidationDistancePct).toBeCloseTo(20, 1);
246
+ });
247
+ it("should report -1 for minLiquidationDistancePct when no positions", () => {
248
+ const result = assessRisk([], [], defaultLimits);
249
+ expect(result.metrics.minLiquidationDistancePct).toBe(-1);
250
+ });
251
+ it("should include liquidationDistances array in assessment", () => {
252
+ const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
253
+ // BTC long at $100000, liq at $30000 → 70% distance (well above 30% * 1.5 = 45% → safe)
254
+ const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 2, 0, "30000") }];
255
+ const result = assessRisk(balances, positions, defaultLimits);
256
+ expect(result.liquidationDistances).toHaveLength(1);
257
+ expect(result.liquidationDistances[0].symbol).toBe("BTC");
258
+ expect(result.liquidationDistances[0].distancePct).toBeCloseTo(70, 1);
259
+ expect(result.liquidationDistances[0].status).toBe("safe");
260
+ });
261
+ });
262
+ describe("LIQUIDATION_DISTANCE_HARD_CAP", () => {
263
+ it("should be 20", () => {
264
+ expect(LIQUIDATION_DISTANCE_HARD_CAP).toBe(20);
265
+ });
266
+ });
@@ -1,5 +1,6 @@
1
1
  import { PacificaAdapter } from "../exchanges/pacifica.js";
2
2
  import { printJson, jsonOk } from "../utils.js";
3
+ import { setEnvVar } from "./init.js";
3
4
  import chalk from "chalk";
4
5
  export function registerManageCommands(program, getAdapter, isJson, getPacificaAdapter) {
5
6
  const manage = program.command("manage").description("Account management");
@@ -288,6 +289,9 @@ export function registerManageCommands(program, getAdapter, isJson, getPacificaA
288
289
  console.log(chalk.gray(" Generating key pair + registering on-chain...\n"));
289
290
  }
290
291
  const { privateKey, publicKey } = await adapter.setupApiKey(keyIndex);
292
+ // Auto-save to .env
293
+ setEnvVar("LIGHTER_API_KEY", privateKey);
294
+ setEnvVar("LIGHTER_ACCOUNT_INDEX", String(adapter.accountIndex));
291
295
  if (isJson()) {
292
296
  return printJson(jsonOk({
293
297
  privateKey,
@@ -295,15 +299,13 @@ export function registerManageCommands(program, getAdapter, isJson, getPacificaA
295
299
  address: adapter.address,
296
300
  accountIndex: adapter.accountIndex,
297
301
  apiKeyIndex: keyIndex,
302
+ savedToEnv: true,
298
303
  }));
299
304
  }
300
- console.log(chalk.green(" API Key Registered!\n"));
305
+ console.log(chalk.green(" API Key Registered & saved to ~/.perp/.env\n"));
301
306
  console.log(` ${chalk.bold("Private Key:")} ${privateKey}`);
302
307
  console.log(` ${chalk.bold("Public Key:")} ${publicKey}`);
303
- console.log();
304
- console.log(chalk.yellow(" Add to your .env file:"));
305
- console.log(chalk.white(` LIGHTER_API_KEY=${privateKey}`));
306
- console.log(chalk.white(` LIGHTER_ACCOUNT_INDEX=${adapter.accountIndex}`));
308
+ console.log(` ${chalk.bold("Account:")} ${adapter.accountIndex}`);
307
309
  console.log();
308
310
  });
309
311
  }
@@ -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 } from "../risk.js";
3
+ import { loadRiskLimits, saveRiskLimits, assessRisk, getLiquidationDistances, LIQUIDATION_DISTANCE_HARD_CAP } 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");
@@ -56,6 +56,14 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
56
56
  console.log(` Positions: ${assessment.metrics.positionCount}`);
57
57
  console.log(` Largest Position: $${formatUsd(assessment.metrics.largestPositionUsd)}`);
58
58
  console.log(` Max Leverage Used: ${assessment.metrics.maxLeverageUsed}x`);
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;
65
+ console.log(` Min Liq Distance: ${ldColor(`${assessment.metrics.minLiquidationDistancePct.toFixed(1)}%`)}`);
66
+ }
59
67
  if (assessment.violations.length > 0) {
60
68
  console.log(chalk.red.bold("\n Violations"));
61
69
  const vRows = assessment.violations.map(v => {
@@ -89,15 +97,18 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
89
97
  .option("--max-positions <n>", "Max number of simultaneous positions")
90
98
  .option("--max-leverage <n>", "Max leverage per position")
91
99
  .option("--max-margin <pct>", "Max margin utilization %")
100
+ .option("--min-liq-distance <pct>", `Min liquidation distance % (hard cap: >=${LIQUIDATION_DISTANCE_HARD_CAP}%)`)
92
101
  .option("--reset", "Reset all limits to defaults")
93
102
  .action(async (opts) => {
94
103
  let limits = loadRiskLimits();
95
104
  const hasUpdate = opts.maxDrawdown || opts.maxPosition || opts.maxExposure ||
96
- opts.dailyLoss || opts.maxPositions || opts.maxLeverage || opts.maxMargin || opts.reset;
105
+ opts.dailyLoss || opts.maxPositions || opts.maxLeverage || opts.maxMargin ||
106
+ opts.minLiqDistance || opts.reset;
97
107
  if (opts.reset) {
98
108
  limits = {
99
109
  maxDrawdownUsd: 500, maxPositionUsd: 5000, maxTotalExposureUsd: 20000,
100
110
  dailyLossLimitUsd: 200, maxPositions: 10, maxLeverage: 20, maxMarginUtilization: 80,
111
+ minLiquidationDistance: 30,
101
112
  };
102
113
  }
103
114
  if (opts.maxDrawdown)
@@ -114,6 +125,17 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
114
125
  limits.maxLeverage = parseInt(opts.maxLeverage);
115
126
  if (opts.maxMargin)
116
127
  limits.maxMarginUtilization = parseFloat(opts.maxMargin);
128
+ if (opts.minLiqDistance) {
129
+ const val = parseFloat(opts.minLiqDistance);
130
+ if (val < LIQUIDATION_DISTANCE_HARD_CAP) {
131
+ const msg = `Cannot set min liquidation distance below ${LIQUIDATION_DISTANCE_HARD_CAP}% (hard cap). Got: ${val}%`;
132
+ if (isJson())
133
+ return printJson(jsonOk({ error: msg, hardCap: LIQUIDATION_DISTANCE_HARD_CAP }));
134
+ console.log(chalk.red(`\n ${msg}\n`));
135
+ return;
136
+ }
137
+ limits.minLiquidationDistance = val;
138
+ }
117
139
  if (hasUpdate)
118
140
  saveRiskLimits(limits);
119
141
  if (isJson())
@@ -125,7 +147,8 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
125
147
  console.log(` Daily Loss Limit: $${formatUsd(limits.dailyLossLimitUsd)}`);
126
148
  console.log(` Max Positions: ${limits.maxPositions}`);
127
149
  console.log(` Max Leverage: ${limits.maxLeverage}x`);
128
- console.log(` Max Margin Util: ${limits.maxMarginUtilization}%\n`);
150
+ console.log(` Max Margin Util: ${limits.maxMarginUtilization}%`);
151
+ console.log(` Min Liq Distance: ${limits.minLiquidationDistance}% ${chalk.gray(`(hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`)}\n`);
129
152
  console.log(chalk.gray(` Config file: ~/.perp/risk.json\n`));
130
153
  });
131
154
  // ── risk check ── (pre-trade check, for agent use)
@@ -166,4 +189,84 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
166
189
  }
167
190
  });
168
191
  });
192
+ // ── risk liquidation-distance ──
193
+ risk
194
+ .command("liquidation-distance")
195
+ .description("Show % distance from liquidation price for all positions")
196
+ .alias("liq-dist")
197
+ .option("--exchange <exchanges>", "Comma-separated exchanges (default: all)")
198
+ .action(async (opts) => {
199
+ await withJsonErrors(isJson(), async () => {
200
+ const exchanges = opts.exchange
201
+ ? opts.exchange.split(",").map(e => e.trim())
202
+ : [...EXCHANGES];
203
+ const positions = [];
204
+ const results = await Promise.allSettled(exchanges.map(async (ex) => {
205
+ const adapter = await getAdapterForExchange(ex);
206
+ const pos = await adapter.getPositions();
207
+ for (const p of pos)
208
+ positions.push({ exchange: ex, position: p });
209
+ }));
210
+ for (let i = 0; i < results.length; i++) {
211
+ if (results[i].status === "rejected") {
212
+ const err = results[i].reason;
213
+ if (!isJson()) {
214
+ console.log(chalk.yellow(` ${exchanges[i]}: ${err instanceof Error ? err.message : String(err)}`));
215
+ }
216
+ }
217
+ }
218
+ if (positions.length === 0) {
219
+ if (isJson())
220
+ return printJson(jsonOk({ positions: [], message: "No open positions" }));
221
+ console.log(chalk.gray("\n No open positions.\n"));
222
+ return;
223
+ }
224
+ const limits = loadRiskLimits();
225
+ const distances = getLiquidationDistances(positions, limits);
226
+ if (isJson()) {
227
+ return printJson(jsonOk({
228
+ positions: distances,
229
+ limits: {
230
+ minLiquidationDistance: limits.minLiquidationDistance,
231
+ hardCap: LIQUIDATION_DISTANCE_HARD_CAP,
232
+ },
233
+ }));
234
+ }
235
+ console.log(chalk.cyan.bold("\n Liquidation Distance Report\n"));
236
+ console.log(chalk.gray(` Your limit: ${limits.minLiquidationDistance}% | Hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%\n`));
237
+ const rows = distances.map(d => {
238
+ const statusColor = {
239
+ safe: chalk.green,
240
+ warning: chalk.yellow,
241
+ danger: chalk.red,
242
+ critical: chalk.bgRed.white,
243
+ }[d.status];
244
+ return [
245
+ chalk.white(d.exchange),
246
+ chalk.white.bold(d.symbol),
247
+ d.side === "long" ? chalk.green("LONG") : chalk.red("SHORT"),
248
+ `$${formatUsd(d.markPrice)}`,
249
+ `$${formatUsd(d.liquidationPrice)}`,
250
+ statusColor(`${d.distancePct.toFixed(1)}%`),
251
+ statusColor(d.status.toUpperCase()),
252
+ ];
253
+ });
254
+ console.log(makeTable(["Exchange", "Symbol", "Side", "Mark Price", "Liq Price", "Distance", "Status"], rows));
255
+ // Summary warnings
256
+ const critical = distances.filter(d => d.status === "critical");
257
+ const danger = distances.filter(d => d.status === "danger");
258
+ if (critical.length > 0) {
259
+ console.log(chalk.bgRed.white.bold(` ⚠ ${critical.length} position(s) BELOW HARD CAP (${LIQUIDATION_DISTANCE_HARD_CAP}%) — REDUCE IMMEDIATELY`));
260
+ }
261
+ if (danger.length > 0) {
262
+ console.log(chalk.red.bold(` ⚠ ${danger.length} position(s) below your limit (${limits.minLiquidationDistance}%) — action recommended`));
263
+ }
264
+ if (critical.length === 0 && danger.length === 0) {
265
+ console.log(chalk.green(" All positions within safe liquidation distance.\n"));
266
+ }
267
+ else {
268
+ console.log();
269
+ }
270
+ });
271
+ });
169
272
  }
@@ -382,13 +382,41 @@ export function registerWalletCommands(program, isJson) {
382
382
  settings.defaultExchange = resolved;
383
383
  saveSettings(settings);
384
384
  }
385
+ // Auto-setup Lighter API key if setting lighter PK
386
+ let lighterApiSetup = {};
387
+ if (resolved === "lighter") {
388
+ try {
389
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
390
+ const adapter = new LighterAdapter(normalized);
391
+ await adapter.init();
392
+ const { privateKey: apiKey } = await adapter.setupApiKey();
393
+ setEnvVar("LIGHTER_API_KEY", apiKey);
394
+ setEnvVar("LIGHTER_ACCOUNT_INDEX", String(adapter.accountIndex));
395
+ lighterApiSetup = { apiKey, accountIndex: adapter.accountIndex };
396
+ }
397
+ catch (e) {
398
+ lighterApiSetup = { error: e instanceof Error ? e.message : String(e) };
399
+ }
400
+ }
385
401
  if (isJson())
386
- return printJson(jsonOk({ exchange: resolved, address, envFile: ENV_FILE, default: !!opts.default }));
402
+ return printJson(jsonOk({
403
+ exchange: resolved, address, envFile: ENV_FILE, default: !!opts.default,
404
+ ...(resolved === "lighter" && { lighterApiKey: lighterApiSetup }),
405
+ }));
387
406
  console.log(chalk.green(`\n ${resolved} configured.`));
388
407
  console.log(` Address: ${chalk.green(address)}`);
389
408
  console.log(` Saved to: ${chalk.gray("~/.perp/.env")}`);
390
409
  if (opts.default)
391
410
  console.log(` Default: ${chalk.cyan(resolved)}`);
411
+ if (resolved === "lighter") {
412
+ if (lighterApiSetup.apiKey) {
413
+ console.log(chalk.green(` API Key: auto-registered (index: ${lighterApiSetup.accountIndex})`));
414
+ }
415
+ else {
416
+ console.log(chalk.yellow(` API Key: setup failed — ${lighterApiSetup.error}`));
417
+ console.log(chalk.gray(` You can retry: perp -e lighter manage setup-api-key`));
418
+ }
419
+ }
392
420
  console.log();
393
421
  });
394
422
  // ── show (show configured exchanges with public addresses) ──
package/dist/index.js CHANGED
@@ -48,7 +48,7 @@ const _defaultExchange = _settings.defaultExchange || "pacifica";
48
48
  program
49
49
  .name("perp")
50
50
  .description("Multi-DEX Perpetual Futures CLI (Pacifica, Hyperliquid, Lighter)")
51
- .version("0.3.3")
51
+ .version("0.3.5")
52
52
  .option("-e, --exchange <exchange>", `Exchange: pacifica, hyperliquid, lighter (default: ${_defaultExchange})`, _defaultExchange)
53
53
  .option("-n, --network <network>", "Network: mainnet or testnet", "mainnet")
54
54
  .option("-k, --private-key <key>", "Private key")
@@ -383,7 +383,7 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
383
383
  process.env.LIGHTER_PRIVATE_KEY);
384
384
  if (!status.hasWallets && !hasEnvKey && !settings.defaultExchange) {
385
385
  // Fresh install — onboarding
386
- console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(" v0.3.3\n"));
386
+ console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(" v0.3.5\n"));
387
387
  console.log(" Multi-DEX perpetual futures CLI for Pacifica, Hyperliquid, and Lighter.\n");
388
388
  console.log(` Get started: ${chalk.cyan("perp init")}`);
389
389
  console.log(chalk.gray(`\n Or explore without a wallet:`));
@@ -396,7 +396,7 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
396
396
  // Configured — show status overview
397
397
  const defaultEx = settings.defaultExchange || "pacifica";
398
398
  const activeEntries = Object.entries(status.active);
399
- console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(" v0.3.3\n"));
399
+ console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(" v0.3.5\n"));
400
400
  console.log(` Default exchange: ${chalk.cyan(defaultEx)}`);
401
401
  if (activeEntries.length > 0) {
402
402
  console.log(chalk.white.bold("\n Wallets:"));
@@ -56,7 +56,7 @@ function err(error, meta) {
56
56
  return JSON.stringify({ ok: false, error, meta }, null, 2);
57
57
  }
58
58
  // ── MCP Server ──
59
- const server = new McpServer({ name: "perp-cli", version: "0.3.3" }, { capabilities: { tools: {}, resources: {} } });
59
+ const server = new McpServer({ name: "perp-cli", version: "0.3.5" }, { capabilities: { tools: {}, resources: {} } });
60
60
  // ============================================================
61
61
  // Market Data tools (read-only, no private key needed)
62
62
  // ============================================================
package/dist/risk.d.ts CHANGED
@@ -7,7 +7,10 @@ export interface RiskLimits {
7
7
  maxPositions: number;
8
8
  maxLeverage: number;
9
9
  maxMarginUtilization: number;
10
+ minLiquidationDistance: number;
10
11
  }
12
+ /** Hard cap: liquidation distance can NEVER be set below this % */
13
+ export declare const LIQUIDATION_DISTANCE_HARD_CAP = 20;
11
14
  export declare function loadRiskLimits(): RiskLimits;
12
15
  export declare function saveRiskLimits(limits: RiskLimits): void;
13
16
  export type RiskLevel = "low" | "medium" | "high" | "critical";
@@ -18,6 +21,21 @@ export interface RiskViolation {
18
21
  current: number;
19
22
  limit: number;
20
23
  }
24
+ export interface LiquidationDistanceInfo {
25
+ exchange: string;
26
+ symbol: string;
27
+ side: "long" | "short";
28
+ markPrice: number;
29
+ liquidationPrice: number;
30
+ distancePct: number;
31
+ status: "safe" | "warning" | "danger" | "critical";
32
+ }
33
+ /** Calculate % distance from current price to liquidation price */
34
+ export declare function calcLiquidationDistance(markPrice: number, liquidationPrice: number, side: "long" | "short"): number;
35
+ export declare function getLiquidationDistances(positions: {
36
+ exchange: string;
37
+ position: ExchangePosition;
38
+ }[], limits?: RiskLimits): LiquidationDistanceInfo[];
21
39
  export interface RiskAssessment {
22
40
  level: RiskLevel;
23
41
  violations: RiskViolation[];
@@ -30,7 +48,9 @@ export interface RiskAssessment {
30
48
  marginUtilization: number;
31
49
  largestPositionUsd: number;
32
50
  maxLeverageUsed: number;
51
+ minLiquidationDistancePct: number;
33
52
  };
53
+ liquidationDistances: LiquidationDistanceInfo[];
34
54
  limits: RiskLimits;
35
55
  canTrade: boolean;
36
56
  }
package/dist/risk.js CHANGED
@@ -8,7 +8,10 @@ const DEFAULT_LIMITS = {
8
8
  maxPositions: 10,
9
9
  maxLeverage: 20,
10
10
  maxMarginUtilization: 80,
11
+ minLiquidationDistance: 30,
11
12
  };
13
+ /** Hard cap: liquidation distance can NEVER be set below this % */
14
+ export const LIQUIDATION_DISTANCE_HARD_CAP = 20;
12
15
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
13
16
  const RISK_FILE = resolve(PERP_DIR, "risk.json");
14
17
  export function loadRiskLimits() {
@@ -27,6 +30,38 @@ export function saveRiskLimits(limits) {
27
30
  mkdirSync(PERP_DIR, { recursive: true, mode: 0o700 });
28
31
  writeFileSync(RISK_FILE, JSON.stringify(limits, null, 2), { mode: 0o600 });
29
32
  }
33
+ /** Calculate % distance from current price to liquidation price */
34
+ export function calcLiquidationDistance(markPrice, liquidationPrice, side) {
35
+ if (liquidationPrice <= 0 || markPrice <= 0)
36
+ return Infinity;
37
+ if (side === "long") {
38
+ // Long: liq price is below mark price
39
+ return ((markPrice - liquidationPrice) / markPrice) * 100;
40
+ }
41
+ else {
42
+ // Short: liq price is above mark price
43
+ return ((liquidationPrice - markPrice) / markPrice) * 100;
44
+ }
45
+ }
46
+ export function getLiquidationDistances(positions, limits) {
47
+ const lim = limits ?? loadRiskLimits();
48
+ return positions
49
+ .filter(({ position: p }) => p.liquidationPrice !== "N/A" && Number(p.liquidationPrice) > 0)
50
+ .map(({ exchange, position: p }) => {
51
+ const markPrice = Number(p.markPrice);
52
+ const liquidationPrice = Number(p.liquidationPrice);
53
+ const distancePct = calcLiquidationDistance(markPrice, liquidationPrice, p.side);
54
+ let status = "safe";
55
+ if (distancePct < LIQUIDATION_DISTANCE_HARD_CAP)
56
+ status = "critical";
57
+ else if (distancePct < lim.minLiquidationDistance)
58
+ status = "danger";
59
+ else if (distancePct < lim.minLiquidationDistance * 1.5)
60
+ status = "warning";
61
+ return { exchange, symbol: p.symbol, side: p.side, markPrice, liquidationPrice, distancePct, status };
62
+ })
63
+ .sort((a, b) => a.distancePct - b.distancePct);
64
+ }
30
65
  export function assessRisk(balances, positions, limits) {
31
66
  const lim = limits ?? loadRiskLimits();
32
67
  const violations = [];
@@ -107,6 +142,34 @@ export function assessRisk(balances, positions, limits) {
107
142
  limit: lim.maxMarginUtilization,
108
143
  });
109
144
  }
145
+ // Check liquidation distances
146
+ const liquidationDistances = getLiquidationDistances(positions, lim);
147
+ let minLiquidationDistancePct = Infinity;
148
+ for (const ld of liquidationDistances) {
149
+ if (ld.distancePct < minLiquidationDistancePct) {
150
+ minLiquidationDistancePct = ld.distancePct;
151
+ }
152
+ if (ld.distancePct < LIQUIDATION_DISTANCE_HARD_CAP) {
153
+ violations.push({
154
+ rule: "liquidation_distance_hard_cap",
155
+ severity: "critical",
156
+ message: `${ld.exchange}:${ld.symbol} is ${ld.distancePct.toFixed(1)}% from liquidation (hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`,
157
+ current: ld.distancePct,
158
+ limit: LIQUIDATION_DISTANCE_HARD_CAP,
159
+ });
160
+ }
161
+ else if (ld.distancePct < lim.minLiquidationDistance) {
162
+ violations.push({
163
+ rule: "min_liquidation_distance",
164
+ severity: "high",
165
+ message: `${ld.exchange}:${ld.symbol} is ${ld.distancePct.toFixed(1)}% from liquidation (limit: ${lim.minLiquidationDistance}%)`,
166
+ current: ld.distancePct,
167
+ limit: lim.minLiquidationDistance,
168
+ });
169
+ }
170
+ }
171
+ if (minLiquidationDistancePct === Infinity)
172
+ minLiquidationDistancePct = -1; // no positions
110
173
  // Determine overall risk level
111
174
  let level = "low";
112
175
  if (violations.some(v => v.severity === "critical"))
@@ -129,7 +192,9 @@ export function assessRisk(balances, positions, limits) {
129
192
  marginUtilization,
130
193
  largestPositionUsd,
131
194
  maxLeverageUsed,
195
+ minLiquidationDistancePct,
132
196
  },
197
+ liquidationDistances,
133
198
  limits: lim,
134
199
  canTrade,
135
200
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter",
5
5
  "bin": {
6
6
  "perp": "./dist/index.js",