perp-cli 0.3.18 → 0.4.0
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 +4 -1
- package/dist/commands/agent.js +14 -6
- package/dist/commands/arb/index.js +272 -30
- package/dist/commands/init.js +1 -1
- package/dist/commands/wallet.js +9 -0
- package/dist/exchanges/lighter.js +46 -1
- package/dist/exchanges/pacifica.js +1 -1
- package/dist/index.js +6 -6
- package/dist/mcp-server.js +6 -6
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +34 -8
package/README.md
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
Multi-DEX perpetual futures CLI — **Pacifica** (Solana), **Hyperliquid** (HyperEVM), **Lighter** (Ethereum).
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install -g perp-cli
|
|
6
|
+
npm install -g perp-cli # global install
|
|
7
7
|
perp --json portfolio
|
|
8
|
+
|
|
9
|
+
# Or without global install (restricted environments)
|
|
10
|
+
npx -y perp-cli --json portfolio
|
|
8
11
|
```
|
|
9
12
|
|
|
10
13
|
## Features
|
package/dist/commands/agent.js
CHANGED
|
@@ -134,7 +134,7 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
134
134
|
"perp account orders --json",
|
|
135
135
|
"perp account history --json",
|
|
136
136
|
"perp account trades --json",
|
|
137
|
-
"perp
|
|
137
|
+
"perp portfolio --json",
|
|
138
138
|
],
|
|
139
139
|
description: "Read account state: balances, positions, open orders, trade history",
|
|
140
140
|
},
|
|
@@ -170,13 +170,21 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
170
170
|
category: "arbitrage",
|
|
171
171
|
commands: [
|
|
172
172
|
"perp arb funding --json",
|
|
173
|
-
"perp arb
|
|
173
|
+
"perp arb scan --min 5 --json",
|
|
174
174
|
"perp arb scan --json",
|
|
175
|
+
"perp arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated --json",
|
|
176
|
+
"perp arb status --json",
|
|
177
|
+
"perp arb close <SYM> --json",
|
|
178
|
+
"perp arb close <SYM> --dry-run --json",
|
|
179
|
+
"perp arb close <SYM> --pair <longEx>:<shortEx> --json",
|
|
180
|
+
"perp arb funding-earned --json",
|
|
181
|
+
"perp arb funding-earned --period 30 --json",
|
|
182
|
+
"perp arb history --json",
|
|
175
183
|
"perp arb dex --json",
|
|
176
184
|
"perp arb dex-monitor --min 10",
|
|
177
185
|
"perp gap show --json",
|
|
178
186
|
],
|
|
179
|
-
description: "Cross-exchange
|
|
187
|
+
description: "Cross-exchange funding arb: scan → exec → status → funding-earned → close. Plus cross-dex arb and price gaps.",
|
|
180
188
|
},
|
|
181
189
|
{
|
|
182
190
|
category: "risk_management",
|
|
@@ -338,10 +346,10 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
338
346
|
}
|
|
339
347
|
}
|
|
340
348
|
else if (g.includes("arb") || g.includes("arbitrage") || g.includes("funding")) {
|
|
341
|
-
steps.push({ step: 1, command: "perp arb
|
|
349
|
+
steps.push({ step: 1, command: "perp arb scan --min 5 --json", description: "Scan funding rate arbitrage opportunities" }, { step: 2, command: "perp arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated --dry-run --json", description: "Dry-run arb execution" });
|
|
342
350
|
}
|
|
343
351
|
else if (g.includes("status") || g.includes("check") || g.includes("overview")) {
|
|
344
|
-
steps.push({ step: 1, command: "perp
|
|
352
|
+
steps.push({ step: 1, command: "perp portfolio --json", description: "Balances + positions + risk" }, { step: 2, command: "perp account positions --json", description: "Detailed positions" }, { step: 3, command: "perp account orders --json", description: "Open orders" });
|
|
345
353
|
}
|
|
346
354
|
else if (g.includes("deposit")) {
|
|
347
355
|
const amount = extractNumber(g) || "100";
|
|
@@ -357,7 +365,7 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
357
365
|
}
|
|
358
366
|
}
|
|
359
367
|
else {
|
|
360
|
-
steps.push({ step: 1, command: "perp agent capabilities", description: "List all available capabilities" }, { step: 2, command: "perp
|
|
368
|
+
steps.push({ step: 1, command: "perp agent capabilities", description: "List all available capabilities" }, { step: 2, command: "perp portfolio --json", description: "Check account status" });
|
|
361
369
|
}
|
|
362
370
|
printJson(jsonOk({
|
|
363
371
|
goal,
|
|
@@ -165,6 +165,16 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
165
165
|
// Net PnL
|
|
166
166
|
const totalUpnl = longPos.unrealizedPnl + shortPos.unrealizedPnl;
|
|
167
167
|
const netPnl = totalUpnl + estimatedFundingIncome - totalFees;
|
|
168
|
+
// Daily funding estimate from current spread
|
|
169
|
+
const dailyFundingEstimate = currentSpread
|
|
170
|
+
? (currentSpread / 100) / 365 * avgNotional
|
|
171
|
+
: 0;
|
|
172
|
+
// Breakeven analysis
|
|
173
|
+
const totalCosts = totalFees; // entry+exit fees
|
|
174
|
+
const remainingToBreakeven = totalCosts - estimatedFundingIncome - totalUpnl;
|
|
175
|
+
const daysToBreakeven = dailyFundingEstimate > 0 && remainingToBreakeven > 0
|
|
176
|
+
? remainingToBreakeven / dailyFundingEstimate
|
|
177
|
+
: remainingToBreakeven <= 0 ? 0 : null;
|
|
168
178
|
arbPairs.push({
|
|
169
179
|
symbol,
|
|
170
180
|
longExchange: longPos.exchange,
|
|
@@ -195,12 +205,33 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
195
205
|
estimatedFees: totalFees,
|
|
196
206
|
unrealizedPnl: totalUpnl,
|
|
197
207
|
netPnl,
|
|
208
|
+
dailyFundingEstimate,
|
|
209
|
+
breakeven: {
|
|
210
|
+
daysToBreakeven,
|
|
211
|
+
dailyIncome: dailyFundingEstimate,
|
|
212
|
+
totalCosts,
|
|
213
|
+
remainingToBreakeven: Math.max(0, remainingToBreakeven),
|
|
214
|
+
},
|
|
198
215
|
});
|
|
199
216
|
}
|
|
200
217
|
}
|
|
201
218
|
}
|
|
202
219
|
if (isJson()) {
|
|
203
|
-
|
|
220
|
+
const totalNotionalJ = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
|
|
221
|
+
const totalDailyJ = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
|
|
222
|
+
return printJson(jsonOk({
|
|
223
|
+
pairs: arbPairs,
|
|
224
|
+
aggregate: {
|
|
225
|
+
positions: arbPairs.length,
|
|
226
|
+
totalNotionalUsd: totalNotionalJ,
|
|
227
|
+
unrealizedPnl: arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0),
|
|
228
|
+
estimatedFunding: arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0),
|
|
229
|
+
estimatedFees: arbPairs.reduce((s, p) => s + p.estimatedFees, 0),
|
|
230
|
+
netPnl: arbPairs.reduce((s, p) => s + p.netPnl, 0),
|
|
231
|
+
dailyFundingEstimate: totalDailyJ,
|
|
232
|
+
annualizedApr: totalNotionalJ > 0 ? (totalDailyJ * 365 / totalNotionalJ) * 100 : 0,
|
|
233
|
+
},
|
|
234
|
+
}));
|
|
204
235
|
}
|
|
205
236
|
if (arbPairs.length === 0) {
|
|
206
237
|
console.log(chalk.gray(" No open arb positions found.\n"));
|
|
@@ -240,12 +271,20 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
240
271
|
const totalFunding = arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0);
|
|
241
272
|
const totalFees = arbPairs.reduce((s, p) => s + p.estimatedFees, 0);
|
|
242
273
|
const totalNet = arbPairs.reduce((s, p) => s + p.netPnl, 0);
|
|
274
|
+
const totalDailyFunding = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
|
|
275
|
+
const totalNotional = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
|
|
276
|
+
const portfolioApr = totalNotional > 0 ? (totalDailyFunding * 365 / totalNotional) * 100 : 0;
|
|
243
277
|
console.log(chalk.white.bold("\n Summary"));
|
|
244
278
|
console.log(` Positions: ${arbPairs.length}`);
|
|
279
|
+
console.log(` Total Notional: $${formatUsd(totalNotional)}`);
|
|
245
280
|
console.log(` Unrealized PnL: ${formatPnl(totalUpnl)}`);
|
|
246
281
|
console.log(` Est. Funding: ${chalk.yellow(`$${totalFunding.toFixed(4)}`)}`);
|
|
247
282
|
console.log(` Est. Fees: ${chalk.red(`-$${totalFees.toFixed(4)}`)}`);
|
|
248
283
|
console.log(` Net PnL: ${formatPnl(totalNet)}`);
|
|
284
|
+
console.log(` Daily Income: ${chalk.cyan(`$${totalDailyFunding.toFixed(4)}/day`)}`);
|
|
285
|
+
if (portfolioApr > 0) {
|
|
286
|
+
console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)}`);
|
|
287
|
+
}
|
|
249
288
|
console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker for entry + exit.)\n`));
|
|
250
289
|
});
|
|
251
290
|
// ── arb close ──
|
|
@@ -389,44 +428,66 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
389
428
|
console.log(chalk.yellow(` BUY ${shortPos.size} ${shortPos.rawSymbol} on ${shortPos.exchange} (close short)\n`));
|
|
390
429
|
return;
|
|
391
430
|
}
|
|
392
|
-
// Execute close on both exchanges
|
|
431
|
+
// Execute close on both exchanges with retry
|
|
432
|
+
const MAX_RETRIES = 2;
|
|
393
433
|
const results = [];
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
(
|
|
434
|
+
async function closeWithRetry(exchange, rawSymbol, side, size, label) {
|
|
435
|
+
let lastError = "";
|
|
436
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
397
437
|
try {
|
|
398
|
-
const adapter = await getAdapterForExchange(
|
|
399
|
-
await adapter.marketOrder(
|
|
400
|
-
results.push({ exchange
|
|
438
|
+
const adapter = await getAdapterForExchange(exchange);
|
|
439
|
+
await adapter.marketOrder(rawSymbol, side, String(size));
|
|
440
|
+
results.push({ exchange, action: `${side} (close ${label})`, status: "success", retries: attempt });
|
|
401
441
|
if (!isJson())
|
|
402
|
-
console.log(chalk.green(` Closed
|
|
442
|
+
console.log(chalk.green(` Closed ${label} on ${exchange}: ${side.toUpperCase()} ${size} ${sym}${attempt > 0 ? ` (retry ${attempt})` : ""}`));
|
|
443
|
+
return;
|
|
403
444
|
}
|
|
404
445
|
catch (err) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
446
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
447
|
+
if (attempt < MAX_RETRIES) {
|
|
448
|
+
if (!isJson())
|
|
449
|
+
console.log(chalk.yellow(` Retry ${attempt + 1}/${MAX_RETRIES} for ${label} on ${exchange}...`));
|
|
450
|
+
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
|
451
|
+
}
|
|
409
452
|
}
|
|
410
|
-
}
|
|
411
|
-
(
|
|
453
|
+
}
|
|
454
|
+
results.push({ exchange, action: `${side} (close ${label})`, status: "failed", error: lastError, retries: MAX_RETRIES });
|
|
455
|
+
if (!isJson())
|
|
456
|
+
console.error(chalk.red(` Failed to close ${label} on ${exchange}: ${lastError}`));
|
|
457
|
+
}
|
|
458
|
+
// Close both legs concurrently
|
|
459
|
+
await Promise.all([
|
|
460
|
+
closeWithRetry(longPos.exchange, longPos.rawSymbol, "sell", longPos.size, "long"),
|
|
461
|
+
closeWithRetry(shortPos.exchange, shortPos.rawSymbol, "buy", shortPos.size, "short"),
|
|
462
|
+
]);
|
|
463
|
+
const allSuccess = results.every(r => r.status === "success");
|
|
464
|
+
const anySuccess = results.some(r => r.status === "success");
|
|
465
|
+
// Verify positions are actually closed
|
|
466
|
+
let verification;
|
|
467
|
+
if (anySuccess) {
|
|
468
|
+
verification = [];
|
|
469
|
+
for (const r of results) {
|
|
470
|
+
if (r.status !== "success")
|
|
471
|
+
continue;
|
|
412
472
|
try {
|
|
413
|
-
const adapter = await getAdapterForExchange(
|
|
414
|
-
await adapter.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
473
|
+
const adapter = await getAdapterForExchange(r.exchange);
|
|
474
|
+
const positions = await adapter.getPositions();
|
|
475
|
+
const remaining = positions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym);
|
|
476
|
+
verification.push({
|
|
477
|
+
exchange: r.exchange,
|
|
478
|
+
remainingSize: remaining ? Math.abs(Number(remaining.size)) : 0,
|
|
479
|
+
closed: !remaining || Math.abs(Number(remaining.size)) < 0.0001,
|
|
480
|
+
});
|
|
418
481
|
}
|
|
419
|
-
catch
|
|
420
|
-
|
|
421
|
-
results.push({ exchange: shortPos.exchange, action: "buy (close short)", status: "failed", error: msg });
|
|
422
|
-
if (!isJson())
|
|
423
|
-
console.error(chalk.red(` Failed to close short on ${shortPos.exchange}: ${msg}`));
|
|
482
|
+
catch {
|
|
483
|
+
verification.push({ exchange: r.exchange, remainingSize: -1, closed: false });
|
|
424
484
|
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
485
|
+
}
|
|
486
|
+
const notClosed = verification.filter(v => !v.closed);
|
|
487
|
+
if (notClosed.length > 0 && !isJson()) {
|
|
488
|
+
console.log(chalk.yellow(`\n Warning: Position may not be fully closed on: ${notClosed.map(v => v.exchange).join(", ")}`));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
430
491
|
// Log to execution log
|
|
431
492
|
const arbPairId = `${sym}:${longPos.exchange}:${shortPos.exchange}`;
|
|
432
493
|
logExecution({
|
|
@@ -459,6 +520,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
459
520
|
estimatedFees: totalFees,
|
|
460
521
|
netPnl,
|
|
461
522
|
results,
|
|
523
|
+
verification,
|
|
462
524
|
}));
|
|
463
525
|
}
|
|
464
526
|
if (!allSuccess && anySuccess) {
|
|
@@ -918,4 +980,184 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
918
980
|
console.log(chalk.yellow("\n To execute, use: perp rebalance execute --auto-bridge\n"));
|
|
919
981
|
}
|
|
920
982
|
});
|
|
983
|
+
// ── arb funding-earned ──
|
|
984
|
+
arb
|
|
985
|
+
.command("funding-earned")
|
|
986
|
+
.description("Actual funding payments received/paid for arb positions")
|
|
987
|
+
.option("--period <days>", "Number of days to look back", "7")
|
|
988
|
+
.option("--symbol <symbol>", "Filter by symbol")
|
|
989
|
+
.action(async (opts) => {
|
|
990
|
+
const periodDays = parseInt(opts.period);
|
|
991
|
+
const filterSym = opts.symbol?.toUpperCase();
|
|
992
|
+
if (!isJson())
|
|
993
|
+
console.log(chalk.cyan(`\n Fetching funding payments (last ${periodDays} days)...\n`));
|
|
994
|
+
// Fetch positions to identify arb pairs
|
|
995
|
+
const allPositions = [];
|
|
996
|
+
for (const exName of EXCHANGES) {
|
|
997
|
+
try {
|
|
998
|
+
const adapter = await getAdapterForExchange(exName);
|
|
999
|
+
const positions = await adapter.getPositions();
|
|
1000
|
+
for (const p of positions) {
|
|
1001
|
+
const normalized = p.symbol.replace("-PERP", "").toUpperCase();
|
|
1002
|
+
allPositions.push({
|
|
1003
|
+
exchange: exName,
|
|
1004
|
+
symbol: normalized,
|
|
1005
|
+
side: p.side,
|
|
1006
|
+
size: Math.abs(Number(p.size)),
|
|
1007
|
+
markPrice: Number(p.markPrice),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
// exchange not configured
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// Identify arb pairs (long on one exchange, short on another)
|
|
1016
|
+
const arbPairKeys = new Set();
|
|
1017
|
+
const arbPairInfo = new Map();
|
|
1018
|
+
const longs = allPositions.filter(p => p.side === "long");
|
|
1019
|
+
const shorts = allPositions.filter(p => p.side === "short");
|
|
1020
|
+
for (const l of longs) {
|
|
1021
|
+
for (const s of shorts) {
|
|
1022
|
+
if (l.exchange !== s.exchange && l.symbol === s.symbol) {
|
|
1023
|
+
const key = l.symbol;
|
|
1024
|
+
arbPairKeys.add(key);
|
|
1025
|
+
arbPairInfo.set(key, {
|
|
1026
|
+
longExchange: l.exchange,
|
|
1027
|
+
shortExchange: s.exchange,
|
|
1028
|
+
notionalUsd: (l.size * l.markPrice + s.size * s.markPrice) / 2,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const allPayments = [];
|
|
1034
|
+
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
|
1035
|
+
for (const exName of EXCHANGES) {
|
|
1036
|
+
try {
|
|
1037
|
+
const adapter = await getAdapterForExchange(exName);
|
|
1038
|
+
const payments = await adapter.getFundingPayments(200);
|
|
1039
|
+
for (const p of payments) {
|
|
1040
|
+
if (p.time < cutoff)
|
|
1041
|
+
continue;
|
|
1042
|
+
const sym = p.symbol.replace("-PERP", "").toUpperCase();
|
|
1043
|
+
if (filterSym && sym !== filterSym)
|
|
1044
|
+
continue;
|
|
1045
|
+
allPayments.push({
|
|
1046
|
+
time: p.time,
|
|
1047
|
+
exchange: exName,
|
|
1048
|
+
symbol: sym,
|
|
1049
|
+
payment: typeof p.payment === "string" ? parseFloat(p.payment) : Number(p.payment),
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
// exchange not configured or no payments
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// Group by symbol, then by exchange
|
|
1058
|
+
const bySymbol = new Map();
|
|
1059
|
+
for (const p of allPayments) {
|
|
1060
|
+
if (!bySymbol.has(p.symbol))
|
|
1061
|
+
bySymbol.set(p.symbol, []);
|
|
1062
|
+
bySymbol.get(p.symbol).push(p);
|
|
1063
|
+
}
|
|
1064
|
+
const pairs = [];
|
|
1065
|
+
for (const [symbol, payments] of bySymbol) {
|
|
1066
|
+
const isArb = arbPairKeys.has(symbol);
|
|
1067
|
+
const pairInfo = arbPairInfo.get(symbol);
|
|
1068
|
+
// Group by exchange
|
|
1069
|
+
const byEx = new Map();
|
|
1070
|
+
for (const p of payments) {
|
|
1071
|
+
if (!byEx.has(p.exchange))
|
|
1072
|
+
byEx.set(p.exchange, []);
|
|
1073
|
+
byEx.get(p.exchange).push(p);
|
|
1074
|
+
}
|
|
1075
|
+
const exchangeSummaries = [];
|
|
1076
|
+
let totalReceived = 0;
|
|
1077
|
+
let totalPaid = 0;
|
|
1078
|
+
for (const [exchange, exPayments] of byEx) {
|
|
1079
|
+
const received = exPayments.filter(p => p.payment > 0).reduce((s, p) => s + p.payment, 0);
|
|
1080
|
+
const paid = exPayments.filter(p => p.payment < 0).reduce((s, p) => s + Math.abs(p.payment), 0);
|
|
1081
|
+
const net = received - paid;
|
|
1082
|
+
totalReceived += received;
|
|
1083
|
+
totalPaid += paid;
|
|
1084
|
+
exchangeSummaries.push({ exchange, totalReceived: received, totalPaid: paid, net, payments: exPayments.length });
|
|
1085
|
+
}
|
|
1086
|
+
const netEarned = totalReceived - totalPaid;
|
|
1087
|
+
const dailyAvg = periodDays > 0 ? netEarned / periodDays : 0;
|
|
1088
|
+
const notionalUsd = pairInfo?.notionalUsd || 0;
|
|
1089
|
+
const annualizedApr = notionalUsd > 0 ? (dailyAvg * 365 / notionalUsd) * 100 : 0;
|
|
1090
|
+
pairs.push({
|
|
1091
|
+
symbol,
|
|
1092
|
+
isArbPair: isArb,
|
|
1093
|
+
longExchange: pairInfo?.longExchange ?? null,
|
|
1094
|
+
shortExchange: pairInfo?.shortExchange ?? null,
|
|
1095
|
+
notionalUsd,
|
|
1096
|
+
byExchange: exchangeSummaries,
|
|
1097
|
+
totalReceived,
|
|
1098
|
+
totalPaid,
|
|
1099
|
+
netEarned,
|
|
1100
|
+
dailyAvg,
|
|
1101
|
+
annualizedApr,
|
|
1102
|
+
payments: payments.length,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
// Sort: arb pairs first, then by netEarned
|
|
1106
|
+
pairs.sort((a, b) => {
|
|
1107
|
+
if (a.isArbPair !== b.isArbPair)
|
|
1108
|
+
return a.isArbPair ? -1 : 1;
|
|
1109
|
+
return b.netEarned - a.netEarned;
|
|
1110
|
+
});
|
|
1111
|
+
// Totals
|
|
1112
|
+
const totals = {
|
|
1113
|
+
totalReceived: pairs.reduce((s, p) => s + p.totalReceived, 0),
|
|
1114
|
+
totalPaid: pairs.reduce((s, p) => s + p.totalPaid, 0),
|
|
1115
|
+
netEarned: pairs.reduce((s, p) => s + p.netEarned, 0),
|
|
1116
|
+
dailyAvg: pairs.reduce((s, p) => s + p.dailyAvg, 0),
|
|
1117
|
+
totalNotional: pairs.filter(p => p.isArbPair).reduce((s, p) => s + p.notionalUsd, 0),
|
|
1118
|
+
arbPairs: pairs.filter(p => p.isArbPair).length,
|
|
1119
|
+
period: periodDays,
|
|
1120
|
+
};
|
|
1121
|
+
const totalArbNotional = totals.totalNotional;
|
|
1122
|
+
const portfolioApr = totalArbNotional > 0
|
|
1123
|
+
? (totals.dailyAvg * 365 / totalArbNotional) * 100
|
|
1124
|
+
: 0;
|
|
1125
|
+
if (isJson()) {
|
|
1126
|
+
return printJson(jsonOk({
|
|
1127
|
+
pairs,
|
|
1128
|
+
totals: { ...totals, annualizedApr: portfolioApr },
|
|
1129
|
+
}));
|
|
1130
|
+
}
|
|
1131
|
+
if (pairs.length === 0) {
|
|
1132
|
+
console.log(chalk.gray(" No funding payments found.\n"));
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
console.log(chalk.cyan.bold(" Funding Payments Earned\n"));
|
|
1136
|
+
const rows = pairs.map(p => {
|
|
1137
|
+
const arbTag = p.isArbPair ? chalk.green("ARB") : chalk.gray("---");
|
|
1138
|
+
const exchanges = p.byExchange.map(e => e.exchange.slice(0, 3).toUpperCase()).join("+");
|
|
1139
|
+
return [
|
|
1140
|
+
chalk.white.bold(p.symbol),
|
|
1141
|
+
arbTag,
|
|
1142
|
+
exchanges,
|
|
1143
|
+
chalk.green(`$${p.totalReceived.toFixed(4)}`),
|
|
1144
|
+
chalk.red(`-$${p.totalPaid.toFixed(4)}`),
|
|
1145
|
+
formatPnl(p.netEarned),
|
|
1146
|
+
`$${p.dailyAvg.toFixed(4)}/d`,
|
|
1147
|
+
p.annualizedApr > 0 ? `${p.annualizedApr.toFixed(1)}%` : "-",
|
|
1148
|
+
String(p.payments),
|
|
1149
|
+
];
|
|
1150
|
+
});
|
|
1151
|
+
console.log(makeTable(["Symbol", "Type", "Exchanges", "Received", "Paid", "Net", "Daily Avg", "APR", "#"], rows));
|
|
1152
|
+
console.log(chalk.white.bold("\n Totals"));
|
|
1153
|
+
console.log(` Period: ${periodDays} days`);
|
|
1154
|
+
console.log(` Received: ${chalk.green(`$${totals.totalReceived.toFixed(4)}`)}`);
|
|
1155
|
+
console.log(` Paid: ${chalk.red(`-$${totals.totalPaid.toFixed(4)}`)}`);
|
|
1156
|
+
console.log(` Net Earned: ${formatPnl(totals.netEarned)}`);
|
|
1157
|
+
console.log(` Daily Avg: $${totals.dailyAvg.toFixed(4)}/day`);
|
|
1158
|
+
if (portfolioApr > 0) {
|
|
1159
|
+
console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)} (on $${formatUsd(totalArbNotional)} arb notional)`);
|
|
1160
|
+
}
|
|
1161
|
+
console.log();
|
|
1162
|
+
});
|
|
921
1163
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -231,7 +231,7 @@ export function registerInitCommand(program) {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
console.log(chalk.white.bold("\n Next steps:"));
|
|
234
|
-
console.log(` ${chalk.green("perp
|
|
234
|
+
console.log(` ${chalk.green("perp portfolio")} balances + positions + risk`);
|
|
235
235
|
if (selected.includes("hyperliquid")) {
|
|
236
236
|
console.log(` ${chalk.green("perp deposit hyperliquid <amt>")} deposit USDC`);
|
|
237
237
|
}
|
package/dist/commands/wallet.js
CHANGED
|
@@ -400,10 +400,15 @@ export function registerWalletCommands(program, isJson) {
|
|
|
400
400
|
lighterApiSetup = { error: e instanceof Error ? e.message : String(e) };
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
|
+
const settings = loadSettings();
|
|
404
|
+
const referralHint = !settings.referrals
|
|
405
|
+
? { referrals: false, hint: "Optional: 'perp settings referrals on' to support perp-cli development (no extra fees — exchange rebates only)" }
|
|
406
|
+
: undefined;
|
|
403
407
|
if (isJson())
|
|
404
408
|
return printJson(jsonOk({
|
|
405
409
|
exchange: resolved, address, envFile: ENV_FILE, default: !!opts.default,
|
|
406
410
|
...(resolved === "lighter" && { lighterApiKey: lighterApiSetup }),
|
|
411
|
+
...(referralHint && { referralHint }),
|
|
407
412
|
}));
|
|
408
413
|
console.log(chalk.green(`\n ${resolved} configured.`));
|
|
409
414
|
console.log(` Address: ${chalk.green(address)}`);
|
|
@@ -419,6 +424,10 @@ export function registerWalletCommands(program, isJson) {
|
|
|
419
424
|
console.log(chalk.gray(` You can retry: perp -e lighter manage setup-api-key`));
|
|
420
425
|
}
|
|
421
426
|
}
|
|
427
|
+
if (!settings.referrals) {
|
|
428
|
+
console.log(chalk.gray(` Optional: ${chalk.cyan("perp settings referrals on")} to support perp-cli development`));
|
|
429
|
+
console.log(chalk.gray(` (no extra fees — exchange rebates only)`));
|
|
430
|
+
}
|
|
422
431
|
console.log();
|
|
423
432
|
});
|
|
424
433
|
// ── show (show configured exchanges with public addresses) ──
|
|
@@ -878,7 +878,52 @@ export class LighterAdapter {
|
|
|
878
878
|
}
|
|
879
879
|
const result = await sendRes.json();
|
|
880
880
|
if (result.code !== 200) {
|
|
881
|
-
|
|
881
|
+
// Retry with fresh nonce if invalid nonce (pending tx or stale nonce)
|
|
882
|
+
if (result.message && /invalid nonce/i.test(result.message)) {
|
|
883
|
+
const MAX_NONCE_RETRIES = 3;
|
|
884
|
+
let lastError = result.message;
|
|
885
|
+
for (let attempt = 1; attempt <= MAX_NONCE_RETRIES; attempt++) {
|
|
886
|
+
await new Promise(r => setTimeout(r, 500 * attempt));
|
|
887
|
+
// Re-fetch nonce from API (may have updated since first call)
|
|
888
|
+
const freshNonceRes = await this.restGet("/nextNonce", {
|
|
889
|
+
account_index: String(this._accountIndex),
|
|
890
|
+
api_key_index: String(apiKeyIndex),
|
|
891
|
+
});
|
|
892
|
+
const freshNonce = (freshNonceRes.nonce ?? freshNonceRes.next_nonce ?? nonce) + attempt;
|
|
893
|
+
const retrySigned = await client.signChangePubKey({
|
|
894
|
+
pubkey: publicKey, nonce: freshNonce, apiKeyIndex, accountIndex: this._accountIndex,
|
|
895
|
+
});
|
|
896
|
+
if (retrySigned.error || !retrySigned.txInfo || !retrySigned.messageToSign) {
|
|
897
|
+
lastError = retrySigned.error ?? "incomplete response";
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const retryTxInfo = JSON.parse(retrySigned.txInfo);
|
|
901
|
+
retryTxInfo.L1Sig = await wallet.signMessage(retrySigned.messageToSign);
|
|
902
|
+
const retryRes = await fetch(`${this._baseUrl}/api/v1/sendTx`, {
|
|
903
|
+
method: "POST",
|
|
904
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
905
|
+
body: new URLSearchParams({
|
|
906
|
+
tx_type: String(retrySigned.txType ?? 0),
|
|
907
|
+
tx_info: JSON.stringify(retryTxInfo),
|
|
908
|
+
}),
|
|
909
|
+
});
|
|
910
|
+
if (!retryRes.ok) {
|
|
911
|
+
lastError = `sendTx ${retryRes.status}: ${await retryRes.text()}`;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const retryResult = await retryRes.json();
|
|
915
|
+
if (retryResult.code === 200) {
|
|
916
|
+
return { privateKey, publicKey }; // Success on retry
|
|
917
|
+
}
|
|
918
|
+
lastError = retryResult.message ?? JSON.stringify(retryResult);
|
|
919
|
+
if (!/invalid nonce/i.test(lastError))
|
|
920
|
+
break; // Different error, stop retrying
|
|
921
|
+
}
|
|
922
|
+
throw new Error(`ChangePubKey failed after ${MAX_NONCE_RETRIES} nonce retries: ${lastError}`);
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
throw new Error(`ChangePubKey failed: ${result.message ?? JSON.stringify(result)}`);
|
|
926
|
+
}
|
|
882
927
|
}
|
|
883
928
|
return { privateKey, publicKey };
|
|
884
929
|
}
|
package/dist/index.js
CHANGED
|
@@ -388,20 +388,20 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
|
|
|
388
388
|
process.env.LIGHTER_PRIVATE_KEY);
|
|
389
389
|
if (!status.hasWallets && !hasEnvKey && !settings.defaultExchange) {
|
|
390
390
|
// Fresh install — onboarding
|
|
391
|
-
console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(
|
|
391
|
+
console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(` v${_pkg.version}\n`));
|
|
392
392
|
console.log(" Multi-DEX perpetual futures CLI for Pacifica, Hyperliquid, and Lighter.\n");
|
|
393
|
-
console.log(` Get started: ${chalk.cyan("perp
|
|
393
|
+
console.log(` Get started: ${chalk.cyan("perp wallet set <exchange> <key>")}`);
|
|
394
394
|
console.log(chalk.gray(`\n Or explore without a wallet:`));
|
|
395
395
|
console.log(` ${chalk.green("perp market list")} available markets`);
|
|
396
396
|
console.log(` ${chalk.green("perp -e hyperliquid market list")} Hyperliquid markets`);
|
|
397
|
-
console.log(` ${chalk.green("perp arb
|
|
397
|
+
console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
|
|
398
398
|
console.log(` ${chalk.green("perp --help")} all commands\n`);
|
|
399
399
|
}
|
|
400
400
|
else {
|
|
401
401
|
// Configured — show status overview
|
|
402
402
|
const defaultEx = settings.defaultExchange || "pacifica";
|
|
403
403
|
const activeEntries = Object.entries(status.active);
|
|
404
|
-
console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(
|
|
404
|
+
console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(` v${_pkg.version}\n`));
|
|
405
405
|
console.log(` Default exchange: ${chalk.cyan(defaultEx)}`);
|
|
406
406
|
if (activeEntries.length > 0) {
|
|
407
407
|
console.log(chalk.white.bold("\n Wallets:"));
|
|
@@ -432,10 +432,10 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
|
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
434
|
console.log(chalk.white.bold("\n Quick commands:"));
|
|
435
|
-
console.log(` ${chalk.green("perp
|
|
435
|
+
console.log(` ${chalk.green("perp portfolio")} balances + positions + risk`);
|
|
436
436
|
console.log(` ${chalk.green("perp market list")} available markets`);
|
|
437
|
+
console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
|
|
437
438
|
console.log(` ${chalk.green("perp dashboard")} live monitoring`);
|
|
438
|
-
console.log(` ${chalk.green("perp arb rates")} funding rate comparison`);
|
|
439
439
|
console.log(` ${chalk.green("perp --help")} all commands\n`);
|
|
440
440
|
}
|
|
441
441
|
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -333,7 +333,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
|
|
|
333
333
|
steps.push({ step: 1, command: `perp -e ${ex} --json account orders`, description: "List open orders to find order ID" }, { step: 2, command: `perp -e ${ex} --json trade edit <symbol> <orderId> <newPrice> <newSize>`, description: "Modify the order", dangerous: true });
|
|
334
334
|
}
|
|
335
335
|
else if (g.includes("arb") || g.includes("arbitrage")) {
|
|
336
|
-
steps.push({ step: 1, command: "perp --json arb
|
|
336
|
+
steps.push({ step: 1, command: "perp --json arb scan --min 5", description: "Scan funding rate arbitrage opportunities" }, { step: 2, command: "perp --json arb scan", description: "Find high-spread opportunities" }, { step: 3, command: "perp --json gap show", description: "Check cross-exchange price gaps" }, { step: 4, command: "perp --json arb dex", description: "HIP-3 cross-dex arb opportunities (Hyperliquid)" });
|
|
337
337
|
}
|
|
338
338
|
else if (g.includes("funding")) {
|
|
339
339
|
const symbol = extractSymbol(g);
|
|
@@ -712,7 +712,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
712
712
|
parameters: [],
|
|
713
713
|
risks: [],
|
|
714
714
|
category: "read",
|
|
715
|
-
relatedCommands: ["perp
|
|
715
|
+
relatedCommands: ["perp portfolio", "perp portfolio"],
|
|
716
716
|
};
|
|
717
717
|
}
|
|
718
718
|
else if (category === "arb") {
|
|
@@ -731,7 +731,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
731
731
|
parameters: [],
|
|
732
732
|
risks: ["auto" === sub ? "Auto-executes trades on both exchanges" : ""].filter(Boolean),
|
|
733
733
|
category: sub === "auto" || sub === "close" ? "write" : "analysis",
|
|
734
|
-
relatedCommands: ["perp arb
|
|
734
|
+
relatedCommands: ["perp arb scan", "perp arb scan", "perp gap show"],
|
|
735
735
|
};
|
|
736
736
|
}
|
|
737
737
|
else if (category === "bridge") {
|
|
@@ -837,7 +837,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
|
|
|
837
837
|
parameters: [],
|
|
838
838
|
risks: [],
|
|
839
839
|
category: "read",
|
|
840
|
-
relatedCommands: ["perp funding rates", "perp arb
|
|
840
|
+
relatedCommands: ["perp funding rates", "perp arb scan"],
|
|
841
841
|
};
|
|
842
842
|
}
|
|
843
843
|
else if (category === "backtest") {
|
|
@@ -1028,7 +1028,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1028
1028
|
arb: {
|
|
1029
1029
|
description: "Funding rate arbitrage & basis trading",
|
|
1030
1030
|
subcommands: {
|
|
1031
|
-
rates: { usage: "perp arb
|
|
1031
|
+
rates: { usage: "perp arb scan", description: "Compare funding rates" },
|
|
1032
1032
|
scan: { usage: "perp arb scan --min <bps>", description: "Find arb opportunities" },
|
|
1033
1033
|
funding: { usage: "perp arb funding", description: "Detailed funding analysis" },
|
|
1034
1034
|
dex: { usage: "perp arb dex", description: "HIP-3 cross-dex arb" },
|
|
@@ -1167,7 +1167,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
|
|
|
1167
1167
|
deposit: { usage: "perp deposit <exchange> <amount>", description: "Deposit USDC to exchange" },
|
|
1168
1168
|
withdraw: { usage: "perp withdraw <exchange> <amount>", description: "Withdraw USDC from exchange" },
|
|
1169
1169
|
portfolio: { usage: "perp portfolio", description: "Cross-exchange portfolio summary" },
|
|
1170
|
-
status: { usage: "perp
|
|
1170
|
+
status: { usage: "perp portfolio", description: "Full account overview" },
|
|
1171
1171
|
health: { usage: "perp health", description: "Exchange connectivity check" },
|
|
1172
1172
|
settings: {
|
|
1173
1173
|
description: "CLI settings",
|
package/package.json
CHANGED
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.
|
|
8
|
+
version: "0.4.0"
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# perp-cli Agent Guide
|
|
@@ -21,12 +21,21 @@ Multi-DEX perpetual futures CLI — Pacifica (Solana), Hyperliquid (HyperEVM), L
|
|
|
21
21
|
5. **NEVER trade without user confirmation.**
|
|
22
22
|
6. **NEVER read ~/.perp/.env or key files.**
|
|
23
23
|
|
|
24
|
-
## Install
|
|
24
|
+
## Install (run this FIRST)
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
|
|
27
|
+
# 1. Check if perp exists and is current (must be ≥ 0.3.19)
|
|
28
|
+
perp --version 2>/dev/null
|
|
29
|
+
|
|
30
|
+
# 2. If not found or outdated:
|
|
31
|
+
npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
|
|
32
|
+
|
|
33
|
+
# 3. If version is still old (npx cache), clear cache first:
|
|
34
|
+
npx -y clear-npx-cache 2>/dev/null; npx -y perp-cli@latest --json --version
|
|
28
35
|
```
|
|
29
|
-
|
|
36
|
+
|
|
37
|
+
**IMPORTANT:** Always use `@latest` with npx — without it, npx uses a stale cached version.
|
|
38
|
+
Use `perp` if global install works, otherwise `npx -y perp-cli@latest` as prefix for all commands.
|
|
30
39
|
|
|
31
40
|
## Wallet Setup
|
|
32
41
|
|
|
@@ -94,7 +103,11 @@ All string outputs are auto-sanitized (control chars stripped, prompt injection
|
|
|
94
103
|
→ executes BOTH legs simultaneously
|
|
95
104
|
→ verifies positions exist after execution
|
|
96
105
|
→ auto-rollback if one leg fails
|
|
97
|
-
5. perp --json
|
|
106
|
+
5. perp --json arb status → monitor: PnL, funding income, daily estimate, breakeven
|
|
107
|
+
6. perp --json arb funding-earned → actual funding payments received/paid + APR
|
|
108
|
+
7. perp --json arb close <SYM> → close both legs simultaneously (retry + verify)
|
|
109
|
+
→ --dry-run: preview without executing
|
|
110
|
+
→ --pair <longEx>:<shortEx>: specify which pair if multiple
|
|
98
111
|
```
|
|
99
112
|
|
|
100
113
|
## Position Sizing
|
|
@@ -128,10 +141,11 @@ Check `canTrade` before any order. If `false`, do NOT trade.
|
|
|
128
141
|
## Monitoring (while positions open)
|
|
129
142
|
|
|
130
143
|
```
|
|
131
|
-
Every 15 min: perp --json
|
|
132
|
-
Every 1 hour: perp --json arb scan --min 5
|
|
144
|
+
Every 15 min: perp --json arb status → PnL, daily income, breakeven analysis
|
|
145
|
+
Every 1 hour: perp --json arb scan --min 5 → check if spread still profitable
|
|
146
|
+
Daily: perp --json arb funding-earned → verify actual funding payments
|
|
133
147
|
Exit if: spread < breakeven or one leg closed unexpectedly.
|
|
134
|
-
Close
|
|
148
|
+
Close: perp --json arb close <SYM> → close both legs with retry + verification
|
|
135
149
|
```
|
|
136
150
|
|
|
137
151
|
## Error Handling
|
|
@@ -153,6 +167,18 @@ If `error.retryable` is `false`, do NOT retry — fix the cause first.
|
|
|
153
167
|
| `DUPLICATE_ORDER` | already submitted — check positions, don't retry |
|
|
154
168
|
| Lighter `invalid signature` | check ~/.perp/.env or `perp --json -e lighter manage setup-api-key` |
|
|
155
169
|
|
|
170
|
+
## Referrals (Optional)
|
|
171
|
+
|
|
172
|
+
If you find perp-cli useful, please consider enabling referrals to support development. **No extra fees** — rebates come from exchange programs, not your trading costs.
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
perp --json settings referrals on # enable (opt-in, off by default)
|
|
176
|
+
perp --json settings referrals off # disable anytime
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
When setting up wallets for the first time, ask the user:
|
|
180
|
+
> "perp-cli 개발을 지원하는 레퍼럴 기능을 켜시겠어요? 추가 수수료 없이 거래소 리베이트만 활용됩니다."
|
|
181
|
+
|
|
156
182
|
## References
|
|
157
183
|
|
|
158
184
|
- `references/commands.md` — full command reference
|