perp-cli 0.9.6 → 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.
@@ -38,6 +38,25 @@ export interface RecoveredPosition {
38
38
  }
39
39
  /** Recover arb positions from exchange state (perp-perp + spot-perp, same + cross exchange). */
40
40
  export declare function recoverArbPositions(adapters: Map<string, ExchangeAdapter>, log: (msg: string) => void): Promise<RecoveredPosition[]>;
41
+ export interface FundingScore {
42
+ symbol: string;
43
+ exchange: string;
44
+ avgRate: number;
45
+ consistency: number;
46
+ annualized: number;
47
+ payments: number;
48
+ }
49
+ /** Returns true if a reduceOnly rejection indicates the position is already gone. */
50
+ export declare function isPositionGone(msg: string): boolean;
51
+ /** Compute funding period in hours from history timestamps. Falls back to static map. */
52
+ export declare function computeFundingHoursFromHistory(history: {
53
+ time: number;
54
+ }[]): number;
55
+ /** Score a symbol's funding rate using history. Returns null if insufficient data. */
56
+ export declare function scoreFunding(adapter: ExchangeAdapter, symbol: string, exchangeName: string, periods: number, cache?: Map<string, {
57
+ score: FundingScore;
58
+ ts: number;
59
+ }>): Promise<FundingScore | null>;
41
60
  /** Fetch funding rates from an exchange adapter. */
42
61
  export declare function fetchRates(adapter: ExchangeAdapter, exchangeName: string): Promise<{
43
62
  symbol: string;
@@ -2,6 +2,8 @@
2
2
  * Shared utilities for funding arbitrage strategies (v1 + v2).
3
3
  * Extracted to keep individual strategy files under size limits.
4
4
  */
5
+ import { getFundingHours } from "../../funding.js";
6
+ import { annualizeHourlyRate } from "../../funding/normalize.js";
5
7
  // ── Adapter helpers ──
6
8
  /** Build adapter map from primary adapter + extraAdapters in context state. */
7
9
  export function buildAdapterMap(ctx) {
@@ -206,6 +208,44 @@ export async function recoverArbPositions(adapters, log) {
206
208
  }
207
209
  return recovered;
208
210
  }
211
+ /** Returns true if a reduceOnly rejection indicates the position is already gone. */
212
+ export function isPositionGone(msg) {
213
+ return msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
214
+ }
215
+ /** Compute funding period in hours from history timestamps. Falls back to static map. */
216
+ export function computeFundingHoursFromHistory(history) {
217
+ if (history.length < 2)
218
+ return getFundingHours("unknown"); // fallback to 1
219
+ const times = history.map(h => h.time).sort((a, b) => a - b);
220
+ let totalInterval = 0;
221
+ for (let i = 1; i < times.length; i++)
222
+ totalInterval += times[i] - times[i - 1];
223
+ const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
224
+ return avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
225
+ }
226
+ /** Score a symbol's funding rate using history. Returns null if insufficient data. */
227
+ export async function scoreFunding(adapter, symbol, exchangeName, periods, cache) {
228
+ const key = `${exchangeName}:${symbol}`;
229
+ const cached = cache?.get(key);
230
+ if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
231
+ return cached.score;
232
+ try {
233
+ const history = await adapter.getFundingHistory(symbol, periods);
234
+ if (history.length < 2)
235
+ return null;
236
+ const rates = history.map(h => parseFloat(h.rate));
237
+ const fH = computeFundingHoursFromHistory(history);
238
+ const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
239
+ const consistency = rates.filter(r => r > 0).length / rates.length;
240
+ const annualized = annualizeHourlyRate(avgRate / fH);
241
+ const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
242
+ cache?.set(key, { score, ts: Date.now() });
243
+ return score;
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ }
209
249
  /** Fetch funding rates from an exchange adapter. */
210
250
  export async function fetchRates(adapter, exchangeName) {
211
251
  try {
@@ -222,8 +262,16 @@ export async function fetchRates(adapter, exchangeName) {
222
262
  m.fundingHours = await aster.getFundingHours(m.symbol);
223
263
  }
224
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
+ }
225
273
  return withRates
226
- .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
227
275
  .map(m => ({
228
276
  symbol: m.symbol,
229
277
  rate: parseFloat(m.fundingRate),
@@ -30,7 +30,7 @@ export declare class FundingArbV2Strategy implements Strategy {
30
30
  init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
31
31
  onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
32
32
  onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
33
- private scoreFunding;
33
+ private scoreFundingLocal;
34
34
  private calcSignedSpread;
35
35
  private checkCloseByHistory;
36
36
  private findBestPerpShort;
@@ -2,9 +2,10 @@ import { registerStrategy } from "../strategy-registry.js";
2
2
  import { checkArbLiquidity, checkSpotPerpLiquidity } from "../../liquidity.js";
3
3
  import { computeMatchedSize, computeSpotPerpMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
4
4
  import { getFundingHours } from "../../funding.js";
5
+ import { annualizeHourlyRate } from "../../funding/normalize.js";
5
6
  import { logExecution, pruneExecutionLog } from "../../execution-log.js";
6
7
  import { invalidateCache } from "../../cache.js";
7
- import { buildAdapterMap, getSpotAdapter, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, matchSymbol, getPriceEstimate, fetchRates, recoverArbPositions, } from "./funding-arb-utils.js";
8
+ import { buildAdapterMap, getSpotAdapter, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, matchSymbol, getPriceEstimate, fetchRates, recoverArbPositions, scoreFunding, isPositionGone, } from "./funding-arb-utils.js";
8
9
  let _posIdCounter = 0;
9
10
  function genPosId() { return `v2-${Date.now()}-${++_posIdCounter}`; }
10
11
  // ── Strategy ──
@@ -235,7 +236,7 @@ export class FundingArbV2Strategy {
235
236
  ?? findRate(ratesByExchange, pos.shortExchange, pos.symbol);
236
237
  if (!currentInfo)
237
238
  continue;
238
- const curAnn = (currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.shortExchange))) * 8760 * 100;
239
+ const curAnn = annualizeHourlyRate(currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.shortExchange)));
239
240
  if (best.score.annualized - curAnn < p.rotation_threshold)
240
241
  continue;
241
242
  ctx.log(` [ARBv2] Rotating ${pos.symbol} short: ${pos.shortExchange} (${curAnn.toFixed(1)}%) -> ${best.exchange} (${best.score.annualized.toFixed(1)}%)`);
@@ -302,10 +303,10 @@ export class FundingArbV2Strategy {
302
303
  }
303
304
  if (!minEx || !maxEx || minEx === maxEx)
304
305
  continue;
305
- const spread = (maxH - minH) * 8760 * 100;
306
+ const spread = annualizeHourlyRate(maxH - minH);
306
307
  if (spread < p.min_spread)
307
308
  continue;
308
- const shortScore = await this.scoreFunding(adapters.get(maxEx), sym, maxEx, p.history_periods);
309
+ const shortScore = await this.scoreFundingLocal(adapters.get(maxEx), sym, maxEx, p.history_periods);
309
310
  if (shortScore && shortScore.consistency < p.min_consistency)
310
311
  continue;
311
312
  scoredOpps.push({ symbol: sym, longExchange: minEx, shortExchange: maxEx, spread, score: shortScore ?? { symbol: sym, exchange: maxEx, avgRate: maxR, consistency: 1, annualized: spread, payments: 0 }, mode: "perp-perp", shortRate: maxR, longRate: minR });
@@ -373,40 +374,15 @@ export class FundingArbV2Strategy {
373
374
  // ═══════════════════════════════════════════════════════════════════
374
375
  // Core scoring / decision methods
375
376
  // ═══════════════════════════════════════════════════════════════════
376
- async scoreFunding(adapter, symbol, exchange, periods) {
377
- const key = `${exchange}:${symbol}`;
378
- const cached = this._fundingScoreCache.get(key);
379
- if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
380
- return cached.score;
381
- try {
382
- const history = await adapter.getFundingHistory(symbol, periods);
383
- if (history.length < 2)
384
- return null;
385
- const rates = history.map(h => parseFloat(h.rate));
386
- // Compute actual funding period from timestamps (no static map dependency)
387
- const times = history.map(h => h.time).sort((a, b) => a - b);
388
- let totalInterval = 0;
389
- for (let i = 1; i < times.length; i++)
390
- totalInterval += times[i] - times[i - 1];
391
- const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
392
- const fH = avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
393
- const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
394
- const consistency = rates.filter(r => r > 0).length / rates.length;
395
- const annualized = (avgRate / fH) * 8760 * 100;
396
- const score = { symbol, exchange, avgRate, consistency, annualized, payments: rates.length };
397
- this._fundingScoreCache.set(key, { score, ts: Date.now() });
398
- return score;
399
- }
400
- catch {
401
- return null;
402
- }
377
+ scoreFundingLocal(adapter, symbol, exchange, periods) {
378
+ return scoreFunding(adapter, symbol, exchange, periods, this._fundingScoreCache);
403
379
  }
404
380
  calcSignedSpread(pos, ratesByExchange) {
405
381
  if (pos.mode === "spot-perp") {
406
382
  const ri = findRate(ratesByExchange, pos.shortExchange, getPerpSymbol(pos.symbol, pos.shortExchange))
407
383
  ?? findRate(ratesByExchange, pos.shortExchange, pos.symbol);
408
384
  const fH = ri?.fundingHours ?? getFundingHours(pos.shortExchange);
409
- return ri ? (ri.rate / fH) * 8760 * 100 : 0;
385
+ return ri ? annualizeHourlyRate(ri.rate / fH) : 0;
410
386
  }
411
387
  const li = findRate(ratesByExchange, pos.longExchange, pos.symbol);
412
388
  const si = findRate(ratesByExchange, pos.shortExchange, pos.symbol);
@@ -414,7 +390,7 @@ export class FundingArbV2Strategy {
414
390
  return 0;
415
391
  const lH = li.rate / (li.fundingHours ?? getFundingHours(pos.longExchange));
416
392
  const sH = si.rate / (si.fundingHours ?? getFundingHours(pos.shortExchange));
417
- return (sH - lH) * 8760 * 100;
393
+ return annualizeHourlyRate(sH - lH);
418
394
  }
419
395
  checkCloseByHistory(pos, ratesByExchange, p) {
420
396
  const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
@@ -443,8 +419,8 @@ export class FundingArbV2Strategy {
443
419
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
444
420
  if (!ri || ri.rate <= 0)
445
421
  continue;
446
- const score = await this.scoreFunding(adapter, perpSym, exName, p.history_periods)
447
- ?? await this.scoreFunding(adapter, symbol, exName, p.history_periods);
422
+ const score = await this.scoreFundingLocal(adapter, perpSym, exName, p.history_periods)
423
+ ?? await this.scoreFundingLocal(adapter, symbol, exName, p.history_periods);
448
424
  if (!score)
449
425
  continue;
450
426
  if (!best || score.annualized > best.score.annualized)
@@ -553,7 +529,18 @@ export class FundingArbV2Strategy {
553
529
  const pBal = exchangeBalances.get(opp.shortExchange);
554
530
  if (!sBal || !pBal)
555
531
  return false;
556
- 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;
557
544
  if (maxNtl < targetSizeUsd) {
558
545
  if (maxNtl < 10)
559
546
  return false;
@@ -653,17 +640,15 @@ export class FundingArbV2Strategy {
653
640
  this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
654
641
  return false;
655
642
  }
656
- // Transfer USDC back (same-exchange)
657
- if (spotExName === pos.shortExchange) {
658
- try {
659
- const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
660
- const price = await getPriceEstimate(perpAdapter, perpSym, pos.symbol);
661
- const proceeds = price * parseFloat(pos.size) * 0.95;
662
- if (proceeds > 1)
663
- await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(proceeds));
664
- }
665
- 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));
666
650
  }
651
+ catch { /* non-fatal */ }
667
652
  // Close perp short
668
653
  const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
669
654
  try {
@@ -819,7 +804,7 @@ export class FundingArbV2Strategy {
819
804
  ctx.log(` [ARBv2] Close perp-perp: adapters not found`);
820
805
  return false;
821
806
  }
822
- const isGone = (msg) => msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
807
+ const isGone = isPositionGone;
823
808
  let closeLongOk = false;
824
809
  let longGone = false;
825
810
  try {
@@ -34,8 +34,9 @@ export declare class SpotPerpArbStrategy implements Strategy {
34
34
  init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
35
35
  onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
36
36
  onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
37
- private scoreFunding;
38
- private findBestPerp;
37
+ private scoreFundingLocal;
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;
@@ -9,8 +9,9 @@
9
9
  */
10
10
  import { registerStrategy } from "../strategy-registry.js";
11
11
  import { getFundingHours } from "../../funding.js";
12
+ import { annualizeHourlyRate } from "../../funding/normalize.js";
12
13
  import { pruneExecutionLog } from "../../execution-log.js";
13
- import { buildAdapterMap, getSpotAdapter, getPerpSymbol, findRate, matchSymbol, fetchRates, } from "./funding-arb-utils.js";
14
+ import { buildAdapterMap, getSpotAdapter, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, } from "./funding-arb-utils.js";
14
15
  import { SpotPerpExecutor } from "./spot-perp-executor.js";
15
16
  let _posIdCounter = 0;
16
17
  function genPosId() { return `spa-${Date.now()}-${++_posIdCounter}`; }
@@ -124,7 +125,8 @@ export class SpotPerpArbStrategy {
124
125
  availableAdapters.set(name, a);
125
126
  }
126
127
  catch (err) {
127
- 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}`);
128
130
  }
129
131
  }
130
132
  const ratesByExchange = new Map();
@@ -220,14 +222,15 @@ export class SpotPerpArbStrategy {
220
222
  const rcd = this._failCooldown.get(`${pos.id}:rotate`) ?? 0;
221
223
  if (Date.now() < rcd)
222
224
  continue;
223
- const best = await this.findBestPerp(pos.symbol, adapters, ratesByExchange);
225
+ const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange);
226
+ const best = candidates[0];
224
227
  if (!best || best.exchange === pos.perpExchange)
225
228
  continue;
226
229
  const currentInfo = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
227
230
  ?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
228
231
  if (!currentInfo)
229
232
  continue;
230
- const curAnn = (currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.perpExchange))) * 8760 * 100;
233
+ const curAnn = annualizeHourlyRate(currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.perpExchange)));
231
234
  if (best.score.annualized - curAnn < p.rotation_threshold)
232
235
  continue;
233
236
  ctx.log(` [SPA] Rotating ${pos.symbol} short: ${pos.perpExchange} (${curAnn.toFixed(1)}%) -> ${best.exchange} (${best.score.annualized.toFixed(1)}%)`);
@@ -276,10 +279,12 @@ export class SpotPerpArbStrategy {
276
279
  continue;
277
280
  if (Date.now() < (this._failCooldown.get(`${base}:entry`) ?? 0))
278
281
  continue;
279
- const best = await this.findBestPerp(base, adapters, ratesByExchange);
280
- if (!best || best.score.annualized < p.min_rate || best.score.consistency < p.min_consistency)
281
- continue;
282
- 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
+ }
283
288
  }
284
289
  }
285
290
  catch { /* no spot */ }
@@ -346,50 +351,25 @@ export class SpotPerpArbStrategy {
346
351
  return [{ type: "cancel_all" }];
347
352
  }
348
353
  // ── Scoring (decision logic) ──
349
- async scoreFunding(adapter, symbol, exchangeName) {
350
- const p = this.params;
351
- const key = `${exchangeName}:${symbol}`;
352
- const cached = this._fundingScoreCache.get(key);
353
- if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
354
- return cached.score;
355
- try {
356
- const history = await adapter.getFundingHistory(symbol, p.history_periods);
357
- if (history.length < 2)
358
- return null;
359
- const rates = history.map(h => parseFloat(h.rate));
360
- // Compute actual funding period from timestamps (no static map dependency)
361
- const times = history.map(h => h.time).sort((a, b) => a - b);
362
- let totalInterval = 0;
363
- for (let i = 1; i < times.length; i++)
364
- totalInterval += times[i] - times[i - 1];
365
- const avgIntervalH = totalInterval / (times.length - 1) / 3600000;
366
- const fH = avgIntervalH < 1.5 ? 1 : avgIntervalH < 5 ? 4 : 8;
367
- const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
368
- const consistency = rates.filter(r => r > 0).length / rates.length;
369
- const annualized = (avgRate / fH) * 8760 * 100;
370
- const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
371
- this._fundingScoreCache.set(key, { score, ts: Date.now() });
372
- return score;
373
- }
374
- catch {
375
- return null;
376
- }
354
+ scoreFundingLocal(adapter, symbol, exchangeName) {
355
+ return scoreFunding(adapter, symbol, exchangeName, this.params.history_periods, this._fundingScoreCache);
377
356
  }
378
- async findBestPerp(symbol, adapters, ratesByExchange) {
379
- 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 = [];
380
360
  for (const [exName, adapter] of adapters) {
381
361
  const perpSym = getPerpSymbol(symbol, exName);
382
362
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
383
363
  if (!ri || ri.rate <= 0)
384
364
  continue;
385
- const score = await this.scoreFunding(adapter, perpSym, exName)
386
- ?? await this.scoreFunding(adapter, symbol, exName);
365
+ const score = await this.scoreFundingLocal(adapter, perpSym, exName)
366
+ ?? await this.scoreFundingLocal(adapter, symbol, exName);
387
367
  if (!score)
388
368
  continue;
389
- if (!best || score.annualized > best.score.annualized)
390
- best = { exchange: exName, rate: ri.rate, score };
369
+ candidates.push({ exchange: exName, rate: ri.rate, score });
391
370
  }
392
- return best;
371
+ candidates.sort((a, b) => b.score.annualized - a.score.annualized);
372
+ return candidates;
393
373
  }
394
374
  calcAnnualized(pos, ratesByExchange) {
395
375
  const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
@@ -397,7 +377,7 @@ export class SpotPerpArbStrategy {
397
377
  if (!ri)
398
378
  return null; // rate unknown — don't assume 0
399
379
  const fH = ri.fundingHours ?? getFundingHours(pos.perpExchange);
400
- return (ri.rate / fH) * 8760 * 100;
380
+ return annualizeHourlyRate(ri.rate / fH);
401
381
  }
402
382
  shouldClose(pos, ratesByExchange, p) {
403
383
  const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
@@ -446,7 +426,7 @@ export class SpotPerpArbStrategy {
446
426
  this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
447
427
  return false;
448
428
  }
449
- const actualAnn = (rateInfo.rate / actualFh) * 8760 * 100;
429
+ const actualAnn = annualizeHourlyRate(rateInfo.rate / actualFh);
450
430
  if (actualAnn < p.min_rate) {
451
431
  ctx.log(` [SPA] Skip ${symbol}: actual rate ${actualAnn.toFixed(1)}% (${actualFh}h funding) below min_rate ${p.min_rate}%`);
452
432
  this._failCooldown.set(`${symbol}:entry`, Date.now() + 10 * 60 * 1000);
@@ -491,15 +471,18 @@ export class SpotPerpArbStrategy {
491
471
  const pBal = exchangeBalances.get(perpExchange);
492
472
  if (!sBal || !pBal)
493
473
  return false;
494
- // 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)
495
475
  let spotAvailable = sBal.available;
496
- try {
497
- const spotBals = await spotAdapter.getSpotBalances();
498
- const spotUsdc = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
499
- if (spotUsdc)
500
- 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 */ }
501
485
  }
502
- catch { /* non-critical */ }
503
486
  const maxNtl = Math.min(spotAvailable, pBal.available * leverage) * 0.8;
504
487
  if (maxNtl < targetSizeUsd) {
505
488
  if (maxNtl < 10) {
@@ -20,8 +20,6 @@ export declare class SpotPerpExecutor {
20
20
  transferToSpot(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
21
21
  /** Transfer USDC: spot -> perp account. */
22
22
  transferToPerp(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
23
- /** Returns true if a reduceOnly rejection indicates the position is already gone. */
24
- private isPositionGone;
25
23
  /**
26
24
  * Full spot-perp entry flow:
27
25
  * 1. Check liquidity
@@ -8,7 +8,7 @@ import { logExecution } from "../../execution-log.js";
8
8
  import { invalidateCache } from "../../cache.js";
9
9
  import { checkSpotPerpLiquidity } from "../../liquidity.js";
10
10
  import { computeSpotPerpMatchedSize } from "../../arb-sizing.js";
11
- import { getSpotAdapter as getSpotAdapterUtil, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, matchSymbol, getPriceEstimate, } from "./funding-arb-utils.js";
11
+ import { getSpotAdapter as getSpotAdapterUtil, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, matchSymbol, getPriceEstimate, isPositionGone, } from "./funding-arb-utils.js";
12
12
  // ── Executor ──
13
13
  export class SpotPerpExecutor {
14
14
  _spotAdapterCache = new Map();
@@ -30,10 +30,7 @@ export class SpotPerpExecutor {
30
30
  async transferToPerp(spotAdapter, exchange, amount) {
31
31
  await transferUsdcToPerp(spotAdapter, exchange, amount);
32
32
  }
33
- /** Returns true if a reduceOnly rejection indicates the position is already gone. */
34
- isPositionGone(msg) {
35
- return msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
36
- }
33
+ // isPositionGone() is now in funding-arb-utils.ts
37
34
  /**
38
35
  * Full spot-perp entry flow:
39
36
  * 1. Check liquidity
@@ -124,8 +121,8 @@ export class SpotPerpExecutor {
124
121
  const postBals = await spotAdapter.getSpotBalances();
125
122
  const bought = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
126
123
  const boughtAmt = bought ? parseFloat(bought.total) : 0;
127
- if (boughtAmt < parseFloat(matched.size) * 0.5) {
128
- 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`);
129
126
  if (actualTransferred > 0) {
130
127
  try {
131
128
  await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
@@ -197,7 +194,7 @@ export class SpotPerpExecutor {
197
194
  catch (err) {
198
195
  const msg = err instanceof Error ? err.message : String(err);
199
196
  // ReduceOnly rejected = position already gone, proceed to sell spot
200
- if (this.isPositionGone(msg)) {
197
+ if (isPositionGone(msg)) {
201
198
  log(` [SPA] Perp already closed (ReduceOnly rejected)`);
202
199
  }
203
200
  else {
@@ -226,8 +223,8 @@ export class SpotPerpExecutor {
226
223
  const postBals = await spotAdapter.getSpotBalances();
227
224
  const remaining = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
228
225
  const remainingAmt = remaining ? parseFloat(remaining.total) : 0;
229
- if (remainingAmt >= parseFloat(size) * 0.5) {
230
- 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`);
231
228
  try {
232
229
  await perpAdapter.marketOrder(perpSym, "sell", size);
233
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.6",
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",