perp-cli 0.4.2 → 0.4.9
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 +46 -5
- package/dist/arb/reconcile.d.ts +28 -0
- package/dist/arb/reconcile.js +162 -0
- package/dist/arb/sizing.d.ts +12 -1
- package/dist/arb/sizing.js +38 -5
- package/dist/arb/state.d.ts +6 -0
- package/dist/cli-spec.d.ts +0 -4
- package/dist/cli-spec.js +8 -1
- package/dist/commands/account.d.ts +1 -1
- package/dist/commands/account.js +300 -63
- package/dist/commands/agent.js +16 -9
- package/dist/commands/arb/index.js +243 -49
- package/dist/commands/arb-auto.js +696 -9
- 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/dex.js +7 -1
- package/dist/commands/funding.d.ts +2 -1
- package/dist/commands/funding.js +211 -4
- 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 +6 -4
- package/dist/commands/market.js +35 -2
- package/dist/commands/multileg.d.ts +16 -0
- package/dist/commands/multileg.js +174 -0
- package/dist/commands/pnl.d.ts +12 -0
- package/dist/commands/pnl.js +243 -0
- 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/stream.js +132 -55
- package/dist/commands/trade.d.ts +1 -1
- package/dist/commands/trade.js +270 -267
- package/dist/commands/wallet.d.ts +4 -0
- package/dist/commands/wallet.js +11 -1
- package/dist/config.d.ts +6 -1
- package/dist/config.js +21 -1
- package/dist/dashboard/ws-feeds.js +1 -1
- package/dist/equity-tracker.d.ts +57 -0
- package/dist/equity-tracker.js +209 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +69 -0
- package/dist/exchanges/hyperliquid-spot.js +309 -0
- package/dist/exchanges/hyperliquid.d.ts +26 -5
- package/dist/exchanges/hyperliquid.js +171 -73
- package/dist/exchanges/interface.d.ts +1 -1
- package/dist/exchanges/lighter-spot.d.ts +55 -0
- package/dist/exchanges/lighter-spot.js +415 -0
- package/dist/exchanges/lighter.d.ts +10 -2
- package/dist/exchanges/lighter.js +210 -56
- package/dist/exchanges/pacifica.d.ts +11 -4
- package/dist/exchanges/pacifica.js +64 -37
- package/dist/exchanges/spot-interface.d.ts +45 -0
- package/dist/exchanges/spot-interface.js +22 -0
- package/dist/execution-log.d.ts +2 -2
- package/dist/funding/rates.d.ts +36 -0
- package/dist/funding/rates.js +146 -0
- package/dist/index.js +126 -161
- package/dist/liquidity.d.ts +12 -0
- package/dist/liquidity.js +43 -0
- package/dist/mcp-server.js +43 -96
- package/dist/signer/evm-local.d.ts +12 -0
- package/dist/signer/evm-local.js +19 -0
- package/dist/signer/index.d.ts +3 -0
- package/dist/signer/index.js +2 -0
- package/dist/signer/interface.d.ts +31 -0
- package/dist/signer/interface.js +7 -0
- package/dist/signer/solana-local.d.ts +13 -0
- package/dist/signer/solana-local.js +19 -0
- package/dist/smart-order.js +14 -3
- package/dist/strategies/split-order.d.ts +49 -0
- package/dist/strategies/split-order.js +138 -0
- package/dist/utils.js +7 -1
- package/dist/ws/market-feed.d.ts +74 -0
- package/dist/ws/market-feed.js +227 -0
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +80 -4
- package/skills/perp-cli/references/agent-operations.md +19 -9
- package/skills/perp-cli/references/commands.md +54 -32
- package/skills/perp-cli/references/strategies.md +27 -17
- package/skills/perp-cli/scripts/arb-monitor.sh +74 -0
- package/skills/perp-cli/scripts/funding-analysis.sh +85 -0
- package/skills/perp-cli/scripts/preflight.sh +98 -0
- package/skills/perp-cli/scripts/spot-perp-scan.sh +83 -0
- package/skills/perp-cli/scripts/validate-arb.sh +112 -0
- package/skills/perp-cli/templates/arb-scan.sh +0 -15
- package/skills/perp-cli/templates/open-position.sh +0 -38
- package/skills/perp-cli/templates/wallet-setup.sh +0 -17
|
@@ -898,7 +898,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
898
898
|
// ── arb scan ── (one-shot spread scan)
|
|
899
899
|
arb
|
|
900
900
|
.command("scan")
|
|
901
|
-
.description("Scan current funding rate spreads")
|
|
901
|
+
.description("Scan current funding rate spreads (perp-perp or spot-perp)")
|
|
902
|
+
.option("--mode <mode>", "Scan mode: perp-perp, spot-perp, all", "perp-perp")
|
|
902
903
|
.option("--min <pct>", "Min annual spread to show", "10")
|
|
903
904
|
.option("--top <n>", "Return only top N results (for JSON output)")
|
|
904
905
|
.option("--hold-days <days>", "Expected hold period for cost calc", "7")
|
|
@@ -909,27 +910,110 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
909
910
|
const holdDays = parseFloat(opts.holdDays);
|
|
910
911
|
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
911
912
|
const sizeUsd = parseFloat(opts.size);
|
|
913
|
+
let spotSpreads = [];
|
|
914
|
+
// ── spot-perp mode ──
|
|
915
|
+
if (opts.mode === "spot-perp" || opts.mode === "all") {
|
|
916
|
+
if (!isJson())
|
|
917
|
+
console.log(chalk.cyan("\n Scanning spot+perp funding opportunities...\n"));
|
|
918
|
+
const { fetchSpotPerpSpreads } = await import("../funding/rates.js");
|
|
919
|
+
({ spreads: spotSpreads } = await fetchSpotPerpSpreads({ minSpread }));
|
|
920
|
+
if (isJson() && opts.mode === "spot-perp") {
|
|
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);
|
|
937
|
+
if (opts.top)
|
|
938
|
+
result = result.slice(0, parseInt(opts.top));
|
|
939
|
+
return printJson(jsonOk(result));
|
|
940
|
+
}
|
|
941
|
+
if (!isJson()) {
|
|
942
|
+
if (spotSpreads.length === 0) {
|
|
943
|
+
console.log(chalk.gray(` No spot+perp opportunities above ${minSpread}%\n`));
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
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`));
|
|
947
|
+
for (const s of spotSpreads.slice(0, parseInt(opts.top ?? "30"))) {
|
|
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;
|
|
955
|
+
const dir = s.direction === "long-spot-short-perp" ? "Spot+Short" : "Sell+Long";
|
|
956
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
957
|
+
console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
|
|
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)} ` +
|
|
964
|
+
dir);
|
|
965
|
+
}
|
|
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`));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (opts.mode === "spot-perp")
|
|
971
|
+
return;
|
|
972
|
+
if (!isJson())
|
|
973
|
+
console.log(""); // separator
|
|
974
|
+
}
|
|
975
|
+
// ── perp-perp mode (default) ──
|
|
912
976
|
if (!isJson())
|
|
913
|
-
console.log(chalk.cyan("
|
|
977
|
+
console.log(chalk.cyan(" Scanning perp-perp funding rate spreads...\n"));
|
|
914
978
|
const spreads = await fetchFundingSpreads();
|
|
915
979
|
const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
|
|
916
980
|
if (isJson()) {
|
|
917
|
-
|
|
981
|
+
const perpPerp = filtered.map(s => {
|
|
918
982
|
const grossSpread = Math.abs(s.spread);
|
|
919
983
|
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
920
984
|
const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
921
|
-
|
|
922
|
-
|
|
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);
|
|
923
1007
|
if (opts.top)
|
|
924
|
-
|
|
925
|
-
return printJson(jsonOk(
|
|
1008
|
+
combined = combined.slice(0, parseInt(opts.top));
|
|
1009
|
+
return printJson(jsonOk(combined));
|
|
926
1010
|
}
|
|
927
1011
|
if (filtered.length === 0) {
|
|
928
|
-
console.log(chalk.gray(` No spreads above ${minSpread}%\n`));
|
|
1012
|
+
console.log(chalk.gray(` No perp-perp spreads above ${minSpread}%\n`));
|
|
929
1013
|
return;
|
|
930
1014
|
}
|
|
931
1015
|
// Header
|
|
932
|
-
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`));
|
|
933
1017
|
for (const s of filtered) {
|
|
934
1018
|
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
935
1019
|
const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
|
|
@@ -938,6 +1022,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
938
1022
|
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
939
1023
|
const grossColor = grossSpread >= 30 ? chalk.green : chalk.yellow;
|
|
940
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;
|
|
941
1031
|
const rates = [];
|
|
942
1032
|
if (s.pacRate)
|
|
943
1033
|
rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
|
|
@@ -949,6 +1039,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
949
1039
|
`${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
950
1040
|
`${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
951
1041
|
`${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
|
|
1042
|
+
`${gapColor(`${priceGapPct.toFixed(3)}%`.padEnd(8))} ` +
|
|
952
1043
|
`${direction.padEnd(7)} ` +
|
|
953
1044
|
rates.join(" "));
|
|
954
1045
|
}
|
|
@@ -1181,6 +1272,602 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1181
1272
|
console.log(chalk.red(` SHORT: ${result.shortError}\n`));
|
|
1182
1273
|
}
|
|
1183
1274
|
});
|
|
1275
|
+
// ── arb spot-exec ── (spot+perp arb execution)
|
|
1276
|
+
arb
|
|
1277
|
+
.command("spot-exec")
|
|
1278
|
+
.description("Execute spot+perp arb: buy spot + short perp simultaneously")
|
|
1279
|
+
.argument("<symbol>", "Symbol (e.g. ETH, BTC)")
|
|
1280
|
+
.argument("<spotExch>", "Spot exchange: hl (Hyperliquid) or lt (Lighter)")
|
|
1281
|
+
.argument("<perpExch>", "Perp exchange: hl, lt, pac")
|
|
1282
|
+
.argument("<sizeUsd>", "Position size per leg in USD")
|
|
1283
|
+
.option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
|
|
1284
|
+
.option("--leverage <n>", "Set leverage on perp side before entry")
|
|
1285
|
+
.option("--isolated", "Use isolated margin mode on perp side")
|
|
1286
|
+
.option("--dry-run", "Simulate without executing trades")
|
|
1287
|
+
.action(async (symbol, spotExch, perpExch, sizeUsdStr, opts) => {
|
|
1288
|
+
const sym = symbol.toUpperCase();
|
|
1289
|
+
const sizeUsd = parseFloat(sizeUsdStr);
|
|
1290
|
+
const maxSlippage = parseFloat(opts.maxSlippage);
|
|
1291
|
+
const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
|
|
1292
|
+
// Resolve exchange aliases
|
|
1293
|
+
const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
|
|
1294
|
+
spotExch = aliasMap[spotExch.toLowerCase()] || spotExch.toLowerCase();
|
|
1295
|
+
perpExch = aliasMap[perpExch.toLowerCase()] || perpExch.toLowerCase();
|
|
1296
|
+
// Validate spot exchange
|
|
1297
|
+
if (!["hyperliquid", "lighter"].includes(spotExch)) {
|
|
1298
|
+
const msg = `Spot exchange must be hl or lt (got ${spotExch}). Pacifica is perp-only.`;
|
|
1299
|
+
if (isJson())
|
|
1300
|
+
return printJson(jsonOk({ error: msg }));
|
|
1301
|
+
console.log(chalk.red(` ${msg}`));
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
// Load spot adapter
|
|
1305
|
+
const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
|
|
1306
|
+
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
1307
|
+
let spotAdapter;
|
|
1308
|
+
if (spotExch === "hyperliquid") {
|
|
1309
|
+
const hlAdapter = await getAdapterForExchange("hyperliquid");
|
|
1310
|
+
const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
|
|
1311
|
+
await hlSpot.init();
|
|
1312
|
+
spotAdapter = hlSpot;
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
const ltAdapter = await getAdapterForExchange("lighter");
|
|
1316
|
+
const ltSpot = new LighterSpotAdapter(ltAdapter);
|
|
1317
|
+
await ltSpot.init();
|
|
1318
|
+
spotAdapter = ltSpot;
|
|
1319
|
+
}
|
|
1320
|
+
const perpAdapter = await getAdapterForExchange(perpExch);
|
|
1321
|
+
// 1. Set perp leverage if requested
|
|
1322
|
+
if (opts.leverage) {
|
|
1323
|
+
const lev = parseInt(opts.leverage);
|
|
1324
|
+
const mode = opts.isolated ? "isolated" : "cross";
|
|
1325
|
+
if (!isJson())
|
|
1326
|
+
console.log(chalk.gray(` Setting perp leverage ${lev}x ${mode} on ${perpExch}...`));
|
|
1327
|
+
try {
|
|
1328
|
+
await perpAdapter.setLeverage(sym, lev, mode);
|
|
1329
|
+
}
|
|
1330
|
+
catch (e) {
|
|
1331
|
+
const msg = `Failed to set leverage: ${e instanceof Error ? e.message : e}`;
|
|
1332
|
+
if (isJson())
|
|
1333
|
+
return printJson(jsonOk({ error: msg }));
|
|
1334
|
+
console.log(chalk.red(` ${msg}`));
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
// 2. Auto-transfer USDC to spot account if needed
|
|
1339
|
+
if (spotExch === "hyperliquid") {
|
|
1340
|
+
const hlSpot = spotAdapter;
|
|
1341
|
+
const transferAmt = Math.ceil(sizeUsd * 1.1);
|
|
1342
|
+
try {
|
|
1343
|
+
const xferResult = await hlSpot.transferUsdcToSpot(transferAmt);
|
|
1344
|
+
if (xferResult?.status === "err" && xferResult.response?.includes("unified")) {
|
|
1345
|
+
if (!isJson())
|
|
1346
|
+
console.log(chalk.gray(` Unified account — no USDC transfer needed`));
|
|
1347
|
+
}
|
|
1348
|
+
else if (xferResult?.status !== "err") {
|
|
1349
|
+
if (!isJson())
|
|
1350
|
+
console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot`));
|
|
1351
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
// Non-critical — may be unified account or already have balance
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
else if (spotExch === "lighter") {
|
|
1359
|
+
// Lighter requires explicit USDC transfer from perp → spot account
|
|
1360
|
+
const ltSpot = spotAdapter;
|
|
1361
|
+
const transferAmt = Math.ceil(sizeUsd * 1.1);
|
|
1362
|
+
try {
|
|
1363
|
+
const xferResult = await ltSpot.transferUsdcToSpot(transferAmt);
|
|
1364
|
+
if (!isJson())
|
|
1365
|
+
console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot on Lighter`));
|
|
1366
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1367
|
+
}
|
|
1368
|
+
catch (e) {
|
|
1369
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1370
|
+
if (!isJson())
|
|
1371
|
+
console.log(chalk.yellow(` USDC transfer warning: ${msg}`));
|
|
1372
|
+
// Continue — spot order may still work if spot balance already exists
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
// 2b. Verify spot USDC balance is sufficient after transfer
|
|
1376
|
+
try {
|
|
1377
|
+
const spotBalances = await spotAdapter.getSpotBalances();
|
|
1378
|
+
const spotUsdc = spotBalances.find(b => b.token === "USDC_SPOT" || b.token === "USDC");
|
|
1379
|
+
const spotUsdcAvail = Number(spotUsdc?.available ?? 0);
|
|
1380
|
+
// Also check perp balance for the perp leg
|
|
1381
|
+
const perpBalance = await perpAdapter.getBalance();
|
|
1382
|
+
const perpAvail = Number(perpBalance?.available ?? 0);
|
|
1383
|
+
if (spotUsdcAvail < sizeUsd * 0.9) {
|
|
1384
|
+
const msg = `Insufficient spot USDC: have $${spotUsdcAvail.toFixed(2)}, need ~$${sizeUsd}. Transfer may have failed.`;
|
|
1385
|
+
if (isJson())
|
|
1386
|
+
return printJson(jsonOk({ error: msg, spotUsdcAvail, required: sizeUsd }));
|
|
1387
|
+
console.log(chalk.red(` ${msg}`));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (perpAvail < sizeUsd * 0.5) {
|
|
1391
|
+
const msg = `Insufficient perp balance: have $${perpAvail.toFixed(2)} available on ${perpExch}, need margin for ~$${sizeUsd}`;
|
|
1392
|
+
if (isJson())
|
|
1393
|
+
return printJson(jsonOk({ error: msg, perpAvail, required: sizeUsd }));
|
|
1394
|
+
console.log(chalk.red(` ${msg}`));
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
catch {
|
|
1399
|
+
// Balance check failed — continue with execution, let order placement catch errors
|
|
1400
|
+
}
|
|
1401
|
+
// 3. Fetch orderbooks simultaneously
|
|
1402
|
+
const spotSymbol = `${sym}/USDC`;
|
|
1403
|
+
if (!isJson())
|
|
1404
|
+
console.log(chalk.gray(` Checking orderbook depth (spot ${spotExch} + perp ${perpExch})...`));
|
|
1405
|
+
const [spotBook, perpBook] = await Promise.all([
|
|
1406
|
+
spotAdapter.getSpotOrderbook(spotSymbol),
|
|
1407
|
+
perpAdapter.getOrderbook(sym),
|
|
1408
|
+
]);
|
|
1409
|
+
// 3a. Price cross-validation: verify spot and perp are the same underlying
|
|
1410
|
+
// Prevents wrong-token trades if index mapping is incorrect
|
|
1411
|
+
const spotMid = spotBook.bids.length > 0 && spotBook.asks.length > 0
|
|
1412
|
+
? (Number(spotBook.bids[0][0]) + Number(spotBook.asks[0][0])) / 2 : 0;
|
|
1413
|
+
const perpMid = perpBook.bids.length > 0 && perpBook.asks.length > 0
|
|
1414
|
+
? (Number(perpBook.bids[0][0]) + Number(perpBook.asks[0][0])) / 2 : 0;
|
|
1415
|
+
if (spotMid > 0 && perpMid > 0) {
|
|
1416
|
+
const priceDeviation = Math.abs(spotMid - perpMid) / perpMid * 100;
|
|
1417
|
+
if (priceDeviation > 5) {
|
|
1418
|
+
const msg = `Price mismatch: spot mid $${spotMid.toFixed(4)} vs perp mid $${perpMid.toFixed(4)} (${priceDeviation.toFixed(1)}% deviation). Possible wrong token index — aborting.`;
|
|
1419
|
+
if (isJson())
|
|
1420
|
+
return printJson(jsonOk({ error: msg, spotMid, perpMid, deviationPct: priceDeviation }));
|
|
1421
|
+
console.log(chalk.red(`\n ${msg}`));
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
else if (spotMid === 0) {
|
|
1426
|
+
const msg = `Empty spot orderbook for ${sym} — cannot verify token identity`;
|
|
1427
|
+
if (isJson())
|
|
1428
|
+
return printJson(jsonOk({ error: msg }));
|
|
1429
|
+
console.log(chalk.red(`\n ${msg}`));
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
// 3b. Validate depth
|
|
1433
|
+
const spotCheck = computeExecutableSize(spotBook.asks, sizeUsd, maxSlippage);
|
|
1434
|
+
const perpCheck = computeExecutableSize(perpBook.bids, sizeUsd, maxSlippage);
|
|
1435
|
+
if (!spotCheck.canFillFull || !perpCheck.canFillFull) {
|
|
1436
|
+
const fillableUsd = Math.min(spotCheck.maxSize * spotCheck.avgFillPrice, perpCheck.maxSize * perpCheck.avgFillPrice);
|
|
1437
|
+
if (isJson())
|
|
1438
|
+
return printJson(jsonOk({
|
|
1439
|
+
error: "Insufficient orderbook depth",
|
|
1440
|
+
requestedUsd: sizeUsd, fillableUsd: Math.round(fillableUsd * 100) / 100,
|
|
1441
|
+
spot: { exchange: spotExch, depthUsd: spotCheck.depthUsd, canFill: spotCheck.canFillFull },
|
|
1442
|
+
perp: { exchange: perpExch, depthUsd: perpCheck.depthUsd, canFill: perpCheck.canFillFull },
|
|
1443
|
+
}));
|
|
1444
|
+
console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
|
|
1445
|
+
console.log(chalk.gray(` ${spotExch} spot asks: $${spotCheck.depthUsd.toFixed(0)}`));
|
|
1446
|
+
console.log(chalk.gray(` ${perpExch} perp bids: $${perpCheck.depthUsd.toFixed(0)}`));
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
// 4. Compute matched size
|
|
1450
|
+
const inferDecimals = (levels) => {
|
|
1451
|
+
for (const [, s] of levels) {
|
|
1452
|
+
const dot = s.indexOf(".");
|
|
1453
|
+
if (dot >= 0)
|
|
1454
|
+
return s.length - dot - 1;
|
|
1455
|
+
}
|
|
1456
|
+
return 0;
|
|
1457
|
+
};
|
|
1458
|
+
const spotDecimals = inferDecimals(spotBook.asks);
|
|
1459
|
+
const perpDecimals = inferDecimals(perpBook.bids);
|
|
1460
|
+
const decimals = Math.min(spotDecimals, perpDecimals);
|
|
1461
|
+
const matchedBase = Math.min(spotCheck.recommendedSize, perpCheck.recommendedSize);
|
|
1462
|
+
const matchedSize = (Math.floor(matchedBase * 10 ** decimals) / 10 ** decimals).toFixed(decimals);
|
|
1463
|
+
const avgPrice = (spotCheck.avgFillPrice + perpCheck.avgFillPrice) / 2;
|
|
1464
|
+
const matchedUsd = matchedBase * avgPrice;
|
|
1465
|
+
if (!isJson()) {
|
|
1466
|
+
console.log(chalk.cyan(`\n Spot+Perp Arb: ${sym}`));
|
|
1467
|
+
console.log(chalk.gray(` SPOT ${spotExch}: buy ${matchedSize} (~$${(matchedBase * spotCheck.avgFillPrice).toFixed(2)})`));
|
|
1468
|
+
console.log(chalk.gray(` PERP ${perpExch}: sell ${matchedSize} (~$${(matchedBase * perpCheck.avgFillPrice).toFixed(2)})`));
|
|
1469
|
+
console.log(chalk.gray(` Executing both legs simultaneously...\n`));
|
|
1470
|
+
}
|
|
1471
|
+
// 5. Execute both legs (or return dry-run result)
|
|
1472
|
+
if (dryRun) {
|
|
1473
|
+
const result = {
|
|
1474
|
+
status: "filled", mode: "spot-perp", symbol: sym, dryRun: true,
|
|
1475
|
+
size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
1476
|
+
spot: { exchange: spotExch, side: "buy", size: matchedSize },
|
|
1477
|
+
perp: { exchange: perpExch, side: "sell", size: matchedSize },
|
|
1478
|
+
};
|
|
1479
|
+
if (isJson())
|
|
1480
|
+
return printJson(jsonOk(result));
|
|
1481
|
+
console.log(chalk.cyan(` [DRY-RUN] Would execute: spot buy ${matchedSize} on ${spotExch} + perp sell ${matchedSize} on ${perpExch}`));
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
// Same exchange (e.g. Lighter) shares nonce → must execute sequentially
|
|
1485
|
+
// Different exchanges → execute simultaneously for speed
|
|
1486
|
+
const validatePerpFill = (r) => {
|
|
1487
|
+
const res = r;
|
|
1488
|
+
const statuses = res?.response?.data?.statuses;
|
|
1489
|
+
if (statuses && statuses.length > 0) {
|
|
1490
|
+
const st = statuses[0];
|
|
1491
|
+
if (st.error)
|
|
1492
|
+
throw new Error(`Perp sell ${sym}: ${st.error}`);
|
|
1493
|
+
const filled = st.filled;
|
|
1494
|
+
if (filled && Number(filled.totalSz ?? 0) === 0) {
|
|
1495
|
+
throw new Error(`Perp sell ${sym}: 0 fill`);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return r;
|
|
1499
|
+
};
|
|
1500
|
+
let spotResult;
|
|
1501
|
+
let perpResult;
|
|
1502
|
+
if (spotExch === perpExch) {
|
|
1503
|
+
// Sequential: spot first, then perp (avoids nonce collision on same exchange)
|
|
1504
|
+
spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "buy", matchedSize)
|
|
1505
|
+
.then(r => ({ status: "fulfilled", value: r }))
|
|
1506
|
+
.catch(e => ({ status: "rejected", reason: e }));
|
|
1507
|
+
if (spotResult.status === "fulfilled") {
|
|
1508
|
+
perpResult = await perpAdapter.marketOrder(sym, "sell", matchedSize)
|
|
1509
|
+
.then(validatePerpFill)
|
|
1510
|
+
.then(r => ({ status: "fulfilled", value: r }))
|
|
1511
|
+
.catch(e => ({ status: "rejected", reason: e }));
|
|
1512
|
+
}
|
|
1513
|
+
else {
|
|
1514
|
+
perpResult = { status: "rejected", reason: new Error("Skipped: spot failed") };
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
// Parallel: different exchanges, no nonce conflict
|
|
1519
|
+
[spotResult, perpResult] = await Promise.allSettled([
|
|
1520
|
+
spotAdapter.spotMarketOrder(spotSymbol, "buy", matchedSize),
|
|
1521
|
+
perpAdapter.marketOrder(sym, "sell", matchedSize).then(validatePerpFill),
|
|
1522
|
+
]);
|
|
1523
|
+
}
|
|
1524
|
+
const spotOk = spotResult.status === "fulfilled";
|
|
1525
|
+
const perpOk = perpResult.status === "fulfilled";
|
|
1526
|
+
if (spotOk && perpOk) {
|
|
1527
|
+
// ── Post-execution fill verification ──
|
|
1528
|
+
// Wait for exchange state to settle, then verify both legs actually filled
|
|
1529
|
+
let fillVerified = true;
|
|
1530
|
+
let verifyWarnings = [];
|
|
1531
|
+
try {
|
|
1532
|
+
await new Promise(r => setTimeout(r, 800));
|
|
1533
|
+
// Verify spot: check base token balance appeared
|
|
1534
|
+
const spotBals = await spotAdapter.getSpotBalances();
|
|
1535
|
+
const baseToken = sym; // e.g. "ETH"
|
|
1536
|
+
const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(baseToken));
|
|
1537
|
+
const spotTokenAmt = Number(spotTokenBal?.total ?? 0);
|
|
1538
|
+
const expectedSize = parseFloat(matchedSize);
|
|
1539
|
+
if (spotTokenAmt < expectedSize * 0.5) {
|
|
1540
|
+
fillVerified = false;
|
|
1541
|
+
verifyWarnings.push(`Spot buy may not have filled: expected ~${matchedSize} ${baseToken}, found ${spotTokenAmt.toFixed(6)}`);
|
|
1542
|
+
}
|
|
1543
|
+
// Verify perp: check short position exists
|
|
1544
|
+
const perpPositions = await perpAdapter.getPositions();
|
|
1545
|
+
const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
1546
|
+
if (!perpPos) {
|
|
1547
|
+
fillVerified = false;
|
|
1548
|
+
verifyWarnings.push(`Perp short position not found for ${sym} on ${perpExch} — fill may not have executed`);
|
|
1549
|
+
}
|
|
1550
|
+
else if (Number(perpPos.size) < expectedSize * 0.5) {
|
|
1551
|
+
verifyWarnings.push(`Perp short size ${perpPos.size} < expected ${matchedSize} — partial fill?`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
catch (verifyErr) {
|
|
1555
|
+
// Verification check itself failed — log but don't block
|
|
1556
|
+
verifyWarnings.push(`Fill verification check failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
|
|
1557
|
+
}
|
|
1558
|
+
if (verifyWarnings.length > 0 && !isJson()) {
|
|
1559
|
+
for (const w of verifyWarnings) {
|
|
1560
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (!fillVerified) {
|
|
1564
|
+
// Fills couldn't be confirmed — log as unverified but still persist
|
|
1565
|
+
// (rolling back based on uncertain data is worse than persisting)
|
|
1566
|
+
logExecution({
|
|
1567
|
+
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
1568
|
+
symbol: sym, side: "entry", size: matchedSize,
|
|
1569
|
+
status: "unverified", dryRun: false,
|
|
1570
|
+
meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd, warnings: verifyWarnings },
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
// Log execution — verified
|
|
1575
|
+
logExecution({
|
|
1576
|
+
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
1577
|
+
symbol: sym, side: "entry", size: matchedSize,
|
|
1578
|
+
status: "success", dryRun: false,
|
|
1579
|
+
meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd },
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
// Persist position state
|
|
1583
|
+
persistAddPosition({
|
|
1584
|
+
id: `${sym}-spot-${Date.now()}`,
|
|
1585
|
+
symbol: sym,
|
|
1586
|
+
longExchange: spotExch,
|
|
1587
|
+
shortExchange: perpExch,
|
|
1588
|
+
longSize: parseFloat(matchedSize),
|
|
1589
|
+
shortSize: parseFloat(matchedSize),
|
|
1590
|
+
entryTime: new Date().toISOString(),
|
|
1591
|
+
entrySpread: 0, // Will be updated on next scan
|
|
1592
|
+
entryLongPrice: spotCheck.avgFillPrice,
|
|
1593
|
+
entryShortPrice: perpCheck.avgFillPrice,
|
|
1594
|
+
accumulatedFunding: 0,
|
|
1595
|
+
lastCheckTime: new Date().toISOString(),
|
|
1596
|
+
mode: "spot-perp",
|
|
1597
|
+
spotExchange: spotExch,
|
|
1598
|
+
spotSymbol: spotSymbol,
|
|
1599
|
+
});
|
|
1600
|
+
// Return leftover USDC from spot back to perp (non-unified HL accounts)
|
|
1601
|
+
if (spotExch === "hyperliquid") {
|
|
1602
|
+
try {
|
|
1603
|
+
const hlSpot = spotAdapter;
|
|
1604
|
+
const bals = await hlSpot.getSpotBalances();
|
|
1605
|
+
const usdcBal = bals.find(b => b.token.startsWith("USDC"));
|
|
1606
|
+
const leftover = Number(usdcBal?.available ?? 0);
|
|
1607
|
+
if (leftover > 1) {
|
|
1608
|
+
const xfer = await hlSpot.transferUsdcToPerp(Math.floor(leftover));
|
|
1609
|
+
if (xfer?.status !== "err") {
|
|
1610
|
+
if (!isJson())
|
|
1611
|
+
console.log(chalk.gray(` Returned ~$${Math.floor(leftover)} USDC to perp account`));
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
catch { /* non-critical — unified accounts don't need transfer */ }
|
|
1616
|
+
}
|
|
1617
|
+
const result = {
|
|
1618
|
+
status: fillVerified ? "filled" : "filled_unverified",
|
|
1619
|
+
mode: "spot-perp", symbol: sym,
|
|
1620
|
+
size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
1621
|
+
spot: { exchange: spotExch, side: "buy", size: matchedSize },
|
|
1622
|
+
perp: { exchange: perpExch, side: "sell", size: matchedSize },
|
|
1623
|
+
...(verifyWarnings.length > 0 && { warnings: verifyWarnings }),
|
|
1624
|
+
};
|
|
1625
|
+
if (isJson())
|
|
1626
|
+
return printJson(jsonOk(result));
|
|
1627
|
+
if (fillVerified) {
|
|
1628
|
+
console.log(chalk.green(` FILLED (verified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
console.log(chalk.yellow(` FILLED (unverified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
|
|
1632
|
+
}
|
|
1633
|
+
console.log(chalk.green(` SPOT ${spotExch} ✓ (bought ${matchedSize})`));
|
|
1634
|
+
console.log(chalk.green(` PERP ${perpExch} ✓ (shorted ${matchedSize})\n`));
|
|
1635
|
+
}
|
|
1636
|
+
else if (spotOk !== perpOk) {
|
|
1637
|
+
// One leg failed — rollback
|
|
1638
|
+
const filledSide = spotOk ? "spot" : "perp";
|
|
1639
|
+
const failedSide = spotOk ? "perp" : "spot";
|
|
1640
|
+
const failedErr = spotOk
|
|
1641
|
+
? perpResult.reason
|
|
1642
|
+
: spotResult.reason;
|
|
1643
|
+
const failedErrMsg = failedErr instanceof Error ? failedErr.message : String(failedErr);
|
|
1644
|
+
if (!isJson()) {
|
|
1645
|
+
console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
|
|
1646
|
+
console.log(chalk.red(` Reason: ${failedErrMsg}`));
|
|
1647
|
+
}
|
|
1648
|
+
let rollbackOk = false;
|
|
1649
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1650
|
+
try {
|
|
1651
|
+
if (spotOk) {
|
|
1652
|
+
await spotAdapter.spotMarketOrder(spotSymbol, "sell", matchedSize);
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
await perpAdapter.marketOrder(sym, "buy", matchedSize);
|
|
1656
|
+
}
|
|
1657
|
+
rollbackOk = true;
|
|
1658
|
+
break;
|
|
1659
|
+
}
|
|
1660
|
+
catch { /* retry */ }
|
|
1661
|
+
}
|
|
1662
|
+
logExecution({
|
|
1663
|
+
type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
|
|
1664
|
+
symbol: sym, side: "entry", size: matchedSize,
|
|
1665
|
+
status: "failed", dryRun: false,
|
|
1666
|
+
error: `Partial: ${failedSide} failed (${failedErrMsg}). Rollback: ${rollbackOk ? "ok" : "FAILED"}`,
|
|
1667
|
+
});
|
|
1668
|
+
const result = {
|
|
1669
|
+
status: "partial_fill", mode: "spot-perp", symbol: sym,
|
|
1670
|
+
filledSide, failedSide,
|
|
1671
|
+
failedError: failedErrMsg,
|
|
1672
|
+
rollback: rollbackOk ? "success" : "FAILED — MANUAL CLOSE REQUIRED",
|
|
1673
|
+
};
|
|
1674
|
+
if (isJson())
|
|
1675
|
+
return printJson(jsonOk(result));
|
|
1676
|
+
console.log(rollbackOk
|
|
1677
|
+
? chalk.yellow(` Rollback OK — no open exposure.\n`)
|
|
1678
|
+
: chalk.red.bold(` ROLLBACK FAILED — ${filledSide} leg still open. Close manually!\n`));
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
// Both failed
|
|
1682
|
+
const spotErr = spotResult.reason;
|
|
1683
|
+
const perpErr = perpResult.reason;
|
|
1684
|
+
const result = {
|
|
1685
|
+
status: "both_failed", mode: "spot-perp", symbol: sym,
|
|
1686
|
+
spotError: spotErr instanceof Error ? spotErr.message : String(spotErr),
|
|
1687
|
+
perpError: perpErr instanceof Error ? perpErr.message : String(perpErr),
|
|
1688
|
+
};
|
|
1689
|
+
if (isJson())
|
|
1690
|
+
return printJson(jsonOk(result));
|
|
1691
|
+
console.log(chalk.red(` Both legs failed:`));
|
|
1692
|
+
console.log(chalk.red(` SPOT: ${result.spotError}`));
|
|
1693
|
+
console.log(chalk.red(` PERP: ${result.perpError}\n`));
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
// ── arb spot-close ── (close spot+perp arb position)
|
|
1697
|
+
arb
|
|
1698
|
+
.command("spot-close")
|
|
1699
|
+
.description("Close a spot+perp arb position: sell spot + buy back perp")
|
|
1700
|
+
.argument("<symbol>", "Symbol (e.g. ETH)")
|
|
1701
|
+
.option("--spot-exch <exchange>", "Spot exchange (hl or lt)")
|
|
1702
|
+
.option("--perp-exch <exchange>", "Perp exchange (hl, lt, pac)")
|
|
1703
|
+
.action(async (symbol, opts) => {
|
|
1704
|
+
const sym = symbol.toUpperCase();
|
|
1705
|
+
// Try to find the position from state
|
|
1706
|
+
const state = loadArbState();
|
|
1707
|
+
const pos = state?.positions.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
|
|
1708
|
+
const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
|
|
1709
|
+
const spotExch = aliasMap[opts.spotExch?.toLowerCase() ?? ""] || opts.spotExch?.toLowerCase() || pos?.spotExchange || pos?.longExchange;
|
|
1710
|
+
const perpExch = aliasMap[opts.perpExch?.toLowerCase() ?? ""] || opts.perpExch?.toLowerCase() || pos?.shortExchange;
|
|
1711
|
+
if (!spotExch || !perpExch) {
|
|
1712
|
+
const msg = "Cannot determine exchanges. Specify --spot-exch and --perp-exch, or ensure the position is tracked in state.";
|
|
1713
|
+
if (isJson())
|
|
1714
|
+
return printJson(jsonOk({ error: msg }));
|
|
1715
|
+
console.log(chalk.red(` ${msg}`));
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
// Load adapters
|
|
1719
|
+
const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
|
|
1720
|
+
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
1721
|
+
let spotAdapter;
|
|
1722
|
+
if (spotExch === "hyperliquid") {
|
|
1723
|
+
const hlAdapter = await getAdapterForExchange("hyperliquid");
|
|
1724
|
+
const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
|
|
1725
|
+
await hlSpot.init();
|
|
1726
|
+
spotAdapter = hlSpot;
|
|
1727
|
+
}
|
|
1728
|
+
else {
|
|
1729
|
+
const ltAdapter = await getAdapterForExchange("lighter");
|
|
1730
|
+
const ltSpot = new LighterSpotAdapter(ltAdapter);
|
|
1731
|
+
await ltSpot.init();
|
|
1732
|
+
spotAdapter = ltSpot;
|
|
1733
|
+
}
|
|
1734
|
+
const perpAdapter = await getAdapterForExchange(perpExch);
|
|
1735
|
+
const spotSymbol = `${sym}/USDC`;
|
|
1736
|
+
// Find how much to close
|
|
1737
|
+
const perpPositions = await perpAdapter.getPositions();
|
|
1738
|
+
const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
1739
|
+
if (!perpPos) {
|
|
1740
|
+
const msg = `No short perp position found for ${sym} on ${perpExch}`;
|
|
1741
|
+
if (isJson())
|
|
1742
|
+
return printJson(jsonOk({ error: msg }));
|
|
1743
|
+
console.log(chalk.red(` ${msg}`));
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const closeSize = perpPos.size;
|
|
1747
|
+
if (!isJson()) {
|
|
1748
|
+
console.log(chalk.cyan(`\n Closing Spot+Perp Arb: ${sym}`));
|
|
1749
|
+
console.log(chalk.gray(` SPOT ${spotExch}: sell ${closeSize}`));
|
|
1750
|
+
console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
|
|
1751
|
+
console.log(chalk.gray(` Executing...\n`));
|
|
1752
|
+
}
|
|
1753
|
+
// Same exchange → sequential (nonce collision), different → parallel
|
|
1754
|
+
let spotResult;
|
|
1755
|
+
let perpResult;
|
|
1756
|
+
if (spotExch === perpExch) {
|
|
1757
|
+
// Sequential: sell spot first, then close perp
|
|
1758
|
+
spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize)
|
|
1759
|
+
.then(r => ({ status: "fulfilled", value: r }))
|
|
1760
|
+
.catch(e => ({ status: "rejected", reason: e }));
|
|
1761
|
+
perpResult = await perpAdapter.marketOrder(sym, "buy", closeSize)
|
|
1762
|
+
.then(r => ({ status: "fulfilled", value: r }))
|
|
1763
|
+
.catch(e => ({ status: "rejected", reason: e }));
|
|
1764
|
+
}
|
|
1765
|
+
else {
|
|
1766
|
+
[spotResult, perpResult] = await Promise.allSettled([
|
|
1767
|
+
spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize),
|
|
1768
|
+
perpAdapter.marketOrder(sym, "buy", closeSize),
|
|
1769
|
+
]);
|
|
1770
|
+
}
|
|
1771
|
+
const spotOk = spotResult.status === "fulfilled";
|
|
1772
|
+
const perpOk = perpResult.status === "fulfilled";
|
|
1773
|
+
if (spotOk && perpOk) {
|
|
1774
|
+
// ── Post-close fill verification ──
|
|
1775
|
+
let closeVerified = true;
|
|
1776
|
+
let closeWarnings = [];
|
|
1777
|
+
try {
|
|
1778
|
+
await new Promise(r => setTimeout(r, 800));
|
|
1779
|
+
// Verify spot token was sold (balance should be near zero or gone)
|
|
1780
|
+
const spotBals = await spotAdapter.getSpotBalances();
|
|
1781
|
+
const baseToken = sym;
|
|
1782
|
+
const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(baseToken));
|
|
1783
|
+
const remainingSpotAmt = Number(spotTokenBal?.total ?? 0);
|
|
1784
|
+
const closedSize = parseFloat(closeSize);
|
|
1785
|
+
if (remainingSpotAmt > closedSize * 0.5) {
|
|
1786
|
+
closeVerified = false;
|
|
1787
|
+
closeWarnings.push(`Spot sell may not have filled: still have ${remainingSpotAmt.toFixed(6)} ${baseToken} (expected ~0)`);
|
|
1788
|
+
}
|
|
1789
|
+
// Verify perp short was closed (position should be gone or reduced)
|
|
1790
|
+
const perpPositionsAfter = await perpAdapter.getPositions();
|
|
1791
|
+
const remainingPerpPos = perpPositionsAfter.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
1792
|
+
if (remainingPerpPos && Number(remainingPerpPos.size) > closedSize * 0.5) {
|
|
1793
|
+
closeVerified = false;
|
|
1794
|
+
closeWarnings.push(`Perp short still open: ${remainingPerpPos.size} ${sym} on ${perpExch} (expected closed)`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
catch (verifyErr) {
|
|
1798
|
+
closeWarnings.push(`Close verification failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
|
|
1799
|
+
}
|
|
1800
|
+
if (closeWarnings.length > 0 && !isJson()) {
|
|
1801
|
+
for (const w of closeWarnings) {
|
|
1802
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
// Remove from state
|
|
1806
|
+
persistRemovePosition(sym);
|
|
1807
|
+
logExecution({
|
|
1808
|
+
type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
|
|
1809
|
+
symbol: sym, side: "exit", size: closeSize,
|
|
1810
|
+
status: closeVerified ? "success" : "unverified", dryRun: false,
|
|
1811
|
+
meta: { mode: "spot-perp", ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
|
|
1812
|
+
});
|
|
1813
|
+
// Return spot USDC proceeds back to perp account
|
|
1814
|
+
if (spotExch === "lighter") {
|
|
1815
|
+
try {
|
|
1816
|
+
const ltSpot = spotAdapter;
|
|
1817
|
+
const bals = await ltSpot.getSpotBalances();
|
|
1818
|
+
const usdcBal = bals.find(b => b.token === "USDC");
|
|
1819
|
+
const leftover = Number(usdcBal?.available ?? 0);
|
|
1820
|
+
if (leftover > 0.01) {
|
|
1821
|
+
await ltSpot.transferUsdcToPerp(leftover);
|
|
1822
|
+
if (!isJson())
|
|
1823
|
+
console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot→perp on Lighter`));
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
catch { /* non-critical */ }
|
|
1827
|
+
}
|
|
1828
|
+
else if (spotExch === "hyperliquid") {
|
|
1829
|
+
try {
|
|
1830
|
+
const hlSpot = spotAdapter;
|
|
1831
|
+
const bals = await hlSpot.getSpotBalances();
|
|
1832
|
+
const usdcBal = bals.find(b => b.token.startsWith("USDC"));
|
|
1833
|
+
const leftover = Number(usdcBal?.available ?? 0);
|
|
1834
|
+
if (leftover > 1) {
|
|
1835
|
+
await hlSpot.transferUsdcToPerp(Math.floor(leftover));
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
catch { /* non-critical */ }
|
|
1839
|
+
}
|
|
1840
|
+
const closeResult = {
|
|
1841
|
+
status: closeVerified ? "closed" : "closed_unverified",
|
|
1842
|
+
mode: "spot-perp", symbol: sym, size: closeSize,
|
|
1843
|
+
...(closeWarnings.length > 0 && { warnings: closeWarnings }),
|
|
1844
|
+
};
|
|
1845
|
+
if (isJson())
|
|
1846
|
+
return printJson(jsonOk(closeResult));
|
|
1847
|
+
if (closeVerified) {
|
|
1848
|
+
console.log(chalk.green(` CLOSED (verified): ${sym} ${closeSize} units`));
|
|
1849
|
+
}
|
|
1850
|
+
else {
|
|
1851
|
+
console.log(chalk.yellow(` CLOSED (unverified): ${sym} ${closeSize} units — check positions manually`));
|
|
1852
|
+
}
|
|
1853
|
+
console.log(chalk.green(` SPOT ${spotExch}: sold ✓`));
|
|
1854
|
+
console.log(chalk.green(` PERP ${perpExch}: bought (closed short) ✓\n`));
|
|
1855
|
+
}
|
|
1856
|
+
else {
|
|
1857
|
+
const errors = [];
|
|
1858
|
+
if (!spotOk)
|
|
1859
|
+
errors.push(`Spot sell failed: ${spotResult.reason}`);
|
|
1860
|
+
if (!perpOk)
|
|
1861
|
+
errors.push(`Perp buy failed: ${perpResult.reason}`);
|
|
1862
|
+
const result = { status: "partial_close", symbol: sym, spotOk, perpOk, errors };
|
|
1863
|
+
if (isJson())
|
|
1864
|
+
return printJson(jsonOk(result));
|
|
1865
|
+
console.log(chalk.red(` Close partially failed:`));
|
|
1866
|
+
for (const e of errors)
|
|
1867
|
+
console.log(chalk.red(` ${e}`));
|
|
1868
|
+
console.log(chalk.yellow(` Manual intervention may be needed.\n`));
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1184
1871
|
// ── arb pnl ── (check arb position PnL)
|
|
1185
1872
|
arb
|
|
1186
1873
|
.command("pnl")
|