perp-cli 0.7.32 → 0.7.37

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,45 @@ 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
+ // Wait for cache to expire before checking (exchanges cache positions for ~5s)
574
+ await new Promise(resolve => setTimeout(resolve, 6000));
575
+ try {
576
+ const [longPositions, shortPositions] = await Promise.all([
577
+ longAdapter.getPositions(),
578
+ shortAdapter.getPositions(),
579
+ ]);
580
+ const longExists = longPositions.some(p => p.symbol.toUpperCase() === best.symbol.toUpperCase() && parseFloat(p.size) > 0);
581
+ const shortExists = shortPositions.some(p => p.symbol.toUpperCase() === best.symbol.toUpperCase() && parseFloat(p.size) > 0);
582
+ if (!longExists && !shortExists) {
583
+ // Both missing likely means cache issue — log warning but don't rollback
584
+ ctx.log(` [ARB] Position verification: both sides not visible for ${best.symbol} (may be cache delay)`);
585
+ }
586
+ else if (!longExists || !shortExists) {
587
+ // One side missing = instant liquidation — rollback the other
588
+ ctx.log(` [ARB] Position verification failed for ${best.symbol}: long=${longExists}, short=${shortExists}`);
589
+ 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 });
590
+ if (longExists && !shortExists) {
591
+ try {
592
+ await longAdapter.marketOrder(best.symbol, "sell", matched.size);
593
+ ctx.log(` [ARB] Rollback: closed phantom long ${best.symbol}`);
594
+ }
595
+ catch { /* best effort */ }
596
+ }
597
+ if (shortExists && !longExists) {
598
+ try {
599
+ await shortAdapter.marketOrder(best.symbol, "buy", matched.size);
600
+ ctx.log(` [ARB] Rollback: closed phantom short ${best.symbol}`);
601
+ }
602
+ catch { /* best effort */ }
603
+ }
604
+ this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 10 * 60 * 1000);
605
+ continue;
606
+ }
607
+ }
608
+ catch (verifyErr) {
609
+ ctx.log(` [ARB] Position verification error for ${best.symbol}: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
610
+ }
557
611
  // Track position
558
612
  const currentPositions = ctx.state.get("arbOpenPositions");
559
613
  currentPositions.push({
@@ -711,8 +765,8 @@ export class FundingArbStrategy {
711
765
  // Determine leverage for perp leg
712
766
  const configLeverage = params.leverage ?? 3;
713
767
  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
768
+ const perpMaxLev = this.findRate(ratesByExchange, opp.shortExchange, perpSymbolUpper)?.maxLeverage
769
+ ?? this.findRate(ratesByExchange, opp.shortExchange, opp.symbol.toUpperCase())?.maxLeverage
716
770
  ?? 1;
717
771
  const leverage = Math.min(configLeverage, perpMaxLev);
718
772
  // Set leverage on perp before ordering
@@ -727,7 +781,7 @@ export class FundingArbStrategy {
727
781
  // Check margin usage
728
782
  const perpEquity = parseFloat(perpBal.equity);
729
783
  const perpMarginPct = perpEquity > 0 ? parseFloat(perpBal.marginUsed) / perpEquity * 100 : 0;
730
- if (perpMarginPct > 80) {
784
+ if (perpMarginPct > 70) {
731
785
  ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: perp margin usage too high (${perpMarginPct.toFixed(0)}%)`);
732
786
  return false;
733
787
  }
@@ -901,14 +955,20 @@ export class FundingArbStrategy {
901
955
  * HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
902
956
  */
903
957
  getPerpSymbol(baseSymbol, exchangeName) {
904
- // Strip any existing -PERP suffix first
905
958
  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
959
  void exchangeName;
910
960
  return base;
911
961
  }
962
+ /** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP */
963
+ findRate(ratesByExchange, exchange, symbol) {
964
+ const map = ratesByExchange.get(exchange);
965
+ if (!map)
966
+ return undefined;
967
+ const upper = symbol.toUpperCase();
968
+ return map.get(upper)
969
+ ?? map.get(upper + "-PERP")
970
+ ?? map.get(upper.replace(/-PERP$/, ""));
971
+ }
912
972
  /**
913
973
  * Get a price estimate for a symbol from the perp adapter.
914
974
  * Tries the base symbol first, then with -PERP suffix.
@@ -936,6 +996,7 @@ export class FundingArbStrategy {
936
996
  price: parseFloat(m.markPrice),
937
997
  sizeDecimals: m.sizeDecimals,
938
998
  maxLeverage: m.maxLeverage,
999
+ fundingHours: m.fundingHours,
939
1000
  }));
940
1001
  }
941
1002
  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.37",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",