perp-cli 0.7.23 → 0.7.30
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/arb/sizing.js +2 -1
- package/dist/bot/config.d.ts +1 -0
- package/dist/bot/config.js +1 -0
- package/dist/bot/strategies/funding-arb-strategy.d.ts +1 -0
- package/dist/bot/strategies/funding-arb-strategy.js +232 -96
- package/dist/commands/bot.js +16 -0
- package/dist/exchanges/aster.js +8 -1
- package/dist/exchanges/hyperliquid.js +2 -0
- package/dist/exchanges/lighter.js +4 -0
- package/dist/exchanges/pacifica.js +5 -0
- package/package.json +1 -1
package/dist/arb/sizing.js
CHANGED
|
@@ -55,10 +55,11 @@ function getSizeDecimals(exchange) {
|
|
|
55
55
|
case "hyperliquid": return 2; // HL perps range 0-5; 2 is safe middle ground
|
|
56
56
|
case "lighter": return 2;
|
|
57
57
|
case "pacifica": return 4;
|
|
58
|
+
case "aster": return 0; // aster symbols mostly use integers; explicit sizeDecimals overrides this
|
|
58
59
|
// Spot exchanges
|
|
59
60
|
case "spot:hyperliquid": return 2;
|
|
60
61
|
case "spot:lighter": return 2;
|
|
61
|
-
default: return
|
|
62
|
+
default: return 0; // conservative: integers when unknown
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
/** Minimum notional (USD) per exchange */
|
package/dist/bot/config.d.ts
CHANGED
package/dist/bot/config.js
CHANGED
|
@@ -89,6 +89,7 @@ export function parseStrategy(type, raw) {
|
|
|
89
89
|
exchanges: Array.isArray(raw.exchanges)
|
|
90
90
|
? raw.exchanges.map(String)
|
|
91
91
|
: String(raw.exchanges ?? "pacifica,hyperliquid").split(",").map(s => s.trim()),
|
|
92
|
+
leverage: raw.leverage !== undefined ? Number(raw.leverage) : undefined,
|
|
92
93
|
};
|
|
93
94
|
default:
|
|
94
95
|
return { type, ...raw };
|
|
@@ -25,6 +25,7 @@ export declare class FundingArbStrategy implements Strategy {
|
|
|
25
25
|
};
|
|
26
26
|
private get params();
|
|
27
27
|
private _config;
|
|
28
|
+
private _failCooldown;
|
|
28
29
|
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
29
30
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
30
31
|
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
@@ -15,6 +15,7 @@ export class FundingArbStrategy {
|
|
|
15
15
|
{ name: "size_usd", type: "number", required: true, description: "Position size in USD" },
|
|
16
16
|
{ name: "max_positions", type: "number", required: false, default: 3, description: "Max concurrent positions" },
|
|
17
17
|
{ name: "exchanges", type: "string", required: true, description: "Comma-separated exchange names" },
|
|
18
|
+
{ name: "leverage", type: "number", required: false, default: 3, description: "Max leverage to use" },
|
|
18
19
|
],
|
|
19
20
|
};
|
|
20
21
|
}
|
|
@@ -22,6 +23,7 @@ export class FundingArbStrategy {
|
|
|
22
23
|
return this._config;
|
|
23
24
|
}
|
|
24
25
|
_config = {};
|
|
26
|
+
_failCooldown = new Map(); // "symbol:action" → timestamp of next retry
|
|
25
27
|
async init(ctx, _snapshot) {
|
|
26
28
|
// Prune old execution logs (keep 30 days)
|
|
27
29
|
try {
|
|
@@ -119,15 +121,19 @@ export class FundingArbStrategy {
|
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
const nonUsdc = spotBalances.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
|
|
124
|
+
ctx.log(` [ARB] ${name} spot balances: ${nonUsdc.map(b => `${b.token}=${b.total}`).join(", ") || "none"}`);
|
|
122
125
|
if (nonUsdc.length === 0)
|
|
123
126
|
continue;
|
|
124
127
|
const perpPositions = positionsByExchange.get(name) ?? [];
|
|
125
128
|
for (const perp of perpPositions) {
|
|
126
129
|
const perpKey = `${name}:${perp.symbol}`;
|
|
127
|
-
if (used.has(perpKey))
|
|
130
|
+
if (used.has(perpKey)) {
|
|
131
|
+
ctx.log(` [ARB] ${perpKey} already matched cross-exchange, skip spot-perp`);
|
|
128
132
|
continue;
|
|
133
|
+
}
|
|
129
134
|
const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
|
|
130
|
-
const spotBal = nonUsdc.find(b => b.token.toUpperCase() === base);
|
|
135
|
+
const spotBal = nonUsdc.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === base);
|
|
136
|
+
ctx.log(` [ARB] ${name} perp ${perp.symbol}(${perp.side}) → base=${base}, spotMatch=${spotBal ? spotBal.token : "none"}`);
|
|
131
137
|
if (spotBal && perp.side === "short") {
|
|
132
138
|
recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp" });
|
|
133
139
|
used.add(perpKey);
|
|
@@ -187,7 +193,7 @@ export class FundingArbStrategy {
|
|
|
187
193
|
const rates = await this.fetchRates(a, name);
|
|
188
194
|
const map = new Map();
|
|
189
195
|
for (const r of rates)
|
|
190
|
-
map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals });
|
|
196
|
+
map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage });
|
|
191
197
|
ratesByExchange.set(name, map);
|
|
192
198
|
}
|
|
193
199
|
// ── Fetch funding income periodically (every 5 minutes) ──
|
|
@@ -260,6 +266,11 @@ export class FundingArbStrategy {
|
|
|
260
266
|
// Close if funding flipped (losing money) or spread below close threshold
|
|
261
267
|
const shouldClose = signedSpread < 0 || signedSpread < params.close_spread;
|
|
262
268
|
if (shouldClose) {
|
|
269
|
+
// Cooldown: skip if close recently failed for this symbol
|
|
270
|
+
const closeKey = `${pos.symbol}:close`;
|
|
271
|
+
const cooldownUntil = this._failCooldown.get(closeKey) ?? 0;
|
|
272
|
+
if (Date.now() < cooldownUntil)
|
|
273
|
+
continue;
|
|
263
274
|
const sign = signedSpread >= 0 ? "+" : "";
|
|
264
275
|
ctx.log(` [ARB] Closing ${pos.symbol}: signed spread ${sign}${signedSpread.toFixed(1)}% (close_spread=${params.close_spread}%, entry=${pos.entrySpread.toFixed(1)}%)`);
|
|
265
276
|
if (pos.mode === "spot-perp") {
|
|
@@ -270,18 +281,30 @@ export class FundingArbStrategy {
|
|
|
270
281
|
const longAdapter = adapters.get(pos.longExchange);
|
|
271
282
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
272
283
|
if (longAdapter && shortAdapter) {
|
|
284
|
+
let closeLongOk = false;
|
|
273
285
|
try {
|
|
274
|
-
await
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
|
|
287
|
+
closeLongOk = true;
|
|
288
|
+
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", signedSpread: signedSpread, response: closeLongResult } });
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
292
|
+
ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} (cooldown 5m)`);
|
|
293
|
+
logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "long" } });
|
|
294
|
+
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
298
|
+
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", signedSpread: signedSpread, response: closeShortResult } });
|
|
281
299
|
}
|
|
282
300
|
catch (err) {
|
|
283
301
|
const msg = err instanceof Error ? err.message : String(err);
|
|
284
|
-
ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
|
|
302
|
+
ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg}`);
|
|
303
|
+
logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short" } });
|
|
304
|
+
}
|
|
305
|
+
if (closeLongOk) {
|
|
306
|
+
toClose.push(pos);
|
|
307
|
+
ctx.log(` [ARB] Closed ${pos.symbol} position`);
|
|
285
308
|
}
|
|
286
309
|
}
|
|
287
310
|
}
|
|
@@ -385,9 +408,22 @@ export class FundingArbStrategy {
|
|
|
385
408
|
}
|
|
386
409
|
// Auto-execute: try each opportunity until one succeeds
|
|
387
410
|
if (arbPositions < params.max_positions) {
|
|
411
|
+
// Skip symbols already in open positions
|
|
412
|
+
const openSymbols = new Set(ctx.state.get("arbOpenPositions").map(p => p.symbol.toUpperCase()));
|
|
413
|
+
let skipMargin = 0;
|
|
414
|
+
let skipCooldown = 0;
|
|
388
415
|
for (const best of opportunities) {
|
|
416
|
+
if (openSymbols.has(best.symbol.toUpperCase()))
|
|
417
|
+
continue;
|
|
418
|
+
// Cooldown: skip if entry recently failed for this symbol
|
|
419
|
+
const entryKey = `${best.symbol}:entry`;
|
|
420
|
+
const entryCooldown = this._failCooldown.get(entryKey) ?? 0;
|
|
421
|
+
if (Date.now() < entryCooldown) {
|
|
422
|
+
skipCooldown++;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
389
425
|
if (best.mode === "spot-perp") {
|
|
390
|
-
const opened = await this.openSpotPerp(best, adapters, ctx);
|
|
426
|
+
const opened = await this.openSpotPerp(best, adapters, ctx, ratesByExchange);
|
|
391
427
|
if (opened)
|
|
392
428
|
return [{ type: "noop" }];
|
|
393
429
|
continue;
|
|
@@ -399,24 +435,49 @@ export class FundingArbStrategy {
|
|
|
399
435
|
const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
|
|
400
436
|
if (price <= 0)
|
|
401
437
|
continue;
|
|
402
|
-
//
|
|
438
|
+
// Determine leverage for this symbol
|
|
439
|
+
const configLeverage = params.leverage ?? 3;
|
|
440
|
+
const longMaxLev = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.maxLeverage ?? 1;
|
|
441
|
+
const shortMaxLev = ratesByExchange.get(best.shortExchange)?.get(best.symbol)?.maxLeverage ?? 1;
|
|
442
|
+
const leverage = Math.min(configLeverage, longMaxLev, shortMaxLev);
|
|
443
|
+
// Set leverage on both exchanges before ordering
|
|
444
|
+
try {
|
|
445
|
+
await Promise.all([
|
|
446
|
+
longAdapter.setLeverage(best.symbol, leverage, "cross"),
|
|
447
|
+
shortAdapter.setLeverage(best.symbol, leverage, "cross"),
|
|
448
|
+
]);
|
|
449
|
+
}
|
|
450
|
+
catch { /* non-critical, some exchanges set leverage on order */ }
|
|
451
|
+
// Balance + margin check
|
|
403
452
|
let targetSizeUsd = params.size_usd;
|
|
404
453
|
try {
|
|
405
454
|
const [longBal, shortBal] = await Promise.all([
|
|
406
455
|
longAdapter.getBalance(),
|
|
407
456
|
shortAdapter.getBalance(),
|
|
408
457
|
]);
|
|
458
|
+
// Check margin usage (skip if > 80%)
|
|
459
|
+
const longEquity = parseFloat(longBal.equity);
|
|
460
|
+
const shortEquity = parseFloat(shortBal.equity);
|
|
461
|
+
const longMarginPct = longEquity > 0 ? parseFloat(longBal.marginUsed) / longEquity * 100 : 0;
|
|
462
|
+
const shortMarginPct = shortEquity > 0 ? parseFloat(shortBal.marginUsed) / shortEquity * 100 : 0;
|
|
463
|
+
if (longMarginPct > 80 || shortMarginPct > 80) {
|
|
464
|
+
skipMargin++;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
// Required margin per leg = sizeUsd / leverage
|
|
409
468
|
const longAvail = parseFloat(longBal.available);
|
|
410
469
|
const shortAvail = parseFloat(shortBal.available);
|
|
411
470
|
const minAvail = Math.min(longAvail, shortAvail);
|
|
412
|
-
const
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
471
|
+
const requiredMarginPerLeg = targetSizeUsd / leverage;
|
|
472
|
+
if (minAvail < requiredMarginPerLeg) {
|
|
473
|
+
// Try reducing size to what's affordable
|
|
474
|
+
const maxAffordableNotional = minAvail * leverage * 0.8;
|
|
475
|
+
if (maxAffordableNotional < 10) {
|
|
476
|
+
ctx.log(` [ARB] Skip ${best.symbol}: insufficient margin ($${minAvail.toFixed(2)} < $${requiredMarginPerLeg.toFixed(2)} needed at ${leverage}x)`);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordableNotional.toFixed(0)} (limited by margin $${minAvail.toFixed(2)} at ${leverage}x)`);
|
|
480
|
+
targetSizeUsd = Math.floor(maxAffordableNotional);
|
|
420
481
|
}
|
|
421
482
|
}
|
|
422
483
|
catch { /* non-critical, proceed with original size */ }
|
|
@@ -432,68 +493,77 @@ export class FundingArbStrategy {
|
|
|
432
493
|
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
433
494
|
continue;
|
|
434
495
|
}
|
|
496
|
+
ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
|
|
497
|
+
// Step 1: Execute long leg first
|
|
498
|
+
let longResult;
|
|
435
499
|
try {
|
|
436
|
-
|
|
500
|
+
longResult = await longAdapter.marketOrder(best.symbol, "buy", matched.size);
|
|
501
|
+
logExecution({ type: "arb_entry", exchange: best.longExchange, symbol: best.symbol, side: "buy", size: matched.size, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "long", response: longResult } });
|
|
502
|
+
}
|
|
503
|
+
catch (longErr) {
|
|
504
|
+
const msg = longErr instanceof Error ? longErr.message : String(longErr);
|
|
505
|
+
ctx.log(` [ARB] Long leg failed for ${best.symbol}: ${msg} (cooldown 5m)`);
|
|
506
|
+
logExecution({ type: "arb_entry", exchange: best.longExchange, symbol: best.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp", leg: "long" } });
|
|
507
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
// Step 2: Execute short leg
|
|
511
|
+
try {
|
|
512
|
+
const shortResult = await shortAdapter.marketOrder(best.symbol, "sell", matched.size);
|
|
513
|
+
logExecution({ type: "arb_entry", exchange: best.shortExchange, symbol: best.symbol, side: "sell", size: matched.size, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "short", response: shortResult } });
|
|
514
|
+
}
|
|
515
|
+
catch (shortErr) {
|
|
516
|
+
const msg = shortErr instanceof Error ? shortErr.message : String(shortErr);
|
|
517
|
+
ctx.log(` [ARB] Short leg failed for ${best.symbol}: ${msg} — ROLLING BACK long leg (cooldown 5m)`);
|
|
518
|
+
logExecution({ type: "arb_entry", exchange: best.shortExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp", leg: "short" } });
|
|
519
|
+
// Rollback: close the long position
|
|
437
520
|
try {
|
|
438
|
-
await
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
]);
|
|
442
|
-
}
|
|
443
|
-
catch (orderErr) {
|
|
444
|
-
const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
|
|
445
|
-
const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
|
|
446
|
-
if (lotMatch) {
|
|
447
|
-
const lotSize = parseFloat(lotMatch[1]);
|
|
448
|
-
ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
|
|
449
|
-
matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize, longSizeDecimals: longDec, shortSizeDecimals: shortDec });
|
|
450
|
-
if (!matched) {
|
|
451
|
-
ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
|
|
455
|
-
await Promise.all([
|
|
456
|
-
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
457
|
-
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
458
|
-
]);
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
throw orderErr;
|
|
462
|
-
}
|
|
521
|
+
await longAdapter.marketOrder(best.symbol, "sell", matched.size);
|
|
522
|
+
ctx.log(` [ARB] Rollback success: closed long ${matched.size} ${best.symbol} on ${best.longExchange}`);
|
|
523
|
+
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
|
|
463
524
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
469
|
-
}
|
|
525
|
+
catch (rbErr) {
|
|
526
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
527
|
+
ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
|
|
528
|
+
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
|
|
470
529
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
530
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
// Verify fills match
|
|
534
|
+
try {
|
|
535
|
+
const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
|
|
536
|
+
if (!recon.matched) {
|
|
537
|
+
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
474
538
|
}
|
|
475
|
-
// Track position
|
|
476
|
-
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
477
|
-
currentPositions.push({
|
|
478
|
-
symbol: best.symbol,
|
|
479
|
-
longExchange: best.longExchange,
|
|
480
|
-
shortExchange: best.shortExchange,
|
|
481
|
-
entrySpread: best.spread,
|
|
482
|
-
size: matched.size,
|
|
483
|
-
mode: "perp-perp",
|
|
484
|
-
});
|
|
485
|
-
ctx.state.set("arbOpenPositions", currentPositions);
|
|
486
|
-
ctx.state.set("arbPositions", currentPositions.length);
|
|
487
|
-
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
488
|
-
logExecution({ type: "arb_entry", exchange: `${best.longExchange}↔${best.shortExchange}`, symbol: best.symbol, side: "long/short", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "perp-perp", spread: best.spread } });
|
|
489
|
-
return [{ type: "noop" }];
|
|
490
539
|
}
|
|
491
|
-
catch (
|
|
492
|
-
const
|
|
493
|
-
ctx.log(` [ARB]
|
|
494
|
-
logExecution({ type: "arb_entry", exchange: `${best.longExchange}↔${best.shortExchange}`, symbol: best.symbol, side: "long/short", size: matched?.size ?? "0", status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp" } });
|
|
495
|
-
continue;
|
|
540
|
+
catch (reconErr) {
|
|
541
|
+
const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
|
|
542
|
+
ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
|
|
496
543
|
}
|
|
544
|
+
// Track position
|
|
545
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
546
|
+
currentPositions.push({
|
|
547
|
+
symbol: best.symbol,
|
|
548
|
+
longExchange: best.longExchange,
|
|
549
|
+
shortExchange: best.shortExchange,
|
|
550
|
+
entrySpread: best.spread,
|
|
551
|
+
size: matched.size,
|
|
552
|
+
mode: "perp-perp",
|
|
553
|
+
});
|
|
554
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
555
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
556
|
+
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
557
|
+
return [{ type: "noop" }];
|
|
558
|
+
}
|
|
559
|
+
// Log skip summary only when there were skips (avoids noise when nothing happened)
|
|
560
|
+
if (skipMargin > 0 || skipCooldown > 0) {
|
|
561
|
+
const parts = [];
|
|
562
|
+
if (skipMargin > 0)
|
|
563
|
+
parts.push(`${skipMargin} margin`);
|
|
564
|
+
if (skipCooldown > 0)
|
|
565
|
+
parts.push(`${skipCooldown} cooldown`);
|
|
566
|
+
ctx.log(` [ARB] Skipped ${skipMargin + skipCooldown} opportunities (${parts.join(", ")})`);
|
|
497
567
|
}
|
|
498
568
|
}
|
|
499
569
|
}
|
|
@@ -520,16 +590,24 @@ export class FundingArbStrategy {
|
|
|
520
590
|
const longAdapter = adapters.get(pos.longExchange);
|
|
521
591
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
522
592
|
if (longAdapter && shortAdapter) {
|
|
593
|
+
ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
|
|
594
|
+
try {
|
|
595
|
+
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
|
|
596
|
+
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", stop: true, response: closeLongResult } });
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
600
|
+
ctx.log(` [ARB] Close on stop failed (long) for ${pos.symbol}: ${msg}`);
|
|
601
|
+
logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "long", stop: true } });
|
|
602
|
+
}
|
|
523
603
|
try {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
longAdapter.marketOrder(pos.symbol, "sell", pos.size),
|
|
527
|
-
shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
|
|
528
|
-
]);
|
|
604
|
+
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
605
|
+
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", stop: true, response: closeShortResult } });
|
|
529
606
|
}
|
|
530
607
|
catch (err) {
|
|
531
608
|
const msg = err instanceof Error ? err.message : String(err);
|
|
532
|
-
ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
|
|
609
|
+
ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg}`);
|
|
610
|
+
logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short", stop: true } });
|
|
533
611
|
}
|
|
534
612
|
}
|
|
535
613
|
}
|
|
@@ -603,7 +681,7 @@ export class FundingArbStrategy {
|
|
|
603
681
|
}
|
|
604
682
|
}
|
|
605
683
|
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
606
|
-
async openSpotPerp(opp, adapters, ctx) {
|
|
684
|
+
async openSpotPerp(opp, adapters, ctx, ratesByExchange) {
|
|
607
685
|
const params = this.params;
|
|
608
686
|
const exchangeName = opp.longExchange.replace("-spot", "");
|
|
609
687
|
const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
|
|
@@ -617,19 +695,42 @@ export class FundingArbStrategy {
|
|
|
617
695
|
ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
618
696
|
return false;
|
|
619
697
|
}
|
|
620
|
-
//
|
|
698
|
+
// Determine leverage for perp leg
|
|
699
|
+
const configLeverage = params.leverage ?? 3;
|
|
700
|
+
const perpSymbolUpper = this.getPerpSymbol(opp.symbol, opp.shortExchange).toUpperCase();
|
|
701
|
+
const perpMaxLev = ratesByExchange.get(opp.shortExchange)?.get(perpSymbolUpper)?.maxLeverage
|
|
702
|
+
?? ratesByExchange.get(opp.shortExchange)?.get(opp.symbol.toUpperCase())?.maxLeverage
|
|
703
|
+
?? 1;
|
|
704
|
+
const leverage = Math.min(configLeverage, perpMaxLev);
|
|
705
|
+
// Set leverage on perp before ordering
|
|
706
|
+
try {
|
|
707
|
+
await perpAdapter.setLeverage(this.getPerpSymbol(opp.symbol, opp.shortExchange), leverage, "cross");
|
|
708
|
+
}
|
|
709
|
+
catch { /* non-critical */ }
|
|
710
|
+
// Balance check: spot needs full notional, perp needs sizeUsd / leverage margin
|
|
621
711
|
let targetSizeUsd = params.size_usd;
|
|
622
712
|
try {
|
|
623
713
|
const perpBal = await perpAdapter.getBalance();
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
714
|
+
// Check margin usage
|
|
715
|
+
const perpEquity = parseFloat(perpBal.equity);
|
|
716
|
+
const perpMarginPct = perpEquity > 0 ? parseFloat(perpBal.marginUsed) / perpEquity * 100 : 0;
|
|
717
|
+
if (perpMarginPct > 80) {
|
|
718
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: perp margin usage too high (${perpMarginPct.toFixed(0)}%)`);
|
|
628
719
|
return false;
|
|
629
720
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
721
|
+
const perpAvail = parseFloat(perpBal.available);
|
|
722
|
+
// Total needed from perp account: spot notional (to transfer) + perp margin
|
|
723
|
+
const perpMarginRequired = targetSizeUsd / leverage;
|
|
724
|
+
const totalNeeded = targetSizeUsd + perpMarginRequired;
|
|
725
|
+
if (perpAvail < totalNeeded) {
|
|
726
|
+
// Solve for max affordable: perpAvail >= notional + notional/lev = notional*(1 + 1/lev)
|
|
727
|
+
const maxNotional = perpAvail * 0.8 / (1 + 1 / leverage);
|
|
728
|
+
if (maxNotional < 10) {
|
|
729
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: insufficient balance ($${perpAvail.toFixed(2)} available, need $${totalNeeded.toFixed(2)})`);
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxNotional.toFixed(0)} (limited by balance at ${leverage}x)`);
|
|
733
|
+
targetSizeUsd = Math.floor(maxNotional);
|
|
633
734
|
}
|
|
634
735
|
}
|
|
635
736
|
catch { /* non-critical */ }
|
|
@@ -658,11 +759,46 @@ export class FundingArbStrategy {
|
|
|
658
759
|
await this.transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
|
|
659
760
|
ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
|
|
660
761
|
// Step 2: Buy spot
|
|
661
|
-
|
|
662
|
-
|
|
762
|
+
let spotResult;
|
|
763
|
+
try {
|
|
764
|
+
spotResult = await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
|
|
765
|
+
ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
|
|
766
|
+
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", response: spotResult } });
|
|
767
|
+
}
|
|
768
|
+
catch (spotErr) {
|
|
769
|
+
const msg = spotErr instanceof Error ? spotErr.message : String(spotErr);
|
|
770
|
+
ctx.log(` [ARB] Open spot-perp failed (spot buy) for ${opp.symbol}: ${msg}`);
|
|
771
|
+
logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "spot" } });
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
663
774
|
// Step 3: Short perp
|
|
664
|
-
|
|
665
|
-
|
|
775
|
+
try {
|
|
776
|
+
const perpResult = await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
|
|
777
|
+
ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
|
|
778
|
+
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", response: perpResult } });
|
|
779
|
+
}
|
|
780
|
+
catch (perpErr) {
|
|
781
|
+
const msg = perpErr instanceof Error ? perpErr.message : String(perpErr);
|
|
782
|
+
ctx.log(` [ARB] Open spot-perp failed (perp short) for ${opp.symbol}: ${msg} — ROLLING BACK spot buy`);
|
|
783
|
+
logExecution({ type: "arb_entry", exchange: opp.shortExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "perp" } });
|
|
784
|
+
// Rollback: sell spot back and transfer USDC back to perp
|
|
785
|
+
try {
|
|
786
|
+
await spotAdapter.spotMarketOrder(opp.symbol, "sell", matched.size);
|
|
787
|
+
ctx.log(` [ARB] Rollback success: sold spot ${matched.size} ${opp.symbol}`);
|
|
788
|
+
logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
|
|
789
|
+
// Transfer USDC back to perp account
|
|
790
|
+
try {
|
|
791
|
+
await this.transferUsdcToPerp(spotAdapter, opp.longExchange.replace("-spot", ""), Math.ceil(matched.notional * 1.02));
|
|
792
|
+
}
|
|
793
|
+
catch { /* best effort */ }
|
|
794
|
+
}
|
|
795
|
+
catch (rbErr) {
|
|
796
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
797
|
+
ctx.log(` [ARB] CRITICAL: Spot rollback failed for ${opp.symbol}: ${rbMsg}`);
|
|
798
|
+
logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
666
802
|
// Track position
|
|
667
803
|
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
668
804
|
currentPositions.push({
|
|
@@ -676,7 +812,6 @@ export class FundingArbStrategy {
|
|
|
676
812
|
ctx.state.set("arbOpenPositions", currentPositions);
|
|
677
813
|
ctx.state.set("arbPositions", currentPositions.length);
|
|
678
814
|
ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
679
|
-
logExecution({ type: "arb_entry", exchange: `${opp.longExchange}↔${opp.shortExchange}`, symbol: opp.symbol, side: "spot-long/perp-short", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", spread: opp.spread } });
|
|
680
815
|
return true;
|
|
681
816
|
}
|
|
682
817
|
catch (err) {
|
|
@@ -787,6 +922,7 @@ export class FundingArbStrategy {
|
|
|
787
922
|
rate: parseFloat(m.fundingRate),
|
|
788
923
|
price: parseFloat(m.markPrice),
|
|
789
924
|
sizeDecimals: m.sizeDecimals,
|
|
925
|
+
maxLeverage: m.maxLeverage,
|
|
790
926
|
}));
|
|
791
927
|
}
|
|
792
928
|
catch {
|
package/dist/commands/bot.js
CHANGED
|
@@ -435,6 +435,11 @@ export function registerBotCommands(program, getAdapter, getAdapterFor, isJson)
|
|
|
435
435
|
.option("--config <path>", "YAML/JSON config file")
|
|
436
436
|
.option("--headless", "Run without TUI dashboard")
|
|
437
437
|
.option("--param <key=value>", "Strategy parameter (repeatable)", (val, acc) => [...acc, val], [])
|
|
438
|
+
.option("--max-positions <n>", "Max simultaneous positions")
|
|
439
|
+
.option("--size <usd>", "Position size per leg ($)")
|
|
440
|
+
.option("--min-spread <pct>", "Min annual spread to enter (%)")
|
|
441
|
+
.option("--close-spread <pct>", "Close when spread drops below (%)")
|
|
442
|
+
.option("--exchanges <list>", "Comma-separated exchanges")
|
|
438
443
|
.action(async (strategyName, symbol, opts) => {
|
|
439
444
|
if (!strategyName) {
|
|
440
445
|
await import("../bot/engine.js");
|
|
@@ -493,6 +498,17 @@ export function registerBotCommands(program, getAdapter, getAdapterFor, isJson)
|
|
|
493
498
|
else
|
|
494
499
|
params[key] = raw;
|
|
495
500
|
}
|
|
501
|
+
// Merge shorthand CLI options into params
|
|
502
|
+
if (opts.maxPositions)
|
|
503
|
+
params.max_positions = parseInt(opts.maxPositions);
|
|
504
|
+
if (opts.size)
|
|
505
|
+
params.size_usd = parseFloat(opts.size);
|
|
506
|
+
if (opts.minSpread)
|
|
507
|
+
params.min_spread = parseFloat(opts.minSpread);
|
|
508
|
+
if (opts.closeSpread)
|
|
509
|
+
params.close_spread = parseFloat(opts.closeSpread);
|
|
510
|
+
if (opts.exchanges)
|
|
511
|
+
params.exchanges = opts.exchanges;
|
|
496
512
|
const adapter = await getAdapter();
|
|
497
513
|
const strategy = parseStrategy(strategyName, params);
|
|
498
514
|
config = {
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -277,12 +277,19 @@ export class AsterAdapter {
|
|
|
277
277
|
}
|
|
278
278
|
// ── Trading ──
|
|
279
279
|
async marketOrder(symbol, side, size) {
|
|
280
|
-
|
|
280
|
+
const result = await this._signedPost("/fapi/v1/order", {
|
|
281
281
|
symbol: this._toApi(symbol),
|
|
282
282
|
side: side.toUpperCase(),
|
|
283
283
|
type: "MARKET",
|
|
284
284
|
quantity: size,
|
|
285
285
|
});
|
|
286
|
+
// Validate fill — Binance-style API returns 200 even for 0-fill orders
|
|
287
|
+
const r = result;
|
|
288
|
+
const executedQty = Number(r.executedQty ?? 0);
|
|
289
|
+
if (executedQty === 0) {
|
|
290
|
+
throw new Error(`Market ${side} ${symbol}: order accepted but 0 filled (status: ${r.status}, orderId: ${r.orderId})`);
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
286
293
|
}
|
|
287
294
|
async limitOrder(symbol, side, price, size, opts) {
|
|
288
295
|
const params = {
|
|
@@ -214,6 +214,7 @@ export class HyperliquidAdapter {
|
|
|
214
214
|
const result = universe.map((asset, i) => {
|
|
215
215
|
const ctx = (ctxs[i] ?? {});
|
|
216
216
|
const sym = String(asset.name);
|
|
217
|
+
const szDec = this._szDecimalsMap.get(sym) ?? (asset.szDecimals !== undefined ? Number(asset.szDecimals) : undefined);
|
|
217
218
|
return {
|
|
218
219
|
symbol: sym,
|
|
219
220
|
markPrice: String(ctx.markPx ?? mids[sym] ?? "0"),
|
|
@@ -222,6 +223,7 @@ export class HyperliquidAdapter {
|
|
|
222
223
|
volume24h: String(ctx.dayNtlVlm ?? "0"),
|
|
223
224
|
openInterest: String(ctx.openInterest ?? "0"),
|
|
224
225
|
maxLeverage: Number(asset.maxLeverage ?? 50),
|
|
226
|
+
sizeDecimals: szDec,
|
|
225
227
|
};
|
|
226
228
|
});
|
|
227
229
|
this._marketsCache = result;
|
|
@@ -266,6 +266,8 @@ export class LighterAdapter {
|
|
|
266
266
|
return this._marketsCache;
|
|
267
267
|
const markets = [];
|
|
268
268
|
try {
|
|
269
|
+
// Ensure _marketDecimals is populated (has supported_size_decimals per symbol)
|
|
270
|
+
await this.ensureMarketMap();
|
|
269
271
|
// Fetch order book details and funding rates in parallel
|
|
270
272
|
const [obRes, fundingRates] = await Promise.all([
|
|
271
273
|
this.restGet("/orderBookDetails", {}),
|
|
@@ -277,6 +279,7 @@ export class LighterAdapter {
|
|
|
277
279
|
const imf = d.min_initial_margin_fraction ?? d.default_initial_margin_fraction ?? 500;
|
|
278
280
|
const maxLev = imf > 0 ? Math.floor(10000 / imf) : 50;
|
|
279
281
|
const fr = fundingRates.get(d.symbol);
|
|
282
|
+
const dec = this._marketDecimals.get(d.symbol.toUpperCase());
|
|
280
283
|
markets.push({
|
|
281
284
|
symbol: d.symbol,
|
|
282
285
|
markPrice: fr?.markPrice ?? String(d.last_trade_price ?? 0),
|
|
@@ -285,6 +288,7 @@ export class LighterAdapter {
|
|
|
285
288
|
volume24h: String(d.daily_quote_token_volume ?? 0),
|
|
286
289
|
openInterest: String(d.open_interest ?? 0),
|
|
287
290
|
maxLeverage: maxLev,
|
|
291
|
+
sizeDecimals: dec?.size,
|
|
288
292
|
});
|
|
289
293
|
}
|
|
290
294
|
}
|
|
@@ -64,6 +64,9 @@ export class PacificaAdapter {
|
|
|
64
64
|
const priceMap = new Map(prices.map((p) => [p.symbol, p]));
|
|
65
65
|
const result = markets.map((m) => {
|
|
66
66
|
const p = priceMap.get(m.symbol);
|
|
67
|
+
// Derive sizeDecimals from lot_size (e.g. "0.001" → 3, "1" → 0)
|
|
68
|
+
const lotSize = parseFloat(m.lot_size ?? "0");
|
|
69
|
+
const sizeDecimals = lotSize > 0 ? Math.max(0, -Math.floor(Math.log10(lotSize))) : undefined;
|
|
67
70
|
return {
|
|
68
71
|
symbol: m.symbol,
|
|
69
72
|
markPrice: p?.mark ?? "-",
|
|
@@ -72,6 +75,8 @@ export class PacificaAdapter {
|
|
|
72
75
|
volume24h: p?.volume_24h ?? "-",
|
|
73
76
|
openInterest: p?.open_interest ?? "-",
|
|
74
77
|
maxLeverage: m.max_leverage,
|
|
78
|
+
sizeDecimals,
|
|
79
|
+
stepSize: lotSize > 0 ? m.lot_size : undefined,
|
|
75
80
|
};
|
|
76
81
|
});
|
|
77
82
|
this._marketsCache = result;
|