perp-cli 0.4.2 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +46 -5
  2. package/dist/arb/reconcile.d.ts +28 -0
  3. package/dist/arb/reconcile.js +162 -0
  4. package/dist/arb/sizing.d.ts +12 -1
  5. package/dist/arb/sizing.js +38 -5
  6. package/dist/arb/state.d.ts +6 -0
  7. package/dist/cli-spec.d.ts +0 -4
  8. package/dist/cli-spec.js +8 -1
  9. package/dist/commands/account.d.ts +1 -1
  10. package/dist/commands/account.js +300 -63
  11. package/dist/commands/agent.js +16 -9
  12. package/dist/commands/arb/index.js +243 -49
  13. package/dist/commands/arb-auto.js +696 -9
  14. package/dist/commands/arb.d.ts +2 -1
  15. package/dist/commands/arb.js +535 -131
  16. package/dist/commands/bot.js +207 -0
  17. package/dist/commands/dex.js +7 -1
  18. package/dist/commands/funding.d.ts +2 -1
  19. package/dist/commands/funding.js +211 -4
  20. package/dist/commands/funds.d.ts +4 -0
  21. package/dist/commands/funds.js +741 -0
  22. package/dist/commands/history.d.ts +2 -1
  23. package/dist/commands/history.js +508 -5
  24. package/dist/commands/manage.js +6 -4
  25. package/dist/commands/market.js +35 -2
  26. package/dist/commands/multileg.d.ts +16 -0
  27. package/dist/commands/multileg.js +174 -0
  28. package/dist/commands/pnl.d.ts +12 -0
  29. package/dist/commands/pnl.js +243 -0
  30. package/dist/commands/risk.d.ts +2 -0
  31. package/dist/commands/risk.js +56 -0
  32. package/dist/commands/settings.js +147 -0
  33. package/dist/commands/stream.js +132 -55
  34. package/dist/commands/trade.d.ts +1 -1
  35. package/dist/commands/trade.js +270 -267
  36. package/dist/commands/wallet.d.ts +4 -0
  37. package/dist/commands/wallet.js +11 -1
  38. package/dist/config.d.ts +6 -1
  39. package/dist/config.js +21 -1
  40. package/dist/dashboard/ws-feeds.js +1 -1
  41. package/dist/equity-tracker.d.ts +57 -0
  42. package/dist/equity-tracker.js +209 -0
  43. package/dist/exchanges/hyperliquid-spot.d.ts +69 -0
  44. package/dist/exchanges/hyperliquid-spot.js +309 -0
  45. package/dist/exchanges/hyperliquid.d.ts +26 -5
  46. package/dist/exchanges/hyperliquid.js +171 -73
  47. package/dist/exchanges/interface.d.ts +1 -1
  48. package/dist/exchanges/lighter-spot.d.ts +55 -0
  49. package/dist/exchanges/lighter-spot.js +415 -0
  50. package/dist/exchanges/lighter.d.ts +10 -2
  51. package/dist/exchanges/lighter.js +210 -56
  52. package/dist/exchanges/pacifica.d.ts +11 -4
  53. package/dist/exchanges/pacifica.js +64 -37
  54. package/dist/exchanges/spot-interface.d.ts +45 -0
  55. package/dist/exchanges/spot-interface.js +22 -0
  56. package/dist/execution-log.d.ts +2 -2
  57. package/dist/funding/rates.d.ts +36 -0
  58. package/dist/funding/rates.js +146 -0
  59. package/dist/index.js +126 -161
  60. package/dist/liquidity.d.ts +12 -0
  61. package/dist/liquidity.js +43 -0
  62. package/dist/mcp-server.js +43 -96
  63. package/dist/signer/evm-local.d.ts +12 -0
  64. package/dist/signer/evm-local.js +19 -0
  65. package/dist/signer/index.d.ts +3 -0
  66. package/dist/signer/index.js +2 -0
  67. package/dist/signer/interface.d.ts +31 -0
  68. package/dist/signer/interface.js +7 -0
  69. package/dist/signer/solana-local.d.ts +13 -0
  70. package/dist/signer/solana-local.js +19 -0
  71. package/dist/smart-order.js +14 -3
  72. package/dist/strategies/split-order.d.ts +49 -0
  73. package/dist/strategies/split-order.js +138 -0
  74. package/dist/utils.js +7 -1
  75. package/dist/ws/market-feed.d.ts +74 -0
  76. package/dist/ws/market-feed.js +227 -0
  77. package/package.json +1 -1
  78. package/skills/perp-cli/SKILL.md +80 -4
  79. package/skills/perp-cli/references/agent-operations.md +19 -9
  80. package/skills/perp-cli/references/commands.md +54 -32
  81. package/skills/perp-cli/references/strategies.md +27 -17
  82. package/skills/perp-cli/scripts/arb-monitor.sh +74 -0
  83. package/skills/perp-cli/scripts/funding-analysis.sh +85 -0
  84. package/skills/perp-cli/scripts/preflight.sh +98 -0
  85. package/skills/perp-cli/scripts/spot-perp-scan.sh +83 -0
  86. package/skills/perp-cli/scripts/validate-arb.sh +112 -0
  87. package/skills/perp-cli/templates/arb-scan.sh +0 -15
  88. package/skills/perp-cli/templates/open-position.sh +0 -38
  89. package/skills/perp-cli/templates/wallet-setup.sh +0 -17
@@ -1,11 +1,12 @@
1
1
  import chalk from "chalk";
2
2
  import { makeTable, formatUsd, formatPnl, printJson, jsonOk } from "../../utils.js";
3
3
  import { readExecutionLog, logExecution } from "../../execution-log.js";
4
- import { computeAnnualSpread } from "../../funding.js";
4
+ import { toHourlyRate, computeAnnualSpread } from "../../funding.js";
5
5
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
6
6
  import { computeBasisRisk } from "../../arb-utils.js";
7
7
  import { smartOrder } from "../../smart-order.js";
8
- import { removePosition as persistRemovePosition } from "../../arb-state.js";
8
+ import { removePosition as persistRemovePosition, getPositions as getArbStatePositions } from "../../arb-state.js";
9
+ import { SPOT_PERP_TOKEN_MAP } from "../../exchanges/spot-interface.js";
9
10
  import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
10
11
  import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
11
12
  import { computeEnhancedStats } from "../../arb-history-stats.js";
@@ -89,7 +90,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
89
90
  // ── arb status ──
90
91
  arb
91
92
  .command("status")
92
- .description("Show open arb positions with PnL breakdown")
93
+ .description("Show open arb positions (perp-perp + spot-perp) with PnL breakdown")
93
94
  .action(async () => {
94
95
  if (!isJson())
95
96
  console.log(chalk.cyan("\n Checking arb positions across exchanges...\n"));
@@ -218,66 +219,259 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
218
219
  }
219
220
  }
220
221
  }
222
+ // ── Spot-Perp position detection ──
223
+ // Fetch spot balances from HL and LT, match with perp shorts
224
+ const spotPerpPositions = [];
225
+ // Build reverse map: spot token → perp symbol (UETH→ETH, UBTC→BTC, etc.)
226
+ const spotToPerpSymbol = (token) => {
227
+ const upper = token.toUpperCase();
228
+ return SPOT_PERP_TOKEN_MAP[upper] ?? upper;
229
+ };
230
+ // Collect spot balances per exchange
231
+ const spotBalancesByExchange = new Map();
232
+ const SPOT_EXCHANGES = ["hyperliquid", "lighter"];
233
+ for (const exName of SPOT_EXCHANGES) {
234
+ try {
235
+ const adapter = await getAdapterForExchange(exName);
236
+ if (exName === "hyperliquid") {
237
+ const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
238
+ const hlSpot = new HyperliquidSpotAdapter(adapter);
239
+ await hlSpot.init();
240
+ const balances = await hlSpot.getSpotBalances();
241
+ spotBalancesByExchange.set(exName, balances.filter(b => b.token !== "USDC" && Number(b.total) > 0));
242
+ }
243
+ else if (exName === "lighter") {
244
+ const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
245
+ const ltSpot = new LighterSpotAdapter(adapter);
246
+ await ltSpot.init();
247
+ const balances = await ltSpot.getSpotBalances();
248
+ spotBalancesByExchange.set(exName, balances.filter(b => b.token !== "USDC" && b.token !== "USDC_SPOT" && Number(b.total) > 0));
249
+ }
250
+ }
251
+ catch {
252
+ // exchange not configured or no spot support
253
+ }
254
+ }
255
+ // Load persisted arb state for spot-perp entries
256
+ const persistedSpotPerp = getArbStatePositions().filter(p => p.mode === "spot-perp");
257
+ const persistedSymbols = new Set(persistedSpotPerp.map(p => p.symbol.toUpperCase()));
258
+ // Match: spot balance + perp short on same symbol (different or same exchange)
259
+ for (const [spotEx, balances] of spotBalancesByExchange) {
260
+ for (const bal of balances) {
261
+ const perpSymbol = spotToPerpSymbol(bal.token);
262
+ const spotAmount = Number(bal.total);
263
+ if (spotAmount <= 0)
264
+ continue;
265
+ // Find matching perp short position (on any exchange)
266
+ const matchingShorts = allPositions.filter(p => p.symbol === perpSymbol && p.side === "short");
267
+ for (const shortPos of matchingShorts) {
268
+ // Check if this short is already used by a perp-perp pair
269
+ const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange);
270
+ if (usedByPerpPerp)
271
+ continue;
272
+ // Look up persisted state for hold time and entry info
273
+ const persisted = persistedSpotPerp.find(p => p.symbol.toUpperCase() === perpSymbol
274
+ && p.spotExchange === spotEx
275
+ && p.shortExchange === shortPos.exchange);
276
+ const entryTime = persisted?.entryTime ?? null;
277
+ const holdDurationMs = entryTime ? Date.now() - new Date(entryTime).getTime() : null;
278
+ const holdDuration = holdDurationMs ? formatDuration(holdDurationMs) : null;
279
+ // Get current perp funding rate for this symbol on this exchange
280
+ const rates = rateMap.get(perpSymbol);
281
+ const perpRate = rates?.find(r => r.exchange === shortPos.exchange);
282
+ const fundingRate = perpRate?.rate ?? 0;
283
+ const hourlyRate = fundingRate ? toHourlyRate(fundingRate, shortPos.exchange) : 0;
284
+ const annualPct = hourlyRate * 8760 * 100;
285
+ const perpNotional = shortPos.size * shortPos.markPrice;
286
+ const spotValueUsd = spotAmount * shortPos.markPrice; // approx using perp mark price
287
+ // Funding income: spot has 0 funding, perp short receives when rate > 0
288
+ let estimatedFundingIncome = 0;
289
+ if (holdDurationMs && hourlyRate !== 0) {
290
+ const holdHours = holdDurationMs / (1000 * 60 * 60);
291
+ // Short position: pays when rate < 0, receives when rate > 0
292
+ estimatedFundingIncome = Math.abs(hourlyRate) * perpNotional * holdHours;
293
+ if (hourlyRate < 0)
294
+ estimatedFundingIncome = -estimatedFundingIncome;
295
+ }
296
+ // Add persisted accumulated funding
297
+ if (persisted?.accumulatedFunding) {
298
+ estimatedFundingIncome += persisted.accumulatedFunding;
299
+ }
300
+ // Fees: spot buy + spot sell + perp short entry + perp buy to close
301
+ const { getTakerFee } = await import("../../constants.js");
302
+ const spotFee = getTakerFee(spotEx);
303
+ const perpFee = getTakerFee(shortPos.exchange);
304
+ const totalFees = (spotValueUsd * spotFee * 2) + (perpNotional * perpFee * 2);
305
+ const direction = hourlyRate >= 0 ? "long-spot-short-perp" : "sell-spot-long-perp";
306
+ const dailyFundingEstimate = Math.abs(hourlyRate) * perpNotional * 24;
307
+ spotPerpPositions.push({
308
+ symbol: perpSymbol,
309
+ mode: "spot-perp",
310
+ spotExchange: spotEx,
311
+ perpExchange: shortPos.exchange,
312
+ spotToken: bal.token,
313
+ spotAmount,
314
+ spotValueUsd,
315
+ perpSide: "short",
316
+ perpSize: shortPos.size,
317
+ perpEntryPrice: shortPos.entryPrice,
318
+ perpMarkPrice: shortPos.markPrice,
319
+ perpUnrealizedPnl: shortPos.unrealizedPnl,
320
+ perpLeverage: shortPos.leverage,
321
+ perpNotionalUsd: perpNotional,
322
+ direction,
323
+ currentSpread: annualPct || null,
324
+ holdDuration,
325
+ holdDurationMs,
326
+ estimatedFundingIncome,
327
+ estimatedFees: totalFees,
328
+ netPnl: shortPos.unrealizedPnl + estimatedFundingIncome - totalFees,
329
+ dailyFundingEstimate,
330
+ });
331
+ }
332
+ }
333
+ }
334
+ // Also detect persisted spot-perp entries without matching spot balance (already exited spot)
335
+ for (const persisted of persistedSpotPerp) {
336
+ const sym = persisted.symbol.toUpperCase();
337
+ const alreadyMatched = spotPerpPositions.some(sp => sp.symbol === sym && sp.spotExchange === persisted.spotExchange);
338
+ if (alreadyMatched)
339
+ continue;
340
+ // Check if there's still a perp short for this
341
+ const shortPos = allPositions.find(p => p.symbol === sym && p.side === "short" && p.exchange === persisted.shortExchange);
342
+ if (!shortPos)
343
+ continue; // both legs closed, skip
344
+ const holdDurationMs = Date.now() - new Date(persisted.entryTime).getTime();
345
+ const rates = rateMap.get(sym);
346
+ const perpRate = rates?.find(r => r.exchange === persisted.shortExchange);
347
+ const hourlyRate = perpRate ? toHourlyRate(perpRate.rate, persisted.shortExchange) : 0;
348
+ const perpNotional = shortPos.size * shortPos.markPrice;
349
+ spotPerpPositions.push({
350
+ symbol: sym,
351
+ mode: "spot-perp",
352
+ spotExchange: persisted.spotExchange ?? "unknown",
353
+ perpExchange: persisted.shortExchange,
354
+ spotToken: "?",
355
+ spotAmount: 0,
356
+ spotValueUsd: 0,
357
+ perpSide: "short",
358
+ perpSize: shortPos.size,
359
+ perpEntryPrice: shortPos.entryPrice,
360
+ perpMarkPrice: shortPos.markPrice,
361
+ perpUnrealizedPnl: shortPos.unrealizedPnl,
362
+ perpLeverage: shortPos.leverage,
363
+ perpNotionalUsd: perpNotional,
364
+ direction: hourlyRate >= 0 ? "long-spot-short-perp" : "sell-spot-long-perp",
365
+ currentSpread: hourlyRate ? Math.abs(hourlyRate) * 8760 * 100 : null,
366
+ holdDuration: formatDuration(holdDurationMs),
367
+ holdDurationMs,
368
+ estimatedFundingIncome: persisted.accumulatedFunding ?? 0,
369
+ estimatedFees: 0,
370
+ netPnl: shortPos.unrealizedPnl + (persisted.accumulatedFunding ?? 0),
371
+ dailyFundingEstimate: Math.abs(hourlyRate) * perpNotional * 24,
372
+ });
373
+ }
374
+ // ── JSON output ──
221
375
  if (isJson()) {
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);
376
+ const totalNotionalPP = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
377
+ const totalDailyPP = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
378
+ const totalNotionalSP = spotPerpPositions.reduce((s, p) => s + p.perpNotionalUsd, 0);
379
+ const totalDailySP = spotPerpPositions.reduce((s, p) => s + p.dailyFundingEstimate, 0);
380
+ const totalNotionalJ = totalNotionalPP + totalNotionalSP;
381
+ const totalDailyJ = totalDailyPP + totalDailySP;
224
382
  return printJson(jsonOk({
225
- pairs: arbPairs,
383
+ perpPerpPairs: arbPairs,
384
+ spotPerpPairs: spotPerpPositions,
226
385
  aggregate: {
227
- positions: arbPairs.length,
386
+ perpPerpCount: arbPairs.length,
387
+ spotPerpCount: spotPerpPositions.length,
388
+ totalPositions: arbPairs.length + spotPerpPositions.length,
228
389
  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),
390
+ unrealizedPnl: arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0) + spotPerpPositions.reduce((s, p) => s + p.perpUnrealizedPnl, 0),
391
+ estimatedFunding: arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0) + spotPerpPositions.reduce((s, p) => s + p.estimatedFundingIncome, 0),
392
+ estimatedFees: arbPairs.reduce((s, p) => s + p.estimatedFees, 0) + spotPerpPositions.reduce((s, p) => s + p.estimatedFees, 0),
393
+ netPnl: arbPairs.reduce((s, p) => s + p.netPnl, 0) + spotPerpPositions.reduce((s, p) => s + p.netPnl, 0),
233
394
  dailyFundingEstimate: totalDailyJ,
234
395
  annualizedApr: totalNotionalJ > 0 ? (totalDailyJ * 365 / totalNotionalJ) * 100 : 0,
235
396
  },
236
397
  }));
237
398
  }
238
- if (arbPairs.length === 0) {
399
+ if (arbPairs.length === 0 && spotPerpPositions.length === 0) {
239
400
  console.log(chalk.gray(" No open arb positions found.\n"));
240
401
  return;
241
402
  }
242
- console.log(chalk.cyan.bold(" Open Arb Positions\n"));
243
- const rows = arbPairs.map(p => {
244
- const spreadStr = p.currentSpread !== null ? `${p.currentSpread.toFixed(1)}%` : "-";
245
- const entrySpreadStr = p.entrySpread !== null ? `${p.entrySpread.toFixed(1)}%` : "-";
246
- const holdStr = p.holdDuration ?? "-";
247
- const avgNotional = (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2;
248
- // Compute basis risk from mark prices
249
- const basis = computeBasisRisk(p.longPosition.markPrice, p.shortPosition.markPrice);
250
- const basisStr = basis.divergencePct > 0
251
- ? (basis.warning
252
- ? chalk.red(`${basis.divergencePct.toFixed(1)}%`)
253
- : chalk.gray(`${basis.divergencePct.toFixed(1)}%`))
254
- : chalk.gray("-");
255
- return [
256
- chalk.white.bold(p.symbol),
257
- chalk.green(p.longExchange),
258
- chalk.red(p.shortExchange),
259
- `$${formatUsd(avgNotional)}`,
260
- `$${p.longPosition.entryPrice.toFixed(2)} / $${p.shortPosition.entryPrice.toFixed(2)}`,
261
- `$${p.longPosition.markPrice.toFixed(2)}`,
262
- formatPnl(p.unrealizedPnl),
263
- chalk.yellow(`$${p.estimatedFundingIncome.toFixed(4)}`),
264
- `${entrySpreadStr} -> ${spreadStr}`,
265
- basisStr,
266
- holdStr,
267
- formatPnl(p.netPnl),
268
- ];
269
- });
270
- console.log(makeTable(["Symbol", "Long", "Short", "Size", "Entry", "Mark", "uPnL", "Funding", "Spread", "Basis", "Hold", "Net PnL"], rows));
271
- // Summary
272
- const totalUpnl = arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0);
273
- const totalFunding = arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0);
274
- const totalFees = arbPairs.reduce((s, p) => s + p.estimatedFees, 0);
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);
403
+ // ── Perp-Perp table ──
404
+ if (arbPairs.length > 0) {
405
+ console.log(chalk.cyan.bold(" Perp-Perp Arb Positions\n"));
406
+ const rows = arbPairs.map(p => {
407
+ const spreadStr = p.currentSpread !== null ? `${p.currentSpread.toFixed(1)}%` : "-";
408
+ const entrySpreadStr = p.entrySpread !== null ? `${p.entrySpread.toFixed(1)}%` : "-";
409
+ const holdStr = p.holdDuration ?? "-";
410
+ const avgNotional = (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2;
411
+ const basis = computeBasisRisk(p.longPosition.markPrice, p.shortPosition.markPrice);
412
+ const basisStr = basis.divergencePct > 0
413
+ ? (basis.warning
414
+ ? chalk.red(`${basis.divergencePct.toFixed(1)}%`)
415
+ : chalk.gray(`${basis.divergencePct.toFixed(1)}%`))
416
+ : chalk.gray("-");
417
+ return [
418
+ chalk.white.bold(p.symbol),
419
+ chalk.green(p.longExchange),
420
+ chalk.red(p.shortExchange),
421
+ `$${formatUsd(avgNotional)}`,
422
+ `$${p.longPosition.entryPrice.toFixed(2)} / $${p.shortPosition.entryPrice.toFixed(2)}`,
423
+ `$${p.longPosition.markPrice.toFixed(2)}`,
424
+ formatPnl(p.unrealizedPnl),
425
+ chalk.yellow(`$${p.estimatedFundingIncome.toFixed(4)}`),
426
+ `${entrySpreadStr} -> ${spreadStr}`,
427
+ basisStr,
428
+ holdStr,
429
+ formatPnl(p.netPnl),
430
+ ];
431
+ });
432
+ console.log(makeTable(["Symbol", "Long", "Short", "Size", "Entry", "Mark", "uPnL", "Funding", "Spread", "Basis", "Hold", "Net PnL"], rows));
433
+ }
434
+ // ── Spot-Perp table ──
435
+ if (spotPerpPositions.length > 0) {
436
+ console.log(chalk.cyan.bold(" Spot-Perp Arb Positions\n"));
437
+ const spRows = spotPerpPositions.map(sp => {
438
+ const spotLabel = sp.spotToken !== sp.symbol
439
+ ? `${sp.spotToken} (${sp.spotExchange})`
440
+ : sp.spotExchange;
441
+ const spreadStr = sp.currentSpread !== null ? `${sp.currentSpread.toFixed(1)}%` : "-";
442
+ return [
443
+ chalk.white.bold(sp.symbol),
444
+ chalk.green(`${sp.spotAmount.toFixed(4)} ${sp.spotToken}`),
445
+ spotLabel,
446
+ chalk.red(sp.perpExchange),
447
+ `$${formatUsd(sp.perpNotionalUsd)}`,
448
+ `$${sp.perpMarkPrice.toFixed(2)}`,
449
+ formatPnl(sp.perpUnrealizedPnl),
450
+ chalk.yellow(`$${sp.estimatedFundingIncome.toFixed(4)}`),
451
+ spreadStr,
452
+ sp.holdDuration ?? "-",
453
+ formatPnl(sp.netPnl),
454
+ ];
455
+ });
456
+ console.log(makeTable(["Symbol", "Spot Held", "Spot Ex", "Perp Ex", "Size", "Mark", "uPnL", "Funding", "Spread", "Hold", "Net PnL"], spRows));
457
+ }
458
+ // ── Combined Summary ──
459
+ const totalUpnl = arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0)
460
+ + spotPerpPositions.reduce((s, p) => s + p.perpUnrealizedPnl, 0);
461
+ const totalFunding = arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0)
462
+ + spotPerpPositions.reduce((s, p) => s + p.estimatedFundingIncome, 0);
463
+ const totalFees = arbPairs.reduce((s, p) => s + p.estimatedFees, 0)
464
+ + spotPerpPositions.reduce((s, p) => s + p.estimatedFees, 0);
465
+ const totalNet = arbPairs.reduce((s, p) => s + p.netPnl, 0)
466
+ + spotPerpPositions.reduce((s, p) => s + p.netPnl, 0);
467
+ const totalDailyFunding = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0)
468
+ + spotPerpPositions.reduce((s, p) => s + p.dailyFundingEstimate, 0);
469
+ const totalNotional = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0)
470
+ + spotPerpPositions.reduce((s, p) => s + p.perpNotionalUsd, 0);
278
471
  const portfolioApr = totalNotional > 0 ? (totalDailyFunding * 365 / totalNotional) * 100 : 0;
279
472
  console.log(chalk.white.bold("\n Summary"));
280
- console.log(` Positions: ${arbPairs.length}`);
473
+ console.log(` Perp-Perp: ${arbPairs.length} positions`);
474
+ console.log(` Spot-Perp: ${spotPerpPositions.length} positions`);
281
475
  console.log(` Total Notional: $${formatUsd(totalNotional)}`);
282
476
  console.log(` Unrealized PnL: ${formatPnl(totalUpnl)}`);
283
477
  console.log(` Est. Funding: ${chalk.yellow(`$${totalFunding.toFixed(4)}`)}`);