perp-cli 0.7.24 → 0.7.32
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 +236 -95
- package/dist/commands/bot.js +16 -0
- package/dist/exchanges/aster.js +43 -2
- 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 {
|
|
@@ -130,7 +132,7 @@ export class FundingArbStrategy {
|
|
|
130
132
|
continue;
|
|
131
133
|
}
|
|
132
134
|
const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
|
|
133
|
-
const spotBal = nonUsdc.find(b => b.token.toUpperCase() === base);
|
|
135
|
+
const spotBal = nonUsdc.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === base);
|
|
134
136
|
ctx.log(` [ARB] ${name} perp ${perp.symbol}(${perp.side}) → base=${base}, spotMatch=${spotBal ? spotBal.token : "none"}`);
|
|
135
137
|
if (spotBal && perp.side === "short") {
|
|
136
138
|
recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp" });
|
|
@@ -191,7 +193,7 @@ export class FundingArbStrategy {
|
|
|
191
193
|
const rates = await this.fetchRates(a, name);
|
|
192
194
|
const map = new Map();
|
|
193
195
|
for (const r of rates)
|
|
194
|
-
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 });
|
|
195
197
|
ratesByExchange.set(name, map);
|
|
196
198
|
}
|
|
197
199
|
// ── Fetch funding income periodically (every 5 minutes) ──
|
|
@@ -264,6 +266,11 @@ export class FundingArbStrategy {
|
|
|
264
266
|
// Close if funding flipped (losing money) or spread below close threshold
|
|
265
267
|
const shouldClose = signedSpread < 0 || signedSpread < params.close_spread;
|
|
266
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;
|
|
267
274
|
const sign = signedSpread >= 0 ? "+" : "";
|
|
268
275
|
ctx.log(` [ARB] Closing ${pos.symbol}: signed spread ${sign}${signedSpread.toFixed(1)}% (close_spread=${params.close_spread}%, entry=${pos.entrySpread.toFixed(1)}%)`);
|
|
269
276
|
if (pos.mode === "spot-perp") {
|
|
@@ -274,19 +281,44 @@ export class FundingArbStrategy {
|
|
|
274
281
|
const longAdapter = adapters.get(pos.longExchange);
|
|
275
282
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
276
283
|
if (longAdapter && shortAdapter) {
|
|
284
|
+
let closeLongOk = false;
|
|
277
285
|
try {
|
|
278
|
-
await
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
]);
|
|
282
|
-
toClose.push(pos);
|
|
283
|
-
ctx.log(` [ARB] Closed ${pos.symbol} position`);
|
|
284
|
-
logExecution({ type: "arb_close", exchange: `${pos.longExchange}↔${pos.shortExchange}`, symbol: pos.symbol, side: "close", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, signedSpread: signedSpread } });
|
|
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 } });
|
|
285
289
|
}
|
|
286
290
|
catch (err) {
|
|
287
291
|
const msg = err instanceof Error ? err.message : String(err);
|
|
288
|
-
ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
|
|
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
|
+
if (!closeLongOk)
|
|
297
|
+
continue; // don't close short if long failed — keep hedge intact
|
|
298
|
+
try {
|
|
299
|
+
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
300
|
+
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 } });
|
|
289
301
|
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
304
|
+
ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
|
|
305
|
+
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" } });
|
|
306
|
+
// Rollback: re-open long to restore hedge
|
|
307
|
+
try {
|
|
308
|
+
await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
309
|
+
ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
|
|
310
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
|
|
311
|
+
}
|
|
312
|
+
catch (rbErr) {
|
|
313
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
314
|
+
ctx.log(` [ARB] CRITICAL: Close rollback failed for ${pos.symbol}: ${rbMsg}`);
|
|
315
|
+
logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
|
|
316
|
+
}
|
|
317
|
+
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
toClose.push(pos);
|
|
321
|
+
ctx.log(` [ARB] Closed ${pos.symbol} position`);
|
|
290
322
|
}
|
|
291
323
|
}
|
|
292
324
|
}
|
|
@@ -391,11 +423,20 @@ export class FundingArbStrategy {
|
|
|
391
423
|
if (arbPositions < params.max_positions) {
|
|
392
424
|
// Skip symbols already in open positions
|
|
393
425
|
const openSymbols = new Set(ctx.state.get("arbOpenPositions").map(p => p.symbol.toUpperCase()));
|
|
426
|
+
let skipMargin = 0;
|
|
427
|
+
let skipCooldown = 0;
|
|
394
428
|
for (const best of opportunities) {
|
|
395
429
|
if (openSymbols.has(best.symbol.toUpperCase()))
|
|
396
430
|
continue;
|
|
431
|
+
// Cooldown: skip if entry recently failed for this symbol
|
|
432
|
+
const entryKey = `${best.symbol}:entry`;
|
|
433
|
+
const entryCooldown = this._failCooldown.get(entryKey) ?? 0;
|
|
434
|
+
if (Date.now() < entryCooldown) {
|
|
435
|
+
skipCooldown++;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
397
438
|
if (best.mode === "spot-perp") {
|
|
398
|
-
const opened = await this.openSpotPerp(best, adapters, ctx);
|
|
439
|
+
const opened = await this.openSpotPerp(best, adapters, ctx, ratesByExchange);
|
|
399
440
|
if (opened)
|
|
400
441
|
return [{ type: "noop" }];
|
|
401
442
|
continue;
|
|
@@ -407,24 +448,49 @@ export class FundingArbStrategy {
|
|
|
407
448
|
const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
|
|
408
449
|
if (price <= 0)
|
|
409
450
|
continue;
|
|
410
|
-
//
|
|
451
|
+
// Determine leverage for this symbol
|
|
452
|
+
const configLeverage = params.leverage ?? 3;
|
|
453
|
+
const longMaxLev = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.maxLeverage ?? 1;
|
|
454
|
+
const shortMaxLev = ratesByExchange.get(best.shortExchange)?.get(best.symbol)?.maxLeverage ?? 1;
|
|
455
|
+
const leverage = Math.min(configLeverage, longMaxLev, shortMaxLev);
|
|
456
|
+
// Set leverage on both exchanges before ordering
|
|
457
|
+
try {
|
|
458
|
+
await Promise.all([
|
|
459
|
+
longAdapter.setLeverage(best.symbol, leverage, "cross"),
|
|
460
|
+
shortAdapter.setLeverage(best.symbol, leverage, "cross"),
|
|
461
|
+
]);
|
|
462
|
+
}
|
|
463
|
+
catch { /* non-critical, some exchanges set leverage on order */ }
|
|
464
|
+
// Balance + margin check
|
|
411
465
|
let targetSizeUsd = params.size_usd;
|
|
412
466
|
try {
|
|
413
467
|
const [longBal, shortBal] = await Promise.all([
|
|
414
468
|
longAdapter.getBalance(),
|
|
415
469
|
shortAdapter.getBalance(),
|
|
416
470
|
]);
|
|
471
|
+
// Check margin usage (skip if > 80%)
|
|
472
|
+
const longEquity = parseFloat(longBal.equity);
|
|
473
|
+
const shortEquity = parseFloat(shortBal.equity);
|
|
474
|
+
const longMarginPct = longEquity > 0 ? parseFloat(longBal.marginUsed) / longEquity * 100 : 0;
|
|
475
|
+
const shortMarginPct = shortEquity > 0 ? parseFloat(shortBal.marginUsed) / shortEquity * 100 : 0;
|
|
476
|
+
if (longMarginPct > 80 || shortMarginPct > 80) {
|
|
477
|
+
skipMargin++;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
// Required margin per leg = sizeUsd / leverage
|
|
417
481
|
const longAvail = parseFloat(longBal.available);
|
|
418
482
|
const shortAvail = parseFloat(shortBal.available);
|
|
419
483
|
const minAvail = Math.min(longAvail, shortAvail);
|
|
420
|
-
const
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
484
|
+
const requiredMarginPerLeg = targetSizeUsd / leverage;
|
|
485
|
+
if (minAvail < requiredMarginPerLeg) {
|
|
486
|
+
// Try reducing size to what's affordable
|
|
487
|
+
const maxAffordableNotional = minAvail * leverage * 0.8;
|
|
488
|
+
if (maxAffordableNotional < 10) {
|
|
489
|
+
ctx.log(` [ARB] Skip ${best.symbol}: insufficient margin ($${minAvail.toFixed(2)} < $${requiredMarginPerLeg.toFixed(2)} needed at ${leverage}x)`);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordableNotional.toFixed(0)} (limited by margin $${minAvail.toFixed(2)} at ${leverage}x)`);
|
|
493
|
+
targetSizeUsd = Math.floor(maxAffordableNotional);
|
|
428
494
|
}
|
|
429
495
|
}
|
|
430
496
|
catch { /* non-critical, proceed with original size */ }
|
|
@@ -440,68 +506,77 @@ export class FundingArbStrategy {
|
|
|
440
506
|
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
441
507
|
continue;
|
|
442
508
|
}
|
|
509
|
+
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)}%)`);
|
|
510
|
+
// Step 1: Execute long leg first
|
|
511
|
+
let longResult;
|
|
443
512
|
try {
|
|
444
|
-
|
|
513
|
+
longResult = await longAdapter.marketOrder(best.symbol, "buy", matched.size);
|
|
514
|
+
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 } });
|
|
515
|
+
}
|
|
516
|
+
catch (longErr) {
|
|
517
|
+
const msg = longErr instanceof Error ? longErr.message : String(longErr);
|
|
518
|
+
ctx.log(` [ARB] Long leg failed for ${best.symbol}: ${msg} (cooldown 5m)`);
|
|
519
|
+
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" } });
|
|
520
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
// Step 2: Execute short leg
|
|
524
|
+
try {
|
|
525
|
+
const shortResult = await shortAdapter.marketOrder(best.symbol, "sell", matched.size);
|
|
526
|
+
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 } });
|
|
527
|
+
}
|
|
528
|
+
catch (shortErr) {
|
|
529
|
+
const msg = shortErr instanceof Error ? shortErr.message : String(shortErr);
|
|
530
|
+
ctx.log(` [ARB] Short leg failed for ${best.symbol}: ${msg} — ROLLING BACK long leg (cooldown 5m)`);
|
|
531
|
+
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" } });
|
|
532
|
+
// Rollback: close the long position
|
|
445
533
|
try {
|
|
446
|
-
await
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
]);
|
|
534
|
+
await longAdapter.marketOrder(best.symbol, "sell", matched.size);
|
|
535
|
+
ctx.log(` [ARB] Rollback success: closed long ${matched.size} ${best.symbol} on ${best.longExchange}`);
|
|
536
|
+
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
|
|
450
537
|
}
|
|
451
|
-
catch (
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const lotSize = parseFloat(lotMatch[1]);
|
|
456
|
-
ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
|
|
457
|
-
matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize, longSizeDecimals: longDec, shortSizeDecimals: shortDec });
|
|
458
|
-
if (!matched) {
|
|
459
|
-
ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
|
|
463
|
-
await Promise.all([
|
|
464
|
-
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
465
|
-
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
466
|
-
]);
|
|
467
|
-
}
|
|
468
|
-
else {
|
|
469
|
-
throw orderErr;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
// Verify fills match
|
|
473
|
-
try {
|
|
474
|
-
const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
|
|
475
|
-
if (!recon.matched) {
|
|
476
|
-
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
477
|
-
}
|
|
538
|
+
catch (rbErr) {
|
|
539
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
540
|
+
ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
|
|
541
|
+
logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
|
|
478
542
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
543
|
+
this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
// Verify fills match
|
|
547
|
+
try {
|
|
548
|
+
const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
|
|
549
|
+
if (!recon.matched) {
|
|
550
|
+
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
482
551
|
}
|
|
483
|
-
// Track position
|
|
484
|
-
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
485
|
-
currentPositions.push({
|
|
486
|
-
symbol: best.symbol,
|
|
487
|
-
longExchange: best.longExchange,
|
|
488
|
-
shortExchange: best.shortExchange,
|
|
489
|
-
entrySpread: best.spread,
|
|
490
|
-
size: matched.size,
|
|
491
|
-
mode: "perp-perp",
|
|
492
|
-
});
|
|
493
|
-
ctx.state.set("arbOpenPositions", currentPositions);
|
|
494
|
-
ctx.state.set("arbPositions", currentPositions.length);
|
|
495
|
-
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
496
|
-
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 } });
|
|
497
|
-
return [{ type: "noop" }];
|
|
498
552
|
}
|
|
499
|
-
catch (
|
|
500
|
-
const
|
|
501
|
-
ctx.log(` [ARB]
|
|
502
|
-
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" } });
|
|
503
|
-
continue;
|
|
553
|
+
catch (reconErr) {
|
|
554
|
+
const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
|
|
555
|
+
ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
|
|
504
556
|
}
|
|
557
|
+
// Track position
|
|
558
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
559
|
+
currentPositions.push({
|
|
560
|
+
symbol: best.symbol,
|
|
561
|
+
longExchange: best.longExchange,
|
|
562
|
+
shortExchange: best.shortExchange,
|
|
563
|
+
entrySpread: best.spread,
|
|
564
|
+
size: matched.size,
|
|
565
|
+
mode: "perp-perp",
|
|
566
|
+
});
|
|
567
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
568
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
569
|
+
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
570
|
+
return [{ type: "noop" }];
|
|
571
|
+
}
|
|
572
|
+
// Log skip summary only when there were skips (avoids noise when nothing happened)
|
|
573
|
+
if (skipMargin > 0 || skipCooldown > 0) {
|
|
574
|
+
const parts = [];
|
|
575
|
+
if (skipMargin > 0)
|
|
576
|
+
parts.push(`${skipMargin} margin`);
|
|
577
|
+
if (skipCooldown > 0)
|
|
578
|
+
parts.push(`${skipCooldown} cooldown`);
|
|
579
|
+
ctx.log(` [ARB] Skipped ${skipMargin + skipCooldown} opportunities (${parts.join(", ")})`);
|
|
505
580
|
}
|
|
506
581
|
}
|
|
507
582
|
}
|
|
@@ -528,16 +603,24 @@ export class FundingArbStrategy {
|
|
|
528
603
|
const longAdapter = adapters.get(pos.longExchange);
|
|
529
604
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
530
605
|
if (longAdapter && shortAdapter) {
|
|
606
|
+
ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
|
|
531
607
|
try {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
longAdapter.marketOrder(pos.symbol, "sell", pos.size),
|
|
535
|
-
shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
|
|
536
|
-
]);
|
|
608
|
+
const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
|
|
609
|
+
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 } });
|
|
537
610
|
}
|
|
538
611
|
catch (err) {
|
|
539
612
|
const msg = err instanceof Error ? err.message : String(err);
|
|
540
|
-
ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
|
|
613
|
+
ctx.log(` [ARB] Close on stop failed (long) for ${pos.symbol}: ${msg}`);
|
|
614
|
+
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 } });
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
|
|
618
|
+
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 } });
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
622
|
+
ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg}`);
|
|
623
|
+
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 } });
|
|
541
624
|
}
|
|
542
625
|
}
|
|
543
626
|
}
|
|
@@ -611,7 +694,7 @@ export class FundingArbStrategy {
|
|
|
611
694
|
}
|
|
612
695
|
}
|
|
613
696
|
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
614
|
-
async openSpotPerp(opp, adapters, ctx) {
|
|
697
|
+
async openSpotPerp(opp, adapters, ctx, ratesByExchange) {
|
|
615
698
|
const params = this.params;
|
|
616
699
|
const exchangeName = opp.longExchange.replace("-spot", "");
|
|
617
700
|
const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
|
|
@@ -625,19 +708,42 @@ export class FundingArbStrategy {
|
|
|
625
708
|
ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
626
709
|
return false;
|
|
627
710
|
}
|
|
628
|
-
//
|
|
711
|
+
// Determine leverage for perp leg
|
|
712
|
+
const configLeverage = params.leverage ?? 3;
|
|
713
|
+
const perpSymbolUpper = this.getPerpSymbol(opp.symbol, opp.shortExchange).toUpperCase();
|
|
714
|
+
const perpMaxLev = ratesByExchange.get(opp.shortExchange)?.get(perpSymbolUpper)?.maxLeverage
|
|
715
|
+
?? ratesByExchange.get(opp.shortExchange)?.get(opp.symbol.toUpperCase())?.maxLeverage
|
|
716
|
+
?? 1;
|
|
717
|
+
const leverage = Math.min(configLeverage, perpMaxLev);
|
|
718
|
+
// Set leverage on perp before ordering
|
|
719
|
+
try {
|
|
720
|
+
await perpAdapter.setLeverage(this.getPerpSymbol(opp.symbol, opp.shortExchange), leverage, "cross");
|
|
721
|
+
}
|
|
722
|
+
catch { /* non-critical */ }
|
|
723
|
+
// Balance check: spot needs full notional, perp needs sizeUsd / leverage margin
|
|
629
724
|
let targetSizeUsd = params.size_usd;
|
|
630
725
|
try {
|
|
631
726
|
const perpBal = await perpAdapter.getBalance();
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
727
|
+
// Check margin usage
|
|
728
|
+
const perpEquity = parseFloat(perpBal.equity);
|
|
729
|
+
const perpMarginPct = perpEquity > 0 ? parseFloat(perpBal.marginUsed) / perpEquity * 100 : 0;
|
|
730
|
+
if (perpMarginPct > 80) {
|
|
731
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: perp margin usage too high (${perpMarginPct.toFixed(0)}%)`);
|
|
636
732
|
return false;
|
|
637
733
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
734
|
+
const perpAvail = parseFloat(perpBal.available);
|
|
735
|
+
// Total needed from perp account: spot notional (to transfer) + perp margin
|
|
736
|
+
const perpMarginRequired = targetSizeUsd / leverage;
|
|
737
|
+
const totalNeeded = targetSizeUsd + perpMarginRequired;
|
|
738
|
+
if (perpAvail < totalNeeded) {
|
|
739
|
+
// Solve for max affordable: perpAvail >= notional + notional/lev = notional*(1 + 1/lev)
|
|
740
|
+
const maxNotional = perpAvail * 0.8 / (1 + 1 / leverage);
|
|
741
|
+
if (maxNotional < 10) {
|
|
742
|
+
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: insufficient balance ($${perpAvail.toFixed(2)} available, need $${totalNeeded.toFixed(2)})`);
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxNotional.toFixed(0)} (limited by balance at ${leverage}x)`);
|
|
746
|
+
targetSizeUsd = Math.floor(maxNotional);
|
|
641
747
|
}
|
|
642
748
|
}
|
|
643
749
|
catch { /* non-critical */ }
|
|
@@ -666,11 +772,46 @@ export class FundingArbStrategy {
|
|
|
666
772
|
await this.transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
|
|
667
773
|
ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
|
|
668
774
|
// Step 2: Buy spot
|
|
669
|
-
|
|
670
|
-
|
|
775
|
+
let spotResult;
|
|
776
|
+
try {
|
|
777
|
+
spotResult = await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
|
|
778
|
+
ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
|
|
779
|
+
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 } });
|
|
780
|
+
}
|
|
781
|
+
catch (spotErr) {
|
|
782
|
+
const msg = spotErr instanceof Error ? spotErr.message : String(spotErr);
|
|
783
|
+
ctx.log(` [ARB] Open spot-perp failed (spot buy) for ${opp.symbol}: ${msg}`);
|
|
784
|
+
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" } });
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
671
787
|
// Step 3: Short perp
|
|
672
|
-
|
|
673
|
-
|
|
788
|
+
try {
|
|
789
|
+
const perpResult = await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
|
|
790
|
+
ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
|
|
791
|
+
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 } });
|
|
792
|
+
}
|
|
793
|
+
catch (perpErr) {
|
|
794
|
+
const msg = perpErr instanceof Error ? perpErr.message : String(perpErr);
|
|
795
|
+
ctx.log(` [ARB] Open spot-perp failed (perp short) for ${opp.symbol}: ${msg} — ROLLING BACK spot buy`);
|
|
796
|
+
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" } });
|
|
797
|
+
// Rollback: sell spot back and transfer USDC back to perp
|
|
798
|
+
try {
|
|
799
|
+
await spotAdapter.spotMarketOrder(opp.symbol, "sell", matched.size);
|
|
800
|
+
ctx.log(` [ARB] Rollback success: sold spot ${matched.size} ${opp.symbol}`);
|
|
801
|
+
logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
|
|
802
|
+
// Transfer USDC back to perp account
|
|
803
|
+
try {
|
|
804
|
+
await this.transferUsdcToPerp(spotAdapter, opp.longExchange.replace("-spot", ""), Math.ceil(matched.notional * 1.02));
|
|
805
|
+
}
|
|
806
|
+
catch { /* best effort */ }
|
|
807
|
+
}
|
|
808
|
+
catch (rbErr) {
|
|
809
|
+
const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
|
|
810
|
+
ctx.log(` [ARB] CRITICAL: Spot rollback failed for ${opp.symbol}: ${rbMsg}`);
|
|
811
|
+
logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
|
|
812
|
+
}
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
674
815
|
// Track position
|
|
675
816
|
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
676
817
|
currentPositions.push({
|
|
@@ -684,7 +825,6 @@ export class FundingArbStrategy {
|
|
|
684
825
|
ctx.state.set("arbOpenPositions", currentPositions);
|
|
685
826
|
ctx.state.set("arbPositions", currentPositions.length);
|
|
686
827
|
ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
687
|
-
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 } });
|
|
688
828
|
return true;
|
|
689
829
|
}
|
|
690
830
|
catch (err) {
|
|
@@ -795,6 +935,7 @@ export class FundingArbStrategy {
|
|
|
795
935
|
rate: parseFloat(m.fundingRate),
|
|
796
936
|
price: parseFloat(m.markPrice),
|
|
797
937
|
sizeDecimals: m.sizeDecimals,
|
|
938
|
+
maxLeverage: m.maxLeverage,
|
|
798
939
|
}));
|
|
799
940
|
}
|
|
800
941
|
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,53 @@ export class AsterAdapter {
|
|
|
277
277
|
}
|
|
278
278
|
// ── Trading ──
|
|
279
279
|
async marketOrder(symbol, side, size) {
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
const apiSymbol = this._toApi(symbol);
|
|
281
|
+
const result = await this._signedPost("/fapi/v1/order", {
|
|
282
|
+
symbol: apiSymbol,
|
|
282
283
|
side: side.toUpperCase(),
|
|
283
284
|
type: "MARKET",
|
|
284
285
|
quantity: size,
|
|
285
286
|
});
|
|
287
|
+
const r = result;
|
|
288
|
+
const executedQty = Number(r.executedQty ?? 0);
|
|
289
|
+
const orderId = String(r.orderId ?? "");
|
|
290
|
+
// If immediately filled, return
|
|
291
|
+
if (executedQty > 0 && r.status === "FILLED")
|
|
292
|
+
return result;
|
|
293
|
+
// status=NEW: order accepted but not yet filled — poll for up to 3 seconds
|
|
294
|
+
if (r.status === "NEW" && orderId) {
|
|
295
|
+
for (let i = 0; i < 3; i++) {
|
|
296
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
297
|
+
try {
|
|
298
|
+
const check = await this._signedGet("/fapi/v1/order", {
|
|
299
|
+
symbol: apiSymbol,
|
|
300
|
+
orderId,
|
|
301
|
+
});
|
|
302
|
+
const filledQty = Number(check.executedQty ?? 0);
|
|
303
|
+
if (filledQty > 0)
|
|
304
|
+
return check; // filled
|
|
305
|
+
if (check.status === "CANCELED" || check.status === "EXPIRED" || check.status === "REJECTED") {
|
|
306
|
+
throw new Error(`Market ${side} ${symbol}: order ${check.status} (orderId: ${orderId})`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
if (e instanceof Error && e.message.startsWith("Market "))
|
|
311
|
+
throw e;
|
|
312
|
+
// query failed, retry
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Still not filled after 3s — cancel and throw
|
|
316
|
+
try {
|
|
317
|
+
await this._signedDelete("/fapi/v1/order", { symbol: apiSymbol, orderId });
|
|
318
|
+
}
|
|
319
|
+
catch { /* best effort cancel */ }
|
|
320
|
+
throw new Error(`Market ${side} ${symbol}: order not filled after 3s, cancelled (orderId: ${orderId})`);
|
|
321
|
+
}
|
|
322
|
+
// executedQty === 0 with non-NEW status
|
|
323
|
+
if (executedQty === 0) {
|
|
324
|
+
throw new Error(`Market ${side} ${symbol}: order accepted but 0 filled (status: ${r.status}, orderId: ${orderId})`);
|
|
325
|
+
}
|
|
326
|
+
return result;
|
|
286
327
|
}
|
|
287
328
|
async limitOrder(symbol, side, price, size, opts) {
|
|
288
329
|
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;
|