perp-cli 0.5.3 → 0.6.1

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.
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { printJson, jsonOk, jsonError, makeTable, formatUsd, formatPnl, withJsonErrors } from "../utils.js";
3
3
  import { readExecutionLog, getExecutionStats, pruneExecutionLog } from "../execution-log.js";
4
+ import { loadArbState } from "../arb/state.js";
4
5
  import { readPositionHistory, getPositionStats } from "../position-history.js";
5
6
  import { saveEquitySnapshot, readEquityHistory, computePnlMetrics, computeDailyPnl, aggregateWeekly, } from "../equity-tracker.js";
6
7
  const EXCHANGES = ["pacifica", "hyperliquid", "lighter"];
@@ -15,6 +16,17 @@ function parseSince(since) {
15
16
  }
16
17
  return since;
17
18
  }
19
+ /** Parse period string (7d, 30d, 90d) to milliseconds. Returns undefined for "all" or missing. */
20
+ function parsePeriodMs(period) {
21
+ if (!period || period === "all")
22
+ return undefined;
23
+ const match = period.match(/^(\d+)(h|d|w|m)$/);
24
+ if (!match)
25
+ return undefined;
26
+ const [, num, unit] = match;
27
+ const ms = { h: 3600000, d: 86400000, w: 604800000, m: 2592000000 }[unit] ?? 86400000;
28
+ return parseInt(num) * ms;
29
+ }
18
30
  export function registerHistoryCommands(program, isJson, getAdapterForExchange) {
19
31
  const history = program.command("history").description("Execution log & audit trail");
20
32
  // ── history list ──
@@ -314,158 +326,281 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
314
326
  // ── analytics pnl ──
315
327
  parent
316
328
  .command("pnl")
317
- .description("Realized P&L from exchange trade history (volume & fees by symbol/exchange)")
329
+ .description("P&L analysis — trades, fees, funding income with daily breakdown")
318
330
  .option("-e, --exchange <exchanges>", "Comma-separated exchanges")
319
- .option("--since <period>", "Period: 24h, 7d, 30d")
320
- .option("-n, --limit <n>", "Trade history limit per exchange", "100")
331
+ .option("--period <duration>", "Time period: 7d, 30d, 90d, all (default: all)")
332
+ .option("--since <period>", "Alias for --period")
333
+ .option("-n, --limit <n>", "History limit per exchange", "200")
321
334
  .action(async (opts) => {
322
335
  await withJsonErrors(isJson(), async () => {
323
336
  const exchanges = opts.exchange
324
337
  ? opts.exchange.split(",").map(e => e.trim())
325
338
  : [...EXCHANGES];
339
+ const period = opts.period ?? opts.since;
340
+ const periodMs = parsePeriodMs(period);
341
+ const sinceMs = periodMs ? Date.now() - periodMs : 0;
326
342
  const limit = parseInt(opts.limit);
327
- const sinceMs = opts.since ? new Date(parseSince(opts.since)).getTime() : 0;
328
343
  const allTrades = [];
344
+ const allFunding = [];
345
+ // Fetch trades + funding in parallel from all exchanges
329
346
  await Promise.allSettled(exchanges.map(async (ex) => {
330
347
  try {
331
348
  const adapter = await getAdapterForExchange(ex);
332
- const trades = await adapter.getTradeHistory(limit);
349
+ const [trades, funding] = await Promise.all([
350
+ adapter.getTradeHistory(limit),
351
+ adapter.getFundingPayments(limit),
352
+ ]);
333
353
  for (const t of trades) {
334
354
  if (sinceMs && t.time < sinceMs)
335
355
  continue;
336
- allTrades.push({
337
- exchange: ex,
338
- symbol: t.symbol,
339
- side: t.side,
340
- price: Number(t.price),
341
- size: Number(t.size),
342
- fee: Number(t.fee),
343
- time: t.time,
344
- });
356
+ 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 });
357
+ }
358
+ for (const f of funding) {
359
+ if (sinceMs && f.time < sinceMs)
360
+ continue;
361
+ allFunding.push({ exchange: ex, symbol: f.symbol, payment: Number(f.payment), time: f.time });
345
362
  }
346
363
  }
347
364
  catch { /* skip unavailable exchanges */ }
348
365
  }));
349
- // Aggregate by symbol
350
- const bySymbol = new Map();
366
+ // Aggregate by exchange
351
367
  const byExchange = new Map();
352
368
  let totalVolume = 0;
353
369
  let totalFees = 0;
370
+ let totalFunding = 0;
354
371
  for (const t of allTrades) {
355
372
  const notional = t.price * t.size;
356
373
  totalVolume += notional;
357
374
  totalFees += t.fee;
358
- const sym = bySymbol.get(t.symbol) ?? { volume: 0, fees: 0, trades: 0 };
359
- sym.volume += notional;
360
- sym.fees += t.fee;
361
- sym.trades++;
362
- bySymbol.set(t.symbol, sym);
363
- const ex = byExchange.get(t.exchange) ?? { volume: 0, fees: 0, trades: 0 };
375
+ const ex = byExchange.get(t.exchange) ?? { trades: 0, volume: 0, fees: 0, funding: 0 };
376
+ ex.trades++;
364
377
  ex.volume += notional;
365
378
  ex.fees += t.fee;
366
- ex.trades++;
367
379
  byExchange.set(t.exchange, ex);
368
380
  }
381
+ for (const f of allFunding) {
382
+ totalFunding += f.payment;
383
+ const ex = byExchange.get(f.exchange) ?? { trades: 0, volume: 0, fees: 0, funding: 0 };
384
+ ex.funding += f.payment;
385
+ byExchange.set(f.exchange, ex);
386
+ }
387
+ const netPnl = totalFunding - totalFees;
388
+ // Daily PnL breakdown (fees + funding combined)
389
+ const dailyMap = new Map();
390
+ for (const t of allTrades) {
391
+ const day = new Date(t.time).toISOString().slice(0, 10);
392
+ const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
393
+ d.fees += t.fee;
394
+ dailyMap.set(day, d);
395
+ }
396
+ for (const f of allFunding) {
397
+ const day = new Date(f.time).toISOString().slice(0, 10);
398
+ const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
399
+ d.funding += f.payment;
400
+ dailyMap.set(day, d);
401
+ }
402
+ const daily = [...dailyMap.entries()]
403
+ .sort()
404
+ .map(([date, d]) => ({ date, fees: d.fees, funding: d.funding, net: d.funding - d.fees }));
369
405
  const result = {
406
+ period: period ?? "all",
370
407
  totalTrades: allTrades.length,
371
408
  totalVolume,
372
409
  totalFees,
373
- netAfterFees: -totalFees, // realized PnL would need entry/exit matching; fees are definite cost
374
- bySymbol: Object.fromEntries(bySymbol),
375
- byExchange: Object.fromEntries(byExchange),
410
+ totalFunding,
411
+ netPnl,
412
+ byExchange: Object.fromEntries([...byExchange.entries()].map(([ex, d]) => [ex, { ...d, netPnl: d.funding - d.fees }])),
413
+ daily,
376
414
  };
377
415
  if (isJson())
378
416
  return printJson(jsonOk(result));
379
- console.log(chalk.cyan.bold(`\n Realized Trading P&L ${opts.since ? `(${opts.since})` : ""}\n`));
417
+ // ── Terminal output ──
418
+ console.log(chalk.cyan.bold(`\n P&L Report${period ? ` (${period})` : ""}\n`));
380
419
  console.log(` Total Trades: ${allTrades.length}`);
381
420
  console.log(` Total Volume: $${formatUsd(totalVolume)}`);
382
- console.log(` Total Fees: ${chalk.red(`-$${formatUsd(totalFees)}`)}`);
383
- if (bySymbol.size > 0) {
384
- console.log(chalk.white.bold("\n By Symbol:"));
385
- const symRows = [...bySymbol.entries()]
386
- .sort((a, b) => b[1].volume - a[1].volume)
387
- .map(([sym, d]) => [
388
- chalk.white.bold(sym),
389
- String(d.trades),
390
- `$${formatUsd(d.volume)}`,
391
- chalk.red(`-$${formatUsd(d.fees)}`),
392
- ]);
393
- console.log(makeTable(["Symbol", "Trades", "Volume", "Fees"], symRows));
394
- }
421
+ console.log(` Trading Fees: ${chalk.red(`-$${formatUsd(totalFees)}`)}`);
422
+ console.log(` Funding Income: ${formatPnl(totalFunding)}`);
423
+ console.log(` Net P&L: ${formatPnl(netPnl)}`);
395
424
  if (byExchange.size > 0) {
396
- console.log(chalk.white.bold(" By Exchange:"));
397
- const exRows = [...byExchange.entries()].map(([ex, d]) => [
425
+ console.log(chalk.white.bold("\n By Exchange:"));
426
+ const rows = [...byExchange.entries()].map(([ex, d]) => [
398
427
  chalk.white.bold(ex),
399
428
  String(d.trades),
400
429
  `$${formatUsd(d.volume)}`,
401
430
  chalk.red(`-$${formatUsd(d.fees)}`),
431
+ formatPnl(d.funding),
432
+ formatPnl(d.funding - d.fees),
402
433
  ]);
403
- console.log(makeTable(["Exchange", "Trades", "Volume", "Fees"], exRows));
434
+ console.log(makeTable(["Exchange", "Trades", "Volume", "Fees", "Funding", "Net P&L"], rows));
435
+ }
436
+ if (daily.length > 0) {
437
+ console.log(chalk.white.bold(" Daily P&L:"));
438
+ let cumulative = 0;
439
+ const rows = daily.map(d => {
440
+ cumulative += d.net;
441
+ return [d.date, chalk.red(`-$${formatUsd(d.fees)}`), formatPnl(d.funding), formatPnl(d.net), formatPnl(cumulative)];
442
+ });
443
+ console.log(makeTable(["Date", "Fees", "Funding", "Net", "Cumulative"], rows));
404
444
  }
405
445
  });
406
446
  });
407
447
  // ── analytics funding ──
408
448
  parent
409
449
  .command("funding")
410
- .description("Funding payment aggregation across exchanges (net by symbol/exchange)")
450
+ .description("Funding income analysis — cumulative, daily, per-arb-position breakdown")
411
451
  .option("-e, --exchange <exchanges>", "Comma-separated exchanges")
412
452
  .option("-s, --symbol <symbol>", "Filter by symbol")
413
- .option("-n, --limit <n>", "Funding history limit per exchange", "50")
453
+ .option("--period <duration>", "Time period: 7d, 30d, 90d, all (default: all)")
454
+ .option("--daily", "Show daily funding breakdown")
455
+ .option("-n, --limit <n>", "Funding history limit per exchange", "200")
414
456
  .action(async (opts) => {
415
457
  await withJsonErrors(isJson(), async () => {
416
458
  const exchanges = opts.exchange
417
459
  ? opts.exchange.split(",").map(e => e.trim())
418
460
  : [...EXCHANGES];
461
+ const periodMs = parsePeriodMs(opts.period);
462
+ const sinceMs = periodMs ? Date.now() - periodMs : 0;
419
463
  const allFunding = [];
464
+ // Fetch funding payments and current positions in parallel
465
+ const positionNotionals = new Map();
420
466
  await Promise.allSettled(exchanges.map(async (ex) => {
421
467
  try {
422
468
  const adapter = await getAdapterForExchange(ex);
423
- const payments = await adapter.getFundingPayments(parseInt(opts.limit));
469
+ const [payments, positions] = await Promise.all([
470
+ adapter.getFundingPayments(parseInt(opts.limit)),
471
+ adapter.getPositions(),
472
+ ]);
424
473
  for (const p of payments) {
474
+ if (sinceMs && p.time < sinceMs)
475
+ continue;
425
476
  if (opts.symbol && !p.symbol.toUpperCase().includes(opts.symbol.toUpperCase()))
426
477
  continue;
427
- allFunding.push({
428
- exchange: ex,
429
- symbol: p.symbol,
430
- payment: Number(p.payment),
431
- time: p.time,
432
- });
478
+ allFunding.push({ exchange: ex, symbol: p.symbol, payment: Number(p.payment), time: p.time });
479
+ }
480
+ for (const p of positions) {
481
+ if (Number(p.size) > 0) {
482
+ positionNotionals.set(`${ex}:${p.symbol}`, Math.abs(Number(p.size) * Number(p.markPrice)));
483
+ }
433
484
  }
434
485
  }
435
- catch { /* skip */ }
486
+ catch { /* skip unavailable exchanges */ }
436
487
  }));
437
- // Aggregate
438
- const bySymbol = new Map();
488
+ allFunding.sort((a, b) => a.time - b.time);
489
+ // Aggregate by exchange×symbol
490
+ const byExSym = new Map();
439
491
  const byExchange = new Map();
440
492
  let totalFunding = 0;
441
493
  for (const f of allFunding) {
442
494
  totalFunding += f.payment;
443
- bySymbol.set(f.symbol, (bySymbol.get(f.symbol) ?? 0) + f.payment);
495
+ const key = `${f.exchange}:${f.symbol}`;
496
+ const entry = byExSym.get(key) ?? { exchange: f.exchange, symbol: f.symbol, total: 0, count: 0 };
497
+ entry.total += f.payment;
498
+ entry.count++;
499
+ byExSym.set(key, entry);
444
500
  byExchange.set(f.exchange, (byExchange.get(f.exchange) ?? 0) + f.payment);
445
501
  }
502
+ // Period days for annualized rate
503
+ const periodDays = periodMs
504
+ ? periodMs / 86400000
505
+ : allFunding.length > 1
506
+ ? (allFunding[allFunding.length - 1].time - allFunding[0].time) / 86400000
507
+ : 1;
508
+ // Match funding to arb positions
509
+ const arbState = loadArbState();
510
+ const arbPositions = arbState?.positions ?? [];
511
+ const arbFunding = new Map();
512
+ for (const pos of arbPositions) {
513
+ arbFunding.set(pos.symbol, { longFunding: 0, shortFunding: 0 });
514
+ }
515
+ for (const f of allFunding) {
516
+ const arbEntry = arbFunding.get(f.symbol);
517
+ if (!arbEntry)
518
+ continue;
519
+ const pos = arbPositions.find(p => p.symbol === f.symbol);
520
+ if (f.exchange === pos.longExchange)
521
+ arbEntry.longFunding += f.payment;
522
+ else if (f.exchange === pos.shortExchange)
523
+ arbEntry.shortFunding += f.payment;
524
+ }
525
+ // Daily breakdown
526
+ const dailyMap = new Map();
527
+ for (const f of allFunding) {
528
+ const day = new Date(f.time).toISOString().slice(0, 10);
529
+ dailyMap.set(day, (dailyMap.get(day) ?? 0) + f.payment);
530
+ }
531
+ // Build result
532
+ const exSymRows = [...byExSym.values()].sort((a, b) => b.total - a.total).map(e => {
533
+ const notional = positionNotionals.get(`${e.exchange}:${e.symbol}`);
534
+ const annRate = notional && notional > 0 && periodDays > 0
535
+ ? (e.total / Math.max(1, periodDays)) * 365 / notional * 100
536
+ : null;
537
+ return { exchange: e.exchange, symbol: e.symbol, total: e.total, count: e.count, annualizedRate: annRate };
538
+ });
539
+ const arbRows = arbPositions
540
+ .map(pos => {
541
+ const af = arbFunding.get(pos.symbol);
542
+ if (!af || (af.longFunding === 0 && af.shortFunding === 0))
543
+ return null;
544
+ return {
545
+ symbol: pos.symbol,
546
+ mode: pos.mode ?? "perp-perp",
547
+ longExchange: pos.longExchange,
548
+ shortExchange: pos.shortExchange,
549
+ netFunding: af.longFunding + af.shortFunding,
550
+ longFunding: af.longFunding,
551
+ shortFunding: af.shortFunding,
552
+ daysSinceEntry: Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000)),
553
+ };
554
+ })
555
+ .filter((r) => r !== null);
556
+ const daily = [...dailyMap.entries()].sort().map(([date, amount]) => ({ date, amount }));
446
557
  const result = {
447
- totalPayments: allFunding.length,
558
+ period: opts.period ?? "all",
559
+ periodDays: Math.max(1, Math.round(periodDays)),
448
560
  totalFunding,
449
- bySymbol: Object.fromEntries(bySymbol),
561
+ totalPayments: allFunding.length,
562
+ byExchangeSymbol: exSymRows,
450
563
  byExchange: Object.fromEntries(byExchange),
564
+ arbPositions: arbRows,
565
+ ...(opts.daily ? { daily } : {}),
451
566
  };
452
567
  if (isJson())
453
568
  return printJson(jsonOk(result));
454
- console.log(chalk.cyan.bold("\n Funding Payment Summary\n"));
569
+ // ── Terminal output ──
570
+ console.log(chalk.cyan.bold(`\n Funding Income Report${opts.period ? ` (${opts.period})` : ""}\n`));
455
571
  console.log(` Total Payments: ${allFunding.length}`);
456
572
  console.log(` Net Funding: ${formatPnl(totalFunding)}`);
457
- if (bySymbol.size > 0) {
458
- console.log(chalk.white.bold("\n By Symbol:"));
459
- const rows = [...bySymbol.entries()]
460
- .sort((a, b) => b[1] - a[1])
461
- .map(([sym, amt]) => [chalk.white.bold(sym), formatPnl(amt)]);
462
- console.log(makeTable(["Symbol", "Net Funding"], rows));
573
+ console.log(` Period: ~${Math.round(periodDays)} days`);
574
+ if (exSymRows.length > 0) {
575
+ console.log(chalk.white.bold("\n By Exchange × Symbol:"));
576
+ const rows = exSymRows.map(e => [
577
+ chalk.white.bold(e.exchange),
578
+ e.symbol,
579
+ formatPnl(e.total),
580
+ String(e.count),
581
+ e.annualizedRate !== null ? `${e.annualizedRate >= 0 ? "+" : ""}${e.annualizedRate.toFixed(1)}%` : chalk.gray("—"),
582
+ ]);
583
+ console.log(makeTable(["Exchange", "Symbol", "Total", "Payments", "Ann.Rate"], rows));
463
584
  }
464
- if (byExchange.size > 0) {
465
- console.log(chalk.white.bold(" By Exchange:"));
466
- const rows = [...byExchange.entries()]
467
- .map(([ex, amt]) => [chalk.white.bold(ex), formatPnl(amt)]);
468
- console.log(makeTable(["Exchange", "Net Funding"], rows));
585
+ if (arbRows.length > 0) {
586
+ console.log(chalk.white.bold(" By Arb Position:"));
587
+ const rows = arbRows.map(a => [
588
+ chalk.white.bold(a.symbol),
589
+ `${a.longExchange} → ${a.shortExchange}`,
590
+ a.mode,
591
+ formatPnl(a.netFunding),
592
+ `${a.daysSinceEntry}d`,
593
+ ]);
594
+ console.log(makeTable(["Symbol", "Long → Short", "Mode", "Net Funding", "Held"], rows));
595
+ }
596
+ if (opts.daily && daily.length > 0) {
597
+ console.log(chalk.white.bold(" Daily Breakdown:"));
598
+ let cumulative = 0;
599
+ const rows = daily.map(d => {
600
+ cumulative += d.amount;
601
+ return [d.date, formatPnl(d.amount), formatPnl(cumulative)];
602
+ });
603
+ console.log(makeTable(["Date", "Funding", "Cumulative"], rows));
469
604
  }
470
605
  });
471
606
  });
@@ -558,6 +693,130 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
558
693
  console.log();
559
694
  });
560
695
  });
696
+ // ── analytics compare ──
697
+ parent
698
+ .command("compare")
699
+ .description("Compare active arb positions — ROI, realized PnL, funding income side by side")
700
+ .option("--period <duration>", "Funding lookup period: 7d, 30d, 90d, all (default: all)")
701
+ .option("-n, --limit <n>", "Funding history limit per exchange", "200")
702
+ .action(async (opts) => {
703
+ await withJsonErrors(isJson(), async () => {
704
+ const arbState = loadArbState();
705
+ const positions = arbState?.positions ?? [];
706
+ if (positions.length === 0) {
707
+ if (isJson())
708
+ return printJson(jsonOk({ positions: [], message: "No active arb positions" }));
709
+ console.log(chalk.yellow("\n No active arb positions. Use 'perp arb exec' to open one.\n"));
710
+ return;
711
+ }
712
+ const periodMs = parsePeriodMs(opts.period);
713
+ const sinceMs = periodMs ? Date.now() - periodMs : 0;
714
+ const limit = parseInt(opts.limit);
715
+ // Collect all unique exchanges from arb positions
716
+ const exchangeSet = new Set();
717
+ for (const pos of positions) {
718
+ exchangeSet.add(pos.longExchange);
719
+ exchangeSet.add(pos.shortExchange);
720
+ }
721
+ // Fetch funding + current positions from all relevant exchanges
722
+ const fundingByExSym = new Map();
723
+ const currentPrices = new Map();
724
+ await Promise.allSettled([...exchangeSet].map(async (ex) => {
725
+ try {
726
+ const adapter = await getAdapterForExchange(ex);
727
+ const [funding, livePositions] = await Promise.all([
728
+ adapter.getFundingPayments(limit),
729
+ adapter.getPositions(),
730
+ ]);
731
+ for (const f of funding) {
732
+ if (sinceMs && f.time < sinceMs)
733
+ continue;
734
+ const key = `${ex}:${f.symbol}`;
735
+ fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
736
+ }
737
+ for (const p of livePositions) {
738
+ currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
739
+ }
740
+ }
741
+ catch { /* skip */ }
742
+ }));
743
+ // Build comparison data
744
+ const rows = positions.map(pos => {
745
+ const daysHeld = Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000));
746
+ const longFunding = fundingByExSym.get(`${pos.longExchange}:${pos.symbol}`) ?? 0;
747
+ const shortFunding = fundingByExSym.get(`${pos.shortExchange}:${pos.symbol}`) ?? 0;
748
+ const netFunding = longFunding + shortFunding;
749
+ // Estimate unrealized PnL from price movement
750
+ const longPrice = currentPrices.get(`${pos.longExchange}:${pos.symbol}`) ?? pos.entryLongPrice;
751
+ const shortPrice = currentPrices.get(`${pos.shortExchange}:${pos.symbol}`) ?? pos.entryShortPrice;
752
+ const longPnl = (longPrice - pos.entryLongPrice) * pos.longSize;
753
+ const shortPnl = (pos.entryShortPrice - shortPrice) * pos.shortSize;
754
+ const pricePnl = longPnl + shortPnl;
755
+ const totalPnl = pricePnl + netFunding;
756
+ // Notional for ROI calculation
757
+ const notional = pos.entryLongPrice * pos.longSize + pos.entryShortPrice * pos.shortSize;
758
+ const roi = notional > 0 ? (totalPnl / notional) * 100 : 0;
759
+ const fundingRoi = notional > 0 ? (netFunding / notional) * 100 : 0;
760
+ const annualizedRoi = daysHeld > 0 ? (fundingRoi / daysHeld) * 365 : 0;
761
+ return {
762
+ symbol: pos.symbol,
763
+ mode: pos.mode ?? "perp-perp",
764
+ longExchange: pos.longExchange,
765
+ shortExchange: pos.shortExchange,
766
+ daysHeld,
767
+ entrySpread: pos.entrySpread,
768
+ funding: netFunding,
769
+ pricePnl,
770
+ totalPnl,
771
+ notional,
772
+ roi,
773
+ fundingRoi,
774
+ annualizedRoi,
775
+ };
776
+ });
777
+ // Sort by total PnL descending
778
+ rows.sort((a, b) => b.totalPnl - a.totalPnl);
779
+ const totals = {
780
+ funding: rows.reduce((s, r) => s + r.funding, 0),
781
+ pricePnl: rows.reduce((s, r) => s + r.pricePnl, 0),
782
+ totalPnl: rows.reduce((s, r) => s + r.totalPnl, 0),
783
+ notional: rows.reduce((s, r) => s + r.notional, 0),
784
+ };
785
+ const result = {
786
+ period: opts.period ?? "all",
787
+ positionCount: rows.length,
788
+ positions: rows,
789
+ totals: {
790
+ ...totals,
791
+ roi: totals.notional > 0 ? (totals.totalPnl / totals.notional) * 100 : 0,
792
+ },
793
+ };
794
+ if (isJson())
795
+ return printJson(jsonOk(result));
796
+ // ── Terminal output ──
797
+ console.log(chalk.cyan.bold(`\n Arb Position Comparison${opts.period ? ` (${opts.period})` : ""}\n`));
798
+ const tableRows = rows.map(r => [
799
+ chalk.white.bold(r.symbol),
800
+ `${r.longExchange} → ${r.shortExchange}`,
801
+ r.mode,
802
+ `${r.daysHeld}d`,
803
+ formatPnl(r.funding),
804
+ formatPnl(r.pricePnl),
805
+ formatPnl(r.totalPnl),
806
+ `${r.roi >= 0 ? "+" : ""}${r.roi.toFixed(2)}%`,
807
+ `${r.annualizedRoi >= 0 ? "+" : ""}${r.annualizedRoi.toFixed(1)}%`,
808
+ ]);
809
+ console.log(makeTable(["Symbol", "Long → Short", "Mode", "Held", "Funding", "Price PnL", "Total PnL", "ROI", "Ann.ROI"], tableRows));
810
+ console.log(chalk.white.bold(" Totals:"));
811
+ console.log(` Funding Income: ${formatPnl(totals.funding)}`);
812
+ console.log(` Price PnL: ${formatPnl(totals.pricePnl)}`);
813
+ console.log(` Total PnL: ${formatPnl(totals.totalPnl)}`);
814
+ if (totals.notional > 0) {
815
+ console.log(` Total ROI: ${result.totals.roi >= 0 ? "+" : ""}${result.totals.roi.toFixed(2)}%`);
816
+ }
817
+ console.log();
818
+ });
819
+ });
561
820
  }
562
821
  function parseSinceDate(since) {
563
822
  if (!since)
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Discord Bot DM client.
3
+ *
4
+ * Sends direct messages to a user via the Discord Bot API (REST only, no gateway).
5
+ * Requires: bot token + user ID.
6
+ */
7
+ export interface DiscordConfig {
8
+ botToken: string;
9
+ userId: string;
10
+ channelId?: string;
11
+ }
12
+ /**
13
+ * Open (or retrieve cached) DM channel with a user.
14
+ * Returns the channel ID for sending messages.
15
+ */
16
+ export declare function openDMChannel(config: DiscordConfig): Promise<string>;
17
+ /**
18
+ * Send a DM to the configured user.
19
+ * Automatically opens a DM channel if needed.
20
+ */
21
+ export declare function sendDiscordDM(config: DiscordConfig, message: string): Promise<void>;
22
+ /**
23
+ * Validate bot token by fetching bot user info.
24
+ * Returns the bot's username if valid.
25
+ */
26
+ export declare function validateBotToken(token: string): Promise<{
27
+ valid: boolean;
28
+ username: string;
29
+ }>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Discord Bot DM client.
3
+ *
4
+ * Sends direct messages to a user via the Discord Bot API (REST only, no gateway).
5
+ * Requires: bot token + user ID.
6
+ */
7
+ const DISCORD_API = "https://discord.com/api/v10";
8
+ /**
9
+ * Open (or retrieve cached) DM channel with a user.
10
+ * Returns the channel ID for sending messages.
11
+ */
12
+ export async function openDMChannel(config) {
13
+ if (config.channelId)
14
+ return config.channelId;
15
+ const res = await fetch(`${DISCORD_API}/users/@me/channels`, {
16
+ method: "POST",
17
+ headers: {
18
+ Authorization: `Bot ${config.botToken}`,
19
+ "Content-Type": "application/json",
20
+ },
21
+ body: JSON.stringify({ recipient_id: config.userId }),
22
+ });
23
+ if (!res.ok) {
24
+ const body = await res.text();
25
+ throw new Error(`Discord DM channel failed (${res.status}): ${body}`);
26
+ }
27
+ const data = (await res.json());
28
+ return data.id;
29
+ }
30
+ /**
31
+ * Send a DM to the configured user.
32
+ * Automatically opens a DM channel if needed.
33
+ */
34
+ export async function sendDiscordDM(config, message) {
35
+ const channelId = await openDMChannel(config);
36
+ const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
37
+ method: "POST",
38
+ headers: {
39
+ Authorization: `Bot ${config.botToken}`,
40
+ "Content-Type": "application/json",
41
+ },
42
+ body: JSON.stringify({ content: message }),
43
+ });
44
+ if (!res.ok) {
45
+ const body = await res.text();
46
+ throw new Error(`Discord send failed (${res.status}): ${body}`);
47
+ }
48
+ }
49
+ /**
50
+ * Validate bot token by fetching bot user info.
51
+ * Returns the bot's username if valid.
52
+ */
53
+ export async function validateBotToken(token) {
54
+ try {
55
+ const res = await fetch(`${DISCORD_API}/users/@me`, {
56
+ headers: { Authorization: `Bot ${token}` },
57
+ });
58
+ if (!res.ok)
59
+ return { valid: false, username: "" };
60
+ const data = (await res.json());
61
+ return { valid: true, username: `${data.username}#${data.discriminator}` };
62
+ }
63
+ catch {
64
+ return { valid: false, username: "" };
65
+ }
66
+ }