perp-cli 0.14.1 → 0.14.3

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 (66) 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 +22 -26
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +33 -1
  9. package/dist/bot/strategies/funding-arb-utils.js +82 -19
  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 +48 -29
  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/commands/account.js +2 -2
  19. package/dist/commands/arb/index.d.ts +20 -0
  20. package/dist/commands/arb/index.js +121 -36
  21. package/dist/commands/arb-auto.d.ts +36 -0
  22. package/dist/commands/arb-auto.js +202 -76
  23. package/dist/commands/arb.js +51 -35
  24. package/dist/commands/bot.d.ts +6 -0
  25. package/dist/commands/bot.js +55 -45
  26. package/dist/commands/funds-onchain.js +6 -5
  27. package/dist/commands/funds.js +18 -5
  28. package/dist/commands/history.js +135 -53
  29. package/dist/commands/market.js +2 -2
  30. package/dist/commands/portfolio.d.ts +5 -0
  31. package/dist/commands/portfolio.js +58 -20
  32. package/dist/commands/rebalance.js +46 -10
  33. package/dist/commands/settings.js +2 -1
  34. package/dist/commands/trade.js +8 -4
  35. package/dist/commands/wallet.js +1 -1
  36. package/dist/cross-chain-margin.d.ts +29 -2
  37. package/dist/cross-chain-margin.js +30 -4
  38. package/dist/dashboard/server.d.ts +45 -8
  39. package/dist/dashboard/server.js +113 -42
  40. package/dist/dashboard/ui.js +47 -28
  41. package/dist/dashboard/ws-feeds.d.ts +25 -3
  42. package/dist/dashboard/ws-feeds.js +219 -82
  43. package/dist/dex-asset-map.d.ts +23 -4
  44. package/dist/dex-asset-map.js +39 -13
  45. package/dist/exchanges/arcus/adapter.js +33 -6
  46. package/dist/exchanges/arcus/public.d.ts +6 -0
  47. package/dist/exchanges/arcus/public.js +12 -4
  48. package/dist/exchanges/aster.d.ts +24 -7
  49. package/dist/exchanges/aster.js +178 -93
  50. package/dist/exchanges/hyperliquid.d.ts +30 -4
  51. package/dist/exchanges/hyperliquid.js +159 -55
  52. package/dist/exchanges/interface.d.ts +3 -2
  53. package/dist/exchanges/interface.js +3 -2
  54. package/dist/exchanges/lighter.d.ts +1 -1
  55. package/dist/exchanges/lighter.js +11 -9
  56. package/dist/exchanges/ondo/funds.d.ts +0 -1
  57. package/dist/exchanges/pacifica.d.ts +7 -0
  58. package/dist/exchanges/pacifica.js +52 -26
  59. package/dist/rebalance.d.ts +20 -1
  60. package/dist/rebalance.js +31 -5
  61. package/dist/strategies/funding-arb.js +38 -7
  62. package/dist/trade-validator.d.ts +1 -1
  63. package/dist/trade-validator.js +7 -2
  64. package/package.json +1 -1
  65. package/skills/perp-cli/SKILL.md +2 -2
  66. package/skills/perp-cli/references/commands.md +5 -3
@@ -68,15 +68,20 @@ export function BotDashboard({ initialState, onQuit, onPause, subscribe }) {
68
68
  const drawdown = state.equity !== null && state.peakEquity !== null ? state.peakEquity - state.equity : null;
69
69
  const drawdownPct = drawdown !== null && state.peakEquity !== null && state.peakEquity > 0 ? (drawdown / state.peakEquity) * 100 : 0;
70
70
  const usd = (v) => v === null ? "unknown" : `$${v.toFixed(2)}`;
71
- const totalEquity = state.exchangeBalances !== undefined ? state.exchangeBalances.reduce((s, e) => s + parseFloat(e.equity), 0) : state.equity;
71
+ // Any unavailable venue balance → the total is unknown (never summed as if that venue were $0).
72
+ const totalEquity = state.exchangeBalances !== undefined
73
+ ? (state.exchangeBalances.some((e) => e.equity === null) ? null : state.exchangeBalances.reduce((s, e) => s + parseFloat(e.equity), 0))
74
+ : state.equity;
75
+ const positionErrors = state.positionErrors ?? [];
76
+ const openOrdersError = state.openOrdersError ?? null;
72
77
  const bidOrders = state.openOrders.filter((o) => o.side.toLowerCase() === "buy");
73
78
  const askOrders = state.openOrders.filter((o) => o.side.toLowerCase() === "sell");
74
79
  const hasMultiExchange = (state.exchangeBalances?.length ?? 0) > 1;
75
80
  const isMultiSymbol = state.symbol.toUpperCase() === "ALL";
76
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: [state.strategy, " | ", state.symbol, " | ", state.exchange] }), _jsxs(Text, { color: "gray", children: ["Runtime: ", formatDuration(state.runtime), " | Tick: ", state.tick] })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: phaseColor(state.phase), children: phaseIndicator(state.phase) }) })] }), isMultiSymbol ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Exchanges: ", _jsx(Text, { bold: true, color: "white", children: state.exchangeBalances?.length ?? 1 })] }), _jsxs(Text, { color: "gray", children: ["Positions: ", _jsx(Text, { bold: true, color: state.positions.length > 0 ? "yellow" : "white", children: state.positions.length })] }), _jsxs(Text, { color: "gray", children: ["Total Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(totalEquity) })] }), _jsxs(Text, { color: "gray", children: ["PnL: ", _jsxs(Text, { bold: true, color: state.totalPnl >= 0 ? "green" : "red", children: ["$", state.totalPnl.toFixed(2)] })] })] })) : (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Price: ", _jsxs(Text, { bold: true, color: "white", children: ["$", state.price.toFixed(2)] })] }), _jsxs(Text, { color: "gray", children: ["Vol24h: ", _jsxs(Text, { color: "white", children: [state.volatility24h.toFixed(1), "%"] })] }), _jsxs(Text, { color: "gray", children: ["FR: ", _jsxs(Text, { color: state.fundingRate >= 0 ? "green" : "red", children: [(state.fundingRate * 100).toFixed(4), "%"] })] }), _jsxs(Text, { color: "gray", children: ["OI: ", _jsx(Text, { color: "white", children: state.openInterest })] })] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Account" }), _jsxs(Text, { children: ["Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(state.equity) }), _jsxs(Text, { color: "gray", children: [" (peak: ", usd(state.peakEquity), ")"] })] }), _jsxs(Text, { children: ["Daily P&L: ", _jsxs(Text, { bold: true, color: pnlColor(state.dailyPnl), children: ["$", state.dailyPnl.toFixed(2)] })] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: ["Drawdown: ", _jsx(Text, { bold: true, color: drawdown === null ? "gray" : drawdown > 0 ? "red" : "green", children: drawdown === null ? "unknown" : `${usd(drawdown)} (${drawdownPct.toFixed(1)}%)` })] }), _jsxs(Text, { children: ["Orders: ", _jsx(Text, { bold: true, color: "white", children: state.ordersPlaced }), _jsx(Text, { color: "gray", children: " | Fills: " }), _jsx(Text, { bold: true, color: "white", children: state.fills }), _jsx(Text, { color: "gray", children: " | Total P&L: " }), _jsxs(Text, { bold: true, color: pnlColor(state.totalPnl), children: ["$", state.totalPnl.toFixed(2)] })] })] })] }), state.exchangeBalances && state.exchangeBalances.length > 1 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Exchange Balances" }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "gray", children: pad("Exchange", 16) }), _jsx(Text, { color: "gray", children: pad("Equity", 14) }), _jsx(Text, { color: "gray", children: "Available" })] }), state.exchangeBalances.map((eb, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "white", children: pad(eb.exchange, 16) }), _jsx(Text, { color: "white", children: pad(`$${parseFloat(eb.equity).toFixed(2)}`, 14) }), _jsxs(Text, { color: "gray", children: ["$", parseFloat(eb.available).toFixed(2)] })] }, i)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Positions" }), state.positions.length === 0 ? (_jsx(Text, { color: "gray", children: " No open positions" })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "gray", children: pad("Exchange", 14) }), _jsx(Text, { color: "gray", children: pad("Symbol", 10) }), _jsx(Text, { color: "gray", children: pad("Side", 6) }), _jsx(Text, { color: "gray", children: pad("Size", 12) }), _jsx(Text, { color: "gray", children: pad("Entry", 12) }), _jsx(Text, { color: "gray", children: pad("Mark", 12) }), _jsx(Text, { color: "gray", children: "uPnL" })] }), state.positions.map((p, i) => {
81
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: [state.strategy, " | ", state.symbol, " | ", state.exchange] }), _jsxs(Text, { color: "gray", children: ["Runtime: ", formatDuration(state.runtime), " | Tick: ", state.tick] })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: phaseColor(state.phase), children: phaseIndicator(state.phase) }) })] }), isMultiSymbol ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Exchanges: ", _jsx(Text, { bold: true, color: "white", children: state.exchangeBalances?.length ?? 1 })] }), _jsxs(Text, { color: "gray", children: ["Positions: ", _jsx(Text, { bold: true, color: state.positions.length > 0 ? "yellow" : "white", children: state.positions.length })] }), _jsxs(Text, { color: "gray", children: ["Total Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(totalEquity) })] }), _jsxs(Text, { color: "gray", children: ["PnL: ", _jsxs(Text, { bold: true, color: state.totalPnl >= 0 ? "green" : "red", children: ["$", state.totalPnl.toFixed(2)] })] })] })) : (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Price: ", _jsxs(Text, { bold: true, color: "white", children: ["$", state.price.toFixed(2)] })] }), _jsxs(Text, { color: "gray", children: ["Vol24h: ", _jsx(Text, { color: "white", children: Number.isFinite(state.volatility24h) ? `${state.volatility24h.toFixed(1)}%` : "unknown" })] }), _jsxs(Text, { color: "gray", children: ["FR: ", _jsxs(Text, { color: state.fundingRate >= 0 ? "green" : "red", children: [(state.fundingRate * 100).toFixed(4), "%"] })] }), _jsxs(Text, { color: "gray", children: ["OI: ", _jsx(Text, { color: "white", children: state.openInterest })] })] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Account" }), _jsxs(Text, { children: ["Equity: ", _jsx(Text, { bold: true, color: "white", children: usd(state.equity) }), _jsxs(Text, { color: "gray", children: [" (peak: ", usd(state.peakEquity), ")"] })] }), _jsxs(Text, { children: ["Daily P&L: ", _jsxs(Text, { bold: true, color: pnlColor(state.dailyPnl), children: ["$", state.dailyPnl.toFixed(2)] })] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: ["Drawdown: ", _jsx(Text, { bold: true, color: drawdown === null ? "gray" : drawdown > 0 ? "red" : "green", children: drawdown === null ? "unknown" : `${usd(drawdown)} (${drawdownPct.toFixed(1)}%)` })] }), _jsxs(Text, { children: ["Orders: ", _jsx(Text, { bold: true, color: "white", children: state.ordersPlaced }), _jsx(Text, { color: "gray", children: " | Fills: " }), _jsx(Text, { bold: true, color: "white", children: state.fills }), _jsx(Text, { color: "gray", children: " | Total P&L: " }), _jsxs(Text, { bold: true, color: pnlColor(state.totalPnl), children: ["$", state.totalPnl.toFixed(2)] })] })] })] }), state.exchangeBalances && state.exchangeBalances.length > 1 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Exchange Balances" }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "gray", children: pad("Exchange", 16) }), _jsx(Text, { color: "gray", children: pad("Equity", 14) }), _jsx(Text, { color: "gray", children: "Available" })] }), state.exchangeBalances.map((eb, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "white", children: pad(eb.exchange, 16) }), eb.equity === null ? (_jsxs(Text, { color: "red", children: ["unavailable", eb.error ? ` (${eb.error})` : ""] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "white", children: pad(`$${parseFloat(eb.equity).toFixed(2)}`, 14) }), _jsx(Text, { color: "gray", children: eb.available === null ? "unavailable" : `$${parseFloat(eb.available).toFixed(2)}` })] }))] }, i)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Positions" }), positionErrors.map((pe, i) => (_jsxs(Text, { color: "red", children: [" ", pe.exchange, ": unavailable (", pe.error, ")"] }, `err-${i}`))), state.positions.length === 0 ? (positionErrors.length === 0 && _jsx(Text, { color: "gray", children: " No open positions" })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "gray", children: pad("Exchange", 14) }), _jsx(Text, { color: "gray", children: pad("Symbol", 10) }), _jsx(Text, { color: "gray", children: pad("Side", 6) }), _jsx(Text, { color: "gray", children: pad("Size", 12) }), _jsx(Text, { color: "gray", children: pad("Entry", 12) }), _jsx(Text, { color: "gray", children: pad("Mark", 12) }), _jsx(Text, { color: "gray", children: "uPnL" })] }), state.positions.map((p, i) => {
77
82
  const upnl = parseFloat(p.unrealizedPnl);
78
83
  return (_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "cyan", children: pad(p.exchange ?? "—", 14) }), _jsx(Text, { color: "white", children: pad(p.symbol, 10) }), _jsx(Text, { color: p.side.toLowerCase() === "long" ? "green" : "red", children: pad(p.side, 6) }), _jsx(Text, { color: "white", children: pad(p.size, 12) }), _jsx(Text, { color: "white", children: pad(p.entryPrice, 12) }), _jsx(Text, { color: "white", children: pad(p.markPrice, 12) }), _jsx(Text, { color: isNaN(upnl) ? "gray" : pnlColor(upnl), children: isNaN(upnl) ? "$—" : `$${upnl.toFixed(2)}` })] }, i));
79
- })] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "green", bold: true, children: ["Bids (", bidOrders.length, ")"] }), bidOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (bidOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "green", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] }), _jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "red", bold: true, children: ["Asks (", askOrders.length, ")"] }), askOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (askOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "red", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: state.strategyState.fundingTotal !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Funding & Arb" }), _jsxs(Box, { flexDirection: "row", marginLeft: 2, children: [_jsx(Text, { color: "gray", children: "Funding Income: " }), _jsx(Text, { color: "white", children: String(state.strategyState.fundingTotal) }), _jsx(Text, { color: "gray", children: " Positions: " }), _jsx(Text, { color: "white", children: String(state.strategyState.arbPositions ?? 0) })] }), Array.isArray(state.strategyState.arbPositionDetails) &&
84
+ })] }))] }), openOrdersError !== null ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Open orders unavailable (", openOrdersError, ")"] }) })) : (_jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "green", bold: true, children: ["Bids (", bidOrders.length, ")"] }), bidOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (bidOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "green", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] }), _jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "red", bold: true, children: ["Asks (", askOrders.length, ")"] }), askOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (askOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "red", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] })] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: state.strategyState.fundingTotal !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Funding & Arb" }), _jsxs(Box, { flexDirection: "row", marginLeft: 2, children: [_jsx(Text, { color: "gray", children: "Funding Income: " }), _jsx(Text, { color: "white", children: String(state.strategyState.fundingTotal) }), _jsx(Text, { color: "gray", children: " Positions: " }), _jsx(Text, { color: "white", children: String(state.strategyState.arbPositions ?? 0) })] }), Array.isArray(state.strategyState.arbPositionDetails) &&
80
85
  state.strategyState.arbPositionDetails.map((detail, i) => (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "yellow", children: detail }) }, i)))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Strategy State" }), _jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [Object.entries(state.strategyState)
81
86
  .filter(([k, v]) => {
82
87
  if (k === 'arbRunning' || k === 'fundingLastCheck' || k === 'fundingHistory' || k === 'arbOpenPositions')
@@ -65,6 +65,8 @@ export declare function getEvmUsdcBalance(chain: string, address: string): Promi
65
65
  * Returns balance in USDC (human-readable).
66
66
  */
67
67
  export declare function getSolanaUsdcBalance(ownerPubkey: string): Promise<number>;
68
+ /** RPC "account not found" for getTokenAccountBalance (the ATA was never created ⇒ balance 0). */
69
+ export declare function isSolanaAccountNotFound(e: unknown): boolean;
68
70
  /**
69
71
  * Check USDC balance for a bridge source chain + address.
70
72
  */
@@ -318,13 +318,32 @@ export async function getSolanaUsdcBalance(ownerPubkey) {
318
318
  const usdcMint = new PublicKey(USDC_ADDRESSES.solana);
319
319
  const tokenProgram = new PublicKey(SPL_TOKEN_PROGRAM);
320
320
  const [ata] = PublicKey.findProgramAddressSync([owner.toBuffer(), tokenProgram.toBuffer(), usdcMint.toBuffer()], new PublicKey(ASSOCIATED_TOKEN_PROGRAM));
321
+ let info;
321
322
  try {
322
- const info = await connection.getTokenAccountBalance(ata);
323
- return Number(info.value.uiAmount ?? 0);
323
+ info = await connection.getTokenAccountBalance(ata);
324
324
  }
325
- catch {
326
- return 0; // ATA doesn't exist
325
+ catch (e) {
326
+ // Only a missing USDC token account is a real 0 balance. Any other RPC failure is unknown:
327
+ // reporting it as 0 would read as "no USDC" (or, inverted, mask a balance check).
328
+ if (isSolanaAccountNotFound(e))
329
+ return 0;
330
+ throw new PerpError("EXCHANGE_UNREACHABLE", `Solana USDC balance of ${ownerPubkey} could not be read: ${e instanceof Error ? e.message : String(e)}`, {
331
+ remediation: "Retry, or set a working Solana RPC (settings rpc.solana) — the balance is unknown, not 0",
332
+ });
333
+ }
334
+ // Raw amount / decimals (uiAmount may be null) — a malformed answer is an error, not 0.
335
+ const raw = Number(info.value.amount);
336
+ if (!Number.isFinite(raw) || !Number.isInteger(info.value.decimals)) {
337
+ throw new PerpError("EXCHANGE_ERROR", `Solana RPC returned a malformed USDC balance for ${ownerPubkey}: ${JSON.stringify(info.value)}`, {
338
+ remediation: "Retry, or set a different Solana RPC (settings rpc.solana)",
339
+ });
327
340
  }
341
+ return raw / 10 ** info.value.decimals;
342
+ }
343
+ /** RPC "account not found" for getTokenAccountBalance (the ATA was never created ⇒ balance 0). */
344
+ export function isSolanaAccountNotFound(e) {
345
+ const msg = e instanceof Error ? e.message : String(e);
346
+ return /could not find account|account not found|AccountNotFound/i.test(msg);
328
347
  }
329
348
  /**
330
349
  * Check USDC balance for a bridge source chain + address.
@@ -412,38 +431,43 @@ function encodeHyperCoreHookData(recipientAddress, dexId = HYPERCORE_DEX_PERPS)
412
431
  * Get CCTP fees for a Solana/EVM → HyperCore transfer.
413
432
  */
414
433
  async function getHyperCoreCctpFees(srcDomain, amount) {
434
+ // SSOT rule #2 (same as getCctpRelayFee): the burn's maxFee comes from Circle's fee schedule.
435
+ // A hardcoded default (formerly 1bp + $0.20) could under-fee (transfer stuck) or over-fee —
436
+ // so an unreadable schedule is an error, never a silently-used guess.
415
437
  const url = `${HYPERCORE_FEES_API}/${srcDomain}/${HYPERCORE_CCTP_DOMAIN}?forward=true&hyperCoreDeposit=true`;
438
+ const fail = (why) => new PerpError("EXCHANGE_UNREACHABLE", `CCTP HyperCore fee schedule ${srcDomain}→${HYPERCORE_CCTP_DOMAIN} unavailable: ${why} — nothing was sent`, {
439
+ remediation: "Retry shortly (Circle iris-api); the bridge fee cannot be set without it",
440
+ });
441
+ let res;
416
442
  try {
417
- const res = await fetch(url);
418
- if (res.ok) {
419
- // API returns array: [{finalityThreshold, minimumFee, forwardFee: {low, med, high}}, ...]
420
- const schedules = await res.json();
421
- // Use fast transfer schedule (threshold=1000) if available, else first
422
- const fast = schedules.find(s => s.finalityThreshold === 1000) ?? schedules[0];
423
- if (fast) {
424
- const bps = Number(fast.feeRate ?? 1); // basis points, default 1bp
425
- const forwardFee = Number(fast.forwardFee?.med ?? fast.forwardFee?.low ?? 200000); // subunits
426
- const protocolFee = Math.ceil(amount * 1e6 * bps / 10000);
427
- return {
428
- protocolFee: protocolFee / 1e6,
429
- forwardingFee: forwardFee / 1e6,
430
- totalFee: (protocolFee + forwardFee) / 1e6,
431
- maxFee: protocolFee + forwardFee, // in subunits for instruction
432
- };
433
- }
434
- }
443
+ res = await fetch(url);
435
444
  }
436
- catch {
437
- // fallback to defaults
445
+ catch (e) {
446
+ throw fail(e instanceof Error ? e.message : String(e));
438
447
  }
439
- // Default: 1bp + $0.20
440
- const protocolFee = Math.ceil(amount * 1e6 / 10000);
441
- const forwardingFee = 200000;
448
+ if (!res.ok)
449
+ throw fail(`HTTP ${res.status}`);
450
+ // API returns array: [{finalityThreshold, minimumFee, forwardFee: {low, med, high}}, ...]
451
+ const schedules = await res.json().catch((e) => { throw fail(`invalid JSON (${e instanceof Error ? e.message : String(e)})`); });
452
+ if (!Array.isArray(schedules))
453
+ throw fail(`unexpected response ${JSON.stringify(schedules).slice(0, 200)}`);
454
+ // Use fast transfer schedule (threshold=1000) if available, else the first published tier
455
+ const fast = schedules.find(s => s.finalityThreshold === 1000) ?? schedules[0];
456
+ if (!fast)
457
+ throw fail("no fee schedule");
458
+ // Protocol fee rate in basis points: feeRate when published, else minimumFee (Circle's bps field).
459
+ const bps = Number(fast.feeRate ?? fast.minimumFee);
460
+ const forwardFee = Number(fast.forwardFee?.med ?? fast.forwardFee?.low); // subunits
461
+ if (!Number.isFinite(bps) || bps < 0)
462
+ throw fail("schedule without a protocol fee rate");
463
+ if (!Number.isFinite(forwardFee) || forwardFee < 0)
464
+ throw fail("schedule without a forwarding fee");
465
+ const protocolFee = Math.ceil(amount * 1e6 * bps / 10000);
442
466
  return {
443
467
  protocolFee: protocolFee / 1e6,
444
- forwardingFee: 0.20,
445
- totalFee: (protocolFee + forwardingFee) / 1e6,
446
- maxFee: protocolFee + forwardingFee,
468
+ forwardingFee: forwardFee / 1e6,
469
+ totalFee: (protocolFee + forwardFee) / 1e6,
470
+ maxFee: protocolFee + forwardFee, // in subunits for instruction
447
471
  };
448
472
  }
449
473
  // ── deBridge DLN API ──
@@ -1713,10 +1737,23 @@ export async function getAllQuotes(srcChain, dstChain, amount, senderAddress, re
1713
1737
  getDebridgeQuote(srcChain, dstChain, amount, senderAddress, recipientAddress),
1714
1738
  ]);
1715
1739
  const quotes = [];
1716
- for (const r of results) {
1740
+ const failures = [];
1741
+ const providers = ["cctp", "relay", "debridge"];
1742
+ results.forEach((r, i) => {
1717
1743
  if (r.status === "fulfilled")
1718
1744
  quotes.push(r.value);
1745
+ else
1746
+ failures.push(`${providers[i]}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
1747
+ });
1748
+ // A provider without this route is normal; still say which ones were left out and why, and
1749
+ // never answer "no quotes" without the reasons.
1750
+ if (quotes.length === 0) {
1751
+ throw new PerpError("NO_BRIDGE_ROUTE", `No bridge quote for ${srcChain} → ${dstChain} (${failures.join("; ")})`, {
1752
+ remediation: "Check the chains / amount, or retry if a provider was unreachable",
1753
+ });
1719
1754
  }
1755
+ if (failures.length > 0)
1756
+ process.stderr.write(`[bridge] providers without a quote: ${failures.join("; ")}\n`);
1720
1757
  // Price native fees (deBridge fixFee) so quotes compare on the same basis; an unpriced native
1721
1758
  // fee ranks that quote last (its total cost is unknown), never as free.
1722
1759
  const symbols = [...new Set(quotes.flatMap(q => (q.nativeFee ? [q.nativeFee.symbol] : [])))];
@@ -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
  }