perp-cli 0.4.8 → 0.4.10

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 (45) hide show
  1. package/README.md +44 -5
  2. package/dist/arb/reconcile.d.ts +28 -0
  3. package/dist/arb/reconcile.js +162 -0
  4. package/dist/commands/account.d.ts +1 -1
  5. package/dist/commands/account.js +366 -62
  6. package/dist/commands/agent.js +9 -5
  7. package/dist/commands/arb/index.js +243 -49
  8. package/dist/commands/arb-auto.js +67 -19
  9. package/dist/commands/arb.d.ts +2 -1
  10. package/dist/commands/arb.js +535 -131
  11. package/dist/commands/bot.js +207 -0
  12. package/dist/commands/funds.d.ts +4 -0
  13. package/dist/commands/funds.js +741 -0
  14. package/dist/commands/history.d.ts +2 -1
  15. package/dist/commands/history.js +508 -5
  16. package/dist/commands/manage.js +5 -3
  17. package/dist/commands/market.js +34 -1
  18. package/dist/commands/pnl.js +1 -1
  19. package/dist/commands/risk.d.ts +2 -0
  20. package/dist/commands/risk.js +56 -0
  21. package/dist/commands/settings.js +147 -0
  22. package/dist/commands/trade.d.ts +1 -1
  23. package/dist/commands/trade.js +241 -259
  24. package/dist/commands/wallet.d.ts +4 -0
  25. package/dist/commands/wallet.js +10 -0
  26. package/dist/config.d.ts +2 -2
  27. package/dist/config.js +11 -3
  28. package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
  29. package/dist/exchanges/hyperliquid-spot.js +13 -21
  30. package/dist/exchanges/lighter-spot.js +13 -15
  31. package/dist/exchanges/lighter.js +82 -21
  32. package/dist/exchanges/pacifica.js +12 -0
  33. package/dist/execution-log.d.ts +1 -1
  34. package/dist/index.js +39 -123
  35. package/dist/mcp-server.js +43 -96
  36. package/dist/strategies/split-order.d.ts +49 -0
  37. package/dist/strategies/split-order.js +138 -0
  38. package/package.json +1 -1
  39. package/skills/perp-cli/SKILL.md +13 -26
  40. package/skills/perp-cli/references/agent-operations.md +19 -9
  41. package/skills/perp-cli/references/commands.md +38 -31
  42. package/skills/perp-cli/references/strategies.md +26 -16
  43. package/skills/perp-cli/scripts/funding-analysis.sh +2 -2
  44. package/skills/perp-cli/scripts/preflight.sh +1 -1
  45. package/skills/perp-cli/scripts/spot-perp-scan.sh +1 -1
@@ -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)}`)}`);
@@ -910,19 +910,30 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
910
910
  const holdDays = parseFloat(opts.holdDays);
911
911
  const bridgeCostUsd = parseFloat(opts.bridgeCost);
912
912
  const sizeUsd = parseFloat(opts.size);
913
+ let spotSpreads = [];
913
914
  // ── spot-perp mode ──
914
915
  if (opts.mode === "spot-perp" || opts.mode === "all") {
915
916
  if (!isJson())
916
917
  console.log(chalk.cyan("\n Scanning spot+perp funding opportunities...\n"));
917
918
  const { fetchSpotPerpSpreads } = await import("../funding/rates.js");
918
- const { spreads: spotSpreads } = await fetchSpotPerpSpreads({ minSpread });
919
+ ({ spreads: spotSpreads } = await fetchSpotPerpSpreads({ minSpread }));
919
920
  if (isJson() && opts.mode === "spot-perp") {
920
- let result = spotSpreads.map(s => ({
921
- mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
922
- spotExchanges: s.spotExchanges, annualSpreadPct: s.annualSpreadPct,
923
- perpFundingRate: s.perpFundingRate, direction: s.direction,
924
- markPrice: s.bestMarkPrice, estHourlyIncomeUsd: s.estHourlyIncomeUsd,
925
- }));
921
+ let result = spotSpreads.map(s => {
922
+ // Use best spot exchange for fee calc; spot+perp same legs as perp-perp cost model
923
+ const spotEx = s.spotExchanges[0] || "lighter";
924
+ const rtCost = computeRoundTripCostPct(spotEx, s.perpExchange);
925
+ // Same-exchange spot+perp → no bridge cost
926
+ const effectiveBridgeCost = s.spotExchanges.includes(s.perpExchange) ? 0 : bridgeCostUsd;
927
+ const netSpread = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
928
+ return {
929
+ mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
930
+ spotExchanges: s.spotExchanges, grossSpreadPct: s.annualSpreadPct,
931
+ netSpreadPct: netSpread, estFeesPct: rtCost,
932
+ perpFundingRate: s.perpFundingRate, direction: s.direction,
933
+ markPrice: s.bestMarkPrice, estHourlyIncomeUsd: s.estHourlyIncomeUsd,
934
+ breakEvenDays: rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0,
935
+ };
936
+ }).sort((a, b) => b.netSpreadPct - a.netSpreadPct);
926
937
  if (opts.top)
927
938
  result = result.slice(0, parseInt(opts.top));
928
939
  return printJson(jsonOk(result));
@@ -932,18 +943,28 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
932
943
  console.log(chalk.gray(` No spot+perp opportunities above ${minSpread}%\n`));
933
944
  }
934
945
  else {
935
- console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"SPREAD".padEnd(8)} ${"PERP EX".padEnd(9)} ${"SPOT EX".padEnd(12)} ${"FUND".padEnd(12)} DIRECTION`));
946
+ console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"BE".padEnd(5)} ${"PERP".padEnd(5)} ${"SPOT".padEnd(8)} DIRECTION`));
936
947
  for (const s of spotSpreads.slice(0, parseInt(opts.top ?? "30"))) {
937
- const spreadColor = s.annualSpreadPct >= 30 ? chalk.green : chalk.yellow;
948
+ const spotEx = s.spotExchanges[0] || "lighter";
949
+ const rtCost = computeRoundTripCostPct(spotEx, s.perpExchange);
950
+ const effectiveBridgeCost = s.spotExchanges.includes(s.perpExchange) ? 0 : bridgeCostUsd;
951
+ const netSpread = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
952
+ const breakEvenDays = rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0;
953
+ const grossColor = s.annualSpreadPct >= 30 ? chalk.green : chalk.yellow;
954
+ const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
938
955
  const dir = s.direction === "long-spot-short-perp" ? "Spot+Short" : "Sell+Long";
956
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
939
957
  console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
940
- `${spreadColor(`${s.annualSpreadPct.toFixed(1)}%`.padEnd(8))} ` +
941
- `${s.perpExchange.slice(0, 8).padEnd(9)} ` +
942
- `${s.spotExchanges.join(",").slice(0, 11).padEnd(12)} ` +
943
- `${(s.perpFundingRate * 100).toFixed(4).padEnd(12)}% ` +
958
+ `${grossColor(`${s.annualSpreadPct.toFixed(1)}%`.padEnd(8))} ` +
959
+ `${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
960
+ `${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
961
+ `${chalk.gray(`${breakEvenDays}d`.padEnd(5))} ` +
962
+ `${exAbbr(s.perpExchange).padEnd(5)} ` +
963
+ `${s.spotExchanges.map(exAbbr).join(",").padEnd(8)} ` +
944
964
  dir);
945
965
  }
946
966
  console.log(chalk.gray(`\n ${spotSpreads.length} spot+perp opportunities (spot funding = 0%)`));
967
+ console.log(chalk.gray(` Net spread assumes ${holdDays}d hold, $${bridgeCostUsd} bridge, $${sizeUsd} size. BE = break-even days`));
947
968
  }
948
969
  }
949
970
  if (opts.mode === "spot-perp")
@@ -957,22 +978,42 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
957
978
  const spreads = await fetchFundingSpreads();
958
979
  const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
959
980
  if (isJson()) {
960
- let enriched = filtered.map(s => {
981
+ const perpPerp = filtered.map(s => {
961
982
  const grossSpread = Math.abs(s.spread);
962
983
  const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
963
984
  const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
964
- return { mode: "perp-perp", symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost };
965
- }).sort((a, b) => b.netSpread - a.netSpread);
985
+ // Compute price gap between long and short exchanges
986
+ const longPrice = s.longExch === "pacifica" ? s.pacMarkPrice : s.longExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
987
+ const shortPrice = s.shortExch === "pacifica" ? s.pacMarkPrice : s.shortExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
988
+ const mid = (longPrice + shortPrice) / 2;
989
+ const priceGapPct = mid > 0 ? Math.abs(longPrice - shortPrice) / mid * 100 : 0;
990
+ return { mode: "perp-perp", symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost, priceGapPct: Math.round(priceGapPct * 10000) / 10000 };
991
+ });
992
+ // Merge spot-perp results if "all" mode
993
+ const spotPerp = (opts.mode === "all" && spotSpreads) ? spotSpreads.map(s => {
994
+ const spotEx = s.spotExchanges[0] || "lighter";
995
+ const rtCost = computeRoundTripCostPct(spotEx, s.perpExchange);
996
+ const effectiveBridgeCost = s.spotExchanges.includes(s.perpExchange) ? 0 : bridgeCostUsd;
997
+ const net = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
998
+ return {
999
+ mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
1000
+ spotExchanges: s.spotExchanges, grossSpread: s.annualSpreadPct,
1001
+ netSpread: net, estFeesPct: rtCost,
1002
+ perpFundingRate: s.perpFundingRate, direction: s.direction,
1003
+ markPrice: s.bestMarkPrice, breakEvenDays: rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0,
1004
+ };
1005
+ }) : [];
1006
+ let combined = [...perpPerp, ...spotPerp].sort((a, b) => b.netSpread - a.netSpread);
966
1007
  if (opts.top)
967
- enriched = enriched.slice(0, parseInt(opts.top));
968
- return printJson(jsonOk(enriched));
1008
+ combined = combined.slice(0, parseInt(opts.top));
1009
+ return printJson(jsonOk(combined));
969
1010
  }
970
1011
  if (filtered.length === 0) {
971
1012
  console.log(chalk.gray(` No perp-perp spreads above ${minSpread}%\n`));
972
1013
  return;
973
1014
  }
974
1015
  // Header
975
- console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"DIR".padEnd(7)} RATES`));
1016
+ console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"GAP".padEnd(8)} ${"DIR".padEnd(7)} RATES`));
976
1017
  for (const s of filtered) {
977
1018
  const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
978
1019
  const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
@@ -981,6 +1022,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
981
1022
  const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
982
1023
  const grossColor = grossSpread >= 30 ? chalk.green : chalk.yellow;
983
1024
  const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
1025
+ // Price gap between long/short exchanges
1026
+ const longPrice = s.longExch === "pacifica" ? s.pacMarkPrice : s.longExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
1027
+ const shortPrice = s.shortExch === "pacifica" ? s.pacMarkPrice : s.shortExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
1028
+ const mid = (longPrice + shortPrice) / 2;
1029
+ const priceGapPct = mid > 0 ? Math.abs(longPrice - shortPrice) / mid * 100 : 0;
1030
+ const gapColor = priceGapPct >= 0.5 ? chalk.red : priceGapPct >= 0.1 ? chalk.yellow : chalk.gray;
984
1031
  const rates = [];
985
1032
  if (s.pacRate)
986
1033
  rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
@@ -992,6 +1039,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
992
1039
  `${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
993
1040
  `${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
994
1041
  `${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
1042
+ `${gapColor(`${priceGapPct.toFixed(3)}%`.padEnd(8))} ` +
995
1043
  `${direction.padEnd(7)} ` +
996
1044
  rates.join(" "));
997
1045
  }
@@ -1,2 +1,3 @@
1
1
  import { Command } from "commander";
2
- export declare function registerArbCommands(program: Command, isJson: () => boolean): void;
2
+ import type { ExchangeAdapter } from "../exchanges/interface.js";
3
+ export declare function registerArbCommands(program: Command, isJson: () => boolean, getAdapterForExchange?: (exchange: string) => Promise<ExchangeAdapter>): void;