perp-cli 0.9.6 → 0.9.8
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.
- package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
- package/dist/bot/strategies/funding-arb-utils.js +49 -1
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +1 -1
- package/dist/bot/strategies/funding-arb-v2-strategy.js +32 -47
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +3 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +35 -52
- package/dist/bot/strategies/spot-perp-executor.d.ts +0 -2
- package/dist/bot/strategies/spot-perp-executor.js +7 -10
- package/dist/exchanges/aster.js +2 -1
- package/dist/exchanges/hyperliquid.d.ts +1 -0
- package/dist/exchanges/hyperliquid.js +1 -0
- package/dist/exchanges/interface.d.ts +2 -0
- package/package.json +1 -1
|
@@ -38,6 +38,25 @@ export interface RecoveredPosition {
|
|
|
38
38
|
}
|
|
39
39
|
/** Recover arb positions from exchange state (perp-perp + spot-perp, same + cross exchange). */
|
|
40
40
|
export declare function recoverArbPositions(adapters: Map<string, ExchangeAdapter>, log: (msg: string) => void): Promise<RecoveredPosition[]>;
|
|
41
|
+
export interface FundingScore {
|
|
42
|
+
symbol: string;
|
|
43
|
+
exchange: string;
|
|
44
|
+
avgRate: number;
|
|
45
|
+
consistency: number;
|
|
46
|
+
annualized: number;
|
|
47
|
+
payments: number;
|
|
48
|
+
}
|
|
49
|
+
/** Returns true if a reduceOnly rejection indicates the position is already gone. */
|
|
50
|
+
export declare function isPositionGone(msg: string): boolean;
|
|
51
|
+
/** Compute funding period in hours from history timestamps. Falls back to static map. */
|
|
52
|
+
export declare function computeFundingHoursFromHistory(history: {
|
|
53
|
+
time: number;
|
|
54
|
+
}[]): number;
|
|
55
|
+
/** Score a symbol's funding rate using history. Returns null if insufficient data. */
|
|
56
|
+
export declare function scoreFunding(adapter: ExchangeAdapter, symbol: string, exchangeName: string, periods: number, cache?: Map<string, {
|
|
57
|
+
score: FundingScore;
|
|
58
|
+
ts: number;
|
|
59
|
+
}>): Promise<FundingScore | null>;
|
|
41
60
|
/** Fetch funding rates from an exchange adapter. */
|
|
42
61
|
export declare function fetchRates(adapter: ExchangeAdapter, exchangeName: string): Promise<{
|
|
43
62
|
symbol: string;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Shared utilities for funding arbitrage strategies (v1 + v2).
|
|
3
3
|
* Extracted to keep individual strategy files under size limits.
|
|
4
4
|
*/
|
|
5
|
+
import { getFundingHours } from "../../funding.js";
|
|
6
|
+
import { annualizeHourlyRate } from "../../funding/normalize.js";
|
|
5
7
|
// ── Adapter helpers ──
|
|
6
8
|
/** Build adapter map from primary adapter + extraAdapters in context state. */
|
|
7
9
|
export function buildAdapterMap(ctx) {
|
|
@@ -206,6 +208,44 @@ export async function recoverArbPositions(adapters, log) {
|
|
|
206
208
|
}
|
|
207
209
|
return recovered;
|
|
208
210
|
}
|
|
211
|
+
/** Returns true if a reduceOnly rejection indicates the position is already gone. */
|
|
212
|
+
export function isPositionGone(msg) {
|
|
213
|
+
return msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
|
|
214
|
+
}
|
|
215
|
+
/** Compute funding period in hours from history timestamps. Falls back to static map. */
|
|
216
|
+
export function computeFundingHoursFromHistory(history) {
|
|
217
|
+
if (history.length < 2)
|
|
218
|
+
return getFundingHours("unknown"); // fallback to 1
|
|
219
|
+
const times = history.map(h => h.time).sort((a, b) => a - b);
|
|
220
|
+
let totalInterval = 0;
|
|
221
|
+
for (let i = 1; i < times.length; i++)
|
|
222
|
+
totalInterval += times[i] - times[i - 1];
|
|
223
|
+
const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
|
|
224
|
+
return avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
|
|
225
|
+
}
|
|
226
|
+
/** Score a symbol's funding rate using history. Returns null if insufficient data. */
|
|
227
|
+
export async function scoreFunding(adapter, symbol, exchangeName, periods, cache) {
|
|
228
|
+
const key = `${exchangeName}:${symbol}`;
|
|
229
|
+
const cached = cache?.get(key);
|
|
230
|
+
if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
|
|
231
|
+
return cached.score;
|
|
232
|
+
try {
|
|
233
|
+
const history = await adapter.getFundingHistory(symbol, periods);
|
|
234
|
+
if (history.length < 2)
|
|
235
|
+
return null;
|
|
236
|
+
const rates = history.map(h => parseFloat(h.rate));
|
|
237
|
+
const fH = computeFundingHoursFromHistory(history);
|
|
238
|
+
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
239
|
+
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
240
|
+
const annualized = annualizeHourlyRate(avgRate / fH);
|
|
241
|
+
const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
|
|
242
|
+
cache?.set(key, { score, ts: Date.now() });
|
|
243
|
+
return score;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
209
249
|
/** Fetch funding rates from an exchange adapter. */
|
|
210
250
|
export async function fetchRates(adapter, exchangeName) {
|
|
211
251
|
try {
|
|
@@ -222,8 +262,16 @@ export async function fetchRates(adapter, exchangeName) {
|
|
|
222
262
|
m.fundingHours = await aster.getFundingHours(m.symbol);
|
|
223
263
|
}
|
|
224
264
|
}
|
|
265
|
+
// For non-aster exchanges, set fundingHours from static map if not already set
|
|
266
|
+
if (exchangeName !== "aster") {
|
|
267
|
+
const defaultFH = getFundingHours(exchangeName);
|
|
268
|
+
for (const m of withRates) {
|
|
269
|
+
if (m.fundingHours == null)
|
|
270
|
+
m.fundingHours = defaultFH;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
225
273
|
return withRates
|
|
226
|
-
.filter(m => m.fundingHours != null) // skip symbols with unknown funding period
|
|
274
|
+
.filter(m => m.fundingHours != null) // skip aster symbols with unknown funding period
|
|
227
275
|
.map(m => ({
|
|
228
276
|
symbol: m.symbol,
|
|
229
277
|
rate: parseFloat(m.fundingRate),
|
|
@@ -30,7 +30,7 @@ export declare class FundingArbV2Strategy implements Strategy {
|
|
|
30
30
|
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
31
31
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
32
32
|
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
33
|
-
private
|
|
33
|
+
private scoreFundingLocal;
|
|
34
34
|
private calcSignedSpread;
|
|
35
35
|
private checkCloseByHistory;
|
|
36
36
|
private findBestPerpShort;
|
|
@@ -2,9 +2,10 @@ 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
4
|
import { getFundingHours } from "../../funding.js";
|
|
5
|
+
import { annualizeHourlyRate } from "../../funding/normalize.js";
|
|
5
6
|
import { logExecution, pruneExecutionLog } from "../../execution-log.js";
|
|
6
7
|
import { invalidateCache } from "../../cache.js";
|
|
7
|
-
import { buildAdapterMap, getSpotAdapter, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, matchSymbol, getPriceEstimate, fetchRates, recoverArbPositions, } from "./funding-arb-utils.js";
|
|
8
|
+
import { buildAdapterMap, getSpotAdapter, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, matchSymbol, getPriceEstimate, fetchRates, recoverArbPositions, scoreFunding, isPositionGone, } from "./funding-arb-utils.js";
|
|
8
9
|
let _posIdCounter = 0;
|
|
9
10
|
function genPosId() { return `v2-${Date.now()}-${++_posIdCounter}`; }
|
|
10
11
|
// ── Strategy ──
|
|
@@ -235,7 +236,7 @@ export class FundingArbV2Strategy {
|
|
|
235
236
|
?? findRate(ratesByExchange, pos.shortExchange, pos.symbol);
|
|
236
237
|
if (!currentInfo)
|
|
237
238
|
continue;
|
|
238
|
-
const curAnn = (currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.shortExchange)))
|
|
239
|
+
const curAnn = annualizeHourlyRate(currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.shortExchange)));
|
|
239
240
|
if (best.score.annualized - curAnn < p.rotation_threshold)
|
|
240
241
|
continue;
|
|
241
242
|
ctx.log(` [ARBv2] Rotating ${pos.symbol} short: ${pos.shortExchange} (${curAnn.toFixed(1)}%) -> ${best.exchange} (${best.score.annualized.toFixed(1)}%)`);
|
|
@@ -302,10 +303,10 @@ export class FundingArbV2Strategy {
|
|
|
302
303
|
}
|
|
303
304
|
if (!minEx || !maxEx || minEx === maxEx)
|
|
304
305
|
continue;
|
|
305
|
-
const spread = (maxH - minH)
|
|
306
|
+
const spread = annualizeHourlyRate(maxH - minH);
|
|
306
307
|
if (spread < p.min_spread)
|
|
307
308
|
continue;
|
|
308
|
-
const shortScore = await this.
|
|
309
|
+
const shortScore = await this.scoreFundingLocal(adapters.get(maxEx), sym, maxEx, p.history_periods);
|
|
309
310
|
if (shortScore && shortScore.consistency < p.min_consistency)
|
|
310
311
|
continue;
|
|
311
312
|
scoredOpps.push({ symbol: sym, longExchange: minEx, shortExchange: maxEx, spread, score: shortScore ?? { symbol: sym, exchange: maxEx, avgRate: maxR, consistency: 1, annualized: spread, payments: 0 }, mode: "perp-perp", shortRate: maxR, longRate: minR });
|
|
@@ -373,40 +374,15 @@ export class FundingArbV2Strategy {
|
|
|
373
374
|
// ═══════════════════════════════════════════════════════════════════
|
|
374
375
|
// Core scoring / decision methods
|
|
375
376
|
// ═══════════════════════════════════════════════════════════════════
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const cached = this._fundingScoreCache.get(key);
|
|
379
|
-
if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
|
|
380
|
-
return cached.score;
|
|
381
|
-
try {
|
|
382
|
-
const history = await adapter.getFundingHistory(symbol, periods);
|
|
383
|
-
if (history.length < 2)
|
|
384
|
-
return null;
|
|
385
|
-
const rates = history.map(h => parseFloat(h.rate));
|
|
386
|
-
// Compute actual funding period from timestamps (no static map dependency)
|
|
387
|
-
const times = history.map(h => h.time).sort((a, b) => a - b);
|
|
388
|
-
let totalInterval = 0;
|
|
389
|
-
for (let i = 1; i < times.length; i++)
|
|
390
|
-
totalInterval += times[i] - times[i - 1];
|
|
391
|
-
const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
|
|
392
|
-
const fH = avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
|
|
393
|
-
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
394
|
-
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
395
|
-
const annualized = (avgRate / fH) * 8760 * 100;
|
|
396
|
-
const score = { symbol, exchange, avgRate, consistency, annualized, payments: rates.length };
|
|
397
|
-
this._fundingScoreCache.set(key, { score, ts: Date.now() });
|
|
398
|
-
return score;
|
|
399
|
-
}
|
|
400
|
-
catch {
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
377
|
+
scoreFundingLocal(adapter, symbol, exchange, periods) {
|
|
378
|
+
return scoreFunding(adapter, symbol, exchange, periods, this._fundingScoreCache);
|
|
403
379
|
}
|
|
404
380
|
calcSignedSpread(pos, ratesByExchange) {
|
|
405
381
|
if (pos.mode === "spot-perp") {
|
|
406
382
|
const ri = findRate(ratesByExchange, pos.shortExchange, getPerpSymbol(pos.symbol, pos.shortExchange))
|
|
407
383
|
?? findRate(ratesByExchange, pos.shortExchange, pos.symbol);
|
|
408
384
|
const fH = ri?.fundingHours ?? getFundingHours(pos.shortExchange);
|
|
409
|
-
return ri ? (ri.rate / fH)
|
|
385
|
+
return ri ? annualizeHourlyRate(ri.rate / fH) : 0;
|
|
410
386
|
}
|
|
411
387
|
const li = findRate(ratesByExchange, pos.longExchange, pos.symbol);
|
|
412
388
|
const si = findRate(ratesByExchange, pos.shortExchange, pos.symbol);
|
|
@@ -414,7 +390,7 @@ export class FundingArbV2Strategy {
|
|
|
414
390
|
return 0;
|
|
415
391
|
const lH = li.rate / (li.fundingHours ?? getFundingHours(pos.longExchange));
|
|
416
392
|
const sH = si.rate / (si.fundingHours ?? getFundingHours(pos.shortExchange));
|
|
417
|
-
return (sH - lH)
|
|
393
|
+
return annualizeHourlyRate(sH - lH);
|
|
418
394
|
}
|
|
419
395
|
checkCloseByHistory(pos, ratesByExchange, p) {
|
|
420
396
|
const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
|
|
@@ -443,8 +419,8 @@ export class FundingArbV2Strategy {
|
|
|
443
419
|
const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
|
|
444
420
|
if (!ri || ri.rate <= 0)
|
|
445
421
|
continue;
|
|
446
|
-
const score = await this.
|
|
447
|
-
?? await this.
|
|
422
|
+
const score = await this.scoreFundingLocal(adapter, perpSym, exName, p.history_periods)
|
|
423
|
+
?? await this.scoreFundingLocal(adapter, symbol, exName, p.history_periods);
|
|
448
424
|
if (!score)
|
|
449
425
|
continue;
|
|
450
426
|
if (!best || score.annualized > best.score.annualized)
|
|
@@ -553,7 +529,18 @@ export class FundingArbV2Strategy {
|
|
|
553
529
|
const pBal = exchangeBalances.get(opp.shortExchange);
|
|
554
530
|
if (!sBal || !pBal)
|
|
555
531
|
return false;
|
|
556
|
-
|
|
532
|
+
let spotAvail = sBal.available;
|
|
533
|
+
const spotExAdapter = adapters.get(spotExName);
|
|
534
|
+
if (spotExAdapter && !spotExAdapter.isUnifiedAccount) {
|
|
535
|
+
try {
|
|
536
|
+
const sb = await spotAdapter.getSpotBalances();
|
|
537
|
+
const su = sb.find(b => b.token.toUpperCase().startsWith("USDC"));
|
|
538
|
+
if (su)
|
|
539
|
+
spotAvail += parseFloat(su.available);
|
|
540
|
+
}
|
|
541
|
+
catch { /* non-critical */ }
|
|
542
|
+
}
|
|
543
|
+
const maxNtl = Math.min(spotAvail, pBal.available * leverage) * 0.8;
|
|
557
544
|
if (maxNtl < targetSizeUsd) {
|
|
558
545
|
if (maxNtl < 10)
|
|
559
546
|
return false;
|
|
@@ -653,17 +640,15 @@ export class FundingArbV2Strategy {
|
|
|
653
640
|
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
654
641
|
return false;
|
|
655
642
|
}
|
|
656
|
-
// Transfer USDC back (same-exchange)
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(proceeds));
|
|
664
|
-
}
|
|
665
|
-
catch { /* non-fatal */ }
|
|
643
|
+
// Transfer USDC back to perp (always — not just same-exchange)
|
|
644
|
+
try {
|
|
645
|
+
const spotBals = await spotAdapter.getSpotBalances();
|
|
646
|
+
const usdcBal = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
|
|
647
|
+
const usdcAmt = usdcBal ? parseFloat(usdcBal.total) : 0;
|
|
648
|
+
if (usdcAmt > 1)
|
|
649
|
+
await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(usdcAmt));
|
|
666
650
|
}
|
|
651
|
+
catch { /* non-fatal */ }
|
|
667
652
|
// Close perp short
|
|
668
653
|
const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
669
654
|
try {
|
|
@@ -819,7 +804,7 @@ export class FundingArbV2Strategy {
|
|
|
819
804
|
ctx.log(` [ARBv2] Close perp-perp: adapters not found`);
|
|
820
805
|
return false;
|
|
821
806
|
}
|
|
822
|
-
const isGone =
|
|
807
|
+
const isGone = isPositionGone;
|
|
823
808
|
let closeLongOk = false;
|
|
824
809
|
let longGone = false;
|
|
825
810
|
try {
|
|
@@ -34,8 +34,9 @@ export declare class SpotPerpArbStrategy implements Strategy {
|
|
|
34
34
|
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
35
35
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
36
36
|
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
37
|
-
private
|
|
38
|
-
|
|
37
|
+
private scoreFundingLocal;
|
|
38
|
+
/** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
|
|
39
|
+
private findPerpCandidates;
|
|
39
40
|
private calcAnnualized;
|
|
40
41
|
private shouldClose;
|
|
41
42
|
private tryOpenPosition;
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { registerStrategy } from "../strategy-registry.js";
|
|
11
11
|
import { getFundingHours } from "../../funding.js";
|
|
12
|
+
import { annualizeHourlyRate } from "../../funding/normalize.js";
|
|
12
13
|
import { pruneExecutionLog } from "../../execution-log.js";
|
|
13
|
-
import { buildAdapterMap, getSpotAdapter, getPerpSymbol, findRate, matchSymbol, fetchRates, } from "./funding-arb-utils.js";
|
|
14
|
+
import { buildAdapterMap, getSpotAdapter, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, } from "./funding-arb-utils.js";
|
|
14
15
|
import { SpotPerpExecutor } from "./spot-perp-executor.js";
|
|
15
16
|
let _posIdCounter = 0;
|
|
16
17
|
function genPosId() { return `spa-${Date.now()}-${++_posIdCounter}`; }
|
|
@@ -124,7 +125,8 @@ export class SpotPerpArbStrategy {
|
|
|
124
125
|
availableAdapters.set(name, a);
|
|
125
126
|
}
|
|
126
127
|
catch (err) {
|
|
127
|
-
|
|
128
|
+
const msg = (err instanceof Error ? err.message : String(err)).slice(0, 120);
|
|
129
|
+
ctx.log(` [SPA] getBalance failed for ${name}: ${msg}`);
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
const ratesByExchange = new Map();
|
|
@@ -220,14 +222,15 @@ export class SpotPerpArbStrategy {
|
|
|
220
222
|
const rcd = this._failCooldown.get(`${pos.id}:rotate`) ?? 0;
|
|
221
223
|
if (Date.now() < rcd)
|
|
222
224
|
continue;
|
|
223
|
-
const
|
|
225
|
+
const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange);
|
|
226
|
+
const best = candidates[0];
|
|
224
227
|
if (!best || best.exchange === pos.perpExchange)
|
|
225
228
|
continue;
|
|
226
229
|
const currentInfo = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
227
230
|
?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
|
|
228
231
|
if (!currentInfo)
|
|
229
232
|
continue;
|
|
230
|
-
const curAnn = (currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.perpExchange)))
|
|
233
|
+
const curAnn = annualizeHourlyRate(currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.perpExchange)));
|
|
231
234
|
if (best.score.annualized - curAnn < p.rotation_threshold)
|
|
232
235
|
continue;
|
|
233
236
|
ctx.log(` [SPA] Rotating ${pos.symbol} short: ${pos.perpExchange} (${curAnn.toFixed(1)}%) -> ${best.exchange} (${best.score.annualized.toFixed(1)}%)`);
|
|
@@ -276,10 +279,12 @@ export class SpotPerpArbStrategy {
|
|
|
276
279
|
continue;
|
|
277
280
|
if (Date.now() < (this._failCooldown.get(`${base}:entry`) ?? 0))
|
|
278
281
|
continue;
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange);
|
|
283
|
+
for (const cand of candidates) {
|
|
284
|
+
if (cand.score.annualized < p.min_rate || cand.score.consistency < p.min_consistency)
|
|
285
|
+
continue;
|
|
286
|
+
scoredOpps.push({ symbol: base, spotExchange: spotExName, perpExchange: cand.exchange, annualized: cand.score.annualized, score: cand.score });
|
|
287
|
+
}
|
|
283
288
|
}
|
|
284
289
|
}
|
|
285
290
|
catch { /* no spot */ }
|
|
@@ -346,50 +351,25 @@ export class SpotPerpArbStrategy {
|
|
|
346
351
|
return [{ type: "cancel_all" }];
|
|
347
352
|
}
|
|
348
353
|
// ── Scoring (decision logic) ──
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const key = `${exchangeName}:${symbol}`;
|
|
352
|
-
const cached = this._fundingScoreCache.get(key);
|
|
353
|
-
if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
|
|
354
|
-
return cached.score;
|
|
355
|
-
try {
|
|
356
|
-
const history = await adapter.getFundingHistory(symbol, p.history_periods);
|
|
357
|
-
if (history.length < 2)
|
|
358
|
-
return null;
|
|
359
|
-
const rates = history.map(h => parseFloat(h.rate));
|
|
360
|
-
// Compute actual funding period from timestamps (no static map dependency)
|
|
361
|
-
const times = history.map(h => h.time).sort((a, b) => a - b);
|
|
362
|
-
let totalInterval = 0;
|
|
363
|
-
for (let i = 1; i < times.length; i++)
|
|
364
|
-
totalInterval += times[i] - times[i - 1];
|
|
365
|
-
const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
|
|
366
|
-
const fH = avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
|
|
367
|
-
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
368
|
-
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
369
|
-
const annualized = (avgRate / fH) * 8760 * 100;
|
|
370
|
-
const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
|
|
371
|
-
this._fundingScoreCache.set(key, { score, ts: Date.now() });
|
|
372
|
-
return score;
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
return null;
|
|
376
|
-
}
|
|
354
|
+
scoreFundingLocal(adapter, symbol, exchangeName) {
|
|
355
|
+
return scoreFunding(adapter, symbol, exchangeName, this.params.history_periods, this._fundingScoreCache);
|
|
377
356
|
}
|
|
378
|
-
|
|
379
|
-
|
|
357
|
+
/** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
|
|
358
|
+
async findPerpCandidates(symbol, adapters, ratesByExchange) {
|
|
359
|
+
const candidates = [];
|
|
380
360
|
for (const [exName, adapter] of adapters) {
|
|
381
361
|
const perpSym = getPerpSymbol(symbol, exName);
|
|
382
362
|
const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
|
|
383
363
|
if (!ri || ri.rate <= 0)
|
|
384
364
|
continue;
|
|
385
|
-
const score = await this.
|
|
386
|
-
?? await this.
|
|
365
|
+
const score = await this.scoreFundingLocal(adapter, perpSym, exName)
|
|
366
|
+
?? await this.scoreFundingLocal(adapter, symbol, exName);
|
|
387
367
|
if (!score)
|
|
388
368
|
continue;
|
|
389
|
-
|
|
390
|
-
best = { exchange: exName, rate: ri.rate, score };
|
|
369
|
+
candidates.push({ exchange: exName, rate: ri.rate, score });
|
|
391
370
|
}
|
|
392
|
-
|
|
371
|
+
candidates.sort((a, b) => b.score.annualized - a.score.annualized);
|
|
372
|
+
return candidates;
|
|
393
373
|
}
|
|
394
374
|
calcAnnualized(pos, ratesByExchange) {
|
|
395
375
|
const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
@@ -397,7 +377,7 @@ export class SpotPerpArbStrategy {
|
|
|
397
377
|
if (!ri)
|
|
398
378
|
return null; // rate unknown — don't assume 0
|
|
399
379
|
const fH = ri.fundingHours ?? getFundingHours(pos.perpExchange);
|
|
400
|
-
return (ri.rate / fH)
|
|
380
|
+
return annualizeHourlyRate(ri.rate / fH);
|
|
401
381
|
}
|
|
402
382
|
shouldClose(pos, ratesByExchange, p) {
|
|
403
383
|
const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
|
|
@@ -446,7 +426,7 @@ export class SpotPerpArbStrategy {
|
|
|
446
426
|
this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
447
427
|
return false;
|
|
448
428
|
}
|
|
449
|
-
const actualAnn = (rateInfo.rate / actualFh)
|
|
429
|
+
const actualAnn = annualizeHourlyRate(rateInfo.rate / actualFh);
|
|
450
430
|
if (actualAnn < p.min_rate) {
|
|
451
431
|
ctx.log(` [SPA] Skip ${symbol}: actual rate ${actualAnn.toFixed(1)}% (${actualFh}h funding) below min_rate ${p.min_rate}%`);
|
|
452
432
|
this._failCooldown.set(`${symbol}:entry`, Date.now() + 10 * 60 * 1000);
|
|
@@ -491,15 +471,18 @@ export class SpotPerpArbStrategy {
|
|
|
491
471
|
const pBal = exchangeBalances.get(perpExchange);
|
|
492
472
|
if (!sBal || !pBal)
|
|
493
473
|
return false;
|
|
494
|
-
// Check spot USDC balance
|
|
474
|
+
// Check spot USDC balance (only add if separate from perp — skip for unified accounts like HL)
|
|
495
475
|
let spotAvailable = sBal.available;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
476
|
+
const isUnified = spotExAdapter?.isUnifiedAccount ?? false;
|
|
477
|
+
if (!isUnified) {
|
|
478
|
+
try {
|
|
479
|
+
const spotBals = await spotAdapter.getSpotBalances();
|
|
480
|
+
const spotUsdc = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
|
|
481
|
+
if (spotUsdc)
|
|
482
|
+
spotAvailable += parseFloat(spotUsdc.available);
|
|
483
|
+
}
|
|
484
|
+
catch { /* non-critical */ }
|
|
501
485
|
}
|
|
502
|
-
catch { /* non-critical */ }
|
|
503
486
|
const maxNtl = Math.min(spotAvailable, pBal.available * leverage) * 0.8;
|
|
504
487
|
if (maxNtl < targetSizeUsd) {
|
|
505
488
|
if (maxNtl < 10) {
|
|
@@ -20,8 +20,6 @@ export declare class SpotPerpExecutor {
|
|
|
20
20
|
transferToSpot(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
|
|
21
21
|
/** Transfer USDC: spot -> perp account. */
|
|
22
22
|
transferToPerp(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
|
|
23
|
-
/** Returns true if a reduceOnly rejection indicates the position is already gone. */
|
|
24
|
-
private isPositionGone;
|
|
25
23
|
/**
|
|
26
24
|
* Full spot-perp entry flow:
|
|
27
25
|
* 1. Check liquidity
|
|
@@ -8,7 +8,7 @@ import { logExecution } from "../../execution-log.js";
|
|
|
8
8
|
import { invalidateCache } from "../../cache.js";
|
|
9
9
|
import { checkSpotPerpLiquidity } from "../../liquidity.js";
|
|
10
10
|
import { computeSpotPerpMatchedSize } from "../../arb-sizing.js";
|
|
11
|
-
import { getSpotAdapter as getSpotAdapterUtil, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, matchSymbol, getPriceEstimate, } from "./funding-arb-utils.js";
|
|
11
|
+
import { getSpotAdapter as getSpotAdapterUtil, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, matchSymbol, getPriceEstimate, isPositionGone, } from "./funding-arb-utils.js";
|
|
12
12
|
// ── Executor ──
|
|
13
13
|
export class SpotPerpExecutor {
|
|
14
14
|
_spotAdapterCache = new Map();
|
|
@@ -30,10 +30,7 @@ export class SpotPerpExecutor {
|
|
|
30
30
|
async transferToPerp(spotAdapter, exchange, amount) {
|
|
31
31
|
await transferUsdcToPerp(spotAdapter, exchange, amount);
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
isPositionGone(msg) {
|
|
35
|
-
return msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
|
|
36
|
-
}
|
|
33
|
+
// isPositionGone() is now in funding-arb-utils.ts
|
|
37
34
|
/**
|
|
38
35
|
* Full spot-perp entry flow:
|
|
39
36
|
* 1. Check liquidity
|
|
@@ -124,8 +121,8 @@ export class SpotPerpExecutor {
|
|
|
124
121
|
const postBals = await spotAdapter.getSpotBalances();
|
|
125
122
|
const bought = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
|
|
126
123
|
const boughtAmt = bought ? parseFloat(bought.total) : 0;
|
|
127
|
-
if (boughtAmt < parseFloat(matched.size) * 0.
|
|
128
|
-
log(` [SPA] Spot buy returned OK but ${symbol} balance too low (${boughtAmt.toFixed(4)}) — aborting`);
|
|
124
|
+
if (boughtAmt < parseFloat(matched.size) * 0.8) {
|
|
125
|
+
log(` [SPA] Spot buy returned OK but ${symbol} balance too low (${boughtAmt.toFixed(4)} < ${matched.size} × 80%) — aborting`);
|
|
129
126
|
if (actualTransferred > 0) {
|
|
130
127
|
try {
|
|
131
128
|
await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
|
|
@@ -197,7 +194,7 @@ export class SpotPerpExecutor {
|
|
|
197
194
|
catch (err) {
|
|
198
195
|
const msg = err instanceof Error ? err.message : String(err);
|
|
199
196
|
// ReduceOnly rejected = position already gone, proceed to sell spot
|
|
200
|
-
if (
|
|
197
|
+
if (isPositionGone(msg)) {
|
|
201
198
|
log(` [SPA] Perp already closed (ReduceOnly rejected)`);
|
|
202
199
|
}
|
|
203
200
|
else {
|
|
@@ -226,8 +223,8 @@ export class SpotPerpExecutor {
|
|
|
226
223
|
const postBals = await spotAdapter.getSpotBalances();
|
|
227
224
|
const remaining = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
|
|
228
225
|
const remainingAmt = remaining ? parseFloat(remaining.total) : 0;
|
|
229
|
-
if (remainingAmt >= parseFloat(size) * 0.
|
|
230
|
-
log(` [SPA] CRITICAL: Spot sell returned OK but ${symbol} balance unchanged (${remainingAmt.toFixed(4)}) — re-hedging perp`);
|
|
226
|
+
if (remainingAmt >= parseFloat(size) * 0.8) {
|
|
227
|
+
log(` [SPA] CRITICAL: Spot sell returned OK but ${symbol} balance unchanged (${remainingAmt.toFixed(4)} >= ${size} × 80%) — re-hedging perp`);
|
|
231
228
|
try {
|
|
232
229
|
await perpAdapter.marketOrder(perpSym, "sell", size);
|
|
233
230
|
}
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -457,7 +457,8 @@ export class AsterAdapter {
|
|
|
457
457
|
}
|
|
458
458
|
if (!res.ok) {
|
|
459
459
|
const text = await res.text();
|
|
460
|
-
|
|
460
|
+
const clean = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
461
|
+
throw new Error(`${method} ${path} failed (${res.status}): ${clean}`);
|
|
461
462
|
}
|
|
462
463
|
return res.json();
|
|
463
464
|
}
|
|
@@ -66,6 +66,8 @@ export interface ExchangeAdapter {
|
|
|
66
66
|
readonly name: string;
|
|
67
67
|
readonly chain?: string;
|
|
68
68
|
readonly aliases?: readonly string[];
|
|
69
|
+
/** True if perp and spot share the same balance (e.g., Hyperliquid). False if separate accounts (e.g., Lighter). */
|
|
70
|
+
readonly isUnifiedAccount?: boolean;
|
|
69
71
|
init?(): Promise<void>;
|
|
70
72
|
getMarkets(): Promise<ExchangeMarketInfo[]>;
|
|
71
73
|
getOrderbook(symbol: string): Promise<{
|