perp-cli 0.9.7 → 0.9.8

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.
@@ -262,8 +262,16 @@ export async function fetchRates(adapter, exchangeName) {
262
262
  m.fundingHours = await aster.getFundingHours(m.symbol);
263
263
  }
264
264
  }
265
+ // For non-aster exchanges, set fundingHours from static map if not already set
266
+ if (exchangeName !== "aster") {
267
+ const defaultFH = getFundingHours(exchangeName);
268
+ for (const m of withRates) {
269
+ if (m.fundingHours == null)
270
+ m.fundingHours = defaultFH;
271
+ }
272
+ }
265
273
  return withRates
266
- .filter(m => m.fundingHours != null) // skip symbols with unknown funding period (aster not yet bootstrapped)
274
+ .filter(m => m.fundingHours != null) // skip aster symbols with unknown funding period
267
275
  .map(m => ({
268
276
  symbol: m.symbol,
269
277
  rate: parseFloat(m.fundingRate),
@@ -529,7 +529,18 @@ export class FundingArbV2Strategy {
529
529
  const pBal = exchangeBalances.get(opp.shortExchange);
530
530
  if (!sBal || !pBal)
531
531
  return false;
532
- const maxNtl = Math.min(sBal.available, pBal.available * leverage) * 0.8;
532
+ let spotAvail = sBal.available;
533
+ const spotExAdapter = adapters.get(spotExName);
534
+ if (spotExAdapter && !spotExAdapter.isUnifiedAccount) {
535
+ try {
536
+ const sb = await spotAdapter.getSpotBalances();
537
+ const su = sb.find(b => b.token.toUpperCase().startsWith("USDC"));
538
+ if (su)
539
+ spotAvail += parseFloat(su.available);
540
+ }
541
+ catch { /* non-critical */ }
542
+ }
543
+ const maxNtl = Math.min(spotAvail, pBal.available * leverage) * 0.8;
533
544
  if (maxNtl < targetSizeUsd) {
534
545
  if (maxNtl < 10)
535
546
  return false;
@@ -629,17 +640,15 @@ export class FundingArbV2Strategy {
629
640
  this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
630
641
  return false;
631
642
  }
632
- // Transfer USDC back (same-exchange)
633
- if (spotExName === pos.shortExchange) {
634
- try {
635
- const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
636
- const price = await getPriceEstimate(perpAdapter, perpSym, pos.symbol);
637
- const proceeds = price * parseFloat(pos.size) * 0.95;
638
- if (proceeds > 1)
639
- await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(proceeds));
640
- }
641
- catch { /* non-fatal */ }
643
+ // Transfer USDC back to perp (always — not just same-exchange)
644
+ try {
645
+ const spotBals = await spotAdapter.getSpotBalances();
646
+ const usdcBal = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
647
+ const usdcAmt = usdcBal ? parseFloat(usdcBal.total) : 0;
648
+ if (usdcAmt > 1)
649
+ await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(usdcAmt));
642
650
  }
651
+ catch { /* non-fatal */ }
643
652
  // Close perp short
644
653
  const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
645
654
  try {
@@ -35,7 +35,8 @@ export declare class SpotPerpArbStrategy implements Strategy {
35
35
  onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
36
36
  onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
37
37
  private scoreFundingLocal;
38
- private findBestPerp;
38
+ /** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
39
+ private findPerpCandidates;
39
40
  private calcAnnualized;
40
41
  private shouldClose;
41
42
  private tryOpenPosition;
@@ -125,7 +125,8 @@ export class SpotPerpArbStrategy {
125
125
  availableAdapters.set(name, a);
126
126
  }
127
127
  catch (err) {
128
- ctx.log(` [SPA] getBalance failed for ${name}: ${err instanceof Error ? err.message : String(err)}`);
128
+ const msg = (err instanceof Error ? err.message : String(err)).slice(0, 120);
129
+ ctx.log(` [SPA] getBalance failed for ${name}: ${msg}`);
129
130
  }
130
131
  }
131
132
  const ratesByExchange = new Map();
@@ -221,7 +222,8 @@ export class SpotPerpArbStrategy {
221
222
  const rcd = this._failCooldown.get(`${pos.id}:rotate`) ?? 0;
222
223
  if (Date.now() < rcd)
223
224
  continue;
224
- const best = await this.findBestPerp(pos.symbol, adapters, ratesByExchange);
225
+ const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange);
226
+ const best = candidates[0];
225
227
  if (!best || best.exchange === pos.perpExchange)
226
228
  continue;
227
229
  const currentInfo = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
@@ -277,10 +279,12 @@ export class SpotPerpArbStrategy {
277
279
  continue;
278
280
  if (Date.now() < (this._failCooldown.get(`${base}:entry`) ?? 0))
279
281
  continue;
280
- const best = await this.findBestPerp(base, adapters, ratesByExchange);
281
- if (!best || best.score.annualized < p.min_rate || best.score.consistency < p.min_consistency)
282
- continue;
283
- scoredOpps.push({ symbol: base, spotExchange: spotExName, perpExchange: best.exchange, annualized: best.score.annualized, score: best.score });
282
+ const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange);
283
+ for (const cand of candidates) {
284
+ if (cand.score.annualized < p.min_rate || cand.score.consistency < p.min_consistency)
285
+ continue;
286
+ scoredOpps.push({ symbol: base, spotExchange: spotExName, perpExchange: cand.exchange, annualized: cand.score.annualized, score: cand.score });
287
+ }
284
288
  }
285
289
  }
286
290
  catch { /* no spot */ }
@@ -350,8 +354,9 @@ export class SpotPerpArbStrategy {
350
354
  scoreFundingLocal(adapter, symbol, exchangeName) {
351
355
  return scoreFunding(adapter, symbol, exchangeName, this.params.history_periods, this._fundingScoreCache);
352
356
  }
353
- async findBestPerp(symbol, adapters, ratesByExchange) {
354
- let best = null;
357
+ /** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
358
+ async findPerpCandidates(symbol, adapters, ratesByExchange) {
359
+ const candidates = [];
355
360
  for (const [exName, adapter] of adapters) {
356
361
  const perpSym = getPerpSymbol(symbol, exName);
357
362
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
@@ -361,10 +366,10 @@ export class SpotPerpArbStrategy {
361
366
  ?? await this.scoreFundingLocal(adapter, symbol, exName);
362
367
  if (!score)
363
368
  continue;
364
- if (!best || score.annualized > best.score.annualized)
365
- best = { exchange: exName, rate: ri.rate, score };
369
+ candidates.push({ exchange: exName, rate: ri.rate, score });
366
370
  }
367
- return best;
371
+ candidates.sort((a, b) => b.score.annualized - a.score.annualized);
372
+ return candidates;
368
373
  }
369
374
  calcAnnualized(pos, ratesByExchange) {
370
375
  const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
@@ -466,15 +471,18 @@ export class SpotPerpArbStrategy {
466
471
  const pBal = exchangeBalances.get(perpExchange);
467
472
  if (!sBal || !pBal)
468
473
  return false;
469
- // Check spot USDC balance too (already in spot account, no transfer needed)
474
+ // Check spot USDC balance (only add if separate from perp — skip for unified accounts like HL)
470
475
  let spotAvailable = sBal.available;
471
- try {
472
- const spotBals = await spotAdapter.getSpotBalances();
473
- const spotUsdc = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
474
- if (spotUsdc)
475
- spotAvailable += parseFloat(spotUsdc.available);
476
+ const isUnified = spotExAdapter?.isUnifiedAccount ?? false;
477
+ if (!isUnified) {
478
+ try {
479
+ const spotBals = await spotAdapter.getSpotBalances();
480
+ const spotUsdc = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
481
+ if (spotUsdc)
482
+ spotAvailable += parseFloat(spotUsdc.available);
483
+ }
484
+ catch { /* non-critical */ }
476
485
  }
477
- catch { /* non-critical */ }
478
486
  const maxNtl = Math.min(spotAvailable, pBal.available * leverage) * 0.8;
479
487
  if (maxNtl < targetSizeUsd) {
480
488
  if (maxNtl < 10) {
@@ -121,8 +121,8 @@ export class SpotPerpExecutor {
121
121
  const postBals = await spotAdapter.getSpotBalances();
122
122
  const bought = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
123
123
  const boughtAmt = bought ? parseFloat(bought.total) : 0;
124
- if (boughtAmt < parseFloat(matched.size) * 0.5) {
125
- log(` [SPA] Spot buy returned OK but ${symbol} balance too low (${boughtAmt.toFixed(4)}) — aborting`);
124
+ if (boughtAmt < parseFloat(matched.size) * 0.8) {
125
+ log(` [SPA] Spot buy returned OK but ${symbol} balance too low (${boughtAmt.toFixed(4)} < ${matched.size} × 80%) — aborting`);
126
126
  if (actualTransferred > 0) {
127
127
  try {
128
128
  await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
@@ -223,8 +223,8 @@ export class SpotPerpExecutor {
223
223
  const postBals = await spotAdapter.getSpotBalances();
224
224
  const remaining = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
225
225
  const remainingAmt = remaining ? parseFloat(remaining.total) : 0;
226
- if (remainingAmt >= parseFloat(size) * 0.5) {
227
- log(` [SPA] CRITICAL: Spot sell returned OK but ${symbol} balance unchanged (${remainingAmt.toFixed(4)}) — re-hedging perp`);
226
+ if (remainingAmt >= parseFloat(size) * 0.8) {
227
+ log(` [SPA] CRITICAL: Spot sell returned OK but ${symbol} balance unchanged (${remainingAmt.toFixed(4)} >= ${size} × 80%) — re-hedging perp`);
228
228
  try {
229
229
  await perpAdapter.marketOrder(perpSym, "sell", size);
230
230
  }
@@ -457,7 +457,8 @@ export class AsterAdapter {
457
457
  }
458
458
  if (!res.ok) {
459
459
  const text = await res.text();
460
- throw new Error(`${method} ${path} failed (${res.status}): ${text.slice(0, 200)}`);
460
+ const clean = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
461
+ throw new Error(`${method} ${path} failed (${res.status}): ${clean}`);
461
462
  }
462
463
  return res.json();
463
464
  }
@@ -5,6 +5,7 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
5
5
  readonly name = "hyperliquid";
6
6
  readonly chain = "evm";
7
7
  readonly aliases: readonly ["hl"];
8
+ readonly isUnifiedAccount = true;
8
9
  private sdk;
9
10
  private _address;
10
11
  private _privateKey;
@@ -4,6 +4,7 @@ export class HyperliquidAdapter {
4
4
  name = "hyperliquid";
5
5
  chain = "evm";
6
6
  aliases = ["hl"];
7
+ isUnifiedAccount = true;
7
8
  sdk;
8
9
  _address;
9
10
  _privateKey;
@@ -66,6 +66,8 @@ export interface ExchangeAdapter {
66
66
  readonly name: string;
67
67
  readonly chain?: string;
68
68
  readonly aliases?: readonly string[];
69
+ /** True if perp and spot share the same balance (e.g., Hyperliquid). False if separate accounts (e.g., Lighter). */
70
+ readonly isUnifiedAccount?: boolean;
69
71
  init?(): Promise<void>;
70
72
  getMarkets(): Promise<ExchangeMarketInfo[]>;
71
73
  getOrderbook(symbol: string): Promise<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",