perp-cli 0.3.6 → 0.3.8
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 +1 -1
- package/dist/__tests__/risk-assessment.test.js +40 -1
- package/dist/commands/manage.js +1 -0
- package/dist/commands/risk.js +32 -12
- package/dist/commands/wallet.js +3 -1
- package/dist/exchanges/hyperliquid.d.ts +4 -4
- package/dist/exchanges/hyperliquid.js +3 -17
- package/dist/exchanges/lighter.d.ts +10 -5
- package/dist/exchanges/lighter.js +81 -45
- package/dist/index.js +3 -3
- package/dist/mcp-server.js +1 -1
- package/dist/risk.d.ts +6 -0
- package/dist/risk.js +30 -14
- package/package.json +2 -2
- package/skills/perp-cli/SKILL.md +44 -7
- package/skills/perp-cli/references/commands.md +2 -0
- package/skills/perp-cli/references/strategies.md +28 -2
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
|
+
});
|
package/dist/commands/manage.js
CHANGED
|
@@ -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,
|
package/dist/commands/risk.js
CHANGED
|
@@ -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
|
|
94
|
-
.option("--max-
|
|
95
|
-
.option("--max-
|
|
96
|
-
.option("--
|
|
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.
|
|
105
|
-
opts.
|
|
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:
|
|
145
|
-
console.log(` Max Position Size:
|
|
146
|
-
console.log(` Max Total Exposure:
|
|
147
|
-
console.log(` Daily Loss Limit:
|
|
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}%)`)}
|
|
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)
|
package/dist/commands/wallet.js
CHANGED
|
@@ -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
|
|
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) {
|
|
@@ -51,7 +51,7 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
|
|
|
51
51
|
cancelOrder(symbol: string, orderId: string): Promise<import("hyperliquid").CancelOrderResponse>;
|
|
52
52
|
cancelAllOrders(symbol?: string): Promise<import("hyperliquid").CancelOrderResponse[]>;
|
|
53
53
|
editOrder(symbol: string, orderId: string, price: string, size: string): Promise<unknown>;
|
|
54
|
-
setLeverage(symbol: string, leverage: number, marginMode?: "cross" | "isolated"): Promise<
|
|
54
|
+
setLeverage(symbol: string, leverage: number, marginMode?: "cross" | "isolated"): Promise<any>;
|
|
55
55
|
stopOrder(symbol: string, side: "buy" | "sell", size: string, triggerPrice: string, opts?: {
|
|
56
56
|
limitPrice?: string;
|
|
57
57
|
reduceOnly?: boolean;
|
|
@@ -90,14 +90,14 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
|
|
|
90
90
|
twapCancel(symbol: string, twapId: number): Promise<unknown>;
|
|
91
91
|
/**
|
|
92
92
|
* Update leverage for a symbol.
|
|
93
|
-
*
|
|
93
|
+
* Uses SDK's built-in updateLeverage method.
|
|
94
94
|
*/
|
|
95
|
-
updateLeverage(symbol: string, leverage: number, isCross?: boolean): Promise<
|
|
95
|
+
updateLeverage(symbol: string, leverage: number, isCross?: boolean): Promise<any>;
|
|
96
96
|
/**
|
|
97
97
|
* Update isolated margin for a position.
|
|
98
98
|
* amount > 0 to add margin, amount < 0 to remove
|
|
99
99
|
*/
|
|
100
|
-
updateIsolatedMargin(symbol: string, amount: number): Promise<
|
|
100
|
+
updateIsolatedMargin(symbol: string, amount: number): Promise<any>;
|
|
101
101
|
/**
|
|
102
102
|
* Withdraw from Hyperliquid L1 bridge.
|
|
103
103
|
* Python SDK: withdraw_from_bridge(amount, destination) → action type "withdraw3"
|
|
@@ -586,31 +586,17 @@ export class HyperliquidAdapter {
|
|
|
586
586
|
}
|
|
587
587
|
/**
|
|
588
588
|
* Update leverage for a symbol.
|
|
589
|
-
*
|
|
589
|
+
* Uses SDK's built-in updateLeverage method.
|
|
590
590
|
*/
|
|
591
591
|
async updateLeverage(symbol, leverage, isCross = true) {
|
|
592
|
-
|
|
593
|
-
const action = {
|
|
594
|
-
type: "updateLeverage",
|
|
595
|
-
asset: assetIndex,
|
|
596
|
-
isCross,
|
|
597
|
-
leverage,
|
|
598
|
-
};
|
|
599
|
-
return this._sendExchangeAction(action);
|
|
592
|
+
return this.sdk.exchange.updateLeverage(symbol, isCross ? "cross" : "isolated", leverage);
|
|
600
593
|
}
|
|
601
594
|
/**
|
|
602
595
|
* Update isolated margin for a position.
|
|
603
596
|
* amount > 0 to add margin, amount < 0 to remove
|
|
604
597
|
*/
|
|
605
598
|
async updateIsolatedMargin(symbol, amount) {
|
|
606
|
-
|
|
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);
|
|
599
|
+
return this.sdk.exchange.updateIsolatedMargin(symbol, amount > 0, Math.round(Math.abs(amount) * 1e6));
|
|
614
600
|
}
|
|
615
601
|
/**
|
|
616
602
|
* 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
|
|
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():
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
85
|
-
this._signer = new
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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(
|
|
751
|
-
const
|
|
752
|
-
|
|
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
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
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
|
@@ -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.
|
|
51
|
+
.version("0.3.7")
|
|
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.
|
|
386
|
+
console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(" v0.3.7\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.
|
|
399
|
+
console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(" v0.3.7\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:"));
|
package/dist/mcp-server.js
CHANGED
|
@@ -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.
|
|
59
|
+
const server = new McpServer({ name: "perp-cli", version: "0.3.7" }, { capabilities: { tools: {}, resources: {} } });
|
|
60
60
|
// ============================================================
|
|
61
61
|
// Market Data tools (read-only, no private key needed)
|
|
62
62
|
// ============================================================
|
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 < -
|
|
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.
|
|
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:
|
|
110
|
+
limit: effDrawdown,
|
|
98
111
|
});
|
|
99
112
|
}
|
|
100
|
-
if (largestPositionUsd >
|
|
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.
|
|
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:
|
|
119
|
+
limit: effPosition,
|
|
107
120
|
});
|
|
108
121
|
}
|
|
109
|
-
if (totalExposure >
|
|
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.
|
|
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:
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
return { allowed: false, reason: `
|
|
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.
|
|
3
|
+
"version": "0.3.8",
|
|
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": "^
|
|
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",
|
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.8"
|
|
9
9
|
mcp-server: perp-cli
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -79,15 +79,52 @@ perp --json portfolio # unified multi-exchange view
|
|
|
79
79
|
|
|
80
80
|
### Trade execution (MANDATORY checklist)
|
|
81
81
|
```
|
|
82
|
+
BEFORE ANY TRADE:
|
|
83
|
+
0. perp --json portfolio → check TOTAL equity + per-exchange balances
|
|
84
|
+
- Single position notional < 25% of TOTAL equity
|
|
85
|
+
- Each exchange MUST have sufficient balance for its leg
|
|
86
|
+
- Notional ≠ margin required. Check available balance on EACH exchange.
|
|
87
|
+
- If balance is insufficient, bridge first or reduce size.
|
|
88
|
+
|
|
82
89
|
1. perp --json risk status → check risk level (STOP if critical)
|
|
83
|
-
2. perp --json -e <EX> account info → verify balance
|
|
90
|
+
2. perp --json -e <EX> account info → verify EXCHANGE-SPECIFIC balance
|
|
84
91
|
3. perp --json -e <EX> market mid <SYM> → current price
|
|
85
|
-
4. perp --json
|
|
86
|
-
5. perp --json
|
|
87
|
-
6.
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
4. perp --json -e <EX> trade leverage <SYM> <N> --isolated → set leverage + isolated margin FIRST
|
|
93
|
+
5. perp --json risk check --notional <$> --leverage <L> → risk pre-check
|
|
94
|
+
6. perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L> → trade validation
|
|
95
|
+
⚠ trade check does NOT read exchange-set leverage. ALWAYS pass --leverage explicitly.
|
|
96
|
+
7. [Show order details + risk assessment to user, get explicit confirmation]
|
|
97
|
+
8. perp --json -e <EX> trade market <SYM> <SIDE> <SIZE> → execute
|
|
98
|
+
9. perp --json -e <EX> account positions → verify result + check liquidation price
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Post-entry monitoring (MANDATORY while positions are open)
|
|
102
|
+
```
|
|
103
|
+
Every 15 minutes:
|
|
104
|
+
perp --json risk status → overall risk level + violations
|
|
105
|
+
perp --json risk liquidation-distance → % from liq price for ALL positions
|
|
106
|
+
perp --json -e <EX> account positions → check each position P&L
|
|
107
|
+
|
|
108
|
+
Every 1 hour (at funding settlement):
|
|
109
|
+
perp --json arb rates → is spread still profitable?
|
|
110
|
+
perp --json portfolio → total equity across exchanges
|
|
111
|
+
Compare both legs' unrealized P&L — they should roughly offset
|
|
112
|
+
|
|
113
|
+
Exit triggers:
|
|
114
|
+
- Spread below breakeven (including fees) → show exit plan, get user approval
|
|
115
|
+
- risk status level = "critical" or canTrade = false → reduce immediately
|
|
116
|
+
- One leg closed unexpectedly → close the other leg IMMEDIATELY
|
|
117
|
+
- Target hold duration reached → re-evaluate or exit
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Knowing your capital (CHECK BEFORE ANY DECISION)
|
|
121
|
+
```
|
|
122
|
+
perp --json wallet show → configured wallets + addresses
|
|
123
|
+
perp --json wallet balance → on-chain USDC (in wallet, NOT on exchange)
|
|
124
|
+
perp --json -e <EX> account info → exchange balance (available for trading)
|
|
125
|
+
perp --json portfolio → unified view: equity, margin, P&L per exchange
|
|
90
126
|
```
|
|
127
|
+
**On-chain balance ≠ exchange balance.** Always check both. Capital must be deposited to exchange before trading.
|
|
91
128
|
|
|
92
129
|
For full command reference, see `references/commands.md`.
|
|
93
130
|
For agent-specific operations (setup flows, deposit/withdraw, order types, idempotency), see `references/agent-operations.md`.
|
|
@@ -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
|
|
@@ -236,18 +236,24 @@ perp --json -e <EX> account settings # shows leverage and margin_mode pe
|
|
|
236
236
|
|
|
237
237
|
### Risk Limits — Configure Before Trading
|
|
238
238
|
|
|
239
|
-
Set your risk limits FIRST, before any trading activity
|
|
239
|
+
Set your risk limits FIRST, before any trading activity.
|
|
240
|
+
Limits can be set in **USD or % of equity** (when both are set, the stricter one applies):
|
|
240
241
|
```bash
|
|
241
242
|
perp --json risk limits \
|
|
242
243
|
--max-leverage 5 \
|
|
243
244
|
--max-margin 60 \
|
|
245
|
+
--max-drawdown-pct 10 \
|
|
246
|
+
--max-position-pct 25 \
|
|
247
|
+
--max-drawdown 500 \
|
|
244
248
|
--max-position 5000 \
|
|
245
249
|
--max-exposure 20000 \
|
|
246
|
-
--max-drawdown 500 \
|
|
247
250
|
--daily-loss 200 \
|
|
248
251
|
--min-liq-distance 30
|
|
249
252
|
```
|
|
250
253
|
|
|
254
|
+
**Use % limits for small portfolios.** A $65 portfolio with `--max-drawdown 500` is meaningless.
|
|
255
|
+
With `--max-drawdown-pct 10`, the effective limit auto-adjusts to $6.50.
|
|
256
|
+
|
|
251
257
|
**IMPORTANT: Ask the user about their risk tolerance BEFORE setting limits.** Key questions:
|
|
252
258
|
- "How much leverage are you comfortable with?" (default: 5x for arb)
|
|
253
259
|
- "What's your maximum acceptable loss?" (default: $500)
|
|
@@ -310,6 +316,26 @@ Rules of thumb:
|
|
|
310
316
|
- **Total margin used < 60% of total equity** — leave buffer for adverse moves
|
|
311
317
|
- **Capital in transit (bridging) counts as "at risk"** — it's not available for margin
|
|
312
318
|
|
|
319
|
+
### Per-Exchange Balance Constraints
|
|
320
|
+
|
|
321
|
+
**CRITICAL: You can only trade with the balance available ON THAT EXCHANGE.**
|
|
322
|
+
|
|
323
|
+
Each exchange holds its own separate balance. Before entering any arb:
|
|
324
|
+
```bash
|
|
325
|
+
perp --json -e <EX_A> account info # check available balance on exchange A
|
|
326
|
+
perp --json -e <EX_B> account info # check available balance on exchange B
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Size each leg to fit the available balance on that exchange:**
|
|
330
|
+
```
|
|
331
|
+
Wrong: total portfolio = $65 → 25% = $16 per leg → but Exchange A only has $10
|
|
332
|
+
Right: Exchange A has $10, Exchange B has $15 → max leg size = $10 (limited by smaller side)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
If one exchange has insufficient balance:
|
|
336
|
+
- Reduce position size to fit the smaller balance, OR
|
|
337
|
+
- Bridge capital first (but account for bridge fees + time + unhedged risk during transit)
|
|
338
|
+
|
|
313
339
|
### Stop Loss for Arb Positions
|
|
314
340
|
|
|
315
341
|
Even "market-neutral" arb can lose money if:
|