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 {
|
|
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
|
|
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
|
|
231
|
-
??
|
|
232
|
-
|
|
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
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
254
|
-
??
|
|
255
|
-
if (
|
|
259
|
+
const rateInfo = this.findRate(ratesByExchange, perpExchange, perpSymbol)
|
|
260
|
+
?? this.findRate(ratesByExchange, perpExchange, pos.symbol);
|
|
261
|
+
if (rateInfo === undefined)
|
|
256
262
|
continue;
|
|
257
|
-
|
|
263
|
+
const fundingH = rateInfo.fundingHours ?? getFundingHours(perpExchange);
|
|
264
|
+
signedSpread = (rateInfo.rate / fundingH) * 8760 * 100;
|
|
258
265
|
}
|
|
259
266
|
else {
|
|
260
|
-
const
|
|
261
|
-
const
|
|
262
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
347
|
-
if (
|
|
356
|
+
const rateInfo = this.findRate(ratesByExchange, exName, sym);
|
|
357
|
+
if (!rateInfo)
|
|
348
358
|
continue;
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
354
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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 =
|
|
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 =
|
|
454
|
-
const shortMaxLev =
|
|
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 >
|
|
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 >
|
|
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 =
|
|
503
|
-
const shortDec =
|
|
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 =
|
|
715
|
-
??
|
|
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 >
|
|
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 {
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -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;
|