perp-cli 0.7.48 → 0.9.1
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/engine.d.ts +1 -0
- package/dist/bot/engine.js +1 -0
- package/dist/bot/strategies/funding-arb-strategy.js +30 -23
- package/dist/bot/strategies/funding-arb-utils.d.ts +49 -0
- package/dist/bot/strategies/funding-arb-utils.js +239 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +42 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.js +873 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +547 -0
- package/dist/bot/strategies/spot-perp-executor.d.ts +81 -0
- package/dist/bot/strategies/spot-perp-executor.js +271 -0
- package/dist/commands/bot.js +80 -1
- package/dist/commands/trade.js +131 -0
- package/dist/exchanges/aster.d.ts +1 -1
- package/dist/exchanges/aster.js +5 -5
- package/dist/exchanges/hyperliquid.d.ts +7 -0
- package/dist/exchanges/hyperliquid.js +23 -0
- package/dist/exchanges/lighter-spot.js +20 -0
- package/dist/exchanges/lighter.js +45 -1
- package/dist/exchanges/pacifica.js +42 -1
- package/dist/exchanges/spot-interface.js +4 -0
- package/package.json +1 -1
package/dist/bot/engine.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ import "./strategies/rfq-agent.js";
|
|
|
19
19
|
import "./strategies/claude-agent.js";
|
|
20
20
|
import "./strategies/apex-strategy.js";
|
|
21
21
|
import "./strategies/funding-auto.js";
|
|
22
|
+
import "./strategies/spot-perp-arb-strategy.js";
|
|
22
23
|
export type BotLog = (msg: string) => void;
|
|
23
24
|
export type BotOutputMode = "tui" | "json" | "headless";
|
|
24
25
|
export declare function runBot(adapter: ExchangeAdapter, config: BotConfig, jobId?: string, log?: BotLog, extraAdapters?: Map<string, ExchangeAdapter>, // for funding-arb (multi-exchange)
|
package/dist/bot/engine.js
CHANGED
|
@@ -22,6 +22,7 @@ import "./strategies/rfq-agent.js";
|
|
|
22
22
|
import "./strategies/claude-agent.js";
|
|
23
23
|
import "./strategies/apex-strategy.js";
|
|
24
24
|
import "./strategies/funding-auto.js";
|
|
25
|
+
import "./strategies/spot-perp-arb-strategy.js";
|
|
25
26
|
// ── TUI state emitter ──
|
|
26
27
|
class TuiEmitter {
|
|
27
28
|
stateListeners = [];
|
|
@@ -201,12 +201,21 @@ export class FundingArbStrategy {
|
|
|
201
201
|
const equity = parseFloat(bal.equity);
|
|
202
202
|
const marginUsed = parseFloat(bal.marginUsed);
|
|
203
203
|
const marginPct = equity > 0 ? (marginUsed / equity) * 100 : 100;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
const available = parseFloat(bal.available);
|
|
205
|
+
exchangeBalances.set(name, { equity, available, marginPct });
|
|
206
|
+
// Skip if: margin > 90%, available < $5, or exchange on liquidation cooldown
|
|
207
|
+
const exchCooldown = this._failCooldown.get(`${name}:exchange`) ?? 0;
|
|
208
|
+
if (Date.now() < exchCooldown) {
|
|
209
|
+
ctx.log(` [ARB] ${name} on liquidation cooldown — skipping`);
|
|
210
|
+
}
|
|
211
|
+
else if (available < 5) {
|
|
212
|
+
ctx.log(` [ARB] ${name} available $${available.toFixed(2)} too low — skipping`);
|
|
213
|
+
}
|
|
214
|
+
else if (marginPct >= 90) {
|
|
215
|
+
ctx.log(` [ARB] ${name} margin ${marginPct.toFixed(0)}% — skipping`);
|
|
207
216
|
}
|
|
208
217
|
else {
|
|
209
|
-
|
|
218
|
+
availableAdapters.set(name, a);
|
|
210
219
|
}
|
|
211
220
|
}
|
|
212
221
|
catch (err) {
|
|
@@ -315,8 +324,8 @@ export class FundingArbStrategy {
|
|
|
315
324
|
// Close if funding flipped (losing money) or spread below close threshold
|
|
316
325
|
const minHoldMs = (params.min_hold_hours ?? 2) * 60 * 60 * 1000;
|
|
317
326
|
const holdTime = pos.entryTime > 0 ? Date.now() - pos.entryTime : Infinity;
|
|
318
|
-
const
|
|
319
|
-
const shouldClose =
|
|
327
|
+
const deeplyNegative = signedSpread < -20; // urgent: losing significant funding
|
|
328
|
+
const shouldClose = deeplyNegative || (holdTime >= minHoldMs && signedSpread < params.close_spread);
|
|
320
329
|
if (shouldClose) {
|
|
321
330
|
// Cooldown: skip if close recently failed for this symbol
|
|
322
331
|
const closeKey = `${pos.symbol}:close`;
|
|
@@ -348,6 +357,8 @@ export class FundingArbStrategy {
|
|
|
348
357
|
// Position already liquidated/closed on this exchange
|
|
349
358
|
longPositionGone = true;
|
|
350
359
|
ctx.log(` [ARB] Long position gone for ${pos.symbol} on ${pos.longExchange} (liquidated?) — force closing short`);
|
|
360
|
+
// Cooldown the exchange for 30 minutes to prevent liquidation-entry cycle
|
|
361
|
+
this._failCooldown.set(`${pos.longExchange}:exchange`, Date.now() + 30 * 60 * 1000);
|
|
351
362
|
}
|
|
352
363
|
else {
|
|
353
364
|
ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} (cooldown 5m)`);
|
|
@@ -366,6 +377,7 @@ export class FundingArbStrategy {
|
|
|
366
377
|
if (isPositionGone(msg)) {
|
|
367
378
|
// Short position also gone — both liquidated, just clean up tracking
|
|
368
379
|
ctx.log(` [ARB] Short position also gone for ${pos.symbol} on ${pos.shortExchange} — removing from tracking`);
|
|
380
|
+
this._failCooldown.set(`${pos.shortExchange}:exchange`, Date.now() + 30 * 60 * 1000);
|
|
369
381
|
}
|
|
370
382
|
else if (closeLongOk) {
|
|
371
383
|
// Long was closed but short failed (not liquidated) — rollback long
|
|
@@ -623,20 +635,7 @@ export class FundingArbStrategy {
|
|
|
623
635
|
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
624
636
|
ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
|
|
625
637
|
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
|
|
626
|
-
|
|
627
|
-
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
628
|
-
currentPositions.push({
|
|
629
|
-
symbol: best.symbol,
|
|
630
|
-
longExchange: best.longExchange,
|
|
631
|
-
shortExchange: best.shortExchange, // note: short leg doesn't exist
|
|
632
|
-
entrySpread: 0,
|
|
633
|
-
size: matched.size,
|
|
634
|
-
mode: "perp-perp",
|
|
635
|
-
entryTime: 0, // flag as orphaned
|
|
636
|
-
});
|
|
637
|
-
ctx.state.set("arbOpenPositions", currentPositions);
|
|
638
|
-
ctx.state.set("arbPositions", currentPositions.length);
|
|
639
|
-
ctx.log(` [ARB] Tracking orphaned long position for manual cleanup`);
|
|
638
|
+
ctx.log(` [ARB] CRITICAL: Orphaned long position on ${best.longExchange} for ${best.symbol} — operator must close manually`);
|
|
640
639
|
}
|
|
641
640
|
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
642
641
|
continue;
|
|
@@ -979,6 +978,11 @@ export class FundingArbStrategy {
|
|
|
979
978
|
const msg = spotErr instanceof Error ? spotErr.message : String(spotErr);
|
|
980
979
|
ctx.log(` [ARB] Open spot-perp failed (spot buy) for ${opp.symbol}: ${msg}`);
|
|
981
980
|
logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "spot" } });
|
|
981
|
+
try {
|
|
982
|
+
await this.transferUsdcToPerp(spotAdapter, exchangeName, transferAmt);
|
|
983
|
+
ctx.log(` [ARB] Transferred $${transferAmt} USDC back to perp after spot buy failure`);
|
|
984
|
+
}
|
|
985
|
+
catch { /* best effort */ }
|
|
982
986
|
return false;
|
|
983
987
|
}
|
|
984
988
|
// Step 3: Short perp
|
|
@@ -1143,9 +1147,12 @@ export class FundingArbStrategy {
|
|
|
1143
1147
|
// Bootstrap aster funding hours lazily (1 API call per unknown symbol, cached permanently)
|
|
1144
1148
|
if (exchangeName === "aster" && "getFundingHours" in adapter) {
|
|
1145
1149
|
const aster = adapter;
|
|
1146
|
-
// Only bootstrap symbols not yet cached (up to
|
|
1147
|
-
const uncached = withRates.filter(m =>
|
|
1148
|
-
|
|
1150
|
+
// Only bootstrap symbols not yet cached (up to 20 per tick to complete faster)
|
|
1151
|
+
const uncached = withRates.filter(m => {
|
|
1152
|
+
const cached = adapter?._fundingHoursCache?.get?.(m.symbol);
|
|
1153
|
+
return cached === undefined; // only truly uncached, not symbols confirmed as 1h
|
|
1154
|
+
});
|
|
1155
|
+
const toBootstrap = uncached.slice(0, 20);
|
|
1149
1156
|
for (const m of toBootstrap) {
|
|
1150
1157
|
const fh = await aster.getFundingHours(m.symbol);
|
|
1151
1158
|
m.fundingHours = fh;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for funding arbitrage strategies (v1 + v2).
|
|
3
|
+
* Extracted to keep individual strategy files under size limits.
|
|
4
|
+
*/
|
|
5
|
+
import type { StrategyContext } from "../strategy-types.js";
|
|
6
|
+
import type { ExchangeAdapter } from "../../exchanges/index.js";
|
|
7
|
+
import type { SpotAdapter } from "../../exchanges/spot-interface.js";
|
|
8
|
+
export type RateEntry = {
|
|
9
|
+
rate: number;
|
|
10
|
+
price: number;
|
|
11
|
+
sizeDecimals?: number;
|
|
12
|
+
maxLeverage?: number;
|
|
13
|
+
fundingHours?: number;
|
|
14
|
+
};
|
|
15
|
+
export type RateMap = Map<string, Map<string, RateEntry>>;
|
|
16
|
+
/** Build adapter map from primary adapter + extraAdapters in context state. */
|
|
17
|
+
export declare function buildAdapterMap(ctx: StrategyContext): Map<string, ExchangeAdapter>;
|
|
18
|
+
/** Get or create a spot adapter for a given exchange name + perp adapter instance. */
|
|
19
|
+
export declare function getSpotAdapter(name: string, adapter: ExchangeAdapter): Promise<SpotAdapter | null>;
|
|
20
|
+
/** Transfer USDC from perp to spot account (exchange-specific). */
|
|
21
|
+
export declare function transferUsdcToSpot(spotAdapter: SpotAdapter, exchangeName: string, amount: number): Promise<void>;
|
|
22
|
+
/** Transfer USDC from spot to perp account (exchange-specific). */
|
|
23
|
+
export declare function transferUsdcToPerp(spotAdapter: SpotAdapter, exchangeName: string, amount: number): Promise<void>;
|
|
24
|
+
/** Resolve perp symbol for a given base symbol on an exchange. */
|
|
25
|
+
export declare function getPerpSymbol(baseSymbol: string, _exchangeName: string): string;
|
|
26
|
+
/** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP. */
|
|
27
|
+
export declare function findRate(ratesByExchange: RateMap, exchange: string, symbol: string): RateEntry | undefined;
|
|
28
|
+
/** Match a symbol against a target, accounting for -PERP suffix variations. */
|
|
29
|
+
export declare function matchSymbol(s: string, target: string): boolean;
|
|
30
|
+
/** Get a price estimate for a symbol from the perp adapter. */
|
|
31
|
+
export declare function getPriceEstimate(perpAdapter: ExchangeAdapter, perpSymbol: string, fallbackSymbol: string): Promise<number>;
|
|
32
|
+
export interface RecoveredPosition {
|
|
33
|
+
symbol: string;
|
|
34
|
+
mode: "spot-perp" | "perp-perp";
|
|
35
|
+
longExchange: string;
|
|
36
|
+
shortExchange: string;
|
|
37
|
+
size: string;
|
|
38
|
+
}
|
|
39
|
+
/** Recover arb positions from exchange state (perp-perp + spot-perp, same + cross exchange). */
|
|
40
|
+
export declare function recoverArbPositions(adapters: Map<string, ExchangeAdapter>, log: (msg: string) => void): Promise<RecoveredPosition[]>;
|
|
41
|
+
/** Fetch funding rates from an exchange adapter. */
|
|
42
|
+
export declare function fetchRates(adapter: ExchangeAdapter, exchangeName: string): Promise<{
|
|
43
|
+
symbol: string;
|
|
44
|
+
rate: number;
|
|
45
|
+
price: number;
|
|
46
|
+
sizeDecimals?: number;
|
|
47
|
+
maxLeverage?: number;
|
|
48
|
+
fundingHours?: number;
|
|
49
|
+
}[]>;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for funding arbitrage strategies (v1 + v2).
|
|
3
|
+
* Extracted to keep individual strategy files under size limits.
|
|
4
|
+
*/
|
|
5
|
+
// ── Adapter helpers ──
|
|
6
|
+
/** Build adapter map from primary adapter + extraAdapters in context state. */
|
|
7
|
+
export function buildAdapterMap(ctx) {
|
|
8
|
+
const extraAdapters = ctx.state.get("extraAdapters");
|
|
9
|
+
const adapters = new Map();
|
|
10
|
+
adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
|
|
11
|
+
if (extraAdapters) {
|
|
12
|
+
for (const [name, a] of extraAdapters)
|
|
13
|
+
adapters.set(name, a);
|
|
14
|
+
}
|
|
15
|
+
return adapters;
|
|
16
|
+
}
|
|
17
|
+
const spotAdapterCache = new Map();
|
|
18
|
+
/** Get or create a spot adapter for a given exchange name + perp adapter instance. */
|
|
19
|
+
export async function getSpotAdapter(name, adapter) {
|
|
20
|
+
const cached = spotAdapterCache.get(name);
|
|
21
|
+
if (cached)
|
|
22
|
+
return cached;
|
|
23
|
+
try {
|
|
24
|
+
let spot = null;
|
|
25
|
+
if (name === "hyperliquid") {
|
|
26
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
27
|
+
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
|
|
28
|
+
if (adapter instanceof HyperliquidAdapter) {
|
|
29
|
+
const instance = new HyperliquidSpotAdapter(adapter);
|
|
30
|
+
await instance.init();
|
|
31
|
+
spot = instance;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (name === "lighter") {
|
|
35
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
36
|
+
const { LighterAdapter } = await import("../../exchanges/lighter.js");
|
|
37
|
+
if (adapter instanceof LighterAdapter) {
|
|
38
|
+
const instance = new LighterSpotAdapter(adapter);
|
|
39
|
+
await instance.init();
|
|
40
|
+
spot = instance;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (spot)
|
|
44
|
+
spotAdapterCache.set(name, spot);
|
|
45
|
+
return spot;
|
|
46
|
+
}
|
|
47
|
+
catch { /* not supported */ }
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// ── Transfer helpers ──
|
|
51
|
+
/** Transfer USDC from perp to spot account (exchange-specific). */
|
|
52
|
+
export async function transferUsdcToSpot(spotAdapter, exchangeName, amount) {
|
|
53
|
+
if (exchangeName === "hyperliquid") {
|
|
54
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
55
|
+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
56
|
+
await spotAdapter.transferUsdcToSpot(amount);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (exchangeName === "lighter") {
|
|
61
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
62
|
+
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
63
|
+
await spotAdapter.transferUsdcToSpot(amount);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Transfer USDC from spot to perp account (exchange-specific). */
|
|
69
|
+
export async function transferUsdcToPerp(spotAdapter, exchangeName, amount) {
|
|
70
|
+
if (exchangeName === "hyperliquid") {
|
|
71
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
72
|
+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
73
|
+
await spotAdapter.transferUsdcToPerp(amount);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (exchangeName === "lighter") {
|
|
78
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
79
|
+
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
80
|
+
await spotAdapter.transferUsdcToPerp(amount);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// ── Rate / symbol helpers ──
|
|
86
|
+
/** Resolve perp symbol for a given base symbol on an exchange. */
|
|
87
|
+
export function getPerpSymbol(baseSymbol, _exchangeName) {
|
|
88
|
+
return baseSymbol.replace(/-PERP$/, "").toUpperCase();
|
|
89
|
+
}
|
|
90
|
+
/** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP. */
|
|
91
|
+
export function findRate(ratesByExchange, exchange, symbol) {
|
|
92
|
+
const map = ratesByExchange.get(exchange);
|
|
93
|
+
if (!map)
|
|
94
|
+
return undefined;
|
|
95
|
+
const upper = symbol.toUpperCase();
|
|
96
|
+
return map.get(upper) ?? map.get(upper + "-PERP") ?? map.get(upper.replace(/-PERP$/, ""));
|
|
97
|
+
}
|
|
98
|
+
/** Match a symbol against a target, accounting for -PERP suffix variations. */
|
|
99
|
+
export function matchSymbol(s, target) {
|
|
100
|
+
const u = s.toUpperCase();
|
|
101
|
+
const t = target.toUpperCase();
|
|
102
|
+
return u === t || u === t + "-PERP" || u.replace(/-PERP$/, "") === t;
|
|
103
|
+
}
|
|
104
|
+
/** Get a price estimate for a symbol from the perp adapter. */
|
|
105
|
+
export async function getPriceEstimate(perpAdapter, perpSymbol, fallbackSymbol) {
|
|
106
|
+
try {
|
|
107
|
+
const markets = await perpAdapter.getMarkets();
|
|
108
|
+
const market = markets.find(m => m.symbol.toUpperCase() === perpSymbol.toUpperCase() ||
|
|
109
|
+
m.symbol.toUpperCase() === fallbackSymbol.toUpperCase() ||
|
|
110
|
+
m.symbol.toUpperCase() === `${fallbackSymbol.toUpperCase()}-PERP`);
|
|
111
|
+
return market ? parseFloat(market.markPrice) : 0;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Recover arb positions from exchange state (perp-perp + spot-perp, same + cross exchange). */
|
|
118
|
+
export async function recoverArbPositions(adapters, log) {
|
|
119
|
+
const positionsByExchange = new Map();
|
|
120
|
+
for (const [name, a] of adapters) {
|
|
121
|
+
try {
|
|
122
|
+
const positions = await a.getPositions();
|
|
123
|
+
positionsByExchange.set(name, positions.map(p => ({ symbol: p.symbol.toUpperCase(), side: p.side, size: p.size })));
|
|
124
|
+
}
|
|
125
|
+
catch { /* skip */ }
|
|
126
|
+
}
|
|
127
|
+
const recovered = [];
|
|
128
|
+
const used = new Set();
|
|
129
|
+
// Cross-exchange perp-perp
|
|
130
|
+
for (const [exA, posA] of positionsByExchange) {
|
|
131
|
+
for (const pA of posA) {
|
|
132
|
+
const keyA = `${exA}:${pA.symbol}`;
|
|
133
|
+
if (used.has(keyA))
|
|
134
|
+
continue;
|
|
135
|
+
for (const [exB, posB] of positionsByExchange) {
|
|
136
|
+
if (exA === exB)
|
|
137
|
+
continue;
|
|
138
|
+
for (const pB of posB) {
|
|
139
|
+
const keyB = `${exB}:${pB.symbol}`;
|
|
140
|
+
if (used.has(keyB) || pA.symbol !== pB.symbol || pA.side === pB.side)
|
|
141
|
+
continue;
|
|
142
|
+
const longEx = pA.side === "long" ? exA : exB;
|
|
143
|
+
const shortEx = pA.side === "short" ? exA : exB;
|
|
144
|
+
recovered.push({ symbol: pA.symbol, mode: "perp-perp", longExchange: longEx, shortExchange: shortEx, size: pA.side === "long" ? pA.size : pB.size });
|
|
145
|
+
used.add(keyA);
|
|
146
|
+
used.add(keyB);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (used.has(keyA))
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Same-exchange spot-perp hedges
|
|
155
|
+
for (const [name, a] of adapters) {
|
|
156
|
+
try {
|
|
157
|
+
const spotAdapter = await getSpotAdapter(name, a);
|
|
158
|
+
if (!spotAdapter)
|
|
159
|
+
continue;
|
|
160
|
+
const bals = await spotAdapter.getSpotBalances();
|
|
161
|
+
const nonUsdc = bals.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
|
|
162
|
+
if (nonUsdc.length === 0)
|
|
163
|
+
continue;
|
|
164
|
+
for (const perp of positionsByExchange.get(name) ?? []) {
|
|
165
|
+
const perpKey = `${name}:${perp.symbol}`;
|
|
166
|
+
if (used.has(perpKey))
|
|
167
|
+
continue;
|
|
168
|
+
const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
|
|
169
|
+
const spotBal = nonUsdc.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === base);
|
|
170
|
+
if (spotBal && perp.side === "short") {
|
|
171
|
+
recovered.push({ symbol: base, mode: "spot-perp", longExchange: `${name}-spot`, shortExchange: name, size: perp.size });
|
|
172
|
+
used.add(perpKey);
|
|
173
|
+
log(` Recovered spot-perp: ${base} ${name}-spot<>${name}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch { /* no spot */ }
|
|
178
|
+
}
|
|
179
|
+
// Cross-exchange spot-perp
|
|
180
|
+
for (const [spotExName, spotExAdapter] of adapters) {
|
|
181
|
+
try {
|
|
182
|
+
const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
|
|
183
|
+
if (!spotAdapter)
|
|
184
|
+
continue;
|
|
185
|
+
const bals = await spotAdapter.getSpotBalances();
|
|
186
|
+
const nonUsdc = bals.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
|
|
187
|
+
for (const bal of nonUsdc) {
|
|
188
|
+
const base = bal.token.toUpperCase().replace(/-SPOT$/, "");
|
|
189
|
+
for (const [perpExName, perpPositions] of positionsByExchange) {
|
|
190
|
+
if (perpExName === spotExName)
|
|
191
|
+
continue;
|
|
192
|
+
for (const perp of perpPositions) {
|
|
193
|
+
const perpKey = `${perpExName}:${perp.symbol}`;
|
|
194
|
+
if (used.has(perpKey))
|
|
195
|
+
continue;
|
|
196
|
+
if (perp.symbol.replace(/-PERP$/, "").toUpperCase() === base && perp.side === "short") {
|
|
197
|
+
recovered.push({ symbol: base, mode: "spot-perp", longExchange: `${spotExName}-spot`, shortExchange: perpExName, size: perp.size });
|
|
198
|
+
used.add(perpKey);
|
|
199
|
+
log(` Recovered cross spot-perp: ${base} ${spotExName}-spot<>${perpExName}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch { /* no spot */ }
|
|
206
|
+
}
|
|
207
|
+
return recovered;
|
|
208
|
+
}
|
|
209
|
+
/** Fetch funding rates from an exchange adapter. */
|
|
210
|
+
export async function fetchRates(adapter, exchangeName) {
|
|
211
|
+
try {
|
|
212
|
+
const markets = await adapter.getMarkets();
|
|
213
|
+
const withRates = markets.filter(m => m.fundingRate != null);
|
|
214
|
+
// Bootstrap aster funding hours lazily
|
|
215
|
+
if (exchangeName === "aster" && "getFundingHours" in adapter) {
|
|
216
|
+
const aster = adapter;
|
|
217
|
+
const uncached = withRates.filter(m => {
|
|
218
|
+
const c = adapter?._fundingHoursCache?.get?.(m.symbol);
|
|
219
|
+
return c === undefined;
|
|
220
|
+
});
|
|
221
|
+
for (const m of uncached.slice(0, 20)) {
|
|
222
|
+
m.fundingHours = await aster.getFundingHours(m.symbol);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return withRates
|
|
226
|
+
.filter(m => m.fundingHours != null) // skip symbols with unknown funding period (aster not yet bootstrapped)
|
|
227
|
+
.map(m => ({
|
|
228
|
+
symbol: m.symbol,
|
|
229
|
+
rate: parseFloat(m.fundingRate),
|
|
230
|
+
price: parseFloat(m.markPrice),
|
|
231
|
+
sizeDecimals: m.sizeDecimals,
|
|
232
|
+
maxLeverage: m.maxLeverage,
|
|
233
|
+
fundingHours: m.fundingHours,
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Strategy, StrategyContext, StrategyAction, EnrichedSnapshot } from "../strategy-types.js";
|
|
2
|
+
export declare class FundingArbV2Strategy implements Strategy {
|
|
3
|
+
readonly name = "funding-arb-v2";
|
|
4
|
+
describe(): {
|
|
5
|
+
description: string;
|
|
6
|
+
params: ({
|
|
7
|
+
name: string;
|
|
8
|
+
type: "number";
|
|
9
|
+
required: boolean;
|
|
10
|
+
default: number;
|
|
11
|
+
description: string;
|
|
12
|
+
} | {
|
|
13
|
+
name: string;
|
|
14
|
+
type: "string";
|
|
15
|
+
required: boolean;
|
|
16
|
+
description: string;
|
|
17
|
+
default?: undefined;
|
|
18
|
+
} | {
|
|
19
|
+
name: string;
|
|
20
|
+
type: "boolean";
|
|
21
|
+
required: boolean;
|
|
22
|
+
default: boolean;
|
|
23
|
+
description: string;
|
|
24
|
+
})[];
|
|
25
|
+
};
|
|
26
|
+
private _config;
|
|
27
|
+
private _failCooldown;
|
|
28
|
+
private _fundingScoreCache;
|
|
29
|
+
private get params();
|
|
30
|
+
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
31
|
+
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
32
|
+
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
33
|
+
private scoreFunding;
|
|
34
|
+
private calcSignedSpread;
|
|
35
|
+
private checkCloseByHistory;
|
|
36
|
+
private findBestPerpShort;
|
|
37
|
+
private rotateShort;
|
|
38
|
+
private openSpotPerp;
|
|
39
|
+
private closeSpotPerp;
|
|
40
|
+
private openPerpPerp;
|
|
41
|
+
private closePerpPerp;
|
|
42
|
+
}
|