perp-cli 0.7.24 → 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.
@@ -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,18 +281,30 @@ 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 } });
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 } });
285
299
  }
286
300
  catch (err) {
287
301
  const msg = err instanceof Error ? err.message : String(err);
288
- 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`);
289
308
  }
290
309
  }
291
310
  }
@@ -391,11 +410,20 @@ export class FundingArbStrategy {
391
410
  if (arbPositions < params.max_positions) {
392
411
  // Skip symbols already in open positions
393
412
  const openSymbols = new Set(ctx.state.get("arbOpenPositions").map(p => p.symbol.toUpperCase()));
413
+ let skipMargin = 0;
414
+ let skipCooldown = 0;
394
415
  for (const best of opportunities) {
395
416
  if (openSymbols.has(best.symbol.toUpperCase()))
396
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
+ }
397
425
  if (best.mode === "spot-perp") {
398
- const opened = await this.openSpotPerp(best, adapters, ctx);
426
+ const opened = await this.openSpotPerp(best, adapters, ctx, ratesByExchange);
399
427
  if (opened)
400
428
  return [{ type: "noop" }];
401
429
  continue;
@@ -407,24 +435,49 @@ export class FundingArbStrategy {
407
435
  const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
408
436
  if (price <= 0)
409
437
  continue;
410
- // Balance check
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
411
452
  let targetSizeUsd = params.size_usd;
412
453
  try {
413
454
  const [longBal, shortBal] = await Promise.all([
414
455
  longAdapter.getBalance(),
415
456
  shortAdapter.getBalance(),
416
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
417
468
  const longAvail = parseFloat(longBal.available);
418
469
  const shortAvail = parseFloat(shortBal.available);
419
470
  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);
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);
428
481
  }
429
482
  }
430
483
  catch { /* non-critical, proceed with original size */ }
@@ -440,68 +493,77 @@ export class FundingArbStrategy {
440
493
  ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
441
494
  continue;
442
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;
443
499
  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)}%)`);
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
445
520
  try {
446
- await Promise.all([
447
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
448
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
449
- ]);
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 });
450
524
  }
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
- }
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 });
478
529
  }
479
- catch (reconErr) {
480
- const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
481
- ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
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`);
482
538
  }
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
539
  }
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;
540
+ catch (reconErr) {
541
+ const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
542
+ ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
504
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(", ")})`);
505
567
  }
506
568
  }
507
569
  }
@@ -528,16 +590,24 @@ export class FundingArbStrategy {
528
590
  const longAdapter = adapters.get(pos.longExchange);
529
591
  const shortAdapter = adapters.get(pos.shortExchange);
530
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
+ }
531
603
  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
- ]);
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 } });
537
606
  }
538
607
  catch (err) {
539
608
  const msg = err instanceof Error ? err.message : String(err);
540
- 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 } });
541
611
  }
542
612
  }
543
613
  }
@@ -611,7 +681,7 @@ export class FundingArbStrategy {
611
681
  }
612
682
  }
613
683
  /** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
614
- async openSpotPerp(opp, adapters, ctx) {
684
+ async openSpotPerp(opp, adapters, ctx, ratesByExchange) {
615
685
  const params = this.params;
616
686
  const exchangeName = opp.longExchange.replace("-spot", "");
617
687
  const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
@@ -625,19 +695,42 @@ export class FundingArbStrategy {
625
695
  ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
626
696
  return false;
627
697
  }
628
- // Balance check: need full notional for spot + margin for perp
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
629
711
  let targetSizeUsd = params.size_usd;
630
712
  try {
631
713
  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)`);
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)}%)`);
636
719
  return false;
637
720
  }
638
- if (targetSizeUsd > maxAffordable) {
639
- ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance)`);
640
- targetSizeUsd = Math.floor(maxAffordable);
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);
641
734
  }
642
735
  }
643
736
  catch { /* non-critical */ }
@@ -666,11 +759,46 @@ export class FundingArbStrategy {
666
759
  await this.transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
667
760
  ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
668
761
  // Step 2: Buy spot
669
- await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
670
- ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
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
+ }
671
774
  // Step 3: Short perp
672
- await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
673
- ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
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
+ }
674
802
  // Track position
675
803
  const currentPositions = ctx.state.get("arbOpenPositions");
676
804
  currentPositions.push({
@@ -684,7 +812,6 @@ export class FundingArbStrategy {
684
812
  ctx.state.set("arbOpenPositions", currentPositions);
685
813
  ctx.state.set("arbPositions", currentPositions.length);
686
814
  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
815
  return true;
689
816
  }
690
817
  catch (err) {
@@ -795,6 +922,7 @@ export class FundingArbStrategy {
795
922
  rate: parseFloat(m.fundingRate),
796
923
  price: parseFloat(m.markPrice),
797
924
  sizeDecimals: m.sizeDecimals,
925
+ maxLeverage: m.maxLeverage,
798
926
  }));
799
927
  }
800
928
  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,19 @@ export class AsterAdapter {
277
277
  }
278
278
  // ── Trading ──
279
279
  async marketOrder(symbol, side, size) {
280
- return this._signedPost("/fapi/v1/order", {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.24",
3
+ "version": "0.7.30",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",