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
@@ -162,17 +162,10 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
162
162
  .option("--background", "Run in background (tmux)")
163
163
  .option("--headless", "Run without TUI dashboard")
164
164
  .option("--job-id <id>", "Job ID")
165
- .action(async (opts) => {
166
- const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
167
- const adapters = new Map();
168
- for (const name of exchangeNames) {
169
- try {
170
- adapters.set(name, await getAdapterFor(name));
171
- }
172
- catch {
173
- // skip unavailable
174
- }
175
- }
165
+ .action(async (opts, cmd) => {
166
+ const adapters = await loadBotVenueAdapters(opts.exchanges.split(",").map(e => e.trim()), cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
167
+ // The strategy scans only the venues that loaded.
168
+ const exchangeNames = [...adapters.keys()];
176
169
  if (adapters.size < 2) {
177
170
  console.error(chalk.red("\n Need at least 2 exchanges for arbitrage.\n"));
178
171
  return;
@@ -207,7 +200,8 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
207
200
  `strategy`, `quick-arb`,
208
201
  `--min-spread`, opts.minSpread, `--close-spread`, opts.closeSpread,
209
202
  `--size`, opts.size, `--max-positions`, opts.maxPositions,
210
- `--exchanges`, opts.exchanges, `--interval`, opts.interval,
203
+ // The venues the parent loaded (a skipped default venue is not re-tried as "explicit").
204
+ `--exchanges`, exchangeNames.join(","), `--interval`, opts.interval,
211
205
  `--max-drawdown`, opts.maxDrawdown,
212
206
  ];
213
207
  const job = startJob({
@@ -241,17 +235,10 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
241
235
  .option("--background", "Run in background (tmux)")
242
236
  .option("--headless", "Run without TUI dashboard")
243
237
  .option("--job-id <id>", "Job ID")
244
- .action(async (opts) => {
245
- const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
246
- const adapters = new Map();
247
- for (const name of exchangeNames) {
248
- try {
249
- adapters.set(name, await getAdapterFor(name));
250
- }
251
- catch {
252
- // skip unavailable
253
- }
254
- }
238
+ .action(async (opts, cmd) => {
239
+ const adapters = await loadBotVenueAdapters(opts.exchanges.split(",").map(e => e.trim()), cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
240
+ // The strategy trades only the venues that loaded.
241
+ const exchangeNames = [...adapters.keys()];
255
242
  if (adapters.size < 1) {
256
243
  console.error(chalk.red("\n Need at least 1 exchange for delta-neutral.\n"));
257
244
  return;
@@ -287,7 +274,7 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
287
274
  `strategy`, `delta-neutral`,
288
275
  `--min-rate`, opts.minRate, `--close-rate`, opts.closeRate,
289
276
  `--size`, opts.size, `--max-positions`, opts.maxPositions,
290
- `--leverage`, opts.leverage, `--exchanges`, opts.exchanges,
277
+ `--leverage`, opts.leverage, `--exchanges`, exchangeNames.join(","),
291
278
  `--interval`, opts.interval, `--max-drawdown`, opts.maxDrawdown,
292
279
  ];
293
280
  const job = startJob({
@@ -355,15 +342,9 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
355
342
  const built = preset.buildConfig("multi", sym);
356
343
  const config = opts.size !== undefined ? withPresetSize(built, opts.size) : built;
357
344
  const arbStrategy = config.strategy;
358
- const adapters = new Map();
359
- for (const exName of arbStrategy.exchanges) {
360
- try {
361
- adapters.set(exName, await getAdapterFor(exName));
362
- }
363
- catch {
364
- // skip unavailable
365
- }
366
- }
345
+ // The preset's venue list is a default (not user-passed): a failing venue is skipped with a warning.
346
+ const adapters = await loadBotVenueAdapters(arbStrategy.exchanges, false, getAdapterFor);
347
+ arbStrategy.exchanges = [...adapters.keys()];
367
348
  if (adapters.size < 2) {
368
349
  console.error(chalk.red("\n Need at least 2 exchanges for arbitrage.\n"));
369
350
  return;
@@ -756,6 +737,44 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
756
737
  // Nested scripted plans: `perp strategy plan {validate|execute|example}`
757
738
  registerStrategyPlanCommands(bot, getAdapter, isJson);
758
739
  }
740
+ /**
741
+ * Adapters for a multi-venue bot. An explicitly passed list (--exchanges on the command line)
742
+ * must load in full: a venue that fails refuses the start with a PerpError naming it. A default
743
+ * list skips a failing venue with a stderr warning naming it and the cause.
744
+ */
745
+ export async function loadBotVenueAdapters(names, explicit, getAdapterFor) {
746
+ const adapters = new Map();
747
+ for (const name of names) {
748
+ try {
749
+ const a = await getAdapterFor(name);
750
+ // A venue without credentials gets a read-only adapter: it can neither trade nor report
751
+ // the bot's balances, so it is "not configured" — refused when listed explicitly.
752
+ if (a.isReadOnly === true) {
753
+ if (explicit) {
754
+ throw new PerpError("NO_SIGNER_AVAILABLE", `--exchanges venue ${name} has no credentials configured`, {
755
+ exchange: name, remediation: `Set it up (perp setup) or remove it from --exchanges`,
756
+ });
757
+ }
758
+ process.stderr.write(`[info] ${name} not configured (no credentials) — the bot runs without it\n`);
759
+ continue;
760
+ }
761
+ adapters.set(name, a);
762
+ }
763
+ catch (e) {
764
+ if (e instanceof PerpError && e.structured.code === "NO_SIGNER_AVAILABLE" && explicit)
765
+ throw e;
766
+ const cause = e instanceof Error ? e.message : String(e);
767
+ if (explicit) {
768
+ throw new PerpError(e instanceof PerpError ? e.structured.code : "EXCHANGE_ERROR", `--exchanges venue ${name} could not be initialised: ${cause}`, {
769
+ exchange: name,
770
+ remediation: `Fix ${name}'s setup (credentials / network: perp -e ${name} portfolio), or remove it from --exchanges`,
771
+ });
772
+ }
773
+ process.stderr.write(`[warn] ${name} unavailable (${cause}) — the bot runs without it\n`);
774
+ }
775
+ }
776
+ return adapters;
777
+ }
759
778
  function makeLog() {
760
779
  return (msg) => {
761
780
  const ts = new Date().toLocaleTimeString();
@@ -898,19 +917,10 @@ function registerRunSubcommands(parent, getAdapter, getAdapterFor, isJson) {
898
917
  .option("--rebalance-threshold <usd>", "Rebalance when available < this ($)", "100")
899
918
  .option("--max-drawdown <usd>", "Close all positions if total uPnL exceeds this loss ($)")
900
919
  .option("--job-id <id>", "Job ID (set automatically for background jobs)")
901
- .action(async (opts) => {
920
+ .action(async (opts, cmd) => {
902
921
  const exchangeNames = opts.exchanges.split(",").map((e) => e.trim());
903
- const adapters = new Map();
904
- for (const name of exchangeNames) {
905
- try {
906
- const a = await getAdapterFor(name);
907
- adapters.set(name, a);
908
- }
909
- catch {
910
- if (!isJson())
911
- console.log(chalk.yellow(` Skipping ${name} (no credentials)`));
912
- }
913
- }
922
+ // An explicitly listed venue that fails to load refuses the start; a default one is skipped with a warning.
923
+ const adapters = await loadBotVenueAdapters(exchangeNames, cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
914
924
  if (adapters.size < 2) {
915
925
  console.error(chalk.red("Need at least 2 exchanges for arbitrage. Set credentials for 2+ exchanges."));
916
926
  process.exit(1);
@@ -238,11 +238,12 @@ export function registerOnchainFundsCommands(ctx) {
238
238
  // Ondo Perps withdrawals are web-app only (the API does not allow them), so an address-book
239
239
  // entry made here would serve nothing. The command stays so the refusal is explicit.
240
240
  const addressBook = funds.command("address-book").description("Not available: the Ondo Perps withdrawal address book is managed in the web app");
241
- supportsDryRun(addressBook.command("add <venue> <address>"))
242
- .description("Not available: Ondo Perps withdrawals and their address book are web-app only (refused, also with --dry-run)")
241
+ // Optional arguments: a bare `address-book add` gets the same explicit refusal, not a usage error.
242
+ supportsDryRun(addressBook.command("add [venue] [address]"))
243
+ .description("Not available: no venue offers a withdrawal address book over the CLI (Ondo Perps: web app only; refused, also with --dry-run)")
243
244
  .option("--label <text>", "Ignored")
244
- .action(async (venue) => {
245
- const def = getVenue(venue);
246
- throw unsupported(def.displayName, "address-book", "the Ondo Perps API does not allow withdrawals, so the CLI keeps no withdrawal address book", "Manage withdrawals and the address book in the Ondo Perps web app");
245
+ // Venue-neutral: no venue has a CLI address book, so the arguments are not even resolved.
246
+ .action(async () => {
247
+ throw unsupported("perp-cli", "address-book", "perp-cli has no withdrawal address book for any venue (Ondo Perps withdrawals are web-app only)", "Manage withdrawals and address books in the venue's web app");
247
248
  });
248
249
  }
@@ -94,6 +94,13 @@ export function chainLabel(chain) {
94
94
  throw new Error(`no display label for chain "${chain}" (add it to CHAIN_LABELS)`);
95
95
  return label;
96
96
  }
97
+ /** Circle forwarding fee per HL CCTP destination, from HL_CCTP_ROUTES (null = not published). */
98
+ async function hlCctpFeeText() {
99
+ const { HL_CCTP_ROUTES } = await import("../exchanges/hyperliquid.js");
100
+ return Object.entries(HL_CCTP_ROUTES)
101
+ .map(([chain, r]) => `${chain}: ${r.forwardingFeeUsdc === null ? "fee not published" : `${r.forwardingFeeUsdc} USDC`}`)
102
+ .join(", ");
103
+ }
97
104
  /** Withdraw asset declared by the venue definition (capabilities.withdrawAssets[0]). */
98
105
  function withdrawAssetOf(def) {
99
106
  const assets = requireCapabilities(def).withdrawAssets;
@@ -1345,11 +1352,13 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1345
1352
  },
1346
1353
  withdraw: {
1347
1354
  pacifica: { chain: "Solana", token: "USDC", speed: "~10s", fee: "none" },
1348
- hyperliquid: { chain: "Arbitrum", token: "USDC", speed: "minutes", fee: "$1" },
1355
+ hyperliquid: { chain: "Arbitrum (default) / Ethereum / Base", token: "USDC", speed: "minutes (CCTP attestation)", method: "CCTP (sendToEvmWithData)", fee: await hlCctpFeeText(), command: "perp funds withdraw hyperliquid <amount> [--chain arbitrum|ethereum|base]" },
1349
1356
  lighter: { chain: "Ethereum L1", token: "USDC", speed: "~12 hours (standard), minutes (fast)", fee: "varies" },
1350
1357
  "lighter-rh": { chain: "Robinhood Chain", token: "USDG", command: "perp -e lighter-rh funds withdraw lighter <amount>" },
1351
1358
  ondo: { supported: false, note: "the Ondo Perps API does not allow withdrawals — withdraw in the Ondo Perps web app" },
1352
- qfex: { supported: false, note: "QFEX withdrawals are disabled in perp-cli for now — withdraw in the QFEX web app" },
1359
+ qfex: QFEX_WITHDRAW_ENABLED
1360
+ ? { chain: chainLabel(QFEX_WITHDRAW_NETWORK), token: "USDC", command: "perp funds withdraw qfex <amount> --to <allowlisted address>", gate: "settings withdraw.qfex (allowlist, maxPerTx, maxPerDay)", note: "needs a key with the \"Deposit and withdraw\" permission" }
1361
+ : { supported: false, note: `${QFEX_WITHDRAW_DISABLED.reason} — ${QFEX_WITHDRAW_DISABLED.remediation}` },
1353
1362
  arcus: { chain: "Robinhood Chain", token: "USDG", command: "perp funds withdraw arcus <amount>" },
1354
1363
  aster: { chain: "BNB Chain (default) / Arbitrum", token: "USDT (default) / USDC", command: "perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]", fee: "POST /fapi/v3/aster/user-withdraw-info (taken from the amount)", requires: "mainnet; API wallet approved with --can-withdraw --ip-whitelist; destination = the account's own address" },
1355
1364
  },
@@ -1390,9 +1399,9 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1390
1399
  console.log(chalk.white.bold(" Pacifica") + chalk.gray(" → Solana"));
1391
1400
  console.log(` Speed: ~10s | Fee: none`);
1392
1401
  console.log(` Command: ${chalk.green("perp funds withdraw pacifica <amount>")}`);
1393
- console.log(chalk.white.bold("\n Hyperliquid") + chalk.gray(" → Arbitrum"));
1394
- console.log(` Speed: minutes | Fee: ~$1`);
1395
- console.log(` Command: ${chalk.green("perp -e hl funds withdraw hyperliquid <amount>")}`);
1402
+ console.log(chalk.white.bold("\n Hyperliquid") + chalk.gray(" → Arbitrum (default) / Ethereum / Base over CCTP"));
1403
+ console.log(` Fee: ${await hlCctpFeeText()}`);
1404
+ console.log(` Command: ${chalk.green("perp funds withdraw hyperliquid <amount> [--chain arbitrum|ethereum|base]")}`);
1396
1405
  console.log(chalk.white.bold("\n Lighter") + chalk.gray(" → Ethereum L1"));
1397
1406
  console.log(` Speed: ~12h (standard) | Fee: varies`);
1398
1407
  console.log(` Command: ${chalk.green("perp -e lt funds withdraw lighter <amount>")}`);
@@ -1402,6 +1411,10 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
1402
1411
  console.log(chalk.gray(" Withdraw in the Ondo Perps web app."));
1403
1412
  console.log(chalk.white.bold("\n Arcus") + chalk.gray(" → Robinhood Chain (USDG, to the account owner)"));
1404
1413
  console.log(` Command: ${chalk.green("perp funds withdraw arcus <amount>")}`);
1414
+ console.log(chalk.white.bold("\n QFEX") + chalk.gray(QFEX_WITHDRAW_ENABLED ? ` → ${chainLabel(QFEX_WITHDRAW_NETWORK)} (USDC, allowlisted address)` : " — disabled in perp-cli for now"));
1415
+ console.log(QFEX_WITHDRAW_ENABLED
1416
+ ? ` Command: ${chalk.green("perp funds withdraw qfex <amount> --to <allowlisted address>")}`
1417
+ : chalk.gray(` ${QFEX_WITHDRAW_DISABLED.remediation}.`));
1405
1418
  console.log(chalk.white.bold("\n Aster") + chalk.gray(" → BNB Chain / Arbitrum (USDT, to the account's own address; mainnet only)"));
1406
1419
  console.log(` Needs an API wallet approved with --can-withdraw --ip-whitelist; fee from user-withdraw-info`);
1407
1420
  console.log(` Command: ${chalk.green("perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]")}`);
@@ -359,26 +359,39 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
359
359
  const limit = parseInt(opts.limit);
360
360
  const allTrades = [];
361
361
  const allFunding = [];
362
+ // A venue whose adapter / trades / funding read failed is listed here and left out of the
363
+ // totals (partial: true) — never counted as 0.
364
+ const errors = {};
365
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
362
366
  // Fetch trades + funding in parallel from all exchanges
363
367
  await Promise.allSettled(exchanges.map(async (ex) => {
368
+ let adapter;
364
369
  try {
365
- const adapter = await getAdapterForExchange(ex);
366
- const [trades, funding] = await Promise.all([
367
- adapter.getTradeHistory(limit),
368
- adapter.getFundingPayments(limit),
369
- ]);
370
- for (const t of trades) {
370
+ adapter = await getAdapterForExchange(ex);
371
+ }
372
+ catch (e) {
373
+ errors[`${ex}.adapter`] = errText(e);
374
+ return;
375
+ }
376
+ const [trades, funding] = await Promise.allSettled([adapter.getTradeHistory(limit), adapter.getFundingPayments(limit)]);
377
+ if (trades.status === "fulfilled") {
378
+ for (const t of trades.value) {
371
379
  if (sinceMs && t.time < sinceMs)
372
380
  continue;
373
381
  allTrades.push({ exchange: ex, symbol: t.symbol, side: t.side, price: Number(t.price), size: Number(t.size), fee: Number(t.fee), time: t.time });
374
382
  }
375
- for (const f of funding) {
383
+ }
384
+ else
385
+ errors[`${ex}.trades`] = errText(trades.reason);
386
+ if (funding.status === "fulfilled") {
387
+ for (const f of funding.value) {
376
388
  if (sinceMs && f.time < sinceMs)
377
389
  continue;
378
390
  allFunding.push({ exchange: ex, symbol: f.symbol, payment: Number(f.payment), time: f.time });
379
391
  }
380
392
  }
381
- catch { /* skip unavailable exchanges */ }
393
+ else
394
+ errors[`${ex}.funding`] = errText(funding.reason);
382
395
  }));
383
396
  // Aggregate by exchange
384
397
  const byExchange = new Map();
@@ -428,6 +441,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
428
441
  netPnl,
429
442
  byExchange: Object.fromEntries([...byExchange.entries()].map(([ex, d]) => [ex, { ...d, netPnl: d.funding - d.fees }])),
430
443
  daily,
444
+ errors,
445
+ partial: Object.keys(errors).length > 0,
431
446
  };
432
447
  if (isJson())
433
448
  return printJson(jsonOk(result));
@@ -438,6 +453,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
438
453
  console.log(` Trading Fees: ${chalk.red(`-$${formatUsd(totalFees)}`)}`);
439
454
  console.log(` Funding Income: ${formatPnl(totalFunding)}`);
440
455
  console.log(` Net P&L: ${formatPnl(netPnl)}`);
456
+ for (const [k, msg] of Object.entries(errors))
457
+ console.log(chalk.yellow(` ⚠ ${k} unavailable (left out of the totals): ${msg}`));
441
458
  if (byExchange.size > 0) {
442
459
  console.log(chalk.white.bold("\n By Exchange:"));
443
460
  const rows = [...byExchange.entries()].map(([ex, d]) => [
@@ -480,27 +497,44 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
480
497
  const allFunding = [];
481
498
  // Fetch funding payments and current positions in parallel
482
499
  const positionNotionals = new Map();
500
+ // A venue whose adapter / funding / positions read failed is listed here; its funding is
501
+ // left out of the totals (partial: true) — never counted as 0.
502
+ const errors = {};
503
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
483
504
  await Promise.allSettled(exchanges.map(async (ex) => {
505
+ let adapter;
484
506
  try {
485
- const adapter = await getAdapterForExchange(ex);
486
- const [payments, positions] = await Promise.all([
487
- adapter.getFundingPayments(parseInt(opts.limit)),
488
- adapter.getPositions(),
489
- ]);
490
- for (const p of payments) {
507
+ adapter = await getAdapterForExchange(ex);
508
+ }
509
+ catch (e) {
510
+ errors[`${ex}.adapter`] = errText(e);
511
+ return;
512
+ }
513
+ const [payments, positions] = await Promise.allSettled([
514
+ adapter.getFundingPayments(parseInt(opts.limit)),
515
+ adapter.getPositions(),
516
+ ]);
517
+ if (payments.status === "fulfilled") {
518
+ for (const p of payments.value) {
491
519
  if (sinceMs && p.time < sinceMs)
492
520
  continue;
493
521
  if (opts.symbol && !p.symbol.toUpperCase().includes(opts.symbol.toUpperCase()))
494
522
  continue;
495
523
  allFunding.push({ exchange: ex, symbol: p.symbol, payment: Number(p.payment), time: p.time });
496
524
  }
497
- for (const p of positions) {
525
+ }
526
+ else
527
+ errors[`${ex}.funding`] = errText(payments.reason);
528
+ // Positions only feed the annualized rate (null when the notional is unknown).
529
+ if (positions.status === "fulfilled") {
530
+ for (const p of positions.value) {
498
531
  if (Number(p.size) > 0) {
499
532
  positionNotionals.set(`${ex}:${p.symbol}`, Math.abs(Number(p.size) * Number(p.markPrice)));
500
533
  }
501
534
  }
502
535
  }
503
- catch { /* skip unavailable exchanges */ }
536
+ else
537
+ errors[`${ex}.positions`] = errText(positions.reason);
504
538
  }));
505
539
  allFunding.sort((a, b) => a.time - b.time);
506
540
  // Aggregate by exchange×symbol
@@ -556,16 +590,19 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
556
590
  const arbRows = arbPositions
557
591
  .map(pos => {
558
592
  const af = arbFunding.get(pos.symbol);
559
- if (!af || (af.longFunding === 0 && af.shortFunding === 0))
593
+ // A leg on a venue whose funding read failed is unknown (null), not 0.
594
+ const longFunding = af && !(`${pos.longExchange}.adapter` in errors || `${pos.longExchange}.funding` in errors) ? af.longFunding : null;
595
+ const shortFunding = af && !(`${pos.shortExchange}.adapter` in errors || `${pos.shortExchange}.funding` in errors) ? af.shortFunding : null;
596
+ if (!af || (longFunding === 0 && shortFunding === 0))
560
597
  return null;
561
598
  return {
562
599
  symbol: pos.symbol,
563
600
  mode: pos.mode ?? "perp-perp",
564
601
  longExchange: pos.longExchange,
565
602
  shortExchange: pos.shortExchange,
566
- netFunding: af.longFunding + af.shortFunding,
567
- longFunding: af.longFunding,
568
- shortFunding: af.shortFunding,
603
+ netFunding: longFunding !== null && shortFunding !== null ? longFunding + shortFunding : null,
604
+ longFunding,
605
+ shortFunding,
569
606
  daysSinceEntry: Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000)),
570
607
  };
571
608
  })
@@ -580,6 +617,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
580
617
  byExchange: Object.fromEntries(byExchange),
581
618
  arbPositions: arbRows,
582
619
  ...(opts.daily ? { daily } : {}),
620
+ errors,
621
+ partial: Object.keys(errors).length > 0,
583
622
  };
584
623
  if (isJson())
585
624
  return printJson(jsonOk(result));
@@ -588,6 +627,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
588
627
  console.log(` Total Payments: ${allFunding.length}`);
589
628
  console.log(` Net Funding: ${formatPnl(totalFunding)}`);
590
629
  console.log(` Period: ~${Math.round(periodDays)} days`);
630
+ for (const [k, msg] of Object.entries(errors))
631
+ console.log(chalk.yellow(` ⚠ ${k} unavailable (left out of the totals): ${msg}`));
591
632
  if (exSymRows.length > 0) {
592
633
  console.log(chalk.white.bold("\n By Exchange × Symbol:"));
593
634
  const rows = exSymRows.map(e => [
@@ -605,7 +646,7 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
605
646
  chalk.white.bold(a.symbol),
606
647
  `${a.longExchange} → ${a.shortExchange}`,
607
648
  a.mode,
608
- formatPnl(a.netFunding),
649
+ a.netFunding !== null ? formatPnl(a.netFunding) : chalk.yellow("unavailable"),
609
650
  `${a.daysSinceEntry}d`,
610
651
  ]);
611
652
  console.log(makeTable(["Symbol", "Long → Short", "Mode", "Net Funding", "Held"], rows));
@@ -650,6 +691,7 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
650
691
  // A venue whose read failed is reported here and left out of the totals — never counted as 0.
651
692
  const errors = {};
652
693
  const errText = (e) => (e instanceof Error ? e.message : String(e));
694
+ let venuesRead = 0;
653
695
  await Promise.allSettled(exchanges.map(async (ex) => {
654
696
  let adapter;
655
697
  try {
@@ -663,6 +705,7 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
663
705
  const balance = await adapter.getBalance();
664
706
  totalEquity += Number(balance.equity);
665
707
  totalUPnl += Number(balance.unrealizedPnl);
708
+ venuesRead++;
666
709
  }
667
710
  catch (e) {
668
711
  errors[`${ex}.balance`] = errText(e);
@@ -710,6 +753,10 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
710
753
  : "0",
711
754
  },
712
755
  errors,
756
+ // partial: some venue read failed — portfolio / costs cover only the venues that answered.
757
+ partial: Object.keys(errors).length > 0,
758
+ venuesRead,
759
+ venuesRequested: exchanges.length,
713
760
  };
714
761
  if (isJson())
715
762
  return printJson(jsonOk(report));
@@ -759,25 +806,49 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
759
806
  // Fetch funding + current positions from all relevant exchanges
760
807
  const fundingByExSym = new Map();
761
808
  const currentPrices = new Map();
809
+ // A venue whose adapter / funding / positions read failed is keyed here: rows touching it
810
+ // report null for the unknown part (funding / pricePnl), never 0 or the entry price.
811
+ const errors = {};
812
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
813
+ const fundingFailed = new Set();
762
814
  await Promise.allSettled([...exchangeSet].map(async (ex) => {
815
+ let adapter;
763
816
  try {
764
- const adapter = await getAdapterForExchange(ex);
765
- const [funding, livePositions] = await Promise.all([
766
- adapter.getFundingPayments(limit),
767
- adapter.getPositions(),
768
- ]);
769
- for (const f of funding) {
817
+ adapter = await getAdapterForExchange(ex);
818
+ }
819
+ catch (e) {
820
+ errors[`${ex}.adapter`] = errText(e);
821
+ fundingFailed.add(ex);
822
+ return;
823
+ }
824
+ const [funding, livePositions] = await Promise.allSettled([
825
+ adapter.getFundingPayments(limit),
826
+ adapter.getPositions(),
827
+ ]);
828
+ if (funding.status === "fulfilled") {
829
+ for (const f of funding.value) {
770
830
  if (sinceMs && f.time < sinceMs)
771
831
  continue;
772
832
  const key = `${ex}:${f.symbol}`;
773
833
  fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
774
834
  }
775
- for (const p of livePositions) {
835
+ }
836
+ else {
837
+ errors[`${ex}.funding`] = errText(funding.reason);
838
+ fundingFailed.add(ex);
839
+ }
840
+ if (livePositions.status === "fulfilled") {
841
+ for (const p of livePositions.value) {
776
842
  currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
777
843
  }
778
844
  }
779
- catch { /* skip */ }
845
+ else
846
+ errors[`${ex}.positions`] = errText(livePositions.reason);
780
847
  }));
848
+ // Funding of a venue that answered: the sum of its payments (none = 0). A failed venue: unknown (null).
849
+ const fundingOf = (ex, sym) => fundingFailed.has(ex) ? null : fundingByExSym.get(`${ex}:${sym}`) ?? 0;
850
+ // Current mark from the live position; none (failed read / no live leg) = unknown (null), never the entry price.
851
+ const priceOf = (ex, sym) => currentPrices.get(`${ex}:${sym}`) ?? null;
781
852
  // Entries without a verified entry price (a bot leg the venue reported no fill price for)
782
853
  // have no price PnL: listed apart, never priced at a guess.
783
854
  const priced = positions.filter((p) => p.entryLongPrice !== null && p.entryShortPrice !== null);
@@ -785,21 +856,21 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
785
856
  // Build comparison data
786
857
  const rows = priced.map(pos => {
787
858
  const daysHeld = Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000));
788
- const longFunding = fundingByExSym.get(`${pos.longExchange}:${pos.symbol}`) ?? 0;
789
- const shortFunding = fundingByExSym.get(`${pos.shortExchange}:${pos.symbol}`) ?? 0;
790
- const netFunding = longFunding + shortFunding;
791
- // Estimate unrealized PnL from price movement
792
- const longPrice = currentPrices.get(`${pos.longExchange}:${pos.symbol}`) ?? pos.entryLongPrice;
793
- const shortPrice = currentPrices.get(`${pos.shortExchange}:${pos.symbol}`) ?? pos.entryShortPrice;
794
- const longPnl = (longPrice - pos.entryLongPrice) * pos.longSize;
795
- const shortPnl = (pos.entryShortPrice - shortPrice) * pos.shortSize;
796
- const pricePnl = longPnl + shortPnl;
797
- const totalPnl = pricePnl + netFunding;
859
+ const longFunding = fundingOf(pos.longExchange, pos.symbol);
860
+ const shortFunding = fundingOf(pos.shortExchange, pos.symbol);
861
+ const netFunding = longFunding !== null && shortFunding !== null ? longFunding + shortFunding : null;
862
+ // Estimate unrealized PnL from price movement (null when either leg's mark is unknown)
863
+ const longPrice = priceOf(pos.longExchange, pos.symbol);
864
+ const shortPrice = priceOf(pos.shortExchange, pos.symbol);
865
+ const pricePnl = longPrice !== null && shortPrice !== null
866
+ ? (longPrice - pos.entryLongPrice) * pos.longSize + (pos.entryShortPrice - shortPrice) * pos.shortSize
867
+ : null;
868
+ const totalPnl = pricePnl !== null && netFunding !== null ? pricePnl + netFunding : null;
798
869
  // Notional for ROI calculation
799
870
  const notional = pos.entryLongPrice * pos.longSize + pos.entryShortPrice * pos.shortSize;
800
- const roi = notional > 0 ? (totalPnl / notional) * 100 : 0;
801
- const fundingRoi = notional > 0 ? (netFunding / notional) * 100 : 0;
802
- const annualizedRoi = daysHeld > 0 ? (fundingRoi / daysHeld) * 365 : 0;
871
+ const roi = totalPnl !== null ? (notional > 0 ? (totalPnl / notional) * 100 : 0) : null;
872
+ const fundingRoi = netFunding !== null ? (notional > 0 ? (netFunding / notional) * 100 : 0) : null;
873
+ const annualizedRoi = fundingRoi !== null ? (daysHeld > 0 ? (fundingRoi / daysHeld) * 365 : 0) : null;
803
874
  return {
804
875
  symbol: pos.symbol,
805
876
  mode: pos.mode ?? "perp-perp",
@@ -816,14 +887,19 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
816
887
  annualizedRoi,
817
888
  };
818
889
  });
819
- // Sort by total PnL descending
820
- rows.sort((a, b) => b.totalPnl - a.totalPnl);
890
+ // Sort by total PnL descending (unknown totals last)
891
+ rows.sort((a, b) => (a.totalPnl === null ? 1 : b.totalPnl === null ? -1 : b.totalPnl - a.totalPnl));
892
+ // Totals skip the unknown (null) values; partial flags that they do not cover every row.
893
+ const sumKnown = (vals) => vals.reduce((s, v) => s + (v ?? 0), 0);
894
+ const complete = rows.filter(r => r.totalPnl !== null);
821
895
  const totals = {
822
- funding: rows.reduce((s, r) => s + r.funding, 0),
823
- pricePnl: rows.reduce((s, r) => s + r.pricePnl, 0),
824
- totalPnl: rows.reduce((s, r) => s + r.totalPnl, 0),
825
- notional: rows.reduce((s, r) => s + r.notional, 0),
896
+ funding: sumKnown(rows.map(r => r.funding)),
897
+ pricePnl: sumKnown(rows.map(r => r.pricePnl)),
898
+ totalPnl: sumKnown(complete.map(r => r.totalPnl)),
899
+ // ROI base: only the rows whose total PnL is known.
900
+ notional: complete.reduce((s, r) => s + r.notional, 0),
826
901
  };
902
+ const partial = Object.keys(errors).length > 0 || rows.some(r => r.funding === null || r.pricePnl === null);
827
903
  const result = {
828
904
  period: opts.period ?? "all",
829
905
  positionCount: rows.length,
@@ -833,6 +909,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
833
909
  ...totals,
834
910
  roi: totals.notional > 0 ? (totals.totalPnl / totals.notional) * 100 : 0,
835
911
  },
912
+ errors,
913
+ partial,
836
914
  };
837
915
  if (isJson())
838
916
  return printJson(jsonOk(result));
@@ -843,11 +921,11 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
843
921
  `${r.longExchange} → ${r.shortExchange}`,
844
922
  r.mode,
845
923
  `${r.daysHeld}d`,
846
- formatPnl(r.funding),
847
- formatPnl(r.pricePnl),
848
- formatPnl(r.totalPnl),
849
- `${r.roi >= 0 ? "+" : ""}${r.roi.toFixed(2)}%`,
850
- `${r.annualizedRoi >= 0 ? "+" : ""}${r.annualizedRoi.toFixed(1)}%`,
924
+ r.funding !== null ? formatPnl(r.funding) : chalk.yellow("unavailable"),
925
+ r.pricePnl !== null ? formatPnl(r.pricePnl) : chalk.yellow("unavailable"),
926
+ r.totalPnl !== null ? formatPnl(r.totalPnl) : chalk.yellow("unavailable"),
927
+ r.roi !== null ? `${r.roi >= 0 ? "+" : ""}${r.roi.toFixed(2)}%` : chalk.yellow("unavailable"),
928
+ r.annualizedRoi !== null ? `${r.annualizedRoi >= 0 ? "+" : ""}${r.annualizedRoi.toFixed(1)}%` : chalk.yellow("unavailable"),
851
929
  ]);
852
930
  console.log(makeTable(["Symbol", "Long → Short", "Mode", "Held", "Funding", "Price PnL", "Total PnL", "ROI", "Ann.ROI"], tableRows));
853
931
  if (unpricedPositions.length > 0)
@@ -859,6 +937,10 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
859
937
  if (totals.notional > 0) {
860
938
  console.log(` Total ROI: ${result.totals.roi >= 0 ? "+" : ""}${result.totals.roi.toFixed(2)}%`);
861
939
  }
940
+ if (partial)
941
+ console.log(chalk.yellow(" ⚠ Partial: totals leave out the unavailable values above"));
942
+ for (const [k, msg] of Object.entries(errors))
943
+ console.log(chalk.yellow(` ⚠ ${k} unavailable: ${msg}`));
862
944
  console.log();
863
945
  });
864
946
  });
@@ -27,7 +27,7 @@ getAccountAdapter) {
27
27
  m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A"),
28
28
  usdOrDash(m.volume24h),
29
29
  usdOrDash(m.openInterest),
30
- String(m.maxLeverage) + "x",
30
+ m.maxLeverage === null ? chalk.gray("-") : String(m.maxLeverage) + "x",
31
31
  ];
32
32
  // Multi-exchange mode
33
33
  if (!explicitExchange && getAdapterForExchange) {
@@ -182,7 +182,7 @@ getAccountAdapter) {
182
182
  console.log(` Funding Rate: ${m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A")}`);
183
183
  console.log(` 24h Volume: ${usdOrDash(m.volume24h)}`);
184
184
  console.log(` Open Interest: ${usdOrDash(m.openInterest)}`);
185
- console.log(` Max Leverage: ${m.maxLeverage}x`);
185
+ console.log(` Max Leverage: ${m.maxLeverage === null ? chalk.gray("-") : `${m.maxLeverage}x`}`);
186
186
  console.log();
187
187
  });
188
188
  market
@@ -17,4 +17,9 @@
17
17
  import type { Command } from "commander";
18
18
  import type { ExchangeAdapter } from "../exchanges/index.js";
19
19
  import "../exchanges/venues/index.js";
20
+ /**
21
+ * Open-position count on a HIP-3 dex; null = the read failed (unknown — NOT "no positions").
22
+ * The failure is warned on stderr; callers must treat null as "may hold positions".
23
+ */
24
+ export declare function checkDexPositions(address: string, dex: string): Promise<number | null>;
20
25
  export declare function registerPortfolioCommand(program: Command, getAdapterForExchange: (exchange: string) => Promise<ExchangeAdapter>, isJson: () => boolean, version: string, getHLAdapterForDex?: (dex: string) => Promise<ExchangeAdapter>): void;