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/README.md +29 -0
- package/dist/commands/account.js +60 -0
- package/dist/commands/agent.js +4 -2
- package/dist/commands/alerts.d.ts +2 -0
- package/dist/commands/alerts.js +487 -0
- package/dist/commands/arb/index.js +12 -4
- package/dist/commands/arb-auto.js +50 -19
- package/dist/commands/arb.js +43 -4
- package/dist/commands/history.js +329 -70
- package/dist/discord.d.ts +29 -0
- package/dist/discord.js +66 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
- package/dist/exchanges/hyperliquid-spot.js +25 -10
- package/dist/exchanges/hyperliquid.d.ts +2 -0
- package/dist/exchanges/hyperliquid.js +10 -2
- package/dist/exchanges/lighter-spot.d.ts +6 -0
- package/dist/exchanges/lighter-spot.js +80 -31
- package/dist/exchanges/lighter.d.ts +3 -0
- package/dist/exchanges/lighter.js +46 -25
- package/dist/exchanges/pacifica.js +26 -4
- package/dist/index.js +325 -43
- package/dist/mcp-server.js +444 -1
- package/dist/settings.d.ts +26 -0
- package/dist/settings.js +14 -2
- package/dist/telegram.d.ts +32 -0
- package/dist/telegram.js +74 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +32 -0
- package/package.json +1 -1
- package/skills/perp-cli/package.json +35 -0
package/dist/commands/history.js
CHANGED
|
@@ -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("
|
|
329
|
+
.description("P&L analysis — trades, fees, funding income with daily breakdown")
|
|
318
330
|
.option("-e, --exchange <exchanges>", "Comma-separated exchanges")
|
|
319
|
-
.option("--
|
|
320
|
-
.option("
|
|
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
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
359
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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(`
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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"],
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
+
period: opts.period ?? "all",
|
|
559
|
+
periodDays: Math.max(1, Math.round(periodDays)),
|
|
448
560
|
totalFunding,
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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 (
|
|
465
|
-
console.log(chalk.white.bold(" By
|
|
466
|
-
const rows =
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
}>;
|
package/dist/discord.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -14,6 +14,7 @@ export declare class HyperliquidSpotAdapter implements SpotAdapter {
|
|
|
14
14
|
private _spotAssetMap;
|
|
15
15
|
private _spotAssetReverse;
|
|
16
16
|
private _spotDecimals;
|
|
17
|
+
private _spotCanonical;
|
|
17
18
|
private _initialized;
|
|
18
19
|
private _cachedMetaCtx;
|
|
19
20
|
constructor(hlAdapter: HyperliquidAdapter);
|