perp-cli 0.7.48 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot/engine.d.ts +1 -0
- package/dist/bot/engine.js +1 -0
- package/dist/bot/strategies/funding-arb-strategy.js +30 -23
- package/dist/bot/strategies/funding-arb-utils.d.ts +49 -0
- package/dist/bot/strategies/funding-arb-utils.js +239 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +42 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.js +873 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +547 -0
- package/dist/bot/strategies/spot-perp-executor.d.ts +81 -0
- package/dist/bot/strategies/spot-perp-executor.js +271 -0
- package/dist/commands/bot.js +80 -1
- package/dist/commands/trade.js +131 -0
- package/dist/exchanges/aster.d.ts +1 -1
- package/dist/exchanges/aster.js +5 -5
- package/dist/exchanges/hyperliquid.d.ts +7 -0
- package/dist/exchanges/hyperliquid.js +23 -0
- package/dist/exchanges/lighter-spot.js +20 -0
- package/dist/exchanges/lighter.js +45 -1
- package/dist/exchanges/pacifica.js +42 -1
- package/dist/exchanges/spot-interface.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spot-Perp Arbitrage Strategy
|
|
3
|
+
*
|
|
4
|
+
* Focused strategy: buy spot (HL or Lighter) + short perp on the BEST of 4 exchanges.
|
|
5
|
+
* No perp-perp logic. History-based entry/exit with short rotation.
|
|
6
|
+
*
|
|
7
|
+
* Strategy layer handles ONLY decisions (opportunity scanning, scoring, close/rotation decisions).
|
|
8
|
+
* Execution layer (SpotPerpExecutor) handles ALL exchange interactions.
|
|
9
|
+
*/
|
|
10
|
+
import type { Strategy, StrategyContext, StrategyAction, EnrichedSnapshot } from "../strategy-types.js";
|
|
11
|
+
export declare class SpotPerpArbStrategy implements Strategy {
|
|
12
|
+
readonly name = "spot-perp-arb";
|
|
13
|
+
private readonly executor;
|
|
14
|
+
describe(): {
|
|
15
|
+
description: string;
|
|
16
|
+
params: ({
|
|
17
|
+
name: string;
|
|
18
|
+
type: "number";
|
|
19
|
+
required: boolean;
|
|
20
|
+
default: number;
|
|
21
|
+
description: string;
|
|
22
|
+
} | {
|
|
23
|
+
name: string;
|
|
24
|
+
type: "string";
|
|
25
|
+
required: boolean;
|
|
26
|
+
description: string;
|
|
27
|
+
default?: undefined;
|
|
28
|
+
})[];
|
|
29
|
+
};
|
|
30
|
+
private _config;
|
|
31
|
+
private _failCooldown;
|
|
32
|
+
private _fundingScoreCache;
|
|
33
|
+
private get params();
|
|
34
|
+
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
35
|
+
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
36
|
+
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
37
|
+
private scoreFunding;
|
|
38
|
+
private findBestPerp;
|
|
39
|
+
private calcAnnualized;
|
|
40
|
+
private shouldClose;
|
|
41
|
+
private tryOpenPosition;
|
|
42
|
+
private recoverPositions;
|
|
43
|
+
}
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spot-Perp Arbitrage Strategy
|
|
3
|
+
*
|
|
4
|
+
* Focused strategy: buy spot (HL or Lighter) + short perp on the BEST of 4 exchanges.
|
|
5
|
+
* No perp-perp logic. History-based entry/exit with short rotation.
|
|
6
|
+
*
|
|
7
|
+
* Strategy layer handles ONLY decisions (opportunity scanning, scoring, close/rotation decisions).
|
|
8
|
+
* Execution layer (SpotPerpExecutor) handles ALL exchange interactions.
|
|
9
|
+
*/
|
|
10
|
+
import { registerStrategy } from "../strategy-registry.js";
|
|
11
|
+
import { getFundingHours } from "../../funding.js";
|
|
12
|
+
import { pruneExecutionLog } from "../../execution-log.js";
|
|
13
|
+
import { buildAdapterMap, getSpotAdapter, getPerpSymbol, findRate, matchSymbol, fetchRates, } from "./funding-arb-utils.js";
|
|
14
|
+
import { SpotPerpExecutor } from "./spot-perp-executor.js";
|
|
15
|
+
let _posIdCounter = 0;
|
|
16
|
+
function genPosId() { return `spa-${Date.now()}-${++_posIdCounter}`; }
|
|
17
|
+
// ── Strategy ──
|
|
18
|
+
export class SpotPerpArbStrategy {
|
|
19
|
+
name = "spot-perp-arb";
|
|
20
|
+
executor = new SpotPerpExecutor();
|
|
21
|
+
describe() {
|
|
22
|
+
return {
|
|
23
|
+
description: "Spot-perp arbitrage — buy spot + short perp on best exchange, history-based",
|
|
24
|
+
params: [
|
|
25
|
+
{ name: "min_rate", type: "number", required: false, default: 10, description: "Min annualized % to enter" },
|
|
26
|
+
{ name: "close_rate", type: "number", required: false, default: 2, description: "Close if avg rate falls below this %" },
|
|
27
|
+
{ name: "size_usd", type: "number", required: false, default: 50, description: "Per position USD size" },
|
|
28
|
+
{ name: "max_positions", type: "number", required: false, default: 5, description: "Max concurrent positions" },
|
|
29
|
+
{ name: "exchanges", type: "string", required: true, description: "Comma-separated perp exchange names" },
|
|
30
|
+
{ name: "leverage", type: "number", required: false, default: 3, description: "Perp side leverage" },
|
|
31
|
+
{ name: "min_hold_hours", type: "number", required: false, default: 4, description: "Min hold before close (hours)" },
|
|
32
|
+
{ name: "min_consistency", type: "number", required: false, default: 0.7, description: "Min positive payment ratio (0-1)" },
|
|
33
|
+
{ name: "history_periods", type: "number", required: false, default: 6, description: "Funding periods to check" },
|
|
34
|
+
{ name: "rotation_threshold", type: "number", required: false, default: 10, description: "% improvement needed to rotate short" },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
_config = {};
|
|
39
|
+
_failCooldown = new Map();
|
|
40
|
+
_fundingScoreCache = new Map();
|
|
41
|
+
get params() {
|
|
42
|
+
const c = this._config;
|
|
43
|
+
return {
|
|
44
|
+
type: "spot-perp-arb",
|
|
45
|
+
min_rate: c.min_rate ?? 10,
|
|
46
|
+
close_rate: c.close_rate ?? 2,
|
|
47
|
+
size_usd: c.size_usd ?? 50,
|
|
48
|
+
max_positions: c.max_positions ?? 5,
|
|
49
|
+
exchanges: (typeof c.exchanges === "string" ? c.exchanges.split(",").map(s => s.trim()) : c.exchanges) ?? [],
|
|
50
|
+
leverage: c.leverage ?? 3,
|
|
51
|
+
min_hold_hours: c.min_hold_hours ?? 4,
|
|
52
|
+
min_consistency: c.min_consistency ?? 0.7,
|
|
53
|
+
history_periods: c.history_periods ?? 6,
|
|
54
|
+
rotation_threshold: c.rotation_threshold ?? 10,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// ── init ──
|
|
58
|
+
async init(ctx, _snapshot) {
|
|
59
|
+
try {
|
|
60
|
+
pruneExecutionLog(30);
|
|
61
|
+
}
|
|
62
|
+
catch { /* non-critical */ }
|
|
63
|
+
this._config = ctx.config;
|
|
64
|
+
const p = this.params;
|
|
65
|
+
ctx.state.set("arbRunning", true);
|
|
66
|
+
ctx.state.set("spaPositions", []);
|
|
67
|
+
ctx.state.set("arbPositions", 0);
|
|
68
|
+
ctx.state.set("fundingIncome", 0);
|
|
69
|
+
ctx.state.set("fundingLastCheck", Date.now());
|
|
70
|
+
ctx.log(` [SPA] Ready | rate >= ${p.min_rate}% | close < ${p.close_rate}% | size $${p.size_usd}`);
|
|
71
|
+
ctx.log(` [SPA] Consistency >= ${(p.min_consistency * 100).toFixed(0)}% | history ${p.history_periods} periods | rotation ${p.rotation_threshold}%`);
|
|
72
|
+
ctx.log(` [SPA] Exchanges: ${p.exchanges.join(", ")}`);
|
|
73
|
+
try {
|
|
74
|
+
const adapters = buildAdapterMap(ctx);
|
|
75
|
+
const recovered = await this.recoverPositions(adapters, ctx);
|
|
76
|
+
if (recovered.length > 0) {
|
|
77
|
+
ctx.state.set("spaPositions", recovered);
|
|
78
|
+
ctx.state.set("arbPositions", recovered.length);
|
|
79
|
+
ctx.log(` [SPA] Recovered ${recovered.length} position(s)`);
|
|
80
|
+
}
|
|
81
|
+
// Load historical funding
|
|
82
|
+
let historicalFunding = 0;
|
|
83
|
+
for (const [name, a] of adapters) {
|
|
84
|
+
try {
|
|
85
|
+
const payments = await a.getFundingPayments(200);
|
|
86
|
+
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
87
|
+
const sum = payments.filter(fp => fp.time > oneDayAgo).reduce((s, fp) => s + parseFloat(fp.payment), 0);
|
|
88
|
+
if (Math.abs(sum) > 0.001) {
|
|
89
|
+
historicalFunding += sum;
|
|
90
|
+
ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} historical`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* not supported */ }
|
|
94
|
+
}
|
|
95
|
+
if (Math.abs(historicalFunding) > 0.001)
|
|
96
|
+
ctx.state.set("fundingIncome", historicalFunding);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
ctx.log(` [SPA] Position recovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ── onTick ──
|
|
103
|
+
async onTick(ctx, _snapshot) {
|
|
104
|
+
const p = this.params;
|
|
105
|
+
const positions = ctx.state.get("spaPositions");
|
|
106
|
+
const adapters = buildAdapterMap(ctx);
|
|
107
|
+
const exchangeBalances = new Map();
|
|
108
|
+
const availableAdapters = new Map();
|
|
109
|
+
for (const [name, a] of adapters) {
|
|
110
|
+
try {
|
|
111
|
+
const bal = await a.getBalance();
|
|
112
|
+
const equity = parseFloat(bal.equity);
|
|
113
|
+
const marginPct = equity > 0 ? (parseFloat(bal.marginUsed) / equity) * 100 : 100;
|
|
114
|
+
const available = parseFloat(bal.available);
|
|
115
|
+
exchangeBalances.set(name, { equity, available, marginPct });
|
|
116
|
+
const cd = this._failCooldown.get(`${name}:exchange`) ?? 0;
|
|
117
|
+
if (Date.now() < cd)
|
|
118
|
+
ctx.log(` [SPA] ${name} on cooldown`);
|
|
119
|
+
else if (available < 5)
|
|
120
|
+
ctx.log(` [SPA] ${name} available $${available.toFixed(2)} too low`);
|
|
121
|
+
else if (marginPct >= 90)
|
|
122
|
+
ctx.log(` [SPA] ${name} margin ${marginPct.toFixed(0)}%`);
|
|
123
|
+
else
|
|
124
|
+
availableAdapters.set(name, a);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
ctx.log(` [SPA] getBalance failed for ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const ratesByExchange = new Map();
|
|
131
|
+
for (const [name, a] of adapters) {
|
|
132
|
+
const rates = await fetchRates(a, name);
|
|
133
|
+
const map = new Map();
|
|
134
|
+
for (const r of rates)
|
|
135
|
+
map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage, fundingHours: r.fundingHours });
|
|
136
|
+
ratesByExchange.set(name, map);
|
|
137
|
+
}
|
|
138
|
+
const lastCheck = ctx.state.get("fundingLastCheck");
|
|
139
|
+
if (Date.now() - lastCheck > 5 * 60 * 1000) {
|
|
140
|
+
try {
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (const [name, a] of adapters) {
|
|
143
|
+
try {
|
|
144
|
+
const payments = await a.getFundingPayments(50);
|
|
145
|
+
const start = ctx.state.get("fundingLastCheck");
|
|
146
|
+
const sum = payments.filter(fp => fp.time > start - 5 * 60 * 1000).reduce((s, fp) => s + parseFloat(fp.payment), 0);
|
|
147
|
+
total += sum;
|
|
148
|
+
if (Math.abs(sum) > 0.001)
|
|
149
|
+
ctx.log(` [FUND] ${name}: $${sum.toFixed(4)}`);
|
|
150
|
+
}
|
|
151
|
+
catch { /* skip */ }
|
|
152
|
+
}
|
|
153
|
+
ctx.state.set("fundingIncome", ctx.state.get("fundingIncome") + total);
|
|
154
|
+
ctx.state.set("fundingLastCheck", Date.now());
|
|
155
|
+
}
|
|
156
|
+
catch { /* non-critical */ }
|
|
157
|
+
}
|
|
158
|
+
for (const pos of positions) {
|
|
159
|
+
if (Date.now() - pos.lastFundingCheck < 10 * 60 * 1000)
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
const perpAdapter = adapters.get(pos.perpExchange);
|
|
163
|
+
if (!perpAdapter)
|
|
164
|
+
continue;
|
|
165
|
+
const payments = await perpAdapter.getFundingPayments(p.history_periods * 2);
|
|
166
|
+
const perpSym = getPerpSymbol(pos.symbol, pos.perpExchange);
|
|
167
|
+
const relevant = payments.filter(fp => matchSymbol(fp.symbol, perpSym) || matchSymbol(fp.symbol, pos.symbol));
|
|
168
|
+
const recent = relevant.slice(0, p.history_periods);
|
|
169
|
+
if (recent.length > 0)
|
|
170
|
+
pos.fundingHistory = recent.map(fp => ({ time: fp.time, amount: parseFloat(fp.payment) }));
|
|
171
|
+
pos.lastFundingCheck = Date.now();
|
|
172
|
+
}
|
|
173
|
+
catch { /* non-critical */ }
|
|
174
|
+
}
|
|
175
|
+
const display = positions.map(pos => {
|
|
176
|
+
const ann = this.calcAnnualized(pos, ratesByExchange);
|
|
177
|
+
const sign = ann >= 0 ? "+" : "";
|
|
178
|
+
const hist = pos.fundingHistory;
|
|
179
|
+
const posCount = hist.filter(h => h.amount > 0).length;
|
|
180
|
+
return `${pos.symbol} ${pos.spotExchange}-spot<>${pos.perpExchange} ${sign}${ann.toFixed(1)}% (${posCount}/${hist.length} positive)`;
|
|
181
|
+
});
|
|
182
|
+
ctx.state.set("arbPositionDetails", display);
|
|
183
|
+
ctx.state.set("arbPositions", positions.length);
|
|
184
|
+
ctx.state.set("fundingTotal", `$${ctx.state.get("fundingIncome").toFixed(4)}`);
|
|
185
|
+
// ── Close check ──
|
|
186
|
+
const toClose = [];
|
|
187
|
+
for (const pos of positions) {
|
|
188
|
+
if (!this.shouldClose(pos, ratesByExchange, p))
|
|
189
|
+
continue;
|
|
190
|
+
const cd = this._failCooldown.get(`${pos.symbol}:close`) ?? 0;
|
|
191
|
+
if (Date.now() < cd)
|
|
192
|
+
continue;
|
|
193
|
+
ctx.log(` [SPA] Closing ${pos.symbol} ${pos.spotExchange}-spot<>${pos.perpExchange}`);
|
|
194
|
+
const spotExAdapter = adapters.get(pos.spotExchange) ?? adapters.get(pos.perpExchange);
|
|
195
|
+
const perpAdapter = adapters.get(pos.perpExchange);
|
|
196
|
+
if (!perpAdapter || !spotExAdapter) {
|
|
197
|
+
ctx.log(` [SPA] Close: no adapter`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const spotAdapter = await this.executor.getSpotAdapter(pos.spotExchange, spotExAdapter);
|
|
201
|
+
if (!spotAdapter)
|
|
202
|
+
continue;
|
|
203
|
+
const closed = await this.executor.closePosition({
|
|
204
|
+
symbol: pos.symbol, spotExchange: pos.spotExchange, perpExchange: pos.perpExchange,
|
|
205
|
+
spotAdapter, perpAdapter, size: pos.size, log: ctx.log,
|
|
206
|
+
});
|
|
207
|
+
if (closed)
|
|
208
|
+
toClose.push(pos);
|
|
209
|
+
else
|
|
210
|
+
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
211
|
+
}
|
|
212
|
+
if (toClose.length > 0) {
|
|
213
|
+
const remaining = positions.filter(pos => !toClose.some(c => c.id === pos.id));
|
|
214
|
+
ctx.state.set("spaPositions", remaining);
|
|
215
|
+
ctx.state.set("arbPositions", remaining.length);
|
|
216
|
+
}
|
|
217
|
+
// ── Rotation check ──
|
|
218
|
+
const currentPositions = ctx.state.get("spaPositions");
|
|
219
|
+
for (const pos of currentPositions) {
|
|
220
|
+
const rcd = this._failCooldown.get(`${pos.id}:rotate`) ?? 0;
|
|
221
|
+
if (Date.now() < rcd)
|
|
222
|
+
continue;
|
|
223
|
+
const best = await this.findBestPerp(pos.symbol, adapters, ratesByExchange);
|
|
224
|
+
if (!best || best.exchange === pos.perpExchange)
|
|
225
|
+
continue;
|
|
226
|
+
const currentInfo = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
227
|
+
?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
|
|
228
|
+
if (!currentInfo)
|
|
229
|
+
continue;
|
|
230
|
+
const curAnn = (currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.perpExchange))) * 8760 * 100;
|
|
231
|
+
if (best.score.annualized - curAnn < p.rotation_threshold)
|
|
232
|
+
continue;
|
|
233
|
+
ctx.log(` [SPA] Rotating ${pos.symbol} short: ${pos.perpExchange} (${curAnn.toFixed(1)}%) -> ${best.exchange} (${best.score.annualized.toFixed(1)}%)`);
|
|
234
|
+
const oldAdapter = adapters.get(pos.perpExchange);
|
|
235
|
+
const newAdapter = adapters.get(best.exchange);
|
|
236
|
+
if (!oldAdapter || !newAdapter)
|
|
237
|
+
continue;
|
|
238
|
+
const perpSym = getPerpSymbol(pos.symbol, pos.perpExchange);
|
|
239
|
+
const newMaxLev = findRate(ratesByExchange, best.exchange, getPerpSymbol(pos.symbol, best.exchange))?.maxLeverage
|
|
240
|
+
?? findRate(ratesByExchange, best.exchange, pos.symbol)?.maxLeverage ?? 1;
|
|
241
|
+
const ok = await this.executor.rotateShort({
|
|
242
|
+
symbol: pos.symbol, perpSymbol: perpSym,
|
|
243
|
+
currentExchange: pos.perpExchange, newExchange: best.exchange,
|
|
244
|
+
currentAdapter: oldAdapter, newAdapter,
|
|
245
|
+
size: pos.size, leverage: Math.min(p.leverage, newMaxLev), log: ctx.log,
|
|
246
|
+
});
|
|
247
|
+
if (ok) {
|
|
248
|
+
pos.perpExchange = best.exchange;
|
|
249
|
+
pos.fundingHistory = [];
|
|
250
|
+
pos.lastFundingCheck = 0;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
this._failCooldown.set(`${pos.id}:rotate`, Date.now() + 10 * 60 * 1000);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// ── New entry ──
|
|
257
|
+
const numPos = ctx.state.get("spaPositions").length;
|
|
258
|
+
if (numPos >= p.max_positions)
|
|
259
|
+
return [];
|
|
260
|
+
const openSymbols = new Set(ctx.state.get("spaPositions").map(pos => pos.symbol.toUpperCase()));
|
|
261
|
+
const scoredOpps = [];
|
|
262
|
+
// HL spot: only whitelisted tokens (U-tokens + PURR/HYPE). Lighter spot: all allowed.
|
|
263
|
+
const HL_SPOT_WHITELIST = new Set(["UBTC", "UETH", "USOL", "UFART", "UPUMP", "LINK0", "AVAX0", "AAVE0", "PURR", "HYPE"]);
|
|
264
|
+
for (const [spotExName, spotExAdapter] of adapters) {
|
|
265
|
+
try {
|
|
266
|
+
const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
|
|
267
|
+
if (!spotAdapter)
|
|
268
|
+
continue;
|
|
269
|
+
const spotMarkets = await spotAdapter.getSpotMarkets();
|
|
270
|
+
for (const m of spotMarkets) {
|
|
271
|
+
const base = m.baseToken.toUpperCase();
|
|
272
|
+
// HL spot: skip tokens not in whitelist
|
|
273
|
+
if (spotExName === "hyperliquid" && !HL_SPOT_WHITELIST.has(base))
|
|
274
|
+
continue;
|
|
275
|
+
if (openSymbols.has(base))
|
|
276
|
+
continue;
|
|
277
|
+
if (Date.now() < (this._failCooldown.get(`${base}:entry`) ?? 0))
|
|
278
|
+
continue;
|
|
279
|
+
const best = await this.findBestPerp(base, adapters, ratesByExchange);
|
|
280
|
+
if (!best || best.score.annualized < p.min_rate || best.score.consistency < p.min_consistency)
|
|
281
|
+
continue;
|
|
282
|
+
scoredOpps.push({ symbol: base, spotExchange: spotExName, perpExchange: best.exchange, annualized: best.score.annualized, score: best.score });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch { /* no spot */ }
|
|
286
|
+
}
|
|
287
|
+
scoredOpps.sort((a, b) => b.annualized - a.annualized);
|
|
288
|
+
if (scoredOpps.length > 0 && numPos < p.max_positions) {
|
|
289
|
+
for (const opp of scoredOpps.slice(0, 3)) {
|
|
290
|
+
ctx.log(` [SPA] ${opp.symbol}: ${opp.annualized.toFixed(1)}% consistency=${(opp.score.consistency * 100).toFixed(0)}% ${opp.spotExchange}-spot<>${opp.perpExchange}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const opp of scoredOpps) {
|
|
294
|
+
if (ctx.state.get("spaPositions").length >= p.max_positions)
|
|
295
|
+
break;
|
|
296
|
+
const opened = await this.tryOpenPosition(opp.spotExchange, opp.perpExchange, opp.symbol, opp.annualized, adapters, ctx, ratesByExchange, exchangeBalances);
|
|
297
|
+
if (opened)
|
|
298
|
+
return [{ type: "noop" }];
|
|
299
|
+
}
|
|
300
|
+
if (scoredOpps.length === 0 && numPos < p.max_positions) {
|
|
301
|
+
ctx.log(` [SPA] No opportunities >= ${p.min_rate}% with >= ${(p.min_consistency * 100).toFixed(0)}% consistency`);
|
|
302
|
+
}
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
// ── onStop ──
|
|
306
|
+
async onStop(ctx) {
|
|
307
|
+
const positions = ctx.state.get("spaPositions");
|
|
308
|
+
const adapters = buildAdapterMap(ctx);
|
|
309
|
+
if (positions && positions.length > 0) {
|
|
310
|
+
const remaining = [];
|
|
311
|
+
for (const pos of positions) {
|
|
312
|
+
const spotExAdapter = adapters.get(pos.spotExchange) ?? adapters.get(pos.perpExchange);
|
|
313
|
+
const perpAdapter = adapters.get(pos.perpExchange);
|
|
314
|
+
if (!perpAdapter || !spotExAdapter) {
|
|
315
|
+
ctx.log(` [SPA] CRITICAL: ${pos.symbol} no adapter on stop`);
|
|
316
|
+
remaining.push(pos);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const spotAdapter = await this.executor.getSpotAdapter(pos.spotExchange, spotExAdapter);
|
|
320
|
+
if (!spotAdapter) {
|
|
321
|
+
ctx.log(` [SPA] CRITICAL: ${pos.symbol} no spot adapter on stop`);
|
|
322
|
+
remaining.push(pos);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const closed = await this.executor.closePosition({
|
|
326
|
+
symbol: pos.symbol, spotExchange: pos.spotExchange, perpExchange: pos.perpExchange,
|
|
327
|
+
spotAdapter, perpAdapter, size: pos.size, log: ctx.log,
|
|
328
|
+
});
|
|
329
|
+
if (!closed) {
|
|
330
|
+
ctx.log(` [SPA] CRITICAL: ${pos.symbol} could not be closed on stop`);
|
|
331
|
+
remaining.push(pos);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
ctx.state.set("spaPositions", remaining);
|
|
335
|
+
ctx.state.set("arbPositions", remaining.length);
|
|
336
|
+
}
|
|
337
|
+
const extra = ctx.state.get("extraAdapters");
|
|
338
|
+
if (extra) {
|
|
339
|
+
for (const [, a] of extra) {
|
|
340
|
+
try {
|
|
341
|
+
await a.cancelAllOrders();
|
|
342
|
+
}
|
|
343
|
+
catch { /* best effort */ }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return [{ type: "cancel_all" }];
|
|
347
|
+
}
|
|
348
|
+
// ── Scoring (decision logic) ──
|
|
349
|
+
async scoreFunding(adapter, symbol, exchangeName) {
|
|
350
|
+
const p = this.params;
|
|
351
|
+
const key = `${exchangeName}:${symbol}`;
|
|
352
|
+
const cached = this._fundingScoreCache.get(key);
|
|
353
|
+
if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
|
|
354
|
+
return cached.score;
|
|
355
|
+
try {
|
|
356
|
+
const history = await adapter.getFundingHistory(symbol, p.history_periods);
|
|
357
|
+
if (history.length === 0)
|
|
358
|
+
return null;
|
|
359
|
+
const rates = history.map(h => parseFloat(h.rate));
|
|
360
|
+
const fH = getFundingHours(exchangeName);
|
|
361
|
+
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
362
|
+
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
363
|
+
const annualized = (avgRate / fH) * 8760 * 100;
|
|
364
|
+
const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
|
|
365
|
+
this._fundingScoreCache.set(key, { score, ts: Date.now() });
|
|
366
|
+
return score;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async findBestPerp(symbol, adapters, ratesByExchange) {
|
|
373
|
+
let best = null;
|
|
374
|
+
for (const [exName, adapter] of adapters) {
|
|
375
|
+
const perpSym = getPerpSymbol(symbol, exName);
|
|
376
|
+
const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
|
|
377
|
+
if (!ri || ri.rate <= 0)
|
|
378
|
+
continue;
|
|
379
|
+
const score = await this.scoreFunding(adapter, perpSym, exName)
|
|
380
|
+
?? await this.scoreFunding(adapter, symbol, exName);
|
|
381
|
+
if (!score)
|
|
382
|
+
continue;
|
|
383
|
+
if (!best || score.annualized > best.score.annualized)
|
|
384
|
+
best = { exchange: exName, rate: ri.rate, score };
|
|
385
|
+
}
|
|
386
|
+
return best;
|
|
387
|
+
}
|
|
388
|
+
calcAnnualized(pos, ratesByExchange) {
|
|
389
|
+
const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
390
|
+
?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
|
|
391
|
+
const fH = ri?.fundingHours ?? getFundingHours(pos.perpExchange);
|
|
392
|
+
return ri ? (ri.rate / fH) * 8760 * 100 : 0;
|
|
393
|
+
}
|
|
394
|
+
shouldClose(pos, ratesByExchange, p) {
|
|
395
|
+
const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
|
|
396
|
+
const holdTime = pos.entryTime > 0 ? Date.now() - pos.entryTime : Infinity;
|
|
397
|
+
const ann = this.calcAnnualized(pos, ratesByExchange);
|
|
398
|
+
// Emergency close: instantaneous spread deeply negative
|
|
399
|
+
if (ann < -50)
|
|
400
|
+
return true;
|
|
401
|
+
if (holdTime < minHoldMs)
|
|
402
|
+
return false;
|
|
403
|
+
// History-based: avg net funding over last N payments < 0
|
|
404
|
+
if (pos.fundingHistory.length >= 3) {
|
|
405
|
+
const avgFunding = pos.fundingHistory.reduce((s, h) => s + h.amount, 0) / pos.fundingHistory.length;
|
|
406
|
+
if (avgFunding < 0)
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
// Fallback to instantaneous rate
|
|
410
|
+
return ann < p.close_rate;
|
|
411
|
+
}
|
|
412
|
+
// ── Open position (decision + sizing, delegates execution) ──
|
|
413
|
+
async tryOpenPosition(spotExchange, perpExchange, symbol, entryRate, adapters, ctx, ratesByExchange, exchangeBalances) {
|
|
414
|
+
const p = this.params;
|
|
415
|
+
const spotExAdapter = adapters.get(spotExchange);
|
|
416
|
+
const perpAdapter = adapters.get(perpExchange);
|
|
417
|
+
if (!spotExAdapter || !perpAdapter) {
|
|
418
|
+
this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const spotAdapter = await this.executor.getSpotAdapter(spotExchange, spotExAdapter);
|
|
423
|
+
if (!spotAdapter)
|
|
424
|
+
return false;
|
|
425
|
+
const perpSymbol = getPerpSymbol(symbol, perpExchange);
|
|
426
|
+
const rateInfo = findRate(ratesByExchange, perpExchange, perpSymbol)
|
|
427
|
+
?? findRate(ratesByExchange, perpExchange, symbol);
|
|
428
|
+
const perpMaxLev = rateInfo?.maxLeverage ?? 1;
|
|
429
|
+
const leverage = Math.min(p.leverage, perpMaxLev);
|
|
430
|
+
// Pre-entry: verify fundingHours (aster lazy bootstrap may not have run yet)
|
|
431
|
+
if (perpExchange === "aster" && rateInfo && "getFundingHours" in perpAdapter) {
|
|
432
|
+
const actualFh = await perpAdapter.getFundingHours(symbol);
|
|
433
|
+
if (!actualFh) {
|
|
434
|
+
ctx.log(` [SPA] Skip ${symbol}: fundingHours unknown (aster not yet bootstrapped)`);
|
|
435
|
+
this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
const actualAnn = (rateInfo.rate / actualFh) * 8760 * 100;
|
|
439
|
+
if (actualAnn < p.min_rate) {
|
|
440
|
+
ctx.log(` [SPA] Skip ${symbol}: actual rate ${actualAnn.toFixed(1)}% (${actualFh}h funding) below min_rate ${p.min_rate}%`);
|
|
441
|
+
this._failCooldown.set(`${symbol}:entry`, Date.now() + 10 * 60 * 1000);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Size decision
|
|
446
|
+
let targetSizeUsd = p.size_usd;
|
|
447
|
+
const sameExchange = spotExchange === perpExchange;
|
|
448
|
+
if (sameExchange) {
|
|
449
|
+
const perpBal = await perpAdapter.getBalance();
|
|
450
|
+
const perpAvail = parseFloat(perpBal.available);
|
|
451
|
+
const totalNeeded = targetSizeUsd + targetSizeUsd / leverage;
|
|
452
|
+
if (perpAvail < totalNeeded) {
|
|
453
|
+
const max = perpAvail * 0.8 / (1 + 1 / leverage);
|
|
454
|
+
if (max < 10) {
|
|
455
|
+
ctx.log(` [SPA] Skip ${symbol}: insufficient balance`);
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
targetSizeUsd = Math.floor(max);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
// For cross-exchange: spot side needs USDC in spot OR perp account (can transfer)
|
|
463
|
+
const sBal = exchangeBalances.get(spotExchange);
|
|
464
|
+
const pBal = exchangeBalances.get(perpExchange);
|
|
465
|
+
if (!sBal || !pBal)
|
|
466
|
+
return false;
|
|
467
|
+
// Check spot USDC balance too (already in spot account, no transfer needed)
|
|
468
|
+
let spotAvailable = sBal.available;
|
|
469
|
+
try {
|
|
470
|
+
const spotBals = await spotAdapter.getSpotBalances();
|
|
471
|
+
const spotUsdc = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
|
|
472
|
+
if (spotUsdc)
|
|
473
|
+
spotAvailable += parseFloat(spotUsdc.available);
|
|
474
|
+
}
|
|
475
|
+
catch { /* non-critical */ }
|
|
476
|
+
const maxNtl = Math.min(spotAvailable, pBal.available * leverage) * 0.8;
|
|
477
|
+
if (maxNtl < targetSizeUsd) {
|
|
478
|
+
if (maxNtl < 10) {
|
|
479
|
+
ctx.log(` [SPA] Skip ${symbol}: insufficient balance (spot=$${spotAvailable.toFixed(2)}, perp=$${pBal.available.toFixed(2)})`);
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
targetSizeUsd = Math.floor(maxNtl);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Delegate to executor
|
|
486
|
+
const result = await this.executor.openPosition({
|
|
487
|
+
symbol, perpSymbol, spotExchange, perpExchange,
|
|
488
|
+
spotAdapter, perpAdapter, sizeUsd: targetSizeUsd, leverage, log: ctx.log,
|
|
489
|
+
});
|
|
490
|
+
if (result.success) {
|
|
491
|
+
const cur = ctx.state.get("spaPositions");
|
|
492
|
+
cur.push({ id: genPosId(), symbol, spotExchange, perpExchange, size: result.size, entryTime: Date.now(), entryRate, fundingHistory: [], lastFundingCheck: 0 });
|
|
493
|
+
ctx.state.set("spaPositions", cur);
|
|
494
|
+
ctx.state.set("arbPositions", cur.length);
|
|
495
|
+
ctx.log(` [SPA] Opened! (${cur.length}/${p.max_positions})`);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
ctx.log(` [SPA] Open failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
503
|
+
this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// ── Position recovery ──
|
|
508
|
+
async recoverPositions(adapters, ctx) {
|
|
509
|
+
const recovered = [];
|
|
510
|
+
const perpByEx = new Map();
|
|
511
|
+
for (const [name, a] of adapters) {
|
|
512
|
+
try {
|
|
513
|
+
const pos = await a.getPositions();
|
|
514
|
+
perpByEx.set(name, pos.map(p => ({ symbol: p.symbol.toUpperCase(), side: p.side, size: p.size })));
|
|
515
|
+
}
|
|
516
|
+
catch { /* skip */ }
|
|
517
|
+
}
|
|
518
|
+
const used = new Set();
|
|
519
|
+
for (const [spotExName, spotExAdapter] of adapters) {
|
|
520
|
+
try {
|
|
521
|
+
const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
|
|
522
|
+
if (!spotAdapter)
|
|
523
|
+
continue;
|
|
524
|
+
const bals = await spotAdapter.getSpotBalances();
|
|
525
|
+
for (const bal of bals.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"))) {
|
|
526
|
+
const base = bal.token.toUpperCase().replace(/-SPOT$/, "");
|
|
527
|
+
for (const [perpExName, perps] of perpByEx) {
|
|
528
|
+
for (const perp of perps) {
|
|
529
|
+
const key = `${perpExName}:${perp.symbol}`;
|
|
530
|
+
if (used.has(key))
|
|
531
|
+
continue;
|
|
532
|
+
if (perp.symbol.replace(/-PERP$/, "").toUpperCase() === base && perp.side === "short") {
|
|
533
|
+
recovered.push({ id: genPosId(), symbol: base, spotExchange: spotExName, perpExchange: perpExName, size: perp.size, entryTime: 0, entryRate: 0, fundingHistory: [], lastFundingCheck: 0 });
|
|
534
|
+
used.add(key);
|
|
535
|
+
ctx.log(` [SPA] Recovered: ${base} ${spotExName}-spot<>${perpExName}`);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch { /* no spot */ }
|
|
543
|
+
}
|
|
544
|
+
return recovered;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
registerStrategy("spot-perp-arb", (_config) => new SpotPerpArbStrategy());
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spot-Perp Executor — handles ALL exchange interactions for spot-perp arbitrage.
|
|
3
|
+
*
|
|
4
|
+
* Stateless except for spot adapter cache. Never makes trading decisions.
|
|
5
|
+
* Strategy calls executor methods; executor handles error recovery / rollback.
|
|
6
|
+
*/
|
|
7
|
+
import type { ExchangeAdapter } from "../../exchanges/index.js";
|
|
8
|
+
import type { SpotAdapter } from "../../exchanges/spot-interface.js";
|
|
9
|
+
export interface ExecutionResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
size?: string;
|
|
12
|
+
notional?: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class SpotPerpExecutor {
|
|
16
|
+
private _spotAdapterCache;
|
|
17
|
+
/** Get or create cached spot adapter. */
|
|
18
|
+
getSpotAdapter(name: string, adapter: ExchangeAdapter): Promise<SpotAdapter | null>;
|
|
19
|
+
/** Transfer USDC: perp -> spot account. */
|
|
20
|
+
transferToSpot(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
|
|
21
|
+
/** Transfer USDC: spot -> perp account. */
|
|
22
|
+
transferToPerp(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
|
|
23
|
+
/** Returns true if a reduceOnly rejection indicates the position is already gone. */
|
|
24
|
+
private isPositionGone;
|
|
25
|
+
/**
|
|
26
|
+
* Full spot-perp entry flow:
|
|
27
|
+
* 1. Check liquidity
|
|
28
|
+
* 2. Compute matched size
|
|
29
|
+
* 3. Set leverage on perp
|
|
30
|
+
* 4. Transfer USDC to spot (only the shortfall, track actual transferred)
|
|
31
|
+
* 5. Buy spot
|
|
32
|
+
* 6. Short perp
|
|
33
|
+
* 7. Verify positions
|
|
34
|
+
* On failure: rollback all completed steps.
|
|
35
|
+
*/
|
|
36
|
+
openPosition(params: {
|
|
37
|
+
symbol: string;
|
|
38
|
+
perpSymbol: string;
|
|
39
|
+
spotExchange: string;
|
|
40
|
+
perpExchange: string;
|
|
41
|
+
spotAdapter: SpotAdapter;
|
|
42
|
+
perpAdapter: ExchangeAdapter;
|
|
43
|
+
sizeUsd: number;
|
|
44
|
+
leverage: number;
|
|
45
|
+
log: (msg: string) => void;
|
|
46
|
+
}): Promise<ExecutionResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Full spot-perp close flow:
|
|
49
|
+
* 1. Close perp short FIRST (reduceOnly) — hedge stays intact if this fails
|
|
50
|
+
* 2. Sell spot
|
|
51
|
+
* 3. Transfer USDC back to perp (always, not just same-exchange)
|
|
52
|
+
*/
|
|
53
|
+
closePosition(params: {
|
|
54
|
+
symbol: string;
|
|
55
|
+
spotExchange: string;
|
|
56
|
+
perpExchange: string;
|
|
57
|
+
spotAdapter: SpotAdapter;
|
|
58
|
+
perpAdapter: ExchangeAdapter;
|
|
59
|
+
size: string;
|
|
60
|
+
log: (msg: string) => void;
|
|
61
|
+
}): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Rotate perp short to better exchange:
|
|
64
|
+
* 1. Set leverage on new exchange
|
|
65
|
+
* 2. Close current perp short (reduceOnly)
|
|
66
|
+
* 3. Open new perp short on new exchange
|
|
67
|
+
* 4. Rollback if new short fails
|
|
68
|
+
* Spot stays unchanged.
|
|
69
|
+
*/
|
|
70
|
+
rotateShort(params: {
|
|
71
|
+
symbol: string;
|
|
72
|
+
perpSymbol: string;
|
|
73
|
+
currentExchange: string;
|
|
74
|
+
newExchange: string;
|
|
75
|
+
currentAdapter: ExchangeAdapter;
|
|
76
|
+
newAdapter: ExchangeAdapter;
|
|
77
|
+
size: string;
|
|
78
|
+
leverage: number;
|
|
79
|
+
log: (msg: string) => void;
|
|
80
|
+
}): Promise<boolean>;
|
|
81
|
+
}
|