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
@@ -11,11 +11,11 @@ import { computeMatchedSize, computeSpotPerpMatchedSize } from "../arb/index.js"
11
11
  import { toHourlyRate } from "../funding.js";
12
12
  import { toHourlyRateByBasis } from "../funding/normalize.js";
13
13
  import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
14
- import { fetchAllDexAssets, findDexArbPairs, scanDexArb } from "../dex-asset-map.js";
14
+ import { fetchAllDexAssetsDetailed, findDexArbPairs, scanDexArb } from "../dex-asset-map.js";
15
15
  import { logExecution, readExecutionLog } from "../execution-log.js";
16
16
  import { smartOrder } from "../smart-order.js";
17
17
  import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
18
- import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
18
+ import { checkChainMargins, marginEntryBlocks, computeAutoSize, } from "../cross-chain-margin.js";
19
19
  import { loadArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, updatePosition as persistUpdatePosition, createInitialState, createDaemonStateStore, } from "../arb-state.js";
20
20
  import { handleRates, handleBasisScan, handleDexScan, handleCompare, handleFundingHistory, handleFundingPositions, fetchAllPrices, printGapTable, printTrackSummary, formatGapPrice, } from "./arb.js";
21
21
  // ── Fee-Adjusted Net Spread Calculation ──
@@ -323,6 +323,62 @@ export function isSpreadReversed(longExchange, shortExchange, snapshot) {
323
323
  // Reversed if the long side rate exceeds the short side rate
324
324
  return longHourly > shortHourly;
325
325
  }
326
+ /**
327
+ * Read open positions on each venue for `arb pnl`. A venue whose adapter/positions read
328
+ * fails lands in `positionErrors` (never silently dropped); a venue with no credentials
329
+ * (read-only adapter) lands in `notConfigured`.
330
+ */
331
+ export async function collectArbPnlPositions(exchangeNames, getAdapterForExchange) {
332
+ const positions = [];
333
+ const positionErrors = {};
334
+ const notConfigured = [];
335
+ for (const exName of exchangeNames) {
336
+ try {
337
+ const adapter = await getAdapterForExchange(exName);
338
+ if (adapter.isReadOnly === true) {
339
+ notConfigured.push(exName);
340
+ continue;
341
+ }
342
+ for (const p of await adapter.getPositions()) {
343
+ positions.push({
344
+ exchange: exName,
345
+ symbol: p.symbol.replace("-PERP", ""),
346
+ side: p.side,
347
+ size: Math.abs(Number(p.size)),
348
+ entry: Number(p.entryPrice),
349
+ mark: Number(p.markPrice),
350
+ upnl: Number(p.unrealizedPnl),
351
+ lev: p.leverage,
352
+ });
353
+ }
354
+ }
355
+ catch (e) {
356
+ positionErrors[exName] = e instanceof Error ? e.message : String(e);
357
+ }
358
+ }
359
+ return { positions, positionErrors, notConfigured };
360
+ }
361
+ /**
362
+ * "arb" when the symbol has both a long and a short leg; otherwise "unknown" if any venue's
363
+ * positions were unread (its missing leg may be there — never call it naked), else "single".
364
+ */
365
+ export function arbPnlPairStatus(positions, failedVenues) {
366
+ if (positions.length >= 2 && positions.some(p => p.side === "long") && positions.some(p => p.side === "short"))
367
+ return "arb";
368
+ return failedVenues.length > 0 ? "unknown" : "single";
369
+ }
370
+ /**
371
+ * HIP-3 auto-arb: the dex (either leg) whose market read failed this cycle, or null.
372
+ * A non-null result means the pair's spread is UNKNOWN — the position is kept (never
373
+ * closed as "pair disappeared"); only a pair missing from a successfully read dex is gone.
374
+ */
375
+ export function dexCloseBlockedBy(pos, failedDexes) {
376
+ if (Object.hasOwn(failedDexes, pos.longDex))
377
+ return pos.longDex;
378
+ if (Object.hasOwn(failedDexes, pos.shortDex))
379
+ return pos.shortDex;
380
+ return null;
381
+ }
326
382
  const _warnedArbPriceMismatch = new Set();
327
383
  async function fetchFundingSpreads() {
328
384
  // Selected network (review L7): the Pacifica / HL / Lighter raw fetchers follow -n via
@@ -587,6 +643,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
587
643
  const openPositions = [];
588
644
  // Track which exchanges have low margin (block entries)
589
645
  const blockedExchanges = new Set();
646
+ // Venues whose margin was unknown last cycle (webhook sent once per transition)
647
+ const marginUnknownNotified = new Set();
590
648
  // Track close times per symbol to prevent close→re-entry loops
591
649
  const closeCooldowns = new Map(); // symbol → close timestamp ms
592
650
  // Consecutive definite close failures per symbol (quarantined at MAX_PAIR_CLOSE_FAILURES)
@@ -960,40 +1018,74 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
960
1018
  console.log(chalk.yellow(` ${now} DEGRADED ${pos.symbol}: ${downExchanges.has(pos.longExchange) ? pos.longExchange : pos.shortExchange} is down`));
961
1019
  }
962
1020
  }
963
- try {
964
- // Only check margins when we have open positions (avoid slow adapter init)
965
- const needMarginCheck = openPositions.length > 0;
966
- const adaptersMap = new Map();
967
- if (needMarginCheck) {
968
- const marginExchanges = ["hyperliquid", "lighter", "pacifica"];
969
- for (const name of marginExchanges) {
1021
+ // Only check margins when we have open positions (avoid slow adapter init).
1022
+ // An unreadable margin is UNKNOWN, never healthy: logged every cycle (stderr) and new
1023
+ // entries on that venue are blocked (closing / reducing stays allowed).
1024
+ if (openPositions.length > 0) {
1025
+ const marginCheck = { statuses: [], unknown: [] };
1026
+ try {
1027
+ const adaptersMap = new Map();
1028
+ for (const name of ["hyperliquid", "lighter", "pacifica"]) {
970
1029
  try {
971
- adaptersMap.set(name, await getAdapterForExchange(name));
1030
+ const a = await getAdapterForExchange(name);
1031
+ // No credentials (read-only adapter): no positions / margin there to monitor, and
1032
+ // the bot cannot enter on it either — not "unknown".
1033
+ if (a.isReadOnly === true) {
1034
+ blockedExchanges.add(name);
1035
+ continue;
1036
+ }
1037
+ adaptersMap.set(name, a);
972
1038
  }
973
- catch { /* skip */ }
1039
+ catch (e) {
1040
+ marginCheck.unknown.push({ exchange: name, error: `adapter init failed: ${e instanceof Error ? e.message : String(e)}` });
1041
+ }
1042
+ }
1043
+ if (adaptersMap.size > 0) {
1044
+ const res = await checkChainMargins(adaptersMap, minMarginPct);
1045
+ marginCheck.statuses.push(...res.statuses);
1046
+ marginCheck.unknown.push(...res.unknown);
974
1047
  }
975
1048
  }
976
- if (adaptersMap.size > 0) {
977
- const marginStatuses = await checkChainMargins(adaptersMap, minMarginPct);
978
- for (const ms of marginStatuses) {
979
- if (isCriticalMargin(ms)) {
980
- console.log(chalk.red.bold(` ${now} EMERGENCY: ${ms.exchange} margin ratio ${ms.marginRatio.toFixed(1)}% — CRITICAL (below 15%)`));
981
- blockedExchanges.add(ms.exchange);
982
- await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
983
- exchange: ms.exchange, marginPct: ms.marginRatio, threshold: 15,
984
- });
985
- }
986
- else if (shouldBlockEntries(ms, minMarginPct)) {
987
- console.log(chalk.yellow(` ${now} WARNING: ${ms.exchange} margin ${ms.marginRatio.toFixed(1)}% below ${minMarginPct}% — blocking new entries`));
988
- blockedExchanges.add(ms.exchange);
1049
+ catch (e) {
1050
+ const msg = e instanceof Error ? e.message : String(e);
1051
+ for (const name of ["hyperliquid", "lighter", "pacifica"]) {
1052
+ if (blockedExchanges.has(name))
1053
+ continue;
1054
+ if (!marginCheck.statuses.some(s => s.exchange === name) && !marginCheck.unknown.some(u => u.exchange === name))
1055
+ marginCheck.unknown.push({ exchange: name, error: msg });
1056
+ }
1057
+ }
1058
+ const nowUnknown = new Set();
1059
+ for (const b of marginEntryBlocks(marginCheck, minMarginPct)) {
1060
+ blockedExchanges.add(b.exchange);
1061
+ if (b.level === "critical") {
1062
+ console.log(chalk.red.bold(` ${now} EMERGENCY: ${b.exchange} margin ratio ${b.marginPct.toFixed(1)}% — CRITICAL (below 15%)`));
1063
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
1064
+ exchange: b.exchange, marginPct: b.marginPct, threshold: 15,
1065
+ });
1066
+ }
1067
+ else if (b.level === "low") {
1068
+ console.log(chalk.yellow(` ${now} WARNING: ${b.exchange} margin ${b.marginPct.toFixed(1)}% below ${minMarginPct}% — blocking new entries`));
1069
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
1070
+ exchange: b.exchange, marginPct: b.marginPct, threshold: minMarginPct,
1071
+ });
1072
+ }
1073
+ else {
1074
+ nowUnknown.add(b.exchange);
1075
+ console.error(chalk.yellow(` ${now} MARGIN UNKNOWN: ${b.exchange} (${b.error}) — blocking new entries until margin is readable`));
1076
+ // Webhook once per transition into unknown (stderr carries the per-cycle signal).
1077
+ if (!marginUnknownNotified.has(b.exchange)) {
989
1078
  await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
990
- exchange: ms.exchange, marginPct: ms.marginRatio, threshold: minMarginPct,
1079
+ exchange: b.exchange, marginPct: null, threshold: minMarginPct,
1080
+ message: `MARGIN UNKNOWN: ${b.exchange} (${b.error}) — new entries blocked`,
991
1081
  });
992
1082
  }
993
1083
  }
994
1084
  }
1085
+ marginUnknownNotified.clear();
1086
+ for (const ex of nowUnknown)
1087
+ marginUnknownNotified.add(ex);
995
1088
  }
996
- catch { /* margin check failed, continue without blocking */ }
997
1089
  // Entry decisions: every monitored symbol that is not entered gets its reason — logged per
998
1090
  // symbol for --symbols, summarized per cycle for a full scan (a silent run is impossible).
999
1091
  const skipCounts = new Map();
@@ -1058,7 +1150,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1058
1150
  const shortExchange = snap.shortExch;
1059
1151
  // Block entries if either exchange has low margin
1060
1152
  if (blockedExchanges.has(longExchange) || blockedExchanges.has(shortExchange)) {
1061
- skip(snap.symbol, `entries blocked on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange} (margin low / venue down)`);
1153
+ skip(snap.symbol, `entries blocked on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange} (margin low / margin unknown / venue down)`);
1062
1154
  continue;
1063
1155
  }
1064
1156
  // Compute net spread after fees and bridge costs
@@ -2624,30 +2716,22 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2624
2716
  if (!isJson())
2625
2717
  console.log(chalk.cyan("\n Checking arb positions...\n"));
2626
2718
  const exchangeNames = ["hyperliquid", "lighter", "pacifica"];
2627
- const allPositions = [];
2628
- for (const exName of exchangeNames) {
2629
- try {
2630
- const adapter = await getAdapterForExchange(exName);
2631
- const positions = await adapter.getPositions();
2632
- for (const p of positions) {
2633
- allPositions.push({
2634
- exchange: exName,
2635
- symbol: p.symbol.replace("-PERP", ""),
2636
- side: p.side,
2637
- size: Math.abs(Number(p.size)),
2638
- entry: Number(p.entryPrice),
2639
- mark: Number(p.markPrice),
2640
- upnl: Number(p.unrealizedPnl),
2641
- lev: p.leverage,
2642
- });
2643
- }
2644
- }
2645
- catch {
2646
- // exchange not configured, skip
2647
- }
2719
+ // A venue whose positions could not be read is reported (never dropped): pairs that may
2720
+ // involve it are UNKNOWN, not naked. Only a venue with no credentials is skipped — and said so.
2721
+ const { positions: allPositions, positionErrors, notConfigured } = await collectArbPnlPositions(exchangeNames, getAdapterForExchange);
2722
+ const failedVenues = Object.keys(positionErrors);
2723
+ if (!isJson()) {
2724
+ for (const ex of notConfigured)
2725
+ console.log(chalk.gray(` ${ex}: not configured (no credentials) — skipped`));
2726
+ for (const [ex, msg] of Object.entries(positionErrors))
2727
+ console.error(chalk.red(` ${ex}: positions unavailable — ${msg} (pairs involving ${ex} are UNKNOWN)`));
2648
2728
  }
2649
2729
  if (allPositions.length === 0) {
2650
- console.log(chalk.gray(" No positions found on any exchange.\n"));
2730
+ if (isJson())
2731
+ return printJson(jsonOk({ pairs: [], positionErrors, notConfigured }));
2732
+ console.log(failedVenues.length > 0
2733
+ ? chalk.yellow(` No positions read — ${failedVenues.join(", ")} unavailable, holdings there are UNKNOWN.\n`)
2734
+ : chalk.gray(" No positions found on any configured exchange.\n"));
2651
2735
  return;
2652
2736
  }
2653
2737
  // Group by symbol to find arb pairs
@@ -2665,6 +2749,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2665
2749
  const TAKER_FEE = getTakerFee("default");
2666
2750
  // Fetch actual settled funding payments from each exchange
2667
2751
  const actualFundingByExSymbol = new Map(); // "exchange:SYMBOL" → total settled USD
2752
+ // Venues whose funding history could not be read: their symbols' actualFunding is null, never 0.
2753
+ const fundingErrors = {};
2668
2754
  for (const exName of exchangeNames) {
2669
2755
  try {
2670
2756
  const adapter = await getAdapterForExchange(exName);
@@ -2675,30 +2761,39 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2675
2761
  actualFundingByExSymbol.set(key, (actualFundingByExSymbol.get(key) ?? 0) + Number(fp.payment));
2676
2762
  }
2677
2763
  }
2678
- catch {
2679
- // exchange not configured or API error, skip
2764
+ catch (e) {
2765
+ fundingErrors[exName] = e instanceof Error ? e.message : String(e);
2680
2766
  }
2681
2767
  }
2768
+ /** Settled funding of `symbol` across its legs; null when a leg's venue history was not read. */
2769
+ const actualFundingOf = (symbol, positions) => {
2770
+ let sum = 0;
2771
+ for (const p of positions) {
2772
+ if (fundingErrors[p.exchange] !== undefined)
2773
+ return null;
2774
+ sum += actualFundingByExSymbol.get(`${p.exchange}:${symbol}`) ?? 0; // read, no payment for this symbol = 0
2775
+ }
2776
+ return sum;
2777
+ };
2682
2778
  if (isJson()) {
2683
2779
  const result = [...bySymbol.entries()].map(([symbol, positions]) => {
2684
2780
  const spread = spreadMap.get(symbol);
2685
- // Sum actual funding across exchanges for this symbol
2686
- let actualFunding = 0;
2687
- for (const p of positions) {
2688
- actualFunding += actualFundingByExSymbol.get(`${p.exchange}:${symbol}`) ?? 0;
2689
- }
2690
- return { symbol, positions, currentSpread: spread?.spread ?? 0, actualFunding };
2781
+ const failed = positions.map(p => fundingErrors[p.exchange] === undefined ? null : `${p.exchange}: ${fundingErrors[p.exchange]}`).filter((x) => x !== null);
2782
+ return { symbol, positions, pairStatus: arbPnlPairStatus(positions, failedVenues), currentSpread: spread === undefined ? null : spread.spread, actualFunding: actualFundingOf(symbol, positions), fundingError: failed.length > 0 ? failed.join("; ") : null };
2691
2783
  });
2692
- return printJson(jsonOk(result));
2784
+ return printJson(jsonOk({ pairs: result, positionErrors, notConfigured }));
2693
2785
  }
2694
2786
  let totalUpnl = 0;
2695
2787
  let totalFees = 0;
2696
2788
  let totalEstFunding = 0;
2697
2789
  let totalActualFunding = 0;
2790
+ let actualFundingIncomplete = false;
2698
2791
  for (const [symbol, positions] of bySymbol) {
2699
- const isArb = positions.length >= 2 && positions.some(p => p.side === "long") && positions.some(p => p.side === "short");
2792
+ const pairStatus = arbPnlPairStatus(positions, failedVenues);
2700
2793
  const spread = spreadMap.get(symbol);
2701
- console.log(chalk.white.bold(` ${symbol}`) + (isArb ? chalk.green(" [ARB PAIR]") : chalk.gray(" [single]")));
2794
+ console.log(chalk.white.bold(` ${symbol}`) + (pairStatus === "arb" ? chalk.green(" [ARB PAIR]")
2795
+ : pairStatus === "unknown" ? chalk.yellow(` [UNKNOWN — ${failedVenues.join(", ")} positions unread]`)
2796
+ : chalk.gray(" [single]")));
2702
2797
  for (const p of positions) {
2703
2798
  const sideColor = p.side === "long" ? chalk.green : chalk.red;
2704
2799
  const pnlColor = p.upnl >= 0 ? chalk.green : chalk.red;
@@ -2734,19 +2829,22 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2734
2829
  const holdHours = entryTime ? (Date.now() - entryTime) / (1000 * 60 * 60) : null;
2735
2830
  // Estimated funding based on current spread × hold time
2736
2831
  const estFunding = holdHours !== null ? hourlyIncome * holdHours : 0;
2737
- // Actual settled funding from exchange APIs
2738
- let actualFunding = 0;
2739
- for (const p of positions) {
2740
- actualFunding += actualFundingByExSymbol.get(`${p.exchange}:${symbol}`) ?? 0;
2741
- }
2832
+ // Actual settled funding from exchange APIs (null = a leg's venue history was not read)
2833
+ const actualFunding = actualFundingOf(symbol, positions);
2742
2834
  totalEstFunding += estFunding;
2743
- totalActualFunding += actualFunding;
2744
- const diff = actualFunding - estFunding;
2745
- const diffColor = Math.abs(diff) < 0.01 ? chalk.gray : diff >= 0 ? chalk.green : chalk.red;
2746
2835
  const fmtVal = (v) => v >= 0 ? `$${v.toFixed(4)}` : `-$${Math.abs(v).toFixed(4)}`;
2747
- console.log(chalk.white(` Funding: Est. ${chalk.yellow(fmtVal(estFunding))} / ` +
2748
- `Actual ${chalk.cyan(fmtVal(actualFunding))} / ` +
2749
- `Diff: ${diffColor(fmtVal(diff))}`));
2836
+ if (actualFunding === null) {
2837
+ actualFundingIncomplete = true;
2838
+ console.log(chalk.white(` Funding: Est. ${chalk.yellow(fmtVal(estFunding))} / Actual ${chalk.yellow("unavailable")}`));
2839
+ }
2840
+ else {
2841
+ totalActualFunding += actualFunding;
2842
+ const diff = actualFunding - estFunding;
2843
+ const diffColor = Math.abs(diff) < 0.01 ? chalk.gray : diff >= 0 ? chalk.green : chalk.red;
2844
+ console.log(chalk.white(` Funding: Est. ${chalk.yellow(fmtVal(estFunding))} / ` +
2845
+ `Actual ${chalk.cyan(fmtVal(actualFunding))} / ` +
2846
+ `Diff: ${diffColor(fmtVal(diff))}`));
2847
+ }
2750
2848
  }
2751
2849
  }
2752
2850
  console.log();
@@ -2764,10 +2862,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2764
2862
  const fundingDiff = totalActualFunding - totalEstFunding;
2765
2863
  const diffPct = totalEstFunding !== 0 ? ((fundingDiff / Math.abs(totalEstFunding)) * 100).toFixed(1) : "N/A";
2766
2864
  console.log(` Est. funding: ${chalk.yellow(`$${totalEstFunding.toFixed(4)}`)}`);
2767
- console.log(` Actual funding: ${chalk.cyan(`$${totalActualFunding.toFixed(4)}`)}`);
2865
+ console.log(` Actual funding: ${chalk.cyan(`$${totalActualFunding.toFixed(4)}`)}${actualFundingIncomplete ? chalk.yellow(" (incomplete: some venues' funding history unavailable)") : ""}`);
2768
2866
  console.log(` Funding diff: ${fundingDiff >= 0 ? chalk.green(`+$${fundingDiff.toFixed(4)}`) : chalk.red(`-$${Math.abs(fundingDiff).toFixed(4)}`)} (${diffPct}%)`);
2769
2867
  }
2770
- console.log(` Net (if closed now): ${netColor(`$${netPnl.toFixed(4)}`)}`);
2868
+ console.log(` Net (if closed now): ${netColor(`$${netPnl.toFixed(4)}`)}${actualFundingIncomplete ? chalk.yellow(" (excludes unread funding)") : ""}`);
2869
+ for (const [ex, msg] of Object.entries(fundingErrors))
2870
+ console.log(chalk.yellow(` ${ex} funding history unavailable: ${msg}`));
2771
2871
  console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker. Actual may vary.)`));
2772
2872
  console.log(chalk.gray(` * Net includes actual settled funding where available.\n`));
2773
2873
  }
@@ -2787,10 +2887,13 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2787
2887
  cycle++;
2788
2888
  const ts = new Date().toLocaleTimeString();
2789
2889
  try {
2790
- const pairs = await scanDexArb({
2890
+ const { pairs, failedDexes } = await scanDexArb({
2791
2891
  minAnnualSpread: minSpread,
2792
2892
  includeNative: opts.includeNative,
2793
2893
  });
2894
+ // Failed dex reads are reported, never shown as "no opportunities" for that dex.
2895
+ for (const [dex, msg] of Object.entries(failedDexes))
2896
+ console.error(chalk.yellow(` ${ts} HIP-3 dex ${dex} unavailable: ${msg} — its pairs are missing this cycle`));
2794
2897
  const shown = pairs.slice(0, topN);
2795
2898
  // Clear previous output
2796
2899
  if (cycle > 1) {
@@ -2859,7 +2962,22 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2859
2962
  // Positions live only in memory: a restart re-reads what is held on the dexes, so a
2860
2963
  // held underlying is managed (closed when the spread converges), never re-entered.
2861
2964
  // Unpaired / size-mismatched legs are quarantined. An unreadable dex → not started.
2862
- const assets = await fetchAllDexAssets();
2965
+ // A transient 429 / timeout on one of the deployed dexes is retried (2 s, 5 s) before
2966
+ // refusing: a partial read is never cached, so each attempt re-reads every dex.
2967
+ let { assets, failedDexes: startFailed } = await fetchAllDexAssetsDetailed();
2968
+ for (const waitMs of [2_000, 5_000]) {
2969
+ if (Object.keys(startFailed).length === 0)
2970
+ break;
2971
+ console.error(chalk.yellow(` HIP-3 dex read failed at start (${Object.keys(startFailed).join(", ")}) — retrying in ${waitMs / 1000}s`));
2972
+ await new Promise(r => setTimeout(r, waitMs));
2973
+ ({ assets, failedDexes: startFailed } = await fetchAllDexAssetsDetailed());
2974
+ }
2975
+ if (Object.keys(startFailed).length > 0) {
2976
+ // A dex not read → its held legs are unknown: not started (never re-enter a held pair).
2977
+ throw new PerpError("EXCHANGE_ERROR", `arb auto --hip3: dex market data unreadable at start (${Object.entries(startFailed).map(([d, m]) => `${d}: ${m}`).join("; ")}) — not started (held pairs unknown)`, {
2978
+ exchange: "hyperliquid", remediation: `Check connectivity (perp arb scan --hip3), then restart`,
2979
+ });
2980
+ }
2863
2981
  const pairsNow = findDexArbPairs(assets, { minAnnualSpread: 0, includeNative: true });
2864
2982
  const legs = new Map();
2865
2983
  for (const dex of [...new Set(assets.map(a => a.dex))]) {
@@ -2931,16 +3049,24 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2931
3049
  }
2932
3050
  const cycle = async () => {
2933
3051
  try {
2934
- const pairs = await scanDexArb({
3052
+ const { pairs, failedDexes } = await scanDexArb({
2935
3053
  minAnnualSpread: 0, // get all, filter ourselves
2936
3054
  includeNative: true,
2937
3055
  });
2938
3056
  const now = new Date().toLocaleTimeString();
3057
+ for (const [dex, msg] of Object.entries(failedDexes))
3058
+ console.error(chalk.yellow(` ${now} HIP-3 dex ${dex} unavailable: ${msg}`));
2939
3059
  // Check close conditions
2940
3060
  for (let i = openPositions.length - 1; i >= 0; i--) {
2941
3061
  if (stop.requested())
2942
3062
  break; // stopping: no new close sequence
2943
3063
  const pos = openPositions[i];
3064
+ // A leg's dex not read this cycle → spread unknown: never close on missing data.
3065
+ const unreadDex = dexCloseBlockedBy(pos, failedDexes);
3066
+ if (unreadDex !== null) {
3067
+ console.error(chalk.yellow(` ${now} dex ${unreadDex} unavailable — ${pos.underlying} position kept, re-checked next cycle`));
3068
+ continue;
3069
+ }
2944
3070
  // Find current pair for this position
2945
3071
  const current = pairs.find(p => p.underlying === pos.underlying &&
2946
3072
  ((p.long.dex === pos.longDex && p.short.dex === pos.shortDex) ||
@@ -28,34 +28,33 @@ async function fetchHyperliquidRates() {
28
28
  async function fetchAsterRates() {
29
29
  // SSOT rule #2: do NOT silently coerce missing markPrice / lastFundingRate
30
30
  // to 0 — that would corrupt comparisons against other DEXs. Skip symbols
31
- // whose Aster row is malformed instead. The outer try/catch is intentional:
32
- // if the entire endpoint fails, the arb command can still compare the
33
- // remaining DEXs (graceful degradation is an explicit caller-facing
34
- // behavior, not a silent data substitution).
35
- try {
36
- const premiums = await fetch("https://fapi.asterdex.com/fapi/v1/premiumIndex").then(r => r.json());
37
- const out = [];
38
- for (const p of premiums ?? []) {
39
- const rawSym = String(p.symbol ?? "");
40
- if (!rawSym.endsWith("USDT"))
41
- continue;
42
- const fundingRate = Number(p.lastFundingRate);
43
- const markPrice = Number(p.markPrice);
44
- if (!Number.isFinite(fundingRate) || !Number.isFinite(markPrice) || markPrice <= 0) {
45
- continue;
46
- }
47
- out.push({
48
- exchange: "aster",
49
- symbol: rawSym.replace(/USDT$/, ""),
50
- fundingRate,
51
- markPrice,
52
- });
31
+ // whose Aster row is malformed instead. An endpoint failure PROPAGATES: the
32
+ // caller (handleBasisScan) runs every DEX under Promise.allSettled and reports
33
+ // the rejection on stderr — Aster is never silently absent from the scan.
34
+ const res = await fetch("https://fapi.asterdex.com/fapi/v1/premiumIndex");
35
+ if (!res.ok)
36
+ throw new Error(`Aster premiumIndex HTTP ${res.status}`);
37
+ const premiums = await res.json();
38
+ if (!Array.isArray(premiums))
39
+ throw new Error("Aster premiumIndex returned a non-array response");
40
+ const out = [];
41
+ for (const p of premiums) {
42
+ const rawSym = String(p.symbol ?? "");
43
+ if (!rawSym.endsWith("USDT"))
44
+ continue;
45
+ const fundingRate = Number(p.lastFundingRate);
46
+ const markPrice = Number(p.markPrice);
47
+ if (!Number.isFinite(fundingRate) || !Number.isFinite(markPrice) || markPrice <= 0) {
48
+ continue;
53
49
  }
54
- return out;
55
- }
56
- catch {
57
- return [];
50
+ out.push({
51
+ exchange: "aster",
52
+ symbol: rawSym.replace(/USDT$/, ""),
53
+ fundingRate,
54
+ markPrice,
55
+ });
58
56
  }
57
+ return out;
59
58
  }
60
59
  async function fetchLighterRates() {
61
60
  // SSOT rule #2: error must propagate. Caller (handleBasisScan) uses
@@ -450,7 +449,10 @@ export async function handleBasisScan(isJson, opts) {
450
449
  export async function handleDexScan(isJson, opts) {
451
450
  if (!isJson())
452
451
  console.log(chalk.cyan(" Scanning HIP-3 deployed dex funding spreads...\n"));
453
- const pairs = await scanDexArb({ minAnnualSpread: parseFloat(opts.minSpread), maxPriceGapPct: parseFloat(opts.maxGap), includeNative: opts.includeNative });
452
+ const { pairs, failedDexes } = await scanDexArb({ minAnnualSpread: parseFloat(opts.minSpread), maxPriceGapPct: parseFloat(opts.maxGap), includeNative: opts.includeNative });
453
+ // A dex whose read failed is reported (stderr, both modes — JSON stdout keeps its shape); its pairs are unknown, not absent.
454
+ for (const [dex, msg] of Object.entries(failedDexes))
455
+ process.stderr.write(`[dex-scan] HIP-3 dex ${dex} unavailable: ${msg} — its pairs are missing from this scan\n`);
454
456
  const topN = parseInt(opts.top);
455
457
  const shown = pairs.slice(0, topN);
456
458
  if (isJson())
@@ -543,10 +545,11 @@ export async function handleFundingPositions(isJson, getAdapterForExchange, opts
543
545
  const results = [];
544
546
  const exchangeErrors = {};
545
547
  const fundingUnknown = [];
548
+ /* venue → why its funding history could not be read; its positions get actual* = null (never 0) */ const fundingHistoryErrors = {};
546
549
  await Promise.all(targetExchanges.map(async (exchange) => {
547
550
  try {
548
551
  const adapter = await getAdapterForExchange(exchange);
549
- const [positions, markets, fp] = await Promise.all([adapter.getPositions(), adapter.getMarkets(), adapter.getFundingPayments(100).catch(() => [])]);
552
+ const [positions, markets, fp] = await Promise.all([adapter.getPositions(), adapter.getMarkets(), adapter.getFundingPayments(100).then((rows) => rows, (e) => { fundingHistoryErrors[exchange] = e instanceof Error ? e.message : String(e); return null; })]);
550
553
  if (positions.length === 0)
551
554
  return;
552
555
  /* rate null = the venue reports no funding rate for the market (never read as 0) */ const fm = new Map();
@@ -557,11 +560,15 @@ export async function handleFundingPositions(isJson, getAdapterForExchange, opts
557
560
  }
558
561
  const dayAgo = Date.now() - 86400000;
559
562
  const af = new Map();
560
- for (const f of fp) {
563
+ for (const f of fp ?? []) {
561
564
  if (f.time < dayAgo)
562
565
  continue;
563
566
  const sym = f.symbol.toUpperCase().replace(/-PERP$/, "");
564
- const amt = Number(f.payment) || 0;
567
+ const amt = Number(f.payment);
568
+ if (!Number.isFinite(amt)) {
569
+ fundingHistoryErrors[exchange] = `non-numeric funding payment ${JSON.stringify(f.payment)}`;
570
+ continue;
571
+ }
565
572
  if (!af.has(sym))
566
573
  af.set(sym, { received: 0, paid: 0 });
567
574
  const e = af.get(sym);
@@ -570,6 +577,7 @@ export async function handleFundingPositions(isJson, getAdapterForExchange, opts
570
577
  else
571
578
  e.paid += Math.abs(amt);
572
579
  }
580
+ /* a venue whose history was unreadable or had an unparsable row: actual* = null (never a partial sum shown as complete) */ const histBad = fp === null || fundingHistoryErrors[exchange] !== undefined;
573
581
  for (const pos of positions) {
574
582
  const sym = pos.symbol.toUpperCase().replace(/-PERP$/, "");
575
583
  const fd = fm.get(sym);
@@ -578,7 +586,12 @@ export async function handleFundingPositions(isJson, getAdapterForExchange, opts
578
586
  continue;
579
587
  }
580
588
  const fr = fd.rate;
581
- const mk = Number(pos.markPrice) || fd?.markPrice || 0;
589
+ const posMark = Number(pos.markPrice);
590
+ const mk = Number.isFinite(posMark) && posMark > 0 ? posMark : fd.markPrice;
591
+ if (!(mk > 0)) {
592
+ fundingUnknown.push(`${exchange}:${sym} (no mark price)`);
593
+ continue;
594
+ }
582
595
  const sz = Math.abs(Number(pos.size));
583
596
  const nu = sz * mk;
584
597
  const fh = fd ? fd.fundingHours : null; /* market basis first (§10.1) */
@@ -586,7 +599,7 @@ export async function handleFundingPositions(isJson, getAdapterForExchange, opts
586
599
  const ap = annualizeHourlyRate(hr);
587
600
  const hp = estimateHourlyFundingFromHourly(hr, nu, pos.side);
588
601
  const a = af.get(sym);
589
- results.push({ exchange, symbol: sym, side: pos.side, size: pos.size, entryPrice: pos.entryPrice, markPrice: pos.markPrice, unrealizedPnl: pos.unrealizedPnl, leverage: pos.leverage, notionalUsd: nu, fundingRate: fr, hourlyRate: hr, annualPct: ap, hourlyPayment: hp, dailyPayment: hp * 24, actualReceived24h: a?.received ?? 0, actualPaid24h: a?.paid ?? 0, actualNet24h: (a?.received ?? 0) - (a?.paid ?? 0) });
602
+ results.push({ exchange, symbol: sym, side: pos.side, size: pos.size, entryPrice: pos.entryPrice, markPrice: pos.markPrice, unrealizedPnl: pos.unrealizedPnl, leverage: pos.leverage, notionalUsd: nu, fundingRate: fr, hourlyRate: hr, annualPct: ap, hourlyPayment: hp, dailyPayment: hp * 24, actualReceived24h: histBad ? null : a?.received ?? 0, actualPaid24h: histBad ? null : a?.paid ?? 0, actualNet24h: histBad ? null : (a?.received ?? 0) - (a?.paid ?? 0) });
590
603
  }
591
604
  }
592
605
  catch (err) {
@@ -603,15 +616,18 @@ export async function handleFundingPositions(isJson, getAdapterForExchange, opts
603
616
  const tH = results.reduce((s, r) => s + r.hourlyPayment, 0);
604
617
  const tD = results.reduce((s, r) => s + r.dailyPayment, 0);
605
618
  const tN = results.reduce((s, r) => s + r.notionalUsd, 0);
606
- const tA = results.reduce((s, r) => s + r.actualNet24h, 0);
619
+ const tA = results.reduce((s, r) => s + (r.actualNet24h ?? 0), 0);
620
+ const actualIncomplete = results.some(r => r.actualNet24h === null);
607
621
  if (isJson())
608
- return printJson(jsonOk({ positions: results.map(r => ({ exchange: r.exchange, symbol: r.symbol, side: r.side, size: r.size, notionalUsd: +r.notionalUsd.toFixed(2), fundingRate: r.fundingRate, annualPct: +r.annualPct.toFixed(2), predicted: { hourly: +r.hourlyPayment.toFixed(6), daily: +r.dailyPayment.toFixed(4) }, actual24h: { received: +r.actualReceived24h.toFixed(6), paid: +r.actualPaid24h.toFixed(6), net: +r.actualNet24h.toFixed(6) }, unrealizedPnl: r.unrealizedPnl })), totals: { predicted: { hourly: +tH.toFixed(6), daily: +tD.toFixed(4) }, actual24h: { net: +tA.toFixed(6) }, notionalUsd: +tN.toFixed(2) }, fundingUnknown, errors: Object.keys(exchangeErrors).length > 0 ? exchangeErrors : undefined }));
622
+ return printJson(jsonOk({ positions: results.map(r => ({ exchange: r.exchange, symbol: r.symbol, side: r.side, size: r.size, notionalUsd: +r.notionalUsd.toFixed(2), fundingRate: r.fundingRate, annualPct: +r.annualPct.toFixed(2), predicted: { hourly: +r.hourlyPayment.toFixed(6), daily: +r.dailyPayment.toFixed(4) }, actual24h: r.actualNet24h === null ? null : { received: +(r.actualReceived24h ?? 0).toFixed(6), paid: +(r.actualPaid24h ?? 0).toFixed(6), net: +r.actualNet24h.toFixed(6) }, unrealizedPnl: r.unrealizedPnl })), totals: { predicted: { hourly: +tH.toFixed(6), daily: +tD.toFixed(4) }, actual24h: { net: +tA.toFixed(6), incomplete: actualIncomplete }, notionalUsd: +tN.toFixed(2) }, fundingUnknown, fundingHistoryErrors, errors: Object.keys(exchangeErrors).length > 0 ? exchangeErrors : undefined }));
609
623
  const exAbbr = venueAbbr;
610
624
  console.log(chalk.cyan.bold(" Position Funding Impact\n"));
611
- const rows = results.map(r => { const sc = r.side === "long" ? chalk.green : chalk.red; const rc = r.fundingRate > 0 ? chalk.red : r.fundingRate < 0 ? chalk.green : chalk.white; const as = r.actualNet24h !== 0 ? (r.actualNet24h > 0 ? chalk.green : chalk.red)(`${r.actualNet24h > 0 ? "+" : ""}$${r.actualNet24h.toFixed(4)}`) : chalk.gray("-"); return [chalk.white.bold(exAbbr(r.exchange)), chalk.white.bold(r.symbol), sc(r.side.toUpperCase()), `$${formatUsd(r.notionalUsd)}`, rc(formatPercent(r.fundingRate)), rc(`${r.annualPct.toFixed(1)}%`), as, formatPnl(r.unrealizedPnl)]; });
625
+ const rows = results.map(r => { const sc = r.side === "long" ? chalk.green : chalk.red; const rc = r.fundingRate > 0 ? chalk.red : r.fundingRate < 0 ? chalk.green : chalk.white; const as = r.actualNet24h === null ? chalk.yellow("unavailable") : r.actualNet24h !== 0 ? (r.actualNet24h > 0 ? chalk.green : chalk.red)(`${r.actualNet24h > 0 ? "+" : ""}$${r.actualNet24h.toFixed(4)}`) : chalk.gray("-"); return [chalk.white.bold(exAbbr(r.exchange)), chalk.white.bold(r.symbol), sc(r.side.toUpperCase()), `$${formatUsd(r.notionalUsd)}`, rc(formatPercent(r.fundingRate)), rc(`${r.annualPct.toFixed(1)}%`), as, formatPnl(r.unrealizedPnl)]; });
612
626
  console.log(makeTable(["Ex", "Symbol", "Side", "Notional", "Rate(now)", "Annual(now)", "Actual 24h", "uPnL"], rows));
613
627
  console.log(`\n Total: $${formatUsd(tN)} | Actual 24h: ${tA >= 0 ? "+" : ""}$${tA.toFixed(4)} | Predicted: ${tD >= 0 ? "-" : "+"}$${Math.abs(tD).toFixed(4)}/d\n`);
614
628
  if (fundingUnknown.length > 0)
615
629
  console.log(chalk.yellow(` Funding rate not published (excluded, not counted as 0): ${fundingUnknown.join(", ")}\n`));
630
+ for (const [ex, msg] of Object.entries(fundingHistoryErrors))
631
+ console.log(chalk.yellow(` ${ex} funding history unavailable (Actual 24h excluded, not 0): ${msg}`));
616
632
  });
617
633
  }
@@ -1,3 +1,9 @@
1
1
  import { Command } from "commander";
2
2
  import type { ExchangeAdapter } from "../exchanges/index.js";
3
3
  export declare function registerStrategyCommands(program: Command, getAdapter: () => Promise<ExchangeAdapter>, getAdapterFor: (exchange: string) => Promise<ExchangeAdapter>, isJson: () => boolean): void;
4
+ /**
5
+ * Adapters for a multi-venue bot. An explicitly passed list (--exchanges on the command line)
6
+ * must load in full: a venue that fails refuses the start with a PerpError naming it. A default
7
+ * list skips a failing venue with a stderr warning naming it and the cause.
8
+ */
9
+ export declare function loadBotVenueAdapters(names: string[], explicit: boolean, getAdapterFor: (exchange: string) => Promise<ExchangeAdapter>): Promise<Map<string, ExchangeAdapter>>;