perp-cli 0.7.32 → 0.7.36

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.
@@ -47,6 +47,8 @@ export declare class FundingArbStrategy implements Strategy {
47
47
  * HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
48
48
  */
49
49
  private getPerpSymbol;
50
+ /** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP */
51
+ private findRate;
50
52
  /**
51
53
  * Get a price estimate for a symbol from the perp adapter.
52
54
  * Tries the base symbol first, then with -PERP suffix.
@@ -1,7 +1,7 @@
1
1
  import { registerStrategy } from "../strategy-registry.js";
2
2
  import { checkArbLiquidity, checkSpotPerpLiquidity } from "../../liquidity.js";
3
3
  import { computeMatchedSize, computeSpotPerpMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
4
- import { computeSignedAnnualSpread, annualizeRate } from "../../funding.js";
4
+ import { getFundingHours } from "../../funding.js";
5
5
  import { logExecution, pruneExecutionLog } from "../../execution-log.js";
6
6
  export class FundingArbStrategy {
7
7
  name = "funding-arb";
@@ -153,7 +153,9 @@ export class FundingArbStrategy {
153
153
  for (const [name, a] of adapters) {
154
154
  try {
155
155
  const payments = await a.getFundingPayments(200);
156
- const sum = payments.reduce((s, p) => s + parseFloat(p.payment), 0);
156
+ const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
157
+ const recent = payments.filter(p => p.time > oneDayAgo);
158
+ const sum = recent.reduce((s, p) => s + parseFloat(p.payment), 0);
157
159
  if (Math.abs(sum) > 0.001) {
158
160
  historicalFunding += sum;
159
161
  ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} historical funding`);
@@ -193,7 +195,7 @@ export class FundingArbStrategy {
193
195
  const rates = await this.fetchRates(a, name);
194
196
  const map = new Map();
195
197
  for (const r of rates)
196
- map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage });
198
+ map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage, fundingHours: r.fundingHours });
197
199
  ratesByExchange.set(name, map);
198
200
  }
199
201
  // ── Fetch funding income periodically (every 5 minutes) ──
@@ -227,15 +229,19 @@ export class FundingArbStrategy {
227
229
  // For spot-perp: long side is spot (rate=0), short side is perp
228
230
  const perpExchange = p.shortExchange;
229
231
  const perpSymbol = this.getPerpSymbol(p.symbol, perpExchange);
230
- const shortRate = ratesByExchange.get(perpExchange)?.get(perpSymbol)?.rate
231
- ?? ratesByExchange.get(perpExchange)?.get(p.symbol)?.rate;
232
- signedSpread = shortRate !== undefined ? annualizeRate(shortRate, perpExchange) : 0;
232
+ const rateInfo = this.findRate(ratesByExchange, perpExchange, perpSymbol)
233
+ ?? this.findRate(ratesByExchange, perpExchange, p.symbol);
234
+ const shortRate = rateInfo?.rate;
235
+ const fundingH = rateInfo?.fundingHours ?? getFundingHours(perpExchange);
236
+ signedSpread = shortRate !== undefined ? (shortRate / fundingH) * 8760 * 100 : 0;
233
237
  }
234
238
  else {
235
- const longRate = ratesByExchange.get(p.longExchange)?.get(p.symbol)?.rate;
236
- const shortRate = ratesByExchange.get(p.shortExchange)?.get(p.symbol)?.rate;
237
- signedSpread = (longRate !== undefined && shortRate !== undefined)
238
- ? computeSignedAnnualSpread(shortRate, p.shortExchange, longRate, p.longExchange)
239
+ const longInfo = this.findRate(ratesByExchange, p.longExchange, p.symbol);
240
+ const shortInfo = this.findRate(ratesByExchange, p.shortExchange, p.symbol);
241
+ const longHourly = longInfo ? longInfo.rate / (longInfo.fundingHours ?? getFundingHours(p.longExchange)) : undefined;
242
+ const shortHourly = shortInfo ? shortInfo.rate / (shortInfo.fundingHours ?? getFundingHours(p.shortExchange)) : undefined;
243
+ signedSpread = (longHourly !== undefined && shortHourly !== undefined)
244
+ ? (shortHourly - longHourly) * 8760 * 100
239
245
  : 0;
240
246
  }
241
247
  const sign = signedSpread >= 0 ? "+" : "";
@@ -250,18 +256,21 @@ export class FundingArbStrategy {
250
256
  if (pos.mode === "spot-perp") {
251
257
  const perpExchange = pos.shortExchange;
252
258
  const perpSymbol = this.getPerpSymbol(pos.symbol, perpExchange);
253
- const shortRate = ratesByExchange.get(perpExchange)?.get(perpSymbol)?.rate
254
- ?? ratesByExchange.get(perpExchange)?.get(pos.symbol)?.rate;
255
- if (shortRate === undefined)
259
+ const rateInfo = this.findRate(ratesByExchange, perpExchange, perpSymbol)
260
+ ?? this.findRate(ratesByExchange, perpExchange, pos.symbol);
261
+ if (rateInfo === undefined)
256
262
  continue;
257
- signedSpread = annualizeRate(shortRate, perpExchange);
263
+ const fundingH = rateInfo.fundingHours ?? getFundingHours(perpExchange);
264
+ signedSpread = (rateInfo.rate / fundingH) * 8760 * 100;
258
265
  }
259
266
  else {
260
- const longRate = ratesByExchange.get(pos.longExchange)?.get(pos.symbol)?.rate;
261
- const shortRate = ratesByExchange.get(pos.shortExchange)?.get(pos.symbol)?.rate;
262
- if (longRate === undefined || shortRate === undefined)
267
+ const longInfo = this.findRate(ratesByExchange, pos.longExchange, pos.symbol);
268
+ const shortInfo = this.findRate(ratesByExchange, pos.shortExchange, pos.symbol);
269
+ if (longInfo === undefined || shortInfo === undefined)
263
270
  continue;
264
- signedSpread = computeSignedAnnualSpread(shortRate, pos.shortExchange, longRate, pos.longExchange);
271
+ const longHourly = longInfo.rate / (longInfo.fundingHours ?? getFundingHours(pos.longExchange));
272
+ const shortHourly = shortInfo.rate / (shortInfo.fundingHours ?? getFundingHours(pos.shortExchange));
273
+ signedSpread = (shortHourly - longHourly) * 8760 * 100;
265
274
  }
266
275
  // Close if funding flipped (losing money) or spread below close threshold
267
276
  const shouldClose = signedSpread < 0 || signedSpread < params.close_spread;
@@ -340,23 +349,27 @@ export class FundingArbStrategy {
340
349
  }
341
350
  // Perp-perp opportunities
342
351
  for (const sym of allSymbols) {
343
- let minRate = Infinity, maxRate = -Infinity;
352
+ let minHourly = Infinity, maxHourly = -Infinity;
344
353
  let minExchange = "", maxExchange = "";
354
+ let minRate = 0, maxRate = 0;
345
355
  for (const exName of exchangeNames) {
346
- const rate = ratesByExchange.get(exName)?.get(sym)?.rate;
347
- if (rate === undefined)
356
+ const rateInfo = this.findRate(ratesByExchange, exName, sym);
357
+ if (!rateInfo)
348
358
  continue;
349
- if (rate < minRate) {
350
- minRate = rate;
359
+ const hourlyRate = rateInfo.rate / (rateInfo.fundingHours ?? getFundingHours(exName));
360
+ if (hourlyRate < minHourly) {
361
+ minHourly = hourlyRate;
351
362
  minExchange = exName;
363
+ minRate = rateInfo.rate;
352
364
  }
353
- if (rate > maxRate) {
354
- maxRate = rate;
365
+ if (hourlyRate > maxHourly) {
366
+ maxHourly = hourlyRate;
355
367
  maxExchange = exName;
368
+ maxRate = rateInfo.rate;
356
369
  }
357
370
  }
358
371
  if (minExchange && maxExchange && minExchange !== maxExchange) {
359
- const spread = computeSignedAnnualSpread(maxRate, maxExchange, minRate, minExchange);
372
+ const spread = (maxHourly - minHourly) * 8760 * 100;
360
373
  if (spread >= params.min_spread) {
361
374
  opportunities.push({
362
375
  symbol: sym,
@@ -382,14 +395,16 @@ export class FundingArbStrategy {
382
395
  const perpRates = ratesByExchange.get(name);
383
396
  if (!perpRates)
384
397
  continue;
385
- for (const [sym, { rate }] of perpRates) {
398
+ for (const [sym, rateEntry] of perpRates) {
399
+ const { rate } = rateEntry;
386
400
  // Only consider positive funding (shorts receive, longs pay)
387
401
  if (rate <= 0)
388
402
  continue;
389
403
  const base = sym.replace(/-PERP$/, "").toUpperCase();
390
404
  if (!spotSymbols.has(base))
391
405
  continue;
392
- const annualSpread = annualizeRate(rate, name);
406
+ const hourlyRate = rate / (rateEntry.fundingHours ?? getFundingHours(name));
407
+ const annualSpread = hourlyRate * 8760 * 100;
393
408
  if (annualSpread >= spotPerpMinSpread) {
394
409
  opportunities.push({
395
410
  symbol: base,
@@ -445,13 +460,13 @@ export class FundingArbStrategy {
445
460
  const shortAdapter = adapters.get(best.shortExchange);
446
461
  if (!longAdapter || !shortAdapter)
447
462
  continue;
448
- const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
463
+ const price = this.findRate(ratesByExchange, best.longExchange, best.symbol)?.price ?? 0;
449
464
  if (price <= 0)
450
465
  continue;
451
466
  // Determine leverage for this symbol
452
467
  const configLeverage = params.leverage ?? 3;
453
- const longMaxLev = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.maxLeverage ?? 1;
454
- const shortMaxLev = ratesByExchange.get(best.shortExchange)?.get(best.symbol)?.maxLeverage ?? 1;
468
+ const longMaxLev = this.findRate(ratesByExchange, best.longExchange, best.symbol)?.maxLeverage ?? 1;
469
+ const shortMaxLev = this.findRate(ratesByExchange, best.shortExchange, best.symbol)?.maxLeverage ?? 1;
455
470
  const leverage = Math.min(configLeverage, longMaxLev, shortMaxLev);
456
471
  // Set leverage on both exchanges before ordering
457
472
  try {
@@ -468,12 +483,12 @@ export class FundingArbStrategy {
468
483
  longAdapter.getBalance(),
469
484
  shortAdapter.getBalance(),
470
485
  ]);
471
- // Check margin usage (skip if > 80%)
486
+ // Check margin usage (skip if > 70%)
472
487
  const longEquity = parseFloat(longBal.equity);
473
488
  const shortEquity = parseFloat(shortBal.equity);
474
489
  const longMarginPct = longEquity > 0 ? parseFloat(longBal.marginUsed) / longEquity * 100 : 0;
475
490
  const shortMarginPct = shortEquity > 0 ? parseFloat(shortBal.marginUsed) / shortEquity * 100 : 0;
476
- if (longMarginPct > 80 || shortMarginPct > 80) {
491
+ if (longMarginPct > 70 || shortMarginPct > 70) {
477
492
  skipMargin++;
478
493
  continue;
479
494
  }
@@ -499,8 +514,8 @@ export class FundingArbStrategy {
499
514
  if (!liq.viable)
500
515
  continue;
501
516
  // Compute matched size (use actual precision from exchange market data)
502
- const longDec = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.sizeDecimals;
503
- const shortDec = ratesByExchange.get(best.shortExchange)?.get(best.symbol)?.sizeDecimals;
517
+ const longDec = this.findRate(ratesByExchange, best.longExchange, best.symbol)?.sizeDecimals;
518
+ const shortDec = this.findRate(ratesByExchange, best.shortExchange, best.symbol)?.sizeDecimals;
504
519
  let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { longSizeDecimals: longDec, shortSizeDecimals: shortDec });
505
520
  if (!matched) {
506
521
  ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
@@ -554,6 +569,39 @@ export class FundingArbStrategy {
554
569
  const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
555
570
  ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
556
571
  }
572
+ // Verify both positions actually exist (catches instant liquidation)
573
+ try {
574
+ const [longPositions, shortPositions] = await Promise.all([
575
+ longAdapter.getPositions(),
576
+ shortAdapter.getPositions(),
577
+ ]);
578
+ const longExists = longPositions.some(p => p.symbol.toUpperCase() === best.symbol.toUpperCase() && parseFloat(p.size) > 0);
579
+ const shortExists = shortPositions.some(p => p.symbol.toUpperCase() === best.symbol.toUpperCase() && parseFloat(p.size) > 0);
580
+ if (!longExists || !shortExists) {
581
+ ctx.log(` [ARB] Position verification failed for ${best.symbol}: long=${longExists}, short=${shortExists}`);
582
+ logExecution({ type: "arb_entry", exchange: `${best.longExchange}↔${best.shortExchange}`, symbol: best.symbol, side: "verify", size: matched.size, status: "failed", error: `Position missing after fill: long=${longExists} short=${shortExists}`, dryRun: false });
583
+ // Rollback whichever side exists
584
+ if (longExists && !shortExists) {
585
+ try {
586
+ await longAdapter.marketOrder(best.symbol, "sell", matched.size);
587
+ ctx.log(` [ARB] Rollback: closed phantom long ${best.symbol}`);
588
+ }
589
+ catch { /* best effort */ }
590
+ }
591
+ if (shortExists && !longExists) {
592
+ try {
593
+ await shortAdapter.marketOrder(best.symbol, "buy", matched.size);
594
+ ctx.log(` [ARB] Rollback: closed phantom short ${best.symbol}`);
595
+ }
596
+ catch { /* best effort */ }
597
+ }
598
+ this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 10 * 60 * 1000);
599
+ continue;
600
+ }
601
+ }
602
+ catch (verifyErr) {
603
+ ctx.log(` [ARB] Position verification error for ${best.symbol}: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
604
+ }
557
605
  // Track position
558
606
  const currentPositions = ctx.state.get("arbOpenPositions");
559
607
  currentPositions.push({
@@ -711,8 +759,8 @@ export class FundingArbStrategy {
711
759
  // Determine leverage for perp leg
712
760
  const configLeverage = params.leverage ?? 3;
713
761
  const perpSymbolUpper = this.getPerpSymbol(opp.symbol, opp.shortExchange).toUpperCase();
714
- const perpMaxLev = ratesByExchange.get(opp.shortExchange)?.get(perpSymbolUpper)?.maxLeverage
715
- ?? ratesByExchange.get(opp.shortExchange)?.get(opp.symbol.toUpperCase())?.maxLeverage
762
+ const perpMaxLev = this.findRate(ratesByExchange, opp.shortExchange, perpSymbolUpper)?.maxLeverage
763
+ ?? this.findRate(ratesByExchange, opp.shortExchange, opp.symbol.toUpperCase())?.maxLeverage
716
764
  ?? 1;
717
765
  const leverage = Math.min(configLeverage, perpMaxLev);
718
766
  // Set leverage on perp before ordering
@@ -727,7 +775,7 @@ export class FundingArbStrategy {
727
775
  // Check margin usage
728
776
  const perpEquity = parseFloat(perpBal.equity);
729
777
  const perpMarginPct = perpEquity > 0 ? parseFloat(perpBal.marginUsed) / perpEquity * 100 : 0;
730
- if (perpMarginPct > 80) {
778
+ if (perpMarginPct > 70) {
731
779
  ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: perp margin usage too high (${perpMarginPct.toFixed(0)}%)`);
732
780
  return false;
733
781
  }
@@ -901,14 +949,20 @@ export class FundingArbStrategy {
901
949
  * HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
902
950
  */
903
951
  getPerpSymbol(baseSymbol, exchangeName) {
904
- // Strip any existing -PERP suffix first
905
952
  const base = baseSymbol.replace(/-PERP$/, "").toUpperCase();
906
- // HL perp symbols are just the base (e.g. "PURR", "ETH", "BTC")
907
- // Some HIP-3 tokens might need "-PERP" suffix — use base for now
908
- // since getPositions() returns the base form on HL
909
953
  void exchangeName;
910
954
  return base;
911
955
  }
956
+ /** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP */
957
+ findRate(ratesByExchange, exchange, symbol) {
958
+ const map = ratesByExchange.get(exchange);
959
+ if (!map)
960
+ return undefined;
961
+ const upper = symbol.toUpperCase();
962
+ return map.get(upper)
963
+ ?? map.get(upper + "-PERP")
964
+ ?? map.get(upper.replace(/-PERP$/, ""));
965
+ }
912
966
  /**
913
967
  * Get a price estimate for a symbol from the perp adapter.
914
968
  * Tries the base symbol first, then with -PERP suffix.
@@ -936,6 +990,7 @@ export class FundingArbStrategy {
936
990
  price: parseFloat(m.markPrice),
937
991
  sizeDecimals: m.sizeDecimals,
938
992
  maxLeverage: m.maxLeverage,
993
+ fundingHours: m.fundingHours,
939
994
  }));
940
995
  }
941
996
  catch {
@@ -77,6 +77,12 @@ export class AsterAdapter {
77
77
  const maxLev = Number(s.maxLeverage ?? 50);
78
78
  const lotFilter = s.filters
79
79
  ?.find(f => f.filterType === "LOT_SIZE");
80
+ const nextFunding = Number(premium?.nextFundingTime ?? 0);
81
+ const hoursFromMidnight = nextFunding > 0 ? (nextFunding % 86400000) / 3600000 : 0;
82
+ const fundingHours = nextFunding <= 0 ? 1
83
+ : hoursFromMidnight % 8 === 0 ? 8
84
+ : hoursFromMidnight % 4 === 0 ? 4
85
+ : 1;
80
86
  return {
81
87
  symbol: this._fromApi(sym),
82
88
  markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
@@ -87,6 +93,7 @@ export class AsterAdapter {
87
93
  maxLeverage: maxLev,
88
94
  sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : undefined,
89
95
  stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : undefined,
96
+ fundingHours,
90
97
  };
91
98
  });
92
99
  this._marketsCache = result;
@@ -12,6 +12,7 @@ export interface ExchangeMarketInfo {
12
12
  maxLeverage: number;
13
13
  sizeDecimals?: number;
14
14
  stepSize?: string;
15
+ fundingHours?: number;
15
16
  }
16
17
  export interface ExchangePosition {
17
18
  symbol: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.32",
3
+ "version": "0.7.36",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",