perp-cli 0.14.0 → 0.14.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.
Files changed (75) hide show
  1. package/dist/arb/sizing.js +29 -7
  2. package/dist/bot/conditions.d.ts +5 -1
  3. package/dist/bot/conditions.js +16 -11
  4. package/dist/bot/engine.d.ts +12 -0
  5. package/dist/bot/engine.js +55 -44
  6. package/dist/bot/strategies/claude-agent.js +2 -2
  7. package/dist/bot/strategies/funding-arb-strategy.js +16 -20
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
  9. package/dist/bot/strategies/funding-arb-utils.js +44 -0
  10. package/dist/bot/strategies/funding-auto.js +61 -37
  11. package/dist/bot/strategies/grid-strategy.d.ts +2 -0
  12. package/dist/bot/strategies/grid-strategy.js +21 -2
  13. package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
  14. package/dist/bot/tui/BotDashboard.d.ts +13 -2
  15. package/dist/bot/tui/BotDashboard.js +8 -3
  16. package/dist/bridge-engine.d.ts +2 -0
  17. package/dist/bridge-engine.js +68 -31
  18. package/dist/cli-program.js +19 -2
  19. package/dist/commands/account.js +2 -2
  20. package/dist/commands/arb/index.d.ts +20 -0
  21. package/dist/commands/arb/index.js +121 -36
  22. package/dist/commands/arb-auto.d.ts +36 -0
  23. package/dist/commands/arb-auto.js +202 -76
  24. package/dist/commands/arb.js +51 -35
  25. package/dist/commands/bot.d.ts +6 -0
  26. package/dist/commands/bot.js +55 -45
  27. package/dist/commands/funds-onchain.d.ts +1 -1
  28. package/dist/commands/funds-onchain.js +12 -49
  29. package/dist/commands/funds.js +51 -52
  30. package/dist/commands/history.js +165 -62
  31. package/dist/commands/portfolio.d.ts +5 -0
  32. package/dist/commands/portfolio.js +58 -20
  33. package/dist/commands/rebalance.js +46 -10
  34. package/dist/commands/settings.js +2 -1
  35. package/dist/commands/trade.js +8 -4
  36. package/dist/commands/wallet.js +74 -155
  37. package/dist/cross-chain-margin.d.ts +29 -2
  38. package/dist/cross-chain-margin.js +30 -4
  39. package/dist/dashboard/server.d.ts +45 -8
  40. package/dist/dashboard/server.js +113 -42
  41. package/dist/dashboard/ui.js +46 -27
  42. package/dist/dashboard/ws-feeds.d.ts +25 -3
  43. package/dist/dashboard/ws-feeds.js +219 -82
  44. package/dist/dex-asset-map.d.ts +23 -4
  45. package/dist/dex-asset-map.js +39 -13
  46. package/dist/exchanges/arcus/adapter.js +33 -6
  47. package/dist/exchanges/arcus/public.d.ts +6 -0
  48. package/dist/exchanges/arcus/public.js +12 -4
  49. package/dist/exchanges/aster.d.ts +33 -0
  50. package/dist/exchanges/aster.js +162 -55
  51. package/dist/exchanges/hyperliquid.d.ts +30 -0
  52. package/dist/exchanges/hyperliquid.js +159 -34
  53. package/dist/exchanges/lighter.d.ts +1 -1
  54. package/dist/exchanges/lighter.js +11 -9
  55. package/dist/exchanges/ondo/adapter.d.ts +4 -18
  56. package/dist/exchanges/ondo/adapter.js +5 -29
  57. package/dist/exchanges/ondo/funds.d.ts +1 -29
  58. package/dist/exchanges/ondo/funds.js +2 -94
  59. package/dist/exchanges/pacifica.d.ts +12 -0
  60. package/dist/exchanges/pacifica.js +78 -35
  61. package/dist/exchanges/qfex/adapter.js +4 -1
  62. package/dist/exchanges/venues/ondo.d.ts +0 -5
  63. package/dist/exchanges/venues/ondo.js +2 -6
  64. package/dist/exchanges/venues/qfex.d.ts +11 -0
  65. package/dist/exchanges/venues/qfex.js +10 -2
  66. package/dist/mcp-server.js +2 -2
  67. package/dist/onchain-balances.d.ts +40 -0
  68. package/dist/onchain-balances.js +173 -0
  69. package/dist/rebalance.d.ts +20 -1
  70. package/dist/rebalance.js +31 -5
  71. package/dist/strategies/funding-arb.js +38 -7
  72. package/package.json +1 -1
  73. package/skills/perp-cli/SKILL.md +20 -3
  74. package/skills/perp-cli/references/agent-operations.md +2 -2
  75. package/skills/perp-cli/references/commands.md +12 -9
@@ -288,7 +288,7 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
288
288
  });
289
289
  account
290
290
  .command("history")
291
- .description("Order history")
291
+ .description("Order history (Aster: last 7 days, symbols with a position, open order or fill in that window)")
292
292
  .action(async () => {
293
293
  const adapter = await getAdapter();
294
294
  const orders = await adapter.getOrderHistory(30);
@@ -355,7 +355,7 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
355
355
  });
356
356
  account
357
357
  .command("trades")
358
- .description("Trade history (fills)")
358
+ .description("Trade history (fills) (Aster: last 7 days, symbols with a position, open order or fill in that window)")
359
359
  .action(async () => {
360
360
  const adapter = await getAdapter();
361
361
  const trades = await adapter.getTradeHistory(30);
@@ -12,10 +12,30 @@ export declare function listArbVenues(): Promise<string[]>;
12
12
  * positions read fails is an error (Rule #2): an unread venue is not "no positions" —
13
13
  * a hidden leg would make a live pair look closed.
14
14
  */
15
+ /**
16
+ * Adapters for `arb rebalance`. A venue the user listed explicitly (--exchanges) that cannot
17
+ * be opened is refused with a PerpError naming venue + cause; from the default list it is
18
+ * skipped with a stderr warning naming venue + cause (never silently).
19
+ */
20
+ export declare function resolveRebalanceAdapters(names: readonly string[], explicit: boolean, getAdapter: (exchange: string) => Promise<ExchangeAdapter>, warn?: (msg: string) => void): Promise<Map<string, ExchangeAdapter>>;
15
21
  export declare function readArbVenuePositions(venues: readonly string[], getAdapter: (exchange: string) => Promise<ExchangeAdapter>): Promise<{
16
22
  exchange: string;
17
23
  position: ExchangePosition;
18
24
  }[]>;
25
+ /**
26
+ * Spot-perp funding view from the perp venue's current rate entry. A missing entry → every
27
+ * field null ("unknown"): never a 0 rate / 0 daily income / a guessed direction.
28
+ */
29
+ export declare function spotPerpFundingView(perpRate: {
30
+ exchange: string;
31
+ rate: number;
32
+ fundingHours: number | null;
33
+ } | undefined, perpNotional: number): {
34
+ hourlyRate: number | null;
35
+ annualPct: number | null;
36
+ direction: "long-spot-short-perp" | "sell-spot-long-perp" | null;
37
+ dailyFundingEstimate: number | null;
38
+ };
19
39
  /**
20
40
  * State-file entries the venues do not confirm: no live pair with the same symbol and
21
41
  * legs. `arb status` reports them apart from the live pairs (the venue is the source of truth).
@@ -7,7 +7,7 @@ import { toHourlyRate } from "../../funding.js";
7
7
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
8
8
  import { computeBasisRisk } from "../../arb-utils.js";
9
9
  import { removePosition as persistRemovePosition, getPositions as getArbStatePositions, loadArbState } from "../../arb-state.js";
10
- import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
10
+ import { fetchBalancesWithFailures, computeRebalancePlan } from "../../rebalance.js";
11
11
  import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
12
12
  import { computeEnhancedStats } from "../../arb-history-stats.js";
13
13
  import { listBotSpotVenues, nonQuoteSpotBalances, requireSpotAdapter, spotNetworkOf } from "../../exchanges/spot-factory.js";
@@ -33,6 +33,30 @@ export async function listArbVenues() {
33
33
  * positions read fails is an error (Rule #2): an unread venue is not "no positions" —
34
34
  * a hidden leg would make a live pair look closed.
35
35
  */
36
+ /**
37
+ * Adapters for `arb rebalance`. A venue the user listed explicitly (--exchanges) that cannot
38
+ * be opened is refused with a PerpError naming venue + cause; from the default list it is
39
+ * skipped with a stderr warning naming venue + cause (never silently).
40
+ */
41
+ export async function resolveRebalanceAdapters(names, explicit, getAdapter, warn = (m) => process.stderr.write(`${m}\n`)) {
42
+ const out = new Map();
43
+ for (const name of names) {
44
+ try {
45
+ out.set(name, await getAdapter(name));
46
+ }
47
+ catch (e) {
48
+ const c = classifyError(e, name);
49
+ if (explicit) {
50
+ throw new PerpError(c.code, `Cannot open ${name}: ${c.message}`, {
51
+ exchange: name,
52
+ remediation: `Check ${name} credentials / connectivity (perp account balance -e ${name}), or drop it from --exchanges`,
53
+ });
54
+ }
55
+ warn(`[arb rebalance] ${name} unavailable — skipped: ${c.message}`);
56
+ }
57
+ }
58
+ return out;
59
+ }
36
60
  export async function readArbVenuePositions(venues, getAdapter) {
37
61
  const out = [];
38
62
  for (const exchange of venues) {
@@ -64,6 +88,21 @@ function formatDuration(ms) {
64
88
  const minutes = Math.floor(ms / (1000 * 60));
65
89
  return `${minutes}m`;
66
90
  }
91
+ /**
92
+ * Spot-perp funding view from the perp venue's current rate entry. A missing entry → every
93
+ * field null ("unknown"): never a 0 rate / 0 daily income / a guessed direction.
94
+ */
95
+ export function spotPerpFundingView(perpRate, perpNotional) {
96
+ if (perpRate === undefined)
97
+ return { hourlyRate: null, annualPct: null, direction: null, dailyFundingEstimate: null };
98
+ const hourlyRate = entryHourly(perpRate);
99
+ return {
100
+ hourlyRate,
101
+ annualPct: hourlyRate * 8760 * 100,
102
+ direction: hourlyRate >= 0 ? "long-spot-short-perp" : "sell-spot-long-perp",
103
+ dailyFundingEstimate: Math.abs(hourlyRate) * perpNotional * 24,
104
+ };
105
+ }
67
106
  /** hourly rate of a rate entry: the entry's own interval (Aster, per symbol) or the venue basis. */
68
107
  function entryHourly(e) {
69
108
  return e.fundingHours !== null ? e.rate / e.fundingHours : toHourlyRate(e.rate, e.exchange);
@@ -279,7 +318,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
279
318
  try {
280
319
  rateMap = await fetchFundingRatesMap();
281
320
  }
282
- catch {
321
+ catch (e) {
322
+ // Reported, not silent: every spread / funding rate below shows as unknown (null), never 0.
323
+ process.stderr.write(`[warn] funding rates unavailable — spreads and daily funding shown as unknown: ${e instanceof Error ? e.message : String(e)}\n`);
283
324
  rateMap = new Map();
284
325
  }
285
326
  for (const [symbol, positions] of bySymbol) {
@@ -429,16 +470,18 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
429
470
  // Get current perp funding rate for this symbol on this exchange
430
471
  const rates = rateMap.get(perpSymbol);
431
472
  const perpRate = rates?.find(r => r.exchange === shortPos.exchange);
432
- const fundingRate = perpRate?.rate ?? 0;
433
- const hourlyRate = perpRate && fundingRate ? entryHourly(perpRate) : 0;
434
- const annualPct = hourlyRate * 8760 * 100;
435
473
  const perpNotional = shortPos.size * shortPos.markPrice;
474
+ // Unknown rate → hourlyRate / annualPct / direction / dailyFundingEstimate are null (never 0).
475
+ const { hourlyRate, annualPct, direction, dailyFundingEstimate } = spotPerpFundingView(perpRate, perpNotional);
436
476
  const spotValueUsd = arbSpotAmount * shortPos.markPrice; // approx using perp mark price
437
477
  // Funding income: fetch actual payments from exchange API
438
478
  let actualFundingIncome = 0;
479
+ let fundingSource = "actual";
480
+ let fundingError = null;
439
481
  try {
440
482
  const perpAdapter = await getAdapterForExchange(shortPos.exchange);
441
- const payments = await perpAdapter.getFundingPayments(200);
483
+ const FUNDING_PAGE = 200;
484
+ const payments = await perpAdapter.getFundingPayments(FUNDING_PAGE);
442
485
  // Filter from entry time minus 1h buffer (multi-leg entry takes time)
443
486
  const entryTime = persisted ? new Date(persisted.entryTime).getTime() - 3600000 : 0;
444
487
  for (const p of payments) {
@@ -446,10 +489,22 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
446
489
  actualFundingIncome += Number(p.payment);
447
490
  }
448
491
  }
492
+ // The read may not reach back to the entry: a full page, or Aster's 7-day income window.
493
+ const sevenDaysAgo = Date.now() - 7 * 86_400_000;
494
+ if (payments.length >= FUNDING_PAGE) {
495
+ fundingSource = "partial";
496
+ fundingError = `${FUNDING_PAGE} payments returned (page full) — older funding since entry may be missing`;
497
+ }
498
+ else if (shortPos.exchange === "aster" && (!entryTime || entryTime < sevenDaysAgo)) {
499
+ fundingSource = "partial";
500
+ fundingError = "Aster funding history covers the last 7 days only — funding before that is not included";
501
+ }
449
502
  }
450
- catch {
451
- // Fallback to estimate if API fails
452
- if (holdDurationMs && hourlyRate !== 0) {
503
+ catch (e) {
504
+ // History unreadable: an explicit, labelled estimate (fundingSource / fundingError), never a silent one.
505
+ fundingSource = "estimate";
506
+ fundingError = e instanceof Error ? e.message : String(e);
507
+ if (holdDurationMs && hourlyRate !== null && hourlyRate !== 0) {
453
508
  const holdHours = holdDurationMs / (1000 * 60 * 60);
454
509
  actualFundingIncome = Math.abs(hourlyRate) * perpNotional * holdHours;
455
510
  if (hourlyRate < 0)
@@ -462,8 +517,6 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
462
517
  const spotFee = getTakerFee(spotEx);
463
518
  const perpFee = getTakerFee(shortPos.exchange);
464
519
  const entryFees = (spotValueUsd * spotFee) + (perpNotional * perpFee);
465
- const direction = hourlyRate >= 0 ? "long-spot-short-perp" : "sell-spot-long-perp";
466
- const dailyFundingEstimate = Math.abs(hourlyRate) * perpNotional * 24;
467
520
  spotPerpPositions.push({
468
521
  symbol: perpSymbol,
469
522
  mode: "spot-perp",
@@ -483,10 +536,12 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
483
536
  perpLeverage: shortPos.leverage,
484
537
  perpNotionalUsd: perpNotional,
485
538
  direction,
486
- currentSpread: annualPct || null,
539
+ currentSpread: annualPct,
487
540
  holdDuration,
488
541
  holdDurationMs,
489
542
  estimatedFundingIncome,
543
+ fundingSource,
544
+ fundingError,
490
545
  estimatedFees: entryFees,
491
546
  netPnl: (spotValueUsd - shortPos.entryPrice * arbSpotAmount) + shortPos.unrealizedPnl + estimatedFundingIncome - entryFees,
492
547
  dailyFundingEstimate,
@@ -507,8 +562,8 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
507
562
  const holdDurationMs = Date.now() - new Date(persisted.entryTime).getTime();
508
563
  const rates = rateMap.get(sym);
509
564
  const perpRate = rates?.find(r => r.exchange === persisted.shortExchange);
510
- const hourlyRate = perpRate ? entryHourly(perpRate) : 0;
511
565
  const perpNotional = shortPos.size * shortPos.markPrice;
566
+ const fv = spotPerpFundingView(perpRate, perpNotional);
512
567
  spotPerpPositions.push({
513
568
  symbol: sym,
514
569
  mode: "spot-perp",
@@ -527,14 +582,16 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
527
582
  perpUnrealizedPnl: shortPos.unrealizedPnl,
528
583
  perpLeverage: shortPos.leverage,
529
584
  perpNotionalUsd: perpNotional,
530
- direction: hourlyRate >= 0 ? "long-spot-short-perp" : "sell-spot-long-perp",
531
- currentSpread: hourlyRate ? Math.abs(hourlyRate) * 8760 * 100 : null,
585
+ direction: fv.direction,
586
+ currentSpread: fv.annualPct === null ? null : Math.abs(fv.annualPct),
532
587
  holdDuration: formatDuration(holdDurationMs),
533
588
  holdDurationMs,
534
589
  estimatedFundingIncome: persisted.accumulatedFunding ?? 0,
590
+ fundingSource: "recorded",
591
+ fundingError: null,
535
592
  estimatedFees: 0,
536
593
  netPnl: shortPos.unrealizedPnl + (persisted.accumulatedFunding ?? 0),
537
- dailyFundingEstimate: Math.abs(hourlyRate) * perpNotional * 24,
594
+ dailyFundingEstimate: fv.dailyFundingEstimate,
538
595
  });
539
596
  }
540
597
  // State vs venue: the venue is the source of truth. A pair is reported only when the
@@ -544,12 +601,13 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
544
601
  // Pairs only inferred from a long and a short sharing the symbol are reported apart (never as arb positions).
545
602
  const unconfirmedPairs = arbPairs.filter(p => p.provenance === "inferred");
546
603
  arbPairs.splice(0, arbPairs.length, ...arbPairs.filter(p => p.provenance !== "inferred"));
604
+ const fundingUnknownSP = spotPerpPositions.filter(p => p.dailyFundingEstimate === null).map(p => `${p.perpExchange}:${p.symbol}`);
547
605
  // ── JSON output ──
548
606
  if (isJson()) {
549
607
  const totalNotionalPP = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
550
608
  const totalDailyPP = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
551
609
  const totalNotionalSP = spotPerpPositions.reduce((s, p) => s + p.perpNotionalUsd, 0);
552
- const totalDailySP = spotPerpPositions.reduce((s, p) => s + p.dailyFundingEstimate, 0);
610
+ const totalDailySP = spotPerpPositions.reduce((s, p) => s + (p.dailyFundingEstimate ?? 0), 0);
553
611
  const totalNotionalJ = totalNotionalPP + totalNotionalSP;
554
612
  const totalDailyJ = totalDailyPP + totalDailySP;
555
613
  return printJson(jsonOk({
@@ -569,6 +627,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
569
627
  netPnl: arbPairs.reduce((s, p) => s + p.netPnl, 0) + spotPerpPositions.reduce((s, p) => s + p.netPnl, 0),
570
628
  dailyFundingEstimate: totalDailyJ,
571
629
  annualizedApr: totalNotionalJ > 0 ? (totalDailyJ * 365 / totalNotionalJ) * 100 : 0,
630
+ // Spot-perp positions whose funding rate is unknown are excluded from the daily / APR sums.
631
+ dailyFundingIncomplete: fundingUnknownSP.length > 0,
632
+ fundingUnknown: fundingUnknownSP,
572
633
  },
573
634
  }));
574
635
  }
@@ -647,12 +708,12 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
647
708
  console.log(chalk.white.bold(" PnL Breakdown:"));
648
709
  console.log(` Spot PnL: ${formatPnl(spotPnl)}`);
649
710
  console.log(` Perp PnL: ${formatPnl(sp.perpUnrealizedPnl)}`);
650
- console.log(` Funding Earned: ${chalk.yellow(`$${sp.estimatedFundingIncome.toFixed(4)}`)}`);
711
+ console.log(` Funding Earned: ${chalk.yellow(`$${sp.estimatedFundingIncome.toFixed(4)}`)}${sp.fundingSource === "estimate" ? chalk.yellow(` (estimate — funding history unavailable: ${sp.fundingError})`) : sp.fundingSource === "partial" ? chalk.yellow(` (partial: ${sp.fundingError})`) : ""}`);
651
712
  console.log(` Trading Fees: ${chalk.red(`-$${sp.estimatedFees.toFixed(4)}`)}`);
652
713
  console.log(` ${chalk.white.bold("Total PnL:")} ${formatPnl(sp.netPnl)}${roi !== 0 ? chalk.gray(` (ROI: ${roi >= 0 ? "+" : ""}${roi.toFixed(2)}%)`) : ""}`);
653
714
  console.log("");
654
715
  console.log(` Current Spread: ${spreadStr}`);
655
- console.log(` Daily Income: ${chalk.cyan(`$${sp.dailyFundingEstimate.toFixed(4)}/day`)}`);
716
+ console.log(` Daily Income: ${sp.dailyFundingEstimate === null ? chalk.yellow(`unknown (no current ${sp.perpExchange} funding rate)`) : chalk.cyan(`$${sp.dailyFundingEstimate.toFixed(4)}/day`)}`);
656
717
  if (annRoi !== 0)
657
718
  console.log(` Annualized ROI: ${chalk.cyan(`${annRoi >= 0 ? "+" : ""}${annRoi.toFixed(1)}%`)}`);
658
719
  }
@@ -668,7 +729,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
668
729
  const totalNet = arbPairs.reduce((s, p) => s + p.netPnl, 0)
669
730
  + spotPerpPositions.reduce((s, p) => s + p.netPnl, 0);
670
731
  const totalDailyFunding = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0)
671
- + spotPerpPositions.reduce((s, p) => s + p.dailyFundingEstimate, 0);
732
+ + spotPerpPositions.reduce((s, p) => s + (p.dailyFundingEstimate ?? 0), 0);
672
733
  const totalNotional = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0)
673
734
  + spotPerpPositions.reduce((s, p) => s + p.perpNotionalUsd, 0);
674
735
  const portfolioApr = totalNotional > 0 ? (totalDailyFunding * 365 / totalNotional) * 100 : 0;
@@ -680,7 +741,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
680
741
  console.log(` Est. Funding: ${chalk.yellow(`$${totalFunding.toFixed(4)}`)}`);
681
742
  console.log(` Est. Fees: ${chalk.red(`-$${totalFees.toFixed(4)}`)}`);
682
743
  console.log(` Net PnL: ${formatPnl(totalNet)}`);
683
- console.log(` Daily Income: ${chalk.cyan(`$${totalDailyFunding.toFixed(4)}/day`)}`);
744
+ console.log(` Daily Income: ${chalk.cyan(`$${totalDailyFunding.toFixed(4)}/day`)}${fundingUnknownSP.length > 0 ? chalk.yellow(` (incomplete — funding rate unknown: ${fundingUnknownSP.join(", ")})`) : ""}`);
684
745
  if (portfolioApr > 0) {
685
746
  console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)}`);
686
747
  }
@@ -1296,12 +1357,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1296
1357
  const dryRun = isDryRun(cmd);
1297
1358
  const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
1298
1359
  const adapters = new Map();
1299
- for (const name of exchangeNames) {
1300
- try {
1301
- adapters.set(name, await getAdapterForExchange(name));
1302
- }
1303
- catch { /* skip unavailable */ }
1304
- }
1360
+ // Explicit --exchanges → an unavailable venue refuses (PerpError); the default list → skipped with a stderr warning.
1361
+ const explicitExchanges = cmd.getOptionValueSource("exchanges") === "cli";
1362
+ for (const [name, adapter] of await resolveRebalanceAdapters(exchangeNames, explicitExchanges, getAdapterForExchange))
1363
+ adapters.set(name, adapter);
1305
1364
  if (adapters.size === 0) {
1306
1365
  if (isJson())
1307
1366
  return printJson(jsonOk({ error: "No exchanges available" }));
@@ -1312,7 +1371,8 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1312
1371
  if (!opts.target && !opts.check) {
1313
1372
  opts.check = true;
1314
1373
  }
1315
- const snapshots = await fetchAllBalances(adapters);
1374
+ // A venue whose balance read failed is reported (never dropped from the totals silently).
1375
+ const { snapshots, failed: unavailable, notConfigured } = await fetchBalancesWithFailures(adapters);
1316
1376
  const totalEquity = snapshots.reduce((s, e) => s + e.equity, 0);
1317
1377
  const totalAvailable = snapshots.reduce((s, e) => s + e.available, 0);
1318
1378
  const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : e === "lighter" ? "LT" : e === "aster" ? "AST" : e.toUpperCase();
@@ -1333,6 +1393,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1333
1393
  })),
1334
1394
  totalEquity,
1335
1395
  totalAvailable,
1396
+ unavailable,
1397
+ notConfigured,
1398
+ partial: unavailable.length > 0,
1336
1399
  }));
1337
1400
  }
1338
1401
  console.log(chalk.cyan("\n Cross-Exchange Balance Distribution\n"));
@@ -1355,8 +1418,20 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1355
1418
  console.log(` Total Equity: $${formatUsd(totalEquity)}`);
1356
1419
  console.log(` Total Available: $${formatUsd(totalAvailable)}`);
1357
1420
  console.log(` Exchanges: ${snapshots.length}\n`);
1421
+ for (const f of unavailable)
1422
+ console.log(chalk.yellow(` ⚠ ${f.exchange} balance unavailable (not in the totals): ${f.error}`));
1423
+ for (const n of notConfigured)
1424
+ console.log(chalk.gray(` ${n}: not configured (no credentials) — left out`));
1425
+ if (unavailable.length > 0)
1426
+ console.log();
1358
1427
  return;
1359
1428
  }
1429
+ // A plan over fewer venues than requested would move funds on a wrong picture — refuse.
1430
+ if (unavailable.length > 0) {
1431
+ throw new PerpError("EXCHANGE_ERROR", `Rebalance plan refused: balance unavailable on ${unavailable.map(f => f.exchange).join(", ")}`, {
1432
+ remediation: `Retry when every venue answers, or limit the plan to readable venues with --exchanges (errors: ${unavailable.map(f => `${f.exchange}: ${f.error}`).join("; ")})`,
1433
+ });
1434
+ }
1360
1435
  // Parse target ratios
1361
1436
  if (!opts.target) {
1362
1437
  console.error(chalk.red(" --target required (e.g., '50:50' or '33:33:33')"));
@@ -1394,9 +1469,11 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1394
1469
  const moveDetails = await Promise.all(moves.map(async (m) => {
1395
1470
  const srcChain = EXCHANGE_TO_CHAIN[m.from] ?? "unknown";
1396
1471
  const dstChain = EXCHANGE_TO_CHAIN[m.to] ?? "unknown";
1472
+ // null = no quote could be read (quoteError says why) — never a made-up fee / provider.
1397
1473
  let bridgeFee = 0;
1398
1474
  let bridgeProvider = "same-chain";
1399
1475
  let bridgeTime = "instant";
1476
+ let quoteError = null;
1400
1477
  if (srcChain !== dstChain && srcChain !== "unknown" && dstChain !== "unknown") {
1401
1478
  try {
1402
1479
  const quote = await getBestQuote(srcChain, dstChain, m.amount, "0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000");
@@ -1404,10 +1481,11 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1404
1481
  bridgeProvider = quote.provider;
1405
1482
  bridgeTime = `~${Math.ceil(quote.estimatedTime / 60)}min`;
1406
1483
  }
1407
- catch {
1408
- bridgeFee = 0.5; // fallback estimate
1409
- bridgeProvider = "cctp";
1410
- bridgeTime = "~3min";
1484
+ catch (e) {
1485
+ bridgeFee = null;
1486
+ bridgeProvider = null;
1487
+ bridgeTime = null;
1488
+ quoteError = e instanceof Error ? e.message : String(e);
1411
1489
  }
1412
1490
  }
1413
1491
  return {
@@ -1417,6 +1495,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1417
1495
  bridgeFee,
1418
1496
  bridgeProvider,
1419
1497
  bridgeTime,
1498
+ quoteError,
1420
1499
  };
1421
1500
  }));
1422
1501
  if (isJson()) {
@@ -1453,12 +1532,18 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1453
1532
  for (let i = 0; i < moveDetails.length; i++) {
1454
1533
  const m = moveDetails[i];
1455
1534
  console.log(chalk.white.bold(` Move ${i + 1}: $${m.amount} ${exAbbr(m.from)} -> ${exAbbr(m.to)}`));
1456
- console.log(chalk.gray(` Route: ${m.srcChain} -> ${m.dstChain} via ${m.bridgeProvider}`));
1457
- console.log(chalk.gray(` Fee: ~$${m.bridgeFee.toFixed(2)} | Time: ${m.bridgeTime}`));
1535
+ if (m.bridgeFee === null) {
1536
+ console.log(chalk.yellow(` Route: ${m.srcChain} -> ${m.dstChain} — no bridge quote (${m.quoteError})`));
1537
+ }
1538
+ else {
1539
+ console.log(chalk.gray(` Route: ${m.srcChain} -> ${m.dstChain} via ${m.bridgeProvider}`));
1540
+ console.log(chalk.gray(` Fee: ~$${m.bridgeFee.toFixed(2)} | Time: ${m.bridgeTime}`));
1541
+ }
1458
1542
  console.log();
1459
1543
  }
1460
- const totalFees = moveDetails.reduce((s, m) => s + m.bridgeFee, 0);
1461
- console.log(chalk.gray(` Total bridge fees: ~$${totalFees.toFixed(2)}`));
1544
+ const totalFees = moveDetails.reduce((s, m) => s + (m.bridgeFee ?? 0), 0);
1545
+ const unquoted = moveDetails.filter(m => m.bridgeFee === null).length;
1546
+ console.log(chalk.gray(` Total bridge fees: ~$${totalFees.toFixed(2)}`) + (unquoted > 0 ? chalk.yellow(` (excludes ${unquoted} move(s) without a quote)`) : ""));
1462
1547
  if (dryRun) {
1463
1548
  console.log(chalk.yellow("\n [DRY RUN] No transfers executed.\n"));
1464
1549
  }
@@ -180,6 +180,42 @@ export declare function isNearSettlement(longExchange: string, shortExchange: st
180
180
  * meaning we're now paying on both sides instead of collecting.
181
181
  */
182
182
  export declare function isSpreadReversed(longExchange: string, shortExchange: string, snapshot: FundingSnapshot): boolean;
183
+ export interface ArbPnlPosition {
184
+ exchange: string;
185
+ symbol: string;
186
+ side: string;
187
+ size: number;
188
+ entry: number;
189
+ mark: number;
190
+ upnl: number;
191
+ lev: number;
192
+ }
193
+ /**
194
+ * Read open positions on each venue for `arb pnl`. A venue whose adapter/positions read
195
+ * fails lands in `positionErrors` (never silently dropped); a venue with no credentials
196
+ * (read-only adapter) lands in `notConfigured`.
197
+ */
198
+ export declare function collectArbPnlPositions(exchangeNames: string[], getAdapterForExchange: (exchange: string) => Promise<ExchangeAdapter>): Promise<{
199
+ positions: ArbPnlPosition[];
200
+ positionErrors: Record<string, string>;
201
+ notConfigured: string[];
202
+ }>;
203
+ /**
204
+ * "arb" when the symbol has both a long and a short leg; otherwise "unknown" if any venue's
205
+ * positions were unread (its missing leg may be there — never call it naked), else "single".
206
+ */
207
+ export declare function arbPnlPairStatus(positions: Array<{
208
+ side: string;
209
+ }>, failedVenues: string[]): "arb" | "single" | "unknown";
210
+ /**
211
+ * HIP-3 auto-arb: the dex (either leg) whose market read failed this cycle, or null.
212
+ * A non-null result means the pair's spread is UNKNOWN — the position is kept (never
213
+ * closed as "pair disappeared"); only a pair missing from a successfully read dex is gone.
214
+ */
215
+ export declare function dexCloseBlockedBy(pos: {
216
+ longDex: string;
217
+ shortDex: string;
218
+ }, failedDexes: Record<string, string>): string | null;
183
219
  export declare let handleSpotClose: ((sym: string, opts: {
184
220
  spotExch?: string;
185
221
  perpExch?: string;