perp-cli 0.14.1 → 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 (61) 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/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/portfolio.d.ts +5 -0
  30. package/dist/commands/portfolio.js +58 -20
  31. package/dist/commands/rebalance.js +46 -10
  32. package/dist/commands/settings.js +2 -1
  33. package/dist/commands/trade.js +8 -4
  34. package/dist/commands/wallet.js +1 -1
  35. package/dist/cross-chain-margin.d.ts +29 -2
  36. package/dist/cross-chain-margin.js +30 -4
  37. package/dist/dashboard/server.d.ts +45 -8
  38. package/dist/dashboard/server.js +113 -42
  39. package/dist/dashboard/ui.js +46 -27
  40. package/dist/dashboard/ws-feeds.d.ts +25 -3
  41. package/dist/dashboard/ws-feeds.js +219 -82
  42. package/dist/dex-asset-map.d.ts +23 -4
  43. package/dist/dex-asset-map.js +39 -13
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.d.ts +6 -0
  46. package/dist/exchanges/arcus/public.js +12 -4
  47. package/dist/exchanges/aster.d.ts +19 -7
  48. package/dist/exchanges/aster.js +79 -46
  49. package/dist/exchanges/hyperliquid.d.ts +30 -0
  50. package/dist/exchanges/hyperliquid.js +159 -34
  51. package/dist/exchanges/lighter.d.ts +1 -1
  52. package/dist/exchanges/lighter.js +11 -9
  53. package/dist/exchanges/ondo/funds.d.ts +0 -1
  54. package/dist/exchanges/pacifica.d.ts +7 -0
  55. package/dist/exchanges/pacifica.js +52 -26
  56. package/dist/rebalance.d.ts +20 -1
  57. package/dist/rebalance.js +31 -5
  58. package/dist/strategies/funding-arb.js +38 -7
  59. package/package.json +1 -1
  60. package/skills/perp-cli/SKILL.md +2 -2
  61. package/skills/perp-cli/references/commands.md +5 -3
@@ -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
  };
@@ -1,7 +1,8 @@
1
1
  import { isDryRun, supportsDryRun } from "../cli-flags.js";
2
2
  import chalk from "chalk";
3
3
  import { formatUsd, makeTable, printJson, jsonOk } from "../utils.js";
4
- import { fetchAllBalances, computeRebalancePlan, describeBridgeRoute, estimateMoveTime, } from "../rebalance.js";
4
+ import { PerpError } from "../errors.js";
5
+ import { fetchBalancesWithFailures, computeRebalancePlan, describeBridgeRoute, estimateMoveTime, } from "../rebalance.js";
5
6
  import { EXCHANGE_TO_CHAIN, assertBridgeNetwork, executeBestBridge } from "../bridge-engine.js";
6
7
  import { cliCredentialOptsFrom } from "../config.js";
7
8
  export function registerFundsRebalanceCommands(parent, getAdapterForExchange, isJson, getNetwork) {
@@ -31,9 +32,14 @@ export function registerFundsRebalanceCommands(parent, getAdapterForExchange, is
31
32
  }
32
33
  if (!isJson())
33
34
  console.log(chalk.cyan("\n Fetching balances across exchanges...\n"));
34
- const snapshots = await fetchAllBalances(adapters);
35
+ const { snapshots, failed, notConfigured } = await fetchBalancesWithFailures(adapters);
36
+ // A venue whose balance read failed is unavailable — listed, never shown as $0 or dropped.
37
+ unavailable.push(...failed);
38
+ const partial = unavailable.length > 0;
35
39
  if (isJson())
36
- return printJson(jsonOk({ snapshots, unavailable }));
40
+ return printJson(jsonOk({ snapshots, unavailable, partial, notConfigured }));
41
+ for (const n of notConfigured)
42
+ console.log(chalk.gray(` ${n}: not configured (no credentials) — left out`));
37
43
  const totalEquity = snapshots.reduce((s, e) => s + e.equity, 0);
38
44
  const totalAvailable = snapshots.reduce((s, e) => s + e.available, 0);
39
45
  const rows = snapshots.map((s) => {
@@ -53,7 +59,8 @@ export function registerFundsRebalanceCommands(parent, getAdapterForExchange, is
53
59
  console.log(chalk.cyan.bold(" Totals"));
54
60
  console.log(` Total Equity: $${formatUsd(totalEquity)}`);
55
61
  console.log(` Total Available: $${formatUsd(totalAvailable)}`);
56
- console.log(` Exchanges: ${snapshots.length}\n`);
62
+ console.log(` Exchanges: ${snapshots.length}${partial ? ` of ${exchangeNames.length} (partial)` : ""}\n`);
63
+ printUnavailable(unavailable);
57
64
  });
58
65
  // ── rebalance plan ──
59
66
  rebalance
@@ -82,13 +89,22 @@ export function registerFundsRebalanceCommands(parent, getAdapterForExchange, is
82
89
  }
83
90
  if (!isJson())
84
91
  console.log(chalk.cyan("\n Computing rebalance plan...\n"));
85
- const snapshots = await fetchAllBalances(adapters);
92
+ const { snapshots, failed, notConfigured } = await fetchBalancesWithFailures(adapters);
93
+ unavailable.push(...failed);
94
+ // partial: the plan covers only the venues that were read — `execute` refuses to run it.
95
+ const partial = unavailable.length > 0;
86
96
  const plan = computeRebalancePlan(snapshots, {
87
97
  minMove: parseFloat(opts.minMove),
88
98
  reserve: parseFloat(opts.reserve),
89
99
  });
90
100
  if (isJson())
91
- return printJson(jsonOk({ ...plan, unavailable }));
101
+ return printJson(jsonOk({ ...plan, unavailable, partial, notConfigured }));
102
+ for (const n of notConfigured)
103
+ console.log(chalk.gray(` ${n}: not configured (no credentials) — left out of the plan`));
104
+ if (partial) {
105
+ console.log(chalk.yellow(` ⚠ Partial plan: computed over ${snapshots.length} of ${exchangeNames.length} exchanges.`));
106
+ printUnavailable(unavailable);
107
+ }
92
108
  // Show current state
93
109
  const rows = plan.snapshots.map((s) => {
94
110
  const target = plan.targetPerExchange;
@@ -134,19 +150,33 @@ export function registerFundsRebalanceCommands(parent, getAdapterForExchange, is
134
150
  assertBridgeNetwork(getNetwork());
135
151
  const exchangeNames = opts.exchanges.split(",").map((e) => e.trim());
136
152
  const adapters = new Map();
153
+ const unavailable = [];
137
154
  for (const name of exchangeNames) {
138
155
  try {
139
156
  adapters.set(name, await getAdapterForExchange(name));
140
157
  }
141
- catch {
142
- // skip
158
+ catch (e) {
159
+ unavailable.push({ exchange: name, error: e instanceof Error ? e.message : String(e) });
143
160
  }
144
161
  }
145
- if (adapters.size < 2) {
162
+ if (adapters.size < 2 && unavailable.length === 0) {
146
163
  console.error(chalk.red("\n Need at least 2 exchanges.\n"));
147
164
  return;
148
165
  }
149
- const snapshots = await fetchAllBalances(adapters);
166
+ const { snapshots, failed, notConfigured } = await fetchBalancesWithFailures(adapters);
167
+ unavailable.push(...failed);
168
+ // A venue without credentials holds nothing the CLI can move: left out (noted), not a failure.
169
+ if (!isJson())
170
+ for (const n of notConfigured)
171
+ console.log(chalk.gray(` ${n}: not configured (no credentials) — left out`));
172
+ // Moving funds on a plan computed over fewer venues than requested would mis-size every
173
+ // move: abort unless every requested venue was read.
174
+ if (unavailable.length > 0) {
175
+ throw new PerpError("EXCHANGE_ERROR", `Rebalance aborted: balances of ${unavailable.map(u => u.exchange).join(", ")} could not be read (${unavailable.map(u => `${u.exchange}: ${u.error}`).join("; ")})`, {
176
+ unavailable,
177
+ remediation: `Retry when every venue answers, or exclude the unavailable venue(s) explicitly with --exchanges ${exchangeNames.filter(n => !unavailable.some(u => u.exchange === n)).join(",") || "<list>"}`,
178
+ });
179
+ }
150
180
  const plan = computeRebalancePlan(snapshots, {
151
181
  minMove: parseFloat(opts.minMove),
152
182
  reserve: parseFloat(opts.reserve),
@@ -238,6 +268,12 @@ export function registerFundsRebalanceCommands(parent, getAdapterForExchange, is
238
268
  console.log(chalk.cyan(" Rebalance initiated. Monitor progress with 'perp funds rebalance check'.\n"));
239
269
  });
240
270
  }
271
+ function printUnavailable(unavailable) {
272
+ for (const u of unavailable)
273
+ console.log(chalk.yellow(` ⚠ ${u.exchange} unavailable (left out): ${u.error}`));
274
+ if (unavailable.length > 0)
275
+ console.log();
276
+ }
241
277
  /**
242
278
  * Execute a withdrawal from a source exchange.
243
279
  */
@@ -1,3 +1,4 @@
1
+ import { QFEX_WITHDRAW_ENABLED } from "../exchanges/venues/qfex.js";
1
2
  import { unsupported } from "../exchanges/trading-v2.js";
2
3
  import chalk from "chalk";
3
4
  import { printJson, jsonOk, withJsonErrors } from "../utils.js";
@@ -116,7 +117,7 @@ export function registerSettingsCommands(program, isJson, getAdapterForExchange)
116
117
  const fee = "feeBps" in e ? `${e.feeBps} bps` : "fee share (you pay the normal fee)";
117
118
  console.log(` ${v.padEnd(14)} ${id} ${fee}${BUILDER_FEE_CAPABILITIES[v]?.feeKind === "max" ? " (max)" : ""}`);
118
119
  }
119
- console.log(chalk.white.bold("\n Withdraw gate (qfex: API-key withdrawals; hyperliquid: withdraw / transfer to a non-owner address; off unless set):"));
120
+ console.log(chalk.white.bold(`\n Withdraw gate (qfex: API-key withdrawals${QFEX_WITHDRAW_ENABLED ? "" : " — currently disabled in perp-cli"}; hyperliquid: withdraw / transfer to a non-owner address; off unless set):`));
120
121
  const gates = Object.entries(s.withdraw);
121
122
  if (gates.length === 0)
122
123
  console.log(chalk.gray(` (none — gated withdrawals on ${WITHDRAW_GATE_VENUES.join(", ")} are refused; Hyperliquid to the owner's own address is always allowed)`));
@@ -1938,7 +1938,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1938
1938
  // ── Recent Fills ──
1939
1939
  trade
1940
1940
  .command("fills [symbol]")
1941
- .description("Recent trade fills, optionally filtered by symbol")
1941
+ .description("Recent trade fills, optionally filtered by symbol (Aster: last 7 days, symbols with a position, open order or fill in that window)")
1942
1942
  .option("-l, --limit <n>", "Number of fills", "30")
1943
1943
  .action(async (symbol, opts) => {
1944
1944
  await withJsonErrors(isJson(), async () => {
@@ -2120,6 +2120,7 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
2120
2120
  }
2121
2121
  // Fetch funding payments (recent) for display
2122
2122
  let fundingBySymbol = {};
2123
+ let fundingError = null;
2123
2124
  try {
2124
2125
  const payments = await adapter.getFundingPayments(50);
2125
2126
  for (const fp of payments) {
@@ -2127,8 +2128,9 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
2127
2128
  fundingBySymbol[sym] = (fundingBySymbol[sym] || 0) + parseFloat(fp.payment);
2128
2129
  }
2129
2130
  }
2130
- catch {
2131
- // funding payments may not be supported on all exchanges
2131
+ catch (e) {
2132
+ // Shown under the table: funding columns are then unknown, not 0.
2133
+ fundingError = e instanceof Error ? e.message : String(e);
2132
2134
  }
2133
2135
  console.clear();
2134
2136
  console.log(chalk.cyan.bold(`\n PnL Tracker — ${adapter.name} | ${new Date().toLocaleTimeString()}\n`));
@@ -2155,11 +2157,13 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
2155
2157
  `$${formatUsd(p.markPrice)}`,
2156
2158
  pnlColor(`${pnl >= 0 ? "+" : ""}$${pnl.toFixed(2)}`),
2157
2159
  pctColor(`${pnlPct >= 0 ? "+" : ""}${pnlPct.toFixed(2)}%`),
2158
- funding !== 0 ? `$${funding.toFixed(4)}` : "-",
2160
+ fundingError !== null ? chalk.yellow("n/a") : funding !== 0 ? `$${funding.toFixed(4)}` : "-",
2159
2161
  ];
2160
2162
  });
2161
2163
  console.log(makeTable(["Symbol", "Side", "Size", "Entry", "Mark", "PnL", "PnL%", "Funding"], rows));
2162
2164
  }
2165
+ if (fundingError !== null)
2166
+ console.log(chalk.yellow(`\n Funding history unavailable (Funding column n/a): ${fundingError}`));
2163
2167
  console.log(chalk.gray(`\n Refreshing every ${intervalSec}s... Press Ctrl+C to stop.`));
2164
2168
  await new Promise(r => setTimeout(r, intervalSec * 1000));
2165
2169
  }
@@ -114,7 +114,7 @@ function balanceTable(balances) {
114
114
  b.chain,
115
115
  chalk.white.bold(b.token),
116
116
  b.balance === null ? chalk.red("read failed") : b.balance,
117
- b.usdValue === null ? chalk.gray("-") : `$${formatUsd(b.usdValue)}`,
117
+ b.usdValue !== null ? `$${formatUsd(b.usdValue)}` : b.priceError !== null ? chalk.gray("price unavailable") : chalk.gray("-"),
118
118
  ]);
119
119
  const total = stableTotal(balances);
120
120
  rows.push([chalk.gray("total"), chalk.white.bold("stablecoins"), "", `$${formatUsd(total.usd)}${total.errors.length > 0 ? chalk.red(" (some reads failed)") : ""}`]);
@@ -15,14 +15,25 @@ export interface ChainMarginStatus {
15
15
  marginRatio: number;
16
16
  belowThreshold: boolean;
17
17
  }
18
+ /** A venue whose margin could not be read: UNKNOWN (never healthy) — callers block new entries on it. */
19
+ export interface ChainMarginUnknown {
20
+ exchange: string;
21
+ error: string;
22
+ }
23
+ export interface ChainMarginCheck {
24
+ statuses: ChainMarginStatus[];
25
+ /** Venues whose balance read failed / returned non-numeric values (margin unknown). */
26
+ unknown: ChainMarginUnknown[];
27
+ }
18
28
  /**
19
29
  * Check margin status across all configured exchanges.
20
30
  *
21
31
  * @param adapters - Map of exchange name to adapter
22
32
  * @param minMarginPct - Warn when free margin / equity falls below this (default: 30%)
23
- * @returns Per-exchange margin status array
33
+ * @returns Per-exchange margin status, plus every venue whose margin is unknown (a failed
34
+ * read is reported there — never dropped, so it can still block entries)
24
35
  */
25
- export declare function checkChainMargins(adapters: Map<string, ExchangeAdapter>, minMarginPct?: number): Promise<ChainMarginStatus[]>;
36
+ export declare function checkChainMargins(adapters: Map<string, ExchangeAdapter>, minMarginPct?: number): Promise<ChainMarginCheck>;
26
37
  /**
27
38
  * Check if a specific exchange has critically low margin (below 15%).
28
39
  */
@@ -31,6 +42,22 @@ export declare function isCriticalMargin(status: ChainMarginStatus): boolean;
31
42
  * Check if a specific exchange should block new entries (below threshold).
32
43
  */
33
44
  export declare function shouldBlockEntries(status: ChainMarginStatus, minMarginPct?: number): boolean;
45
+ /** Why new entries on a venue are blocked this cycle (closing / reducing stays allowed). */
46
+ export interface MarginEntryBlock {
47
+ exchange: string;
48
+ level: "critical" | "low" | "unknown";
49
+ /** null when the margin is unknown */
50
+ marginPct: number | null;
51
+ /** Threshold breached; null when unknown */
52
+ threshold: number | null;
53
+ /** Read error when unknown */
54
+ error?: string;
55
+ }
56
+ /**
57
+ * Entry blocks from a margin check: critical (<15%), low (< minMarginPct), and UNKNOWN
58
+ * (failed read — never treated as healthy, so new entries are blocked on it too).
59
+ */
60
+ export declare function marginEntryBlocks(check: ChainMarginCheck, minMarginPct?: number): MarginEntryBlock[];
34
61
  /**
35
62
  * Compute the optimal position size automatically based on:
36
63
  * 1. Orderbook depth on both sides
@@ -17,7 +17,8 @@ const EXCHANGE_CHAIN_MAP = {
17
17
  *
18
18
  * @param adapters - Map of exchange name to adapter
19
19
  * @param minMarginPct - Warn when free margin / equity falls below this (default: 30%)
20
- * @returns Per-exchange margin status array
20
+ * @returns Per-exchange margin status, plus every venue whose margin is unknown (a failed
21
+ * read is reported there — never dropped, so it can still block entries)
21
22
  */
22
23
  export async function checkChainMargins(adapters, minMarginPct = 30) {
23
24
  const entries = [...adapters.entries()];
@@ -25,6 +26,9 @@ export async function checkChainMargins(adapters, minMarginPct = 30) {
25
26
  const bal = await adapter.getBalance();
26
27
  const equity = Number(bal.equity);
27
28
  const usedMargin = Number(bal.marginUsed);
29
+ if (!Number.isFinite(equity) || !Number.isFinite(usedMargin)) {
30
+ throw new Error(`balance returned non-numeric equity/marginUsed (${String(bal.equity)}/${String(bal.marginUsed)})`);
31
+ }
28
32
  const freeMargin = equity - usedMargin;
29
33
  // marginRatio = (freeMargin / equity) * 100
30
34
  // If equity is 0, ratio is 0 (critical). If no margin used, ratio is 100.
@@ -39,9 +43,15 @@ export async function checkChainMargins(adapters, minMarginPct = 30) {
39
43
  belowThreshold: marginRatio < minMarginPct,
40
44
  };
41
45
  }));
42
- return results
43
- .filter((r) => r.status === "fulfilled")
44
- .map((r) => r.value);
46
+ const statuses = [];
47
+ const unknown = [];
48
+ results.forEach((r, i) => {
49
+ if (r.status === "fulfilled")
50
+ statuses.push(r.value);
51
+ else
52
+ unknown.push({ exchange: entries[i][0], error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
53
+ });
54
+ return { statuses, unknown };
45
55
  }
46
56
  /**
47
57
  * Check if a specific exchange has critically low margin (below 15%).
@@ -55,6 +65,22 @@ export function isCriticalMargin(status) {
55
65
  export function shouldBlockEntries(status, minMarginPct = 30) {
56
66
  return status.marginRatio < minMarginPct;
57
67
  }
68
+ /**
69
+ * Entry blocks from a margin check: critical (<15%), low (< minMarginPct), and UNKNOWN
70
+ * (failed read — never treated as healthy, so new entries are blocked on it too).
71
+ */
72
+ export function marginEntryBlocks(check, minMarginPct = 30) {
73
+ const out = [];
74
+ for (const ms of check.statuses) {
75
+ if (isCriticalMargin(ms))
76
+ out.push({ exchange: ms.exchange, level: "critical", marginPct: ms.marginRatio, threshold: 15 });
77
+ else if (shouldBlockEntries(ms, minMarginPct))
78
+ out.push({ exchange: ms.exchange, level: "low", marginPct: ms.marginRatio, threshold: minMarginPct });
79
+ }
80
+ for (const u of check.unknown)
81
+ out.push({ exchange: u.exchange, level: "unknown", marginPct: null, threshold: null, error: u.error });
82
+ return out;
83
+ }
58
84
  // ── Dynamic Auto-Sizing ──
59
85
  /**
60
86
  * Compute the optimal position size automatically based on:
@@ -4,7 +4,8 @@
4
4
  * Polls all configured exchange adapters and pushes updates to connected clients.
5
5
  * Includes cross-exchange arb data: funding rate comparison + dex arb scan.
6
6
  */
7
- import type { ExchangeAdapter, ExchangeBalance, ExchangePosition, ExchangeOrder, ExchangeMarketInfo } from "../exchanges/index.js";
7
+ import type { ExchangeAdapter, ExchangeOrder, ExchangeMarketInfo } from "../exchanges/index.js";
8
+ import { type DashboardBalance, type DashboardPosition } from "./ws-feeds.js";
8
9
  export interface DashboardExchange {
9
10
  name: string;
10
11
  adapter: ExchangeAdapter;
@@ -40,15 +41,40 @@ export interface DexAssetRow {
40
41
  oi: number;
41
42
  }[];
42
43
  }
43
- export interface DashboardSnapshot {
44
- timestamp: string;
45
- exchanges: {
44
+ /**
45
+ * JSON shape served on /api/snapshot and pushed over the WS ("snapshot" messages).
46
+ *
47
+ * A venue part that could not be read is null — never a $0 balance / empty list:
48
+ * exchanges[].balance / positions / orders: null = unavailable, the reason is in `error`.
49
+ * exchanges[].balance.unrealizedPnl, positions[].entryPrice / markPrice / unrealizedPnl: null = unknown.
50
+ * exchanges[].feed: live-feed health (WS manager only) — stale = no fresh data (see isFeedStale).
51
+ * totals cover only the known values; totals.partial = some venue part was left out,
52
+ * totals.unavailable lists which ("<venue>.<part>").
53
+ */
54
+ export interface DashboardExchangeData {
55
+ name: string;
56
+ balance: DashboardBalance | null;
57
+ positions: DashboardPosition[] | null;
58
+ orders: ExchangeOrder[] | null;
59
+ topMarkets: ExchangeMarketInfo[];
60
+ /** Why topMarkets could not be refreshed (the list shown is the last good read); null = fresh. */
61
+ marketsError: string | null;
62
+ /** Why a part is null / the feed is stale; null = everything read. */
63
+ error: string | null;
64
+ feed?: {
65
+ mode: "ws" | "rest" | "connecting";
66
+ lastOkAt: number | null;
67
+ lastError: string | null;
68
+ stale: boolean;
69
+ };
70
+ dexBalances?: {
46
71
  name: string;
47
- balance: ExchangeBalance;
48
- positions: ExchangePosition[];
49
- orders: ExchangeOrder[];
50
- topMarkets: ExchangeMarketInfo[];
72
+ balance: DashboardBalance | null;
51
73
  }[];
74
+ }
75
+ export interface DashboardSnapshot {
76
+ timestamp: string;
77
+ exchanges: DashboardExchangeData[];
52
78
  totals: {
53
79
  equity: number;
54
80
  available: number;
@@ -56,6 +82,10 @@ export interface DashboardSnapshot {
56
82
  unrealizedPnl: number;
57
83
  positionCount: number;
58
84
  orderCount: number;
85
+ /** true = some venue part (or a stale feed) is not reflected in these sums. */
86
+ partial: boolean;
87
+ /** "<venue>.<part>" left out of the sums (balance, unrealizedPnl, positions, orders, stale). */
88
+ unavailable: string[];
59
89
  };
60
90
  arb: {
61
91
  opportunities: ArbOpportunity[];
@@ -73,6 +103,13 @@ export interface DashboardOpts {
73
103
  arbInterval?: number;
74
104
  signal?: AbortSignal;
75
105
  }
106
+ /**
107
+ * REST read of every venue. A failed read is null + its reason in `error` — never a $0
108
+ * balance or an empty position / order list (those would read as "flat, no money").
109
+ */
110
+ export declare function readExchangesRest(exchanges: DashboardExchange[]): Promise<DashboardExchangeData[]>;
111
+ /** Merge HL dex entries + compute totals. Unknown (null) parts are skipped and listed in totals.unavailable. */
112
+ export declare function mergeAndTotal(exchangeData: DashboardExchangeData[]): DashboardSnapshot;
76
113
  /**
77
114
  * Start the dashboard HTTP + WebSocket server.
78
115
  */