perp-cli 0.7.23 → 0.7.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -119,15 +121,19 @@ export class FundingArbStrategy {
119
121
  }
120
122
  }
121
123
  const nonUsdc = spotBalances.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
124
+ ctx.log(` [ARB] ${name} spot balances: ${nonUsdc.map(b => `${b.token}=${b.total}`).join(", ") || "none"}`);
122
125
  if (nonUsdc.length === 0)
123
126
  continue;
124
127
  const perpPositions = positionsByExchange.get(name) ?? [];
125
128
  for (const perp of perpPositions) {
126
129
  const perpKey = `${name}:${perp.symbol}`;
127
- if (used.has(perpKey))
130
+ if (used.has(perpKey)) {
131
+ ctx.log(` [ARB] ${perpKey} already matched cross-exchange, skip spot-perp`);
128
132
  continue;
133
+ }
129
134
  const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
130
- const spotBal = nonUsdc.find(b => b.token.toUpperCase() === base);
135
+ const spotBal = nonUsdc.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === base);
136
+ ctx.log(` [ARB] ${name} perp ${perp.symbol}(${perp.side}) → base=${base}, spotMatch=${spotBal ? spotBal.token : "none"}`);
131
137
  if (spotBal && perp.side === "short") {
132
138
  recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp" });
133
139
  used.add(perpKey);
@@ -187,7 +193,7 @@ export class FundingArbStrategy {
187
193
  const rates = await this.fetchRates(a, name);
188
194
  const map = new Map();
189
195
  for (const r of rates)
190
- map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals });
196
+ map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage });
191
197
  ratesByExchange.set(name, map);
192
198
  }
193
199
  // ── Fetch funding income periodically (every 5 minutes) ──
@@ -260,6 +266,11 @@ export class FundingArbStrategy {
260
266
  // Close if funding flipped (losing money) or spread below close threshold
261
267
  const shouldClose = signedSpread < 0 || signedSpread < params.close_spread;
262
268
  if (shouldClose) {
269
+ // Cooldown: skip if close recently failed for this symbol
270
+ const closeKey = `${pos.symbol}:close`;
271
+ const cooldownUntil = this._failCooldown.get(closeKey) ?? 0;
272
+ if (Date.now() < cooldownUntil)
273
+ continue;
263
274
  const sign = signedSpread >= 0 ? "+" : "";
264
275
  ctx.log(` [ARB] Closing ${pos.symbol}: signed spread ${sign}${signedSpread.toFixed(1)}% (close_spread=${params.close_spread}%, entry=${pos.entrySpread.toFixed(1)}%)`);
265
276
  if (pos.mode === "spot-perp") {
@@ -270,18 +281,30 @@ export class FundingArbStrategy {
270
281
  const longAdapter = adapters.get(pos.longExchange);
271
282
  const shortAdapter = adapters.get(pos.shortExchange);
272
283
  if (longAdapter && shortAdapter) {
284
+ let closeLongOk = false;
273
285
  try {
274
- await Promise.all([
275
- longAdapter.marketOrder(pos.symbol, "sell", pos.size),
276
- shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
277
- ]);
278
- toClose.push(pos);
279
- ctx.log(` [ARB] Closed ${pos.symbol} position`);
280
- 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 } });
281
299
  }
282
300
  catch (err) {
283
301
  const msg = err instanceof Error ? err.message : String(err);
284
- ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
302
+ ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg}`);
303
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short" } });
304
+ }
305
+ if (closeLongOk) {
306
+ toClose.push(pos);
307
+ ctx.log(` [ARB] Closed ${pos.symbol} position`);
285
308
  }
286
309
  }
287
310
  }
@@ -385,9 +408,22 @@ export class FundingArbStrategy {
385
408
  }
386
409
  // Auto-execute: try each opportunity until one succeeds
387
410
  if (arbPositions < params.max_positions) {
411
+ // Skip symbols already in open positions
412
+ const openSymbols = new Set(ctx.state.get("arbOpenPositions").map(p => p.symbol.toUpperCase()));
413
+ let skipMargin = 0;
414
+ let skipCooldown = 0;
388
415
  for (const best of opportunities) {
416
+ if (openSymbols.has(best.symbol.toUpperCase()))
417
+ continue;
418
+ // Cooldown: skip if entry recently failed for this symbol
419
+ const entryKey = `${best.symbol}:entry`;
420
+ const entryCooldown = this._failCooldown.get(entryKey) ?? 0;
421
+ if (Date.now() < entryCooldown) {
422
+ skipCooldown++;
423
+ continue;
424
+ }
389
425
  if (best.mode === "spot-perp") {
390
- const opened = await this.openSpotPerp(best, adapters, ctx);
426
+ const opened = await this.openSpotPerp(best, adapters, ctx, ratesByExchange);
391
427
  if (opened)
392
428
  return [{ type: "noop" }];
393
429
  continue;
@@ -399,24 +435,49 @@ export class FundingArbStrategy {
399
435
  const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
400
436
  if (price <= 0)
401
437
  continue;
402
- // 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
403
452
  let targetSizeUsd = params.size_usd;
404
453
  try {
405
454
  const [longBal, shortBal] = await Promise.all([
406
455
  longAdapter.getBalance(),
407
456
  shortAdapter.getBalance(),
408
457
  ]);
458
+ // Check margin usage (skip if > 80%)
459
+ const longEquity = parseFloat(longBal.equity);
460
+ const shortEquity = parseFloat(shortBal.equity);
461
+ const longMarginPct = longEquity > 0 ? parseFloat(longBal.marginUsed) / longEquity * 100 : 0;
462
+ const shortMarginPct = shortEquity > 0 ? parseFloat(shortBal.marginUsed) / shortEquity * 100 : 0;
463
+ if (longMarginPct > 80 || shortMarginPct > 80) {
464
+ skipMargin++;
465
+ continue;
466
+ }
467
+ // Required margin per leg = sizeUsd / leverage
409
468
  const longAvail = parseFloat(longBal.available);
410
469
  const shortAvail = parseFloat(shortBal.available);
411
470
  const minAvail = Math.min(longAvail, shortAvail);
412
- const maxAffordable = minAvail * 0.8;
413
- if (maxAffordable < 10) {
414
- ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available)`);
415
- continue;
416
- }
417
- if (targetSizeUsd > maxAffordable) {
418
- ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
419
- 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);
420
481
  }
421
482
  }
422
483
  catch { /* non-critical, proceed with original size */ }
@@ -432,68 +493,77 @@ export class FundingArbStrategy {
432
493
  ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
433
494
  continue;
434
495
  }
496
+ ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
497
+ // Step 1: Execute long leg first
498
+ let longResult;
435
499
  try {
436
- 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
437
520
  try {
438
- await Promise.all([
439
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
440
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
441
- ]);
442
- }
443
- catch (orderErr) {
444
- const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
445
- const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
446
- if (lotMatch) {
447
- const lotSize = parseFloat(lotMatch[1]);
448
- ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
449
- matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize, longSizeDecimals: longDec, shortSizeDecimals: shortDec });
450
- if (!matched) {
451
- ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
452
- continue;
453
- }
454
- ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
455
- await Promise.all([
456
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
457
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
458
- ]);
459
- }
460
- else {
461
- throw orderErr;
462
- }
521
+ await longAdapter.marketOrder(best.symbol, "sell", matched.size);
522
+ ctx.log(` [ARB] Rollback success: closed long ${matched.size} ${best.symbol} on ${best.longExchange}`);
523
+ logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
463
524
  }
464
- // Verify fills match
465
- try {
466
- const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
467
- if (!recon.matched) {
468
- ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
469
- }
525
+ catch (rbErr) {
526
+ const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
527
+ ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
528
+ logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
470
529
  }
471
- catch (reconErr) {
472
- const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
473
- 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`);
474
538
  }
475
- // Track position
476
- const currentPositions = ctx.state.get("arbOpenPositions");
477
- currentPositions.push({
478
- symbol: best.symbol,
479
- longExchange: best.longExchange,
480
- shortExchange: best.shortExchange,
481
- entrySpread: best.spread,
482
- size: matched.size,
483
- mode: "perp-perp",
484
- });
485
- ctx.state.set("arbOpenPositions", currentPositions);
486
- ctx.state.set("arbPositions", currentPositions.length);
487
- ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
488
- logExecution({ type: "arb_entry", exchange: `${best.longExchange}↔${best.shortExchange}`, symbol: best.symbol, side: "long/short", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "perp-perp", spread: best.spread } });
489
- return [{ type: "noop" }];
490
539
  }
491
- catch (err) {
492
- const msg = err instanceof Error ? err.message : String(err);
493
- ctx.log(` [ARB] Execution failed for ${best.symbol}: ${msg}`);
494
- logExecution({ type: "arb_entry", exchange: `${best.longExchange}↔${best.shortExchange}`, symbol: best.symbol, side: "long/short", size: matched?.size ?? "0", status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp" } });
495
- continue;
540
+ catch (reconErr) {
541
+ const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
542
+ ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
496
543
  }
544
+ // Track position
545
+ const currentPositions = ctx.state.get("arbOpenPositions");
546
+ currentPositions.push({
547
+ symbol: best.symbol,
548
+ longExchange: best.longExchange,
549
+ shortExchange: best.shortExchange,
550
+ entrySpread: best.spread,
551
+ size: matched.size,
552
+ mode: "perp-perp",
553
+ });
554
+ ctx.state.set("arbOpenPositions", currentPositions);
555
+ ctx.state.set("arbPositions", currentPositions.length);
556
+ ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
557
+ return [{ type: "noop" }];
558
+ }
559
+ // Log skip summary only when there were skips (avoids noise when nothing happened)
560
+ if (skipMargin > 0 || skipCooldown > 0) {
561
+ const parts = [];
562
+ if (skipMargin > 0)
563
+ parts.push(`${skipMargin} margin`);
564
+ if (skipCooldown > 0)
565
+ parts.push(`${skipCooldown} cooldown`);
566
+ ctx.log(` [ARB] Skipped ${skipMargin + skipCooldown} opportunities (${parts.join(", ")})`);
497
567
  }
498
568
  }
499
569
  }
@@ -520,16 +590,24 @@ export class FundingArbStrategy {
520
590
  const longAdapter = adapters.get(pos.longExchange);
521
591
  const shortAdapter = adapters.get(pos.shortExchange);
522
592
  if (longAdapter && shortAdapter) {
593
+ ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
594
+ try {
595
+ const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
596
+ logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "long", stop: true, response: closeLongResult } });
597
+ }
598
+ catch (err) {
599
+ const msg = err instanceof Error ? err.message : String(err);
600
+ ctx.log(` [ARB] Close on stop failed (long) for ${pos.symbol}: ${msg}`);
601
+ logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "long", stop: true } });
602
+ }
523
603
  try {
524
- ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
525
- await Promise.all([
526
- longAdapter.marketOrder(pos.symbol, "sell", pos.size),
527
- shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
528
- ]);
604
+ const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
605
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "short", stop: true, response: closeShortResult } });
529
606
  }
530
607
  catch (err) {
531
608
  const msg = err instanceof Error ? err.message : String(err);
532
- ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
609
+ ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg}`);
610
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short", stop: true } });
533
611
  }
534
612
  }
535
613
  }
@@ -603,7 +681,7 @@ export class FundingArbStrategy {
603
681
  }
604
682
  }
605
683
  /** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
606
- async openSpotPerp(opp, adapters, ctx) {
684
+ async openSpotPerp(opp, adapters, ctx, ratesByExchange) {
607
685
  const params = this.params;
608
686
  const exchangeName = opp.longExchange.replace("-spot", "");
609
687
  const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
@@ -617,19 +695,42 @@ export class FundingArbStrategy {
617
695
  ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
618
696
  return false;
619
697
  }
620
- // 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
621
711
  let targetSizeUsd = params.size_usd;
622
712
  try {
623
713
  const perpBal = await perpAdapter.getBalance();
624
- const perpAvail = parseFloat(perpBal.available);
625
- const maxAffordable = perpAvail * 0.8;
626
- if (maxAffordable < 10) {
627
- 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)}%)`);
628
719
  return false;
629
720
  }
630
- if (targetSizeUsd > maxAffordable) {
631
- ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance)`);
632
- 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);
633
734
  }
634
735
  }
635
736
  catch { /* non-critical */ }
@@ -658,11 +759,46 @@ export class FundingArbStrategy {
658
759
  await this.transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
659
760
  ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
660
761
  // Step 2: Buy spot
661
- await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
662
- 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
+ }
663
774
  // Step 3: Short perp
664
- await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
665
- 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
+ }
666
802
  // Track position
667
803
  const currentPositions = ctx.state.get("arbOpenPositions");
668
804
  currentPositions.push({
@@ -676,7 +812,6 @@ export class FundingArbStrategy {
676
812
  ctx.state.set("arbOpenPositions", currentPositions);
677
813
  ctx.state.set("arbPositions", currentPositions.length);
678
814
  ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);
679
- logExecution({ type: "arb_entry", exchange: `${opp.longExchange}↔${opp.shortExchange}`, symbol: opp.symbol, side: "spot-long/perp-short", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", spread: opp.spread } });
680
815
  return true;
681
816
  }
682
817
  catch (err) {
@@ -787,6 +922,7 @@ export class FundingArbStrategy {
787
922
  rate: parseFloat(m.fundingRate),
788
923
  price: parseFloat(m.markPrice),
789
924
  sizeDecimals: m.sizeDecimals,
925
+ maxLeverage: m.maxLeverage,
790
926
  }));
791
927
  }
792
928
  catch {
@@ -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.23",
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",