perp-cli 0.3.19 → 0.4.2

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
@@ -2,6 +2,8 @@
2
2
  * Arb utilities: settlement timing strategy, basis risk monitoring, and webhook notifications.
3
3
  */
4
4
  import type { ExchangeAdapter } from "../exchanges/interface.js";
5
+ /** Settlement schedules per exchange (UTC hours when settlement occurs) */
6
+ export declare const SETTLEMENT_SCHEDULES: Record<string, number[]>;
5
7
  export type SettleStrategy = "block" | "aggressive" | "off";
6
8
  /**
7
9
  * Get the most recent settlement time for an exchange.
package/dist/arb/utils.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  // ── Settlement Timing Strategy ──
5
5
  /** Settlement schedules per exchange (UTC hours when settlement occurs) */
6
- const SETTLEMENT_SCHEDULES = {
6
+ export const SETTLEMENT_SCHEDULES = {
7
7
  hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
8
8
  pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
9
9
  lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
@@ -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
  },
@@ -142,14 +142,14 @@ export function registerAgentCommands(program, getAdapter, isJson) {
142
142
  category: "trading",
143
143
  commands: [
144
144
  "perp trade check <symbol> <side> <size> --json",
145
- "perp trade market <symbol> <buy|sell> <size> --json --client-id <id>",
145
+ "perp trade market <symbol> <buy|sell> <size> --json [--smart] --client-id <id>",
146
146
  "perp trade limit <symbol> <buy|sell> <price> <size> --json --client-id <id>",
147
147
  "perp trade cancel <symbol> <orderId> --json",
148
148
  "perp trade cancel-all --json",
149
- "perp trade close <symbol> --json",
150
- "perp trade close-all --json",
151
- "perp trade flatten --json",
152
- "perp trade reduce <symbol> <percent> --json",
149
+ "perp trade close <symbol> --json [--smart]",
150
+ "perp trade close-all --json [--smart]",
151
+ "perp trade flatten --json [--smart]",
152
+ "perp trade reduce <symbol> <percent> --json [--smart]",
153
153
  "perp trade stop <symbol> <side> <stopPrice> <size> --json",
154
154
  "perp trade tpsl <symbol> <side> --tp <price> --sl <price> --json",
155
155
  "perp trade twap <symbol> <side> <size> <duration> --json",
@@ -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,
@@ -4,12 +4,14 @@ import { readExecutionLog, logExecution } from "../../execution-log.js";
4
4
  import { computeAnnualSpread } from "../../funding.js";
5
5
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
6
6
  import { computeBasisRisk } from "../../arb-utils.js";
7
+ import { smartOrder } from "../../smart-order.js";
8
+ import { removePosition as persistRemovePosition } from "../../arb-state.js";
7
9
  import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
8
10
  import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
9
11
  import { computeEnhancedStats } from "../../arb-history-stats.js";
10
12
  // ── Helpers ──
13
+ import { DEFAULT_TAKER_FEE as TAKER_FEE } from "../../constants.js";
11
14
  const EXCHANGES = ["hyperliquid", "lighter", "pacifica"];
12
- const TAKER_FEE = 0.00035; // ~0.035% typical taker fee
13
15
  function formatDuration(ms) {
14
16
  const hours = Math.floor(ms / (1000 * 60 * 60));
15
17
  const days = Math.floor(hours / 24);
@@ -165,6 +167,16 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
165
167
  // Net PnL
166
168
  const totalUpnl = longPos.unrealizedPnl + shortPos.unrealizedPnl;
167
169
  const netPnl = totalUpnl + estimatedFundingIncome - totalFees;
170
+ // Daily funding estimate from current spread
171
+ const dailyFundingEstimate = currentSpread
172
+ ? (currentSpread / 100) / 365 * avgNotional
173
+ : 0;
174
+ // Breakeven analysis
175
+ const totalCosts = totalFees; // entry+exit fees
176
+ const remainingToBreakeven = totalCosts - estimatedFundingIncome - totalUpnl;
177
+ const daysToBreakeven = dailyFundingEstimate > 0 && remainingToBreakeven > 0
178
+ ? remainingToBreakeven / dailyFundingEstimate
179
+ : remainingToBreakeven <= 0 ? 0 : null;
168
180
  arbPairs.push({
169
181
  symbol,
170
182
  longExchange: longPos.exchange,
@@ -195,12 +207,33 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
195
207
  estimatedFees: totalFees,
196
208
  unrealizedPnl: totalUpnl,
197
209
  netPnl,
210
+ dailyFundingEstimate,
211
+ breakeven: {
212
+ daysToBreakeven,
213
+ dailyIncome: dailyFundingEstimate,
214
+ totalCosts,
215
+ remainingToBreakeven: Math.max(0, remainingToBreakeven),
216
+ },
198
217
  });
199
218
  }
200
219
  }
201
220
  }
202
221
  if (isJson()) {
203
- return printJson(jsonOk(arbPairs));
222
+ const totalNotionalJ = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
223
+ const totalDailyJ = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
224
+ return printJson(jsonOk({
225
+ pairs: arbPairs,
226
+ aggregate: {
227
+ positions: arbPairs.length,
228
+ totalNotionalUsd: totalNotionalJ,
229
+ unrealizedPnl: arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0),
230
+ estimatedFunding: arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0),
231
+ estimatedFees: arbPairs.reduce((s, p) => s + p.estimatedFees, 0),
232
+ netPnl: arbPairs.reduce((s, p) => s + p.netPnl, 0),
233
+ dailyFundingEstimate: totalDailyJ,
234
+ annualizedApr: totalNotionalJ > 0 ? (totalDailyJ * 365 / totalNotionalJ) * 100 : 0,
235
+ },
236
+ }));
204
237
  }
205
238
  if (arbPairs.length === 0) {
206
239
  console.log(chalk.gray(" No open arb positions found.\n"));
@@ -240,12 +273,20 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
240
273
  const totalFunding = arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0);
241
274
  const totalFees = arbPairs.reduce((s, p) => s + p.estimatedFees, 0);
242
275
  const totalNet = arbPairs.reduce((s, p) => s + p.netPnl, 0);
276
+ const totalDailyFunding = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
277
+ const totalNotional = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
278
+ const portfolioApr = totalNotional > 0 ? (totalDailyFunding * 365 / totalNotional) * 100 : 0;
243
279
  console.log(chalk.white.bold("\n Summary"));
244
280
  console.log(` Positions: ${arbPairs.length}`);
281
+ console.log(` Total Notional: $${formatUsd(totalNotional)}`);
245
282
  console.log(` Unrealized PnL: ${formatPnl(totalUpnl)}`);
246
283
  console.log(` Est. Funding: ${chalk.yellow(`$${totalFunding.toFixed(4)}`)}`);
247
284
  console.log(` Est. Fees: ${chalk.red(`-$${totalFees.toFixed(4)}`)}`);
248
285
  console.log(` Net PnL: ${formatPnl(totalNet)}`);
286
+ console.log(` Daily Income: ${chalk.cyan(`$${totalDailyFunding.toFixed(4)}/day`)}`);
287
+ if (portfolioApr > 0) {
288
+ console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)}`);
289
+ }
249
290
  console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker for entry + exit.)\n`));
250
291
  });
251
292
  // ── arb close ──
@@ -254,6 +295,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
254
295
  .description("Manually close an arb position on both exchanges")
255
296
  .option("--dry-run", "Show what would happen without executing")
256
297
  .option("--pair <pair>", "Specify arb pair as longExchange:shortExchange (e.g. pacifica:hyperliquid)")
298
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
257
299
  .action(async (symbol, opts) => {
258
300
  const sym = symbol.toUpperCase();
259
301
  const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
@@ -389,44 +431,78 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
389
431
  console.log(chalk.yellow(` BUY ${shortPos.size} ${shortPos.rawSymbol} on ${shortPos.exchange} (close short)\n`));
390
432
  return;
391
433
  }
392
- // Execute close on both exchanges
434
+ // Execute close on both exchanges with retry
435
+ const MAX_RETRIES = 2;
393
436
  const results = [];
394
- // Close both legs concurrently
395
- const closePromises = [
396
- (async () => {
437
+ async function closeWithRetry(exchange, rawSymbol, side, size, label) {
438
+ let lastError = "";
439
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
397
440
  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" });
441
+ const adapter = await getAdapterForExchange(exchange);
442
+ if (opts.smart) {
443
+ await smartOrder(adapter, rawSymbol, side, String(size), { reduceOnly: true });
444
+ }
445
+ else {
446
+ await adapter.marketOrder(rawSymbol, side, String(size));
447
+ }
448
+ results.push({ exchange, action: `${side} (close ${label})`, status: "success", retries: attempt });
401
449
  if (!isJson())
402
- console.log(chalk.green(` Closed long on ${longPos.exchange}: SELL ${longPos.size} ${sym}`));
450
+ console.log(chalk.green(` Closed ${label} on ${exchange}: ${side.toUpperCase()} ${size} ${sym}${opts.smart ? " (smart)" : ""}${attempt > 0 ? ` (retry ${attempt})` : ""}`));
451
+ return;
403
452
  }
404
453
  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}`));
454
+ lastError = err instanceof Error ? err.message : String(err);
455
+ if (attempt < MAX_RETRIES) {
456
+ if (!isJson())
457
+ console.log(chalk.yellow(` Retry ${attempt + 1}/${MAX_RETRIES} for ${label} on ${exchange}...`));
458
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
459
+ }
409
460
  }
410
- })(),
411
- (async () => {
461
+ }
462
+ results.push({ exchange, action: `${side} (close ${label})`, status: "failed", error: lastError, retries: MAX_RETRIES });
463
+ if (!isJson())
464
+ console.error(chalk.red(` Failed to close ${label} on ${exchange}: ${lastError}`));
465
+ }
466
+ // Close both legs concurrently
467
+ await Promise.all([
468
+ closeWithRetry(longPos.exchange, longPos.rawSymbol, "sell", longPos.size, "long"),
469
+ closeWithRetry(shortPos.exchange, shortPos.rawSymbol, "buy", shortPos.size, "short"),
470
+ ]);
471
+ const allSuccess = results.every(r => r.status === "success");
472
+ const anySuccess = results.some(r => r.status === "success");
473
+ // Verify positions are actually closed
474
+ let verification;
475
+ if (anySuccess) {
476
+ verification = [];
477
+ for (const r of results) {
478
+ if (r.status !== "success")
479
+ continue;
412
480
  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}`));
481
+ const adapter = await getAdapterForExchange(r.exchange);
482
+ const positions = await adapter.getPositions();
483
+ const remaining = positions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym);
484
+ verification.push({
485
+ exchange: r.exchange,
486
+ remainingSize: remaining ? Math.abs(Number(remaining.size)) : 0,
487
+ closed: !remaining || Math.abs(Number(remaining.size)) < 0.0001,
488
+ });
418
489
  }
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}`));
490
+ catch {
491
+ verification.push({ exchange: r.exchange, remainingSize: -1, closed: false });
424
492
  }
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");
493
+ }
494
+ const notClosed = verification.filter(v => !v.closed);
495
+ if (notClosed.length > 0 && !isJson()) {
496
+ console.log(chalk.yellow(`\n Warning: Position may not be fully closed on: ${notClosed.map(v => v.exchange).join(", ")}`));
497
+ }
498
+ }
499
+ // Remove from persisted daemon state (so daemon doesn't try to re-close)
500
+ if (allSuccess) {
501
+ try {
502
+ persistRemovePosition(sym);
503
+ }
504
+ catch { /* state may not exist */ }
505
+ }
430
506
  // Log to execution log
431
507
  const arbPairId = `${sym}:${longPos.exchange}:${shortPos.exchange}`;
432
508
  logExecution({
@@ -459,6 +535,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
459
535
  estimatedFees: totalFees,
460
536
  netPnl,
461
537
  results,
538
+ verification,
462
539
  }));
463
540
  }
464
541
  if (!allSuccess && anySuccess) {
@@ -918,4 +995,184 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
918
995
  console.log(chalk.yellow("\n To execute, use: perp rebalance execute --auto-bridge\n"));
919
996
  }
920
997
  });
998
+ // ── arb funding-earned ──
999
+ arb
1000
+ .command("funding-earned")
1001
+ .description("Actual funding payments received/paid for arb positions")
1002
+ .option("--period <days>", "Number of days to look back", "7")
1003
+ .option("--symbol <symbol>", "Filter by symbol")
1004
+ .action(async (opts) => {
1005
+ const periodDays = parseInt(opts.period);
1006
+ const filterSym = opts.symbol?.toUpperCase();
1007
+ if (!isJson())
1008
+ console.log(chalk.cyan(`\n Fetching funding payments (last ${periodDays} days)...\n`));
1009
+ // Fetch positions to identify arb pairs
1010
+ const allPositions = [];
1011
+ for (const exName of EXCHANGES) {
1012
+ try {
1013
+ const adapter = await getAdapterForExchange(exName);
1014
+ const positions = await adapter.getPositions();
1015
+ for (const p of positions) {
1016
+ const normalized = p.symbol.replace("-PERP", "").toUpperCase();
1017
+ allPositions.push({
1018
+ exchange: exName,
1019
+ symbol: normalized,
1020
+ side: p.side,
1021
+ size: Math.abs(Number(p.size)),
1022
+ markPrice: Number(p.markPrice),
1023
+ });
1024
+ }
1025
+ }
1026
+ catch {
1027
+ // exchange not configured
1028
+ }
1029
+ }
1030
+ // Identify arb pairs (long on one exchange, short on another)
1031
+ const arbPairKeys = new Set();
1032
+ const arbPairInfo = new Map();
1033
+ const longs = allPositions.filter(p => p.side === "long");
1034
+ const shorts = allPositions.filter(p => p.side === "short");
1035
+ for (const l of longs) {
1036
+ for (const s of shorts) {
1037
+ if (l.exchange !== s.exchange && l.symbol === s.symbol) {
1038
+ const key = l.symbol;
1039
+ arbPairKeys.add(key);
1040
+ arbPairInfo.set(key, {
1041
+ longExchange: l.exchange,
1042
+ shortExchange: s.exchange,
1043
+ notionalUsd: (l.size * l.markPrice + s.size * s.markPrice) / 2,
1044
+ });
1045
+ }
1046
+ }
1047
+ }
1048
+ const allPayments = [];
1049
+ const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
1050
+ for (const exName of EXCHANGES) {
1051
+ try {
1052
+ const adapter = await getAdapterForExchange(exName);
1053
+ const payments = await adapter.getFundingPayments(200);
1054
+ for (const p of payments) {
1055
+ if (p.time < cutoff)
1056
+ continue;
1057
+ const sym = p.symbol.replace("-PERP", "").toUpperCase();
1058
+ if (filterSym && sym !== filterSym)
1059
+ continue;
1060
+ allPayments.push({
1061
+ time: p.time,
1062
+ exchange: exName,
1063
+ symbol: sym,
1064
+ payment: typeof p.payment === "string" ? parseFloat(p.payment) : Number(p.payment),
1065
+ });
1066
+ }
1067
+ }
1068
+ catch {
1069
+ // exchange not configured or no payments
1070
+ }
1071
+ }
1072
+ // Group by symbol, then by exchange
1073
+ const bySymbol = new Map();
1074
+ for (const p of allPayments) {
1075
+ if (!bySymbol.has(p.symbol))
1076
+ bySymbol.set(p.symbol, []);
1077
+ bySymbol.get(p.symbol).push(p);
1078
+ }
1079
+ const pairs = [];
1080
+ for (const [symbol, payments] of bySymbol) {
1081
+ const isArb = arbPairKeys.has(symbol);
1082
+ const pairInfo = arbPairInfo.get(symbol);
1083
+ // Group by exchange
1084
+ const byEx = new Map();
1085
+ for (const p of payments) {
1086
+ if (!byEx.has(p.exchange))
1087
+ byEx.set(p.exchange, []);
1088
+ byEx.get(p.exchange).push(p);
1089
+ }
1090
+ const exchangeSummaries = [];
1091
+ let totalReceived = 0;
1092
+ let totalPaid = 0;
1093
+ for (const [exchange, exPayments] of byEx) {
1094
+ const received = exPayments.filter(p => p.payment > 0).reduce((s, p) => s + p.payment, 0);
1095
+ const paid = exPayments.filter(p => p.payment < 0).reduce((s, p) => s + Math.abs(p.payment), 0);
1096
+ const net = received - paid;
1097
+ totalReceived += received;
1098
+ totalPaid += paid;
1099
+ exchangeSummaries.push({ exchange, totalReceived: received, totalPaid: paid, net, payments: exPayments.length });
1100
+ }
1101
+ const netEarned = totalReceived - totalPaid;
1102
+ const dailyAvg = periodDays > 0 ? netEarned / periodDays : 0;
1103
+ const notionalUsd = pairInfo?.notionalUsd || 0;
1104
+ const annualizedApr = notionalUsd > 0 ? (dailyAvg * 365 / notionalUsd) * 100 : 0;
1105
+ pairs.push({
1106
+ symbol,
1107
+ isArbPair: isArb,
1108
+ longExchange: pairInfo?.longExchange ?? null,
1109
+ shortExchange: pairInfo?.shortExchange ?? null,
1110
+ notionalUsd,
1111
+ byExchange: exchangeSummaries,
1112
+ totalReceived,
1113
+ totalPaid,
1114
+ netEarned,
1115
+ dailyAvg,
1116
+ annualizedApr,
1117
+ payments: payments.length,
1118
+ });
1119
+ }
1120
+ // Sort: arb pairs first, then by netEarned
1121
+ pairs.sort((a, b) => {
1122
+ if (a.isArbPair !== b.isArbPair)
1123
+ return a.isArbPair ? -1 : 1;
1124
+ return b.netEarned - a.netEarned;
1125
+ });
1126
+ // Totals
1127
+ const totals = {
1128
+ totalReceived: pairs.reduce((s, p) => s + p.totalReceived, 0),
1129
+ totalPaid: pairs.reduce((s, p) => s + p.totalPaid, 0),
1130
+ netEarned: pairs.reduce((s, p) => s + p.netEarned, 0),
1131
+ dailyAvg: pairs.reduce((s, p) => s + p.dailyAvg, 0),
1132
+ totalNotional: pairs.filter(p => p.isArbPair).reduce((s, p) => s + p.notionalUsd, 0),
1133
+ arbPairs: pairs.filter(p => p.isArbPair).length,
1134
+ period: periodDays,
1135
+ };
1136
+ const totalArbNotional = totals.totalNotional;
1137
+ const portfolioApr = totalArbNotional > 0
1138
+ ? (totals.dailyAvg * 365 / totalArbNotional) * 100
1139
+ : 0;
1140
+ if (isJson()) {
1141
+ return printJson(jsonOk({
1142
+ pairs,
1143
+ totals: { ...totals, annualizedApr: portfolioApr },
1144
+ }));
1145
+ }
1146
+ if (pairs.length === 0) {
1147
+ console.log(chalk.gray(" No funding payments found.\n"));
1148
+ return;
1149
+ }
1150
+ console.log(chalk.cyan.bold(" Funding Payments Earned\n"));
1151
+ const rows = pairs.map(p => {
1152
+ const arbTag = p.isArbPair ? chalk.green("ARB") : chalk.gray("---");
1153
+ const exchanges = p.byExchange.map(e => e.exchange.slice(0, 3).toUpperCase()).join("+");
1154
+ return [
1155
+ chalk.white.bold(p.symbol),
1156
+ arbTag,
1157
+ exchanges,
1158
+ chalk.green(`$${p.totalReceived.toFixed(4)}`),
1159
+ chalk.red(`-$${p.totalPaid.toFixed(4)}`),
1160
+ formatPnl(p.netEarned),
1161
+ `$${p.dailyAvg.toFixed(4)}/d`,
1162
+ p.annualizedApr > 0 ? `${p.annualizedApr.toFixed(1)}%` : "-",
1163
+ String(p.payments),
1164
+ ];
1165
+ });
1166
+ console.log(makeTable(["Symbol", "Type", "Exchanges", "Received", "Paid", "Net", "Daily Avg", "APR", "#"], rows));
1167
+ console.log(chalk.white.bold("\n Totals"));
1168
+ console.log(` Period: ${periodDays} days`);
1169
+ console.log(` Received: ${chalk.green(`$${totals.totalReceived.toFixed(4)}`)}`);
1170
+ console.log(` Paid: ${chalk.red(`-$${totals.totalPaid.toFixed(4)}`)}`);
1171
+ console.log(` Net Earned: ${formatPnl(totals.netEarned)}`);
1172
+ console.log(` Daily Avg: $${totals.dailyAvg.toFixed(4)}/day`);
1173
+ if (portfolioApr > 0) {
1174
+ console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)} (on $${formatUsd(totalArbNotional)} arb notional)`);
1175
+ }
1176
+ console.log();
1177
+ });
921
1178
  }