perp-cli 0.9.4 → 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.
@@ -76,10 +76,11 @@ mode = "headless") {
76
76
  tick: 0,
77
77
  log,
78
78
  };
79
- // Pass extra adapters to strategy context for funding-arb
79
+ // Pass extra adapters and risk config to strategy context
80
80
  if (extraAdapters) {
81
81
  strategyCtx.state.set("extraAdapters", extraAdapters);
82
82
  }
83
+ strategyCtx.state.set("riskConfig", config.risk);
83
84
  const isDryRun = false; // TODO: wire up from config when dry-run mode is added
84
85
  let running = true;
85
86
  // ── TUI / JSON mode setup ──
@@ -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,34 +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 === 0)
384
- return null;
385
- const rates = history.map(h => parseFloat(h.rate));
386
- const fH = getFundingHours(exchange);
387
- const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
388
- const consistency = rates.filter(r => r > 0).length / rates.length;
389
- const annualized = (avgRate / fH) * 8760 * 100;
390
- const score = { symbol, exchange, avgRate, consistency, annualized, payments: rates.length };
391
- this._fundingScoreCache.set(key, { score, ts: Date.now() });
392
- return score;
393
- }
394
- catch {
395
- return null;
396
- }
377
+ scoreFundingLocal(adapter, symbol, exchange, periods) {
378
+ return scoreFunding(adapter, symbol, exchange, periods, this._fundingScoreCache);
397
379
  }
398
380
  calcSignedSpread(pos, ratesByExchange) {
399
381
  if (pos.mode === "spot-perp") {
400
382
  const ri = findRate(ratesByExchange, pos.shortExchange, getPerpSymbol(pos.symbol, pos.shortExchange))
401
383
  ?? findRate(ratesByExchange, pos.shortExchange, pos.symbol);
402
384
  const fH = ri?.fundingHours ?? getFundingHours(pos.shortExchange);
403
- return ri ? (ri.rate / fH) * 8760 * 100 : 0;
385
+ return ri ? annualizeHourlyRate(ri.rate / fH) : 0;
404
386
  }
405
387
  const li = findRate(ratesByExchange, pos.longExchange, pos.symbol);
406
388
  const si = findRate(ratesByExchange, pos.shortExchange, pos.symbol);
@@ -408,7 +390,7 @@ export class FundingArbV2Strategy {
408
390
  return 0;
409
391
  const lH = li.rate / (li.fundingHours ?? getFundingHours(pos.longExchange));
410
392
  const sH = si.rate / (si.fundingHours ?? getFundingHours(pos.shortExchange));
411
- return (sH - lH) * 8760 * 100;
393
+ return annualizeHourlyRate(sH - lH);
412
394
  }
413
395
  checkCloseByHistory(pos, ratesByExchange, p) {
414
396
  const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
@@ -437,8 +419,8 @@ export class FundingArbV2Strategy {
437
419
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
438
420
  if (!ri || ri.rate <= 0)
439
421
  continue;
440
- const score = await this.scoreFunding(adapter, perpSym, exName, p.history_periods)
441
- ?? 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);
442
424
  if (!score)
443
425
  continue;
444
426
  if (!best || score.annualized > best.score.annualized)
@@ -813,7 +795,7 @@ export class FundingArbV2Strategy {
813
795
  ctx.log(` [ARBv2] Close perp-perp: adapters not found`);
814
796
  return false;
815
797
  }
816
- const isGone = (msg) => msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
798
+ const isGone = isPositionGone;
817
799
  let closeLongOk = false;
818
800
  let longGone = false;
819
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,28 +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 === 0)
358
- return null;
359
- const rates = history.map(h => parseFloat(h.rate));
360
- const fH = getFundingHours(exchangeName);
361
- const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
362
- const consistency = rates.filter(r => r > 0).length / rates.length;
363
- const annualized = (avgRate / fH) * 8760 * 100;
364
- const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
365
- this._fundingScoreCache.set(key, { score, ts: Date.now() });
366
- return score;
367
- }
368
- catch {
369
- return null;
370
- }
350
+ scoreFundingLocal(adapter, symbol, exchangeName) {
351
+ return scoreFunding(adapter, symbol, exchangeName, this.params.history_periods, this._fundingScoreCache);
371
352
  }
372
353
  async findBestPerp(symbol, adapters, ratesByExchange) {
373
354
  let best = null;
@@ -376,8 +357,8 @@ export class SpotPerpArbStrategy {
376
357
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
377
358
  if (!ri || ri.rate <= 0)
378
359
  continue;
379
- const score = await this.scoreFunding(adapter, perpSym, exName)
380
- ?? await this.scoreFunding(adapter, symbol, exName);
360
+ const score = await this.scoreFundingLocal(adapter, perpSym, exName)
361
+ ?? await this.scoreFundingLocal(adapter, symbol, exName);
381
362
  if (!score)
382
363
  continue;
383
364
  if (!best || score.annualized > best.score.annualized)
@@ -391,7 +372,7 @@ export class SpotPerpArbStrategy {
391
372
  if (!ri)
392
373
  return null; // rate unknown — don't assume 0
393
374
  const fH = ri.fundingHours ?? getFundingHours(pos.perpExchange);
394
- return (ri.rate / fH) * 8760 * 100;
375
+ return annualizeHourlyRate(ri.rate / fH);
395
376
  }
396
377
  shouldClose(pos, ratesByExchange, p) {
397
378
  const minHoldMs = p.min_hold_hours * 60 * 60 * 1000;
@@ -440,15 +421,31 @@ export class SpotPerpArbStrategy {
440
421
  this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
441
422
  return false;
442
423
  }
443
- const actualAnn = (rateInfo.rate / actualFh) * 8760 * 100;
424
+ const actualAnn = annualizeHourlyRate(rateInfo.rate / actualFh);
444
425
  if (actualAnn < p.min_rate) {
445
426
  ctx.log(` [SPA] Skip ${symbol}: actual rate ${actualAnn.toFixed(1)}% (${actualFh}h funding) below min_rate ${p.min_rate}%`);
446
427
  this._failCooldown.set(`${symbol}:entry`, Date.now() + 10 * 60 * 1000);
447
428
  return false;
448
429
  }
449
430
  }
450
- // Size decision
451
- let targetSizeUsd = p.size_usd;
431
+ // Risk policy: enforce max_position_usd from risk config
432
+ const riskConfig = ctx.state.get("riskConfig");
433
+ const maxTotalPositionUsd = riskConfig?.max_position_usd ?? Infinity;
434
+ const positions = ctx.state.get("spaPositions");
435
+ let currentTotalUsd = 0;
436
+ for (const pos of positions) {
437
+ const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
438
+ ?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
439
+ const price = ri?.price ?? 0;
440
+ currentTotalUsd += price * parseFloat(pos.size);
441
+ }
442
+ const remainingBudget = maxTotalPositionUsd - currentTotalUsd;
443
+ if (remainingBudget < 10) {
444
+ ctx.log(` [SPA] Skip ${symbol}: risk limit — total positions $${currentTotalUsd.toFixed(0)}/$${maxTotalPositionUsd} (no budget)`);
445
+ return false;
446
+ }
447
+ // Size decision (capped by risk budget)
448
+ let targetSizeUsd = Math.min(p.size_usd, remainingBudget);
452
449
  const sameExchange = spotExchange === perpExchange;
453
450
  if (sameExchange) {
454
451
  const perpBal = await perpAdapter.getBalance();
@@ -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 {
@@ -18,7 +18,7 @@ const EXCHANGE_FUNDING_HOURS = {
18
18
  hyperliquid: 1,
19
19
  pacifica: 1,
20
20
  lighter: 8,
21
- aster: 1,
21
+ aster: 8,
22
22
  };
23
23
  /**
24
24
  * Get funding period in hours for an exchange.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.9.4",
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",