perp-cli 0.3.7 → 0.3.9

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
@@ -288,7 +288,7 @@ perp settings set referralCodes.hyperliquid MYCODE
288
288
  - **TypeScript** + Node.js (ESM)
289
289
  - **Solana**: `@solana/web3.js`, `tweetnacl`, `bs58`
290
290
  - **EVM**: `ethers` v6
291
- - **Exchanges**: `hyperliquid` SDK, `lighter-sdk` (WASM), `@pacifica/sdk`
291
+ - **Exchanges**: `hyperliquid` SDK, `lighter-ts-sdk` (WASM), `@pacifica/sdk`
292
292
  - **Bridge**: Circle CCTP V2 (Solana ↔ EVM), deBridge DLN (fallback)
293
293
  - **Testing**: Vitest (860+ tests)
294
294
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
2
+ import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, effectiveLimit, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
3
3
  const defaultLimits = {
4
4
  maxDrawdownUsd: 500,
5
5
  maxPositionUsd: 5000,
@@ -264,3 +264,42 @@ describe("LIQUIDATION_DISTANCE_HARD_CAP", () => {
264
264
  expect(LIQUIDATION_DISTANCE_HARD_CAP).toBe(20);
265
265
  });
266
266
  });
267
+ describe("Percentage-based Risk Limits", () => {
268
+ it("effectiveLimit should return min of USD and pct-of-equity", () => {
269
+ expect(effectiveLimit(500, 10, 1000)).toBe(100); // 10% of $1000 = $100 < $500
270
+ expect(effectiveLimit(500, 10, 10000)).toBe(500); // 10% of $10000 = $1000 > $500
271
+ expect(effectiveLimit(500, undefined, 1000)).toBe(500); // no pct → use USD
272
+ expect(effectiveLimit(500, 10, 0)).toBe(500); // zero equity → use USD
273
+ });
274
+ it("should use pct-based drawdown when equity is small", () => {
275
+ const limits = { ...defaultLimits, maxDrawdownPct: 10 };
276
+ // equity = $100, 10% = $10 effective drawdown limit. uPnL = -$15 > $10 → critical
277
+ const balances = [{ exchange: "test", balance: makeBalance(100, 80, 20, -15) }];
278
+ const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.0001, 100000, 2, -15) }];
279
+ const result = assessRisk(balances, positions, limits);
280
+ expect(result.violations.some(v => v.rule === "max_drawdown")).toBe(true);
281
+ expect(result.level).toBe("critical");
282
+ });
283
+ it("should use pct-based position limit when equity is small", () => {
284
+ const limits = { ...defaultLimits, maxPositionPct: 25 };
285
+ // equity = $200, 25% = $50 effective position limit. Position = $100 > $50 → violation
286
+ const balances = [{ exchange: "test", balance: makeBalance(200, 150, 50, 0) }];
287
+ const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.001, 100000, 2, 0) }]; // $100
288
+ const result = assessRisk(balances, positions, limits);
289
+ expect(result.violations.some(v => v.rule === "max_position_size")).toBe(true);
290
+ });
291
+ it("preTradeCheck should use pct-based limits", () => {
292
+ const limits = { ...defaultLimits, maxPositionPct: 25 };
293
+ // equity = $200, 25% = $50 effective position limit
294
+ const assessment = assessRisk([{ exchange: "test", balance: makeBalance(200, 150, 50, 0) }], [], limits);
295
+ expect(preTradeCheck(assessment, 60, 2).allowed).toBe(false); // $60 > $50
296
+ expect(preTradeCheck(assessment, 40, 2).allowed).toBe(true); // $40 < $50
297
+ });
298
+ it("should use stricter of USD and pct limits", () => {
299
+ const limits = { ...defaultLimits, maxPositionUsd: 100, maxPositionPct: 25 };
300
+ // equity = $1000, 25% = $250. USD limit = $100. Effective = $100 (USD is stricter)
301
+ const assessment = assessRisk([{ exchange: "test", balance: makeBalance(1000, 800, 200, 0) }], [], limits);
302
+ expect(preTradeCheck(assessment, 120, 2).allowed).toBe(false); // $120 > $100
303
+ expect(preTradeCheck(assessment, 80, 2).allowed).toBe(true); // $80 < $100
304
+ });
305
+ });
@@ -292,6 +292,7 @@ export function registerManageCommands(program, getAdapter, isJson, getPacificaA
292
292
  // Auto-save to .env
293
293
  setEnvVar("LIGHTER_API_KEY", privateKey);
294
294
  setEnvVar("LIGHTER_ACCOUNT_INDEX", String(adapter.accountIndex));
295
+ setEnvVar("LIGHTER_API_KEY_INDEX", String(keyIndex));
295
296
  if (isJson()) {
296
297
  return printJson(jsonOk({
297
298
  privateKey,
@@ -90,10 +90,14 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
90
90
  risk
91
91
  .command("limits")
92
92
  .description("View or set risk limits")
93
- .option("--max-drawdown <usd>", "Max unrealized loss before closing all")
94
- .option("--max-position <usd>", "Max single position notional")
95
- .option("--max-exposure <usd>", "Max total exposure across all positions")
96
- .option("--daily-loss <usd>", "Daily realized loss limit")
93
+ .option("--max-drawdown <usd>", "Max unrealized loss (USD)")
94
+ .option("--max-drawdown-pct <pct>", "Max unrealized loss (% of equity)")
95
+ .option("--max-position <usd>", "Max single position notional (USD)")
96
+ .option("--max-position-pct <pct>", "Max single position (% of equity)")
97
+ .option("--max-exposure <usd>", "Max total exposure (USD)")
98
+ .option("--max-exposure-pct <pct>", "Max total exposure (% of equity)")
99
+ .option("--daily-loss <usd>", "Daily realized loss limit (USD)")
100
+ .option("--daily-loss-pct <pct>", "Daily loss limit (% of equity)")
97
101
  .option("--max-positions <n>", "Max number of simultaneous positions")
98
102
  .option("--max-leverage <n>", "Max leverage per position")
99
103
  .option("--max-margin <pct>", "Max margin utilization %")
@@ -101,24 +105,33 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
101
105
  .option("--reset", "Reset all limits to defaults")
102
106
  .action(async (opts) => {
103
107
  let limits = loadRiskLimits();
104
- const hasUpdate = opts.maxDrawdown || opts.maxPosition || opts.maxExposure ||
105
- opts.dailyLoss || opts.maxPositions || opts.maxLeverage || opts.maxMargin ||
108
+ const hasUpdate = opts.maxDrawdown || opts.maxDrawdownPct || opts.maxPosition || opts.maxPositionPct ||
109
+ opts.maxExposure || opts.maxExposurePct || opts.dailyLoss || opts.dailyLossPct ||
110
+ opts.maxPositions || opts.maxLeverage || opts.maxMargin ||
106
111
  opts.minLiqDistance || opts.reset;
107
112
  if (opts.reset) {
108
113
  limits = {
109
114
  maxDrawdownUsd: 500, maxPositionUsd: 5000, maxTotalExposureUsd: 20000,
110
115
  dailyLossLimitUsd: 200, maxPositions: 10, maxLeverage: 20, maxMarginUtilization: 80,
111
- minLiquidationDistance: 30,
116
+ minLiquidationDistance: 30, maxDrawdownPct: 10, maxPositionPct: 25,
112
117
  };
113
118
  }
114
119
  if (opts.maxDrawdown)
115
120
  limits.maxDrawdownUsd = parseFloat(opts.maxDrawdown);
121
+ if (opts.maxDrawdownPct)
122
+ limits.maxDrawdownPct = parseFloat(opts.maxDrawdownPct);
116
123
  if (opts.maxPosition)
117
124
  limits.maxPositionUsd = parseFloat(opts.maxPosition);
125
+ if (opts.maxPositionPct)
126
+ limits.maxPositionPct = parseFloat(opts.maxPositionPct);
118
127
  if (opts.maxExposure)
119
128
  limits.maxTotalExposureUsd = parseFloat(opts.maxExposure);
129
+ if (opts.maxExposurePct)
130
+ limits.maxExposurePct = parseFloat(opts.maxExposurePct);
120
131
  if (opts.dailyLoss)
121
132
  limits.dailyLossLimitUsd = parseFloat(opts.dailyLoss);
133
+ if (opts.dailyLossPct)
134
+ limits.dailyLossPct = parseFloat(opts.dailyLossPct);
122
135
  if (opts.maxPositions)
123
136
  limits.maxPositions = parseInt(opts.maxPositions);
124
137
  if (opts.maxLeverage)
@@ -140,15 +153,22 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
140
153
  saveRiskLimits(limits);
141
154
  if (isJson())
142
155
  return printJson(jsonOk(limits));
156
+ const fmtLimit = (usd, pct) => {
157
+ const parts = [`$${formatUsd(usd)}`];
158
+ if (pct != null)
159
+ parts.push(chalk.cyan(`${pct}% of equity`));
160
+ return parts.join(" / ");
161
+ };
143
162
  console.log(chalk.cyan.bold(`\n Risk Limits ${hasUpdate ? "(updated)" : ""}\n`));
144
- console.log(` Max Drawdown: $${formatUsd(limits.maxDrawdownUsd)}`);
145
- console.log(` Max Position Size: $${formatUsd(limits.maxPositionUsd)}`);
146
- console.log(` Max Total Exposure: $${formatUsd(limits.maxTotalExposureUsd)}`);
147
- console.log(` Daily Loss Limit: $${formatUsd(limits.dailyLossLimitUsd)}`);
163
+ console.log(` Max Drawdown: ${fmtLimit(limits.maxDrawdownUsd, limits.maxDrawdownPct)}`);
164
+ console.log(` Max Position Size: ${fmtLimit(limits.maxPositionUsd, limits.maxPositionPct)}`);
165
+ console.log(` Max Total Exposure: ${fmtLimit(limits.maxTotalExposureUsd, limits.maxExposurePct)}`);
166
+ console.log(` Daily Loss Limit: ${fmtLimit(limits.dailyLossLimitUsd, limits.dailyLossPct)}`);
148
167
  console.log(` Max Positions: ${limits.maxPositions}`);
149
168
  console.log(` Max Leverage: ${limits.maxLeverage}x`);
150
169
  console.log(` Max Margin Util: ${limits.maxMarginUtilization}%`);
151
- console.log(` Min Liq Distance: ${limits.minLiquidationDistance}% ${chalk.gray(`(hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`)}\n`);
170
+ console.log(` Min Liq Distance: ${limits.minLiquidationDistance}% ${chalk.gray(`(hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`)}`);
171
+ console.log(chalk.gray(`\n When both USD and % are set, the stricter limit applies.\n`));
152
172
  console.log(chalk.gray(` Config file: ~/.perp/risk.json\n`));
153
173
  });
154
174
  // ── risk check ── (pre-trade check, for agent use)
@@ -389,9 +389,11 @@ export function registerWalletCommands(program, isJson) {
389
389
  const { LighterAdapter } = await import("../exchanges/lighter.js");
390
390
  const adapter = new LighterAdapter(normalized);
391
391
  await adapter.init();
392
- const { privateKey: apiKey } = await adapter.setupApiKey();
392
+ const apiKeyIndex = 2;
393
+ const { privateKey: apiKey } = await adapter.setupApiKey(apiKeyIndex);
393
394
  setEnvVar("LIGHTER_API_KEY", apiKey);
394
395
  setEnvVar("LIGHTER_ACCOUNT_INDEX", String(adapter.accountIndex));
396
+ setEnvVar("LIGHTER_API_KEY_INDEX", String(apiKeyIndex));
395
397
  lighterApiSetup = { apiKey, accountIndex: adapter.accountIndex };
396
398
  }
397
399
  catch (e) {
@@ -20,6 +20,11 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
20
20
  init(): Promise<void>;
21
21
  /** Load asset index map — supports native and HIP-3 dex. */
22
22
  private _loadAssetMap;
23
+ /**
24
+ * Resolve a symbol to the canonical name in the asset map.
25
+ * Handles: "ICP" → "ICP-PERP", "BTC-PERP" → "BTC-PERP", "km:GOOGL" → "km:GOOGL"
26
+ */
27
+ resolveSymbol(symbol: string): string;
23
28
  getAssetIndex(symbol: string): number;
24
29
  getMarkets(): Promise<ExchangeMarketInfo[]>;
25
30
  getOrderbook(symbol: string): Promise<{
@@ -51,7 +56,7 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
51
56
  cancelOrder(symbol: string, orderId: string): Promise<import("hyperliquid").CancelOrderResponse>;
52
57
  cancelAllOrders(symbol?: string): Promise<import("hyperliquid").CancelOrderResponse[]>;
53
58
  editOrder(symbol: string, orderId: string, price: string, size: string): Promise<unknown>;
54
- setLeverage(symbol: string, leverage: number, marginMode?: "cross" | "isolated"): Promise<unknown>;
59
+ setLeverage(symbol: string, leverage: number, marginMode?: "cross" | "isolated"): Promise<any>;
55
60
  stopOrder(symbol: string, side: "buy" | "sell", size: string, triggerPrice: string, opts?: {
56
61
  limitPrice?: string;
57
62
  reduceOnly?: boolean;
@@ -90,14 +95,14 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
90
95
  twapCancel(symbol: string, twapId: number): Promise<unknown>;
91
96
  /**
92
97
  * Update leverage for a symbol.
93
- * Python SDK: update_leverage(leverage, name, is_cross)
98
+ * Uses SDK's built-in updateLeverage method.
94
99
  */
95
- updateLeverage(symbol: string, leverage: number, isCross?: boolean): Promise<unknown>;
100
+ updateLeverage(symbol: string, leverage: number, isCross?: boolean): Promise<any>;
96
101
  /**
97
102
  * Update isolated margin for a position.
98
103
  * amount > 0 to add margin, amount < 0 to remove
99
104
  */
100
- updateIsolatedMargin(symbol: string, amount: number): Promise<unknown>;
105
+ updateIsolatedMargin(symbol: string, amount: number): Promise<any>;
101
106
  /**
102
107
  * Withdraw from Hyperliquid L1 bridge.
103
108
  * Python SDK: withdraw_from_bridge(amount, destination) → action type "withdraw3"
@@ -93,24 +93,34 @@ export class HyperliquidAdapter {
93
93
  // non-critical
94
94
  }
95
95
  }
96
- getAssetIndex(symbol) {
96
+ /**
97
+ * Resolve a symbol to the canonical name in the asset map.
98
+ * Handles: "ICP" → "ICP-PERP", "BTC-PERP" → "BTC-PERP", "km:GOOGL" → "km:GOOGL"
99
+ */
100
+ resolveSymbol(symbol) {
97
101
  const sym = symbol.toUpperCase();
98
- // Try exact match first (handles both "BTC" for native and "km:GOOGL" for dex)
99
- let idx = this._assetMap.get(sym);
100
- if (idx !== undefined)
101
- return idx;
102
- // For dex: try lowercase prefix (API returns "km:GOOGL" but input may be "KM:GOOGL")
102
+ if (this._assetMap.has(sym))
103
+ return sym;
104
+ if (this._assetMap.has(`${sym}-PERP`))
105
+ return `${sym}-PERP`;
106
+ if (sym.endsWith("-PERP") && this._assetMap.has(sym.replace(/-PERP$/, "")))
107
+ return sym.replace(/-PERP$/, "");
103
108
  if (sym.includes(":")) {
104
109
  const [prefix, base] = sym.split(":");
105
- idx = this._assetMap.get(`${prefix.toLowerCase()}:${base}`);
106
- if (idx !== undefined)
107
- return idx;
108
- // Try base name only (e.g., "GOOGL")
109
- idx = this._assetMap.get(base);
110
- if (idx !== undefined)
111
- return idx;
110
+ const lower = `${prefix.toLowerCase()}:${base}`;
111
+ if (this._assetMap.has(lower))
112
+ return lower;
113
+ if (this._assetMap.has(base))
114
+ return base;
112
115
  }
113
- throw new Error(`Unknown symbol: ${symbol}`);
116
+ return sym; // return as-is, let downstream error
117
+ }
118
+ getAssetIndex(symbol) {
119
+ const resolved = this.resolveSymbol(symbol);
120
+ const idx = this._assetMap.get(resolved);
121
+ if (idx !== undefined)
122
+ return idx;
123
+ throw new Error(`Unknown asset: ${symbol}`);
114
124
  }
115
125
  async getMarkets() {
116
126
  let universe;
@@ -586,31 +596,17 @@ export class HyperliquidAdapter {
586
596
  }
587
597
  /**
588
598
  * Update leverage for a symbol.
589
- * Python SDK: update_leverage(leverage, name, is_cross)
599
+ * Uses SDK's built-in updateLeverage method.
590
600
  */
591
601
  async updateLeverage(symbol, leverage, isCross = true) {
592
- const assetIndex = this.getAssetIndex(symbol);
593
- const action = {
594
- type: "updateLeverage",
595
- asset: assetIndex,
596
- isCross,
597
- leverage,
598
- };
599
- return this._sendExchangeAction(action);
602
+ return this.sdk.exchange.updateLeverage(this.resolveSymbol(symbol), isCross ? "cross" : "isolated", leverage);
600
603
  }
601
604
  /**
602
605
  * Update isolated margin for a position.
603
606
  * amount > 0 to add margin, amount < 0 to remove
604
607
  */
605
608
  async updateIsolatedMargin(symbol, amount) {
606
- const assetIndex = this.getAssetIndex(symbol);
607
- const action = {
608
- type: "updateIsolatedMargin",
609
- asset: assetIndex,
610
- isBuy: true,
611
- ntli: Math.round(amount * 1e6), // USD to 6 decimals
612
- };
613
- return this._sendExchangeAction(action);
609
+ return this.sdk.exchange.updateIsolatedMargin(this.resolveSymbol(symbol), amount > 0, Math.round(Math.abs(amount) * 1e6));
614
610
  }
615
611
  /**
616
612
  * Withdraw from Hyperliquid L1 bridge.
@@ -1,9 +1,10 @@
1
1
  import type { ExchangeAdapter, ExchangeMarketInfo, ExchangePosition, ExchangeOrder, ExchangeBalance, ExchangeTrade, ExchangeFundingPayment, ExchangeKline } from "./interface.js";
2
- type LighterSignerClientType = import("lighter-sdk").LighterSignerClient;
2
+ import type { WasmSignerClient as WasmSignerClientType } from "lighter-ts-sdk";
3
3
  export declare class LighterAdapter implements ExchangeAdapter {
4
4
  readonly name = "lighter";
5
5
  private _signer;
6
6
  private _accountIndex;
7
+ private _apiKeyIndex;
7
8
  private _address;
8
9
  private _marketMap;
9
10
  private _marketDecimals;
@@ -23,7 +24,11 @@ export declare class LighterAdapter implements ExchangeAdapter {
23
24
  apiKey?: string;
24
25
  accountIndex?: number;
25
26
  });
26
- get signer(): LighterSignerClientType;
27
+ get signer(): {
28
+ createAuthToken(deadline: number): Promise<{
29
+ authToken: string;
30
+ }>;
31
+ } & WasmSignerClientType;
27
32
  get accountIndex(): number;
28
33
  get address(): string;
29
34
  get evmKey(): string;
@@ -137,12 +142,13 @@ export declare class LighterAdapter implements ExchangeAdapter {
137
142
  private sendTx;
138
143
  private restGet;
139
144
  private ensureSigner;
140
- private static getWasmOps;
145
+ private static _wasmClient;
146
+ private static getWasmClient;
141
147
  /**
142
148
  * Generate a new Lighter API key pair using the WASM signer.
143
149
  * Returns { privateKey, publicKey } (both 0x-prefixed, 40 bytes).
144
150
  */
145
- static generateApiKey(seed?: string): Promise<{
151
+ static generateApiKey(): Promise<{
146
152
  privateKey: string;
147
153
  publicKey: string;
148
154
  }>;
@@ -156,4 +162,3 @@ export declare class LighterAdapter implements ExchangeAdapter {
156
162
  publicKey: string;
157
163
  }>;
158
164
  }
159
- export {};
@@ -1,11 +1,8 @@
1
- import { createRequire } from "node:module";
2
- // Use createRequire to load the CJS build of lighter-sdk
3
- // (the ESM build has a broken require polyfill that fails on Node built-ins)
4
- const require = createRequire(import.meta.url);
5
1
  export class LighterAdapter {
6
2
  name = "lighter";
7
3
  _signer;
8
4
  _accountIndex = -1;
5
+ _apiKeyIndex;
9
6
  _address;
10
7
  _marketMap = new Map(); // symbol → marketIndex
11
8
  _marketDecimals = new Map(); // symbol → decimals
@@ -26,6 +23,7 @@ export class LighterAdapter {
26
23
  this._evmKey = evmKey;
27
24
  this._apiKey = opts?.apiKey || process.env.LIGHTER_API_KEY || "";
28
25
  this._accountIndexInit = opts?.accountIndex ?? parseInt(process.env.LIGHTER_ACCOUNT_INDEX || "-1");
26
+ this._apiKeyIndex = parseInt(process.env.LIGHTER_API_KEY_INDEX || "2");
29
27
  this._address = "";
30
28
  this._testnet = testnet;
31
29
  this._readOnly = !this._apiKey;
@@ -35,7 +33,16 @@ export class LighterAdapter {
35
33
  this._chainId = testnet ? 300 : 304;
36
34
  }
37
35
  get signer() {
38
- return this._signer;
36
+ const self = this;
37
+ // Compatibility wrapper: ws-feeds.ts expects createAuthToken(deadline) → { authToken }
38
+ return Object.create(this._signer, {
39
+ createAuthToken: {
40
+ value: async (deadline) => {
41
+ const token = await self._signer.createAuthToken(deadline, self._apiKeyIndex, self._accountIndex);
42
+ return { authToken: token };
43
+ },
44
+ },
45
+ });
39
46
  }
40
47
  get accountIndex() {
41
48
  return this._accountIndex;
@@ -65,33 +72,37 @@ export class LighterAdapter {
65
72
  // Auto-generate API key if we have PK but no API key and account exists
66
73
  if (!this._apiKey && this._accountIndex >= 0) {
67
74
  try {
68
- const { privateKey: apiKey } = await this.setupApiKey();
75
+ const autoKeyIndex = 2; // default for auto-setup
76
+ const { privateKey: apiKey } = await this.setupApiKey(autoKeyIndex);
69
77
  this._apiKey = apiKey;
78
+ this._apiKeyIndex = autoKeyIndex;
70
79
  // Save to .env for future use
71
80
  try {
72
81
  const { setEnvVar } = await import("../commands/init.js");
73
82
  setEnvVar("LIGHTER_API_KEY", apiKey);
74
83
  setEnvVar("LIGHTER_ACCOUNT_INDEX", String(this._accountIndex));
84
+ setEnvVar("LIGHTER_API_KEY_INDEX", String(autoKeyIndex));
75
85
  }
76
86
  catch { /* non-critical — env save may fail in some contexts */ }
77
87
  }
78
- catch {
79
- // Auto-setup failed (no gas, network issue, etc.) continue in read-only mode
88
+ catch (e) {
89
+ // Auto-setup failed log the error and continue in read-only mode
90
+ const msg = e instanceof Error ? e.message : String(e);
91
+ console.error(`[lighter] API key auto-setup failed: ${msg}. Trading will be read-only. Run 'perp -e lighter manage setup-api-key' to retry.`);
80
92
  }
81
93
  }
82
94
  // Initialize signer for trading if we have an API key
83
95
  if (this._apiKey) {
84
- const { LighterSignerClient } = require("lighter-sdk");
85
- this._signer = new LighterSignerClient({
96
+ const { WasmSignerClient } = await import("lighter-ts-sdk");
97
+ this._signer = new WasmSignerClient({});
98
+ await this._signer.initialize();
99
+ await this._signer.createClient({
86
100
  url: this._baseUrl,
87
101
  privateKey: this._apiKey,
88
102
  chainId: this._chainId,
103
+ apiKeyIndex: this._apiKeyIndex,
89
104
  accountIndex: this._accountIndex,
90
- apiKeyIndex: parseInt(process.env.LIGHTER_API_KEY_INDEX || "3"),
91
- loaderType: "wasm",
92
105
  });
93
- // Only initialize (load WASM + create client), skip checkClient which does HTTP from WASM
94
- await this._signer.initialize();
95
106
  this._readOnly = false;
96
107
  }
97
108
  // Build symbol → marketIndex map + decimals from orderBookDetails
@@ -230,8 +241,7 @@ export class LighterAdapter {
230
241
  if (this._readOnly)
231
242
  throw new Error("Auth requires API key");
232
243
  const deadline = Math.floor(Date.now() / 1000) + 3600;
233
- const auth = await this._signer.createAuthToken(deadline);
234
- return auth.authToken;
244
+ return this._signer.createAuthToken(deadline, this._apiKeyIndex, this._accountIndex);
235
245
  }
236
246
  async restGetAuth(path, params) {
237
247
  const auth = await this.getAuthToken();
@@ -291,7 +301,7 @@ export class LighterAdapter {
291
301
  baseAmount,
292
302
  price: Math.max(slippageTicks, 1),
293
303
  isAsk: side === "sell" ? 1 : 0,
294
- type: 1, // ORDER_TYPE_MARKET
304
+ orderType: 1, // ORDER_TYPE_MARKET
295
305
  timeInForce: 0, // IOC (Immediate or Cancel)
296
306
  reduceOnly: 0,
297
307
  triggerPrice: 0,
@@ -311,7 +321,7 @@ export class LighterAdapter {
311
321
  baseAmount,
312
322
  price: priceTicks,
313
323
  isAsk: side === "sell" ? 1 : 0,
314
- type: 0, // ORDER_TYPE_LIMIT
324
+ orderType: 0, // ORDER_TYPE_LIMIT
315
325
  timeInForce: 1, // GTT (Good Till Time) — rests on orderbook
316
326
  reduceOnly: opts?.reduceOnly ? 1 : 0,
317
327
  triggerPrice: 0,
@@ -324,13 +334,19 @@ export class LighterAdapter {
324
334
  this.ensureSigner();
325
335
  const nonce = await this.getNextNonce();
326
336
  const marketIndex = this.getMarketIndex(symbol);
327
- const signed = await this._signer.signCancelOrder(marketIndex, parseInt(orderId), nonce);
337
+ const signed = await this._signer.signCancelOrder({
338
+ marketIndex, orderIndex: parseInt(orderId), nonce,
339
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
340
+ });
328
341
  return this.sendTx(signed);
329
342
  }
330
343
  async cancelAllOrders(_symbol) {
331
344
  this.ensureSigner();
332
345
  const nonce = await this.getNextNonce();
333
- const signed = await this._signer.signCancelAllOrders(0, Math.floor(Date.now() / 1000) + 86400, nonce);
346
+ const signed = await this._signer.signCancelAllOrders({
347
+ timeInForce: 0, time: Math.floor(Date.now() / 1000) + 86400, nonce,
348
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
349
+ });
334
350
  return this.sendTx(signed);
335
351
  }
336
352
  async modifyOrder(symbol, orderId, price, size) {
@@ -345,6 +361,8 @@ export class LighterAdapter {
345
361
  price: priceTicks,
346
362
  triggerPrice: 0,
347
363
  nonce,
364
+ apiKeyIndex: this._apiKeyIndex,
365
+ accountIndex: this._accountIndex,
348
366
  });
349
367
  if (signed.error) {
350
368
  throw new Error(`Signer: ${signed.error}`);
@@ -374,7 +392,7 @@ export class LighterAdapter {
374
392
  baseAmount,
375
393
  price: Math.max(priceTicks, 1),
376
394
  isAsk: side === "sell" ? 1 : 0,
377
- type: isMarket ? 1 : 0,
395
+ orderType: isMarket ? 1 : 0,
378
396
  timeInForce: isMarket ? 0 : 1, // IOC for market, GTT for limit
379
397
  reduceOnly: opts?.reduceOnly ? 1 : 0,
380
398
  triggerPrice: triggerTicks,
@@ -390,16 +408,22 @@ export class LighterAdapter {
390
408
  // fraction = initial margin fraction in basis points: 10000/leverage
391
409
  const fraction = Math.round(10000 / leverage);
392
410
  const mode = marginMode === "isolated" ? 1 : 0;
393
- const signed = await this._signer.signUpdateLeverage(marketIndex, fraction, mode, nonce);
411
+ const signed = await this._signer.signUpdateLeverage({
412
+ marketIndex, fraction, marginMode: mode, nonce,
413
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
414
+ });
394
415
  if (signed.error) {
395
416
  throw new Error(`Signer: ${signed.error}`);
396
417
  }
397
418
  return this.sendTx(signed);
398
419
  }
399
- async withdraw(amount, assetId = 2, routeType = 0) {
420
+ async withdraw(amount, assetId = 3, routeType = 0) {
400
421
  this.ensureSigner();
401
422
  const nonce = await this.getNextNonce();
402
- const signed = await this._signer.signWithdraw(amount, assetId, routeType, nonce);
423
+ const signed = await this._signer.signWithdraw({
424
+ usdcAmount: amount, assetIndex: assetId, routeType, nonce,
425
+ apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
426
+ });
403
427
  return this.sendTx(signed);
404
428
  }
405
429
  // ── Interface aliases ──
@@ -680,16 +704,19 @@ export class LighterAdapter {
680
704
  * Direct REST GET helper.
681
705
  */
682
706
  async getNextNonce() {
683
- const apiKeyIndex = parseInt(process.env.LIGHTER_API_KEY_INDEX || "3");
684
707
  const res = await this.restGet("/nextNonce", {
685
708
  account_index: String(this._accountIndex),
686
- api_key_index: String(apiKeyIndex),
709
+ api_key_index: String(this._apiKeyIndex),
687
710
  });
688
711
  return res.nonce ?? res.next_nonce ?? 0;
689
712
  }
690
713
  async signOrder(params) {
691
714
  try {
692
- const result = await this._signer.signCreateOrder(params);
715
+ const result = await this._signer.signCreateOrder({
716
+ ...params,
717
+ apiKeyIndex: this._apiKeyIndex,
718
+ accountIndex: this._accountIndex,
719
+ });
693
720
  if (result.error) {
694
721
  throw new Error(`Signer: ${result.error}`);
695
722
  }
@@ -737,23 +764,23 @@ export class LighterAdapter {
737
764
  "then set LIGHTER_API_KEY in your .env");
738
765
  }
739
766
  }
740
- static async getWasmOps() {
741
- const { WasmLoader } = require("lighter-sdk");
742
- const loader = WasmLoader.getInstance();
743
- await loader.load({});
744
- return loader.getOperations();
767
+ static _wasmClient = null;
768
+ static async getWasmClient() {
769
+ if (LighterAdapter._wasmClient)
770
+ return LighterAdapter._wasmClient;
771
+ const { WasmSignerClient } = await import("lighter-ts-sdk");
772
+ const client = new WasmSignerClient({});
773
+ await client.initialize();
774
+ LighterAdapter._wasmClient = client;
775
+ return client;
745
776
  }
746
777
  /**
747
778
  * Generate a new Lighter API key pair using the WASM signer.
748
779
  * Returns { privateKey, publicKey } (both 0x-prefixed, 40 bytes).
749
780
  */
750
- static async generateApiKey(seed) {
751
- const ops = await LighterAdapter.getWasmOps();
752
- const result = ops
753
- .GenerateAPIKey(seed ?? `pacifica-cli-${Date.now()}-${Math.random()}`);
754
- if (result.err)
755
- throw new Error(`GenerateAPIKey failed: ${result.err}`);
756
- return { privateKey: result.privateKey, publicKey: result.publicKey };
781
+ static async generateApiKey() {
782
+ const client = await LighterAdapter.getWasmClient();
783
+ return client.generateAPIKey();
757
784
  }
758
785
  /**
759
786
  * Generate an API key and register it on-chain via ChangePubKey.
@@ -769,14 +796,23 @@ export class LighterAdapter {
769
796
  account_index: String(this._accountIndex),
770
797
  api_key_index: String(apiKeyIndex),
771
798
  });
772
- const nonce = nonceRes.next_nonce ?? 0;
799
+ const nonce = nonceRes.nonce ?? nonceRes.next_nonce ?? 0;
773
800
  // 3. Create signer client with new key and sign ChangePubKey
774
- const ops = await LighterAdapter.getWasmOps();
775
- const opsAny = ops;
776
- // CreateClient with the new API key
777
- opsAny.CreateClient(this._baseUrl, privateKey, this._chainId, apiKeyIndex, this._accountIndex);
801
+ const client = await LighterAdapter.getWasmClient();
802
+ await client.createClient({
803
+ url: this._baseUrl,
804
+ privateKey,
805
+ chainId: this._chainId,
806
+ apiKeyIndex,
807
+ accountIndex: this._accountIndex,
808
+ });
778
809
  // Sign ChangePubKey
779
- const signed = await opsAny.SignChangePubKey(publicKey, nonce, apiKeyIndex, this._accountIndex);
810
+ const signed = await client.signChangePubKey({
811
+ pubkey: publicKey,
812
+ nonce,
813
+ apiKeyIndex,
814
+ accountIndex: this._accountIndex,
815
+ });
780
816
  if (signed.error)
781
817
  throw new Error(`SignChangePubKey failed: ${signed.error}`);
782
818
  if (!signed.txInfo || !signed.messageToSign) {
package/dist/index.js CHANGED
File without changes
File without changes
package/dist/risk.d.ts CHANGED
@@ -8,7 +8,13 @@ export interface RiskLimits {
8
8
  maxLeverage: number;
9
9
  maxMarginUtilization: number;
10
10
  minLiquidationDistance: number;
11
+ maxDrawdownPct?: number;
12
+ maxPositionPct?: number;
13
+ maxExposurePct?: number;
14
+ dailyLossPct?: number;
11
15
  }
16
+ /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
17
+ export declare function effectiveLimit(usdLimit: number, pctLimit: number | undefined, totalEquity: number): number;
12
18
  /** Hard cap: liquidation distance can NEVER be set below this % */
13
19
  export declare const LIQUIDATION_DISTANCE_HARD_CAP = 20;
14
20
  export declare function loadRiskLimits(): RiskLimits;
package/dist/risk.js CHANGED
@@ -9,7 +9,16 @@ const DEFAULT_LIMITS = {
9
9
  maxLeverage: 20,
10
10
  maxMarginUtilization: 80,
11
11
  minLiquidationDistance: 30,
12
+ maxDrawdownPct: 10,
13
+ maxPositionPct: 25,
12
14
  };
15
+ /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
16
+ export function effectiveLimit(usdLimit, pctLimit, totalEquity) {
17
+ if (pctLimit == null || totalEquity <= 0)
18
+ return usdLimit;
19
+ const pctInUsd = (pctLimit / 100) * totalEquity;
20
+ return Math.min(usdLimit, pctInUsd);
21
+ }
13
22
  /** Hard cap: liquidation distance can NEVER be set below this % */
14
23
  export const LIQUIDATION_DISTANCE_HARD_CAP = 20;
15
24
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
@@ -87,32 +96,36 @@ export function assessRisk(balances, positions, limits) {
87
96
  maxLeverageUsed = lev;
88
97
  }
89
98
  const marginUtilization = totalEquity > 0 ? (totalMarginUsed / totalEquity) * 100 : 0;
99
+ // Resolve effective limits (min of USD and % of equity)
100
+ const effDrawdown = effectiveLimit(lim.maxDrawdownUsd, lim.maxDrawdownPct, totalEquity);
101
+ const effPosition = effectiveLimit(lim.maxPositionUsd, lim.maxPositionPct, totalEquity);
102
+ const effExposure = effectiveLimit(lim.maxTotalExposureUsd, lim.maxExposurePct, totalEquity);
90
103
  // Check violations
91
- if (totalUnrealizedPnl < -lim.maxDrawdownUsd) {
104
+ if (totalUnrealizedPnl < -effDrawdown) {
92
105
  violations.push({
93
106
  rule: "max_drawdown",
94
107
  severity: "critical",
95
- message: `Unrealized loss $${Math.abs(totalUnrealizedPnl).toFixed(2)} exceeds max drawdown $${lim.maxDrawdownUsd}`,
108
+ message: `Unrealized loss $${Math.abs(totalUnrealizedPnl).toFixed(2)} exceeds max drawdown $${effDrawdown.toFixed(2)}${lim.maxDrawdownPct != null ? ` (${lim.maxDrawdownPct}% of equity)` : ""}`,
96
109
  current: Math.abs(totalUnrealizedPnl),
97
- limit: lim.maxDrawdownUsd,
110
+ limit: effDrawdown,
98
111
  });
99
112
  }
100
- if (largestPositionUsd > lim.maxPositionUsd) {
113
+ if (largestPositionUsd > effPosition) {
101
114
  violations.push({
102
115
  rule: "max_position_size",
103
116
  severity: "high",
104
- message: `Largest position $${largestPositionUsd.toFixed(2)} exceeds limit $${lim.maxPositionUsd}`,
117
+ message: `Largest position $${largestPositionUsd.toFixed(2)} exceeds limit $${effPosition.toFixed(2)}${lim.maxPositionPct != null ? ` (${lim.maxPositionPct}% of equity)` : ""}`,
105
118
  current: largestPositionUsd,
106
- limit: lim.maxPositionUsd,
119
+ limit: effPosition,
107
120
  });
108
121
  }
109
- if (totalExposure > lim.maxTotalExposureUsd) {
122
+ if (totalExposure > effExposure) {
110
123
  violations.push({
111
124
  rule: "max_total_exposure",
112
125
  severity: "high",
113
- message: `Total exposure $${totalExposure.toFixed(2)} exceeds limit $${lim.maxTotalExposureUsd}`,
126
+ message: `Total exposure $${totalExposure.toFixed(2)} exceeds limit $${effExposure.toFixed(2)}${lim.maxExposurePct != null ? ` (${lim.maxExposurePct}% of equity)` : ""}`,
114
127
  current: totalExposure,
115
- limit: lim.maxTotalExposureUsd,
128
+ limit: effExposure,
116
129
  });
117
130
  }
118
131
  if (positions.length > lim.maxPositions) {
@@ -205,11 +218,14 @@ export function preTradeCheck(assessment, newOrderNotional, newOrderLeverage) {
205
218
  return { allowed: false, reason: "Trading suspended: critical risk violation active" };
206
219
  }
207
220
  const lim = assessment.limits;
208
- if (newOrderNotional > lim.maxPositionUsd) {
209
- return { allowed: false, reason: `Order notional $${newOrderNotional.toFixed(0)} exceeds max position size $${lim.maxPositionUsd}` };
210
- }
211
- if (assessment.metrics.totalExposure + newOrderNotional > lim.maxTotalExposureUsd) {
212
- return { allowed: false, reason: `Would exceed total exposure limit ($${(assessment.metrics.totalExposure + newOrderNotional).toFixed(0)} > $${lim.maxTotalExposureUsd})` };
221
+ const equity = assessment.metrics.totalEquity;
222
+ const effPosition = effectiveLimit(lim.maxPositionUsd, lim.maxPositionPct, equity);
223
+ const effExposure = effectiveLimit(lim.maxTotalExposureUsd, lim.maxExposurePct, equity);
224
+ if (newOrderNotional > effPosition) {
225
+ return { allowed: false, reason: `Order notional $${newOrderNotional.toFixed(0)} exceeds max position size $${effPosition.toFixed(0)}${lim.maxPositionPct != null ? ` (${lim.maxPositionPct}% of $${equity.toFixed(0)} equity)` : ""}` };
226
+ }
227
+ if (assessment.metrics.totalExposure + newOrderNotional > effExposure) {
228
+ return { allowed: false, reason: `Would exceed total exposure limit ($${(assessment.metrics.totalExposure + newOrderNotional).toFixed(0)} > $${effExposure.toFixed(0)})` };
213
229
  }
214
230
  if (assessment.metrics.positionCount + 1 > lim.maxPositions) {
215
231
  return { allowed: false, reason: `Would exceed max positions (${assessment.metrics.positionCount + 1} > ${lim.maxPositions})` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter",
5
5
  "bin": {
6
6
  "perp": "./dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "dotenv": "^16.5.0",
47
47
  "ethers": "^6.13.2",
48
48
  "hyperliquid": "^1.7.7",
49
- "lighter-sdk": "^0.0.17",
49
+ "lighter-ts-sdk": "^1.0.10",
50
50
  "tweetnacl": "^1.0.3",
51
51
  "ws": "^8.19.0",
52
52
  "yaml": "^2.8.2",
@@ -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.7"
8
+ version: "0.3.9"
9
9
  mcp-server: perp-cli
10
10
  ---
11
11
 
@@ -66,6 +66,12 @@ perp --json -e lighter ... # Lighter (Ethereum)
66
66
  ```
67
67
  If a default exchange is set, `-e` can be omitted.
68
68
 
69
+ ### Symbol naming
70
+ Symbols are auto-resolved across exchanges. Use bare symbols (e.g., `BTC`, `SOL`, `ICP`) everywhere — the CLI handles exchange-specific naming:
71
+ - **Hyperliquid**: `ICP` → `ICP-PERP` (auto-resolved, `-PERP` suffix added)
72
+ - **Pacifica / Lighter**: bare symbols as-is
73
+ - `arb scan` returns bare symbols — pass them directly to any exchange command.
74
+
69
75
  ### Common operations
70
76
  ```bash
71
77
  perp --json wallet show # check configured wallets
@@ -73,10 +79,19 @@ perp --json -e hl account info # balance & margin
73
79
  perp --json -e hl account positions # open positions
74
80
  perp --json -e hl market list # available markets
75
81
  perp --json -e hl market mid BTC # BTC mid price
76
- perp --json arb rates # cross-exchange funding rates
82
+ perp --json arb scan --min 5 # find funding arb opportunities (>5bps spread)
77
83
  perp --json portfolio # unified multi-exchange view
78
84
  ```
79
85
 
86
+ ### Funding arb direction (CRITICAL — do NOT reverse)
87
+ ```
88
+ arb scan returns: longExch, shortExch, netSpread
89
+ → ALWAYS follow longExch/shortExch exactly. NEVER reverse the direction.
90
+ → NEVER enter if netSpread ≤ 0 (= loss after fees)
91
+ → Positive funding = longs pay shorts → be SHORT to receive
92
+ → Negative funding = shorts pay longs → be LONG to receive
93
+ ```
94
+
80
95
  ### Trade execution (MANDATORY checklist)
81
96
  ```
82
97
  BEFORE ANY TRADE:
@@ -89,12 +104,51 @@ BEFORE ANY TRADE:
89
104
  1. perp --json risk status → check risk level (STOP if critical)
90
105
  2. perp --json -e <EX> account info → verify EXCHANGE-SPECIFIC balance
91
106
  3. perp --json -e <EX> market mid <SYM> → current price
92
- 4. perp --json risk check --notional <$> --leverage <L> → risk pre-check
93
- 5. perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> trade validation
94
- 6. [Show order details + risk assessment to user, get explicit confirmation]
95
- 7. perp --json -e <EX> trade market <SYM> <SIDE> <SIZE> → execute
96
- 8. perp --json -e <EX> account positions → verify result + check liquidation price
107
+ 4. perp --json -e <EX> trade leverage <SYM> <N> --isolated set leverage + isolated margin FIRST
108
+ 5. perp --json risk check --notional <$> --leverage <L> → risk pre-check
109
+ 6. perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L> → trade validation
110
+ trade check does NOT read exchange-set leverage. ALWAYS pass --leverage explicitly.
111
+ 7. [Show order details + risk assessment to user, get explicit confirmation]
112
+ 8. perp --json -e <EX> trade market <SYM> <SIDE> <SIZE> → execute
113
+ 9. perp --json -e <EX> account positions → verify result + check liquidation price
114
+ ```
115
+
116
+ ### Exchange-specific constraints
117
+ ```
118
+ Minimum order values (notional, enforced by exchange):
119
+ - Hyperliquid: $10 minimum per order
120
+ - Pacifica: varies by symbol (usually ~$1)
121
+ - Lighter: varies by symbol
122
+
123
+ If your calculated size falls below the minimum, increase to meet it or skip the opportunity.
124
+ trade check returns valid: true/false but is ADVISORY — it does NOT block execution.
125
+ The exchange itself will reject orders below its minimum.
126
+ ```
127
+
128
+ ### Arb order sizing (CRITICAL — both legs MUST match)
129
+ ```
130
+ For funding arb, BOTH legs must have the EXACT SAME SIZE. Size mismatch = directional exposure.
131
+
132
+ 1. Check orderbook depth on BOTH exchanges:
133
+ perp --json -e <LONG_EX> market book <SYM> → asks (you're buying)
134
+ perp --json -e <SHORT_EX> market book <SYM> → bids (you're selling)
135
+
136
+ 2. Compute ORDER_SIZE:
137
+ - fillable_long = sum of ask sizes at best 2-3 levels
138
+ - fillable_short = sum of bid sizes at best 2-3 levels
139
+ - ORDER_SIZE = min(fillable_long, fillable_short, desired_size)
140
+ - Must be ≥ BOTH exchanges' minimum order value (e.g. HL requires ≥$10 notional)
141
+
142
+ 3. Execute BOTH legs with the SAME ORDER_SIZE:
143
+ perp --json -e <LONG_EX> trade market <SYM> buy <ORDER_SIZE>
144
+ → verify fill via account positions
145
+ perp --json -e <SHORT_EX> trade market <SYM> sell <ORDER_SIZE>
146
+ → verify fill via account positions
147
+
148
+ 4. Confirm matched: both positions must show identical size.
149
+ If mismatch (partial fill), adjust the larger to match the smaller.
97
150
  ```
151
+ See `references/strategies.md` for detailed execution strategy (chunked orders, limit orders, failure handling).
98
152
 
99
153
  ### Post-entry monitoring (MANDATORY while positions are open)
100
154
  ```
@@ -104,7 +158,7 @@ Every 15 minutes:
104
158
  perp --json -e <EX> account positions → check each position P&L
105
159
 
106
160
  Every 1 hour (at funding settlement):
107
- perp --json arb rates → is spread still profitable?
161
+ perp --json arb scan --min 5 → is spread still profitable?
108
162
  perp --json portfolio → total equity across exchanges
109
163
  Compare both legs' unrealized P&L — they should roughly offset
110
164
 
@@ -78,7 +78,7 @@ perp --json bridge send --from solana --to arbitrum --amount 500
78
78
  perp --json bridge status <orderId> # wait for completion
79
79
 
80
80
  # 5. Verify both sides have balance, then start arb
81
- perp --json arb rates
81
+ perp --json arb scan --min 5
82
82
  ```
83
83
 
84
84
  ### Lighter API Key Setup
@@ -229,7 +229,7 @@ Error case:
229
229
  - `wallet show`, `wallet balance` — read-only
230
230
  - `account info`, `account positions`, `account orders` — read-only
231
231
  - `market list`, `market mid`, `market book` — read-only
232
- - `arb rates`, `arb scan` — read-only
232
+ - `arb scan` — read-only (`arb rates` is deprecated)
233
233
  - `portfolio`, `risk overview` — read-only
234
234
  - `bridge quote` — read-only
235
235
  - `bridge status` — read-only
@@ -242,6 +242,29 @@ Error case:
242
242
 
243
243
  **For non-idempotent commands:** always verify the result before retrying. Check positions or balances to confirm whether the first attempt succeeded.
244
244
 
245
+ ## Symbol Naming Across Exchanges
246
+
247
+ Symbols are auto-resolved by the CLI. **Always use bare symbols** (e.g., `BTC`, `SOL`, `ICP`) — the CLI handles exchange-specific naming automatically:
248
+
249
+ | Input | Hyperliquid | Pacifica | Lighter |
250
+ |-------|-------------|----------|---------|
251
+ | `ICP` | → `ICP-PERP` | → `ICP` | → `ICP` |
252
+ | `BTC` | → `BTC` | → `BTC` | → `BTC` |
253
+ | `SOL` | → `SOL` | → `SOL` | → `SOL` |
254
+
255
+ - `arb scan` returns bare symbols — pass them directly to trade/leverage commands on any exchange.
256
+ - Do NOT manually add `-PERP` suffix — the CLI resolves this automatically.
257
+
258
+ ## Exchange-Specific Constraints
259
+
260
+ | Exchange | Min Order (notional) | Notes |
261
+ |----------|---------------------|-------|
262
+ | Hyperliquid | **$10** | Rejects orders below $10 notional |
263
+ | Pacifica | ~$1 (varies by symbol) | Lower minimums |
264
+ | Lighter | Varies by symbol | Check market info |
265
+
266
+ **`trade check` is advisory only** — it returns `valid: true/false` but does NOT block execution. The exchange itself enforces minimums and will reject with an error if the order is too small.
267
+
245
268
  ## Common Agent Mistakes
246
269
 
247
270
  1. **Using `perp init`** — interactive, will hang forever. Use `wallet set` instead.
@@ -250,3 +273,5 @@ Error case:
250
273
  4. **Retrying a trade without checking** — leads to double positions. Always check `account positions` after a trade, even if it seemed to fail.
251
274
  5. **Bridging without quoting** — always run `bridge quote` first to show the user fees and estimated time.
252
275
  6. **Assuming deposit is instant** — after `bridge send`, wait for `bridge status` to confirm completion before depositing to the destination exchange.
276
+ 7. **Manually adding `-PERP` suffix** — the CLI auto-resolves symbols. Just use bare names like `ICP`, `SOL`, `BTC`.
277
+ 8. **Order below exchange minimum** — Hyperliquid requires $10+ notional. Compute `size × price` before submitting.
@@ -47,6 +47,8 @@ perp --json trade edit <SYMBOL> <ORDER_ID> <PRICE> <SIZE>
47
47
  perp --json trade cancel <SYMBOL> <ORDER_ID>
48
48
  perp --json trade cancel-all
49
49
  perp --json trade check <SYMBOL> <SIDE> <SIZE> # pre-flight validation (no execution)
50
+ perp --json trade check <SYM> <SIDE> <SIZE> --leverage 3 # check with specific leverage
51
+ # NOTE: trade check does NOT read exchange-set leverage. Always pass --leverage explicitly.
50
52
 
51
53
  # Position management
52
54
  perp --json trade close <SYMBOL> # close single position
@@ -87,11 +89,11 @@ perp --json bridge status <ORDER_ID>
87
89
 
88
90
  ## Arbitrage
89
91
  ```bash
90
- perp --json arb rates # compare funding rates across exchanges
91
- perp --json arb scan --min <BPS> # find opportunities (>N bps spread)
92
+ perp --json arb scan --min <BPS> # find opportunities (>N bps spread) — PRIMARY command
92
93
  perp --json arb funding # detailed funding analysis
93
94
  perp --json arb dex # HIP-3 cross-dex arb (Hyperliquid)
94
95
  perp --json gap show # cross-exchange price gaps
96
+ # NOTE: 'arb rates' is deprecated — use 'arb scan' instead
95
97
  ```
96
98
 
97
99
  ## Wallet Management
@@ -9,7 +9,7 @@ You are not expected to follow rigid rules — use this as a decision framework
9
9
  - Funding rates settle **every 1 hour** on all supported exchanges
10
10
  - Positive rate = longs pay shorts, negative rate = shorts pay longs
11
11
  - Rates are annualized in display but applied hourly: `hourly = annual / 8760`
12
- - Scan rates: `perp --json arb rates`
12
+ - Scan opportunities: `perp --json arb scan --min 5` (shows spread, longExch, shortExch, netSpread)
13
13
 
14
14
  ### Opportunity Cost Awareness
15
15
 
@@ -56,10 +56,29 @@ During transition, you are **unhedged**. Price can move against you. Factor this
56
56
 
57
57
  ### Discovery Loop
58
58
  ```bash
59
- perp --json arb rates # compare rates across exchanges
60
- perp --json arb scan --min 10 # find spreads > 10 bps
59
+ perp --json arb scan --min 5 # find spreads > 5 bps (shows longExch/shortExch/netSpread)
60
+ # NOTE: 'arb rates' is deprecated use 'arb scan' instead
61
61
  ```
62
62
 
63
+ ### CRITICAL: Reading arb scan Results
64
+
65
+ The `arb scan` output tells you EXACTLY what to do:
66
+
67
+ ```
68
+ longExch: "hyperliquid" → open LONG on this exchange
69
+ shortExch: "pacifica" → open SHORT on this exchange
70
+ netSpread: 12.87 → profit after fees (bps/hour)
71
+ ```
72
+
73
+ **Rules:**
74
+ 1. **ALWAYS follow longExch/shortExch directions exactly.** DO NOT reverse them.
75
+ 2. **NEVER enter if netSpread ≤ 0.** Negative netSpread = loss after fees.
76
+ 3. The direction logic: go LONG where funding is negative (longs receive), go SHORT where funding is positive (shorts receive).
77
+
78
+ **Why these directions?**
79
+ - Positive funding (+) = longs pay shorts → you want to be SHORT to receive
80
+ - Negative funding (-) = shorts pay longs → you want to be LONG to receive
81
+
63
82
  ### Decision Framework
64
83
  When evaluating an arb opportunity:
65
84
 
@@ -118,53 +137,102 @@ Actual hold: 6h | Actual net: ~130 bps
118
137
  perp --json portfolio # unified multi-exchange view
119
138
  perp --json risk overview # cross-exchange risk assessment
120
139
  perp --json -e <EX> account positions # per-exchange positions
121
- perp --json arb rates # are current rates still favorable?
140
+ perp --json arb scan --min 5 # are current rates still favorable?
122
141
  ```
123
142
 
124
- ### Order Execution: Sequential Leg Management
143
+ ### Order Execution: Matched Size, Sequential Legs
144
+
145
+ **The #1 rule of arb execution: BOTH LEGS MUST HAVE THE EXACT SAME SIZE.** A size mismatch means you have net directional exposure — the whole point of arb is to be delta-neutral.
125
146
 
126
- **NEVER close or open both legs of an arb at once with market orders.** You must manage execution carefully.
147
+ #### Step 1: Determine Matched Order Size
127
148
 
128
- #### Why This Matters
129
- Orderbooks have limited depth at each price level. A large market order will eat through multiple ticks and suffer heavy slippage. Worse, if you close one leg but fail to close the other (exchange error, rate limit, network issue), you are left with naked directional exposure.
149
+ Before placing ANY order, compute a single `ORDER_SIZE` that BOTH exchanges can fill:
130
150
 
131
- #### Pre-Execution: Check Orderbook Depth
132
- Before executing, verify that the orderbook can absorb your size at acceptable prices on BOTH sides:
133
151
  ```bash
134
- perp --json -e <EX_A> market book <SYM> # check bids/asks depth
135
- perp --json -e <EX_B> market book <SYM> # check bids/asks depth
136
- ```
137
-
138
- Look at the size available at the best tick. If your order size exceeds what's available at the best 2-3 ticks, you MUST split the order.
139
-
140
- #### Execution Strategy
141
- 1. **Determine executable chunk size** — the largest size both orderbooks can absorb at the best tick without excessive slippage
142
- 2. **Execute in sequential chunks:**
143
- ```
144
- Chunk 1: close X on Exchange A → immediately open X on Exchange B
145
- Chunk 2: close X on Exchange A → immediately open X on Exchange B
146
- ... repeat until full size is executed
147
- ```
148
- 3. **Verify each chunk** before proceeding to the next:
149
- ```bash
150
- perp --json -e <EX_A> account positions # confirm partial close
151
- perp --json -e <EX_B> account positions # confirm partial open
152
- ```
153
- 4. **Re-check the orderbook** between chunks — liquidity may have changed
154
-
155
- #### Paired Execution Rule
156
- Each chunk must be a **matched pair**: close on one side, open on the other. Never execute multiple closes without the corresponding opens. If one leg fails:
157
- - STOP immediately
158
- - Assess your current exposure
159
- - Decide whether to retry the failed leg or unwind the completed leg
160
- - Do NOT continue with remaining chunks
152
+ # 1. Check orderbook depth on BOTH sides
153
+ perp --json -e <LONG_EX> market book <SYM> # check asks (you're buying)
154
+ perp --json -e <SHORT_EX> market book <SYM> # check bids (you're selling)
155
+
156
+ # 2. Find immediately fillable size at best 2-3 ticks
157
+ # LONG side: sum ask sizes at best 2-3 ask levels → fillable_long
158
+ # SHORT side: sum bid sizes at best 2-3 bid levels → fillable_short
159
+
160
+ # 3. ORDER_SIZE = min(fillable_long, fillable_short, desired_size)
161
+ # The SMALLER side limits your matched size.
162
+ ```
163
+
164
+ **Example:**
165
+ ```
166
+ LONG exchange asks: $85.00 × 0.5, $85.01 × 0.3 fillable = 0.8
167
+ SHORT exchange bids: $85.10 × 0.4, $85.09 × 0.2 → fillable = 0.6
168
+ Desired size: 1.0
169
+
170
+ → ORDER_SIZE = min(0.8, 0.6, 1.0) = 0.6
171
+ Both legs get exactly 0.6
172
+ ```
173
+
174
+ **CRITICAL: Each exchange has its own minimum order size and step size.**
175
+ ```bash
176
+ perp --json -e <EX> market info <SYM> # check minOrderSize, stepSize
177
+ ```
178
+ Round `ORDER_SIZE` to the coarser step size of the two exchanges. If the matched size falls below either exchange's minimum, the arb is NOT executable at this size — reduce target or skip.
179
+
180
+ #### Step 2: Execute Legs Sequentially with Same Size
181
+
182
+ Once you have a single `ORDER_SIZE`, execute both legs using THAT EXACT SIZE:
183
+
184
+ ```bash
185
+ # Leg 1: Open on exchange A
186
+ perp --json -e <LONG_EX> trade market <SYM> buy <ORDER_SIZE>
187
+
188
+ # Verify leg 1 filled
189
+ perp --json -e <LONG_EX> account positions
190
+
191
+ # Leg 2: Open on exchange B with SAME size
192
+ perp --json -e <SHORT_EX> trade market <SYM> sell <ORDER_SIZE>
193
+
194
+ # Verify leg 2 filled
195
+ perp --json -e <SHORT_EX> account positions
196
+ ```
197
+
198
+ **After both legs, verify sizes match:**
199
+ ```bash
200
+ perp --json -e <LONG_EX> account positions # size = X
201
+ perp --json -e <SHORT_EX> account positions # size must = X
202
+ ```
203
+ If sizes differ (partial fill, rounding), immediately adjust the larger position to match the smaller one.
204
+
205
+ #### Step 3: Split Large Orders into Matched Chunks
206
+
207
+ If your desired size exceeds what the orderbooks can absorb at best ticks, split into chunks — but **every chunk must be a matched pair with identical size on both sides**:
208
+
209
+ ```
210
+ Chunk 1: buy 0.3 on Exchange A → sell 0.3 on Exchange B → verify both
211
+ Chunk 2: buy 0.3 on Exchange A → sell 0.3 on Exchange B → verify both
212
+ ... repeat until full size is executed
213
+ ```
214
+
215
+ **Rules:**
216
+ 1. Re-check orderbook depth between chunks — liquidity changes
217
+ 2. Each chunk: same size on both sides, no exceptions
218
+ 3. If one leg fails → STOP immediately, do NOT continue
219
+ 4. Assess exposure: retry the failed leg, or unwind the completed leg
220
+ 5. NEVER execute multiple orders on one side without matching the other
161
221
 
162
222
  #### Using Limit Orders for Better Execution
163
- For non-urgent transitions, consider limit orders at the best bid/ask instead of market orders:
223
+ For non-urgent entries, use limit orders at best bid/ask instead of market:
224
+ ```bash
225
+ perp --json -e <EX> trade buy <SYM> <ORDER_SIZE> -p <PRICE> # limit order
226
+ ```
227
+ This avoids crossing the spread but risks not getting filled. Set a timeout and fall back to market if unfilled. **Both legs must still use the same `ORDER_SIZE`.**
228
+
229
+ #### Closing Arb Positions: Same Rules Apply
230
+ When exiting, close BOTH legs with the SAME size:
164
231
  ```bash
165
- perp --json -e <EX> trade sell <SYM> <SIZE> -p <PRICE> # limit order
232
+ perp --json -e <LONG_EX> trade close <SYM> # closes full position
233
+ perp --json -e <SHORT_EX> trade close <SYM> # closes full position
166
234
  ```
167
- This avoids crossing the spread, but you risk not getting filled. Set a reasonable timeout and fall back to market if not filled.
235
+ If positions already have mismatched sizes, close to the smaller size first, then close the remaining delta on the larger side.
168
236
 
169
237
  ### When to Exit
170
238
  - Spread compressed below your breakeven (including fees)
@@ -236,18 +304,24 @@ perp --json -e <EX> account settings # shows leverage and margin_mode pe
236
304
 
237
305
  ### Risk Limits — Configure Before Trading
238
306
 
239
- Set your risk limits FIRST, before any trading activity:
307
+ Set your risk limits FIRST, before any trading activity.
308
+ Limits can be set in **USD or % of equity** (when both are set, the stricter one applies):
240
309
  ```bash
241
310
  perp --json risk limits \
242
311
  --max-leverage 5 \
243
312
  --max-margin 60 \
313
+ --max-drawdown-pct 10 \
314
+ --max-position-pct 25 \
315
+ --max-drawdown 500 \
244
316
  --max-position 5000 \
245
317
  --max-exposure 20000 \
246
- --max-drawdown 500 \
247
318
  --daily-loss 200 \
248
319
  --min-liq-distance 30
249
320
  ```
250
321
 
322
+ **Use % limits for small portfolios.** A $65 portfolio with `--max-drawdown 500` is meaningless.
323
+ With `--max-drawdown-pct 10`, the effective limit auto-adjusts to $6.50.
324
+
251
325
  **IMPORTANT: Ask the user about their risk tolerance BEFORE setting limits.** Key questions:
252
326
  - "How much leverage are you comfortable with?" (default: 5x for arb)
253
327
  - "What's your maximum acceptable loss?" (default: $500)
@@ -281,7 +355,7 @@ Check the output:
281
355
  #### Every hour (at funding settlement):
282
356
  ```bash
283
357
  perp --json portfolio # total equity across exchanges
284
- perp --json arb rates # are rates still favorable?
358
+ perp --json arb scan --min 5 # are rates still favorable?
285
359
  perp --json -e <EX_A> account positions # P&L on leg A
286
360
  perp --json -e <EX_B> account positions # P&L on leg B
287
361
  ```
@@ -11,9 +11,5 @@ echo "=== Funding Rate Arbitrage Scanner ==="
11
11
  echo "Minimum spread: ${MIN_SPREAD} bps"
12
12
  echo ""
13
13
 
14
- echo "1. Cross-exchange rates:"
15
- perp --json arb rates
16
-
17
- echo ""
18
- echo "2. Opportunities (>= ${MIN_SPREAD} bps):"
14
+ echo "1. Opportunities (>= ${MIN_SPREAD} bps):"
19
15
  perp --json arb scan --min "$MIN_SPREAD"