perp-cli 0.9.6 → 0.9.7

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 {
@@ -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)
@@ -819,7 +795,7 @@ export class FundingArbV2Strategy {
819
795
  ctx.log(` [ARBv2] Close perp-perp: adapters not found`);
820
796
  return false;
821
797
  }
822
- const isGone = (msg) => msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
798
+ const isGone = isPositionGone;
823
799
  let closeLongOk = false;
824
800
  let longGone = false;
825
801
  try {
@@ -34,7 +34,7 @@ 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;
37
+ private scoreFundingLocal;
38
38
  private findBestPerp;
39
39
  private calcAnnualized;
40
40
  private shouldClose;
@@ -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}`; }
@@ -227,7 +228,7 @@ export class SpotPerpArbStrategy {
227
228
  ?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
228
229
  if (!currentInfo)
229
230
  continue;
230
- const curAnn = (currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.perpExchange))) * 8760 * 100;
231
+ const curAnn = annualizeHourlyRate(currentInfo.rate / (currentInfo.fundingHours ?? getFundingHours(pos.perpExchange)));
231
232
  if (best.score.annualized - curAnn < p.rotation_threshold)
232
233
  continue;
233
234
  ctx.log(` [SPA] Rotating ${pos.symbol} short: ${pos.perpExchange} (${curAnn.toFixed(1)}%) -> ${best.exchange} (${best.score.annualized.toFixed(1)}%)`);
@@ -346,34 +347,8 @@ export class SpotPerpArbStrategy {
346
347
  return [{ type: "cancel_all" }];
347
348
  }
348
349
  // ── 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
- }
350
+ scoreFundingLocal(adapter, symbol, exchangeName) {
351
+ return scoreFunding(adapter, symbol, exchangeName, this.params.history_periods, this._fundingScoreCache);
377
352
  }
378
353
  async findBestPerp(symbol, adapters, ratesByExchange) {
379
354
  let best = null;
@@ -382,8 +357,8 @@ export class SpotPerpArbStrategy {
382
357
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
383
358
  if (!ri || ri.rate <= 0)
384
359
  continue;
385
- const score = await this.scoreFunding(adapter, perpSym, exName)
386
- ?? await this.scoreFunding(adapter, symbol, exName);
360
+ const score = await this.scoreFundingLocal(adapter, perpSym, exName)
361
+ ?? await this.scoreFundingLocal(adapter, symbol, exName);
387
362
  if (!score)
388
363
  continue;
389
364
  if (!best || score.annualized > best.score.annualized)
@@ -397,7 +372,7 @@ export class SpotPerpArbStrategy {
397
372
  if (!ri)
398
373
  return null; // rate unknown — don't assume 0
399
374
  const fH = ri.fundingHours ?? getFundingHours(pos.perpExchange);
400
- return (ri.rate / fH) * 8760 * 100;
375
+ return annualizeHourlyRate(ri.rate / fH);
401
376
  }
402
377
  shouldClose(pos, ratesByExchange, p) {
403
378
  const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
@@ -446,7 +421,7 @@ export class SpotPerpArbStrategy {
446
421
  this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
447
422
  return false;
448
423
  }
449
- const actualAnn = (rateInfo.rate / actualFh) * 8760 * 100;
424
+ const actualAnn = annualizeHourlyRate(rateInfo.rate / actualFh);
450
425
  if (actualAnn < p.min_rate) {
451
426
  ctx.log(` [SPA] Skip ${symbol}: actual rate ${actualAnn.toFixed(1)}% (${actualFh}h funding) below min_rate ${p.min_rate}%`);
452
427
  this._failCooldown.set(`${symbol}:entry`, Date.now() + 10 * 60 * 1000);
@@ -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
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",