perp-cli 0.7.50 → 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.
@@ -0,0 +1,873 @@
1
+ import { registerStrategy } from "../strategy-registry.js";
2
+ import { checkArbLiquidity, checkSpotPerpLiquidity } from "../../liquidity.js";
3
+ import { computeMatchedSize, computeSpotPerpMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
4
+ import { getFundingHours } from "../../funding.js";
5
+ import { logExecution, pruneExecutionLog } from "../../execution-log.js";
6
+ import { invalidateCache } from "../../cache.js";
7
+ import { buildAdapterMap, getSpotAdapter, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, matchSymbol, getPriceEstimate, fetchRates, recoverArbPositions, } from "./funding-arb-utils.js";
8
+ let _posIdCounter = 0;
9
+ function genPosId() { return `v2-${Date.now()}-${++_posIdCounter}`; }
10
+ // ── Strategy ──
11
+ export class FundingArbV2Strategy {
12
+ name = "funding-arb-v2";
13
+ describe() {
14
+ return {
15
+ description: "Funding arbitrage v2 — history-based, spot-perp primary, short rotation",
16
+ params: [
17
+ { name: "min_spread", type: "number", required: false, default: 15, description: "Min annualized spread % to enter" },
18
+ { name: "close_spread", type: "number", required: false, default: 3, description: "Close if spread below this %" },
19
+ { name: "size_usd", type: "number", required: false, default: 50, description: "Position size in USD" },
20
+ { name: "max_positions", type: "number", required: false, default: 5, description: "Max concurrent positions" },
21
+ { name: "exchanges", type: "string", required: true, description: "Comma-separated exchange names" },
22
+ { name: "leverage", type: "number", required: false, default: 3, description: "Max leverage" },
23
+ { name: "min_hold_hours", type: "number", required: false, default: 4, description: "Min hold before close (hours)" },
24
+ { name: "min_consistency", type: "number", required: false, default: 0.7, description: "Min positive payment ratio (0-1)" },
25
+ { name: "history_periods", type: "number", required: false, default: 6, description: "Funding periods to check" },
26
+ { name: "spot_perp_priority", type: "boolean", required: false, default: true, description: "Prefer spot-perp over perp-perp" },
27
+ { name: "rotation_threshold", type: "number", required: false, default: 10, description: "% improvement needed to rotate short" },
28
+ ],
29
+ };
30
+ }
31
+ _config = {};
32
+ _failCooldown = new Map();
33
+ _fundingScoreCache = new Map();
34
+ get params() {
35
+ const c = this._config;
36
+ return {
37
+ type: "funding-arb-v2",
38
+ min_spread: c.min_spread ?? 15,
39
+ close_spread: c.close_spread ?? 3,
40
+ size_usd: c.size_usd ?? 50,
41
+ max_positions: c.max_positions ?? 5,
42
+ exchanges: (typeof c.exchanges === "string" ? c.exchanges.split(",").map(s => s.trim()) : c.exchanges) ?? [],
43
+ leverage: c.leverage ?? 3,
44
+ min_hold_hours: c.min_hold_hours ?? 4,
45
+ min_consistency: c.min_consistency ?? 0.7,
46
+ history_periods: c.history_periods ?? 6,
47
+ spot_perp_priority: c.spot_perp_priority ?? true,
48
+ rotation_threshold: c.rotation_threshold ?? 10,
49
+ };
50
+ }
51
+ // ═══════════════════════════════════════════════════════════════════
52
+ // init
53
+ // ═══════════════════════════════════════════════════════════════════
54
+ async init(ctx, _snapshot) {
55
+ try {
56
+ pruneExecutionLog(30);
57
+ }
58
+ catch { /* non-critical */ }
59
+ this._config = ctx.config;
60
+ const p = this.params;
61
+ ctx.state.set("arbRunning", true);
62
+ ctx.state.set("v2Positions", []);
63
+ ctx.state.set("arbPositions", 0);
64
+ ctx.state.set("fundingIncome", 0);
65
+ ctx.state.set("fundingLastCheck", Date.now());
66
+ ctx.log(` [ARBv2] Ready | spread >= ${p.min_spread}% | close < ${p.close_spread}% | size $${p.size_usd}`);
67
+ ctx.log(` [ARBv2] Consistency >= ${(p.min_consistency * 100).toFixed(0)}% | history ${p.history_periods} periods | rotation ${p.rotation_threshold}%`);
68
+ ctx.log(` [ARBv2] Exchanges: ${p.exchanges.join(", ")} | spot-perp priority: ${p.spot_perp_priority}`);
69
+ try {
70
+ const adapters = buildAdapterMap(ctx);
71
+ const rawRecovered = await recoverArbPositions(adapters, (msg) => ctx.log(` [ARBv2] ${msg}`));
72
+ if (rawRecovered.length > 0) {
73
+ const recovered = rawRecovered.map(r => ({
74
+ id: genPosId(), symbol: r.symbol, mode: r.mode, longExchange: r.longExchange,
75
+ shortExchange: r.shortExchange, size: r.size, entryTime: 0, entrySpread: 0,
76
+ fundingHistory: [], lastFundingCheck: 0,
77
+ }));
78
+ ctx.state.set("v2Positions", recovered);
79
+ ctx.state.set("arbPositions", recovered.length);
80
+ ctx.log(` [ARBv2] Recovered ${recovered.length} position(s)`);
81
+ }
82
+ // Load historical funding
83
+ let historicalFunding = 0;
84
+ for (const [name, a] of adapters) {
85
+ try {
86
+ const payments = await a.getFundingPayments(200);
87
+ const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
88
+ const sum = payments.filter(fp => fp.time > oneDayAgo).reduce((s, fp) => s + parseFloat(fp.payment), 0);
89
+ if (Math.abs(sum) > 0.001) {
90
+ historicalFunding += sum;
91
+ ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} historical`);
92
+ }
93
+ }
94
+ catch { /* not supported */ }
95
+ }
96
+ if (Math.abs(historicalFunding) > 0.001) {
97
+ ctx.state.set("fundingIncome", historicalFunding);
98
+ }
99
+ }
100
+ catch (err) {
101
+ ctx.log(` [ARBv2] Position recovery failed: ${err instanceof Error ? err.message : String(err)}`);
102
+ }
103
+ }
104
+ // ═══════════════════════════════════════════════════════════════════
105
+ // onTick
106
+ // ═══════════════════════════════════════════════════════════════════
107
+ async onTick(ctx, _snapshot) {
108
+ const p = this.params;
109
+ const openPositions = ctx.state.get("v2Positions");
110
+ const adapters = buildAdapterMap(ctx);
111
+ if (adapters.size < 2) {
112
+ ctx.log(` [ARBv2] Need 2+ exchanges, have ${adapters.size}`);
113
+ return [];
114
+ }
115
+ // 1. Pre-filter exchanges
116
+ const exchangeBalances = new Map();
117
+ const availableAdapters = new Map();
118
+ for (const [name, a] of adapters) {
119
+ try {
120
+ const bal = await a.getBalance();
121
+ const equity = parseFloat(bal.equity);
122
+ const marginPct = equity > 0 ? (parseFloat(bal.marginUsed) / equity) * 100 : 100;
123
+ const available = parseFloat(bal.available);
124
+ exchangeBalances.set(name, { equity, available, marginPct });
125
+ const cd = this._failCooldown.get(`${name}:exchange`) ?? 0;
126
+ if (Date.now() < cd)
127
+ ctx.log(` [ARBv2] ${name} on cooldown`);
128
+ else if (available < 5)
129
+ ctx.log(` [ARBv2] ${name} available $${available.toFixed(2)} too low`);
130
+ else if (marginPct >= 90)
131
+ ctx.log(` [ARBv2] ${name} margin ${marginPct.toFixed(0)}%`);
132
+ else
133
+ availableAdapters.set(name, a);
134
+ }
135
+ catch (err) {
136
+ ctx.log(` [ARBv2] getBalance failed for ${name}: ${err instanceof Error ? err.message : String(err)}`);
137
+ }
138
+ }
139
+ // 2. Fetch rates
140
+ const atMax = openPositions.length >= p.max_positions;
141
+ const posExchanges = new Set();
142
+ if (atMax) {
143
+ for (const pos of openPositions) {
144
+ posExchanges.add(pos.longExchange.replace(/-spot$/i, ""));
145
+ posExchanges.add(pos.shortExchange);
146
+ }
147
+ }
148
+ const toScan = atMax ? [...adapters].filter(([n]) => posExchanges.has(n)) : [...adapters];
149
+ const ratesByExchange = new Map();
150
+ for (const [name, a] of toScan) {
151
+ const rates = await fetchRates(a, name);
152
+ const map = new Map();
153
+ for (const r of rates)
154
+ map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage, fundingHours: r.fundingHours });
155
+ ratesByExchange.set(name, map);
156
+ }
157
+ // Periodic funding income
158
+ const lastCheck = ctx.state.get("fundingLastCheck");
159
+ if (Date.now() - lastCheck > 5 * 60 * 1000) {
160
+ try {
161
+ let total = 0;
162
+ for (const [name, a] of adapters) {
163
+ try {
164
+ const payments = await a.getFundingPayments(50);
165
+ const start = ctx.state.get("fundingLastCheck");
166
+ const sum = payments.filter(fp => fp.time > start - 5 * 60 * 1000).reduce((s, fp) => s + parseFloat(fp.payment), 0);
167
+ total += sum;
168
+ if (Math.abs(sum) > 0.001)
169
+ ctx.log(` [FUND] ${name}: $${sum.toFixed(4)}`);
170
+ }
171
+ catch { /* skip */ }
172
+ }
173
+ ctx.state.set("fundingIncome", ctx.state.get("fundingIncome") + total);
174
+ ctx.state.set("fundingLastCheck", Date.now());
175
+ }
176
+ catch { /* non-critical */ }
177
+ }
178
+ // 3. Update funding history (every 10 min)
179
+ for (const pos of openPositions) {
180
+ if (Date.now() - pos.lastFundingCheck < 10 * 60 * 1000)
181
+ continue;
182
+ try {
183
+ const shortAdapter = adapters.get(pos.shortExchange);
184
+ if (!shortAdapter)
185
+ continue;
186
+ const payments = await shortAdapter.getFundingPayments(p.history_periods * 2);
187
+ const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
188
+ const relevant = payments.filter(fp => matchSymbol(fp.symbol, perpSym) || matchSymbol(fp.symbol, pos.symbol));
189
+ const recent = relevant.slice(0, p.history_periods);
190
+ if (recent.length > 0)
191
+ pos.fundingHistory = recent.map(fp => ({ time: fp.time, amount: parseFloat(fp.payment) }));
192
+ pos.lastFundingCheck = Date.now();
193
+ }
194
+ catch { /* non-critical */ }
195
+ }
196
+ // TUI display
197
+ const display = openPositions.map(pos => {
198
+ const spread = this.calcSignedSpread(pos, ratesByExchange);
199
+ const sign = spread >= 0 ? "+" : "";
200
+ return `${pos.symbol} ${pos.longExchange}<>${pos.shortExchange} ${sign}${spread.toFixed(1)}%`;
201
+ });
202
+ ctx.state.set("arbPositionDetails", display);
203
+ ctx.state.set("fundingTotal", `$${ctx.state.get("fundingIncome").toFixed(4)}`);
204
+ // 4. CLOSE check
205
+ const toClose = [];
206
+ for (const pos of openPositions) {
207
+ if (!this.checkCloseByHistory(pos, ratesByExchange, p))
208
+ continue;
209
+ const cd = this._failCooldown.get(`${pos.symbol}:close`) ?? 0;
210
+ if (Date.now() < cd)
211
+ continue;
212
+ ctx.log(` [ARBv2] Closing ${pos.symbol} (${pos.mode}) ${pos.longExchange}<>${pos.shortExchange}`);
213
+ const closed = pos.mode === "spot-perp"
214
+ ? await this.closeSpotPerp(pos, adapters, ctx)
215
+ : await this.closePerpPerp(pos, adapters, ctx);
216
+ if (closed)
217
+ toClose.push(pos);
218
+ }
219
+ if (toClose.length > 0) {
220
+ const remaining = openPositions.filter(pos => !toClose.some(c => c.id === pos.id));
221
+ ctx.state.set("v2Positions", remaining);
222
+ ctx.state.set("arbPositions", remaining.length);
223
+ }
224
+ // 5. ROTATION check (spot-perp only)
225
+ for (const pos of ctx.state.get("v2Positions")) {
226
+ if (pos.mode !== "spot-perp")
227
+ continue;
228
+ const rcd = this._failCooldown.get(`${pos.id}:rotate`) ?? 0;
229
+ if (Date.now() < rcd)
230
+ continue;
231
+ const best = await this.findBestPerpShort(pos.symbol, adapters, ratesByExchange, p);
232
+ if (!best || best.exchange === pos.shortExchange)
233
+ continue;
234
+ const currentInfo = findRate(ratesByExchange, pos.shortExchange, getPerpSymbol(pos.symbol, pos.shortExchange))
235
+ ?? findRate(ratesByExchange, pos.shortExchange, pos.symbol);
236
+ if (!currentInfo)
237
+ continue;
238
+ const curAnn = (currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.shortExchange))) * 8760 * 100;
239
+ if (best.score.annualized - curAnn < p.rotation_threshold)
240
+ continue;
241
+ ctx.log(` [ARBv2] Rotating ${pos.symbol} short: ${pos.shortExchange} (${curAnn.toFixed(1)}%) -> ${best.exchange} (${best.score.annualized.toFixed(1)}%)`);
242
+ const ok = await this.rotateShort(pos, best.exchange, adapters, ctx, ratesByExchange);
243
+ if (!ok)
244
+ this._failCooldown.set(`${pos.id}:rotate`, Date.now() + 10 * 60 * 1000);
245
+ }
246
+ // 6. SCAN for new opportunities
247
+ const numPos = ctx.state.get("v2Positions").length;
248
+ if (numPos >= p.max_positions)
249
+ return [];
250
+ const openSymbols = new Set(ctx.state.get("v2Positions").map(pos => pos.symbol.toUpperCase()));
251
+ const scanNames = [...ratesByExchange.keys()].filter(n => availableAdapters.has(n));
252
+ const scoredOpps = [];
253
+ // 6a. Spot-perp
254
+ if (p.spot_perp_priority) {
255
+ for (const [spotExName, spotExAdapter] of adapters) {
256
+ try {
257
+ const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
258
+ if (!spotAdapter)
259
+ continue;
260
+ const spotMarkets = await spotAdapter.getSpotMarkets();
261
+ for (const m of spotMarkets) {
262
+ const base = m.baseToken.toUpperCase();
263
+ if (openSymbols.has(base))
264
+ continue;
265
+ if (Date.now() < (this._failCooldown.get(`${base}:entry`) ?? 0))
266
+ continue;
267
+ const best = await this.findBestPerpShort(base, adapters, ratesByExchange, p);
268
+ if (!best || best.score.annualized < p.min_spread || best.score.consistency < p.min_consistency)
269
+ continue;
270
+ scoredOpps.push({ symbol: base, longExchange: `${spotExName}-spot`, shortExchange: best.exchange, spread: best.score.annualized, score: best.score, mode: "spot-perp", shortRate: best.rate, longRate: 0 });
271
+ }
272
+ }
273
+ catch { /* no spot */ }
274
+ }
275
+ }
276
+ // 6b. Perp-perp
277
+ const allSyms = new Set();
278
+ for (const [, map] of ratesByExchange)
279
+ for (const sym of map.keys())
280
+ allSyms.add(sym);
281
+ for (const sym of allSyms) {
282
+ if (openSymbols.has(sym.toUpperCase()))
283
+ continue;
284
+ if (Date.now() < (this._failCooldown.get(`${sym}:entry`) ?? 0))
285
+ continue;
286
+ let minH = Infinity, maxH = -Infinity, minEx = "", maxEx = "", minR = 0, maxR = 0;
287
+ for (const ex of scanNames) {
288
+ const ri = findRate(ratesByExchange, ex, sym);
289
+ if (!ri)
290
+ continue;
291
+ const h = ri.rate / (ri.fundingHours ?? getFundingHours(ex));
292
+ if (h < minH) {
293
+ minH = h;
294
+ minEx = ex;
295
+ minR = ri.rate;
296
+ }
297
+ if (h > maxH) {
298
+ maxH = h;
299
+ maxEx = ex;
300
+ maxR = ri.rate;
301
+ }
302
+ }
303
+ if (!minEx || !maxEx || minEx === maxEx)
304
+ continue;
305
+ const spread = (maxH - minH) * 8760 * 100;
306
+ if (spread < p.min_spread)
307
+ continue;
308
+ const shortScore = await this.scoreFunding(adapters.get(maxEx), sym, maxEx, p.history_periods);
309
+ if (shortScore && shortScore.consistency < p.min_consistency)
310
+ continue;
311
+ scoredOpps.push({ symbol: sym, longExchange: minEx, shortExchange: maxEx, spread, score: shortScore ?? { symbol: sym, exchange: maxEx, avgRate: maxR, consistency: 1, annualized: spread, payments: 0 }, mode: "perp-perp", shortRate: maxR, longRate: minR });
312
+ }
313
+ scoredOpps.sort((a, b) => {
314
+ if (p.spot_perp_priority) {
315
+ if (a.mode === "spot-perp" && b.mode !== "spot-perp")
316
+ return -1;
317
+ if (a.mode !== "spot-perp" && b.mode === "spot-perp")
318
+ return 1;
319
+ }
320
+ return b.spread - a.spread;
321
+ });
322
+ if (scoredOpps.length > 0 && numPos < p.max_positions) {
323
+ for (const opp of scoredOpps.slice(0, 3)) {
324
+ ctx.log(` [ARBv2] ${opp.symbol}: ${opp.spread.toFixed(1)}% [${opp.mode}] consistency=${(opp.score.consistency * 100).toFixed(0)}% ${opp.longExchange}<>${opp.shortExchange}`);
325
+ }
326
+ }
327
+ // 7. EXECUTE
328
+ for (const opp of scoredOpps) {
329
+ if (ctx.state.get("v2Positions").length >= p.max_positions)
330
+ break;
331
+ const opened = opp.mode === "spot-perp"
332
+ ? await this.openSpotPerp(opp, adapters, ctx, ratesByExchange, exchangeBalances)
333
+ : await this.openPerpPerp(opp, adapters, ctx, ratesByExchange, exchangeBalances);
334
+ if (opened)
335
+ return [{ type: "noop" }];
336
+ }
337
+ if (scoredOpps.length === 0 && numPos < p.max_positions) {
338
+ ctx.log(` [ARBv2] No opportunities >= ${p.min_spread}% with >= ${(p.min_consistency * 100).toFixed(0)}% consistency`);
339
+ }
340
+ return [];
341
+ }
342
+ // ═══════════════════════════════════════════════════════════════════
343
+ // onStop
344
+ // ═══════════════════════════════════════════════════════════════════
345
+ async onStop(ctx) {
346
+ const positions = ctx.state.get("v2Positions");
347
+ const adapters = buildAdapterMap(ctx);
348
+ if (positions && positions.length > 0) {
349
+ const remaining = [];
350
+ for (const pos of positions) {
351
+ const closed = pos.mode === "spot-perp"
352
+ ? await this.closeSpotPerp(pos, adapters, ctx)
353
+ : await this.closePerpPerp(pos, adapters, ctx);
354
+ if (!closed) {
355
+ ctx.log(` [ARBv2] CRITICAL: ${pos.symbol} could not be closed on stop`);
356
+ remaining.push(pos);
357
+ }
358
+ }
359
+ ctx.state.set("v2Positions", remaining);
360
+ ctx.state.set("arbPositions", remaining.length);
361
+ }
362
+ const extra = ctx.state.get("extraAdapters");
363
+ if (extra) {
364
+ for (const [, a] of extra) {
365
+ try {
366
+ await a.cancelAllOrders();
367
+ }
368
+ catch { /* best effort */ }
369
+ }
370
+ }
371
+ return [{ type: "cancel_all" }];
372
+ }
373
+ // ═══════════════════════════════════════════════════════════════════
374
+ // Core scoring / decision methods
375
+ // ═══════════════════════════════════════════════════════════════════
376
+ async scoreFunding(adapter, symbol, exchange, periods) {
377
+ const key = `${exchange}:${symbol}`;
378
+ const cached = this._fundingScoreCache.get(key);
379
+ if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
380
+ return cached.score;
381
+ try {
382
+ const history = await adapter.getFundingHistory(symbol, periods);
383
+ if (history.length === 0)
384
+ return null;
385
+ const rates = history.map(h => parseFloat(h.rate));
386
+ const fH = getFundingHours(exchange);
387
+ const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
388
+ const consistency = rates.filter(r => r > 0).length / rates.length;
389
+ const annualized = (avgRate / fH) * 8760 * 100;
390
+ const score = { symbol, exchange, avgRate, consistency, annualized, payments: rates.length };
391
+ this._fundingScoreCache.set(key, { score, ts: Date.now() });
392
+ return score;
393
+ }
394
+ catch {
395
+ return null;
396
+ }
397
+ }
398
+ calcSignedSpread(pos, ratesByExchange) {
399
+ if (pos.mode === "spot-perp") {
400
+ const ri = findRate(ratesByExchange, pos.shortExchange, getPerpSymbol(pos.symbol, pos.shortExchange))
401
+ ?? findRate(ratesByExchange, pos.shortExchange, pos.symbol);
402
+ const fH = ri?.fundingHours ?? getFundingHours(pos.shortExchange);
403
+ return ri ? (ri.rate / fH) * 8760 * 100 : 0;
404
+ }
405
+ const li = findRate(ratesByExchange, pos.longExchange, pos.symbol);
406
+ const si = findRate(ratesByExchange, pos.shortExchange, pos.symbol);
407
+ if (!li || !si)
408
+ return 0;
409
+ const lH = li.rate / (li.fundingHours ?? getFundingHours(pos.longExchange));
410
+ const sH = si.rate / (si.fundingHours ?? getFundingHours(pos.shortExchange));
411
+ return (sH - lH) * 8760 * 100;
412
+ }
413
+ checkCloseByHistory(pos, ratesByExchange, p) {
414
+ const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
415
+ const holdTime = pos.entryTime > 0 ? Date.now() - pos.entryTime : Infinity;
416
+ const signedSpread = this.calcSignedSpread(pos, ratesByExchange);
417
+ // Emergency close
418
+ if (signedSpread < -50)
419
+ return true;
420
+ if (holdTime < minHoldMs)
421
+ return false;
422
+ // History-based
423
+ if (pos.fundingHistory.length >= 3) {
424
+ const avgFunding = pos.fundingHistory.reduce((s, h) => s + h.amount, 0) / pos.fundingHistory.length;
425
+ if (avgFunding < 0)
426
+ return true;
427
+ }
428
+ // Fallback to instantaneous
429
+ if (pos.fundingHistory.length < 3)
430
+ return signedSpread < p.close_spread;
431
+ return signedSpread < p.close_spread;
432
+ }
433
+ async findBestPerpShort(symbol, adapters, ratesByExchange, p) {
434
+ let best = null;
435
+ for (const [exName, adapter] of adapters) {
436
+ const perpSym = getPerpSymbol(symbol, exName);
437
+ const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
438
+ if (!ri || ri.rate <= 0)
439
+ continue;
440
+ const score = await this.scoreFunding(adapter, perpSym, exName, p.history_periods)
441
+ ?? await this.scoreFunding(adapter, symbol, exName, p.history_periods);
442
+ if (!score)
443
+ continue;
444
+ if (!best || score.annualized > best.score.annualized)
445
+ best = { exchange: exName, rate: ri.rate, score };
446
+ }
447
+ return best;
448
+ }
449
+ // ═══════════════════════════════════════════════════════════════════
450
+ // Rotation
451
+ // ═══════════════════════════════════════════════════════════════════
452
+ async rotateShort(pos, newExchange, adapters, ctx, ratesByExchange) {
453
+ const oldAdapter = adapters.get(pos.shortExchange);
454
+ const newAdapter = adapters.get(newExchange);
455
+ if (!oldAdapter || !newAdapter) {
456
+ ctx.log(` [ARBv2] Rotation: adapter not found`);
457
+ return false;
458
+ }
459
+ const oldSym = getPerpSymbol(pos.symbol, pos.shortExchange);
460
+ const newSym = getPerpSymbol(pos.symbol, newExchange);
461
+ const p = this.params;
462
+ const newMaxLev = findRate(ratesByExchange, newExchange, newSym)?.maxLeverage ?? findRate(ratesByExchange, newExchange, pos.symbol)?.maxLeverage ?? 1;
463
+ try {
464
+ await newAdapter.setLeverage(newSym, Math.min(p.leverage, newMaxLev), "cross");
465
+ }
466
+ catch (err) {
467
+ ctx.log(` [ARBv2] Rotation setLeverage failed: ${err instanceof Error ? err.message : String(err)}`);
468
+ return false;
469
+ }
470
+ // Close old short
471
+ try {
472
+ await oldAdapter.marketOrder(oldSym, "buy", pos.size, { reduceOnly: true });
473
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { rotation: true } });
474
+ }
475
+ catch (err) {
476
+ ctx.log(` [ARBv2] Rotation close failed: ${err instanceof Error ? err.message : String(err)}`);
477
+ return false;
478
+ }
479
+ // Open new short
480
+ try {
481
+ await newAdapter.marketOrder(newSym, "sell", pos.size);
482
+ logExecution({ type: "arb_entry", exchange: newExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { rotation: true } });
483
+ }
484
+ catch (err) {
485
+ const msg = err instanceof Error ? err.message : String(err);
486
+ ctx.log(` [ARBv2] Rotation new short failed: ${msg} — rolling back`);
487
+ try {
488
+ await oldAdapter.marketOrder(oldSym, "sell", pos.size);
489
+ ctx.log(` [ARBv2] Rotation rollback OK`);
490
+ logExecution({ type: "multi_leg_rollback", exchange: pos.shortExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false });
491
+ }
492
+ catch (rbErr) {
493
+ ctx.log(` [ARBv2] CRITICAL: Rotation rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
494
+ logExecution({ type: "multi_leg_rollback", exchange: pos.shortExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: rbErr instanceof Error ? rbErr.message : String(rbErr), dryRun: false });
495
+ }
496
+ return false;
497
+ }
498
+ pos.shortExchange = newExchange;
499
+ pos.fundingHistory = [];
500
+ pos.lastFundingCheck = 0;
501
+ return true;
502
+ }
503
+ // ═══════════════════════════════════════════════════════════════════
504
+ // Open / Close — spot-perp
505
+ // ═══════════════════════════════════════════════════════════════════
506
+ async openSpotPerp(opp, adapters, ctx, ratesByExchange, exchangeBalances) {
507
+ const p = this.params;
508
+ const spotExName = opp.longExchange.replace("-spot", "");
509
+ const spotExAdapter = adapters.get(spotExName);
510
+ const perpAdapter = adapters.get(opp.shortExchange);
511
+ if (!spotExAdapter || !perpAdapter) {
512
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
513
+ return false;
514
+ }
515
+ try {
516
+ const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
517
+ if (!spotAdapter)
518
+ return false;
519
+ const perpSymbol = getPerpSymbol(opp.symbol, opp.shortExchange);
520
+ const perpMaxLev = findRate(ratesByExchange, opp.shortExchange, perpSymbol)?.maxLeverage ?? findRate(ratesByExchange, opp.shortExchange, opp.symbol)?.maxLeverage ?? 1;
521
+ const leverage = Math.min(p.leverage, perpMaxLev);
522
+ try {
523
+ await perpAdapter.setLeverage(perpSymbol, leverage, "cross");
524
+ }
525
+ catch (err) {
526
+ ctx.log(` [ARBv2] setLeverage failed: ${err instanceof Error ? err.message : String(err)}`);
527
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
528
+ return false;
529
+ }
530
+ let targetSizeUsd = p.size_usd;
531
+ const sameExchange = spotExName === opp.shortExchange;
532
+ if (sameExchange) {
533
+ const perpBal = await perpAdapter.getBalance();
534
+ const perpAvail = parseFloat(perpBal.available);
535
+ const totalNeeded = targetSizeUsd + targetSizeUsd / leverage;
536
+ if (perpAvail < totalNeeded) {
537
+ const max = perpAvail * 0.8 / (1 + 1 / leverage);
538
+ if (max < 10) {
539
+ ctx.log(` [ARBv2] Skip spot-perp ${opp.symbol}: insufficient balance`);
540
+ return false;
541
+ }
542
+ targetSizeUsd = Math.floor(max);
543
+ }
544
+ }
545
+ else {
546
+ const sBal = exchangeBalances.get(spotExName);
547
+ const pBal = exchangeBalances.get(opp.shortExchange);
548
+ if (!sBal || !pBal)
549
+ return false;
550
+ const maxNtl = Math.min(sBal.available, pBal.available * leverage) * 0.8;
551
+ if (maxNtl < targetSizeUsd) {
552
+ if (maxNtl < 10)
553
+ return false;
554
+ targetSizeUsd = Math.floor(maxNtl);
555
+ }
556
+ }
557
+ const price = await getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
558
+ if (price <= 0)
559
+ return false;
560
+ const liq = await checkSpotPerpLiquidity(spotAdapter, perpAdapter, opp.symbol, perpSymbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
561
+ if (!liq.viable)
562
+ return false;
563
+ const spotMarkets = await spotAdapter.getSpotMarkets();
564
+ const spotDec = spotMarkets.find(m => m.baseToken.toUpperCase() === opp.symbol.toUpperCase())?.sizeDecimals;
565
+ const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, price, spotExName, opp.shortExchange, spotDec);
566
+ if (!matched) {
567
+ ctx.log(` [ARBv2] Skip spot-perp ${opp.symbol}: matched size fail`);
568
+ return false;
569
+ }
570
+ ctx.log(` [ARBv2] Opening spot-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)})`);
571
+ if (sameExchange) {
572
+ const tAmt = Math.ceil(matched.notional * 1.02);
573
+ await transferUsdcToSpot(spotAdapter, spotExName, tAmt);
574
+ }
575
+ // Buy spot
576
+ try {
577
+ await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
578
+ logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "spot" } });
579
+ }
580
+ catch (err) {
581
+ const msg = err instanceof Error ? err.message : String(err);
582
+ ctx.log(` [ARBv2] Spot buy failed: ${msg}`);
583
+ if (sameExchange) {
584
+ try {
585
+ await transferUsdcToPerp(spotAdapter, spotExName, Math.ceil(matched.notional * 1.02));
586
+ }
587
+ catch { /* best effort */ }
588
+ }
589
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
590
+ return false;
591
+ }
592
+ // Short perp
593
+ try {
594
+ await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
595
+ logExecution({ type: "arb_entry", exchange: opp.shortExchange, symbol: opp.symbol, side: "sell", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "perp" } });
596
+ }
597
+ catch (err) {
598
+ const msg = err instanceof Error ? err.message : String(err);
599
+ ctx.log(` [ARBv2] Perp short failed: ${msg} — ROLLING BACK spot`);
600
+ try {
601
+ await spotAdapter.spotMarketOrder(opp.symbol, "sell", matched.size);
602
+ logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
603
+ if (sameExchange) {
604
+ try {
605
+ await transferUsdcToPerp(spotAdapter, spotExName, Math.ceil(matched.notional * 1.02));
606
+ }
607
+ catch { /* best effort */ }
608
+ }
609
+ }
610
+ catch (rbErr) {
611
+ ctx.log(` [ARBv2] CRITICAL: Spot rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
612
+ }
613
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
614
+ return false;
615
+ }
616
+ const cur = ctx.state.get("v2Positions");
617
+ cur.push({ id: genPosId(), symbol: opp.symbol, mode: "spot-perp", longExchange: opp.longExchange, shortExchange: opp.shortExchange, size: matched.size, entryTime: Date.now(), entrySpread: opp.spread, fundingHistory: [], lastFundingCheck: 0 });
618
+ ctx.state.set("v2Positions", cur);
619
+ ctx.state.set("arbPositions", cur.length);
620
+ ctx.log(` [ARBv2] Spot-perp opened! (${cur.length}/${p.max_positions})`);
621
+ return true;
622
+ }
623
+ catch (err) {
624
+ ctx.log(` [ARBv2] Open spot-perp failed: ${err instanceof Error ? err.message : String(err)}`);
625
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
626
+ return false;
627
+ }
628
+ }
629
+ async closeSpotPerp(pos, adapters, ctx) {
630
+ const spotExName = pos.longExchange.replace("-spot", "");
631
+ const adapterForSpot = adapters.get(spotExName) ?? adapters.get(pos.shortExchange);
632
+ const perpAdapter = adapters.get(pos.shortExchange);
633
+ if (!perpAdapter || !adapterForSpot) {
634
+ ctx.log(` [ARBv2] Close spot-perp: no adapter`);
635
+ return false;
636
+ }
637
+ try {
638
+ const spotAdapter = await getSpotAdapter(spotExName, adapterForSpot);
639
+ if (!spotAdapter)
640
+ return false;
641
+ // Sell spot
642
+ try {
643
+ await spotAdapter.spotMarketOrder(pos.symbol, "sell", pos.size);
644
+ }
645
+ catch (err) {
646
+ ctx.log(` [ARBv2] Spot sell failed: ${err instanceof Error ? err.message : String(err)}`);
647
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
648
+ return false;
649
+ }
650
+ // Transfer USDC back (same-exchange)
651
+ if (spotExName === pos.shortExchange) {
652
+ try {
653
+ const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
654
+ const price = await getPriceEstimate(perpAdapter, perpSym, pos.symbol);
655
+ const proceeds = price * parseFloat(pos.size) * 0.95;
656
+ if (proceeds > 1)
657
+ await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(proceeds));
658
+ }
659
+ catch { /* non-fatal */ }
660
+ }
661
+ // Close perp short
662
+ const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
663
+ try {
664
+ await perpAdapter.marketOrder(perpSym, "buy", pos.size, { reduceOnly: true });
665
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode } });
666
+ return true;
667
+ }
668
+ catch (err) {
669
+ ctx.log(` [ARBv2] Perp close failed: ${err instanceof Error ? err.message : String(err)} — rollback spot`);
670
+ try {
671
+ await spotAdapter.spotMarketOrder(pos.symbol, "buy", pos.size);
672
+ }
673
+ catch (rbErr) {
674
+ ctx.log(` [ARBv2] CRITICAL: Spot rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
675
+ }
676
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
677
+ return false;
678
+ }
679
+ }
680
+ catch (err) {
681
+ ctx.log(` [ARBv2] Close spot-perp failed: ${err instanceof Error ? err.message : String(err)}`);
682
+ return false;
683
+ }
684
+ }
685
+ // ═══════════════════════════════════════════════════════════════════
686
+ // Open / Close — perp-perp
687
+ // ═══════════════════════════════════════════════════════════════════
688
+ async openPerpPerp(opp, adapters, ctx, ratesByExchange, exchangeBalances) {
689
+ const p = this.params;
690
+ const longAdapter = adapters.get(opp.longExchange);
691
+ const shortAdapter = adapters.get(opp.shortExchange);
692
+ if (!longAdapter || !shortAdapter)
693
+ return false;
694
+ const price = findRate(ratesByExchange, opp.longExchange, opp.symbol)?.price ?? 0;
695
+ if (price <= 0)
696
+ return false;
697
+ const longMaxLev = findRate(ratesByExchange, opp.longExchange, opp.symbol)?.maxLeverage ?? 1;
698
+ const shortMaxLev = findRate(ratesByExchange, opp.shortExchange, opp.symbol)?.maxLeverage ?? 1;
699
+ const leverage = Math.min(p.leverage, longMaxLev, shortMaxLev);
700
+ try {
701
+ await longAdapter.setLeverage(opp.symbol, leverage, "cross");
702
+ }
703
+ catch (err) {
704
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
705
+ return false;
706
+ }
707
+ try {
708
+ await shortAdapter.setLeverage(opp.symbol, leverage, "cross");
709
+ }
710
+ catch (err) {
711
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
712
+ return false;
713
+ }
714
+ let targetSizeUsd = p.size_usd;
715
+ const lBal = exchangeBalances.get(opp.longExchange);
716
+ const sBal = exchangeBalances.get(opp.shortExchange);
717
+ if (!lBal || !sBal)
718
+ return false;
719
+ const minAvail = Math.min(lBal.available, sBal.available);
720
+ if (minAvail < targetSizeUsd / leverage) {
721
+ const max = minAvail * leverage * 0.8;
722
+ if (max < 10)
723
+ return false;
724
+ targetSizeUsd = Math.floor(max);
725
+ }
726
+ const liq = await checkArbLiquidity(longAdapter, shortAdapter, opp.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
727
+ if (!liq.viable)
728
+ return false;
729
+ const lDec = findRate(ratesByExchange, opp.longExchange, opp.symbol)?.sizeDecimals;
730
+ const sDec = findRate(ratesByExchange, opp.shortExchange, opp.symbol)?.sizeDecimals;
731
+ const matched = computeMatchedSize(liq.adjustedSizeUsd, price, opp.longExchange, opp.shortExchange, { longSizeDecimals: lDec, shortSizeDecimals: sDec });
732
+ if (!matched) {
733
+ ctx.log(` [ARBv2] Skip ${opp.symbol}: matched size fail`);
734
+ return false;
735
+ }
736
+ ctx.log(` [ARBv2] Opening perp-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)}/leg)`);
737
+ // Long leg
738
+ try {
739
+ await longAdapter.marketOrder(opp.symbol, "buy", matched.size);
740
+ logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "long" } });
741
+ }
742
+ catch (err) {
743
+ ctx.log(` [ARBv2] Long leg failed: ${err instanceof Error ? err.message : String(err)}`);
744
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
745
+ return false;
746
+ }
747
+ // Short leg
748
+ try {
749
+ await shortAdapter.marketOrder(opp.symbol, "sell", matched.size);
750
+ logExecution({ type: "arb_entry", exchange: opp.shortExchange, symbol: opp.symbol, side: "sell", size: matched.size, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "short" } });
751
+ }
752
+ catch (err) {
753
+ const msg = err instanceof Error ? err.message : String(err);
754
+ ctx.log(` [ARBv2] Short leg failed: ${msg} — ROLLING BACK`);
755
+ try {
756
+ await longAdapter.marketOrder(opp.symbol, "sell", matched.size, { reduceOnly: true });
757
+ logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
758
+ }
759
+ catch (rbErr) {
760
+ ctx.log(` [ARBv2] CRITICAL: Rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
761
+ }
762
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 5 * 60 * 1000);
763
+ return false;
764
+ }
765
+ // Verify
766
+ try {
767
+ const r = await reconcileArbFills(longAdapter, shortAdapter, opp.symbol, (msg) => ctx.log(` ${msg}`));
768
+ if (!r.matched)
769
+ ctx.log(` [ARBv2] WARNING: fills not matched`);
770
+ }
771
+ catch { /* non-critical */ }
772
+ invalidateCache("acct");
773
+ await new Promise(resolve => setTimeout(resolve, 2000));
774
+ try {
775
+ const [lp, sp] = await Promise.all([longAdapter.getPositions(), shortAdapter.getPositions()]);
776
+ const lE = lp.some(x => matchSymbol(x.symbol, opp.symbol) && parseFloat(x.size) > 0);
777
+ const sE = sp.some(x => matchSymbol(x.symbol, opp.symbol) && parseFloat(x.size) > 0);
778
+ if (!lE && !sE) {
779
+ ctx.log(` [ARBv2] Positions not visible (cache delay?)`);
780
+ }
781
+ else if (!lE || !sE) {
782
+ ctx.log(` [ARBv2] Position verify failed: long=${lE}, short=${sE}`);
783
+ if (lE && !sE) {
784
+ try {
785
+ await longAdapter.marketOrder(opp.symbol, "sell", matched.size, { reduceOnly: true });
786
+ }
787
+ catch { /* best effort */ }
788
+ }
789
+ if (sE && !lE) {
790
+ try {
791
+ await shortAdapter.marketOrder(opp.symbol, "buy", matched.size, { reduceOnly: true });
792
+ }
793
+ catch { /* best effort */ }
794
+ }
795
+ this._failCooldown.set(`${opp.symbol}:entry`, Date.now() + 10 * 60 * 1000);
796
+ return false;
797
+ }
798
+ }
799
+ catch (err) {
800
+ ctx.log(` [ARBv2] Verify error: ${err instanceof Error ? err.message : String(err)}`);
801
+ }
802
+ const cur = ctx.state.get("v2Positions");
803
+ cur.push({ id: genPosId(), symbol: opp.symbol, mode: "perp-perp", longExchange: opp.longExchange, shortExchange: opp.shortExchange, size: matched.size, entryTime: Date.now(), entrySpread: opp.spread, fundingHistory: [], lastFundingCheck: 0 });
804
+ ctx.state.set("v2Positions", cur);
805
+ ctx.state.set("arbPositions", cur.length);
806
+ ctx.log(` [ARBv2] Perp-perp opened! (${cur.length}/${p.max_positions})`);
807
+ return true;
808
+ }
809
+ async closePerpPerp(pos, adapters, ctx) {
810
+ const longAdapter = adapters.get(pos.longExchange);
811
+ const shortAdapter = adapters.get(pos.shortExchange);
812
+ if (!longAdapter || !shortAdapter) {
813
+ ctx.log(` [ARBv2] Close perp-perp: adapters not found`);
814
+ return false;
815
+ }
816
+ const isGone = (msg) => msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
817
+ let closeLongOk = false;
818
+ let longGone = false;
819
+ try {
820
+ await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
821
+ closeLongOk = true;
822
+ logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "long" } });
823
+ }
824
+ catch (err) {
825
+ const msg = err instanceof Error ? err.message : String(err);
826
+ if (isGone(msg)) {
827
+ longGone = true;
828
+ this._failCooldown.set(`${pos.longExchange}:exchange`, Date.now() + 30 * 60 * 1000);
829
+ }
830
+ else {
831
+ ctx.log(` [ARBv2] Close long failed: ${msg}`);
832
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
833
+ return false;
834
+ }
835
+ }
836
+ try {
837
+ await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
838
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "short" } });
839
+ }
840
+ catch (err) {
841
+ const msg = err instanceof Error ? err.message : String(err);
842
+ if (isGone(msg)) {
843
+ this._failCooldown.set(`${pos.shortExchange}:exchange`, Date.now() + 30 * 60 * 1000);
844
+ }
845
+ else if (closeLongOk) {
846
+ ctx.log(` [ARBv2] Close short failed: ${msg} — re-opening long`);
847
+ try {
848
+ await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
849
+ logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
850
+ }
851
+ catch (rbErr) {
852
+ ctx.log(` [ARBv2] CRITICAL: Rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
853
+ }
854
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
855
+ return false;
856
+ }
857
+ else {
858
+ ctx.log(` [ARBv2] Force closing orphaned short: ${msg}`);
859
+ try {
860
+ await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
861
+ }
862
+ catch (fErr) {
863
+ ctx.log(` [ARBv2] CRITICAL: Force close failed: ${fErr instanceof Error ? fErr.message : String(fErr)}`);
864
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 10 * 60 * 1000);
865
+ return false;
866
+ }
867
+ }
868
+ }
869
+ ctx.log(` [ARBv2] Closed ${pos.symbol}${longGone ? " (long was liquidated)" : ""}`);
870
+ return true;
871
+ }
872
+ }
873
+ registerStrategy("funding-arb-v2", (_config) => new FundingArbV2Strategy());