perp-cli 0.7.17 → 0.7.19

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.
@@ -37,6 +37,7 @@ export interface FundingArbStrategyParams {
37
37
  type: "funding-arb";
38
38
  min_spread: number;
39
39
  close_spread: number;
40
+ spot_perp_min_spread?: number;
40
41
  size_usd: number;
41
42
  max_positions: number;
42
43
  exchanges: string[];
@@ -83,6 +83,7 @@ export function parseStrategy(type, raw) {
83
83
  type: "funding-arb",
84
84
  min_spread: Number(raw.min_spread ?? 20),
85
85
  close_spread: Number(raw.close_spread ?? 5),
86
+ spot_perp_min_spread: raw.spot_perp_min_spread !== undefined ? Number(raw.spot_perp_min_spread) : undefined,
86
87
  size_usd: Number(raw.size_usd ?? raw.size ?? 50),
87
88
  max_positions: Number(raw.max_positions ?? 3),
88
89
  exchanges: Array.isArray(raw.exchanges)
@@ -28,5 +28,28 @@ export declare class FundingArbStrategy implements Strategy {
28
28
  init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
29
29
  onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
30
30
  onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
31
+ /** Close a spot-perp position: sell spot, transfer USDC back, close perp short */
32
+ private closeSpotPerp;
33
+ /** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
34
+ private openSpotPerp;
35
+ /** Transfer USDC from perp to spot account (exchange-specific) */
36
+ private transferUsdcToSpot;
37
+ /** Transfer USDC from spot to perp account (exchange-specific) */
38
+ private transferUsdcToPerp;
39
+ /**
40
+ * Get spot adapter for a given exchange name + perp adapter instance.
41
+ * Returns null if the exchange doesn't support spot or instantiation fails.
42
+ */
43
+ private getSpotAdapter;
44
+ /**
45
+ * Resolve the perp symbol for a given base symbol on an exchange.
46
+ * HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
47
+ */
48
+ private getPerpSymbol;
49
+ /**
50
+ * Get a price estimate for a symbol from the perp adapter.
51
+ * Tries the base symbol first, then with -PERP suffix.
52
+ */
53
+ private getPriceEstimate;
31
54
  private fetchRates;
32
55
  }
@@ -1,7 +1,7 @@
1
1
  import { registerStrategy } from "../strategy-registry.js";
2
- import { checkArbLiquidity } from "../../liquidity.js";
3
- import { computeMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
4
- import { computeAnnualSpread } from "../../funding.js";
2
+ import { checkArbLiquidity, checkSpotPerpLiquidity } from "../../liquidity.js";
3
+ import { computeMatchedSize, computeSpotPerpMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
4
+ import { computeSignedAnnualSpread, annualizeRate } from "../../funding.js";
5
5
  export class FundingArbStrategy {
6
6
  name = "funding-arb";
7
7
  describe() {
@@ -10,6 +10,7 @@ export class FundingArbStrategy {
10
10
  params: [
11
11
  { name: "min_spread", type: "number", required: true, description: "Minimum annualized spread %" },
12
12
  { name: "close_spread", type: "number", required: false, default: 5, description: "Spread to close at" },
13
+ { name: "spot_perp_min_spread", type: "number", required: false, description: "Min spread for spot-perp (default: min_spread)" },
13
14
  { name: "size_usd", type: "number", required: true, description: "Position size in USD" },
14
15
  { name: "max_positions", type: "number", required: false, default: 3, description: "Max concurrent positions" },
15
16
  { name: "exchanges", type: "string", required: true, description: "Comma-separated exchange names" },
@@ -30,6 +31,9 @@ export class FundingArbStrategy {
30
31
  ctx.state.set("fundingLastCheck", Date.now());
31
32
  ctx.state.set("fundingHistory", []);
32
33
  ctx.log(` [ARB] Funding arb ready | spread >= ${params.min_spread}% | close < ${params.close_spread}% | size $${params.size_usd}`);
34
+ if (params.spot_perp_min_spread !== undefined) {
35
+ ctx.log(` [ARB] Spot-perp min spread: ${params.spot_perp_min_spread}%`);
36
+ }
33
37
  ctx.log(` [ARB] Exchanges: ${params.exchanges.join(", ")}`);
34
38
  // ── Recover existing positions from exchanges ──
35
39
  try {
@@ -76,7 +80,7 @@ export class FundingArbStrategy {
76
80
  const longEx = pA.side === "long" ? exA : exB;
77
81
  const shortEx = pA.side === "short" ? exA : exB;
78
82
  const size = pA.side === "long" ? pA.size : pB.size;
79
- recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size });
83
+ recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size, mode: "perp-perp" });
80
84
  used.add(keyA);
81
85
  used.add(keyB);
82
86
  break;
@@ -89,20 +93,37 @@ export class FundingArbStrategy {
89
93
  // ── Same-exchange spot-perp hedges (e.g. PURR spot + PURR-PERP short on HL) ──
90
94
  for (const [name, a] of adapters) {
91
95
  try {
92
- const spotState = await a._getSpotClearinghouseState?.();
93
- if (!spotState?.balances)
96
+ let spotBalances = [];
97
+ if (name === "hyperliquid") {
98
+ const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
99
+ const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
100
+ if (a instanceof HyperliquidAdapter) {
101
+ const hlSpot = new HyperliquidSpotAdapter(a);
102
+ await hlSpot.init();
103
+ spotBalances = await hlSpot.getSpotBalances();
104
+ }
105
+ }
106
+ else if (name === "lighter") {
107
+ const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
108
+ const { LighterAdapter } = await import("../../exchanges/lighter.js");
109
+ if (a instanceof LighterAdapter) {
110
+ const ltSpot = new LighterSpotAdapter(a);
111
+ await ltSpot.init();
112
+ spotBalances = await ltSpot.getSpotBalances();
113
+ }
114
+ }
115
+ const nonUsdc = spotBalances.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
116
+ if (nonUsdc.length === 0)
94
117
  continue;
95
- const spotBalances = spotState.balances
96
- .filter(b => Number(b.total) > 0 && b.coin.toUpperCase() !== "USDC");
97
118
  const perpPositions = positionsByExchange.get(name) ?? [];
98
119
  for (const perp of perpPositions) {
99
120
  const perpKey = `${name}:${perp.symbol}`;
100
121
  if (used.has(perpKey))
101
122
  continue;
102
123
  const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
103
- const spotBal = spotBalances.find(b => b.coin.toUpperCase() === base);
124
+ const spotBal = nonUsdc.find(b => b.token.toUpperCase() === base);
104
125
  if (spotBal && perp.side === "short") {
105
- recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size });
126
+ recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp" });
106
127
  used.add(perpKey);
107
128
  ctx.log(` [ARB] Detected spot-perp hedge: ${base} ${name}-spot↔${name}`);
108
129
  }
@@ -189,39 +210,72 @@ export class FundingArbStrategy {
189
210
  }
190
211
  // ── Update strategy state for TUI display ──
191
212
  const arbPosForDisplay = openPositions.map(p => {
192
- const longRate = ratesByExchange.get(p.longExchange)?.get(p.symbol)?.rate;
193
- const shortRate = ratesByExchange.get(p.shortExchange)?.get(p.symbol)?.rate;
194
- const spread = (longRate !== undefined && shortRate !== undefined)
195
- ? computeAnnualSpread(shortRate, p.shortExchange, longRate, p.longExchange)
196
- : 0;
197
- return `${p.symbol} ${p.longExchange}↔${p.shortExchange} ${spread.toFixed(1)}%`;
213
+ let signedSpread;
214
+ if (p.mode === "spot-perp") {
215
+ // For spot-perp: long side is spot (rate=0), short side is perp
216
+ const perpExchange = p.shortExchange;
217
+ const perpSymbol = this.getPerpSymbol(p.symbol, perpExchange);
218
+ const shortRate = ratesByExchange.get(perpExchange)?.get(perpSymbol)?.rate
219
+ ?? ratesByExchange.get(perpExchange)?.get(p.symbol)?.rate;
220
+ signedSpread = shortRate !== undefined ? annualizeRate(shortRate, perpExchange) : 0;
221
+ }
222
+ else {
223
+ const longRate = ratesByExchange.get(p.longExchange)?.get(p.symbol)?.rate;
224
+ const shortRate = ratesByExchange.get(p.shortExchange)?.get(p.symbol)?.rate;
225
+ signedSpread = (longRate !== undefined && shortRate !== undefined)
226
+ ? computeSignedAnnualSpread(shortRate, p.shortExchange, longRate, p.longExchange)
227
+ : 0;
228
+ }
229
+ const sign = signedSpread >= 0 ? "+" : "";
230
+ return `${p.symbol} ${p.longExchange}↔${p.shortExchange} ${sign}${signedSpread.toFixed(1)}%`;
198
231
  });
199
232
  ctx.state.set("arbPositionDetails", arbPosForDisplay);
200
233
  ctx.state.set("fundingTotal", `$${ctx.state.get("fundingIncome").toFixed(4)}`);
201
- // ── Check existing positions for close_spread ──
234
+ // ── Check existing positions for close conditions ──
202
235
  const toClose = [];
203
236
  for (const pos of openPositions) {
204
- const longRate = ratesByExchange.get(pos.longExchange)?.get(pos.symbol)?.rate;
205
- const shortRate = ratesByExchange.get(pos.shortExchange)?.get(pos.symbol)?.rate;
206
- if (longRate === undefined || shortRate === undefined)
207
- continue;
208
- const currentSpread = computeAnnualSpread(shortRate, pos.shortExchange, longRate, pos.longExchange);
209
- if (currentSpread < params.close_spread) {
210
- ctx.log(` [ARB] Closing ${pos.symbol}: spread ${currentSpread.toFixed(1)}% < close_spread ${params.close_spread}% (entry: ${pos.entrySpread.toFixed(1)}%)`);
211
- const longAdapter = adapters.get(pos.longExchange);
212
- const shortAdapter = adapters.get(pos.shortExchange);
213
- if (longAdapter && shortAdapter) {
214
- try {
215
- await Promise.all([
216
- longAdapter.marketOrder(pos.symbol, "sell", pos.size),
217
- shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
218
- ]);
219
- toClose.push(pos);
220
- ctx.log(` [ARB] Closed ${pos.symbol} position`);
221
- }
222
- catch (err) {
223
- const msg = err instanceof Error ? err.message : String(err);
224
- ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
237
+ let signedSpread;
238
+ if (pos.mode === "spot-perp") {
239
+ const perpExchange = pos.shortExchange;
240
+ const perpSymbol = this.getPerpSymbol(pos.symbol, perpExchange);
241
+ const shortRate = ratesByExchange.get(perpExchange)?.get(perpSymbol)?.rate
242
+ ?? ratesByExchange.get(perpExchange)?.get(pos.symbol)?.rate;
243
+ if (shortRate === undefined)
244
+ continue;
245
+ signedSpread = annualizeRate(shortRate, perpExchange);
246
+ }
247
+ else {
248
+ const longRate = ratesByExchange.get(pos.longExchange)?.get(pos.symbol)?.rate;
249
+ const shortRate = ratesByExchange.get(pos.shortExchange)?.get(pos.symbol)?.rate;
250
+ if (longRate === undefined || shortRate === undefined)
251
+ continue;
252
+ signedSpread = computeSignedAnnualSpread(shortRate, pos.shortExchange, longRate, pos.longExchange);
253
+ }
254
+ // Close if funding flipped (losing money) or spread below close threshold
255
+ const shouldClose = signedSpread < 0 || signedSpread < params.close_spread;
256
+ if (shouldClose) {
257
+ const sign = signedSpread >= 0 ? "+" : "";
258
+ ctx.log(` [ARB] Closing ${pos.symbol}: signed spread ${sign}${signedSpread.toFixed(1)}% (close_spread=${params.close_spread}%, entry=${pos.entrySpread.toFixed(1)}%)`);
259
+ if (pos.mode === "spot-perp") {
260
+ await this.closeSpotPerp(pos, adapters, ctx);
261
+ toClose.push(pos);
262
+ }
263
+ else {
264
+ const longAdapter = adapters.get(pos.longExchange);
265
+ const shortAdapter = adapters.get(pos.shortExchange);
266
+ if (longAdapter && shortAdapter) {
267
+ try {
268
+ await Promise.all([
269
+ longAdapter.marketOrder(pos.symbol, "sell", pos.size),
270
+ shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
271
+ ]);
272
+ toClose.push(pos);
273
+ ctx.log(` [ARB] Closed ${pos.symbol} position`);
274
+ }
275
+ catch (err) {
276
+ const msg = err instanceof Error ? err.message : String(err);
277
+ ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
278
+ }
225
279
  }
226
280
  }
227
281
  }
@@ -241,6 +295,7 @@ export class FundingArbStrategy {
241
295
  for (const sym of map.keys())
242
296
  allSymbols.add(sym);
243
297
  }
298
+ // Perp-perp opportunities
244
299
  for (const sym of allSymbols) {
245
300
  let minRate = Infinity, maxRate = -Infinity;
246
301
  let minExchange = "", maxExchange = "";
@@ -258,7 +313,7 @@ export class FundingArbStrategy {
258
313
  }
259
314
  }
260
315
  if (minExchange && maxExchange && minExchange !== maxExchange) {
261
- const spread = computeAnnualSpread(maxRate, maxExchange, minRate, minExchange);
316
+ const spread = computeSignedAnnualSpread(maxRate, maxExchange, minRate, minExchange);
262
317
  if (spread >= params.min_spread) {
263
318
  opportunities.push({
264
319
  symbol: sym,
@@ -267,116 +322,160 @@ export class FundingArbStrategy {
267
322
  longRate: minRate,
268
323
  shortRate: maxRate,
269
324
  spread,
325
+ mode: "perp-perp",
270
326
  });
271
327
  }
272
328
  }
273
329
  }
330
+ // Spot-perp opportunities: for each exchange with positive perp rate, check if spot is available
331
+ const spotPerpMinSpread = params.spot_perp_min_spread ?? params.min_spread;
332
+ for (const [name, a] of adapters) {
333
+ try {
334
+ const spotAdapter = await this.getSpotAdapter(name, a);
335
+ if (!spotAdapter)
336
+ continue;
337
+ const spotMarkets = await spotAdapter.getSpotMarkets();
338
+ const spotSymbols = new Set(spotMarkets.map(m => m.baseToken.toUpperCase()));
339
+ const perpRates = ratesByExchange.get(name);
340
+ if (!perpRates)
341
+ continue;
342
+ for (const [sym, { rate }] of perpRates) {
343
+ // Only consider positive funding (shorts receive, longs pay)
344
+ if (rate <= 0)
345
+ continue;
346
+ const base = sym.replace(/-PERP$/, "").toUpperCase();
347
+ if (!spotSymbols.has(base))
348
+ continue;
349
+ const annualSpread = annualizeRate(rate, name);
350
+ if (annualSpread >= spotPerpMinSpread) {
351
+ opportunities.push({
352
+ symbol: base,
353
+ longExchange: `${name}-spot`,
354
+ shortExchange: name,
355
+ longRate: 0,
356
+ shortRate: rate,
357
+ spread: annualSpread,
358
+ mode: "spot-perp",
359
+ });
360
+ }
361
+ }
362
+ }
363
+ catch { /* exchange doesn't support spot */ }
364
+ }
274
365
  opportunities.sort((a, b) => b.spread - a.spread);
275
366
  if (opportunities.length > 0) {
276
367
  const top = opportunities.slice(0, 3);
277
368
  for (const opp of top) {
278
- ctx.log(` [ARB] ${opp.symbol}: ${opp.spread.toFixed(1)}% spread (long ${opp.longExchange} ${(opp.longRate * 100).toFixed(4)}% / short ${opp.shortExchange} ${(opp.shortRate * 100).toFixed(4)}%)`);
369
+ if (opp.mode === "spot-perp") {
370
+ ctx.log(` [ARB] ${opp.symbol}: ${opp.spread.toFixed(1)}% spread [spot-perp] (short ${opp.shortExchange} ${(opp.shortRate * 100).toFixed(4)}%)`);
371
+ }
372
+ else {
373
+ ctx.log(` [ARB] ${opp.symbol}: ${opp.spread.toFixed(1)}% spread (long ${opp.longExchange} ${(opp.longRate * 100).toFixed(4)}% / short ${opp.shortExchange} ${(opp.shortRate * 100).toFixed(4)}%)`);
374
+ }
279
375
  }
280
376
  // Auto-execute if under position limit
281
377
  if (arbPositions < params.max_positions && opportunities.length > 0) {
282
378
  const best = opportunities[0];
283
- const longAdapter = adapters.get(best.longExchange);
284
- const shortAdapter = adapters.get(best.shortExchange);
285
- if (longAdapter && shortAdapter) {
286
- const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
287
- if (price > 0) {
288
- // Balance check — reduce size to fit available balance
289
- let targetSizeUsd = params.size_usd;
290
- try {
291
- const [longBal, shortBal] = await Promise.all([
292
- longAdapter.getBalance(),
293
- shortAdapter.getBalance(),
294
- ]);
295
- const longAvail = parseFloat(longBal.available);
296
- const shortAvail = parseFloat(shortBal.available);
297
- const minAvail = Math.min(longAvail, shortAvail);
298
- // Use at most 80% of available balance per leg
299
- const maxAffordable = minAvail * 0.8;
300
- if (maxAffordable < 10) {
301
- ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available, need $10+ per leg)`);
302
- return [];
303
- }
304
- if (targetSizeUsd > maxAffordable) {
305
- ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
306
- targetSizeUsd = Math.floor(maxAffordable);
307
- }
308
- }
309
- catch { /* non-critical, proceed with original size */ }
310
- // Liquidity check
311
- const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
312
- if (!liq.viable)
313
- return [];
314
- // Compute matched size
315
- let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
316
- if (!matched) {
317
- ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
318
- return [];
319
- }
320
- try {
321
- ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
379
+ if (best.mode === "spot-perp") {
380
+ await this.openSpotPerp(best, adapters, ctx);
381
+ }
382
+ else {
383
+ const longAdapter = adapters.get(best.longExchange);
384
+ const shortAdapter = adapters.get(best.shortExchange);
385
+ if (longAdapter && shortAdapter) {
386
+ const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
387
+ if (price > 0) {
388
+ // Balance check
389
+ let targetSizeUsd = params.size_usd;
322
390
  try {
323
- await Promise.all([
324
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
325
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
391
+ const [longBal, shortBal] = await Promise.all([
392
+ longAdapter.getBalance(),
393
+ shortAdapter.getBalance(),
326
394
  ]);
395
+ const longAvail = parseFloat(longBal.available);
396
+ const shortAvail = parseFloat(shortBal.available);
397
+ const minAvail = Math.min(longAvail, shortAvail);
398
+ const maxAffordable = minAvail * 0.8;
399
+ if (maxAffordable < 10) {
400
+ ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available, need $10+ per leg)`);
401
+ return [];
402
+ }
403
+ if (targetSizeUsd > maxAffordable) {
404
+ ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
405
+ targetSizeUsd = Math.floor(maxAffordable);
406
+ }
327
407
  }
328
- catch (orderErr) {
329
- // Parse lot size from error and retry with corrected size
330
- const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
331
- const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
332
- if (lotMatch) {
333
- const lotSize = parseFloat(lotMatch[1]);
334
- ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
335
- matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
336
- if (!matched) {
337
- ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
338
- return [];
339
- }
340
- ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
408
+ catch { /* non-critical, proceed with original size */ }
409
+ // Liquidity check
410
+ const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
411
+ if (!liq.viable)
412
+ return [];
413
+ // Compute matched size
414
+ let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
415
+ if (!matched) {
416
+ ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
417
+ return [];
418
+ }
419
+ try {
420
+ ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
421
+ try {
341
422
  await Promise.all([
342
423
  longAdapter.marketOrder(best.symbol, "buy", matched.size),
343
424
  shortAdapter.marketOrder(best.symbol, "sell", matched.size),
344
425
  ]);
345
426
  }
346
- else {
347
- throw orderErr;
427
+ catch (orderErr) {
428
+ // Parse lot size from error and retry with corrected size
429
+ const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
430
+ const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
431
+ if (lotMatch) {
432
+ const lotSize = parseFloat(lotMatch[1]);
433
+ ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
434
+ matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
435
+ if (!matched) {
436
+ ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
437
+ return [];
438
+ }
439
+ ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
440
+ await Promise.all([
441
+ longAdapter.marketOrder(best.symbol, "buy", matched.size),
442
+ shortAdapter.marketOrder(best.symbol, "sell", matched.size),
443
+ ]);
444
+ }
445
+ else {
446
+ throw orderErr;
447
+ }
348
448
  }
349
- }
350
- // Verify fills match, correct if needed
351
- try {
352
- const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
353
- if (!recon.matched) {
354
- ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
449
+ // Verify fills match, correct if needed
450
+ try {
451
+ const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
452
+ if (!recon.matched) {
453
+ ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
454
+ }
355
455
  }
456
+ catch (reconErr) {
457
+ const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
458
+ ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
459
+ }
460
+ // Track position
461
+ const currentPositions = ctx.state.get("arbOpenPositions");
462
+ currentPositions.push({
463
+ symbol: best.symbol,
464
+ longExchange: best.longExchange,
465
+ shortExchange: best.shortExchange,
466
+ entrySpread: best.spread,
467
+ size: matched.size,
468
+ mode: "perp-perp",
469
+ });
470
+ ctx.state.set("arbOpenPositions", currentPositions);
471
+ ctx.state.set("arbPositions", currentPositions.length);
472
+ ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
473
+ return [{ type: "noop" }];
356
474
  }
357
- catch (reconErr) {
358
- const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
359
- ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
475
+ catch (err) {
476
+ const msg = err instanceof Error ? err.message : String(err);
477
+ ctx.log(` [ARB] Execution failed: ${msg}`);
360
478
  }
361
- // Track position in array
362
- const currentPositions = ctx.state.get("arbOpenPositions");
363
- currentPositions.push({
364
- symbol: best.symbol,
365
- longExchange: best.longExchange,
366
- shortExchange: best.shortExchange,
367
- entrySpread: best.spread,
368
- size: matched.size,
369
- });
370
- ctx.state.set("arbOpenPositions", currentPositions);
371
- ctx.state.set("arbPositions", currentPositions.length);
372
- ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
373
- // Return noop -- we already executed directly via adapters
374
- // (arb requires multi-adapter coordination, can't be expressed as single-adapter actions)
375
- return [{ type: "noop" }];
376
- }
377
- catch (err) {
378
- const msg = err instanceof Error ? err.message : String(err);
379
- ctx.log(` [ARB] Execution failed: ${msg}`);
380
479
  }
381
480
  }
382
481
  }
@@ -390,7 +489,6 @@ export class FundingArbStrategy {
390
489
  async onStop(ctx) {
391
490
  const openPositions = ctx.state.get("arbOpenPositions");
392
491
  const extraAdapters = ctx.state.get("extraAdapters");
393
- // Close all open arb positions
394
492
  if (openPositions && openPositions.length > 0) {
395
493
  const adapters = new Map();
396
494
  adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
@@ -399,26 +497,31 @@ export class FundingArbStrategy {
399
497
  adapters.set(name, a);
400
498
  }
401
499
  for (const pos of openPositions) {
402
- const longAdapter = adapters.get(pos.longExchange);
403
- const shortAdapter = adapters.get(pos.shortExchange);
404
- if (longAdapter && shortAdapter) {
405
- try {
406
- ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
407
- await Promise.all([
408
- longAdapter.marketOrder(pos.symbol, "sell", pos.size),
409
- shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
410
- ]);
411
- }
412
- catch (err) {
413
- const msg = err instanceof Error ? err.message : String(err);
414
- ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
500
+ if (pos.mode === "spot-perp") {
501
+ await this.closeSpotPerp(pos, adapters, ctx);
502
+ }
503
+ else {
504
+ const longAdapter = adapters.get(pos.longExchange);
505
+ const shortAdapter = adapters.get(pos.shortExchange);
506
+ if (longAdapter && shortAdapter) {
507
+ try {
508
+ ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
509
+ await Promise.all([
510
+ longAdapter.marketOrder(pos.symbol, "sell", pos.size),
511
+ shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
512
+ ]);
513
+ }
514
+ catch (err) {
515
+ const msg = err instanceof Error ? err.message : String(err);
516
+ ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
517
+ }
415
518
  }
416
519
  }
417
520
  }
418
521
  ctx.state.set("arbOpenPositions", []);
419
522
  ctx.state.set("arbPositions", 0);
420
523
  }
421
- // Cancel on extra adapters directly (M2: was only cancelling on primary adapter)
524
+ // Cancel on extra adapters directly
422
525
  if (extraAdapters) {
423
526
  for (const [, a] of extraAdapters) {
424
527
  try {
@@ -429,6 +532,231 @@ export class FundingArbStrategy {
429
532
  }
430
533
  return [{ type: "cancel_all" }];
431
534
  }
535
+ /** Close a spot-perp position: sell spot, transfer USDC back, close perp short */
536
+ async closeSpotPerp(pos, adapters, ctx) {
537
+ const exchangeName = pos.longExchange.replace("-spot", "");
538
+ const perpAdapter = adapters.get(pos.shortExchange) ?? adapters.get(exchangeName);
539
+ if (!perpAdapter) {
540
+ ctx.log(` [ARB] Close spot-perp failed: no adapter for ${pos.shortExchange}`);
541
+ return;
542
+ }
543
+ try {
544
+ const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
545
+ if (!spotAdapter) {
546
+ ctx.log(` [ARB] Close spot-perp failed: spot adapter not available for ${exchangeName}`);
547
+ return;
548
+ }
549
+ ctx.log(` [ARB] Closing spot-perp ${pos.symbol} on stop (spot sell + perp buy)`);
550
+ // Step 1: Sell spot
551
+ try {
552
+ await spotAdapter.spotMarketOrder(pos.symbol, "sell", pos.size);
553
+ ctx.log(` [ARB] Sold spot ${pos.size} ${pos.symbol}`);
554
+ }
555
+ catch (err) {
556
+ const msg = err instanceof Error ? err.message : String(err);
557
+ ctx.log(` [ARB] Spot sell failed for ${pos.symbol}: ${msg}`);
558
+ }
559
+ // Step 2: Transfer USDC back to perp (use 95% of notional as estimate)
560
+ try {
561
+ const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
562
+ const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, pos.symbol);
563
+ const proceeds = priceEstimate * parseFloat(pos.size) * 0.95;
564
+ if (proceeds > 1) {
565
+ await this.transferUsdcToPerp(spotAdapter, exchangeName, Math.floor(proceeds));
566
+ ctx.log(` [ARB] Transferred ~$${Math.floor(proceeds)} USDC back to perp`);
567
+ }
568
+ }
569
+ catch (err) {
570
+ const msg = err instanceof Error ? err.message : String(err);
571
+ ctx.log(` [ARB] USDC transfer back failed: ${msg}`);
572
+ }
573
+ // Step 3: Close perp short
574
+ try {
575
+ const perpSymbol = this.getPerpSymbol(pos.symbol, pos.shortExchange);
576
+ await perpAdapter.marketOrder(perpSymbol, "buy", pos.size);
577
+ ctx.log(` [ARB] Closed perp short ${pos.size} ${perpSymbol}`);
578
+ }
579
+ catch (err) {
580
+ const msg = err instanceof Error ? err.message : String(err);
581
+ ctx.log(` [ARB] Perp close failed for ${pos.symbol}: ${msg}`);
582
+ }
583
+ }
584
+ catch (err) {
585
+ const msg = err instanceof Error ? err.message : String(err);
586
+ ctx.log(` [ARB] Close spot-perp failed for ${pos.symbol}: ${msg}`);
587
+ }
588
+ }
589
+ /** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
590
+ async openSpotPerp(opp, adapters, ctx) {
591
+ const params = this.params;
592
+ const exchangeName = opp.longExchange.replace("-spot", "");
593
+ const perpAdapter = adapters.get(opp.shortExchange) ?? adapters.get(exchangeName);
594
+ if (!perpAdapter) {
595
+ ctx.log(` [ARB] Open spot-perp failed: no adapter for ${opp.shortExchange}`);
596
+ return;
597
+ }
598
+ try {
599
+ const spotAdapter = await this.getSpotAdapter(exchangeName, perpAdapter);
600
+ if (!spotAdapter) {
601
+ ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
602
+ return;
603
+ }
604
+ // Balance check: need full notional for spot + margin for perp
605
+ let targetSizeUsd = params.size_usd;
606
+ try {
607
+ const perpBal = await perpAdapter.getBalance();
608
+ const perpAvail = parseFloat(perpBal.available);
609
+ const maxAffordable = perpAvail * 0.8;
610
+ if (maxAffordable < 10) {
611
+ ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: insufficient balance ($${perpAvail.toFixed(2)} available)`);
612
+ return;
613
+ }
614
+ if (targetSizeUsd > maxAffordable) {
615
+ ctx.log(` [ARB] Spot-perp size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance)`);
616
+ targetSizeUsd = Math.floor(maxAffordable);
617
+ }
618
+ }
619
+ catch { /* non-critical */ }
620
+ const perpSymbol = this.getPerpSymbol(opp.symbol, opp.shortExchange);
621
+ const priceEstimate = await this.getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
622
+ if (priceEstimate <= 0) {
623
+ ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't determine price`);
624
+ return;
625
+ }
626
+ // Liquidity check
627
+ const liq = await checkSpotPerpLiquidity(spotAdapter, perpAdapter, opp.symbol, perpSymbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
628
+ if (!liq.viable)
629
+ return;
630
+ // Compute matched size
631
+ const spotMarkets = await spotAdapter.getSpotMarkets();
632
+ const spotMarket = spotMarkets.find(m => m.baseToken.toUpperCase() === opp.symbol.toUpperCase());
633
+ const spotDecimals = spotMarket?.sizeDecimals;
634
+ const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, priceEstimate, exchangeName, opp.shortExchange, spotDecimals);
635
+ if (!matched) {
636
+ ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't compute matched size`);
637
+ return;
638
+ }
639
+ ctx.log(` [ARB] Opening spot-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)}, slippage ~${liq.spotSlippage.toFixed(2)}%/${liq.perpSlippage.toFixed(2)}%)`);
640
+ // Step 1: Transfer USDC to spot
641
+ const transferAmt = Math.ceil(matched.notional * 1.02); // slight buffer for price movement
642
+ await this.transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
643
+ ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
644
+ // Step 2: Buy spot
645
+ await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
646
+ ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
647
+ // Step 3: Short perp
648
+ await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
649
+ ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
650
+ // Track position
651
+ const currentPositions = ctx.state.get("arbOpenPositions");
652
+ currentPositions.push({
653
+ symbol: opp.symbol,
654
+ longExchange: opp.longExchange,
655
+ shortExchange: opp.shortExchange,
656
+ entrySpread: opp.spread,
657
+ size: matched.size,
658
+ mode: "spot-perp",
659
+ });
660
+ ctx.state.set("arbOpenPositions", currentPositions);
661
+ ctx.state.set("arbPositions", currentPositions.length);
662
+ ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);
663
+ }
664
+ catch (err) {
665
+ const msg = err instanceof Error ? err.message : String(err);
666
+ ctx.log(` [ARB] Open spot-perp failed for ${opp.symbol}: ${msg}`);
667
+ }
668
+ }
669
+ /** Transfer USDC from perp to spot account (exchange-specific) */
670
+ async transferUsdcToSpot(spotAdapter, exchangeName, amount) {
671
+ if (exchangeName === "hyperliquid") {
672
+ const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
673
+ if (spotAdapter instanceof HyperliquidSpotAdapter) {
674
+ await spotAdapter.transferUsdcToSpot(amount);
675
+ return;
676
+ }
677
+ }
678
+ else if (exchangeName === "lighter") {
679
+ const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
680
+ if (spotAdapter instanceof LighterSpotAdapter) {
681
+ await spotAdapter.transferUsdcToSpot(amount);
682
+ return;
683
+ }
684
+ }
685
+ }
686
+ /** Transfer USDC from spot to perp account (exchange-specific) */
687
+ async transferUsdcToPerp(spotAdapter, exchangeName, amount) {
688
+ if (exchangeName === "hyperliquid") {
689
+ const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
690
+ if (spotAdapter instanceof HyperliquidSpotAdapter) {
691
+ await spotAdapter.transferUsdcToPerp(amount);
692
+ return;
693
+ }
694
+ }
695
+ else if (exchangeName === "lighter") {
696
+ const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
697
+ if (spotAdapter instanceof LighterSpotAdapter) {
698
+ await spotAdapter.transferUsdcToPerp(amount);
699
+ return;
700
+ }
701
+ }
702
+ }
703
+ /**
704
+ * Get spot adapter for a given exchange name + perp adapter instance.
705
+ * Returns null if the exchange doesn't support spot or instantiation fails.
706
+ */
707
+ async getSpotAdapter(name, adapter) {
708
+ try {
709
+ if (name === "hyperliquid") {
710
+ const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
711
+ const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
712
+ if (adapter instanceof HyperliquidAdapter) {
713
+ const spot = new HyperliquidSpotAdapter(adapter);
714
+ await spot.init();
715
+ return spot;
716
+ }
717
+ }
718
+ else if (name === "lighter") {
719
+ const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
720
+ const { LighterAdapter } = await import("../../exchanges/lighter.js");
721
+ if (adapter instanceof LighterAdapter) {
722
+ const spot = new LighterSpotAdapter(adapter);
723
+ await spot.init();
724
+ return spot;
725
+ }
726
+ }
727
+ }
728
+ catch { /* not supported */ }
729
+ return null;
730
+ }
731
+ /**
732
+ * Resolve the perp symbol for a given base symbol on an exchange.
733
+ * HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
734
+ */
735
+ getPerpSymbol(baseSymbol, exchangeName) {
736
+ // Strip any existing -PERP suffix first
737
+ const base = baseSymbol.replace(/-PERP$/, "").toUpperCase();
738
+ // HL perp symbols are just the base (e.g. "PURR", "ETH", "BTC")
739
+ // Some HIP-3 tokens might need "-PERP" suffix — use base for now
740
+ // since getPositions() returns the base form on HL
741
+ void exchangeName;
742
+ return base;
743
+ }
744
+ /**
745
+ * Get a price estimate for a symbol from the perp adapter.
746
+ * Tries the base symbol first, then with -PERP suffix.
747
+ */
748
+ async getPriceEstimate(perpAdapter, perpSymbol, fallbackSymbol) {
749
+ try {
750
+ const markets = await perpAdapter.getMarkets();
751
+ const market = markets.find(m => m.symbol.toUpperCase() === perpSymbol.toUpperCase() ||
752
+ m.symbol.toUpperCase() === fallbackSymbol.toUpperCase() ||
753
+ m.symbol.toUpperCase() === `${fallbackSymbol.toUpperCase()}-PERP`);
754
+ return market ? parseFloat(market.markPrice) : 0;
755
+ }
756
+ catch {
757
+ return 0;
758
+ }
759
+ }
432
760
  async fetchRates(adapter, _exchangeName) {
433
761
  try {
434
762
  const markets = await adapter.getMarkets();
@@ -331,8 +331,17 @@ export class LighterAdapter {
331
331
  const available = Number(acct.available_balance || 0);
332
332
  const collateral = Number(acct.collateral || 0);
333
333
  const unrealizedPnl = acct.positions?.reduce((sum, p) => sum + Number(p.unrealized_pnl || 0), 0) ?? 0;
334
+ // Include spot USDC balance (separate from perp collateral)
335
+ let spotUsdc = 0;
336
+ try {
337
+ const assets = (acct.assets ?? []);
338
+ const usdcAsset = assets.find(a => a.symbol === "USDC");
339
+ if (usdcAsset)
340
+ spotUsdc = parseFloat(usdcAsset.balance || "0");
341
+ }
342
+ catch { /* non-critical */ }
334
343
  return {
335
- equity: String(totalAsset),
344
+ equity: String(totalAsset + spotUsdc),
336
345
  available: String(available),
337
346
  marginUsed: String(Math.max(0, collateral - available)),
338
347
  unrealizedPnl: String(unrealizedPnl),
@@ -28,6 +28,14 @@ export declare function annualizeHourlyRate(hourlyRate: number): number;
28
28
  * Normalizes both to hourly before comparing.
29
29
  */
30
30
  export declare function computeAnnualSpread(rateA: number, exchangeA: string, rateB: number, exchangeB: string): number;
31
+ /**
32
+ * Compute SIGNED annualized spread for an existing arb position.
33
+ * Positive = favorable (short receiving more than long paying).
34
+ * Negative = unfavorable (funding flipped, position losing on funding).
35
+ *
36
+ * For spot-perp: pass longRate=0, longExchange=shortExchange (spot has no funding).
37
+ */
38
+ export declare function computeSignedAnnualSpread(shortRate: number, shortExchange: string, longRate: number, longExchange: string): number;
31
39
  /**
32
40
  * Estimate hourly funding payment for a position.
33
41
  * @param rate - raw funding rate from the exchange
@@ -51,6 +51,18 @@ export function computeAnnualSpread(rateA, exchangeA, rateB, exchangeB) {
51
51
  const hourlyB = toHourlyRate(rateB, exchangeB);
52
52
  return Math.abs(hourlyA - hourlyB) * HOURLY_PERIODS * 100;
53
53
  }
54
+ /**
55
+ * Compute SIGNED annualized spread for an existing arb position.
56
+ * Positive = favorable (short receiving more than long paying).
57
+ * Negative = unfavorable (funding flipped, position losing on funding).
58
+ *
59
+ * For spot-perp: pass longRate=0, longExchange=shortExchange (spot has no funding).
60
+ */
61
+ export function computeSignedAnnualSpread(shortRate, shortExchange, longRate, longExchange) {
62
+ const hourlyShort = toHourlyRate(shortRate, shortExchange);
63
+ const hourlyLong = toHourlyRate(longRate, longExchange);
64
+ return (hourlyShort - hourlyLong) * HOURLY_PERIODS * 100;
65
+ }
54
66
  /**
55
67
  * Estimate hourly funding payment for a position.
56
68
  * @param rate - raw funding rate from the exchange
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",