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