perp-cli 0.7.37 → 0.7.42

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.
@@ -42,6 +42,7 @@ export interface FundingArbStrategyParams {
42
42
  max_positions: number;
43
43
  exchanges: string[];
44
44
  leverage?: number;
45
+ min_hold_hours?: number;
45
46
  }
46
47
  export interface GenericStrategyParams {
47
48
  type: string;
@@ -30,7 +30,7 @@ function parseBotConfig(raw) {
30
30
  const name = String(raw.name ?? `bot-${Date.now()}`);
31
31
  const exchange = String(raw.exchange ?? "hyperliquid");
32
32
  const symbol = String(raw.symbol ?? "ETH");
33
- const monitorInterval = Number(raw.monitor_interval_sec ?? raw.interval ?? 10);
33
+ const monitorInterval = Number(raw.monitor_interval_sec ?? raw.interval ?? 30);
34
34
  // Strategy
35
35
  const stratRaw = (raw.strategy ?? raw.params ?? {});
36
36
  const stratType = String(stratRaw.type ?? raw.strategy_type ?? "grid");
@@ -131,7 +131,7 @@ export function quickGridConfig(opts) {
131
131
  ...(opts.maxRuntime ? [{ type: "time_after", value: opts.maxRuntime }] : []),
132
132
  ],
133
133
  risk: { ...DEFAULT_RISK, max_drawdown: opts.maxDrawdown },
134
- monitor_interval_sec: 10,
134
+ monitor_interval_sec: 30,
135
135
  };
136
136
  }
137
137
  export function quickDCAConfig(opts) {
@@ -58,7 +58,7 @@ const gridStandard = {
58
58
  { type: "volatility_above", value: 12 },
59
59
  ],
60
60
  risk: { ...DEFAULT_RISK, max_drawdown: 100, max_daily_loss: 50 },
61
- monitor_interval_sec: 10,
61
+ monitor_interval_sec: 30,
62
62
  }),
63
63
  };
64
64
  const gridAggressive = {
@@ -111,7 +111,7 @@ const gridLongBias = {
111
111
  entry_conditions: [{ type: "always", value: 0 }],
112
112
  exit_conditions: [],
113
113
  risk: { ...DEFAULT_RISK, max_drawdown: 150, max_daily_loss: 75 },
114
- monitor_interval_sec: 10,
114
+ monitor_interval_sec: 30,
115
115
  }),
116
116
  };
117
117
  // ── DCA Presets ──
@@ -16,6 +16,7 @@ export class FundingArbStrategy {
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
18
  { name: "leverage", type: "number", required: false, default: 3, description: "Max leverage to use" },
19
+ { name: "min_hold_hours", type: "number", required: false, default: 2, description: "Min hold time before close (hours)" },
19
20
  ],
20
21
  };
21
22
  }
@@ -88,7 +89,7 @@ export class FundingArbStrategy {
88
89
  const longEx = pA.side === "long" ? exA : exB;
89
90
  const shortEx = pA.side === "short" ? exA : exB;
90
91
  const size = pA.side === "long" ? pA.size : pB.size;
91
- recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size, mode: "perp-perp" });
92
+ recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size, mode: "perp-perp", entryTime: 0 });
92
93
  used.add(keyA);
93
94
  used.add(keyB);
94
95
  break;
@@ -135,7 +136,7 @@ export class FundingArbStrategy {
135
136
  const spotBal = nonUsdc.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === base);
136
137
  ctx.log(` [ARB] ${name} perp ${perp.symbol}(${perp.side}) → base=${base}, spotMatch=${spotBal ? spotBal.token : "none"}`);
137
138
  if (spotBal && perp.side === "short") {
138
- recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp" });
139
+ recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp", entryTime: 0 });
139
140
  used.add(perpKey);
140
141
  ctx.log(` [ARB] Detected spot-perp hedge: ${base} ${name}-spot↔${name}`);
141
142
  }
@@ -189,9 +190,44 @@ export class FundingArbStrategy {
189
190
  ctx.log(` [ARB] Need 2+ exchanges, have ${adapters.size}. Skipping.`);
190
191
  return [];
191
192
  }
192
- // Fetch rates from all exchanges
193
- const ratesByExchange = new Map();
193
+ // ── Pre-filter exchanges by margin availability ──
194
+ const exchangeBalances = new Map();
195
+ const availableAdapters = new Map();
194
196
  for (const [name, a] of adapters) {
197
+ try {
198
+ const bal = await a.getBalance();
199
+ const equity = parseFloat(bal.equity);
200
+ const marginUsed = parseFloat(bal.marginUsed);
201
+ const marginPct = equity > 0 ? (marginUsed / equity) * 100 : 100;
202
+ exchangeBalances.set(name, { equity, available: parseFloat(bal.available), marginPct });
203
+ if (marginPct < 70) {
204
+ availableAdapters.set(name, a);
205
+ }
206
+ else {
207
+ ctx.log(` [ARB] ${name} margin ${marginPct.toFixed(0)}% — skipping for this tick`);
208
+ }
209
+ }
210
+ catch {
211
+ availableAdapters.set(name, a); // on error, include it
212
+ }
213
+ }
214
+ // Determine which exchanges need rate fetching.
215
+ // When at max_positions we only need rates for exchanges that hold open positions
216
+ // (to evaluate close conditions). Exchanges with no positions can be skipped.
217
+ const atMaxPositions = openPositions.length >= (params.max_positions ?? 3);
218
+ const positionExchanges = new Set();
219
+ if (atMaxPositions) {
220
+ for (const p of openPositions) {
221
+ positionExchanges.add(p.longExchange.replace(/-spot$/i, ""));
222
+ positionExchanges.add(p.shortExchange);
223
+ }
224
+ }
225
+ const exchangesToScan = atMaxPositions
226
+ ? [...adapters].filter(([name]) => positionExchanges.has(name))
227
+ : [...adapters];
228
+ // Fetch rates — all exchanges if room for new positions, only position exchanges if full
229
+ const ratesByExchange = new Map();
230
+ for (const [name, a] of exchangesToScan) {
195
231
  const rates = await this.fetchRates(a, name);
196
232
  const map = new Map();
197
233
  for (const r of rates)
@@ -273,7 +309,10 @@ export class FundingArbStrategy {
273
309
  signedSpread = (shortHourly - longHourly) * 8760 * 100;
274
310
  }
275
311
  // Close if funding flipped (losing money) or spread below close threshold
276
- const shouldClose = signedSpread < 0 || signedSpread < params.close_spread;
312
+ const minHoldMs = (params.min_hold_hours ?? 2) * 60 * 60 * 1000;
313
+ const holdTime = pos.entryTime > 0 ? Date.now() - pos.entryTime : Infinity;
314
+ const isFundingFlipped = signedSpread < 0;
315
+ const shouldClose = isFundingFlipped || (holdTime >= minHoldMs && signedSpread < params.close_spread);
277
316
  if (shouldClose) {
278
317
  // Cooldown: skip if close recently failed for this symbol
279
318
  const closeKey = `${pos.symbol}:close`;
@@ -341,6 +380,7 @@ export class FundingArbStrategy {
341
380
  // ── Find new opportunities ──
342
381
  const arbPositions = ctx.state.get("arbOpenPositions").length;
343
382
  const exchangeNames = [...ratesByExchange.keys()];
383
+ const scanExchangeNames = exchangeNames.filter(n => availableAdapters.has(n));
344
384
  const opportunities = [];
345
385
  const allSymbols = new Set();
346
386
  for (const [, map] of ratesByExchange) {
@@ -352,7 +392,7 @@ export class FundingArbStrategy {
352
392
  let minHourly = Infinity, maxHourly = -Infinity;
353
393
  let minExchange = "", maxExchange = "";
354
394
  let minRate = 0, maxRate = 0;
355
- for (const exName of exchangeNames) {
395
+ for (const exName of scanExchangeNames) {
356
396
  const rateInfo = this.findRate(ratesByExchange, exName, sym);
357
397
  if (!rateInfo)
358
398
  continue;
@@ -438,7 +478,6 @@ export class FundingArbStrategy {
438
478
  if (arbPositions < params.max_positions) {
439
479
  // Skip symbols already in open positions
440
480
  const openSymbols = new Set(ctx.state.get("arbOpenPositions").map(p => p.symbol.toUpperCase()));
441
- let skipMargin = 0;
442
481
  let skipCooldown = 0;
443
482
  for (const best of opportunities) {
444
483
  if (openSymbols.has(best.symbol.toUpperCase()))
@@ -476,39 +515,24 @@ export class FundingArbStrategy {
476
515
  ]);
477
516
  }
478
517
  catch { /* non-critical, some exchanges set leverage on order */ }
479
- // Balance + margin check
518
+ // Balance + margin check (use pre-fetched balances from tick start)
480
519
  let targetSizeUsd = params.size_usd;
481
- try {
482
- const [longBal, shortBal] = await Promise.all([
483
- longAdapter.getBalance(),
484
- shortAdapter.getBalance(),
485
- ]);
486
- // Check margin usage (skip if > 70%)
487
- const longEquity = parseFloat(longBal.equity);
488
- const shortEquity = parseFloat(shortBal.equity);
489
- const longMarginPct = longEquity > 0 ? parseFloat(longBal.marginUsed) / longEquity * 100 : 0;
490
- const shortMarginPct = shortEquity > 0 ? parseFloat(shortBal.marginUsed) / shortEquity * 100 : 0;
491
- if (longMarginPct > 70 || shortMarginPct > 70) {
492
- skipMargin++;
520
+ const longBal = exchangeBalances.get(best.longExchange);
521
+ const shortBal = exchangeBalances.get(best.shortExchange);
522
+ if (!longBal || !shortBal)
523
+ continue;
524
+ const minAvail = Math.min(longBal.available, shortBal.available);
525
+ const requiredMarginPerLeg = targetSizeUsd / leverage;
526
+ if (minAvail < requiredMarginPerLeg) {
527
+ // Try reducing size to what's affordable
528
+ const maxAffordableNotional = minAvail * leverage * 0.8;
529
+ if (maxAffordableNotional < 10) {
530
+ ctx.log(` [ARB] Skip ${best.symbol}: insufficient margin ($${minAvail.toFixed(2)} < $${requiredMarginPerLeg.toFixed(2)} at ${leverage}x)`);
493
531
  continue;
494
532
  }
495
- // Required margin per leg = sizeUsd / leverage
496
- const longAvail = parseFloat(longBal.available);
497
- const shortAvail = parseFloat(shortBal.available);
498
- const minAvail = Math.min(longAvail, shortAvail);
499
- const requiredMarginPerLeg = targetSizeUsd / leverage;
500
- if (minAvail < requiredMarginPerLeg) {
501
- // Try reducing size to what's affordable
502
- const maxAffordableNotional = minAvail * leverage * 0.8;
503
- if (maxAffordableNotional < 10) {
504
- ctx.log(` [ARB] Skip ${best.symbol}: insufficient margin ($${minAvail.toFixed(2)} < $${requiredMarginPerLeg.toFixed(2)} needed at ${leverage}x)`);
505
- continue;
506
- }
507
- ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordableNotional.toFixed(0)} (limited by margin $${minAvail.toFixed(2)} at ${leverage}x)`);
508
- targetSizeUsd = Math.floor(maxAffordableNotional);
509
- }
533
+ ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordableNotional.toFixed(0)} (limited by margin $${minAvail.toFixed(2)} at ${leverage}x)`);
534
+ targetSizeUsd = Math.floor(maxAffordableNotional);
510
535
  }
511
- catch { /* non-critical, proceed with original size */ }
512
536
  // Liquidity check
513
537
  const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
514
538
  if (!liq.viable)
@@ -570,15 +594,19 @@ export class FundingArbStrategy {
570
594
  ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
571
595
  }
572
596
  // Verify both positions actually exist (catches instant liquidation)
573
- // Wait for cache to expire before checking (exchanges cache positions for ~5s)
574
- await new Promise(resolve => setTimeout(resolve, 6000));
597
+ // Invalidate cache so getPositions returns fresh data
598
+ const { invalidateCache } = await import("../../cache.js");
599
+ invalidateCache("acct");
600
+ await new Promise(resolve => setTimeout(resolve, 2000)); // brief wait for exchange settlement
575
601
  try {
576
602
  const [longPositions, shortPositions] = await Promise.all([
577
603
  longAdapter.getPositions(),
578
604
  shortAdapter.getPositions(),
579
605
  ]);
580
- const longExists = longPositions.some(p => p.symbol.toUpperCase() === best.symbol.toUpperCase() && parseFloat(p.size) > 0);
581
- const shortExists = shortPositions.some(p => p.symbol.toUpperCase() === best.symbol.toUpperCase() && parseFloat(p.size) > 0);
606
+ const symUpper = best.symbol.toUpperCase();
607
+ const matchSymbol = (s) => { const u = s.toUpperCase(); return u === symUpper || u === symUpper + "-PERP" || u.replace(/-PERP$/, "") === symUpper; };
608
+ const longExists = longPositions.some(p => matchSymbol(p.symbol) && parseFloat(p.size) > 0);
609
+ const shortExists = shortPositions.some(p => matchSymbol(p.symbol) && parseFloat(p.size) > 0);
582
610
  if (!longExists && !shortExists) {
583
611
  // Both missing likely means cache issue — log warning but don't rollback
584
612
  ctx.log(` [ARB] Position verification: both sides not visible for ${best.symbol} (may be cache delay)`);
@@ -617,6 +645,7 @@ export class FundingArbStrategy {
617
645
  entrySpread: best.spread,
618
646
  size: matched.size,
619
647
  mode: "perp-perp",
648
+ entryTime: Date.now(),
620
649
  });
621
650
  ctx.state.set("arbOpenPositions", currentPositions);
622
651
  ctx.state.set("arbPositions", currentPositions.length);
@@ -624,13 +653,8 @@ export class FundingArbStrategy {
624
653
  return [{ type: "noop" }];
625
654
  }
626
655
  // Log skip summary only when there were skips (avoids noise when nothing happened)
627
- if (skipMargin > 0 || skipCooldown > 0) {
628
- const parts = [];
629
- if (skipMargin > 0)
630
- parts.push(`${skipMargin} margin`);
631
- if (skipCooldown > 0)
632
- parts.push(`${skipCooldown} cooldown`);
633
- ctx.log(` [ARB] Skipped ${skipMargin + skipCooldown} opportunities (${parts.join(", ")})`);
656
+ if (skipCooldown > 0) {
657
+ ctx.log(` [ARB] Skipped ${skipCooldown} opportunities (${skipCooldown} cooldown)`);
634
658
  }
635
659
  }
636
660
  }
@@ -875,6 +899,7 @@ export class FundingArbStrategy {
875
899
  entrySpread: opp.spread,
876
900
  size: matched.size,
877
901
  mode: "spot-perp",
902
+ entryTime: Date.now(),
878
903
  });
879
904
  ctx.state.set("arbOpenPositions", currentPositions);
880
905
  ctx.state.set("arbPositions", currentPositions.length);
package/dist/cache.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Dashboard and CLI share cached data via /tmp/perp-cli-cache/.
5
5
  * Writes are atomic (write to .tmp then rename) to prevent partial reads.
6
6
  */
7
- export declare const TTL_ACCOUNT = 5000;
7
+ export declare const TTL_ACCOUNT = 15000;
8
8
  export declare const TTL_MARKET = 30000;
9
9
  export declare function getCached<T>(key: string): T | null;
10
10
  export declare function setCached<T>(key: string, data: T, ttlMs: number): void;
package/dist/cache.js CHANGED
@@ -9,7 +9,7 @@ import { join } from "path";
9
9
  import { tmpdir } from "os";
10
10
  const CACHE_DIR = join(tmpdir(), "perp-cli-cache");
11
11
  // TTL presets
12
- export const TTL_ACCOUNT = 5_000; // 5s — balance, positions, orders
12
+ export const TTL_ACCOUNT = 15_000; // 15s — balance, positions, orders (ticks are 30s; 15s ensures same-tick dedup)
13
13
  export const TTL_MARKET = 30_000; // 30s — funding rates, prices, markets
14
14
  let dirReady = false;
15
15
  function ensureDir() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.37",
3
+ "version": "0.7.42",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",