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 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
@@ -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 status --json",
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 rates --json",
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 + cross-dex funding arb, price gaps, HIP-3 deployed dex arb",
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 rates --json", description: "Compare funding rates across exchanges" }, { step: 2, command: "perp arb scan --json", description: "Find high-spread opportunities" }, { step: 3, command: "perp gap show --json", description: "Check price gaps" }, { step: 4, command: "perp arb auto --dry-run --min-spread 30", description: "Dry-run auto arb bot" });
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 status --json", description: "Full account overview" }, { step: 2, command: "perp account positions --json", description: "Detailed positions" }, { step: 3, command: "perp account orders --json", description: "Open orders" });
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 status --json", description: "Check account status" });
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
- return printJson(jsonOk(arbPairs));
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
- // Close both legs concurrently
395
- const closePromises = [
396
- (async () => {
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(longPos.exchange);
399
- await adapter.marketOrder(longPos.rawSymbol, "sell", String(longPos.size));
400
- results.push({ exchange: longPos.exchange, action: "sell (close long)", status: "success" });
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 long on ${longPos.exchange}: SELL ${longPos.size} ${sym}`));
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
- const msg = err instanceof Error ? err.message : String(err);
406
- results.push({ exchange: longPos.exchange, action: "sell (close long)", status: "failed", error: msg });
407
- if (!isJson())
408
- console.error(chalk.red(` Failed to close long on ${longPos.exchange}: ${msg}`));
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
- (async () => {
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(shortPos.exchange);
414
- await adapter.marketOrder(shortPos.rawSymbol, "buy", String(shortPos.size));
415
- results.push({ exchange: shortPos.exchange, action: "buy (close short)", status: "success" });
416
- if (!isJson())
417
- console.log(chalk.green(` Closed short on ${shortPos.exchange}: BUY ${shortPos.size} ${sym}`));
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 (err) {
420
- const msg = err instanceof Error ? err.message : String(err);
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
- await Promise.all(closePromises);
428
- const allSuccess = results.every(r => r.status === "success");
429
- const anySuccess = results.some(r => r.status === "success");
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
  }
@@ -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 status")} account overview`);
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
  }
@@ -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
- throw new Error(`ChangePubKey failed: ${result.message ?? JSON.stringify(result)}`);
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
  }
@@ -242,7 +242,7 @@ export class PacificaAdapter {
242
242
  return history.slice(0, limit).map((h) => ({
243
243
  time: Number(h.created_at ?? 0),
244
244
  symbol: String(h.symbol ?? ""),
245
- payment: String(h.amount ?? "0"),
245
+ payment: String(h.payout ?? "0"),
246
246
  }));
247
247
  }
248
248
  }
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(" v0.3.7\n"));
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 init")}`);
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 rates")} funding rate comparison`);
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(" v0.3.7\n"));
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 status")} account overview`);
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
  }
@@ -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 rates", description: "Compare funding rates across exchanges" }, { 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)" });
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 status", "perp portfolio"],
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 rates", "perp arb scan", "perp gap show"],
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 rates"],
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 rates", description: "Compare funding rates" },
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 status", description: "Full account overview" },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.3.18",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -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.18"
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
- npm install -g perp-cli
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
- No sudo: `npm install -g perp-cli --prefix ~/.npm-global && export PATH="$HOME/.npm-global/bin:$PATH"`
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 portfolio → verify positions
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 portfolio
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 both: perp --json -e <EX1> trade close <SYM> & perp --json -e <EX2> trade close <SYM>
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