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.
@@ -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 2;
62
+ default: return 0; // conservative: integers when unknown
62
63
  }
63
64
  }
64
65
  /** Minimum notional (USD) per exchange */
@@ -41,6 +41,7 @@ export interface FundingArbStrategyParams {
41
41
  size_usd: number;
42
42
  max_positions: number;
43
43
  exchanges: string[];
44
+ leverage?: number;
44
45
  }
45
46
  export interface GenericStrategyParams {
46
47
  type: string;
@@ -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 Promise.all([
279
- longAdapter.marketOrder(pos.symbol, "sell", pos.size),
280
- shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
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
- // Balance check
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 maxAffordable = minAvail * 0.8;
421
- if (maxAffordable < 10) {
422
- ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available)`);
423
- continue;
424
- }
425
- if (targetSizeUsd > maxAffordable) {
426
- ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
427
- targetSizeUsd = Math.floor(maxAffordable);
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
- 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)}%)`);
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 Promise.all([
447
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
448
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
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 (orderErr) {
452
- const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
453
- const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
454
- if (lotMatch) {
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
- catch (reconErr) {
480
- const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
481
- ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
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 (err) {
500
- const msg = err instanceof Error ? err.message : String(err);
501
- ctx.log(` [ARB] Execution failed for ${best.symbol}: ${msg}`);
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
- ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
533
- await Promise.all([
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
- // Balance check: need full notional for spot + margin for perp
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
- const perpAvail = parseFloat(perpBal.available);
633
- const maxAffordable = perpAvail * 0.8;
634
- if (maxAffordable < 10) {
635
- ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: insufficient balance ($${perpAvail.toFixed(2)} available)`);
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
- if (targetSizeUsd > maxAffordable) {
639
- ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance)`);
640
- targetSizeUsd = Math.floor(maxAffordable);
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
- await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
670
- ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
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
- await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
673
- ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
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 {
@@ -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 = {
@@ -277,12 +277,53 @@ export class AsterAdapter {
277
277
  }
278
278
  // ── Trading ──
279
279
  async marketOrder(symbol, side, size) {
280
- return this._signedPost("/fapi/v1/order", {
281
- symbol: this._toApi(symbol),
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.24",
3
+ "version": "0.7.32",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",