perp-cli 0.5.2 → 0.6.0

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.
package/dist/index.js CHANGED
@@ -35,7 +35,8 @@ import { registerPlanCommands } from "./commands/plan.js";
35
35
  // funding merged into arb.ts
36
36
  import { registerBacktestCommands } from "./commands/backtest.js";
37
37
  import { registerDashboardCommands } from "./commands/dashboard.js";
38
- import { registerInitCommand, EXCHANGE_ENV_MAP, validateKey } from "./commands/init.js";
38
+ import { registerInitCommand } from "./commands/init.js";
39
+ import { registerAlertCommands } from "./commands/alerts.js";
39
40
  import { loadSettings, saveSettings } from "./settings.js";
40
41
  import { setSharedApiNetwork } from "./shared-api.js";
41
42
  const _require = createRequire(import.meta.url);
@@ -312,6 +313,7 @@ registerPlanCommands(program, getAdapter, isJson);
312
313
  registerBacktestCommands(program, isJson);
313
314
  registerDashboardCommands(program, getAdapterForExchange, isJson, getHLAdapterForDex);
314
315
  registerInitCommand(program);
316
+ registerAlertCommands(program, isJson);
315
317
  // Agent discovery: perp api-spec — deprecated, use 'perp agent schema'
316
318
  const apiSpecCmd = program
317
319
  .command("api-spec")
@@ -322,16 +324,284 @@ const apiSpecCmd = program
322
324
  printJson(jsonOk(getCliSpec(program)));
323
325
  });
324
326
  apiSpecCmd._hidden = true;
325
- // Deprecated: perp status → use 'perp portfolio' or 'perp account'
326
- const statusCmd = program.command("status").description("Use 'perp portfolio'")
327
- .option("--health", "Check connectivity").action(async (opts) => {
327
+ // Unified dashboard: balances + positions + top arb opportunities
328
+ program.command("status")
329
+ .description("Unified dashboard: balances, positions, and top arb opportunities")
330
+ .option("--health", "Check connectivity only")
331
+ .action(async (opts) => {
328
332
  if (opts.health) {
329
333
  const { runHealthCheck } = await import("./commands/risk.js");
330
334
  return runHealthCheck(isJson);
331
335
  }
332
- console.log("Use 'perp portfolio' or 'perp account' instead.");
336
+ const json = isJson();
337
+ const { formatUsd, formatPnl, makeTable, printJson, jsonOk, withJsonErrors } = await import("./utils.js");
338
+ const { fetchAllFundingRates, TOP_SYMBOLS } = await import("./funding-rates.js");
339
+ const { saveFundingSnapshot, getHistoricalRates } = await import("./funding-history.js");
340
+ await withJsonErrors(json, async () => {
341
+ const EX_LIST = ["pacifica", "hyperliquid", "lighter"];
342
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
343
+ const snapshotPromises = EX_LIST.map(async (ex) => {
344
+ try {
345
+ const adapter = await getAdapterForExchange(ex);
346
+ const [balance, positions, orders] = await Promise.all([
347
+ adapter.getBalance(), adapter.getPositions(), adapter.getOpenOrders(),
348
+ ]);
349
+ // Fetch spot balances for HL and LT (Pacifica is perp-only)
350
+ let spotHoldings = [];
351
+ let isUnified = false;
352
+ try {
353
+ if (ex === "hyperliquid") {
354
+ const { HyperliquidSpotAdapter } = await import("./exchanges/hyperliquid-spot.js");
355
+ const hlSpot = new HyperliquidSpotAdapter(adapter);
356
+ await hlSpot.init();
357
+ const [raw, markets] = await Promise.all([hlSpot.getSpotBalances(), hlSpot.getSpotMarkets()]);
358
+ const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
359
+ const strip = (t) => t.replace(/-SPOT$/i, "").toUpperCase();
360
+ spotHoldings = raw.filter(b => Number(b.total) > 0).map(b => {
361
+ const base = strip(b.token);
362
+ return { ...b, valueUsd: base === "USDC" ? Number(b.total) : (priceMap.get(base) ?? 0) * Number(b.total) };
363
+ });
364
+ isUnified = !adapter.dex;
365
+ }
366
+ else if (ex === "lighter") {
367
+ const { LighterAdapter } = await import("./exchanges/lighter.js");
368
+ const { LighterSpotAdapter } = await import("./exchanges/lighter-spot.js");
369
+ const ltSpot = new LighterSpotAdapter(adapter);
370
+ await ltSpot.init();
371
+ const [raw, markets] = await Promise.all([ltSpot.getSpotBalances(), ltSpot.getSpotMarkets()]);
372
+ const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
373
+ spotHoldings = raw.filter(b => Number(b.total) > 0).map(b => ({
374
+ ...b,
375
+ valueUsd: b.token === "USDC" || b.token === "USDC_SPOT" ? Number(b.total) : (priceMap.get(b.token.toUpperCase()) ?? 0) * Number(b.total),
376
+ }));
377
+ }
378
+ }
379
+ catch { /* spot not available */ }
380
+ return { exchange: ex, connected: true, balance, positions, openOrders: orders.length, spotHoldings, isUnified, error: undefined };
381
+ }
382
+ catch (err) {
383
+ return { exchange: ex, connected: false, balance: null, positions: [], openOrders: 0, spotHoldings: [], isUnified: false, error: err instanceof Error ? err.message : String(err) };
384
+ }
385
+ });
386
+ const arbPromise = fetchAllFundingRates({ symbols: TOP_SYMBOLS, minSpread: 0 }).catch(() => null);
387
+ const [snapshots, arbSnapshot] = await Promise.all([Promise.all(snapshotPromises), arbPromise]);
388
+ // Save funding snapshot for sparkline history
389
+ if (arbSnapshot) {
390
+ try {
391
+ const allRates = arbSnapshot.symbols.flatMap(s => s.rates);
392
+ if (allRates.length > 0)
393
+ saveFundingSnapshot(allRates);
394
+ }
395
+ catch { /* */ }
396
+ }
397
+ // Build totals (include non-USDC spot value for unified accounts)
398
+ let totalEquity = 0;
399
+ let totalSpotValue = 0;
400
+ const allPositions = [];
401
+ const allSpotHoldings = [];
402
+ for (const s of snapshots) {
403
+ if (s.balance)
404
+ totalEquity += Number(s.balance.equity);
405
+ for (const p of s.positions)
406
+ allPositions.push({ ...p, exchange: s.exchange });
407
+ // For unified accounts (HL), USDC is already in perp equity — only add non-USDC spot
408
+ const displaySpot = s.isUnified
409
+ ? s.spotHoldings.filter(b => b.token.replace(/-SPOT$/i, "").toUpperCase() !== "USDC")
410
+ : s.spotHoldings;
411
+ const spotVal = displaySpot.reduce((sum, b) => sum + b.valueUsd, 0);
412
+ totalSpotValue += spotVal;
413
+ totalEquity += spotVal;
414
+ for (const b of displaySpot)
415
+ allSpotHoldings.push({ ...b, exchange: s.exchange });
416
+ }
417
+ // Top arb
418
+ const topArb = arbSnapshot
419
+ ? arbSnapshot.symbols.filter(s => s.maxSpreadAnnual >= 5).sort((a, b) => b.maxSpreadAnnual - a.maxSpreadAnnual).slice(0, 5)
420
+ : [];
421
+ // Risk
422
+ const marginUsed = snapshots.reduce((s, x) => s + (x.balance ? Number(x.balance.marginUsed) : 0), 0);
423
+ const marginPct = totalEquity > 0 ? (marginUsed / totalEquity) * 100 : 0;
424
+ const riskLevel = marginPct < 30 ? "LOW" : marginPct < 60 ? "MEDIUM" : "HIGH";
425
+ if (json) {
426
+ const now = new Date();
427
+ const h24 = new Date(now.getTime() - 24 * 3600_000);
428
+ return printJson(jsonOk({
429
+ version: _pkg.version,
430
+ totalEquity,
431
+ riskLevel,
432
+ exchanges: snapshots.map(s => ({
433
+ name: s.exchange, connected: s.connected,
434
+ equity: s.balance ? Number(s.balance.equity) : 0,
435
+ available: s.balance ? Number(s.balance.available) : 0,
436
+ spotHoldings: s.spotHoldings.filter(b => Number(b.total) > 0).map(b => ({
437
+ token: b.token, total: b.total, available: b.available, valueUsd: b.valueUsd,
438
+ })),
439
+ })),
440
+ totalSpotValue,
441
+ positions: allPositions,
442
+ topArbOpportunities: await (async () => {
443
+ const { getHistoricalAverages: getAvgs } = await import("./funding-history.js");
444
+ const syms = topArb.map(a => a.symbol);
445
+ const avgs = syms.length > 0 ? getAvgs(syms, ["hyperliquid", "pacifica", "lighter"]) : new Map();
446
+ return topArb.map(a => {
447
+ const bestEx = a.rates.find(r => r.exchange === "hyperliquid")?.exchange ?? a.rates[0]?.exchange ?? "hyperliquid";
448
+ const avg = avgs.get(`${a.symbol}:${bestEx}`);
449
+ return {
450
+ symbol: a.symbol, spreadAnnual: a.maxSpreadAnnual,
451
+ direction: `${exAbbr(a.shortExchange)}>${exAbbr(a.longExchange)}`,
452
+ avg24h: avg?.avg24h != null ? Math.abs(avg.avg24h) * 8760 * 100 : null,
453
+ avg7d: avg?.avg7d != null ? Math.abs(avg.avg7d) * 8760 * 100 : null,
454
+ rateHistory: getHistoricalRates(a.symbol, bestEx, h24, now).map(h => ({ ts: h.ts, hourlyRate: h.hourlyRate })),
455
+ };
456
+ });
457
+ })(),
458
+ }));
459
+ }
460
+ // ── Terminal UI ──
461
+ console.log(chalk.cyan.bold(`\n perp-cli v${_pkg.version}`) + chalk.gray(` — ${snapshots.filter(s => s.connected).length} exchanges connected\n`));
462
+ // Balance bars + Top Arb side by side
463
+ const now = new Date();
464
+ const h24 = new Date(now.getTime() - 24 * 3600_000);
465
+ // Left column: Balances (perp + spot per exchange)
466
+ // Compute per-exchange totals: perp equity + non-USDC spot (unified) or all spot
467
+ const exTotals = snapshots.map(s => {
468
+ if (!s.connected || !s.balance)
469
+ return { total: 0, margin: 0, equity: 0 };
470
+ const equity = Number(s.balance.equity);
471
+ const margin = Number(s.balance.marginUsed);
472
+ const displaySpot = s.isUnified
473
+ ? s.spotHoldings.filter(b => b.token.replace(/-SPOT$/i, "").toUpperCase() !== "USDC")
474
+ : s.spotHoldings;
475
+ const spotVal = displaySpot.reduce((sum, b) => sum + b.valueUsd, 0);
476
+ return { total: equity + spotVal, margin, equity };
477
+ });
478
+ const balLines = [];
479
+ balLines.push(chalk.white.bold(" Balances"));
480
+ for (let i = 0; i < snapshots.length; i++) {
481
+ const s = snapshots[i];
482
+ if (!s.connected) {
483
+ balLines.push(` ${chalk.gray(exAbbr(s.exchange).padEnd(4))} ${chalk.red("disconnected")}`);
484
+ continue;
485
+ }
486
+ const { total, margin, equity } = exTotals[i];
487
+ const usagePct = equity > 0 ? (margin / equity) * 100 : 0;
488
+ const barFull = Math.min(20, Math.round(usagePct / 5));
489
+ const usageColor = usagePct < 30 ? chalk.green : usagePct < 60 ? chalk.yellow : chalk.red;
490
+ const bar = usageColor("\u2588".repeat(barFull)) + chalk.gray("\u2591".repeat(20 - barFull));
491
+ balLines.push(` ${chalk.white.bold(exAbbr(s.exchange).padEnd(4))} $${formatUsd(total).padEnd(9)} ${bar} ${usagePct.toFixed(0)}% used`);
492
+ }
493
+ // Spot holdings (non-USDC tokens with value)
494
+ const displaySpotHoldings = allSpotHoldings.filter(b => {
495
+ const tk = b.token.replace(/[-_]SPOT$/i, "").toUpperCase();
496
+ return tk !== "USDC";
497
+ });
498
+ if (displaySpotHoldings.length > 0) {
499
+ balLines.push("");
500
+ balLines.push(chalk.white.bold(" Spot Holdings"));
501
+ for (const b of displaySpotHoldings) {
502
+ const token = b.token.replace(/-SPOT$/i, "");
503
+ const ex = exAbbr(b.exchange);
504
+ balLines.push(` ${chalk.gray(ex.padEnd(4))} ${chalk.white.bold(token.padEnd(6))} ${b.total.padEnd(12)} ${chalk.gray(`$${formatUsd(b.valueUsd)}`)}`);
505
+ }
506
+ }
507
+ const riskColor = riskLevel === "LOW" ? chalk.green : riskLevel === "MEDIUM" ? chalk.yellow : chalk.red;
508
+ balLines.push(` ${"─".repeat(44)}`);
509
+ balLines.push(` ${chalk.cyan.bold("Total")} ${chalk.cyan.bold(`$${formatUsd(totalEquity)}`.padEnd(9))} Risk: ${riskColor(riskLevel)}`);
510
+ // Right column: Top Arb Opportunities (with 24h/7d averages from local history — 0 API calls)
511
+ const { getHistoricalAverages } = await import("./funding-history.js");
512
+ const arbSymbols = topArb.map(a => a.symbol);
513
+ const arbExchanges = ["hyperliquid", "pacifica", "lighter"];
514
+ const histAvgs = arbSymbols.length > 0 ? getHistoricalAverages(arbSymbols, arbExchanges) : new Map();
515
+ const arbLines = [];
516
+ arbLines.push(chalk.white.bold(" Top Arb Opportunities"));
517
+ if (topArb.length > 0) {
518
+ // SYMBOL SPREAD 24h TREND 7d DIR
519
+ arbLines.push(chalk.gray(" " + "".padEnd(8) + "now".padEnd(9) + "24h".padEnd(7) + "".padEnd(9) + "7d".padEnd(7)));
520
+ for (const a of topArb) {
521
+ const spreadColor = a.maxSpreadAnnual >= 100 ? chalk.green.bold : a.maxSpreadAnnual >= 30 ? chalk.green : chalk.yellow;
522
+ const dir = `${exAbbr(a.shortExchange)}>${exAbbr(a.longExchange)}`;
523
+ const bestEx = a.rates.find(r => r.exchange === "hyperliquid")?.exchange ?? a.rates[0]?.exchange ?? "hyperliquid";
524
+ // 24h trend arrow
525
+ const history = getHistoricalRates(a.symbol, bestEx, h24, now);
526
+ let trend = "";
527
+ if (history.length >= 2) {
528
+ const oldAnn = Math.abs(history[0].hourlyRate) * 8760 * 100;
529
+ const newAnn = Math.abs(history[history.length - 1].hourlyRate) * 8760 * 100;
530
+ const delta = newAnn - oldAnn;
531
+ const abs = Math.abs(delta);
532
+ trend = abs < 1 ? chalk.gray(`\u2500${abs.toFixed(0)}%`)
533
+ : delta > 0 ? chalk.red(`\u25B2+${abs.toFixed(0)}%`)
534
+ : chalk.green(`\u25BC-${abs.toFixed(0)}%`);
535
+ }
536
+ // 24h and 7d averages (annualized from hourly)
537
+ const avg = histAvgs.get(`${a.symbol}:${bestEx}`);
538
+ const avg24h = avg?.avg24h != null ? `${(Math.abs(avg.avg24h) * 8760 * 100).toFixed(0)}%` : "-";
539
+ const avg7d = avg?.avg7d != null ? `${(Math.abs(avg.avg7d) * 8760 * 100).toFixed(0)}%` : "-";
540
+ arbLines.push(` ${chalk.white.bold(a.symbol.padEnd(8))} ${spreadColor(`${a.maxSpreadAnnual.toFixed(1)}%`.padEnd(9))}${chalk.gray(avg24h.padEnd(7))}${trend.padEnd(9)}${chalk.gray(avg7d.padEnd(7))}${chalk.gray(dir)}`);
541
+ }
542
+ }
543
+ else {
544
+ arbLines.push(chalk.gray(" No opportunities above 5%"));
545
+ }
546
+ // Print layout — side-by-side if terminal is wide enough, else stacked
547
+ const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
548
+ const termW = process.stdout.columns || 80;
549
+ const SIDE_BY_SIDE_MIN = 100; // 2 indent + 1 + 46 + 1 + 48 + 1
550
+ if (termW >= SIDE_BY_SIDE_MIN) {
551
+ const LEFT_W = 46;
552
+ const RIGHT_W = 48;
553
+ const maxLines = Math.max(balLines.length, arbLines.length);
554
+ console.log(` ${"┌"}${"─".repeat(LEFT_W)}${"┬"}${"─".repeat(RIGHT_W)}${"┐"}`);
555
+ for (let i = 0; i < maxLines; i++) {
556
+ const left = balLines[i] ?? "";
557
+ const right = arbLines[i] ?? "";
558
+ const leftPad = LEFT_W - stripAnsi(left).length;
559
+ const rightPad = RIGHT_W - stripAnsi(right).length;
560
+ console.log(` \u2502${left}${" ".repeat(Math.max(0, leftPad))}\u2502${right}${" ".repeat(Math.max(0, rightPad))}\u2502`);
561
+ }
562
+ console.log(` ${"└"}${"─".repeat(LEFT_W)}${"┴"}${"─".repeat(RIGHT_W)}${"┘"}`);
563
+ }
564
+ else {
565
+ // Stacked layout for narrow terminals
566
+ const boxW = Math.min(termW - 4, 74); // 2 indent + 2 borders
567
+ const printBox = (lines) => {
568
+ console.log(` ${"┌"}${"─".repeat(boxW)}${"┐"}`);
569
+ for (const line of lines) {
570
+ const pad = boxW - stripAnsi(line).length;
571
+ console.log(` \u2502${line}${" ".repeat(Math.max(0, pad))}\u2502`);
572
+ }
573
+ console.log(` ${"└"}${"─".repeat(boxW)}${"┘"}`);
574
+ };
575
+ printBox(balLines);
576
+ console.log();
577
+ printBox(arbLines);
578
+ }
579
+ // Positions
580
+ if (allPositions.length > 0) {
581
+ console.log(chalk.white.bold("\n Positions"));
582
+ const posRows = allPositions.map(p => {
583
+ const sideColor = p.side === "long" ? chalk.green : chalk.red;
584
+ const notional = Math.abs(Number(p.size) * Number(p.markPrice));
585
+ const levStr = p.leverage > 0 ? `${p.leverage}x` : "-";
586
+ const warn = p.leverage >= 5 ? chalk.red(" \u26A0") : "";
587
+ return [
588
+ chalk.white.bold(p.symbol.replace("-PERP", "")),
589
+ chalk.gray(exAbbr(p.exchange)),
590
+ sideColor(p.side.toUpperCase()),
591
+ p.size,
592
+ `$${formatUsd(p.entryPrice)}\u2192$${formatUsd(p.markPrice)}`,
593
+ formatPnl(p.unrealizedPnl),
594
+ `$${formatUsd(notional)}`,
595
+ levStr + warn,
596
+ ];
597
+ });
598
+ console.log(makeTable(["Symbol", "Ex", "Side", "Size", "Entry\u2192Mark", "uPnL", "Notional", "Lev"], posRows));
599
+ }
600
+ else {
601
+ console.log(chalk.gray("\n No open positions.\n"));
602
+ }
603
+ });
333
604
  });
334
- statusCmd._hidden = true;
335
605
  // Switch shared API URLs if --network testnet is used
336
606
  program.hook("preAction", () => {
337
607
  const network = program.opts().network;
@@ -351,62 +621,74 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
351
621
  const hasEnvKey = !!(process.env.PRIVATE_KEY || process.env.PACIFICA_PRIVATE_KEY ||
352
622
  process.env.HL_PRIVATE_KEY || process.env.HYPERLIQUID_PRIVATE_KEY ||
353
623
  process.env.LIGHTER_PRIVATE_KEY);
624
+ // ── ASCII banner ──
625
+ const banner = [
626
+ chalk.cyan(" ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"),
627
+ chalk.cyan(" ┃") + chalk.white.bold(" perp-cli ") + chalk.gray(`v${_pkg.version}`) + " ".repeat(Math.max(0, 32 - _pkg.version.length)) + chalk.cyan("┃"),
628
+ chalk.cyan(" ┃") + chalk.gray(" Multi-DEX Perpetual Futures ") + chalk.cyan("┃"),
629
+ chalk.cyan(" ┃") + chalk.gray(" Pacifica · Hyperliquid · Lighter ") + chalk.cyan("┃"),
630
+ chalk.cyan(" ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"),
631
+ ];
632
+ console.log("\n" + banner.join("\n"));
354
633
  if (!status.hasWallets && !hasEnvKey && !settings.defaultExchange) {
355
634
  // Fresh install — onboarding
356
- console.log(chalk.cyan.bold("\n Welcome to perp-cli!") + chalk.gray(` v${_pkg.version}\n`));
357
- console.log(" Multi-DEX perpetual futures CLI for Pacifica, Hyperliquid, and Lighter.\n");
358
- console.log(` Get started: ${chalk.cyan("perp wallet set <exchange> <key>")}`);
635
+ console.log(chalk.yellow.bold("\n ⚡ Get started:\n"));
636
+ console.log(` ${chalk.cyan("perp wallet set <exchange> <key>")}`);
359
637
  console.log(chalk.gray(`\n Or explore without a wallet:`));
360
638
  console.log(` ${chalk.green("perp market list")} available markets`);
361
- console.log(` ${chalk.green("perp -e hyperliquid market list")} Hyperliquid markets`);
639
+ console.log(` ${chalk.green("perp -e hl market list")} Hyperliquid markets`);
362
640
  console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
363
641
  console.log(` ${chalk.green("perp --help")} all commands\n`);
364
642
  }
365
643
  else {
366
- // Configured — show status overview
367
- const defaultEx = settings.defaultExchange || "pacifica";
368
- const activeEntries = Object.entries(status.active);
369
- console.log(chalk.cyan.bold("\n perp-cli") + chalk.gray(` v${_pkg.version}\n`));
370
- console.log(` Default exchange: ${chalk.cyan(defaultEx)}`);
371
- if (activeEntries.length > 0) {
372
- console.log(chalk.white.bold("\n Wallets:"));
373
- for (const [exchange, walletName] of activeEntries) {
374
- const w = status.wallets[walletName];
375
- if (w) {
376
- const addr = w.address.length > 20
377
- ? w.address.slice(0, 6) + "..." + w.address.slice(-4)
378
- : w.address;
379
- console.log(` ${chalk.cyan(exchange.padEnd(14))} ${chalk.white(walletName)} ${chalk.gray(addr)}`);
380
- }
644
+ // Configured — show exchange status + balance
645
+ const EX_NAMES = ["pacifica", "hyperliquid", "lighter"];
646
+ const exLabel = (e) => e === "pacifica" ? "Pacifica" : e === "hyperliquid" ? "Hyperliquid" : "Lighter";
647
+ // Ping + balance in parallel (with 5s timeout to keep landing fast)
648
+ const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]);
649
+ const statusResults = await Promise.allSettled(EX_NAMES.map(async (ex) => {
650
+ try {
651
+ const adapter = await withTimeout(getAdapterForExchange(ex), 5000);
652
+ const [balance, positions] = await withTimeout(Promise.all([adapter.getBalance(), adapter.getPositions()]), 5000);
653
+ const posCount = positions.filter(p => Number(p.size) > 0).length;
654
+ return { exchange: ex, ok: true, equity: Number(balance.equity), positions: posCount };
381
655
  }
382
- }
383
- else if (hasEnvKey) {
384
- console.log(chalk.white.bold("\n Configured:"));
385
- for (const [exchange, info] of Object.entries(EXCHANGE_ENV_MAP)) {
386
- const key = process.env[info.envKey];
387
- if (key) {
388
- try {
389
- const { valid, address } = await validateKey(info.chain, key);
390
- const addr = valid ? address : "(invalid key)";
391
- console.log(` ${chalk.cyan(exchange.padEnd(14))} ${chalk.green(addr)}`);
392
- }
393
- catch {
394
- console.log(` ${chalk.cyan(exchange.padEnd(14))} ${chalk.gray("(error reading key)")}`);
395
- }
396
- }
656
+ catch {
657
+ return { exchange: ex, ok: false, equity: 0, positions: 0 };
658
+ }
659
+ }));
660
+ console.log(chalk.white.bold("\n Exchanges:"));
661
+ let totalEquity = 0;
662
+ let totalPositions = 0;
663
+ for (const r of statusResults) {
664
+ if (r.status !== "fulfilled")
665
+ continue;
666
+ const s = r.value;
667
+ const icon = s.ok ? chalk.green("●") : chalk.red("○");
668
+ const eq = s.ok ? chalk.white(`$${Number(s.equity).toLocaleString("en", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`) : chalk.gray("—");
669
+ const pos = s.ok && s.positions > 0 ? chalk.yellow(` ${s.positions} pos`) : "";
670
+ console.log(` ${icon} ${chalk.cyan(exLabel(s.exchange).padEnd(14))} ${eq}${pos}`);
671
+ if (s.ok) {
672
+ totalEquity += s.equity;
673
+ totalPositions += s.positions;
397
674
  }
398
675
  }
399
- console.log(chalk.white.bold("\n Quick commands:"));
676
+ if (totalEquity > 0) {
677
+ console.log(chalk.gray(" ─".repeat(20)));
678
+ console.log(` ${chalk.white.bold("Total".padEnd(16))} ${chalk.white.bold(`$${totalEquity.toLocaleString("en", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)}${totalPositions > 0 ? chalk.yellow(` ${totalPositions} pos`) : ""}`);
679
+ }
680
+ console.log(chalk.white.bold("\n Commands:"));
400
681
  console.log(` ${chalk.green("perp portfolio")} balances + positions + risk`);
401
- console.log(` ${chalk.green("perp market list")} available markets`);
682
+ console.log(` ${chalk.green("perp status")} full dashboard`);
402
683
  console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
403
- console.log(` ${chalk.green("perp dashboard")} live monitoring`);
684
+ console.log(` ${chalk.green("perp dashboard")} live web monitoring`);
404
685
  console.log(` ${chalk.green("perp --help")} all commands\n`);
405
686
  }
406
687
  }
407
688
  catch {
408
689
  program.help();
409
690
  }
691
+ setTimeout(() => process.exit(0), 500);
410
692
  })();
411
693
  }
412
694
  else {