perp-cli 0.7.47 → 0.7.50

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.
@@ -201,12 +201,21 @@ export class FundingArbStrategy {
201
201
  const equity = parseFloat(bal.equity);
202
202
  const marginUsed = parseFloat(bal.marginUsed);
203
203
  const marginPct = equity > 0 ? (marginUsed / equity) * 100 : 100;
204
- exchangeBalances.set(name, { equity, available: parseFloat(bal.available), marginPct });
205
- if (marginPct < 90) {
206
- availableAdapters.set(name, a);
204
+ const available = parseFloat(bal.available);
205
+ exchangeBalances.set(name, { equity, available, marginPct });
206
+ // Skip if: margin > 90%, available < $5, or exchange on liquidation cooldown
207
+ const exchCooldown = this._failCooldown.get(`${name}:exchange`) ?? 0;
208
+ if (Date.now() < exchCooldown) {
209
+ ctx.log(` [ARB] ${name} on liquidation cooldown — skipping`);
210
+ }
211
+ else if (available < 5) {
212
+ ctx.log(` [ARB] ${name} available $${available.toFixed(2)} too low — skipping`);
213
+ }
214
+ else if (marginPct >= 90) {
215
+ ctx.log(` [ARB] ${name} margin ${marginPct.toFixed(0)}% — skipping`);
207
216
  }
208
217
  else {
209
- ctx.log(` [ARB] ${name} margin ${marginPct.toFixed(0)}% — skipping for this tick`);
218
+ availableAdapters.set(name, a);
210
219
  }
211
220
  }
212
221
  catch (err) {
@@ -334,7 +343,9 @@ export class FundingArbStrategy {
334
343
  const longAdapter = adapters.get(pos.longExchange);
335
344
  const shortAdapter = adapters.get(pos.shortExchange);
336
345
  if (longAdapter && shortAdapter) {
346
+ const isPositionGone = (msg) => msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
337
347
  let closeLongOk = false;
348
+ let longPositionGone = false;
338
349
  try {
339
350
  const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
340
351
  closeLongOk = true;
@@ -342,11 +353,20 @@ export class FundingArbStrategy {
342
353
  }
343
354
  catch (err) {
344
355
  const msg = err instanceof Error ? err.message : String(err);
345
- ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} (cooldown 5m)`);
346
- 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" } });
347
- this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
356
+ if (isPositionGone(msg)) {
357
+ // Position already liquidated/closed on this exchange
358
+ longPositionGone = true;
359
+ ctx.log(` [ARB] Long position gone for ${pos.symbol} on ${pos.longExchange} (liquidated?) — force closing short`);
360
+ // Cooldown the exchange for 30 minutes to prevent liquidation-entry cycle
361
+ this._failCooldown.set(`${pos.longExchange}:exchange`, Date.now() + 30 * 60 * 1000);
362
+ }
363
+ else {
364
+ ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} (cooldown 5m)`);
365
+ 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" } });
366
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
367
+ }
348
368
  }
349
- if (!closeLongOk)
369
+ if (!closeLongOk && !longPositionGone)
350
370
  continue;
351
371
  try {
352
372
  const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
@@ -354,25 +374,45 @@ export class FundingArbStrategy {
354
374
  }
355
375
  catch (err) {
356
376
  const msg = err instanceof Error ? err.message : String(err);
357
- ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
358
- 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" } });
359
- // Rollback: re-open long to restore hedge
360
- try {
361
- await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
362
- ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
363
- // Note: re-open is NOT reduceOnly (it's a new position)
364
- logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
377
+ if (isPositionGone(msg)) {
378
+ // Short position also gone — both liquidated, just clean up tracking
379
+ ctx.log(` [ARB] Short position also gone for ${pos.symbol} on ${pos.shortExchange} — removing from tracking`);
380
+ this._failCooldown.set(`${pos.shortExchange}:exchange`, Date.now() + 30 * 60 * 1000);
365
381
  }
366
- catch (rbErr) {
367
- const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
368
- ctx.log(` [ARB] CRITICAL: Close rollback failed for ${pos.symbol}: ${rbMsg}`);
369
- logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
382
+ else if (closeLongOk) {
383
+ // Long was closed but short failed (not liquidated) — rollback long
384
+ ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
385
+ 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" } });
386
+ try {
387
+ await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
388
+ ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
389
+ logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
390
+ }
391
+ catch (rbErr) {
392
+ const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
393
+ ctx.log(` [ARB] CRITICAL: Close rollback failed for ${pos.symbol}: ${rbMsg}`);
394
+ logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
395
+ }
396
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
397
+ continue;
398
+ }
399
+ else {
400
+ // Long was gone, short failed (not liquidated) — orphaned short, force close without reduceOnly
401
+ ctx.log(` [ARB] Force closing orphaned short for ${pos.symbol}: ${msg}`);
402
+ try {
403
+ await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
404
+ ctx.log(` [ARB] Force closed short ${pos.size} ${pos.symbol} on ${pos.shortExchange}`);
405
+ }
406
+ catch (forceErr) {
407
+ const forceMsg = forceErr instanceof Error ? forceErr.message : String(forceErr);
408
+ ctx.log(` [ARB] CRITICAL: Force close short failed for ${pos.symbol}: ${forceMsg}`);
409
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 10 * 60 * 1000);
410
+ continue;
411
+ }
370
412
  }
371
- this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
372
- continue;
373
413
  }
374
414
  toClose.push(pos);
375
- ctx.log(` [ARB] Closed ${pos.symbol} position`);
415
+ ctx.log(` [ARB] Closed ${pos.symbol} position${longPositionGone ? ' (long was liquidated)' : ''}`);
376
416
  }
377
417
  }
378
418
  }
@@ -595,20 +635,7 @@ export class FundingArbStrategy {
595
635
  const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
596
636
  ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
597
637
  logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
598
- // Track orphaned long position for manual cleanup
599
- const currentPositions = ctx.state.get("arbOpenPositions");
600
- currentPositions.push({
601
- symbol: best.symbol,
602
- longExchange: best.longExchange,
603
- shortExchange: best.shortExchange, // note: short leg doesn't exist
604
- entrySpread: 0,
605
- size: matched.size,
606
- mode: "perp-perp",
607
- entryTime: 0, // flag as orphaned
608
- });
609
- ctx.state.set("arbOpenPositions", currentPositions);
610
- ctx.state.set("arbPositions", currentPositions.length);
611
- ctx.log(` [ARB] Tracking orphaned long position for manual cleanup`);
638
+ ctx.log(` [ARB] CRITICAL: Orphaned long position on ${best.longExchange} for ${best.symbol} — operator must close manually`);
612
639
  }
613
640
  this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
614
641
  continue;
@@ -951,6 +978,11 @@ export class FundingArbStrategy {
951
978
  const msg = spotErr instanceof Error ? spotErr.message : String(spotErr);
952
979
  ctx.log(` [ARB] Open spot-perp failed (spot buy) for ${opp.symbol}: ${msg}`);
953
980
  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" } });
981
+ try {
982
+ await this.transferUsdcToPerp(spotAdapter, exchangeName, transferAmt);
983
+ ctx.log(` [ARB] Transferred $${transferAmt} USDC back to perp after spot buy failure`);
984
+ }
985
+ catch { /* best effort */ }
954
986
  return false;
955
987
  }
956
988
  // Step 3: Short perp
@@ -1115,9 +1147,12 @@ export class FundingArbStrategy {
1115
1147
  // Bootstrap aster funding hours lazily (1 API call per unknown symbol, cached permanently)
1116
1148
  if (exchangeName === "aster" && "getFundingHours" in adapter) {
1117
1149
  const aster = adapter;
1118
- // Only bootstrap symbols not yet cached (up to 5 per tick to limit API calls)
1119
- const uncached = withRates.filter(m => m.fundingHours === undefined || m.fundingHours === 1);
1120
- const toBootstrap = uncached.slice(0, 5);
1150
+ // Only bootstrap symbols not yet cached (up to 20 per tick to complete faster)
1151
+ const uncached = withRates.filter(m => {
1152
+ const cached = adapter?._fundingHoursCache?.get?.(m.symbol);
1153
+ return cached === undefined; // only truly uncached, not symbols confirmed as 1h
1154
+ });
1155
+ const toBootstrap = uncached.slice(0, 20);
1121
1156
  for (const m of toBootstrap) {
1122
1157
  const fh = await aster.getFundingHours(m.symbol);
1123
1158
  m.fundingHours = fh;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.47",
3
+ "version": "0.7.50",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",