perp-cli 0.7.19 → 0.7.20

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.
@@ -44,7 +44,7 @@ export async function getMarketSnapshot(adapter, symbol) {
44
44
  throw new Error(`Market "${symbol}" not found on ${adapter.name}`);
45
45
  }
46
46
  const price = parseFloat(m.markPrice);
47
- const fundingRate = parseFloat(m.fundingRate);
47
+ const fundingRate = parseFloat(m.fundingRate ?? "0");
48
48
  const volume24h = parseFloat(m.volume24h);
49
49
  // Try to get kline data for 24h high/low + RSI
50
50
  let high24h = price;
@@ -373,110 +373,112 @@ export class FundingArbStrategy {
373
373
  ctx.log(` [ARB] ${opp.symbol}: ${opp.spread.toFixed(1)}% spread (long ${opp.longExchange} ${(opp.longRate * 100).toFixed(4)}% / short ${opp.shortExchange} ${(opp.shortRate * 100).toFixed(4)}%)`);
374
374
  }
375
375
  }
376
- // Auto-execute if under position limit
377
- if (arbPositions < params.max_positions && opportunities.length > 0) {
378
- const best = opportunities[0];
379
- if (best.mode === "spot-perp") {
380
- await this.openSpotPerp(best, adapters, ctx);
381
- }
382
- else {
376
+ // Auto-execute: try each opportunity until one succeeds
377
+ if (arbPositions < params.max_positions) {
378
+ for (const best of opportunities) {
379
+ if (best.mode === "spot-perp") {
380
+ const opened = await this.openSpotPerp(best, adapters, ctx);
381
+ if (opened)
382
+ return [{ type: "noop" }];
383
+ continue;
384
+ }
383
385
  const longAdapter = adapters.get(best.longExchange);
384
386
  const shortAdapter = adapters.get(best.shortExchange);
385
- if (longAdapter && shortAdapter) {
386
- const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
387
- if (price > 0) {
388
- // Balance check
389
- let targetSizeUsd = params.size_usd;
390
- try {
391
- const [longBal, shortBal] = await Promise.all([
392
- longAdapter.getBalance(),
393
- shortAdapter.getBalance(),
394
- ]);
395
- const longAvail = parseFloat(longBal.available);
396
- const shortAvail = parseFloat(shortBal.available);
397
- const minAvail = Math.min(longAvail, shortAvail);
398
- const maxAffordable = minAvail * 0.8;
399
- if (maxAffordable < 10) {
400
- ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available, need $10+ per leg)`);
401
- return [];
402
- }
403
- if (targetSizeUsd > maxAffordable) {
404
- ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
405
- targetSizeUsd = Math.floor(maxAffordable);
387
+ if (!longAdapter || !shortAdapter)
388
+ continue;
389
+ const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
390
+ if (price <= 0)
391
+ continue;
392
+ // Balance check
393
+ let targetSizeUsd = params.size_usd;
394
+ try {
395
+ const [longBal, shortBal] = await Promise.all([
396
+ longAdapter.getBalance(),
397
+ shortAdapter.getBalance(),
398
+ ]);
399
+ const longAvail = parseFloat(longBal.available);
400
+ const shortAvail = parseFloat(shortBal.available);
401
+ const minAvail = Math.min(longAvail, shortAvail);
402
+ const maxAffordable = minAvail * 0.8;
403
+ if (maxAffordable < 10) {
404
+ ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available)`);
405
+ continue;
406
+ }
407
+ if (targetSizeUsd > maxAffordable) {
408
+ ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
409
+ targetSizeUsd = Math.floor(maxAffordable);
410
+ }
411
+ }
412
+ catch { /* non-critical, proceed with original size */ }
413
+ // Liquidity check
414
+ const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
415
+ if (!liq.viable)
416
+ continue;
417
+ // Compute matched size
418
+ let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
419
+ if (!matched) {
420
+ ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
421
+ continue;
422
+ }
423
+ try {
424
+ ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
425
+ try {
426
+ await Promise.all([
427
+ longAdapter.marketOrder(best.symbol, "buy", matched.size),
428
+ shortAdapter.marketOrder(best.symbol, "sell", matched.size),
429
+ ]);
430
+ }
431
+ catch (orderErr) {
432
+ const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
433
+ const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
434
+ if (lotMatch) {
435
+ const lotSize = parseFloat(lotMatch[1]);
436
+ ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
437
+ matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
438
+ if (!matched) {
439
+ ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
440
+ continue;
406
441
  }
442
+ ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
443
+ await Promise.all([
444
+ longAdapter.marketOrder(best.symbol, "buy", matched.size),
445
+ shortAdapter.marketOrder(best.symbol, "sell", matched.size),
446
+ ]);
407
447
  }
408
- catch { /* non-critical, proceed with original size */ }
409
- // Liquidity check
410
- const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
411
- if (!liq.viable)
412
- return [];
413
- // Compute matched size
414
- let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
415
- if (!matched) {
416
- ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
417
- return [];
418
- }
419
- try {
420
- ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
421
- try {
422
- await Promise.all([
423
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
424
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
425
- ]);
426
- }
427
- catch (orderErr) {
428
- // Parse lot size from error and retry with corrected size
429
- const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
430
- const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
431
- if (lotMatch) {
432
- const lotSize = parseFloat(lotMatch[1]);
433
- ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
434
- matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
435
- if (!matched) {
436
- ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
437
- return [];
438
- }
439
- ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
440
- await Promise.all([
441
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
442
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
443
- ]);
444
- }
445
- else {
446
- throw orderErr;
447
- }
448
- }
449
- // Verify fills match, correct if needed
450
- try {
451
- const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
452
- if (!recon.matched) {
453
- ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
454
- }
455
- }
456
- catch (reconErr) {
457
- const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
458
- ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
459
- }
460
- // Track position
461
- const currentPositions = ctx.state.get("arbOpenPositions");
462
- currentPositions.push({
463
- symbol: best.symbol,
464
- longExchange: best.longExchange,
465
- shortExchange: best.shortExchange,
466
- entrySpread: best.spread,
467
- size: matched.size,
468
- mode: "perp-perp",
469
- });
470
- ctx.state.set("arbOpenPositions", currentPositions);
471
- ctx.state.set("arbPositions", currentPositions.length);
472
- ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
473
- return [{ type: "noop" }];
448
+ else {
449
+ throw orderErr;
474
450
  }
475
- catch (err) {
476
- const msg = err instanceof Error ? err.message : String(err);
477
- ctx.log(` [ARB] Execution failed: ${msg}`);
451
+ }
452
+ // Verify fills match
453
+ try {
454
+ const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
455
+ if (!recon.matched) {
456
+ ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
478
457
  }
479
458
  }
459
+ catch (reconErr) {
460
+ const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
461
+ ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
462
+ }
463
+ // Track position
464
+ const currentPositions = ctx.state.get("arbOpenPositions");
465
+ currentPositions.push({
466
+ symbol: best.symbol,
467
+ longExchange: best.longExchange,
468
+ shortExchange: best.shortExchange,
469
+ entrySpread: best.spread,
470
+ size: matched.size,
471
+ mode: "perp-perp",
472
+ });
473
+ ctx.state.set("arbOpenPositions", currentPositions);
474
+ ctx.state.set("arbPositions", currentPositions.length);
475
+ ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
476
+ return [{ type: "noop" }];
477
+ }
478
+ catch (err) {
479
+ const msg = err instanceof Error ? err.message : String(err);
480
+ ctx.log(` [ARB] Execution failed for ${best.symbol}: ${msg}`);
481
+ continue;
480
482
  }
481
483
  }
482
484
  }
@@ -593,13 +595,13 @@ export class FundingArbStrategy {
593
595
  const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
594
596
  if (!perpAdapter) {
595
597
  ctx.log(` [ARB] Open spot-perp failed: no adapter for ${opp.shortExchange}`);
596
- return;
598
+ return false;
597
599
  }
598
600
  try {
599
601
  const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
600
602
  if (!spotAdapter) {
601
603
  ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
602
- return;
604
+ return false;
603
605
  }
604
606
  // Balance check: need full notional for spot + margin for perp
605
607
  let targetSizeUsd = params.size_usd;
@@ -609,7 +611,7 @@ export class FundingArbStrategy {
609
611
  const maxAffordable = perpAvail * 0.8;
610
612
  if (maxAffordable < 10) {
611
613
  ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: insufficient balance ($${perpAvail.toFixed(2)} available)`);
612
- return;
614
+ return false;
613
615
  }
614
616
  if (targetSizeUsd > maxAffordable) {
615
617
  ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance)`);
@@ -621,12 +623,12 @@ export class FundingArbStrategy {
621
623
  const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
622
624
  if (priceEstimate <= 0) {
623
625
  ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't determine price`);
624
- return;
626
+ return false;
625
627
  }
626
628
  // Liquidity check
627
629
  const liq = await checkSpotPerpLiquidity(spotAdapter, perpAdapter, opp.symbol, perpSymbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
628
630
  if (!liq.viable)
629
- return;
631
+ return false;
630
632
  // Compute matched size
631
633
  const spotMarkets = await spotAdapter.getSpotMarkets();
632
634
  const spotMarket = spotMarkets.find(m => m.baseToken.toUpperCase() === opp.symbol.toUpperCase());
@@ -634,7 +636,7 @@ export class FundingArbStrategy {
634
636
  const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, priceEstimate, exchangeName, opp.shortExchange, spotDecimals);
635
637
  if (!matched) {
636
638
  ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't compute matched size`);
637
- return;
639
+ return false;
638
640
  }
639
641
  ctx.log(` [ARB] Opening spot-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)}, slippage ~${liq.spotSlippage.toFixed(2)}%/${liq.perpSlippage.toFixed(2)}%)`);
640
642
  // Step 1: Transfer USDC to spot
@@ -660,10 +662,12 @@ export class FundingArbStrategy {
660
662
  ctx.state.set("arbOpenPositions", currentPositions);
661
663
  ctx.state.set("arbPositions", currentPositions.length);
662
664
  ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);
665
+ return true;
663
666
  }
664
667
  catch (err) {
665
668
  const msg = err instanceof Error ? err.message : String(err);
666
669
  ctx.log(` [ARB] Open spot-perp failed for ${opp.symbol}: ${msg}`);
670
+ return false;
667
671
  }
668
672
  }
669
673
  /** Transfer USDC from perp to spot account (exchange-specific) */
@@ -760,7 +764,9 @@ export class FundingArbStrategy {
760
764
  async fetchRates(adapter, _exchangeName) {
761
765
  try {
762
766
  const markets = await adapter.getMarkets();
763
- return markets.map(m => ({
767
+ return markets
768
+ .filter(m => m.fundingRate != null) // skip symbols with unavailable rates
769
+ .map(m => ({
764
770
  symbol: m.symbol,
765
771
  rate: parseFloat(m.fundingRate),
766
772
  price: parseFloat(m.markPrice),
@@ -48,7 +48,7 @@ export class FundingArbV2Strategy {
48
48
  const map = new Map();
49
49
  for (const m of markets) {
50
50
  map.set(m.symbol.toUpperCase(), {
51
- rate: parseFloat(m.fundingRate),
51
+ rate: m.fundingRate != null ? parseFloat(m.fundingRate) : NaN,
52
52
  price: parseFloat(m.markPrice),
53
53
  });
54
54
  }
@@ -125,7 +125,7 @@ class FundingAutoStrategy {
125
125
  const sym = normalizeSymbol(raw);
126
126
  const price = parseFloat(m.markPrice);
127
127
  map.set(sym, {
128
- rate: parseFloat(m.fundingRate),
128
+ rate: m.fundingRate != null ? parseFloat(m.fundingRate) : NaN,
129
129
  price,
130
130
  exchange: name,
131
131
  });
@@ -15,7 +15,7 @@ export function registerMarketCommands(program, getAdapter, isJson, getAdapterFo
15
15
  chalk.white.bold(m.symbol),
16
16
  `$${formatUsd(m.markPrice)}`,
17
17
  `$${formatUsd(m.indexPrice)}`,
18
- formatPercent(m.fundingRate),
18
+ m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A"),
19
19
  `$${formatUsd(m.volume24h)}`,
20
20
  `$${formatUsd(m.openInterest)}`,
21
21
  String(m.maxLeverage) + "x",
@@ -98,7 +98,7 @@ export function registerMarketCommands(program, getAdapter, isJson, getAdapterFo
98
98
  chalk.white.bold(m.symbol),
99
99
  `$${formatUsd(m.markPrice)}`,
100
100
  `$${formatUsd(m.indexPrice)}`,
101
- formatPercent(m.fundingRate),
101
+ m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A"),
102
102
  ];
103
103
  if (!explicitExchange && getAdapterForExchange) {
104
104
  const grouped = {};
@@ -170,7 +170,7 @@ export function registerMarketCommands(program, getAdapter, isJson, getAdapterFo
170
170
  console.log(chalk.cyan.bold(`\n ${m.symbol} on ${adapter.name}\n`));
171
171
  console.log(` Mark Price: $${formatUsd(m.markPrice)}`);
172
172
  console.log(` Index Price: $${formatUsd(m.indexPrice)}`);
173
- console.log(` Funding Rate: ${formatPercent(m.fundingRate)}`);
173
+ console.log(` Funding Rate: ${m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A")}`);
174
174
  console.log(` 24h Volume: $${formatUsd(m.volume24h)}`);
175
175
  console.log(` Open Interest: $${formatUsd(m.openInterest)}`);
176
176
  console.log(` Max Leverage: ${m.maxLeverage}x`);
@@ -79,7 +79,7 @@ export class AsterAdapter {
79
79
  symbol: this._fromApi(sym),
80
80
  markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
81
81
  indexPrice: String(premium?.indexPrice ?? "0"),
82
- fundingRate: String(premium?.lastFundingRate ?? "0"),
82
+ fundingRate: premium?.lastFundingRate != null ? String(premium.lastFundingRate) : null,
83
83
  volume24h: String(ticker?.quoteVolume ?? ticker?.volume ?? "0"),
84
84
  openInterest: "0",
85
85
  maxLeverage: maxLev,
@@ -218,7 +218,7 @@ export class HyperliquidAdapter {
218
218
  symbol: sym,
219
219
  markPrice: String(ctx.markPx ?? mids[sym] ?? "0"),
220
220
  indexPrice: String(ctx.oraclePx ?? "0"),
221
- fundingRate: String(ctx.funding ?? "0"),
221
+ fundingRate: ctx.funding != null ? String(ctx.funding) : null,
222
222
  volume24h: String(ctx.dayNtlVlm ?? "0"),
223
223
  openInterest: String(ctx.openInterest ?? "0"),
224
224
  maxLeverage: Number(asset.maxLeverage ?? 50),
@@ -6,7 +6,7 @@ export interface ExchangeMarketInfo {
6
6
  symbol: string;
7
7
  markPrice: string;
8
8
  indexPrice: string;
9
- fundingRate: string;
9
+ fundingRate: string | null;
10
10
  volume24h: string;
11
11
  openInterest: string;
12
12
  maxLeverage: number;
@@ -266,18 +266,22 @@ export class LighterAdapter {
266
266
  return this._marketsCache;
267
267
  const markets = [];
268
268
  try {
269
- const res = await this.restGet("/orderBookDetails", {});
270
- for (const d of res.order_book_details ?? []) {
269
+ // Fetch order book details and funding rates in parallel
270
+ const [obRes, fundingRates] = await Promise.all([
271
+ this.restGet("/orderBookDetails", {}),
272
+ this.getFundingRates().catch(() => new Map()),
273
+ ]);
274
+ for (const d of obRes.order_book_details ?? []) {
271
275
  if (d.market_type !== "perp")
272
276
  continue;
273
- // max_leverage from min_initial_margin_fraction (e.g. 400 = 4% margin = 25x)
274
277
  const imf = d.min_initial_margin_fraction ?? d.default_initial_margin_fraction ?? 500;
275
278
  const maxLev = imf > 0 ? Math.floor(10000 / imf) : 50;
279
+ const fr = fundingRates.get(d.symbol);
276
280
  markets.push({
277
281
  symbol: d.symbol,
278
- markPrice: String(d.last_trade_price ?? 0),
282
+ markPrice: fr?.markPrice ?? String(d.last_trade_price ?? 0),
279
283
  indexPrice: String(d.last_trade_price ?? 0),
280
- fundingRate: "0", // funding rates require auth
284
+ fundingRate: fr?.rate ?? null,
281
285
  volume24h: String(d.daily_quote_token_volume ?? 0),
282
286
  openInterest: String(d.open_interest ?? 0),
283
287
  maxLeverage: maxLev,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.19",
3
+ "version": "0.7.20",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",