perp-cli 0.7.50 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot/engine.d.ts +1 -0
- package/dist/bot/engine.js +9 -4
- package/dist/bot/strategies/funding-arb-strategy.js +2 -2
- package/dist/bot/strategies/funding-arb-utils.d.ts +49 -0
- package/dist/bot/strategies/funding-arb-utils.js +239 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +42 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.js +873 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +547 -0
- package/dist/bot/strategies/spot-perp-executor.d.ts +81 -0
- package/dist/bot/strategies/spot-perp-executor.js +271 -0
- package/dist/commands/bot.js +80 -1
- package/dist/commands/trade.js +131 -0
- package/dist/exchanges/aster.d.ts +1 -1
- package/dist/exchanges/aster.js +5 -5
- package/dist/exchanges/hyperliquid.d.ts +7 -0
- package/dist/exchanges/hyperliquid.js +23 -0
- package/dist/exchanges/lighter-spot.js +20 -0
- package/dist/exchanges/lighter.js +45 -1
- package/dist/exchanges/pacifica.js +42 -1
- package/dist/exchanges/spot-interface.js +4 -0
- package/package.json +1 -1
|
@@ -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());
|