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
@@ -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));
@@ -647,24 +688,44 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
647
688
  let totalEquity = 0;
648
689
  let totalUPnl = 0;
649
690
  const symbolSet = new Set();
691
+ // A venue whose read failed is reported here and left out of the totals — never counted as 0.
692
+ const errors = {};
693
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
694
+ let venuesRead = 0;
650
695
  await Promise.allSettled(exchanges.map(async (ex) => {
696
+ let adapter;
697
+ try {
698
+ adapter = await getAdapterForExchange(ex);
699
+ }
700
+ catch (e) {
701
+ errors[`${ex}.adapter`] = errText(e);
702
+ return;
703
+ }
651
704
  try {
652
- const adapter = await getAdapterForExchange(ex);
653
- const [balance, trades, funding] = await Promise.all([
654
- adapter.getBalance(),
655
- adapter.getTradeHistory(50).catch(() => []),
656
- adapter.getFundingPayments(50).catch(() => []),
657
- ]);
705
+ const balance = await adapter.getBalance();
658
706
  totalEquity += Number(balance.equity);
659
707
  totalUPnl += Number(balance.unrealizedPnl);
660
- for (const t of trades) {
708
+ venuesRead++;
709
+ }
710
+ catch (e) {
711
+ errors[`${ex}.balance`] = errText(e);
712
+ return;
713
+ }
714
+ const [trades, funding] = await Promise.allSettled([adapter.getTradeHistory(50), adapter.getFundingPayments(50)]);
715
+ if (trades.status === "fulfilled") {
716
+ for (const t of trades.value) {
661
717
  totalFees += Number(t.fee);
662
718
  symbolSet.add(t.symbol);
663
719
  }
664
- for (const f of funding)
720
+ }
721
+ else
722
+ errors[`${ex}.trades`] = errText(trades.reason);
723
+ if (funding.status === "fulfilled") {
724
+ for (const f of funding.value)
665
725
  totalFunding += Number(f.payment);
666
726
  }
667
- catch { /* skip */ }
727
+ else
728
+ errors[`${ex}.funding`] = errText(funding.reason);
668
729
  }));
669
730
  const report = {
670
731
  period: opts.since ?? "all time",
@@ -691,6 +752,11 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
691
752
  ? (stats.totalTrades / Math.max(1, (Date.now() - new Date(records[records.length - 1]?.timestamp ?? Date.now()).getTime()) / 86400000)).toFixed(1)
692
753
  : "0",
693
754
  },
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,
694
760
  };
695
761
  if (isJson())
696
762
  return printJson(jsonOk(report));
@@ -707,6 +773,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
707
773
  console.log(` Trading Fees: ${chalk.red(`-$${formatUsd(totalFees)}`)}`);
708
774
  console.log(` Funding Income: ${formatPnl(totalFunding)}`);
709
775
  console.log(` Net: ${formatPnl(totalFunding - totalFees)}`);
776
+ for (const [k, msg] of Object.entries(errors))
777
+ console.log(chalk.yellow(` ⚠ ${k} unavailable (left out of the totals): ${msg}`));
710
778
  console.log();
711
779
  });
712
780
  });
@@ -738,25 +806,49 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
738
806
  // Fetch funding + current positions from all relevant exchanges
739
807
  const fundingByExSym = new Map();
740
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();
741
814
  await Promise.allSettled([...exchangeSet].map(async (ex) => {
815
+ let adapter;
742
816
  try {
743
- const adapter = await getAdapterForExchange(ex);
744
- const [funding, livePositions] = await Promise.all([
745
- adapter.getFundingPayments(limit),
746
- adapter.getPositions(),
747
- ]);
748
- 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) {
749
830
  if (sinceMs && f.time < sinceMs)
750
831
  continue;
751
832
  const key = `${ex}:${f.symbol}`;
752
833
  fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
753
834
  }
754
- 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) {
755
842
  currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
756
843
  }
757
844
  }
758
- catch { /* skip */ }
845
+ else
846
+ errors[`${ex}.positions`] = errText(livePositions.reason);
759
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;
760
852
  // Entries without a verified entry price (a bot leg the venue reported no fill price for)
761
853
  // have no price PnL: listed apart, never priced at a guess.
762
854
  const priced = positions.filter((p) => p.entryLongPrice !== null && p.entryShortPrice !== null);
@@ -764,21 +856,21 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
764
856
  // Build comparison data
765
857
  const rows = priced.map(pos => {
766
858
  const daysHeld = Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000));
767
- const longFunding = fundingByExSym.get(`${pos.longExchange}:${pos.symbol}`) ?? 0;
768
- const shortFunding = fundingByExSym.get(`${pos.shortExchange}:${pos.symbol}`) ?? 0;
769
- const netFunding = longFunding + shortFunding;
770
- // Estimate unrealized PnL from price movement
771
- const longPrice = currentPrices.get(`${pos.longExchange}:${pos.symbol}`) ?? pos.entryLongPrice;
772
- const shortPrice = currentPrices.get(`${pos.shortExchange}:${pos.symbol}`) ?? pos.entryShortPrice;
773
- const longPnl = (longPrice - pos.entryLongPrice) * pos.longSize;
774
- const shortPnl = (pos.entryShortPrice - shortPrice) * pos.shortSize;
775
- const pricePnl = longPnl + shortPnl;
776
- 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;
777
869
  // Notional for ROI calculation
778
870
  const notional = pos.entryLongPrice * pos.longSize + pos.entryShortPrice * pos.shortSize;
779
- const roi = notional > 0 ? (totalPnl / notional) * 100 : 0;
780
- const fundingRoi = notional > 0 ? (netFunding / notional) * 100 : 0;
781
- 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;
782
874
  return {
783
875
  symbol: pos.symbol,
784
876
  mode: pos.mode ?? "perp-perp",
@@ -795,14 +887,19 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
795
887
  annualizedRoi,
796
888
  };
797
889
  });
798
- // Sort by total PnL descending
799
- 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);
800
895
  const totals = {
801
- funding: rows.reduce((s, r) => s + r.funding, 0),
802
- pricePnl: rows.reduce((s, r) => s + r.pricePnl, 0),
803
- totalPnl: rows.reduce((s, r) => s + r.totalPnl, 0),
804
- 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),
805
901
  };
902
+ const partial = Object.keys(errors).length > 0 || rows.some(r => r.funding === null || r.pricePnl === null);
806
903
  const result = {
807
904
  period: opts.period ?? "all",
808
905
  positionCount: rows.length,
@@ -812,6 +909,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
812
909
  ...totals,
813
910
  roi: totals.notional > 0 ? (totals.totalPnl / totals.notional) * 100 : 0,
814
911
  },
912
+ errors,
913
+ partial,
815
914
  };
816
915
  if (isJson())
817
916
  return printJson(jsonOk(result));
@@ -822,11 +921,11 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
822
921
  `${r.longExchange} → ${r.shortExchange}`,
823
922
  r.mode,
824
923
  `${r.daysHeld}d`,
825
- formatPnl(r.funding),
826
- formatPnl(r.pricePnl),
827
- formatPnl(r.totalPnl),
828
- `${r.roi >= 0 ? "+" : ""}${r.roi.toFixed(2)}%`,
829
- `${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"),
830
929
  ]);
831
930
  console.log(makeTable(["Symbol", "Long → Short", "Mode", "Held", "Funding", "Price PnL", "Total PnL", "ROI", "Ann.ROI"], tableRows));
832
931
  if (unpricedPositions.length > 0)
@@ -838,6 +937,10 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
838
937
  if (totals.notional > 0) {
839
938
  console.log(` Total ROI: ${result.totals.roi >= 0 ? "+" : ""}${result.totals.roi.toFixed(2)}%`);
840
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}`));
841
944
  console.log();
842
945
  });
843
946
  });
@@ -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;
@@ -176,7 +176,7 @@ async function fetchExchangeEntry(exName, getAdapter) {
176
176
  adapter.getBalance(),
177
177
  adapter.getPositions(),
178
178
  adapter.getOpenOrders(),
179
- adapter.getFundingPayments(200).catch(() => []),
179
+ adapter.getFundingPayments(200).then((rows) => ({ rows, error: null }), (e) => ({ rows: null, error: e instanceof Error ? e.message : String(e) })),
180
180
  ]);
181
181
  // Spot balances for every venue with def.spot (§12). Failures are surfaced on
182
182
  // stderr (the perp snapshot is still reported); unpriced tokens are excluded + warned.
@@ -200,8 +200,11 @@ async function fetchExchangeEntry(exName, getAdapter) {
200
200
  warn(`${def.displayName} spot balances unavailable: ${e instanceof Error ? e.message : String(e)}`);
201
201
  }
202
202
  const cutoff24h = Date.now() - 24 * 60 * 60 * 1000;
203
- const recent = fundingPayments.filter(f => f.time >= cutoff24h);
204
- const funding24h = recent.reduce((sum, f) => sum + Number(f.payment), 0);
203
+ if (fundingPayments.error !== null)
204
+ warn(`${def.displayName} funding history unavailable (funding24h null): ${fundingPayments.error}`);
205
+ const funding24h = fundingPayments.rows === null
206
+ ? null
207
+ : fundingPayments.rows.filter(f => f.time >= cutoff24h).reduce((sum, f) => sum + Number(f.payment), 0);
205
208
  // Shared balances (Pacifica USDC, HL unified USDC) are already in perp equity (§12.4 #8).
206
209
  const nonUsdcSpotValue = spot.filter(b => !b.shared).reduce((s, b) => s + b.valueUsd, 0);
207
210
  return {
@@ -227,6 +230,7 @@ async function fetchExchangeEntry(exName, getAdapter) {
227
230
  })),
228
231
  openOrders: orders.length,
229
232
  funding24h,
233
+ fundingError: fundingPayments.error,
230
234
  totalAccountValueUsd: Number(bal.equity) + nonUsdcSpotValue,
231
235
  error: null,
232
236
  };
@@ -240,7 +244,9 @@ async function fetchExchangeEntry(exName, getAdapter) {
240
244
  spot: [],
241
245
  positions: [],
242
246
  openOrders: 0,
243
- funding24h: 0,
247
+ funding24h: null,
248
+ // The whole venue was unreadable: funding is unknown for the same reason.
249
+ fundingError: err instanceof Error ? err.message : String(err),
244
250
  totalAccountValueUsd: 0,
245
251
  error: err instanceof Error ? err.message : String(err),
246
252
  };
@@ -251,13 +257,17 @@ function buildTotals(entries) {
251
257
  let spotValueUsd = 0;
252
258
  let marginUsed = 0;
253
259
  let funding24h = 0;
260
+ let fundingIncomplete = false;
254
261
  let accountValueUsd = 0;
255
262
  for (const e of entries) {
256
263
  if (e.perp) {
257
264
  equity += Number(e.perp.equity);
258
265
  marginUsed += Number(e.perp.marginUsed);
259
266
  }
260
- funding24h += e.funding24h;
267
+ if (e.funding24h === null)
268
+ fundingIncomplete = true;
269
+ else
270
+ funding24h += e.funding24h;
261
271
  // Non-USDC spot value (for unified accounts USDC is already in equity)
262
272
  const exSpot = e.isUnified
263
273
  ? e.spot.filter(b => b.token.replace(/-SPOT$/i, "").toUpperCase() !== "USDC")
@@ -272,7 +282,7 @@ function buildTotals(entries) {
272
282
  const marginPct = denom > 0 ? (marginUsed / denom) * 100 : 0;
273
283
  const level = marginPct < 30 ? "LOW" : marginPct < 60 ? "MEDIUM" : "HIGH";
274
284
  return {
275
- totals: { equity, spotValueUsd, accountValueUsd, marginUsed, marginPct, funding24h },
285
+ totals: { equity, spotValueUsd, accountValueUsd, marginUsed, marginPct, funding24h, fundingIncomplete },
276
286
  risk: { level, marginPct },
277
287
  };
278
288
  }
@@ -537,7 +547,11 @@ async function runWatch(resolved, version, getAdapterForExchange, isJsonMode) {
537
547
  }
538
548
  // ── Serve mode (delegates to dashboard infra) ──────────────────────────────
539
549
  const KNOWN_HIP3_DEXES = ["xyz", "flx", "hyna", "km", "cash", "vntl"];
540
- async function checkDexPositions(address, dex) {
550
+ /**
551
+ * Open-position count on a HIP-3 dex; null = the read failed (unknown — NOT "no positions").
552
+ * The failure is warned on stderr; callers must treat null as "may hold positions".
553
+ */
554
+ export async function checkDexPositions(address, dex) {
541
555
  try {
542
556
  // Selected network (review L7): follows -n via setSharedApiNetwork (live binding).
543
557
  const res = await fetch(HYPERLIQUID_API_URL, {
@@ -545,15 +559,22 @@ async function checkDexPositions(address, dex) {
545
559
  headers: { "Content-Type": "application/json" },
546
560
  body: JSON.stringify({ type: "clearinghouseState", user: address, dex }),
547
561
  });
562
+ if (!res.ok)
563
+ throw new Error(`HTTP ${res.status}`);
548
564
  const state = await res.json();
549
- const positions = (state?.assetPositions ?? []);
550
- return positions.filter(p => {
565
+ if (!state || !Array.isArray(state.assetPositions))
566
+ throw new Error("clearinghouseState without assetPositions");
567
+ return state.assetPositions.filter(p => {
551
568
  const pos = (p.position ?? p);
552
- return Number(pos.szi ?? 0) !== 0;
569
+ const szi = Number(pos.szi);
570
+ if (!Number.isFinite(szi))
571
+ throw new Error(`position without a numeric szi (${String(pos.coin)})`);
572
+ return szi !== 0;
553
573
  }).length;
554
574
  }
555
- catch {
556
- return 0;
575
+ catch (e) {
576
+ process.stderr.write(`[warn] hl:${dex} positions unknown (HIP-3 dex read failed; included in the dashboard): ${e instanceof Error ? e.message : String(e)}\n`);
577
+ return null;
557
578
  }
558
579
  }
559
580
  async function autoDetectHip3Dexes(address, getHLAdapterForDex, verbose) {
@@ -561,16 +582,22 @@ async function autoDetectHip3Dexes(address, getHLAdapterForDex, verbose) {
561
582
  dex,
562
583
  count: await checkDexPositions(address, dex),
563
584
  })));
564
- const activeDexes = checks.filter(c => c.count > 0);
585
+ // Unknown (null) counts are included: a failed read must not hide a dex that may hold positions.
586
+ const activeDexes = checks.filter(c => c.count === null || c.count > 0);
565
587
  const results = [];
566
588
  for (const { dex, count } of activeDexes) {
567
589
  try {
568
590
  const adapter = await getHLAdapterForDex(dex);
569
591
  results.push({ name: `hl:${dex}`, adapter });
570
- if (verbose)
571
- console.log(chalk.green(` ✓ hl:${dex} (HIP-3, ${count} position${count > 1 ? "s" : ""})`));
592
+ if (verbose) {
593
+ console.log(count === null
594
+ ? chalk.yellow(` ⚠ hl:${dex} (HIP-3, positions unknown — read failed)`)
595
+ : chalk.green(` ✓ hl:${dex} (HIP-3, ${count} position${count > 1 ? "s" : ""})`));
596
+ }
597
+ }
598
+ catch (e) {
599
+ process.stderr.write(`[warn] hl:${dex} left out of the dashboard (adapter unavailable${count === null ? ", positions unknown" : `, ${count} position(s)`}): ${e instanceof Error ? e.message : String(e)}\n`);
572
600
  }
573
- catch { /* skip */ }
574
601
  }
575
602
  return results;
576
603
  }
@@ -578,12 +605,20 @@ async function runServe(resolved, getAdapterForExchange, isJsonMode, getHLAdapte
578
605
  const { startDashboard } = await import("../dashboard/index.js");
579
606
  if (isJsonMode) {
580
607
  const exchanges = [];
608
+ // A venue whose adapter failed is not monitored — reported in serve.errors + stderr, not dropped silently.
609
+ const errors = {};
610
+ const noteError = (name, e) => {
611
+ errors[name] = e instanceof Error ? e.message : String(e);
612
+ process.stderr.write(`[warn] ${name} not monitored (adapter unavailable): ${errors[name]}\n`);
613
+ };
581
614
  for (const name of resolved.exchangeList) {
582
615
  try {
583
616
  const adapter = await getAdapterForExchange(name);
584
617
  exchanges.push({ name, adapter });
585
618
  }
586
- catch { /* skip */ }
619
+ catch (e) {
620
+ noteError(name, e);
621
+ }
587
622
  }
588
623
  if (getHLAdapterForDex) {
589
624
  for (const dex of resolved.dexList) {
@@ -591,7 +626,9 @@ async function runServe(resolved, getAdapterForExchange, isJsonMode, getHLAdapte
591
626
  const adapter = await getHLAdapterForDex(dex);
592
627
  exchanges.push({ name: `hl:${dex}`, adapter });
593
628
  }
594
- catch { /* skip */ }
629
+ catch (e) {
630
+ noteError(`hl:${dex}`, e);
631
+ }
595
632
  }
596
633
  if (resolved.autoDex && !resolved.dexList.length && resolved.exchangeList.includes("hyperliquid")) {
597
634
  const hlEx = exchanges.find(e => e.name === "hyperliquid");
@@ -612,12 +649,13 @@ async function runServe(resolved, getAdapterForExchange, isJsonMode, getHLAdapte
612
649
  scope: resolved.scope,
613
650
  mode: "serve",
614
651
  exchanges: [],
615
- totals: { equity: 0, spotValueUsd: 0, accountValueUsd: 0, marginUsed: 0, marginPct: 0, funding24h: 0 },
652
+ totals: { equity: 0, spotValueUsd: 0, accountValueUsd: 0, marginUsed: 0, marginPct: 0, funding24h: 0, fundingIncomplete: false },
616
653
  risk: { level: "LOW", marginPct: 0 },
617
654
  serve: {
618
655
  url: `http://localhost:${dashboard.port}`,
619
656
  port: dashboard.port,
620
657
  exchanges: exchanges.map(e => e.name),
658
+ errors,
621
659
  },
622
660
  };
623
661
  printJson(jsonOk(data));
@@ -757,7 +795,7 @@ export function registerPortfolioCommand(program, getAdapterForExchange, isJson,
757
795
  scope: resolved.scope,
758
796
  mode: "health",
759
797
  exchanges: [],
760
- totals: { equity: 0, spotValueUsd: 0, accountValueUsd: 0, marginUsed: 0, marginPct: 0, funding24h: 0 },
798
+ totals: { equity: 0, spotValueUsd: 0, accountValueUsd: 0, marginUsed: 0, marginPct: 0, funding24h: 0, fundingIncomplete: false },
761
799
  risk: { level: "LOW", marginPct: 0 },
762
800
  health,
763
801
  };