perp-cli 0.4.8 → 0.4.10
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.
- package/README.md +44 -5
- package/dist/arb/reconcile.d.ts +28 -0
- package/dist/arb/reconcile.js +162 -0
- package/dist/commands/account.d.ts +1 -1
- package/dist/commands/account.js +366 -62
- package/dist/commands/agent.js +9 -5
- package/dist/commands/arb/index.js +243 -49
- package/dist/commands/arb-auto.js +67 -19
- package/dist/commands/arb.d.ts +2 -1
- package/dist/commands/arb.js +535 -131
- package/dist/commands/bot.js +207 -0
- package/dist/commands/funds.d.ts +4 -0
- package/dist/commands/funds.js +741 -0
- package/dist/commands/history.d.ts +2 -1
- package/dist/commands/history.js +508 -5
- package/dist/commands/manage.js +5 -3
- package/dist/commands/market.js +34 -1
- package/dist/commands/pnl.js +1 -1
- package/dist/commands/risk.d.ts +2 -0
- package/dist/commands/risk.js +56 -0
- package/dist/commands/settings.js +147 -0
- package/dist/commands/trade.d.ts +1 -1
- package/dist/commands/trade.js +241 -259
- package/dist/commands/wallet.d.ts +4 -0
- package/dist/commands/wallet.js +10 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +11 -3
- package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
- package/dist/exchanges/hyperliquid-spot.js +13 -21
- package/dist/exchanges/lighter-spot.js +13 -15
- package/dist/exchanges/lighter.js +82 -21
- package/dist/exchanges/pacifica.js +12 -0
- package/dist/execution-log.d.ts +1 -1
- package/dist/index.js +39 -123
- package/dist/mcp-server.js +43 -96
- package/dist/strategies/split-order.d.ts +49 -0
- package/dist/strategies/split-order.js +138 -0
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +13 -26
- package/skills/perp-cli/references/agent-operations.md +19 -9
- package/skills/perp-cli/references/commands.md +38 -31
- package/skills/perp-cli/references/strategies.md +26 -16
- package/skills/perp-cli/scripts/funding-analysis.sh +2 -2
- package/skills/perp-cli/scripts/preflight.sh +1 -1
- package/skills/perp-cli/scripts/spot-perp-scan.sh +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { makeTable, formatUsd, formatPnl, printJson, jsonOk } from "../../utils.js";
|
|
3
3
|
import { readExecutionLog, logExecution } from "../../execution-log.js";
|
|
4
|
-
import { computeAnnualSpread } from "../../funding.js";
|
|
4
|
+
import { toHourlyRate, computeAnnualSpread } from "../../funding.js";
|
|
5
5
|
import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
|
|
6
6
|
import { computeBasisRisk } from "../../arb-utils.js";
|
|
7
7
|
import { smartOrder } from "../../smart-order.js";
|
|
8
|
-
import { removePosition as persistRemovePosition } from "../../arb-state.js";
|
|
8
|
+
import { removePosition as persistRemovePosition, getPositions as getArbStatePositions } from "../../arb-state.js";
|
|
9
|
+
import { SPOT_PERP_TOKEN_MAP } from "../../exchanges/spot-interface.js";
|
|
9
10
|
import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
|
|
10
11
|
import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
|
|
11
12
|
import { computeEnhancedStats } from "../../arb-history-stats.js";
|
|
@@ -89,7 +90,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
89
90
|
// ── arb status ──
|
|
90
91
|
arb
|
|
91
92
|
.command("status")
|
|
92
|
-
.description("Show open arb positions with PnL breakdown")
|
|
93
|
+
.description("Show open arb positions (perp-perp + spot-perp) with PnL breakdown")
|
|
93
94
|
.action(async () => {
|
|
94
95
|
if (!isJson())
|
|
95
96
|
console.log(chalk.cyan("\n Checking arb positions across exchanges...\n"));
|
|
@@ -218,66 +219,259 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
}
|
|
222
|
+
// ── Spot-Perp position detection ──
|
|
223
|
+
// Fetch spot balances from HL and LT, match with perp shorts
|
|
224
|
+
const spotPerpPositions = [];
|
|
225
|
+
// Build reverse map: spot token → perp symbol (UETH→ETH, UBTC→BTC, etc.)
|
|
226
|
+
const spotToPerpSymbol = (token) => {
|
|
227
|
+
const upper = token.toUpperCase();
|
|
228
|
+
return SPOT_PERP_TOKEN_MAP[upper] ?? upper;
|
|
229
|
+
};
|
|
230
|
+
// Collect spot balances per exchange
|
|
231
|
+
const spotBalancesByExchange = new Map();
|
|
232
|
+
const SPOT_EXCHANGES = ["hyperliquid", "lighter"];
|
|
233
|
+
for (const exName of SPOT_EXCHANGES) {
|
|
234
|
+
try {
|
|
235
|
+
const adapter = await getAdapterForExchange(exName);
|
|
236
|
+
if (exName === "hyperliquid") {
|
|
237
|
+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
238
|
+
const hlSpot = new HyperliquidSpotAdapter(adapter);
|
|
239
|
+
await hlSpot.init();
|
|
240
|
+
const balances = await hlSpot.getSpotBalances();
|
|
241
|
+
spotBalancesByExchange.set(exName, balances.filter(b => b.token !== "USDC" && Number(b.total) > 0));
|
|
242
|
+
}
|
|
243
|
+
else if (exName === "lighter") {
|
|
244
|
+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
245
|
+
const ltSpot = new LighterSpotAdapter(adapter);
|
|
246
|
+
await ltSpot.init();
|
|
247
|
+
const balances = await ltSpot.getSpotBalances();
|
|
248
|
+
spotBalancesByExchange.set(exName, balances.filter(b => b.token !== "USDC" && b.token !== "USDC_SPOT" && Number(b.total) > 0));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// exchange not configured or no spot support
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Load persisted arb state for spot-perp entries
|
|
256
|
+
const persistedSpotPerp = getArbStatePositions().filter(p => p.mode === "spot-perp");
|
|
257
|
+
const persistedSymbols = new Set(persistedSpotPerp.map(p => p.symbol.toUpperCase()));
|
|
258
|
+
// Match: spot balance + perp short on same symbol (different or same exchange)
|
|
259
|
+
for (const [spotEx, balances] of spotBalancesByExchange) {
|
|
260
|
+
for (const bal of balances) {
|
|
261
|
+
const perpSymbol = spotToPerpSymbol(bal.token);
|
|
262
|
+
const spotAmount = Number(bal.total);
|
|
263
|
+
if (spotAmount <= 0)
|
|
264
|
+
continue;
|
|
265
|
+
// Find matching perp short position (on any exchange)
|
|
266
|
+
const matchingShorts = allPositions.filter(p => p.symbol === perpSymbol && p.side === "short");
|
|
267
|
+
for (const shortPos of matchingShorts) {
|
|
268
|
+
// Check if this short is already used by a perp-perp pair
|
|
269
|
+
const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange);
|
|
270
|
+
if (usedByPerpPerp)
|
|
271
|
+
continue;
|
|
272
|
+
// Look up persisted state for hold time and entry info
|
|
273
|
+
const persisted = persistedSpotPerp.find(p => p.symbol.toUpperCase() === perpSymbol
|
|
274
|
+
&& p.spotExchange === spotEx
|
|
275
|
+
&& p.shortExchange === shortPos.exchange);
|
|
276
|
+
const entryTime = persisted?.entryTime ?? null;
|
|
277
|
+
const holdDurationMs = entryTime ? Date.now() - new Date(entryTime).getTime() : null;
|
|
278
|
+
const holdDuration = holdDurationMs ? formatDuration(holdDurationMs) : null;
|
|
279
|
+
// Get current perp funding rate for this symbol on this exchange
|
|
280
|
+
const rates = rateMap.get(perpSymbol);
|
|
281
|
+
const perpRate = rates?.find(r => r.exchange === shortPos.exchange);
|
|
282
|
+
const fundingRate = perpRate?.rate ?? 0;
|
|
283
|
+
const hourlyRate = fundingRate ? toHourlyRate(fundingRate, shortPos.exchange) : 0;
|
|
284
|
+
const annualPct = hourlyRate * 8760 * 100;
|
|
285
|
+
const perpNotional = shortPos.size * shortPos.markPrice;
|
|
286
|
+
const spotValueUsd = spotAmount * shortPos.markPrice; // approx using perp mark price
|
|
287
|
+
// Funding income: spot has 0 funding, perp short receives when rate > 0
|
|
288
|
+
let estimatedFundingIncome = 0;
|
|
289
|
+
if (holdDurationMs && hourlyRate !== 0) {
|
|
290
|
+
const holdHours = holdDurationMs / (1000 * 60 * 60);
|
|
291
|
+
// Short position: pays when rate < 0, receives when rate > 0
|
|
292
|
+
estimatedFundingIncome = Math.abs(hourlyRate) * perpNotional * holdHours;
|
|
293
|
+
if (hourlyRate < 0)
|
|
294
|
+
estimatedFundingIncome = -estimatedFundingIncome;
|
|
295
|
+
}
|
|
296
|
+
// Add persisted accumulated funding
|
|
297
|
+
if (persisted?.accumulatedFunding) {
|
|
298
|
+
estimatedFundingIncome += persisted.accumulatedFunding;
|
|
299
|
+
}
|
|
300
|
+
// Fees: spot buy + spot sell + perp short entry + perp buy to close
|
|
301
|
+
const { getTakerFee } = await import("../../constants.js");
|
|
302
|
+
const spotFee = getTakerFee(spotEx);
|
|
303
|
+
const perpFee = getTakerFee(shortPos.exchange);
|
|
304
|
+
const totalFees = (spotValueUsd * spotFee * 2) + (perpNotional * perpFee * 2);
|
|
305
|
+
const direction = hourlyRate >= 0 ? "long-spot-short-perp" : "sell-spot-long-perp";
|
|
306
|
+
const dailyFundingEstimate = Math.abs(hourlyRate) * perpNotional * 24;
|
|
307
|
+
spotPerpPositions.push({
|
|
308
|
+
symbol: perpSymbol,
|
|
309
|
+
mode: "spot-perp",
|
|
310
|
+
spotExchange: spotEx,
|
|
311
|
+
perpExchange: shortPos.exchange,
|
|
312
|
+
spotToken: bal.token,
|
|
313
|
+
spotAmount,
|
|
314
|
+
spotValueUsd,
|
|
315
|
+
perpSide: "short",
|
|
316
|
+
perpSize: shortPos.size,
|
|
317
|
+
perpEntryPrice: shortPos.entryPrice,
|
|
318
|
+
perpMarkPrice: shortPos.markPrice,
|
|
319
|
+
perpUnrealizedPnl: shortPos.unrealizedPnl,
|
|
320
|
+
perpLeverage: shortPos.leverage,
|
|
321
|
+
perpNotionalUsd: perpNotional,
|
|
322
|
+
direction,
|
|
323
|
+
currentSpread: annualPct || null,
|
|
324
|
+
holdDuration,
|
|
325
|
+
holdDurationMs,
|
|
326
|
+
estimatedFundingIncome,
|
|
327
|
+
estimatedFees: totalFees,
|
|
328
|
+
netPnl: shortPos.unrealizedPnl + estimatedFundingIncome - totalFees,
|
|
329
|
+
dailyFundingEstimate,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Also detect persisted spot-perp entries without matching spot balance (already exited spot)
|
|
335
|
+
for (const persisted of persistedSpotPerp) {
|
|
336
|
+
const sym = persisted.symbol.toUpperCase();
|
|
337
|
+
const alreadyMatched = spotPerpPositions.some(sp => sp.symbol === sym && sp.spotExchange === persisted.spotExchange);
|
|
338
|
+
if (alreadyMatched)
|
|
339
|
+
continue;
|
|
340
|
+
// Check if there's still a perp short for this
|
|
341
|
+
const shortPos = allPositions.find(p => p.symbol === sym && p.side === "short" && p.exchange === persisted.shortExchange);
|
|
342
|
+
if (!shortPos)
|
|
343
|
+
continue; // both legs closed, skip
|
|
344
|
+
const holdDurationMs = Date.now() - new Date(persisted.entryTime).getTime();
|
|
345
|
+
const rates = rateMap.get(sym);
|
|
346
|
+
const perpRate = rates?.find(r => r.exchange === persisted.shortExchange);
|
|
347
|
+
const hourlyRate = perpRate ? toHourlyRate(perpRate.rate, persisted.shortExchange) : 0;
|
|
348
|
+
const perpNotional = shortPos.size * shortPos.markPrice;
|
|
349
|
+
spotPerpPositions.push({
|
|
350
|
+
symbol: sym,
|
|
351
|
+
mode: "spot-perp",
|
|
352
|
+
spotExchange: persisted.spotExchange ?? "unknown",
|
|
353
|
+
perpExchange: persisted.shortExchange,
|
|
354
|
+
spotToken: "?",
|
|
355
|
+
spotAmount: 0,
|
|
356
|
+
spotValueUsd: 0,
|
|
357
|
+
perpSide: "short",
|
|
358
|
+
perpSize: shortPos.size,
|
|
359
|
+
perpEntryPrice: shortPos.entryPrice,
|
|
360
|
+
perpMarkPrice: shortPos.markPrice,
|
|
361
|
+
perpUnrealizedPnl: shortPos.unrealizedPnl,
|
|
362
|
+
perpLeverage: shortPos.leverage,
|
|
363
|
+
perpNotionalUsd: perpNotional,
|
|
364
|
+
direction: hourlyRate >= 0 ? "long-spot-short-perp" : "sell-spot-long-perp",
|
|
365
|
+
currentSpread: hourlyRate ? Math.abs(hourlyRate) * 8760 * 100 : null,
|
|
366
|
+
holdDuration: formatDuration(holdDurationMs),
|
|
367
|
+
holdDurationMs,
|
|
368
|
+
estimatedFundingIncome: persisted.accumulatedFunding ?? 0,
|
|
369
|
+
estimatedFees: 0,
|
|
370
|
+
netPnl: shortPos.unrealizedPnl + (persisted.accumulatedFunding ?? 0),
|
|
371
|
+
dailyFundingEstimate: Math.abs(hourlyRate) * perpNotional * 24,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// ── JSON output ──
|
|
221
375
|
if (isJson()) {
|
|
222
|
-
const
|
|
223
|
-
const
|
|
376
|
+
const totalNotionalPP = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
|
|
377
|
+
const totalDailyPP = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
|
|
378
|
+
const totalNotionalSP = spotPerpPositions.reduce((s, p) => s + p.perpNotionalUsd, 0);
|
|
379
|
+
const totalDailySP = spotPerpPositions.reduce((s, p) => s + p.dailyFundingEstimate, 0);
|
|
380
|
+
const totalNotionalJ = totalNotionalPP + totalNotionalSP;
|
|
381
|
+
const totalDailyJ = totalDailyPP + totalDailySP;
|
|
224
382
|
return printJson(jsonOk({
|
|
225
|
-
|
|
383
|
+
perpPerpPairs: arbPairs,
|
|
384
|
+
spotPerpPairs: spotPerpPositions,
|
|
226
385
|
aggregate: {
|
|
227
|
-
|
|
386
|
+
perpPerpCount: arbPairs.length,
|
|
387
|
+
spotPerpCount: spotPerpPositions.length,
|
|
388
|
+
totalPositions: arbPairs.length + spotPerpPositions.length,
|
|
228
389
|
totalNotionalUsd: totalNotionalJ,
|
|
229
|
-
unrealizedPnl: arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0),
|
|
230
|
-
estimatedFunding: arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0),
|
|
231
|
-
estimatedFees: arbPairs.reduce((s, p) => s + p.estimatedFees, 0),
|
|
232
|
-
netPnl: arbPairs.reduce((s, p) => s + p.netPnl, 0),
|
|
390
|
+
unrealizedPnl: arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0) + spotPerpPositions.reduce((s, p) => s + p.perpUnrealizedPnl, 0),
|
|
391
|
+
estimatedFunding: arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0) + spotPerpPositions.reduce((s, p) => s + p.estimatedFundingIncome, 0),
|
|
392
|
+
estimatedFees: arbPairs.reduce((s, p) => s + p.estimatedFees, 0) + spotPerpPositions.reduce((s, p) => s + p.estimatedFees, 0),
|
|
393
|
+
netPnl: arbPairs.reduce((s, p) => s + p.netPnl, 0) + spotPerpPositions.reduce((s, p) => s + p.netPnl, 0),
|
|
233
394
|
dailyFundingEstimate: totalDailyJ,
|
|
234
395
|
annualizedApr: totalNotionalJ > 0 ? (totalDailyJ * 365 / totalNotionalJ) * 100 : 0,
|
|
235
396
|
},
|
|
236
397
|
}));
|
|
237
398
|
}
|
|
238
|
-
if (arbPairs.length === 0) {
|
|
399
|
+
if (arbPairs.length === 0 && spotPerpPositions.length === 0) {
|
|
239
400
|
console.log(chalk.gray(" No open arb positions found.\n"));
|
|
240
401
|
return;
|
|
241
402
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
?
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
403
|
+
// ── Perp-Perp table ──
|
|
404
|
+
if (arbPairs.length > 0) {
|
|
405
|
+
console.log(chalk.cyan.bold(" Perp-Perp Arb Positions\n"));
|
|
406
|
+
const rows = arbPairs.map(p => {
|
|
407
|
+
const spreadStr = p.currentSpread !== null ? `${p.currentSpread.toFixed(1)}%` : "-";
|
|
408
|
+
const entrySpreadStr = p.entrySpread !== null ? `${p.entrySpread.toFixed(1)}%` : "-";
|
|
409
|
+
const holdStr = p.holdDuration ?? "-";
|
|
410
|
+
const avgNotional = (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2;
|
|
411
|
+
const basis = computeBasisRisk(p.longPosition.markPrice, p.shortPosition.markPrice);
|
|
412
|
+
const basisStr = basis.divergencePct > 0
|
|
413
|
+
? (basis.warning
|
|
414
|
+
? chalk.red(`${basis.divergencePct.toFixed(1)}%`)
|
|
415
|
+
: chalk.gray(`${basis.divergencePct.toFixed(1)}%`))
|
|
416
|
+
: chalk.gray("-");
|
|
417
|
+
return [
|
|
418
|
+
chalk.white.bold(p.symbol),
|
|
419
|
+
chalk.green(p.longExchange),
|
|
420
|
+
chalk.red(p.shortExchange),
|
|
421
|
+
`$${formatUsd(avgNotional)}`,
|
|
422
|
+
`$${p.longPosition.entryPrice.toFixed(2)} / $${p.shortPosition.entryPrice.toFixed(2)}`,
|
|
423
|
+
`$${p.longPosition.markPrice.toFixed(2)}`,
|
|
424
|
+
formatPnl(p.unrealizedPnl),
|
|
425
|
+
chalk.yellow(`$${p.estimatedFundingIncome.toFixed(4)}`),
|
|
426
|
+
`${entrySpreadStr} -> ${spreadStr}`,
|
|
427
|
+
basisStr,
|
|
428
|
+
holdStr,
|
|
429
|
+
formatPnl(p.netPnl),
|
|
430
|
+
];
|
|
431
|
+
});
|
|
432
|
+
console.log(makeTable(["Symbol", "Long", "Short", "Size", "Entry", "Mark", "uPnL", "Funding", "Spread", "Basis", "Hold", "Net PnL"], rows));
|
|
433
|
+
}
|
|
434
|
+
// ── Spot-Perp table ──
|
|
435
|
+
if (spotPerpPositions.length > 0) {
|
|
436
|
+
console.log(chalk.cyan.bold(" Spot-Perp Arb Positions\n"));
|
|
437
|
+
const spRows = spotPerpPositions.map(sp => {
|
|
438
|
+
const spotLabel = sp.spotToken !== sp.symbol
|
|
439
|
+
? `${sp.spotToken} (${sp.spotExchange})`
|
|
440
|
+
: sp.spotExchange;
|
|
441
|
+
const spreadStr = sp.currentSpread !== null ? `${sp.currentSpread.toFixed(1)}%` : "-";
|
|
442
|
+
return [
|
|
443
|
+
chalk.white.bold(sp.symbol),
|
|
444
|
+
chalk.green(`${sp.spotAmount.toFixed(4)} ${sp.spotToken}`),
|
|
445
|
+
spotLabel,
|
|
446
|
+
chalk.red(sp.perpExchange),
|
|
447
|
+
`$${formatUsd(sp.perpNotionalUsd)}`,
|
|
448
|
+
`$${sp.perpMarkPrice.toFixed(2)}`,
|
|
449
|
+
formatPnl(sp.perpUnrealizedPnl),
|
|
450
|
+
chalk.yellow(`$${sp.estimatedFundingIncome.toFixed(4)}`),
|
|
451
|
+
spreadStr,
|
|
452
|
+
sp.holdDuration ?? "-",
|
|
453
|
+
formatPnl(sp.netPnl),
|
|
454
|
+
];
|
|
455
|
+
});
|
|
456
|
+
console.log(makeTable(["Symbol", "Spot Held", "Spot Ex", "Perp Ex", "Size", "Mark", "uPnL", "Funding", "Spread", "Hold", "Net PnL"], spRows));
|
|
457
|
+
}
|
|
458
|
+
// ── Combined Summary ──
|
|
459
|
+
const totalUpnl = arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0)
|
|
460
|
+
+ spotPerpPositions.reduce((s, p) => s + p.perpUnrealizedPnl, 0);
|
|
461
|
+
const totalFunding = arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0)
|
|
462
|
+
+ spotPerpPositions.reduce((s, p) => s + p.estimatedFundingIncome, 0);
|
|
463
|
+
const totalFees = arbPairs.reduce((s, p) => s + p.estimatedFees, 0)
|
|
464
|
+
+ spotPerpPositions.reduce((s, p) => s + p.estimatedFees, 0);
|
|
465
|
+
const totalNet = arbPairs.reduce((s, p) => s + p.netPnl, 0)
|
|
466
|
+
+ spotPerpPositions.reduce((s, p) => s + p.netPnl, 0);
|
|
467
|
+
const totalDailyFunding = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0)
|
|
468
|
+
+ spotPerpPositions.reduce((s, p) => s + p.dailyFundingEstimate, 0);
|
|
469
|
+
const totalNotional = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0)
|
|
470
|
+
+ spotPerpPositions.reduce((s, p) => s + p.perpNotionalUsd, 0);
|
|
278
471
|
const portfolioApr = totalNotional > 0 ? (totalDailyFunding * 365 / totalNotional) * 100 : 0;
|
|
279
472
|
console.log(chalk.white.bold("\n Summary"));
|
|
280
|
-
console.log(`
|
|
473
|
+
console.log(` Perp-Perp: ${arbPairs.length} positions`);
|
|
474
|
+
console.log(` Spot-Perp: ${spotPerpPositions.length} positions`);
|
|
281
475
|
console.log(` Total Notional: $${formatUsd(totalNotional)}`);
|
|
282
476
|
console.log(` Unrealized PnL: ${formatPnl(totalUpnl)}`);
|
|
283
477
|
console.log(` Est. Funding: ${chalk.yellow(`$${totalFunding.toFixed(4)}`)}`);
|
|
@@ -910,19 +910,30 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
910
910
|
const holdDays = parseFloat(opts.holdDays);
|
|
911
911
|
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
912
912
|
const sizeUsd = parseFloat(opts.size);
|
|
913
|
+
let spotSpreads = [];
|
|
913
914
|
// ── spot-perp mode ──
|
|
914
915
|
if (opts.mode === "spot-perp" || opts.mode === "all") {
|
|
915
916
|
if (!isJson())
|
|
916
917
|
console.log(chalk.cyan("\n Scanning spot+perp funding opportunities...\n"));
|
|
917
918
|
const { fetchSpotPerpSpreads } = await import("../funding/rates.js");
|
|
918
|
-
|
|
919
|
+
({ spreads: spotSpreads } = await fetchSpotPerpSpreads({ minSpread }));
|
|
919
920
|
if (isJson() && opts.mode === "spot-perp") {
|
|
920
|
-
let result = spotSpreads.map(s =>
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
921
|
+
let result = spotSpreads.map(s => {
|
|
922
|
+
// Use best spot exchange for fee calc; spot+perp same legs as perp-perp cost model
|
|
923
|
+
const spotEx = s.spotExchanges[0] || "lighter";
|
|
924
|
+
const rtCost = computeRoundTripCostPct(spotEx, s.perpExchange);
|
|
925
|
+
// Same-exchange spot+perp → no bridge cost
|
|
926
|
+
const effectiveBridgeCost = s.spotExchanges.includes(s.perpExchange) ? 0 : bridgeCostUsd;
|
|
927
|
+
const netSpread = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
|
|
928
|
+
return {
|
|
929
|
+
mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
|
|
930
|
+
spotExchanges: s.spotExchanges, grossSpreadPct: s.annualSpreadPct,
|
|
931
|
+
netSpreadPct: netSpread, estFeesPct: rtCost,
|
|
932
|
+
perpFundingRate: s.perpFundingRate, direction: s.direction,
|
|
933
|
+
markPrice: s.bestMarkPrice, estHourlyIncomeUsd: s.estHourlyIncomeUsd,
|
|
934
|
+
breakEvenDays: rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0,
|
|
935
|
+
};
|
|
936
|
+
}).sort((a, b) => b.netSpreadPct - a.netSpreadPct);
|
|
926
937
|
if (opts.top)
|
|
927
938
|
result = result.slice(0, parseInt(opts.top));
|
|
928
939
|
return printJson(jsonOk(result));
|
|
@@ -932,18 +943,28 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
932
943
|
console.log(chalk.gray(` No spot+perp opportunities above ${minSpread}%\n`));
|
|
933
944
|
}
|
|
934
945
|
else {
|
|
935
|
-
console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"
|
|
946
|
+
console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"BE".padEnd(5)} ${"PERP".padEnd(5)} ${"SPOT".padEnd(8)} DIRECTION`));
|
|
936
947
|
for (const s of spotSpreads.slice(0, parseInt(opts.top ?? "30"))) {
|
|
937
|
-
const
|
|
948
|
+
const spotEx = s.spotExchanges[0] || "lighter";
|
|
949
|
+
const rtCost = computeRoundTripCostPct(spotEx, s.perpExchange);
|
|
950
|
+
const effectiveBridgeCost = s.spotExchanges.includes(s.perpExchange) ? 0 : bridgeCostUsd;
|
|
951
|
+
const netSpread = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
|
|
952
|
+
const breakEvenDays = rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0;
|
|
953
|
+
const grossColor = s.annualSpreadPct >= 30 ? chalk.green : chalk.yellow;
|
|
954
|
+
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
938
955
|
const dir = s.direction === "long-spot-short-perp" ? "Spot+Short" : "Sell+Long";
|
|
956
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
939
957
|
console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
|
|
940
|
-
`${
|
|
941
|
-
`${
|
|
942
|
-
`${
|
|
943
|
-
`${
|
|
958
|
+
`${grossColor(`${s.annualSpreadPct.toFixed(1)}%`.padEnd(8))} ` +
|
|
959
|
+
`${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
960
|
+
`${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
|
|
961
|
+
`${chalk.gray(`${breakEvenDays}d`.padEnd(5))} ` +
|
|
962
|
+
`${exAbbr(s.perpExchange).padEnd(5)} ` +
|
|
963
|
+
`${s.spotExchanges.map(exAbbr).join(",").padEnd(8)} ` +
|
|
944
964
|
dir);
|
|
945
965
|
}
|
|
946
966
|
console.log(chalk.gray(`\n ${spotSpreads.length} spot+perp opportunities (spot funding = 0%)`));
|
|
967
|
+
console.log(chalk.gray(` Net spread assumes ${holdDays}d hold, $${bridgeCostUsd} bridge, $${sizeUsd} size. BE = break-even days`));
|
|
947
968
|
}
|
|
948
969
|
}
|
|
949
970
|
if (opts.mode === "spot-perp")
|
|
@@ -957,22 +978,42 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
957
978
|
const spreads = await fetchFundingSpreads();
|
|
958
979
|
const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
|
|
959
980
|
if (isJson()) {
|
|
960
|
-
|
|
981
|
+
const perpPerp = filtered.map(s => {
|
|
961
982
|
const grossSpread = Math.abs(s.spread);
|
|
962
983
|
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
963
984
|
const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
964
|
-
|
|
965
|
-
|
|
985
|
+
// Compute price gap between long and short exchanges
|
|
986
|
+
const longPrice = s.longExch === "pacifica" ? s.pacMarkPrice : s.longExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
|
|
987
|
+
const shortPrice = s.shortExch === "pacifica" ? s.pacMarkPrice : s.shortExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
|
|
988
|
+
const mid = (longPrice + shortPrice) / 2;
|
|
989
|
+
const priceGapPct = mid > 0 ? Math.abs(longPrice - shortPrice) / mid * 100 : 0;
|
|
990
|
+
return { mode: "perp-perp", symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost, priceGapPct: Math.round(priceGapPct * 10000) / 10000 };
|
|
991
|
+
});
|
|
992
|
+
// Merge spot-perp results if "all" mode
|
|
993
|
+
const spotPerp = (opts.mode === "all" && spotSpreads) ? spotSpreads.map(s => {
|
|
994
|
+
const spotEx = s.spotExchanges[0] || "lighter";
|
|
995
|
+
const rtCost = computeRoundTripCostPct(spotEx, s.perpExchange);
|
|
996
|
+
const effectiveBridgeCost = s.spotExchanges.includes(s.perpExchange) ? 0 : bridgeCostUsd;
|
|
997
|
+
const net = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
|
|
998
|
+
return {
|
|
999
|
+
mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
|
|
1000
|
+
spotExchanges: s.spotExchanges, grossSpread: s.annualSpreadPct,
|
|
1001
|
+
netSpread: net, estFeesPct: rtCost,
|
|
1002
|
+
perpFundingRate: s.perpFundingRate, direction: s.direction,
|
|
1003
|
+
markPrice: s.bestMarkPrice, breakEvenDays: rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0,
|
|
1004
|
+
};
|
|
1005
|
+
}) : [];
|
|
1006
|
+
let combined = [...perpPerp, ...spotPerp].sort((a, b) => b.netSpread - a.netSpread);
|
|
966
1007
|
if (opts.top)
|
|
967
|
-
|
|
968
|
-
return printJson(jsonOk(
|
|
1008
|
+
combined = combined.slice(0, parseInt(opts.top));
|
|
1009
|
+
return printJson(jsonOk(combined));
|
|
969
1010
|
}
|
|
970
1011
|
if (filtered.length === 0) {
|
|
971
1012
|
console.log(chalk.gray(` No perp-perp spreads above ${minSpread}%\n`));
|
|
972
1013
|
return;
|
|
973
1014
|
}
|
|
974
1015
|
// Header
|
|
975
|
-
console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"DIR".padEnd(7)} RATES`));
|
|
1016
|
+
console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"GAP".padEnd(8)} ${"DIR".padEnd(7)} RATES`));
|
|
976
1017
|
for (const s of filtered) {
|
|
977
1018
|
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
978
1019
|
const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
|
|
@@ -981,6 +1022,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
981
1022
|
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
982
1023
|
const grossColor = grossSpread >= 30 ? chalk.green : chalk.yellow;
|
|
983
1024
|
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
1025
|
+
// Price gap between long/short exchanges
|
|
1026
|
+
const longPrice = s.longExch === "pacifica" ? s.pacMarkPrice : s.longExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
|
|
1027
|
+
const shortPrice = s.shortExch === "pacifica" ? s.pacMarkPrice : s.shortExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
|
|
1028
|
+
const mid = (longPrice + shortPrice) / 2;
|
|
1029
|
+
const priceGapPct = mid > 0 ? Math.abs(longPrice - shortPrice) / mid * 100 : 0;
|
|
1030
|
+
const gapColor = priceGapPct >= 0.5 ? chalk.red : priceGapPct >= 0.1 ? chalk.yellow : chalk.gray;
|
|
984
1031
|
const rates = [];
|
|
985
1032
|
if (s.pacRate)
|
|
986
1033
|
rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
|
|
@@ -992,6 +1039,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
992
1039
|
`${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
993
1040
|
`${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
994
1041
|
`${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
|
|
1042
|
+
`${gapColor(`${priceGapPct.toFixed(3)}%`.padEnd(8))} ` +
|
|
995
1043
|
`${direction.padEnd(7)} ` +
|
|
996
1044
|
rates.join(" "));
|
|
997
1045
|
}
|
package/dist/commands/arb.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
|
|
2
|
+
import type { ExchangeAdapter } from "../exchanges/interface.js";
|
|
3
|
+
export declare function registerArbCommands(program: Command, isJson: () => boolean, getAdapterForExchange?: (exchange: string) => Promise<ExchangeAdapter>): void;
|