perp-cli 0.7.17 → 0.7.20
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/conditions.js +1 -1
- package/dist/bot/config.d.ts +1 -0
- package/dist/bot/config.js +1 -0
- package/dist/bot/strategies/funding-arb-strategy.d.ts +23 -0
- package/dist/bot/strategies/funding-arb-strategy.js +479 -145
- package/dist/bot/strategies/funding-arb-v2.js +1 -1
- package/dist/bot/strategies/funding-auto.js +1 -1
- package/dist/commands/market.js +3 -3
- package/dist/exchanges/aster.js +1 -1
- package/dist/exchanges/hyperliquid.js +1 -1
- package/dist/exchanges/interface.d.ts +1 -1
- package/dist/exchanges/lighter.js +19 -6
- package/dist/funding/normalize.d.ts +8 -0
- package/dist/funding/normalize.js +12 -0
- package/package.json +1 -1
package/dist/bot/conditions.js
CHANGED
|
@@ -44,7 +44,7 @@ export async function getMarketSnapshot(adapter, symbol) {
|
|
|
44
44
|
throw new Error(`Market "${symbol}" not found on ${adapter.name}`);
|
|
45
45
|
}
|
|
46
46
|
const price = parseFloat(m.markPrice);
|
|
47
|
-
const fundingRate = parseFloat(m.fundingRate);
|
|
47
|
+
const fundingRate = parseFloat(m.fundingRate ?? "0");
|
|
48
48
|
const volume24h = parseFloat(m.volume24h);
|
|
49
49
|
// Try to get kline data for 24h high/low + RSI
|
|
50
50
|
let high24h = price;
|
package/dist/bot/config.d.ts
CHANGED
package/dist/bot/config.js
CHANGED
|
@@ -83,6 +83,7 @@ export function parseStrategy(type, raw) {
|
|
|
83
83
|
type: "funding-arb",
|
|
84
84
|
min_spread: Number(raw.min_spread ?? 20),
|
|
85
85
|
close_spread: Number(raw.close_spread ?? 5),
|
|
86
|
+
spot_perp_min_spread: raw.spot_perp_min_spread !== undefined ? Number(raw.spot_perp_min_spread) : undefined,
|
|
86
87
|
size_usd: Number(raw.size_usd ?? raw.size ?? 50),
|
|
87
88
|
max_positions: Number(raw.max_positions ?? 3),
|
|
88
89
|
exchanges: Array.isArray(raw.exchanges)
|
|
@@ -28,5 +28,28 @@ export declare class FundingArbStrategy implements Strategy {
|
|
|
28
28
|
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
29
29
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
30
30
|
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
31
|
+
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short */
|
|
32
|
+
private closeSpotPerp;
|
|
33
|
+
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
34
|
+
private openSpotPerp;
|
|
35
|
+
/** Transfer USDC from perp to spot account (exchange-specific) */
|
|
36
|
+
private transferUsdcToSpot;
|
|
37
|
+
/** Transfer USDC from spot to perp account (exchange-specific) */
|
|
38
|
+
private transferUsdcToPerp;
|
|
39
|
+
/**
|
|
40
|
+
* Get spot adapter for a given exchange name + perp adapter instance.
|
|
41
|
+
* Returns null if the exchange doesn't support spot or instantiation fails.
|
|
42
|
+
*/
|
|
43
|
+
private getSpotAdapter;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the perp symbol for a given base symbol on an exchange.
|
|
46
|
+
* HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
|
|
47
|
+
*/
|
|
48
|
+
private getPerpSymbol;
|
|
49
|
+
/**
|
|
50
|
+
* Get a price estimate for a symbol from the perp adapter.
|
|
51
|
+
* Tries the base symbol first, then with -PERP suffix.
|
|
52
|
+
*/
|
|
53
|
+
private getPriceEstimate;
|
|
31
54
|
private fetchRates;
|
|
32
55
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { registerStrategy } from "../strategy-registry.js";
|
|
2
|
-
import { checkArbLiquidity } from "../../liquidity.js";
|
|
3
|
-
import { computeMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
|
|
4
|
-
import {
|
|
2
|
+
import { checkArbLiquidity, checkSpotPerpLiquidity } from "../../liquidity.js";
|
|
3
|
+
import { computeMatchedSize, computeSpotPerpMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
|
|
4
|
+
import { computeSignedAnnualSpread, annualizeRate } from "../../funding.js";
|
|
5
5
|
export class FundingArbStrategy {
|
|
6
6
|
name = "funding-arb";
|
|
7
7
|
describe() {
|
|
@@ -10,6 +10,7 @@ export class FundingArbStrategy {
|
|
|
10
10
|
params: [
|
|
11
11
|
{ name: "min_spread", type: "number", required: true, description: "Minimum annualized spread %" },
|
|
12
12
|
{ name: "close_spread", type: "number", required: false, default: 5, description: "Spread to close at" },
|
|
13
|
+
{ name: "spot_perp_min_spread", type: "number", required: false, description: "Min spread for spot-perp (default: min_spread)" },
|
|
13
14
|
{ name: "size_usd", type: "number", required: true, description: "Position size in USD" },
|
|
14
15
|
{ name: "max_positions", type: "number", required: false, default: 3, description: "Max concurrent positions" },
|
|
15
16
|
{ name: "exchanges", type: "string", required: true, description: "Comma-separated exchange names" },
|
|
@@ -30,6 +31,9 @@ export class FundingArbStrategy {
|
|
|
30
31
|
ctx.state.set("fundingLastCheck", Date.now());
|
|
31
32
|
ctx.state.set("fundingHistory", []);
|
|
32
33
|
ctx.log(` [ARB] Funding arb ready | spread >= ${params.min_spread}% | close < ${params.close_spread}% | size $${params.size_usd}`);
|
|
34
|
+
if (params.spot_perp_min_spread !== undefined) {
|
|
35
|
+
ctx.log(` [ARB] Spot-perp min spread: ${params.spot_perp_min_spread}%`);
|
|
36
|
+
}
|
|
33
37
|
ctx.log(` [ARB] Exchanges: ${params.exchanges.join(", ")}`);
|
|
34
38
|
// ── Recover existing positions from exchanges ──
|
|
35
39
|
try {
|
|
@@ -76,7 +80,7 @@ export class FundingArbStrategy {
|
|
|
76
80
|
const longEx = pA.side === "long" ? exA : exB;
|
|
77
81
|
const shortEx = pA.side === "short" ? exA : exB;
|
|
78
82
|
const size = pA.side === "long" ? pA.size : pB.size;
|
|
79
|
-
recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size });
|
|
83
|
+
recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size, mode: "perp-perp" });
|
|
80
84
|
used.add(keyA);
|
|
81
85
|
used.add(keyB);
|
|
82
86
|
break;
|
|
@@ -89,20 +93,37 @@ export class FundingArbStrategy {
|
|
|
89
93
|
// ── Same-exchange spot-perp hedges (e.g. PURR spot + PURR-PERP short on HL) ──
|
|
90
94
|
for (const [name, a] of adapters) {
|
|
91
95
|
try {
|
|
92
|
-
|
|
93
|
-
if (
|
|
96
|
+
let spotBalances = [];
|
|
97
|
+
if (name === "hyperliquid") {
|
|
98
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
99
|
+
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
|
|
100
|
+
if (a instanceof HyperliquidAdapter) {
|
|
101
|
+
const hlSpot = new HyperliquidSpotAdapter(a);
|
|
102
|
+
await hlSpot.init();
|
|
103
|
+
spotBalances = await hlSpot.getSpotBalances();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (name === "lighter") {
|
|
107
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
108
|
+
const { LighterAdapter } = await import("../../exchanges/lighter.js");
|
|
109
|
+
if (a instanceof LighterAdapter) {
|
|
110
|
+
const ltSpot = new LighterSpotAdapter(a);
|
|
111
|
+
await ltSpot.init();
|
|
112
|
+
spotBalances = await ltSpot.getSpotBalances();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const nonUsdc = spotBalances.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
|
|
116
|
+
if (nonUsdc.length === 0)
|
|
94
117
|
continue;
|
|
95
|
-
const spotBalances = spotState.balances
|
|
96
|
-
.filter(b => Number(b.total) > 0 && b.coin.toUpperCase() !== "USDC");
|
|
97
118
|
const perpPositions = positionsByExchange.get(name) ?? [];
|
|
98
119
|
for (const perp of perpPositions) {
|
|
99
120
|
const perpKey = `${name}:${perp.symbol}`;
|
|
100
121
|
if (used.has(perpKey))
|
|
101
122
|
continue;
|
|
102
123
|
const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
|
|
103
|
-
const spotBal =
|
|
124
|
+
const spotBal = nonUsdc.find(b => b.token.toUpperCase() === base);
|
|
104
125
|
if (spotBal && perp.side === "short") {
|
|
105
|
-
recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size });
|
|
126
|
+
recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp" });
|
|
106
127
|
used.add(perpKey);
|
|
107
128
|
ctx.log(` [ARB] Detected spot-perp hedge: ${base} ${name}-spot↔${name}`);
|
|
108
129
|
}
|
|
@@ -189,39 +210,72 @@ export class FundingArbStrategy {
|
|
|
189
210
|
}
|
|
190
211
|
// ── Update strategy state for TUI display ──
|
|
191
212
|
const arbPosForDisplay = openPositions.map(p => {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
let signedSpread;
|
|
214
|
+
if (p.mode === "spot-perp") {
|
|
215
|
+
// For spot-perp: long side is spot (rate=0), short side is perp
|
|
216
|
+
const perpExchange = p.shortExchange;
|
|
217
|
+
const perpSymbol = this.getPerpSymbol(p.symbol, perpExchange);
|
|
218
|
+
const shortRate = ratesByExchange.get(perpExchange)?.get(perpSymbol)?.rate
|
|
219
|
+
?? ratesByExchange.get(perpExchange)?.get(p.symbol)?.rate;
|
|
220
|
+
signedSpread = shortRate !== undefined ? annualizeRate(shortRate, perpExchange) : 0;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const longRate = ratesByExchange.get(p.longExchange)?.get(p.symbol)?.rate;
|
|
224
|
+
const shortRate = ratesByExchange.get(p.shortExchange)?.get(p.symbol)?.rate;
|
|
225
|
+
signedSpread = (longRate !== undefined && shortRate !== undefined)
|
|
226
|
+
? computeSignedAnnualSpread(shortRate, p.shortExchange, longRate, p.longExchange)
|
|
227
|
+
: 0;
|
|
228
|
+
}
|
|
229
|
+
const sign = signedSpread >= 0 ? "+" : "";
|
|
230
|
+
return `${p.symbol} ${p.longExchange}↔${p.shortExchange} ${sign}${signedSpread.toFixed(1)}%`;
|
|
198
231
|
});
|
|
199
232
|
ctx.state.set("arbPositionDetails", arbPosForDisplay);
|
|
200
233
|
ctx.state.set("fundingTotal", `$${ctx.state.get("fundingIncome").toFixed(4)}`);
|
|
201
|
-
// ── Check existing positions for
|
|
234
|
+
// ── Check existing positions for close conditions ──
|
|
202
235
|
const toClose = [];
|
|
203
236
|
for (const pos of openPositions) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
237
|
+
let signedSpread;
|
|
238
|
+
if (pos.mode === "spot-perp") {
|
|
239
|
+
const perpExchange = pos.shortExchange;
|
|
240
|
+
const perpSymbol = this.getPerpSymbol(pos.symbol, perpExchange);
|
|
241
|
+
const shortRate = ratesByExchange.get(perpExchange)?.get(perpSymbol)?.rate
|
|
242
|
+
?? ratesByExchange.get(perpExchange)?.get(pos.symbol)?.rate;
|
|
243
|
+
if (shortRate === undefined)
|
|
244
|
+
continue;
|
|
245
|
+
signedSpread = annualizeRate(shortRate, perpExchange);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const longRate = ratesByExchange.get(pos.longExchange)?.get(pos.symbol)?.rate;
|
|
249
|
+
const shortRate = ratesByExchange.get(pos.shortExchange)?.get(pos.symbol)?.rate;
|
|
250
|
+
if (longRate === undefined || shortRate === undefined)
|
|
251
|
+
continue;
|
|
252
|
+
signedSpread = computeSignedAnnualSpread(shortRate, pos.shortExchange, longRate, pos.longExchange);
|
|
253
|
+
}
|
|
254
|
+
// Close if funding flipped (losing money) or spread below close threshold
|
|
255
|
+
const shouldClose = signedSpread < 0 || signedSpread < params.close_spread;
|
|
256
|
+
if (shouldClose) {
|
|
257
|
+
const sign = signedSpread >= 0 ? "+" : "";
|
|
258
|
+
ctx.log(` [ARB] Closing ${pos.symbol}: signed spread ${sign}${signedSpread.toFixed(1)}% (close_spread=${params.close_spread}%, entry=${pos.entrySpread.toFixed(1)}%)`);
|
|
259
|
+
if (pos.mode === "spot-perp") {
|
|
260
|
+
await this.closeSpotPerp(pos, adapters, ctx);
|
|
261
|
+
toClose.push(pos);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const longAdapter = adapters.get(pos.longExchange);
|
|
265
|
+
const shortAdapter = adapters.get(pos.shortExchange);
|
|
266
|
+
if (longAdapter && shortAdapter) {
|
|
267
|
+
try {
|
|
268
|
+
await Promise.all([
|
|
269
|
+
longAdapter.marketOrder(pos.symbol, "sell", pos.size),
|
|
270
|
+
shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
|
|
271
|
+
]);
|
|
272
|
+
toClose.push(pos);
|
|
273
|
+
ctx.log(` [ARB] Closed ${pos.symbol} position`);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
277
|
+
ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
|
|
278
|
+
}
|
|
225
279
|
}
|
|
226
280
|
}
|
|
227
281
|
}
|
|
@@ -241,6 +295,7 @@ export class FundingArbStrategy {
|
|
|
241
295
|
for (const sym of map.keys())
|
|
242
296
|
allSymbols.add(sym);
|
|
243
297
|
}
|
|
298
|
+
// Perp-perp opportunities
|
|
244
299
|
for (const sym of allSymbols) {
|
|
245
300
|
let minRate = Infinity, maxRate = -Infinity;
|
|
246
301
|
let minExchange = "", maxExchange = "";
|
|
@@ -258,7 +313,7 @@ export class FundingArbStrategy {
|
|
|
258
313
|
}
|
|
259
314
|
}
|
|
260
315
|
if (minExchange && maxExchange && minExchange !== maxExchange) {
|
|
261
|
-
const spread =
|
|
316
|
+
const spread = computeSignedAnnualSpread(maxRate, maxExchange, minRate, minExchange);
|
|
262
317
|
if (spread >= params.min_spread) {
|
|
263
318
|
opportunities.push({
|
|
264
319
|
symbol: sym,
|
|
@@ -267,117 +322,163 @@ export class FundingArbStrategy {
|
|
|
267
322
|
longRate: minRate,
|
|
268
323
|
shortRate: maxRate,
|
|
269
324
|
spread,
|
|
325
|
+
mode: "perp-perp",
|
|
270
326
|
});
|
|
271
327
|
}
|
|
272
328
|
}
|
|
273
329
|
}
|
|
330
|
+
// Spot-perp opportunities: for each exchange with positive perp rate, check if spot is available
|
|
331
|
+
const spotPerpMinSpread = params.spot_perp_min_spread ?? params.min_spread;
|
|
332
|
+
for (const [name, a] of adapters) {
|
|
333
|
+
try {
|
|
334
|
+
const spotAdapter = await this.getSpotAdapter(name, a);
|
|
335
|
+
if (!spotAdapter)
|
|
336
|
+
continue;
|
|
337
|
+
const spotMarkets = await spotAdapter.getSpotMarkets();
|
|
338
|
+
const spotSymbols = new Set(spotMarkets.map(m => m.baseToken.toUpperCase()));
|
|
339
|
+
const perpRates = ratesByExchange.get(name);
|
|
340
|
+
if (!perpRates)
|
|
341
|
+
continue;
|
|
342
|
+
for (const [sym, { rate }] of perpRates) {
|
|
343
|
+
// Only consider positive funding (shorts receive, longs pay)
|
|
344
|
+
if (rate <= 0)
|
|
345
|
+
continue;
|
|
346
|
+
const base = sym.replace(/-PERP$/, "").toUpperCase();
|
|
347
|
+
if (!spotSymbols.has(base))
|
|
348
|
+
continue;
|
|
349
|
+
const annualSpread = annualizeRate(rate, name);
|
|
350
|
+
if (annualSpread >= spotPerpMinSpread) {
|
|
351
|
+
opportunities.push({
|
|
352
|
+
symbol: base,
|
|
353
|
+
longExchange: `${name}-spot`,
|
|
354
|
+
shortExchange: name,
|
|
355
|
+
longRate: 0,
|
|
356
|
+
shortRate: rate,
|
|
357
|
+
spread: annualSpread,
|
|
358
|
+
mode: "spot-perp",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch { /* exchange doesn't support spot */ }
|
|
364
|
+
}
|
|
274
365
|
opportunities.sort((a, b) => b.spread - a.spread);
|
|
275
366
|
if (opportunities.length > 0) {
|
|
276
367
|
const top = opportunities.slice(0, 3);
|
|
277
368
|
for (const opp of top) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
369
|
+
if (opp.mode === "spot-perp") {
|
|
370
|
+
ctx.log(` [ARB] ${opp.symbol}: ${opp.spread.toFixed(1)}% spread [spot-perp] (short ${opp.shortExchange} ${(opp.shortRate * 100).toFixed(4)}%)`);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
ctx.log(` [ARB] ${opp.symbol}: ${opp.spread.toFixed(1)}% spread (long ${opp.longExchange} ${(opp.longRate * 100).toFixed(4)}% / short ${opp.shortExchange} ${(opp.shortRate * 100).toFixed(4)}%)`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Auto-execute: try each opportunity until one succeeds
|
|
377
|
+
if (arbPositions < params.max_positions) {
|
|
378
|
+
for (const best of opportunities) {
|
|
379
|
+
if (best.mode === "spot-perp") {
|
|
380
|
+
const opened = await this.openSpotPerp(best, adapters, ctx);
|
|
381
|
+
if (opened)
|
|
382
|
+
return [{ type: "noop" }];
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const longAdapter = adapters.get(best.longExchange);
|
|
386
|
+
const shortAdapter = adapters.get(best.shortExchange);
|
|
387
|
+
if (!longAdapter || !shortAdapter)
|
|
388
|
+
continue;
|
|
286
389
|
const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
|
|
287
|
-
if (price
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
if (targetSizeUsd > maxAffordable) {
|
|
305
|
-
ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
|
|
306
|
-
targetSizeUsd = Math.floor(maxAffordable);
|
|
307
|
-
}
|
|
390
|
+
if (price <= 0)
|
|
391
|
+
continue;
|
|
392
|
+
// Balance check
|
|
393
|
+
let targetSizeUsd = params.size_usd;
|
|
394
|
+
try {
|
|
395
|
+
const [longBal, shortBal] = await Promise.all([
|
|
396
|
+
longAdapter.getBalance(),
|
|
397
|
+
shortAdapter.getBalance(),
|
|
398
|
+
]);
|
|
399
|
+
const longAvail = parseFloat(longBal.available);
|
|
400
|
+
const shortAvail = parseFloat(shortBal.available);
|
|
401
|
+
const minAvail = Math.min(longAvail, shortAvail);
|
|
402
|
+
const maxAffordable = minAvail * 0.8;
|
|
403
|
+
if (maxAffordable < 10) {
|
|
404
|
+
ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available)`);
|
|
405
|
+
continue;
|
|
308
406
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (!liq.viable)
|
|
313
|
-
return [];
|
|
314
|
-
// Compute matched size
|
|
315
|
-
let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
|
|
316
|
-
if (!matched) {
|
|
317
|
-
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
318
|
-
return [];
|
|
407
|
+
if (targetSizeUsd > maxAffordable) {
|
|
408
|
+
ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
|
|
409
|
+
targetSizeUsd = Math.floor(maxAffordable);
|
|
319
410
|
}
|
|
411
|
+
}
|
|
412
|
+
catch { /* non-critical, proceed with original size */ }
|
|
413
|
+
// Liquidity check
|
|
414
|
+
const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
|
|
415
|
+
if (!liq.viable)
|
|
416
|
+
continue;
|
|
417
|
+
// Compute matched size
|
|
418
|
+
let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
|
|
419
|
+
if (!matched) {
|
|
420
|
+
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
|
|
320
425
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
426
|
+
await Promise.all([
|
|
427
|
+
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
428
|
+
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
429
|
+
]);
|
|
430
|
+
}
|
|
431
|
+
catch (orderErr) {
|
|
432
|
+
const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
|
|
433
|
+
const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
|
|
434
|
+
if (lotMatch) {
|
|
435
|
+
const lotSize = parseFloat(lotMatch[1]);
|
|
436
|
+
ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
|
|
437
|
+
matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
|
|
438
|
+
if (!matched) {
|
|
439
|
+
ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
|
|
323
443
|
await Promise.all([
|
|
324
444
|
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
325
445
|
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
326
446
|
]);
|
|
327
447
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
|
|
331
|
-
const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
|
|
332
|
-
if (lotMatch) {
|
|
333
|
-
const lotSize = parseFloat(lotMatch[1]);
|
|
334
|
-
ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
|
|
335
|
-
matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
|
|
336
|
-
if (!matched) {
|
|
337
|
-
ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
|
|
338
|
-
return [];
|
|
339
|
-
}
|
|
340
|
-
ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
|
|
341
|
-
await Promise.all([
|
|
342
|
-
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
343
|
-
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
344
|
-
]);
|
|
345
|
-
}
|
|
346
|
-
else {
|
|
347
|
-
throw orderErr;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
// Verify fills match, correct if needed
|
|
351
|
-
try {
|
|
352
|
-
const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
|
|
353
|
-
if (!recon.matched) {
|
|
354
|
-
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
355
|
-
}
|
|
448
|
+
else {
|
|
449
|
+
throw orderErr;
|
|
356
450
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
451
|
+
}
|
|
452
|
+
// Verify fills match
|
|
453
|
+
try {
|
|
454
|
+
const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
|
|
455
|
+
if (!recon.matched) {
|
|
456
|
+
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
360
457
|
}
|
|
361
|
-
// Track position in array
|
|
362
|
-
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
363
|
-
currentPositions.push({
|
|
364
|
-
symbol: best.symbol,
|
|
365
|
-
longExchange: best.longExchange,
|
|
366
|
-
shortExchange: best.shortExchange,
|
|
367
|
-
entrySpread: best.spread,
|
|
368
|
-
size: matched.size,
|
|
369
|
-
});
|
|
370
|
-
ctx.state.set("arbOpenPositions", currentPositions);
|
|
371
|
-
ctx.state.set("arbPositions", currentPositions.length);
|
|
372
|
-
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
373
|
-
// Return noop -- we already executed directly via adapters
|
|
374
|
-
// (arb requires multi-adapter coordination, can't be expressed as single-adapter actions)
|
|
375
|
-
return [{ type: "noop" }];
|
|
376
458
|
}
|
|
377
|
-
catch (
|
|
378
|
-
const
|
|
379
|
-
ctx.log(` [ARB]
|
|
459
|
+
catch (reconErr) {
|
|
460
|
+
const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
|
|
461
|
+
ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
|
|
380
462
|
}
|
|
463
|
+
// Track position
|
|
464
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
465
|
+
currentPositions.push({
|
|
466
|
+
symbol: best.symbol,
|
|
467
|
+
longExchange: best.longExchange,
|
|
468
|
+
shortExchange: best.shortExchange,
|
|
469
|
+
entrySpread: best.spread,
|
|
470
|
+
size: matched.size,
|
|
471
|
+
mode: "perp-perp",
|
|
472
|
+
});
|
|
473
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
474
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
475
|
+
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
476
|
+
return [{ type: "noop" }];
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
480
|
+
ctx.log(` [ARB] Execution failed for ${best.symbol}: ${msg}`);
|
|
481
|
+
continue;
|
|
381
482
|
}
|
|
382
483
|
}
|
|
383
484
|
}
|
|
@@ -390,7 +491,6 @@ export class FundingArbStrategy {
|
|
|
390
491
|
async onStop(ctx) {
|
|
391
492
|
const openPositions = ctx.state.get("arbOpenPositions");
|
|
392
493
|
const extraAdapters = ctx.state.get("extraAdapters");
|
|
393
|
-
// Close all open arb positions
|
|
394
494
|
if (openPositions && openPositions.length > 0) {
|
|
395
495
|
const adapters = new Map();
|
|
396
496
|
adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
|
|
@@ -399,26 +499,31 @@ export class FundingArbStrategy {
|
|
|
399
499
|
adapters.set(name, a);
|
|
400
500
|
}
|
|
401
501
|
for (const pos of openPositions) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
502
|
+
if (pos.mode === "spot-perp") {
|
|
503
|
+
await this.closeSpotPerp(pos, adapters, ctx);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
const longAdapter = adapters.get(pos.longExchange);
|
|
507
|
+
const shortAdapter = adapters.get(pos.shortExchange);
|
|
508
|
+
if (longAdapter && shortAdapter) {
|
|
509
|
+
try {
|
|
510
|
+
ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
|
|
511
|
+
await Promise.all([
|
|
512
|
+
longAdapter.marketOrder(pos.symbol, "sell", pos.size),
|
|
513
|
+
shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
|
|
514
|
+
]);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
518
|
+
ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
|
|
519
|
+
}
|
|
415
520
|
}
|
|
416
521
|
}
|
|
417
522
|
}
|
|
418
523
|
ctx.state.set("arbOpenPositions", []);
|
|
419
524
|
ctx.state.set("arbPositions", 0);
|
|
420
525
|
}
|
|
421
|
-
// Cancel on extra adapters directly
|
|
526
|
+
// Cancel on extra adapters directly
|
|
422
527
|
if (extraAdapters) {
|
|
423
528
|
for (const [, a] of extraAdapters) {
|
|
424
529
|
try {
|
|
@@ -429,10 +534,239 @@ export class FundingArbStrategy {
|
|
|
429
534
|
}
|
|
430
535
|
return [{ type: "cancel_all" }];
|
|
431
536
|
}
|
|
537
|
+
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short */
|
|
538
|
+
async closeSpotPerp(pos, adapters, ctx) {
|
|
539
|
+
const exchangeName = pos.longExchange.replace("-spot", "");
|
|
540
|
+
const perpAdapter = adapters.get(pos.shortExchange) ?? adapters.get(exchangeName);
|
|
541
|
+
if (!perpAdapter) {
|
|
542
|
+
ctx.log(` [ARB] Close spot-perp failed: no adapter for ${pos.shortExchange}`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
|
|
547
|
+
if (!spotAdapter) {
|
|
548
|
+
ctx.log(` [ARB] Close spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
ctx.log(` [ARB] Closing spot-perp ${pos.symbol} on stop (spot sell + perp buy)`);
|
|
552
|
+
// Step 1: Sell spot
|
|
553
|
+
try {
|
|
554
|
+
await spotAdapter.spotMarketOrder(pos.symbol, "sell", pos.size);
|
|
555
|
+
ctx.log(` [ARB] Sold spot ${pos.size} ${pos.symbol}`);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
559
|
+
ctx.log(` [ARB] Spot sell failed for ${pos.symbol}: ${msg}`);
|
|
560
|
+
}
|
|
561
|
+
// Step 2: Transfer USDC back to perp (use 95% of notional as estimate)
|
|
562
|
+
try {
|
|
563
|
+
const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
564
|
+
const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, pos.symbol);
|
|
565
|
+
const proceeds = priceEstimate * parseFloat(pos.size) * 0.95;
|
|
566
|
+
if (proceeds > 1) {
|
|
567
|
+
await this.transferUsdcToPerp(spotAdapter, exchangeName, Math.floor(proceeds));
|
|
568
|
+
ctx.log(` [ARB] Transferred ~$${Math.floor(proceeds)} USDC back to perp`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
573
|
+
ctx.log(` [ARB] USDC transfer back failed: ${msg}`);
|
|
574
|
+
}
|
|
575
|
+
// Step 3: Close perp short
|
|
576
|
+
try {
|
|
577
|
+
const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
578
|
+
await perpAdapter.marketOrder(perpSymbol, "buy", pos.size);
|
|
579
|
+
ctx.log(` [ARB] Closed perp short ${pos.size} ${perpSymbol}`);
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
583
|
+
ctx.log(` [ARB] Perp close failed for ${pos.symbol}: ${msg}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
588
|
+
ctx.log(` [ARB] Close spot-perp failed for ${pos.symbol}: ${msg}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
592
|
+
async openSpotPerp(opp, adapters, ctx) {
|
|
593
|
+
const params = this.params;
|
|
594
|
+
const exchangeName = opp.longExchange.replace("-spot", "");
|
|
595
|
+
const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
|
|
596
|
+
if (!perpAdapter) {
|
|
597
|
+
ctx.log(` [ARB] Open spot-perp failed: no adapter for ${opp.shortExchange}`);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
|
|
602
|
+
if (!spotAdapter) {
|
|
603
|
+
ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
// Balance check: need full notional for spot + margin for perp
|
|
607
|
+
let targetSizeUsd = params.size_usd;
|
|
608
|
+
try {
|
|
609
|
+
const perpBal = await perpAdapter.getBalance();
|
|
610
|
+
const perpAvail = parseFloat(perpBal.available);
|
|
611
|
+
const maxAffordable = perpAvail * 0.8;
|
|
612
|
+
if (maxAffordable < 10) {
|
|
613
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: insufficient balance ($${perpAvail.toFixed(2)} available)`);
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
if (targetSizeUsd > maxAffordable) {
|
|
617
|
+
ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance)`);
|
|
618
|
+
targetSizeUsd = Math.floor(maxAffordable);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch { /* non-critical */ }
|
|
622
|
+
const perpSymbol = this.getPerpSymbol(opp.symbol, opp.shortExchange);
|
|
623
|
+
const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
|
|
624
|
+
if (priceEstimate <= 0) {
|
|
625
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't determine price`);
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
// Liquidity check
|
|
629
|
+
const liq = await checkSpotPerpLiquidity(spotAdapter, perpAdapter, opp.symbol, perpSymbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
|
|
630
|
+
if (!liq.viable)
|
|
631
|
+
return false;
|
|
632
|
+
// Compute matched size
|
|
633
|
+
const spotMarkets = await spotAdapter.getSpotMarkets();
|
|
634
|
+
const spotMarket = spotMarkets.find(m => m.baseToken.toUpperCase() === opp.symbol.toUpperCase());
|
|
635
|
+
const spotDecimals = spotMarket?.sizeDecimals;
|
|
636
|
+
const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, priceEstimate, exchangeName, opp.shortExchange, spotDecimals);
|
|
637
|
+
if (!matched) {
|
|
638
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't compute matched size`);
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
ctx.log(` [ARB] Opening spot-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)}, slippage ~${liq.spotSlippage.toFixed(2)}%/${liq.perpSlippage.toFixed(2)}%)`);
|
|
642
|
+
// Step 1: Transfer USDC to spot
|
|
643
|
+
const transferAmt = Math.ceil(matched.notional * 1.02); // slight buffer for price movement
|
|
644
|
+
await this.transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
|
|
645
|
+
ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
|
|
646
|
+
// Step 2: Buy spot
|
|
647
|
+
await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
|
|
648
|
+
ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
|
|
649
|
+
// Step 3: Short perp
|
|
650
|
+
await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
|
|
651
|
+
ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
|
|
652
|
+
// Track position
|
|
653
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
654
|
+
currentPositions.push({
|
|
655
|
+
symbol: opp.symbol,
|
|
656
|
+
longExchange: opp.longExchange,
|
|
657
|
+
shortExchange: opp.shortExchange,
|
|
658
|
+
entrySpread: opp.spread,
|
|
659
|
+
size: matched.size,
|
|
660
|
+
mode: "spot-perp",
|
|
661
|
+
});
|
|
662
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
663
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
664
|
+
ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
669
|
+
ctx.log(` [ARB] Open spot-perp failed for ${opp.symbol}: ${msg}`);
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/** Transfer USDC from perp to spot account (exchange-specific) */
|
|
674
|
+
async transferUsdcToSpot(spotAdapter, exchangeName, amount) {
|
|
675
|
+
if (exchangeName === "hyperliquid") {
|
|
676
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
677
|
+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
678
|
+
await spotAdapter.transferUsdcToSpot(amount);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
else if (exchangeName === "lighter") {
|
|
683
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
684
|
+
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
685
|
+
await spotAdapter.transferUsdcToSpot(amount);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/** Transfer USDC from spot to perp account (exchange-specific) */
|
|
691
|
+
async transferUsdcToPerp(spotAdapter, exchangeName, amount) {
|
|
692
|
+
if (exchangeName === "hyperliquid") {
|
|
693
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
694
|
+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
695
|
+
await spotAdapter.transferUsdcToPerp(amount);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else if (exchangeName === "lighter") {
|
|
700
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
701
|
+
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
702
|
+
await spotAdapter.transferUsdcToPerp(amount);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Get spot adapter for a given exchange name + perp adapter instance.
|
|
709
|
+
* Returns null if the exchange doesn't support spot or instantiation fails.
|
|
710
|
+
*/
|
|
711
|
+
async getSpotAdapter(name, adapter) {
|
|
712
|
+
try {
|
|
713
|
+
if (name === "hyperliquid") {
|
|
714
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
715
|
+
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
|
|
716
|
+
if (adapter instanceof HyperliquidAdapter) {
|
|
717
|
+
const spot = new HyperliquidSpotAdapter(adapter);
|
|
718
|
+
await spot.init();
|
|
719
|
+
return spot;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
else if (name === "lighter") {
|
|
723
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
724
|
+
const { LighterAdapter } = await import("../../exchanges/lighter.js");
|
|
725
|
+
if (adapter instanceof LighterAdapter) {
|
|
726
|
+
const spot = new LighterSpotAdapter(adapter);
|
|
727
|
+
await spot.init();
|
|
728
|
+
return spot;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch { /* not supported */ }
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Resolve the perp symbol for a given base symbol on an exchange.
|
|
737
|
+
* HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
|
|
738
|
+
*/
|
|
739
|
+
getPerpSymbol(baseSymbol, exchangeName) {
|
|
740
|
+
// Strip any existing -PERP suffix first
|
|
741
|
+
const base = baseSymbol.replace(/-PERP$/, "").toUpperCase();
|
|
742
|
+
// HL perp symbols are just the base (e.g. "PURR", "ETH", "BTC")
|
|
743
|
+
// Some HIP-3 tokens might need "-PERP" suffix — use base for now
|
|
744
|
+
// since getPositions() returns the base form on HL
|
|
745
|
+
void exchangeName;
|
|
746
|
+
return base;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Get a price estimate for a symbol from the perp adapter.
|
|
750
|
+
* Tries the base symbol first, then with -PERP suffix.
|
|
751
|
+
*/
|
|
752
|
+
async getPriceEstimate(perpAdapter, perpSymbol, fallbackSymbol) {
|
|
753
|
+
try {
|
|
754
|
+
const markets = await perpAdapter.getMarkets();
|
|
755
|
+
const market = markets.find(m => m.symbol.toUpperCase() === perpSymbol.toUpperCase() ||
|
|
756
|
+
m.symbol.toUpperCase() === fallbackSymbol.toUpperCase() ||
|
|
757
|
+
m.symbol.toUpperCase() === `${fallbackSymbol.toUpperCase()}-PERP`);
|
|
758
|
+
return market ? parseFloat(market.markPrice) : 0;
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
return 0;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
432
764
|
async fetchRates(adapter, _exchangeName) {
|
|
433
765
|
try {
|
|
434
766
|
const markets = await adapter.getMarkets();
|
|
435
|
-
return markets
|
|
767
|
+
return markets
|
|
768
|
+
.filter(m => m.fundingRate != null) // skip symbols with unavailable rates
|
|
769
|
+
.map(m => ({
|
|
436
770
|
symbol: m.symbol,
|
|
437
771
|
rate: parseFloat(m.fundingRate),
|
|
438
772
|
price: parseFloat(m.markPrice),
|
|
@@ -48,7 +48,7 @@ export class FundingArbV2Strategy {
|
|
|
48
48
|
const map = new Map();
|
|
49
49
|
for (const m of markets) {
|
|
50
50
|
map.set(m.symbol.toUpperCase(), {
|
|
51
|
-
rate: parseFloat(m.fundingRate),
|
|
51
|
+
rate: m.fundingRate != null ? parseFloat(m.fundingRate) : NaN,
|
|
52
52
|
price: parseFloat(m.markPrice),
|
|
53
53
|
});
|
|
54
54
|
}
|
|
@@ -125,7 +125,7 @@ class FundingAutoStrategy {
|
|
|
125
125
|
const sym = normalizeSymbol(raw);
|
|
126
126
|
const price = parseFloat(m.markPrice);
|
|
127
127
|
map.set(sym, {
|
|
128
|
-
rate: parseFloat(m.fundingRate),
|
|
128
|
+
rate: m.fundingRate != null ? parseFloat(m.fundingRate) : NaN,
|
|
129
129
|
price,
|
|
130
130
|
exchange: name,
|
|
131
131
|
});
|
package/dist/commands/market.js
CHANGED
|
@@ -15,7 +15,7 @@ export function registerMarketCommands(program, getAdapter, isJson, getAdapterFo
|
|
|
15
15
|
chalk.white.bold(m.symbol),
|
|
16
16
|
`$${formatUsd(m.markPrice)}`,
|
|
17
17
|
`$${formatUsd(m.indexPrice)}`,
|
|
18
|
-
formatPercent(m.fundingRate),
|
|
18
|
+
m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A"),
|
|
19
19
|
`$${formatUsd(m.volume24h)}`,
|
|
20
20
|
`$${formatUsd(m.openInterest)}`,
|
|
21
21
|
String(m.maxLeverage) + "x",
|
|
@@ -98,7 +98,7 @@ export function registerMarketCommands(program, getAdapter, isJson, getAdapterFo
|
|
|
98
98
|
chalk.white.bold(m.symbol),
|
|
99
99
|
`$${formatUsd(m.markPrice)}`,
|
|
100
100
|
`$${formatUsd(m.indexPrice)}`,
|
|
101
|
-
formatPercent(m.fundingRate),
|
|
101
|
+
m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A"),
|
|
102
102
|
];
|
|
103
103
|
if (!explicitExchange && getAdapterForExchange) {
|
|
104
104
|
const grouped = {};
|
|
@@ -170,7 +170,7 @@ export function registerMarketCommands(program, getAdapter, isJson, getAdapterFo
|
|
|
170
170
|
console.log(chalk.cyan.bold(`\n ${m.symbol} on ${adapter.name}\n`));
|
|
171
171
|
console.log(` Mark Price: $${formatUsd(m.markPrice)}`);
|
|
172
172
|
console.log(` Index Price: $${formatUsd(m.indexPrice)}`);
|
|
173
|
-
console.log(` Funding Rate: ${formatPercent(m.fundingRate)}`);
|
|
173
|
+
console.log(` Funding Rate: ${m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A")}`);
|
|
174
174
|
console.log(` 24h Volume: $${formatUsd(m.volume24h)}`);
|
|
175
175
|
console.log(` Open Interest: $${formatUsd(m.openInterest)}`);
|
|
176
176
|
console.log(` Max Leverage: ${m.maxLeverage}x`);
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -79,7 +79,7 @@ export class AsterAdapter {
|
|
|
79
79
|
symbol: this._fromApi(sym),
|
|
80
80
|
markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
|
|
81
81
|
indexPrice: String(premium?.indexPrice ?? "0"),
|
|
82
|
-
fundingRate: String(premium
|
|
82
|
+
fundingRate: premium?.lastFundingRate != null ? String(premium.lastFundingRate) : null,
|
|
83
83
|
volume24h: String(ticker?.quoteVolume ?? ticker?.volume ?? "0"),
|
|
84
84
|
openInterest: "0",
|
|
85
85
|
maxLeverage: maxLev,
|
|
@@ -218,7 +218,7 @@ export class HyperliquidAdapter {
|
|
|
218
218
|
symbol: sym,
|
|
219
219
|
markPrice: String(ctx.markPx ?? mids[sym] ?? "0"),
|
|
220
220
|
indexPrice: String(ctx.oraclePx ?? "0"),
|
|
221
|
-
fundingRate: String(ctx.funding
|
|
221
|
+
fundingRate: ctx.funding != null ? String(ctx.funding) : null,
|
|
222
222
|
volume24h: String(ctx.dayNtlVlm ?? "0"),
|
|
223
223
|
openInterest: String(ctx.openInterest ?? "0"),
|
|
224
224
|
maxLeverage: Number(asset.maxLeverage ?? 50),
|
|
@@ -266,18 +266,22 @@ export class LighterAdapter {
|
|
|
266
266
|
return this._marketsCache;
|
|
267
267
|
const markets = [];
|
|
268
268
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
// Fetch order book details and funding rates in parallel
|
|
270
|
+
const [obRes, fundingRates] = await Promise.all([
|
|
271
|
+
this.restGet("/orderBookDetails", {}),
|
|
272
|
+
this.getFundingRates().catch(() => new Map()),
|
|
273
|
+
]);
|
|
274
|
+
for (const d of obRes.order_book_details ?? []) {
|
|
271
275
|
if (d.market_type !== "perp")
|
|
272
276
|
continue;
|
|
273
|
-
// max_leverage from min_initial_margin_fraction (e.g. 400 = 4% margin = 25x)
|
|
274
277
|
const imf = d.min_initial_margin_fraction ?? d.default_initial_margin_fraction ?? 500;
|
|
275
278
|
const maxLev = imf > 0 ? Math.floor(10000 / imf) : 50;
|
|
279
|
+
const fr = fundingRates.get(d.symbol);
|
|
276
280
|
markets.push({
|
|
277
281
|
symbol: d.symbol,
|
|
278
|
-
markPrice: String(d.last_trade_price ?? 0),
|
|
282
|
+
markPrice: fr?.markPrice ?? String(d.last_trade_price ?? 0),
|
|
279
283
|
indexPrice: String(d.last_trade_price ?? 0),
|
|
280
|
-
fundingRate:
|
|
284
|
+
fundingRate: fr?.rate ?? null,
|
|
281
285
|
volume24h: String(d.daily_quote_token_volume ?? 0),
|
|
282
286
|
openInterest: String(d.open_interest ?? 0),
|
|
283
287
|
maxLeverage: maxLev,
|
|
@@ -331,8 +335,17 @@ export class LighterAdapter {
|
|
|
331
335
|
const available = Number(acct.available_balance || 0);
|
|
332
336
|
const collateral = Number(acct.collateral || 0);
|
|
333
337
|
const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + Number(p.unrealized_pnl || 0), 0) ?? 0;
|
|
338
|
+
// Include spot USDC balance (separate from perp collateral)
|
|
339
|
+
let spotUsdc = 0;
|
|
340
|
+
try {
|
|
341
|
+
const assets = (acct.assets ?? []);
|
|
342
|
+
const usdcAsset = assets.find(a => a.symbol === "USDC");
|
|
343
|
+
if (usdcAsset)
|
|
344
|
+
spotUsdc = parseFloat(usdcAsset.balance || "0");
|
|
345
|
+
}
|
|
346
|
+
catch { /* non-critical */ }
|
|
334
347
|
return {
|
|
335
|
-
equity: String(totalAsset),
|
|
348
|
+
equity: String(totalAsset + spotUsdc),
|
|
336
349
|
available: String(available),
|
|
337
350
|
marginUsed: String(Math.max(0, collateral - available)),
|
|
338
351
|
unrealizedPnl: String(unrealizedPnl),
|
|
@@ -28,6 +28,14 @@ export declare function annualizeHourlyRate(hourlyRate: number): number;
|
|
|
28
28
|
* Normalizes both to hourly before comparing.
|
|
29
29
|
*/
|
|
30
30
|
export declare function computeAnnualSpread(rateA: number, exchangeA: string, rateB: number, exchangeB: string): number;
|
|
31
|
+
/**
|
|
32
|
+
* Compute SIGNED annualized spread for an existing arb position.
|
|
33
|
+
* Positive = favorable (short receiving more than long paying).
|
|
34
|
+
* Negative = unfavorable (funding flipped, position losing on funding).
|
|
35
|
+
*
|
|
36
|
+
* For spot-perp: pass longRate=0, longExchange=shortExchange (spot has no funding).
|
|
37
|
+
*/
|
|
38
|
+
export declare function computeSignedAnnualSpread(shortRate: number, shortExchange: string, longRate: number, longExchange: string): number;
|
|
31
39
|
/**
|
|
32
40
|
* Estimate hourly funding payment for a position.
|
|
33
41
|
* @param rate - raw funding rate from the exchange
|
|
@@ -51,6 +51,18 @@ export function computeAnnualSpread(rateA, exchangeA, rateB, exchangeB) {
|
|
|
51
51
|
const hourlyB = toHourlyRate(rateB, exchangeB);
|
|
52
52
|
return Math.abs(hourlyA - hourlyB) * HOURLY_PERIODS * 100;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Compute SIGNED annualized spread for an existing arb position.
|
|
56
|
+
* Positive = favorable (short receiving more than long paying).
|
|
57
|
+
* Negative = unfavorable (funding flipped, position losing on funding).
|
|
58
|
+
*
|
|
59
|
+
* For spot-perp: pass longRate=0, longExchange=shortExchange (spot has no funding).
|
|
60
|
+
*/
|
|
61
|
+
export function computeSignedAnnualSpread(shortRate, shortExchange, longRate, longExchange) {
|
|
62
|
+
const hourlyShort = toHourlyRate(shortRate, shortExchange);
|
|
63
|
+
const hourlyLong = toHourlyRate(longRate, longExchange);
|
|
64
|
+
return (hourlyShort - hourlyLong) * HOURLY_PERIODS * 100;
|
|
65
|
+
}
|
|
54
66
|
/**
|
|
55
67
|
* Estimate hourly funding payment for a position.
|
|
56
68
|
* @param rate - raw funding rate from the exchange
|