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 +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 +9 -4
- package/dist/exchanges/hyperliquid.js +27 -31
- package/dist/exchanges/lighter.d.ts +10 -5
- package/dist/exchanges/lighter.js +81 -45
- package/dist/index.js +0 -0
- package/dist/mcp-server.js +0 -0
- package/dist/risk.d.ts +6 -0
- package/dist/risk.js +30 -14
- package/package.json +2 -2
- package/skills/perp-cli/SKILL.md +62 -8
- package/skills/perp-cli/references/agent-operations.md +27 -2
- package/skills/perp-cli/references/commands.md +4 -2
- package/skills/perp-cli/references/strategies.md +117 -43
- package/skills/perp-cli/templates/arb-scan.sh +1 -5
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) {
|
|
@@ -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<
|
|
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
|
-
*
|
|
98
|
+
* Uses SDK's built-in updateLeverage method.
|
|
94
99
|
*/
|
|
95
|
-
updateLeverage(symbol: string, leverage: number, isCross?: boolean): Promise<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
return
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
if (
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
599
|
+
* Uses SDK's built-in updateLeverage method.
|
|
590
600
|
*/
|
|
591
601
|
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);
|
|
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
|
-
|
|
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
|
|
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
|
File without changes
|
package/dist/mcp-server.js
CHANGED
|
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 < -
|
|
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.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": "^
|
|
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.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
|
|
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
|
|
93
|
-
5. perp --json
|
|
94
|
-
6.
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
60
|
-
|
|
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
|
|
140
|
+
perp --json arb scan --min 5 # are current rates still favorable?
|
|
122
141
|
```
|
|
123
142
|
|
|
124
|
-
### Order Execution: Sequential
|
|
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
|
-
|
|
147
|
+
#### Step 1: Determine Matched Order Size
|
|
127
148
|
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
perp --json -e <
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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
|
|
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.
|
|
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"
|