perp-cli 0.7.16 → 0.7.19
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/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 +506 -137
- package/dist/exchanges/lighter.js +10 -1
- package/dist/funding/normalize.d.ts +8 -0
- package/dist/funding/normalize.js +12 -0
- package/package.json +1 -1
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;
|
|
@@ -86,10 +90,68 @@ export class FundingArbStrategy {
|
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
}
|
|
93
|
+
// ── Same-exchange spot-perp hedges (e.g. PURR spot + PURR-PERP short on HL) ──
|
|
94
|
+
for (const [name, a] of adapters) {
|
|
95
|
+
try {
|
|
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)
|
|
117
|
+
continue;
|
|
118
|
+
const perpPositions = positionsByExchange.get(name) ?? [];
|
|
119
|
+
for (const perp of perpPositions) {
|
|
120
|
+
const perpKey = `${name}:${perp.symbol}`;
|
|
121
|
+
if (used.has(perpKey))
|
|
122
|
+
continue;
|
|
123
|
+
const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
|
|
124
|
+
const spotBal = nonUsdc.find(b => b.token.toUpperCase() === base);
|
|
125
|
+
if (spotBal && perp.side === "short") {
|
|
126
|
+
recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp" });
|
|
127
|
+
used.add(perpKey);
|
|
128
|
+
ctx.log(` [ARB] Detected spot-perp hedge: ${base} ${name}-spot↔${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { /* adapter doesn't support spot */ }
|
|
133
|
+
}
|
|
89
134
|
if (recovered.length > 0) {
|
|
90
135
|
ctx.state.set("arbOpenPositions", recovered);
|
|
91
136
|
ctx.state.set("arbPositions", recovered.length);
|
|
92
|
-
ctx.log(` [ARB] Recovered ${recovered.length}
|
|
137
|
+
ctx.log(` [ARB] Recovered ${recovered.length} position(s): ${recovered.map(p => `${p.symbol} ${p.longExchange}↔${p.shortExchange}`).join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
// ── Load historical funding income ──
|
|
140
|
+
let historicalFunding = 0;
|
|
141
|
+
for (const [name, a] of adapters) {
|
|
142
|
+
try {
|
|
143
|
+
const payments = await a.getFundingPayments(200);
|
|
144
|
+
const sum = payments.reduce((s, p) => s + parseFloat(p.payment), 0);
|
|
145
|
+
if (Math.abs(sum) > 0.001) {
|
|
146
|
+
historicalFunding += sum;
|
|
147
|
+
ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} historical funding`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch { /* not all adapters support this */ }
|
|
151
|
+
}
|
|
152
|
+
if (Math.abs(historicalFunding) > 0.001) {
|
|
153
|
+
ctx.state.set("fundingIncome", historicalFunding);
|
|
154
|
+
ctx.log(` [FUND] Total historical funding: $${historicalFunding.toFixed(4)}`);
|
|
93
155
|
}
|
|
94
156
|
}
|
|
95
157
|
catch (err) {
|
|
@@ -131,8 +193,8 @@ export class FundingArbStrategy {
|
|
|
131
193
|
try {
|
|
132
194
|
const payments = await a.getFundingPayments(50);
|
|
133
195
|
const startTime = ctx.state.get("fundingLastCheck");
|
|
134
|
-
const recent = payments.filter(p => p.
|
|
135
|
-
const sum = recent.reduce((s, p) => s + parseFloat(p.
|
|
196
|
+
const recent = payments.filter(p => p.time > startTime - 5 * 60 * 1000);
|
|
197
|
+
const sum = recent.reduce((s, p) => s + parseFloat(p.payment), 0);
|
|
136
198
|
totalFunding += sum;
|
|
137
199
|
if (Math.abs(sum) > 0.001) {
|
|
138
200
|
ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} funding received`);
|
|
@@ -148,39 +210,72 @@ export class FundingArbStrategy {
|
|
|
148
210
|
}
|
|
149
211
|
// ── Update strategy state for TUI display ──
|
|
150
212
|
const arbPosForDisplay = openPositions.map(p => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)}%`;
|
|
157
231
|
});
|
|
158
232
|
ctx.state.set("arbPositionDetails", arbPosForDisplay);
|
|
159
233
|
ctx.state.set("fundingTotal", `$${ctx.state.get("fundingIncome").toFixed(4)}`);
|
|
160
|
-
// ── Check existing positions for
|
|
234
|
+
// ── Check existing positions for close conditions ──
|
|
161
235
|
const toClose = [];
|
|
162
236
|
for (const pos of openPositions) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
}
|
|
184
279
|
}
|
|
185
280
|
}
|
|
186
281
|
}
|
|
@@ -200,6 +295,7 @@ export class FundingArbStrategy {
|
|
|
200
295
|
for (const sym of map.keys())
|
|
201
296
|
allSymbols.add(sym);
|
|
202
297
|
}
|
|
298
|
+
// Perp-perp opportunities
|
|
203
299
|
for (const sym of allSymbols) {
|
|
204
300
|
let minRate = Infinity, maxRate = -Infinity;
|
|
205
301
|
let minExchange = "", maxExchange = "";
|
|
@@ -217,7 +313,7 @@ export class FundingArbStrategy {
|
|
|
217
313
|
}
|
|
218
314
|
}
|
|
219
315
|
if (minExchange && maxExchange && minExchange !== maxExchange) {
|
|
220
|
-
const spread =
|
|
316
|
+
const spread = computeSignedAnnualSpread(maxRate, maxExchange, minRate, minExchange);
|
|
221
317
|
if (spread >= params.min_spread) {
|
|
222
318
|
opportunities.push({
|
|
223
319
|
symbol: sym,
|
|
@@ -226,116 +322,160 @@ export class FundingArbStrategy {
|
|
|
226
322
|
longRate: minRate,
|
|
227
323
|
shortRate: maxRate,
|
|
228
324
|
spread,
|
|
325
|
+
mode: "perp-perp",
|
|
229
326
|
});
|
|
230
327
|
}
|
|
231
328
|
}
|
|
232
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
|
+
}
|
|
233
365
|
opportunities.sort((a, b) => b.spread - a.spread);
|
|
234
366
|
if (opportunities.length > 0) {
|
|
235
367
|
const top = opportunities.slice(0, 3);
|
|
236
368
|
for (const opp of top) {
|
|
237
|
-
|
|
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
|
+
}
|
|
238
375
|
}
|
|
239
376
|
// Auto-execute if under position limit
|
|
240
377
|
if (arbPositions < params.max_positions && opportunities.length > 0) {
|
|
241
378
|
const best = opportunities[0];
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
]);
|
|
254
|
-
const longAvail = parseFloat(longBal.available);
|
|
255
|
-
const shortAvail = parseFloat(shortBal.available);
|
|
256
|
-
const minAvail = Math.min(longAvail, shortAvail);
|
|
257
|
-
// Use at most 80% of available balance per leg
|
|
258
|
-
const maxAffordable = minAvail * 0.8;
|
|
259
|
-
if (maxAffordable < 10) {
|
|
260
|
-
ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available, need $10+ per leg)`);
|
|
261
|
-
return [];
|
|
262
|
-
}
|
|
263
|
-
if (targetSizeUsd > maxAffordable) {
|
|
264
|
-
ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
|
|
265
|
-
targetSizeUsd = Math.floor(maxAffordable);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
catch { /* non-critical, proceed with original size */ }
|
|
269
|
-
// Liquidity check
|
|
270
|
-
const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
|
|
271
|
-
if (!liq.viable)
|
|
272
|
-
return [];
|
|
273
|
-
// Compute matched size
|
|
274
|
-
let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
|
|
275
|
-
if (!matched) {
|
|
276
|
-
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
277
|
-
return [];
|
|
278
|
-
}
|
|
279
|
-
try {
|
|
280
|
-
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)}%)`);
|
|
379
|
+
if (best.mode === "spot-perp") {
|
|
380
|
+
await this.openSpotPerp(best, adapters, ctx);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
const longAdapter = adapters.get(best.longExchange);
|
|
384
|
+
const shortAdapter = adapters.get(best.shortExchange);
|
|
385
|
+
if (longAdapter && shortAdapter) {
|
|
386
|
+
const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
|
|
387
|
+
if (price > 0) {
|
|
388
|
+
// Balance check
|
|
389
|
+
let targetSizeUsd = params.size_usd;
|
|
281
390
|
try {
|
|
282
|
-
await Promise.all([
|
|
283
|
-
longAdapter.
|
|
284
|
-
shortAdapter.
|
|
391
|
+
const [longBal, shortBal] = await Promise.all([
|
|
392
|
+
longAdapter.getBalance(),
|
|
393
|
+
shortAdapter.getBalance(),
|
|
285
394
|
]);
|
|
395
|
+
const longAvail = parseFloat(longBal.available);
|
|
396
|
+
const shortAvail = parseFloat(shortBal.available);
|
|
397
|
+
const minAvail = Math.min(longAvail, shortAvail);
|
|
398
|
+
const maxAffordable = minAvail * 0.8;
|
|
399
|
+
if (maxAffordable < 10) {
|
|
400
|
+
ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available, need $10+ per leg)`);
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
if (targetSizeUsd > maxAffordable) {
|
|
404
|
+
ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
|
|
405
|
+
targetSizeUsd = Math.floor(maxAffordable);
|
|
406
|
+
}
|
|
286
407
|
}
|
|
287
|
-
catch
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
408
|
+
catch { /* non-critical, proceed with original size */ }
|
|
409
|
+
// Liquidity check
|
|
410
|
+
const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
|
|
411
|
+
if (!liq.viable)
|
|
412
|
+
return [];
|
|
413
|
+
// Compute matched size
|
|
414
|
+
let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
|
|
415
|
+
if (!matched) {
|
|
416
|
+
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
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)}%)`);
|
|
421
|
+
try {
|
|
300
422
|
await Promise.all([
|
|
301
423
|
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
302
424
|
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
303
425
|
]);
|
|
304
426
|
}
|
|
305
|
-
|
|
306
|
-
|
|
427
|
+
catch (orderErr) {
|
|
428
|
+
// Parse lot size from error and retry with corrected size
|
|
429
|
+
const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
|
|
430
|
+
const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
|
|
431
|
+
if (lotMatch) {
|
|
432
|
+
const lotSize = parseFloat(lotMatch[1]);
|
|
433
|
+
ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
|
|
434
|
+
matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
|
|
435
|
+
if (!matched) {
|
|
436
|
+
ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
|
|
440
|
+
await Promise.all([
|
|
441
|
+
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
442
|
+
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
443
|
+
]);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
throw orderErr;
|
|
447
|
+
}
|
|
307
448
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
449
|
+
// Verify fills match, correct if needed
|
|
450
|
+
try {
|
|
451
|
+
const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
|
|
452
|
+
if (!recon.matched) {
|
|
453
|
+
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
454
|
+
}
|
|
314
455
|
}
|
|
456
|
+
catch (reconErr) {
|
|
457
|
+
const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
|
|
458
|
+
ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
|
|
459
|
+
}
|
|
460
|
+
// Track position
|
|
461
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
462
|
+
currentPositions.push({
|
|
463
|
+
symbol: best.symbol,
|
|
464
|
+
longExchange: best.longExchange,
|
|
465
|
+
shortExchange: best.shortExchange,
|
|
466
|
+
entrySpread: best.spread,
|
|
467
|
+
size: matched.size,
|
|
468
|
+
mode: "perp-perp",
|
|
469
|
+
});
|
|
470
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
471
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
472
|
+
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
473
|
+
return [{ type: "noop" }];
|
|
315
474
|
}
|
|
316
|
-
catch (
|
|
317
|
-
const
|
|
318
|
-
ctx.log(` [ARB]
|
|
475
|
+
catch (err) {
|
|
476
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
477
|
+
ctx.log(` [ARB] Execution failed: ${msg}`);
|
|
319
478
|
}
|
|
320
|
-
// Track position in array
|
|
321
|
-
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
322
|
-
currentPositions.push({
|
|
323
|
-
symbol: best.symbol,
|
|
324
|
-
longExchange: best.longExchange,
|
|
325
|
-
shortExchange: best.shortExchange,
|
|
326
|
-
entrySpread: best.spread,
|
|
327
|
-
size: matched.size,
|
|
328
|
-
});
|
|
329
|
-
ctx.state.set("arbOpenPositions", currentPositions);
|
|
330
|
-
ctx.state.set("arbPositions", currentPositions.length);
|
|
331
|
-
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
332
|
-
// Return noop -- we already executed directly via adapters
|
|
333
|
-
// (arb requires multi-adapter coordination, can't be expressed as single-adapter actions)
|
|
334
|
-
return [{ type: "noop" }];
|
|
335
|
-
}
|
|
336
|
-
catch (err) {
|
|
337
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
338
|
-
ctx.log(` [ARB] Execution failed: ${msg}`);
|
|
339
479
|
}
|
|
340
480
|
}
|
|
341
481
|
}
|
|
@@ -349,7 +489,6 @@ export class FundingArbStrategy {
|
|
|
349
489
|
async onStop(ctx) {
|
|
350
490
|
const openPositions = ctx.state.get("arbOpenPositions");
|
|
351
491
|
const extraAdapters = ctx.state.get("extraAdapters");
|
|
352
|
-
// Close all open arb positions
|
|
353
492
|
if (openPositions && openPositions.length > 0) {
|
|
354
493
|
const adapters = new Map();
|
|
355
494
|
adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
|
|
@@ -358,26 +497,31 @@ export class FundingArbStrategy {
|
|
|
358
497
|
adapters.set(name, a);
|
|
359
498
|
}
|
|
360
499
|
for (const pos of openPositions) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
500
|
+
if (pos.mode === "spot-perp") {
|
|
501
|
+
await this.closeSpotPerp(pos, adapters, ctx);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const longAdapter = adapters.get(pos.longExchange);
|
|
505
|
+
const shortAdapter = adapters.get(pos.shortExchange);
|
|
506
|
+
if (longAdapter && shortAdapter) {
|
|
507
|
+
try {
|
|
508
|
+
ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
|
|
509
|
+
await Promise.all([
|
|
510
|
+
longAdapter.marketOrder(pos.symbol, "sell", pos.size),
|
|
511
|
+
shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
|
|
512
|
+
]);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
516
|
+
ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
|
|
517
|
+
}
|
|
374
518
|
}
|
|
375
519
|
}
|
|
376
520
|
}
|
|
377
521
|
ctx.state.set("arbOpenPositions", []);
|
|
378
522
|
ctx.state.set("arbPositions", 0);
|
|
379
523
|
}
|
|
380
|
-
// Cancel on extra adapters directly
|
|
524
|
+
// Cancel on extra adapters directly
|
|
381
525
|
if (extraAdapters) {
|
|
382
526
|
for (const [, a] of extraAdapters) {
|
|
383
527
|
try {
|
|
@@ -388,6 +532,231 @@ export class FundingArbStrategy {
|
|
|
388
532
|
}
|
|
389
533
|
return [{ type: "cancel_all" }];
|
|
390
534
|
}
|
|
535
|
+
/** Close a spot-perp position: sell spot, transfer USDC back, close perp short */
|
|
536
|
+
async closeSpotPerp(pos, adapters, ctx) {
|
|
537
|
+
const exchangeName = pos.longExchange.replace("-spot", "");
|
|
538
|
+
const perpAdapter = adapters.get(pos.shortExchange) ?? adapters.get(exchangeName);
|
|
539
|
+
if (!perpAdapter) {
|
|
540
|
+
ctx.log(` [ARB] Close spot-perp failed: no adapter for ${pos.shortExchange}`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
|
|
545
|
+
if (!spotAdapter) {
|
|
546
|
+
ctx.log(` [ARB] Close spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
ctx.log(` [ARB] Closing spot-perp ${pos.symbol} on stop (spot sell + perp buy)`);
|
|
550
|
+
// Step 1: Sell spot
|
|
551
|
+
try {
|
|
552
|
+
await spotAdapter.spotMarketOrder(pos.symbol, "sell", pos.size);
|
|
553
|
+
ctx.log(` [ARB] Sold spot ${pos.size} ${pos.symbol}`);
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
557
|
+
ctx.log(` [ARB] Spot sell failed for ${pos.symbol}: ${msg}`);
|
|
558
|
+
}
|
|
559
|
+
// Step 2: Transfer USDC back to perp (use 95% of notional as estimate)
|
|
560
|
+
try {
|
|
561
|
+
const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
562
|
+
const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, pos.symbol);
|
|
563
|
+
const proceeds = priceEstimate * parseFloat(pos.size) * 0.95;
|
|
564
|
+
if (proceeds > 1) {
|
|
565
|
+
await this.transferUsdcToPerp(spotAdapter, exchangeName, Math.floor(proceeds));
|
|
566
|
+
ctx.log(` [ARB] Transferred ~$${Math.floor(proceeds)} USDC back to perp`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
571
|
+
ctx.log(` [ARB] USDC transfer back failed: ${msg}`);
|
|
572
|
+
}
|
|
573
|
+
// Step 3: Close perp short
|
|
574
|
+
try {
|
|
575
|
+
const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
576
|
+
await perpAdapter.marketOrder(perpSymbol, "buy", pos.size);
|
|
577
|
+
ctx.log(` [ARB] Closed perp short ${pos.size} ${perpSymbol}`);
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
581
|
+
ctx.log(` [ARB] Perp close failed for ${pos.symbol}: ${msg}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
586
|
+
ctx.log(` [ARB] Close spot-perp failed for ${pos.symbol}: ${msg}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
590
|
+
async openSpotPerp(opp, adapters, ctx) {
|
|
591
|
+
const params = this.params;
|
|
592
|
+
const exchangeName = opp.longExchange.replace("-spot", "");
|
|
593
|
+
const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
|
|
594
|
+
if (!perpAdapter) {
|
|
595
|
+
ctx.log(` [ARB] Open spot-perp failed: no adapter for ${opp.shortExchange}`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
|
|
600
|
+
if (!spotAdapter) {
|
|
601
|
+
ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// Balance check: need full notional for spot + margin for perp
|
|
605
|
+
let targetSizeUsd = params.size_usd;
|
|
606
|
+
try {
|
|
607
|
+
const perpBal = await perpAdapter.getBalance();
|
|
608
|
+
const perpAvail = parseFloat(perpBal.available);
|
|
609
|
+
const maxAffordable = perpAvail * 0.8;
|
|
610
|
+
if (maxAffordable < 10) {
|
|
611
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: insufficient balance ($${perpAvail.toFixed(2)} available)`);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (targetSizeUsd > maxAffordable) {
|
|
615
|
+
ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance)`);
|
|
616
|
+
targetSizeUsd = Math.floor(maxAffordable);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
catch { /* non-critical */ }
|
|
620
|
+
const perpSymbol = this.getPerpSymbol(opp.symbol, opp.shortExchange);
|
|
621
|
+
const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
|
|
622
|
+
if (priceEstimate <= 0) {
|
|
623
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't determine price`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
// Liquidity check
|
|
627
|
+
const liq = await checkSpotPerpLiquidity(spotAdapter, perpAdapter, opp.symbol, perpSymbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
|
|
628
|
+
if (!liq.viable)
|
|
629
|
+
return;
|
|
630
|
+
// Compute matched size
|
|
631
|
+
const spotMarkets = await spotAdapter.getSpotMarkets();
|
|
632
|
+
const spotMarket = spotMarkets.find(m => m.baseToken.toUpperCase() === opp.symbol.toUpperCase());
|
|
633
|
+
const spotDecimals = spotMarket?.sizeDecimals;
|
|
634
|
+
const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, priceEstimate, exchangeName, opp.shortExchange, spotDecimals);
|
|
635
|
+
if (!matched) {
|
|
636
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't compute matched size`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
ctx.log(` [ARB] Opening spot-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)}, slippage ~${liq.spotSlippage.toFixed(2)}%/${liq.perpSlippage.toFixed(2)}%)`);
|
|
640
|
+
// Step 1: Transfer USDC to spot
|
|
641
|
+
const transferAmt = Math.ceil(matched.notional * 1.02); // slight buffer for price movement
|
|
642
|
+
await this.transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
|
|
643
|
+
ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
|
|
644
|
+
// Step 2: Buy spot
|
|
645
|
+
await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
|
|
646
|
+
ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
|
|
647
|
+
// Step 3: Short perp
|
|
648
|
+
await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
|
|
649
|
+
ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
|
|
650
|
+
// Track position
|
|
651
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
652
|
+
currentPositions.push({
|
|
653
|
+
symbol: opp.symbol,
|
|
654
|
+
longExchange: opp.longExchange,
|
|
655
|
+
shortExchange: opp.shortExchange,
|
|
656
|
+
entrySpread: opp.spread,
|
|
657
|
+
size: matched.size,
|
|
658
|
+
mode: "spot-perp",
|
|
659
|
+
});
|
|
660
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
661
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
662
|
+
ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
666
|
+
ctx.log(` [ARB] Open spot-perp failed for ${opp.symbol}: ${msg}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/** Transfer USDC from perp to spot account (exchange-specific) */
|
|
670
|
+
async transferUsdcToSpot(spotAdapter, exchangeName, amount) {
|
|
671
|
+
if (exchangeName === "hyperliquid") {
|
|
672
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
673
|
+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
674
|
+
await spotAdapter.transferUsdcToSpot(amount);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
else if (exchangeName === "lighter") {
|
|
679
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
680
|
+
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
681
|
+
await spotAdapter.transferUsdcToSpot(amount);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** Transfer USDC from spot to perp account (exchange-specific) */
|
|
687
|
+
async transferUsdcToPerp(spotAdapter, exchangeName, amount) {
|
|
688
|
+
if (exchangeName === "hyperliquid") {
|
|
689
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
690
|
+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
691
|
+
await spotAdapter.transferUsdcToPerp(amount);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
else if (exchangeName === "lighter") {
|
|
696
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
697
|
+
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
698
|
+
await spotAdapter.transferUsdcToPerp(amount);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Get spot adapter for a given exchange name + perp adapter instance.
|
|
705
|
+
* Returns null if the exchange doesn't support spot or instantiation fails.
|
|
706
|
+
*/
|
|
707
|
+
async getSpotAdapter(name, adapter) {
|
|
708
|
+
try {
|
|
709
|
+
if (name === "hyperliquid") {
|
|
710
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
711
|
+
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
|
|
712
|
+
if (adapter instanceof HyperliquidAdapter) {
|
|
713
|
+
const spot = new HyperliquidSpotAdapter(adapter);
|
|
714
|
+
await spot.init();
|
|
715
|
+
return spot;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
else if (name === "lighter") {
|
|
719
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
720
|
+
const { LighterAdapter } = await import("../../exchanges/lighter.js");
|
|
721
|
+
if (adapter instanceof LighterAdapter) {
|
|
722
|
+
const spot = new LighterSpotAdapter(adapter);
|
|
723
|
+
await spot.init();
|
|
724
|
+
return spot;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
catch { /* not supported */ }
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Resolve the perp symbol for a given base symbol on an exchange.
|
|
733
|
+
* HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
|
|
734
|
+
*/
|
|
735
|
+
getPerpSymbol(baseSymbol, exchangeName) {
|
|
736
|
+
// Strip any existing -PERP suffix first
|
|
737
|
+
const base = baseSymbol.replace(/-PERP$/, "").toUpperCase();
|
|
738
|
+
// HL perp symbols are just the base (e.g. "PURR", "ETH", "BTC")
|
|
739
|
+
// Some HIP-3 tokens might need "-PERP" suffix — use base for now
|
|
740
|
+
// since getPositions() returns the base form on HL
|
|
741
|
+
void exchangeName;
|
|
742
|
+
return base;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Get a price estimate for a symbol from the perp adapter.
|
|
746
|
+
* Tries the base symbol first, then with -PERP suffix.
|
|
747
|
+
*/
|
|
748
|
+
async getPriceEstimate(perpAdapter, perpSymbol, fallbackSymbol) {
|
|
749
|
+
try {
|
|
750
|
+
const markets = await perpAdapter.getMarkets();
|
|
751
|
+
const market = markets.find(m => m.symbol.toUpperCase() === perpSymbol.toUpperCase() ||
|
|
752
|
+
m.symbol.toUpperCase() === fallbackSymbol.toUpperCase() ||
|
|
753
|
+
m.symbol.toUpperCase() === `${fallbackSymbol.toUpperCase()}-PERP`);
|
|
754
|
+
return market ? parseFloat(market.markPrice) : 0;
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return 0;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
391
760
|
async fetchRates(adapter, _exchangeName) {
|
|
392
761
|
try {
|
|
393
762
|
const markets = await adapter.getMarkets();
|
|
@@ -331,8 +331,17 @@ export class LighterAdapter {
|
|
|
331
331
|
const available = Number(acct.available_balance || 0);
|
|
332
332
|
const collateral = Number(acct.collateral || 0);
|
|
333
333
|
const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + Number(p.unrealized_pnl || 0), 0) ?? 0;
|
|
334
|
+
// Include spot USDC balance (separate from perp collateral)
|
|
335
|
+
let spotUsdc = 0;
|
|
336
|
+
try {
|
|
337
|
+
const assets = (acct.assets ?? []);
|
|
338
|
+
const usdcAsset = assets.find(a => a.symbol === "USDC");
|
|
339
|
+
if (usdcAsset)
|
|
340
|
+
spotUsdc = parseFloat(usdcAsset.balance || "0");
|
|
341
|
+
}
|
|
342
|
+
catch { /* non-critical */ }
|
|
334
343
|
return {
|
|
335
|
-
equity: String(totalAsset),
|
|
344
|
+
equity: String(totalAsset + spotUsdc),
|
|
336
345
|
available: String(available),
|
|
337
346
|
marginUsed: String(Math.max(0, collateral - available)),
|
|
338
347
|
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
|