perp-cli 0.4.12 → 0.5.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.
@@ -5,7 +5,7 @@ import { toHourlyRate, computeAnnualSpread } from "../../funding.js";
5
5
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
6
6
  import { computeBasisRisk } from "../../arb-utils.js";
7
7
  import { smartOrder } from "../../smart-order.js";
8
- import { removePosition as persistRemovePosition, getPositions as getArbStatePositions } from "../../arb-state.js";
8
+ import { removePosition as persistRemovePosition, getPositions as getArbStatePositions, loadArbState } from "../../arb-state.js";
9
9
  import { SPOT_PERP_TOKEN_MAP } from "../../exchanges/spot-interface.js";
10
10
  import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
11
11
  import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
@@ -90,8 +90,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
90
90
  // ── arb status ──
91
91
  arb
92
92
  .command("status")
93
- .description("Show open arb positions (perp-perp + spot-perp) with PnL breakdown")
94
- .action(async () => {
93
+ .description("Show open arb positions with PnL breakdown + actual funding earned")
94
+ .option("--period <days>", "Funding lookback period in days", "7")
95
+ .option("--symbol <symbol>", "Filter funding data by symbol")
96
+ .action(async (opts) => {
95
97
  if (!isJson())
96
98
  console.log(chalk.cyan("\n Checking arb positions across exchanges...\n"));
97
99
  // Fetch positions from all exchanges
@@ -482,6 +484,8 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
482
484
  console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)}`);
483
485
  }
484
486
  console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker for entry + exit.)\n`));
487
+ // Always show actual funding payments earned
488
+ await printFundingEarned(getAdapterForExchange, isJson, { period: opts.period, symbol: opts.symbol });
485
489
  });
486
490
  // ── arb close ──
487
491
  arb
@@ -490,9 +494,174 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
490
494
  .option("--dry-run", "Show what would happen without executing")
491
495
  .option("--pair <pair>", "Specify arb pair as longExchange:shortExchange (e.g. pacifica:hyperliquid)")
492
496
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
497
+ .option("--spot-exch <exchange>", "Override spot exchange for spot-perp close (hl or lt)")
498
+ .option("--perp-exch <exchange>", "Override perp exchange for spot-perp close")
493
499
  .action(async (symbol, opts) => {
494
500
  const sym = symbol.toUpperCase();
495
501
  const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
502
+ // Check if this is a spot-perp position (auto-detect from arb state)
503
+ const arbState = loadArbState();
504
+ const statePos = arbState?.positions?.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
505
+ if (statePos) {
506
+ // ── Inline spot-perp close logic ──
507
+ const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
508
+ const spotExch = aliasMap[opts.spotExch?.toLowerCase() ?? ""] || opts.spotExch?.toLowerCase() || statePos.spotExchange || statePos.longExchange;
509
+ const perpExch = aliasMap[opts.perpExch?.toLowerCase() ?? ""] || opts.perpExch?.toLowerCase() || statePos.shortExchange;
510
+ if (!spotExch || !perpExch) {
511
+ const msg = "Cannot determine exchanges. Specify --spot-exch and --perp-exch, or ensure the position is tracked in state.";
512
+ if (isJson())
513
+ return printJson(jsonOk({ error: msg }));
514
+ console.log(chalk.red(` ${msg}`));
515
+ return;
516
+ }
517
+ // Load spot adapter
518
+ const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
519
+ const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
520
+ let spotAdapter;
521
+ if (spotExch === "hyperliquid") {
522
+ const hlAdapter = await getAdapterForExchange("hyperliquid");
523
+ const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
524
+ await hlSpot.init();
525
+ spotAdapter = hlSpot;
526
+ }
527
+ else {
528
+ const ltAdapter = await getAdapterForExchange("lighter");
529
+ const ltSpot = new LighterSpotAdapter(ltAdapter);
530
+ await ltSpot.init();
531
+ spotAdapter = ltSpot;
532
+ }
533
+ const perpAdapter = await getAdapterForExchange(perpExch);
534
+ const spotSymbol = `${sym}/USDC`;
535
+ // Find how much to close
536
+ const perpPositions = await perpAdapter.getPositions();
537
+ const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
538
+ if (!perpPos) {
539
+ const msg = `No short perp position found for ${sym} on ${perpExch}`;
540
+ if (isJson())
541
+ return printJson(jsonOk({ error: msg }));
542
+ console.log(chalk.red(` ${msg}`));
543
+ return;
544
+ }
545
+ const closeSize = perpPos.size;
546
+ if (!isJson()) {
547
+ console.log(chalk.cyan(`\n Closing Spot+Perp Arb: ${sym}`));
548
+ console.log(chalk.gray(` SPOT ${spotExch}: sell ${closeSize}`));
549
+ console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
550
+ console.log(chalk.gray(` Executing...\n`));
551
+ }
552
+ // Same exchange -> sequential (nonce collision), different -> parallel
553
+ let spotResult;
554
+ let perpResult;
555
+ if (spotExch === perpExch) {
556
+ spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize)
557
+ .then(r => ({ status: "fulfilled", value: r }))
558
+ .catch(e => ({ status: "rejected", reason: e }));
559
+ perpResult = await perpAdapter.marketOrder(sym, "buy", closeSize)
560
+ .then(r => ({ status: "fulfilled", value: r }))
561
+ .catch(e => ({ status: "rejected", reason: e }));
562
+ }
563
+ else {
564
+ [spotResult, perpResult] = await Promise.allSettled([
565
+ spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize),
566
+ perpAdapter.marketOrder(sym, "buy", closeSize),
567
+ ]);
568
+ }
569
+ const spotOk = spotResult.status === "fulfilled";
570
+ const perpOk = perpResult.status === "fulfilled";
571
+ if (spotOk && perpOk) {
572
+ // Post-close fill verification
573
+ let closeVerified = true;
574
+ const closeWarnings = [];
575
+ try {
576
+ await new Promise(r => setTimeout(r, 800));
577
+ const spotBals = await spotAdapter.getSpotBalances();
578
+ const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(sym));
579
+ const remainingSpotAmt = Number(spotTokenBal?.total ?? 0);
580
+ const closedSize = parseFloat(closeSize);
581
+ if (remainingSpotAmt > closedSize * 0.5) {
582
+ closeVerified = false;
583
+ closeWarnings.push(`Spot sell may not have filled: still have ${remainingSpotAmt.toFixed(6)} ${sym} (expected ~0)`);
584
+ }
585
+ const perpPositionsAfter = await perpAdapter.getPositions();
586
+ const remainingPerpPos = perpPositionsAfter.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
587
+ if (remainingPerpPos && Number(remainingPerpPos.size) > closedSize * 0.5) {
588
+ closeVerified = false;
589
+ closeWarnings.push(`Perp short still open: ${remainingPerpPos.size} ${sym} on ${perpExch} (expected closed)`);
590
+ }
591
+ }
592
+ catch (verifyErr) {
593
+ closeWarnings.push(`Close verification failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
594
+ }
595
+ if (closeWarnings.length > 0 && !isJson()) {
596
+ for (const w of closeWarnings)
597
+ console.log(chalk.yellow(` Warning: ${w}`));
598
+ }
599
+ persistRemovePosition(sym);
600
+ logExecution({
601
+ type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
602
+ symbol: sym, side: "exit", size: closeSize,
603
+ status: closeVerified ? "success" : "unverified", dryRun: false,
604
+ meta: { mode: "spot-perp", ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
605
+ });
606
+ // Return spot USDC proceeds back to perp account
607
+ if (spotExch === "lighter") {
608
+ try {
609
+ const ltSpot = spotAdapter;
610
+ const bals = await ltSpot.getSpotBalances();
611
+ const usdcBal = bals.find(b => b.token === "USDC");
612
+ const leftover = Number(usdcBal?.available ?? 0);
613
+ if (leftover > 0.01) {
614
+ await ltSpot.transferUsdcToPerp(leftover);
615
+ if (!isJson())
616
+ console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot->perp on Lighter`));
617
+ }
618
+ }
619
+ catch { /* non-critical */ }
620
+ }
621
+ else if (spotExch === "hyperliquid") {
622
+ try {
623
+ const hlSpot = spotAdapter;
624
+ const bals = await hlSpot.getSpotBalances();
625
+ const usdcBal = bals.find(b => b.token.startsWith("USDC"));
626
+ const leftover = Number(usdcBal?.available ?? 0);
627
+ if (leftover > 1)
628
+ await hlSpot.transferUsdcToPerp(Math.floor(leftover));
629
+ }
630
+ catch { /* non-critical */ }
631
+ }
632
+ const closeResult = {
633
+ status: closeVerified ? "closed" : "closed_unverified",
634
+ mode: "spot-perp", symbol: sym, size: closeSize,
635
+ ...(closeWarnings.length > 0 && { warnings: closeWarnings }),
636
+ };
637
+ if (isJson())
638
+ return printJson(jsonOk(closeResult));
639
+ if (closeVerified) {
640
+ console.log(chalk.green(` CLOSED (verified): ${sym} ${closeSize} units`));
641
+ }
642
+ else {
643
+ console.log(chalk.yellow(` CLOSED (unverified): ${sym} ${closeSize} units -- check positions manually`));
644
+ }
645
+ console.log(chalk.green(` SPOT ${spotExch}: sold`));
646
+ console.log(chalk.green(` PERP ${perpExch}: bought (closed short)\n`));
647
+ }
648
+ else {
649
+ const errors = [];
650
+ if (!spotOk)
651
+ errors.push(`Spot sell failed: ${spotResult.reason}`);
652
+ if (!perpOk)
653
+ errors.push(`Perp buy failed: ${perpResult.reason}`);
654
+ const result = { status: "partial_close", symbol: sym, spotOk, perpOk, errors };
655
+ if (isJson())
656
+ return printJson(jsonOk(result));
657
+ console.log(chalk.red(` Close partially failed:`));
658
+ for (const e of errors)
659
+ console.log(chalk.red(` ${e}`));
660
+ console.log(chalk.yellow(` Manual intervention may be needed.\n`));
661
+ }
662
+ return;
663
+ }
664
+ // ── Perp-perp close logic (original) ──
496
665
  if (!isJson()) {
497
666
  console.log(chalk.cyan(`\n Closing arb position for ${sym}...\n`));
498
667
  if (dryRun)
@@ -745,6 +914,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
745
914
  // ── arb history ──
746
915
  arb
747
916
  .command("history")
917
+ .alias("log")
748
918
  .description("Past arb trade performance and statistics")
749
919
  .option("--period <days>", "Number of days to look back", "30")
750
920
  .action(async (opts) => {
@@ -1008,6 +1178,31 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1008
1178
  console.log();
1009
1179
  });
1010
1180
  // ── arb rebalance ──
1181
+ arb
1182
+ .command("config")
1183
+ .description("Show arb configuration defaults (use 'arb auto --help' for all options)")
1184
+ .action(async () => {
1185
+ if (isJson()) {
1186
+ return printJson(jsonOk({
1187
+ minSpread: 30, closeSpread: 5, size: 100, holdDays: 7,
1188
+ bridgeCost: 0, interval: 60, cooldown: 30, minMargin: 30,
1189
+ settleStrategy: "block", maxLegs: 5,
1190
+ }));
1191
+ }
1192
+ console.log(chalk.cyan.bold("\n Arb Auto Defaults\n"));
1193
+ console.log(` Min Spread: 30% annual`);
1194
+ console.log(` Close Spread: 5% annual`);
1195
+ console.log(` Size: $100 per leg`);
1196
+ console.log(` Hold Days: 7d (for cost amortization)`);
1197
+ console.log(` Bridge Cost: $0`);
1198
+ console.log(` Check Interval: 60s`);
1199
+ console.log(` Cooldown: 30min (after closing a symbol)`);
1200
+ console.log(` Min Margin: 30%`);
1201
+ console.log(` Settle Strategy: block`);
1202
+ console.log(` Max Legs: 5`);
1203
+ console.log(chalk.gray(`\n Override with 'perp arb auto --min-spread 20 --size 50 ...'\n`));
1204
+ console.log(chalk.gray(` Run 'perp arb auto --help' for all options.\n`));
1205
+ });
1011
1206
  arb
1012
1207
  .command("rebalance")
1013
1208
  .description("Cross-exchange balance rebalancing for arb")
@@ -1189,26 +1384,21 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1189
1384
  console.log(chalk.yellow("\n To execute, use: perp rebalance execute --auto-bridge\n"));
1190
1385
  }
1191
1386
  });
1192
- // ── arb funding-earned ──
1193
- arb
1194
- .command("funding-earned")
1195
- .description("Actual funding payments received/paid for arb positions")
1196
- .option("--period <days>", "Number of days to look back", "7")
1197
- .option("--symbol <symbol>", "Filter by symbol")
1198
- .action(async (opts) => {
1387
+ // ── printFundingEarned helper (used by arb status --funding) ──
1388
+ async function printFundingEarned(getAdapter, isJsonFn, opts) {
1199
1389
  const periodDays = parseInt(opts.period);
1200
1390
  const filterSym = opts.symbol?.toUpperCase();
1201
- if (!isJson())
1391
+ if (!isJsonFn())
1202
1392
  console.log(chalk.cyan(`\n Fetching funding payments (last ${periodDays} days)...\n`));
1203
1393
  // Fetch positions to identify arb pairs
1204
- const allPositions = [];
1394
+ const fundingPositions = [];
1205
1395
  for (const exName of EXCHANGES) {
1206
1396
  try {
1207
- const adapter = await getAdapterForExchange(exName);
1397
+ const adapter = await getAdapter(exName);
1208
1398
  const positions = await adapter.getPositions();
1209
1399
  for (const p of positions) {
1210
1400
  const normalized = p.symbol.replace("-PERP", "").toUpperCase();
1211
- allPositions.push({
1401
+ fundingPositions.push({
1212
1402
  exchange: exName,
1213
1403
  symbol: normalized,
1214
1404
  side: p.side,
@@ -1224,10 +1414,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1224
1414
  // Identify arb pairs (long on one exchange, short on another)
1225
1415
  const arbPairKeys = new Set();
1226
1416
  const arbPairInfo = new Map();
1227
- const longs = allPositions.filter(p => p.side === "long");
1228
- const shorts = allPositions.filter(p => p.side === "short");
1229
- for (const l of longs) {
1230
- for (const s of shorts) {
1417
+ const fLongs = fundingPositions.filter(p => p.side === "long");
1418
+ const fShorts = fundingPositions.filter(p => p.side === "short");
1419
+ for (const l of fLongs) {
1420
+ for (const s of fShorts) {
1231
1421
  if (l.exchange !== s.exchange && l.symbol === s.symbol) {
1232
1422
  const key = l.symbol;
1233
1423
  arbPairKeys.add(key);
@@ -1243,7 +1433,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1243
1433
  const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
1244
1434
  for (const exName of EXCHANGES) {
1245
1435
  try {
1246
- const adapter = await getAdapterForExchange(exName);
1436
+ const adapter = await getAdapter(exName);
1247
1437
  const payments = await adapter.getFundingPayments(200);
1248
1438
  for (const p of payments) {
1249
1439
  if (p.time < cutoff)
@@ -1331,11 +1521,14 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1331
1521
  const portfolioApr = totalArbNotional > 0
1332
1522
  ? (totals.dailyAvg * 365 / totalArbNotional) * 100
1333
1523
  : 0;
1334
- if (isJson()) {
1335
- return printJson(jsonOk({
1336
- pairs,
1337
- totals: { ...totals, annualizedApr: portfolioApr },
1524
+ if (isJsonFn()) {
1525
+ printJson(jsonOk({
1526
+ fundingEarned: {
1527
+ pairs,
1528
+ totals: { ...totals, annualizedApr: portfolioApr },
1529
+ },
1338
1530
  }));
1531
+ return;
1339
1532
  }
1340
1533
  if (pairs.length === 0) {
1341
1534
  console.log(chalk.gray(" No funding payments found.\n"));
@@ -1368,5 +1561,5 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1368
1561
  console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)} (on $${formatUsd(totalArbNotional)} arb notional)`);
1369
1562
  }
1370
1563
  console.log();
1371
- });
1564
+ }
1372
1565
  }
@@ -50,5 +50,14 @@ export declare function isNearSettlement(longExchange: string, shortExchange: st
50
50
  * meaning we're now paying on both sides instead of collecting.
51
51
  */
52
52
  export declare function isSpreadReversed(longExchange: string, shortExchange: string, snapshot: FundingSnapshot): boolean;
53
+ export declare let handleSpotClose: ((sym: string, opts: {
54
+ spotExch?: string;
55
+ perpExch?: string;
56
+ }, getAdapterForExchange: (exchange: string) => Promise<import("../exchanges/interface.js").ExchangeAdapter>, isJson: () => boolean) => Promise<void>) | undefined;
53
57
  export declare function registerArbAutoCommands(program: Command, getAdapterForExchange: (exchange: string) => Promise<ExchangeAdapter>, isJson: () => boolean, getHLAdapterForDex?: (dex: string) => Promise<import("../exchanges/hyperliquid.js").HyperliquidAdapter>): void;
58
+ export declare let handleArbPnlRef: (() => Promise<void>) | undefined;
59
+ /**
60
+ * Exported wrapper for handleArbPnl.
61
+ */
62
+ export declare function handleArbPnl(getAdapterForExchange: (exchange: string) => Promise<import("../exchanges/interface.js").ExchangeAdapter>, isJson: () => boolean): Promise<void>;
54
63
  export {};