perp-cli 0.4.11 → 0.5.0
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/dist/commands/account.js +151 -20
- package/dist/commands/agent.js +5 -5
- package/dist/commands/arb/index.js +191 -24
- package/dist/commands/arb-auto.d.ts +9 -0
- package/dist/commands/arb-auto.js +577 -382
- package/dist/commands/arb.d.ts +44 -0
- package/dist/commands/arb.js +236 -514
- package/dist/commands/market.js +108 -73
- package/dist/exchanges/hyperliquid.d.ts +2 -2
- package/dist/exchanges/hyperliquid.js +126 -31
- package/dist/exchanges/lighter-spot.js +4 -19
- package/dist/exchanges/lighter.js +21 -7
- package/dist/liquidity.js +3 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { formatUsd, printJson, jsonOk } from "../utils.js";
|
|
2
|
+
import { formatUsd, printJson, jsonOk, makeTable } from "../utils.js";
|
|
3
3
|
import { computeExecutableSize } from "../liquidity.js";
|
|
4
4
|
import { computeAnnualSpread, toHourlyRate } from "../funding.js";
|
|
5
5
|
import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
|
|
@@ -9,6 +9,7 @@ import { smartOrder } from "../smart-order.js";
|
|
|
9
9
|
import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
|
|
10
10
|
import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
|
|
11
11
|
import { loadArbState, saveArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, createInitialState, } from "../arb-state.js";
|
|
12
|
+
import { handleRates, handleBasisScan, handleDexScan, handleCompare, handleFundingHistory, handleFundingPositions, fetchAllPrices, printGapTable, printTrackSummary, formatGapPrice, } from "./arb.js";
|
|
12
13
|
// ── Fee-Adjusted Net Spread Calculation ──
|
|
13
14
|
import { getTakerFee } from "../constants.js";
|
|
14
15
|
/**
|
|
@@ -151,6 +152,8 @@ async function fetchFundingSpreads() {
|
|
|
151
152
|
}
|
|
152
153
|
return snapshots.sort((a, b) => Math.abs(b.spread) - Math.abs(a.spread));
|
|
153
154
|
}
|
|
155
|
+
// Module-level holder for spot-close handler -- assigned inside registerArbAutoCommands.
|
|
156
|
+
export let handleSpotClose;
|
|
154
157
|
export function registerArbAutoCommands(program, getAdapterForExchange, isJson, getHLAdapterForDex) {
|
|
155
158
|
const arb = program.commands.find(c => c.name() === "arb");
|
|
156
159
|
if (!arb)
|
|
@@ -158,7 +161,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
158
161
|
// ── arb auto ── (daemon mode)
|
|
159
162
|
arb
|
|
160
163
|
.command("auto")
|
|
161
|
-
.description("Auto-execute funding rate arbitrage (daemon)")
|
|
164
|
+
.description("Auto-execute funding rate arbitrage (daemon). Use --hip3 for HIP-3 cross-dex mode.")
|
|
162
165
|
.option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
|
|
163
166
|
.option("--close-spread <pct>", "Close when spread drops below (%)", "5")
|
|
164
167
|
.option("--size <usd>", "Position size per leg ($)", "100")
|
|
@@ -167,7 +170,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
167
170
|
.option("--symbols <list>", "Comma-separated symbols to monitor (default: all)")
|
|
168
171
|
.option("--interval <seconds>", "Check interval", "60")
|
|
169
172
|
.option("--hold-days <days>", "Expected hold period for cost amortization", "7")
|
|
170
|
-
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0
|
|
173
|
+
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0")
|
|
171
174
|
.option("--no-reversal-exit", "Disable emergency exit on spread reversal")
|
|
172
175
|
.option("--settle-aware", "Avoid entries near funding settlement (default: true)")
|
|
173
176
|
.option("--no-settle-aware", "Disable settlement timing awareness")
|
|
@@ -179,7 +182,23 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
179
182
|
.option("--cooldown <minutes>", "Minutes to wait before re-entering a closed symbol", "30")
|
|
180
183
|
.option("--dry-run", "Simulate without executing trades")
|
|
181
184
|
.option("--background", "Run in background (tmux)")
|
|
185
|
+
.option("--hip3", "Run HIP-3 cross-dex auto arb instead of perp-perp")
|
|
186
|
+
.option("--min-oi <usd>", "Min OI (USD) to enter (hip3 mode)", "50000")
|
|
187
|
+
.option("--min-grade <grade>", "Min viability grade: A, B, C, D (hip3 mode)", "C")
|
|
182
188
|
.action(async (opts) => {
|
|
189
|
+
// Route to HIP-3 dex-auto logic if --hip3 flag is set
|
|
190
|
+
if (opts.hip3) {
|
|
191
|
+
return handleDexAuto({
|
|
192
|
+
minSpread: opts.minSpread,
|
|
193
|
+
closeSpread: opts.closeSpread,
|
|
194
|
+
size: opts.size,
|
|
195
|
+
maxPositions: opts.maxPositions,
|
|
196
|
+
minOi: opts.minOi,
|
|
197
|
+
minGrade: opts.minGrade,
|
|
198
|
+
interval: opts.interval,
|
|
199
|
+
dryRun: opts.dryRun,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
183
202
|
if (opts.background) {
|
|
184
203
|
const { startJob } = await import("../jobs.js");
|
|
185
204
|
const cliArgs = [
|
|
@@ -898,14 +917,242 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
898
917
|
// ── arb scan ── (one-shot spread scan)
|
|
899
918
|
arb
|
|
900
919
|
.command("scan")
|
|
901
|
-
.description("Scan
|
|
902
|
-
.option("--mode <mode>", "Scan mode: perp-perp, spot-perp
|
|
920
|
+
.description("Scan funding spreads, rates, basis, gaps, HIP-3, or positions (--live for continuous monitoring)")
|
|
921
|
+
.option("--mode <mode>", "Scan mode: all, perp-perp, spot-perp", "all")
|
|
903
922
|
.option("--min <pct>", "Min annual spread to show", "10")
|
|
904
923
|
.option("--top <n>", "Return only top N results (for JSON output)")
|
|
905
924
|
.option("--hold-days <days>", "Expected hold period for cost calc", "7")
|
|
906
|
-
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0
|
|
925
|
+
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0")
|
|
907
926
|
.option("--size <usd>", "Position size per leg ($) for cost calc", "100")
|
|
927
|
+
.option("--rates", "Show funding rates across all exchanges")
|
|
928
|
+
.option("--basis [symbol]", "Scan cross-exchange basis opportunities (optionally for a specific symbol)")
|
|
929
|
+
.option("--hip3", "Scan HIP-3 cross-dex funding spreads")
|
|
930
|
+
.option("--compare <symbol>", "Compare funding rates for a specific symbol")
|
|
931
|
+
.option("--history [symbol]", "Show funding rate history (optionally for a specific symbol)")
|
|
932
|
+
.option("--positions", "Show position funding impact")
|
|
933
|
+
.option("-s, --symbol <sym>", "Filter/track symbol")
|
|
934
|
+
.option("--symbols <list>", "Comma-separated list of symbols")
|
|
935
|
+
.option("--all", "Include all symbols (not just top)")
|
|
936
|
+
.option("--hours <n>", "Hours of history to show", "24")
|
|
937
|
+
.option("--exchange <ex>", "Filter by exchange")
|
|
938
|
+
.option("--max-gap <pct>", "Max price gap % (hip3 mode)")
|
|
939
|
+
.option("--include-native", "Include native HL perps (hip3 mode)", true)
|
|
940
|
+
.option("--no-include-native", "Exclude native HL perps (hip3 mode)")
|
|
941
|
+
.option("--gaps", "Show cross-exchange price gaps")
|
|
942
|
+
.option("--live", "Continuous live monitoring (auto-refresh)")
|
|
943
|
+
.option("--track", "Track a symbol's price gap over time (requires -s)")
|
|
944
|
+
.option("--alert <pct>", "Alert when price gap exceeds threshold % (requires -s)")
|
|
945
|
+
.option("--interval <seconds>", "Refresh interval for --live mode", "60")
|
|
946
|
+
.option("--duration <minutes>", "Track duration in minutes (with --track)", "10")
|
|
947
|
+
.option("--beep", "Beep on large gaps (with --gaps)")
|
|
948
|
+
.option("--check-liquidity", "Check orderbook depth in live mode (slower)")
|
|
908
949
|
.action(async (opts) => {
|
|
950
|
+
// ── Price gap modes ──
|
|
951
|
+
if (opts.gaps || opts.track || opts.alert) {
|
|
952
|
+
const sym = opts.symbol?.toUpperCase();
|
|
953
|
+
const minGap = parseFloat(opts.min);
|
|
954
|
+
const intervalMs = parseInt(opts.interval) * 1000;
|
|
955
|
+
// Alert mode
|
|
956
|
+
if (opts.alert) {
|
|
957
|
+
if (!sym) {
|
|
958
|
+
console.log(chalk.red(" --alert requires --symbol or -s"));
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const threshold = parseFloat(opts.alert);
|
|
962
|
+
if (!isJson())
|
|
963
|
+
console.log(chalk.cyan(`\n Waiting for ${sym} gap > ${threshold}%...\n`));
|
|
964
|
+
const check = async () => {
|
|
965
|
+
const snapshots = await fetchAllPrices();
|
|
966
|
+
const s = snapshots.find((x) => x.symbol === sym);
|
|
967
|
+
if (!s)
|
|
968
|
+
return false;
|
|
969
|
+
const now = new Date().toLocaleTimeString();
|
|
970
|
+
if (!isJson())
|
|
971
|
+
console.log(chalk.gray(` ${now} ${sym} gap: ${s.maxGapPct.toFixed(4)}%`));
|
|
972
|
+
if (s.maxGapPct >= threshold) {
|
|
973
|
+
if (isJson()) {
|
|
974
|
+
printJson(jsonOk(s));
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
console.log(chalk.green.bold(`\n TRIGGERED! ${sym} gap: ${s.maxGapPct.toFixed(4)}% (> ${threshold}%)`));
|
|
978
|
+
const lines = [];
|
|
979
|
+
if (s.pacPrice !== null)
|
|
980
|
+
lines.push(` Pacifica: $${formatGapPrice(s.pacPrice)}`);
|
|
981
|
+
if (s.hlPrice !== null)
|
|
982
|
+
lines.push(` Hyperliquid: $${formatGapPrice(s.hlPrice)}`);
|
|
983
|
+
if (s.ltPrice !== null)
|
|
984
|
+
lines.push(` Lighter: $${formatGapPrice(s.ltPrice)}`);
|
|
985
|
+
lines.push(` Gap: $${s.maxGap.toFixed(4)} (${s.cheapest}→${s.expensive})`);
|
|
986
|
+
console.log(lines.join("\n") + "\n");
|
|
987
|
+
}
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
return false;
|
|
991
|
+
};
|
|
992
|
+
if (await check())
|
|
993
|
+
return;
|
|
994
|
+
await new Promise((resolve) => {
|
|
995
|
+
const timer = setInterval(async () => { if (await check()) {
|
|
996
|
+
clearInterval(timer);
|
|
997
|
+
resolve();
|
|
998
|
+
} }, intervalMs);
|
|
999
|
+
});
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
// Track mode
|
|
1003
|
+
if (opts.track) {
|
|
1004
|
+
if (!sym) {
|
|
1005
|
+
console.log(chalk.red(" --track requires --symbol or -s"));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const durationMs = parseInt(opts.duration) * 60 * 1000;
|
|
1009
|
+
const endTime = Date.now() + durationMs;
|
|
1010
|
+
const samples = [];
|
|
1011
|
+
if (!isJson()) {
|
|
1012
|
+
console.log(chalk.cyan.bold(`\n Tracking ${sym} price gap\n`));
|
|
1013
|
+
console.log(` Duration: ${opts.duration} min`);
|
|
1014
|
+
console.log(` Interval: ${opts.interval}s`);
|
|
1015
|
+
console.log(chalk.gray(" Collecting samples...\n"));
|
|
1016
|
+
}
|
|
1017
|
+
const sample = async () => {
|
|
1018
|
+
const snapshots = await fetchAllPrices();
|
|
1019
|
+
const s = snapshots.find((x) => x.symbol === sym);
|
|
1020
|
+
if (!s) {
|
|
1021
|
+
console.log(chalk.gray(` ${new Date().toLocaleTimeString()} — ${sym} not found on both exchanges`));
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
samples.push({ time: new Date().toLocaleTimeString(), gap: s.maxGap, gapPct: s.maxGapPct, direction: `${s.cheapest}→${s.expensive}` });
|
|
1025
|
+
const gapColor = s.maxGapPct >= 0.1 ? chalk.yellow : chalk.gray;
|
|
1026
|
+
const parts = [`PAC: ${s.pacPrice !== null ? "$" + formatGapPrice(s.pacPrice) : "-"}`];
|
|
1027
|
+
parts.push(`HL: ${s.hlPrice !== null ? "$" + formatGapPrice(s.hlPrice) : "-"}`);
|
|
1028
|
+
parts.push(`LT: ${s.ltPrice !== null ? "$" + formatGapPrice(s.ltPrice) : "-"}`);
|
|
1029
|
+
console.log(` ${chalk.white(s.symbol.padEnd(6))} ${parts.join(" ")} ${gapColor(`${s.maxGapPct.toFixed(4)}%`)} ${s.cheapest}→${s.expensive}`);
|
|
1030
|
+
};
|
|
1031
|
+
await sample();
|
|
1032
|
+
const timer = setInterval(async () => { if (Date.now() >= endTime) {
|
|
1033
|
+
clearInterval(timer);
|
|
1034
|
+
printTrackSummary(sym, samples);
|
|
1035
|
+
return;
|
|
1036
|
+
} await sample(); }, intervalMs);
|
|
1037
|
+
await new Promise((resolve) => { setTimeout(resolve, durationMs + 1000); });
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
// Gaps snapshot (default for --gaps without --live)
|
|
1041
|
+
if (!isJson())
|
|
1042
|
+
console.log(chalk.cyan("\n Fetching prices from Pacifica, Hyperliquid & Lighter...\n"));
|
|
1043
|
+
let snapshots = await fetchAllPrices();
|
|
1044
|
+
if (sym)
|
|
1045
|
+
snapshots = snapshots.filter((s) => s.symbol.includes(sym));
|
|
1046
|
+
if (opts.top)
|
|
1047
|
+
snapshots = snapshots.slice(0, parseInt(opts.top));
|
|
1048
|
+
if (isJson())
|
|
1049
|
+
return printJson(jsonOk(snapshots.filter((s) => s.maxGapPct >= minGap)));
|
|
1050
|
+
printGapTable(snapshots, minGap);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
// ── Live monitoring mode ──
|
|
1054
|
+
if (opts.live) {
|
|
1055
|
+
// If --hip3, route to dex monitor
|
|
1056
|
+
if (opts.hip3) {
|
|
1057
|
+
return handleDexMonitor({ ...opts, top: opts.top ?? "15" });
|
|
1058
|
+
}
|
|
1059
|
+
// Perp-perp live funding spread monitor
|
|
1060
|
+
const minSpread = parseFloat(opts.min);
|
|
1061
|
+
const intervalSec = parseInt(opts.interval);
|
|
1062
|
+
const topN = parseInt(opts.top ?? "15");
|
|
1063
|
+
const checkLiq = opts.checkLiquidity ?? false;
|
|
1064
|
+
const holdDays = parseFloat(opts.holdDays);
|
|
1065
|
+
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
1066
|
+
const sizeUsd = parseFloat(opts.size);
|
|
1067
|
+
let cycle = 0;
|
|
1068
|
+
if (!isJson()) {
|
|
1069
|
+
console.log(chalk.cyan.bold("\n Funding Arb Monitor"));
|
|
1070
|
+
console.log(chalk.gray(` Min spread: ${minSpread}% | Refresh: ${intervalSec}s | Top: ${topN}`));
|
|
1071
|
+
console.log(chalk.gray(` Net spread: ${holdDays}d hold, $${bridgeCostUsd} bridge, $${sizeUsd} size`));
|
|
1072
|
+
if (checkLiq)
|
|
1073
|
+
console.log(chalk.gray(` Liquidity check: ON`));
|
|
1074
|
+
console.log(chalk.gray(` Press Ctrl+C to stop\n`));
|
|
1075
|
+
}
|
|
1076
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
1077
|
+
while (true) {
|
|
1078
|
+
cycle++;
|
|
1079
|
+
const ts = new Date().toLocaleTimeString();
|
|
1080
|
+
try {
|
|
1081
|
+
const spreads = await fetchFundingSpreads();
|
|
1082
|
+
const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread).slice(0, topN);
|
|
1083
|
+
if (cycle > 1) {
|
|
1084
|
+
const linesToClear = filtered.length + 4;
|
|
1085
|
+
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`);
|
|
1086
|
+
}
|
|
1087
|
+
console.log(chalk.gray(` ${ts} — Cycle ${cycle} | ${filtered.length} opportunities >= ${minSpread}%\n`));
|
|
1088
|
+
if (filtered.length === 0) {
|
|
1089
|
+
console.log(chalk.gray(` No opportunities found.\n`));
|
|
1090
|
+
}
|
|
1091
|
+
else {
|
|
1092
|
+
const liqData = new Map();
|
|
1093
|
+
if (checkLiq && filtered.length > 0) {
|
|
1094
|
+
const topCheck = filtered.slice(0, 5);
|
|
1095
|
+
await Promise.allSettled(topCheck.map(async (s) => {
|
|
1096
|
+
try {
|
|
1097
|
+
const [hlOB, ltOB] = await Promise.all([fetchHLOrderbook(s.symbol), fetchLighterOrderbook(s.symbol)]);
|
|
1098
|
+
const hlDepth = hlOB.reduce((sum, l) => sum + l[0] * l[1], 0);
|
|
1099
|
+
const ltDepth = ltOB.reduce((sum, l) => sum + l[0] * l[1], 0);
|
|
1100
|
+
const hlBest = hlOB[0]?.[0] ?? 0;
|
|
1101
|
+
const ltBest = ltOB[0]?.[0] ?? 0;
|
|
1102
|
+
const gap = hlBest && ltBest ? (Math.abs(hlBest - ltBest) / Math.min(hlBest, ltBest) * 100).toFixed(3) : "?";
|
|
1103
|
+
liqData.set(s.symbol, { hlDepth: Math.round(hlDepth), ltDepth: Math.round(ltDepth), gap });
|
|
1104
|
+
}
|
|
1105
|
+
catch { /* skip */ }
|
|
1106
|
+
}));
|
|
1107
|
+
}
|
|
1108
|
+
for (const s of filtered) {
|
|
1109
|
+
const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
|
|
1110
|
+
const grossSpread = Math.abs(s.spread);
|
|
1111
|
+
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
1112
|
+
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
1113
|
+
const grossColor = grossSpread >= 50 ? chalk.green.bold : grossSpread >= 30 ? chalk.green : chalk.yellow;
|
|
1114
|
+
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
1115
|
+
const rates = [];
|
|
1116
|
+
if (s.pacRate)
|
|
1117
|
+
rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
|
|
1118
|
+
if (s.hlRate)
|
|
1119
|
+
rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
|
|
1120
|
+
if (s.ltRate)
|
|
1121
|
+
rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
|
|
1122
|
+
let liqInfo = "";
|
|
1123
|
+
const ld = liqData.get(s.symbol);
|
|
1124
|
+
if (ld)
|
|
1125
|
+
liqInfo = chalk.gray(` | depth: HL $${ld.hlDepth.toLocaleString()} LT $${ld.ltDepth.toLocaleString()} gap:${ld.gap}%`);
|
|
1126
|
+
console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
|
|
1127
|
+
`${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
1128
|
+
`${netColor(`net:${netSpread.toFixed(1)}%`.padEnd(12))} ` +
|
|
1129
|
+
`${direction.padEnd(7)} ` +
|
|
1130
|
+
rates.join(" ") + liqInfo);
|
|
1131
|
+
}
|
|
1132
|
+
console.log(chalk.gray(` * Net spreads are predicted estimates (${holdDays}d hold, $${bridgeCostUsd} bridge)`));
|
|
1133
|
+
console.log();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
catch (err) {
|
|
1137
|
+
console.log(chalk.red(` ${ts} Error: ${err instanceof Error ? err.message : err}\n`));
|
|
1138
|
+
}
|
|
1139
|
+
await new Promise(r => setTimeout(r, intervalSec * 1000));
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Route to specialized handlers
|
|
1143
|
+
if (opts.compare)
|
|
1144
|
+
return handleCompare(isJson, opts.compare);
|
|
1145
|
+
if (opts.rates)
|
|
1146
|
+
return handleRates(isJson, { symbol: opts.symbol, symbols: opts.symbols, all: opts.all, minSpread: opts.min });
|
|
1147
|
+
if (opts.basis)
|
|
1148
|
+
return handleBasisScan(isJson, { minBasis: opts.min, symbol: typeof opts.basis === "string" ? opts.basis : opts.symbol });
|
|
1149
|
+
if (opts.hip3)
|
|
1150
|
+
return handleDexScan(isJson, { minSpread: opts.min, maxGap: opts.maxGap || "5", includeNative: opts.includeNative !== false, top: opts.top || "20" });
|
|
1151
|
+
if (opts.history)
|
|
1152
|
+
return handleFundingHistory(isJson, { symbol: (typeof opts.history === "string" ? opts.history : opts.symbol) || "ETH", hours: opts.hours || "24", exchange: opts.exchange });
|
|
1153
|
+
if (opts.positions)
|
|
1154
|
+
return handleFundingPositions(isJson, getAdapterForExchange, { exchanges: opts.exchange });
|
|
1155
|
+
// Default: existing scan logic continues below
|
|
909
1156
|
const minSpread = parseFloat(opts.min);
|
|
910
1157
|
const holdDays = parseFloat(opts.holdDays);
|
|
911
1158
|
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
@@ -943,27 +1190,29 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
943
1190
|
console.log(chalk.gray(` No spot+perp opportunities above ${minSpread}%\n`));
|
|
944
1191
|
}
|
|
945
1192
|
else {
|
|
946
|
-
|
|
947
|
-
|
|
1193
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
1194
|
+
const rows = spotSpreads.slice(0, parseInt(opts.top ?? "30")).map(s => {
|
|
948
1195
|
const spotEx = s.spotExchanges[0] || "lighter";
|
|
949
1196
|
const rtCost = computeRoundTripCostPct(spotEx, s.perpExchange);
|
|
950
1197
|
const effectiveBridgeCost = s.spotExchanges.includes(s.perpExchange) ? 0 : bridgeCostUsd;
|
|
951
1198
|
const netSpread = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
|
|
952
1199
|
const breakEvenDays = rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0;
|
|
953
|
-
const grossColor = s.annualSpreadPct >= 30 ? chalk.green : chalk.yellow;
|
|
1200
|
+
const grossColor = s.annualSpreadPct >= 50 ? chalk.green.bold : s.annualSpreadPct >= 30 ? chalk.green : chalk.yellow;
|
|
954
1201
|
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
955
1202
|
const dir = s.direction === "long-spot-short-perp" ? "Spot+Short" : "Sell+Long";
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
dir
|
|
965
|
-
|
|
966
|
-
|
|
1203
|
+
return [
|
|
1204
|
+
chalk.white.bold(s.symbol),
|
|
1205
|
+
grossColor(`${s.annualSpreadPct.toFixed(1)}%`),
|
|
1206
|
+
netColor(`${netSpread.toFixed(1)}%`),
|
|
1207
|
+
chalk.gray(`${rtCost.toFixed(2)}%`),
|
|
1208
|
+
chalk.gray(`${breakEvenDays}d`),
|
|
1209
|
+
exAbbr(s.perpExchange),
|
|
1210
|
+
s.spotExchanges.map(exAbbr).join(","),
|
|
1211
|
+
dir,
|
|
1212
|
+
];
|
|
1213
|
+
});
|
|
1214
|
+
console.log(makeTable(["Symbol", "Gross", "Net", "Fees", "BE", "Perp", "Spot", "Direction"], rows));
|
|
1215
|
+
console.log(chalk.gray(` ${spotSpreads.length} spot+perp opportunities (spot funding = 0%)`));
|
|
967
1216
|
console.log(chalk.gray(` Net spread assumes ${holdDays}d hold, $${bridgeCostUsd} bridge, $${sizeUsd} size. BE = break-even days`));
|
|
968
1217
|
}
|
|
969
1218
|
}
|
|
@@ -1012,17 +1261,14 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1012
1261
|
console.log(chalk.gray(` No perp-perp spreads above ${minSpread}%\n`));
|
|
1013
1262
|
return;
|
|
1014
1263
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
for (const s of filtered) {
|
|
1018
|
-
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
1264
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
1265
|
+
const rows = filtered.map(s => {
|
|
1019
1266
|
const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
|
|
1020
1267
|
const grossSpread = Math.abs(s.spread);
|
|
1021
1268
|
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
1022
1269
|
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
1023
|
-
const grossColor = grossSpread >= 30 ? chalk.green : chalk.yellow;
|
|
1270
|
+
const grossColor = grossSpread >= 50 ? chalk.green.bold : grossSpread >= 30 ? chalk.green : chalk.yellow;
|
|
1024
1271
|
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
1025
|
-
// Price gap between long/short exchanges
|
|
1026
1272
|
const longPrice = s.longExch === "pacifica" ? s.pacMarkPrice : s.longExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
|
|
1027
1273
|
const shortPrice = s.shortExch === "pacifica" ? s.pacMarkPrice : s.shortExch === "hyperliquid" ? s.hlMarkPrice : s.ltMarkPrice;
|
|
1028
1274
|
const mid = (longPrice + shortPrice) / 2;
|
|
@@ -1035,15 +1281,18 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1035
1281
|
rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
|
|
1036
1282
|
if (s.ltRate)
|
|
1037
1283
|
rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
`${
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1284
|
+
return [
|
|
1285
|
+
chalk.white.bold(s.symbol),
|
|
1286
|
+
grossColor(`${grossSpread.toFixed(1)}%`),
|
|
1287
|
+
netColor(`${netSpread.toFixed(1)}%`),
|
|
1288
|
+
chalk.gray(`${rtCost.toFixed(2)}%`),
|
|
1289
|
+
gapColor(`${priceGapPct.toFixed(3)}%`),
|
|
1290
|
+
direction,
|
|
1291
|
+
rates.join(" "),
|
|
1292
|
+
];
|
|
1293
|
+
});
|
|
1294
|
+
console.log(makeTable(["Symbol", "Gross", "Net", "Fees", "Gap", "Dir", "Rates"], rows));
|
|
1295
|
+
console.log(chalk.gray(` ${filtered.length} opportunities above ${minSpread}% gross annual spread`));
|
|
1047
1296
|
console.log(chalk.gray(` Net spread assumes ${holdDays}d hold, $${bridgeCostUsd} bridge cost, $${sizeUsd} size`));
|
|
1048
1297
|
console.log(chalk.gray(` * Spreads are estimates based on current rates — actual may vary`));
|
|
1049
1298
|
console.log(chalk.gray(` Use 'perp arb auto --min-spread ${minSpread}' to auto-trade\n`));
|
|
@@ -1051,19 +1300,27 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1051
1300
|
// ── arb exec ── (validate orderbook + simultaneous dual-leg entry)
|
|
1052
1301
|
arb
|
|
1053
1302
|
.command("exec")
|
|
1054
|
-
.description("Execute arb: validate orderbook depth
|
|
1303
|
+
.description("Execute arb: validate orderbook depth, then enter both legs simultaneously. Use spot:<exch> for spot+perp mode.")
|
|
1055
1304
|
.argument("<symbol>", "Symbol (e.g. BTC, ETH, ICP)")
|
|
1056
|
-
.argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter)")
|
|
1305
|
+
.argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter, or spot:hl / spot:lt for spot+perp)")
|
|
1057
1306
|
.argument("<shortExch>", "Exchange to go SHORT on (hl, pac, lighter)")
|
|
1058
1307
|
.argument("<sizeUsd>", "Position size per leg in USD")
|
|
1059
1308
|
.option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
|
|
1060
1309
|
.option("--leverage <n>", "Set leverage before entry (both exchanges)")
|
|
1061
1310
|
.option("--isolated", "Use isolated margin mode")
|
|
1062
1311
|
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
|
|
1312
|
+
.option("--dry-run", "Simulate without executing trades")
|
|
1313
|
+
.option("--reconcile", "Auto-adjust leg sizes to match after execution (trim larger side)")
|
|
1063
1314
|
.action(async (symbol, longExch, shortExch, sizeUsdStr, opts) => {
|
|
1315
|
+
// Detect spot: prefix -> route to spot-exec logic
|
|
1316
|
+
if (longExch.startsWith("spot:")) {
|
|
1317
|
+
const spotExch = longExch.slice(5); // extract "hl" from "spot:hl"
|
|
1318
|
+
return handleSpotExec(symbol, spotExch, shortExch, sizeUsdStr, opts);
|
|
1319
|
+
}
|
|
1064
1320
|
const sym = symbol.toUpperCase();
|
|
1065
1321
|
const sizeUsd = parseFloat(sizeUsdStr);
|
|
1066
1322
|
const maxSlippage = parseFloat(opts.maxSlippage);
|
|
1323
|
+
const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
|
|
1067
1324
|
// Resolve exchange aliases
|
|
1068
1325
|
const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
|
|
1069
1326
|
longExch = aliasMap[longExch.toLowerCase()] || longExch.toLowerCase();
|
|
@@ -1150,11 +1407,25 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1150
1407
|
const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
|
|
1151
1408
|
const matchedUsd = matchedBase * avgPrice;
|
|
1152
1409
|
if (!isJson()) {
|
|
1153
|
-
console.log(chalk.cyan(`\n Arb Exec: ${sym}${opts.smart ? " (smart order)" : ""}`));
|
|
1410
|
+
console.log(chalk.cyan(`\n Arb Exec: ${sym}${opts.smart ? " (smart order)" : ""}${dryRun ? " [DRY RUN]" : ""}`));
|
|
1154
1411
|
console.log(chalk.gray(` LONG ${longExch}: buy ${matchedSize} (~$${(matchedBase * longCheck.avgFillPrice).toFixed(2)}, slippage ${longCheck.slippagePct.toFixed(3)}%)`));
|
|
1155
1412
|
console.log(chalk.gray(` SHORT ${shortExch}: sell ${matchedSize} (~$${(matchedBase * shortCheck.avgFillPrice).toFixed(2)}, slippage ${shortCheck.slippagePct.toFixed(3)}%)`));
|
|
1156
|
-
console.log(chalk.gray(` Executing both legs simultaneously...\n`));
|
|
1157
1413
|
}
|
|
1414
|
+
// 4b. Dry-run: return simulated result without executing
|
|
1415
|
+
if (dryRun) {
|
|
1416
|
+
const result = {
|
|
1417
|
+
status: "filled", symbol: sym, dryRun: true,
|
|
1418
|
+
size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
1419
|
+
long: { exchange: longExch, side: "buy", size: matchedSize, slippagePct: longCheck.slippagePct },
|
|
1420
|
+
short: { exchange: shortExch, side: "sell", size: matchedSize, slippagePct: shortCheck.slippagePct },
|
|
1421
|
+
};
|
|
1422
|
+
if (isJson())
|
|
1423
|
+
return printJson(jsonOk(result));
|
|
1424
|
+
console.log(chalk.yellow(`\n [DRY RUN] Would execute: long ${matchedSize} on ${longExch} + short ${matchedSize} on ${shortExch} (~$${matchedUsd.toFixed(2)})\n`));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (!isJson())
|
|
1428
|
+
console.log(chalk.gray(` Executing both legs simultaneously...\n`));
|
|
1158
1429
|
// 5. Execute both legs simultaneously
|
|
1159
1430
|
const execLong = opts.smart
|
|
1160
1431
|
? smartOrder(longAdapter, sym, "buy", matchedSize).then(r => r.result)
|
|
@@ -1197,22 +1468,79 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1197
1468
|
console.log(chalk.yellow(` Rolled back ${openSide} leg. Check exchange min order sizes.\n`));
|
|
1198
1469
|
return;
|
|
1199
1470
|
}
|
|
1471
|
+
// 7. Verify fill sizes match expected
|
|
1472
|
+
const expectedSize = parseFloat(matchedSize);
|
|
1473
|
+
const longFilled = Number(longPos.size);
|
|
1474
|
+
const shortFilled = Number(shortPos.size);
|
|
1475
|
+
const fillWarnings = [];
|
|
1476
|
+
if (longFilled < expectedSize * 0.5)
|
|
1477
|
+
fillWarnings.push(`Long fill ${longPos.size} < expected ${matchedSize} — partial fill?`);
|
|
1478
|
+
if (shortFilled < expectedSize * 0.5)
|
|
1479
|
+
fillWarnings.push(`Short fill ${shortPos.size} < expected ${matchedSize} — partial fill?`);
|
|
1480
|
+
// Size mismatch between legs
|
|
1481
|
+
const sizeDiffPct = Math.abs(longFilled - shortFilled) / Math.max(longFilled, shortFilled) * 100;
|
|
1482
|
+
if (sizeDiffPct > 5)
|
|
1483
|
+
fillWarnings.push(`Leg size mismatch: long ${longPos.size} vs short ${shortPos.size} (${sizeDiffPct.toFixed(1)}% diff)`);
|
|
1484
|
+
// 8. Reconcile — trim larger leg to match smaller
|
|
1485
|
+
let reconciled = false;
|
|
1486
|
+
let reconciledSize = "";
|
|
1487
|
+
if (opts.reconcile && sizeDiffPct > 1 && longFilled > 0 && shortFilled > 0) {
|
|
1488
|
+
const targetSize = Math.min(longFilled, shortFilled);
|
|
1489
|
+
const trimSide = longFilled > shortFilled ? "long" : "short";
|
|
1490
|
+
const trimAdapter = trimSide === "long" ? longAdapter : shortAdapter;
|
|
1491
|
+
const trimAction = trimSide === "long" ? "sell" : "buy"; // reduce long = sell, reduce short = buy
|
|
1492
|
+
const excess = Math.abs(longFilled - shortFilled);
|
|
1493
|
+
const trimDecimals = matchedSize.includes(".") ? matchedSize.split(".")[1].length : 0;
|
|
1494
|
+
const excessStr = excess.toFixed(trimDecimals);
|
|
1495
|
+
if (!isJson())
|
|
1496
|
+
console.log(chalk.cyan(` Reconciling: ${trimSide} has ${excess.toFixed(trimDecimals)} excess → trimming...`));
|
|
1497
|
+
try {
|
|
1498
|
+
if (opts.smart) {
|
|
1499
|
+
await smartOrder(trimAdapter, sym, trimAction, excessStr, { reduceOnly: true });
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
await trimAdapter.marketOrder(sym, trimAction, excessStr);
|
|
1503
|
+
}
|
|
1504
|
+
reconciled = true;
|
|
1505
|
+
reconciledSize = targetSize.toFixed(trimDecimals);
|
|
1506
|
+
fillWarnings.length = 0; // clear mismatch warnings
|
|
1507
|
+
if (!isJson())
|
|
1508
|
+
console.log(chalk.green(` Reconciled: both legs now ~${reconciledSize} units`));
|
|
1509
|
+
}
|
|
1510
|
+
catch (reconErr) {
|
|
1511
|
+
const msg = reconErr instanceof Error ? reconErr.message : String(reconErr);
|
|
1512
|
+
fillWarnings.push(`Reconcile failed (${trimSide} trim): ${msg}`);
|
|
1513
|
+
if (!isJson())
|
|
1514
|
+
console.log(chalk.yellow(` Reconcile failed: ${msg}`));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const fillStatus = fillWarnings.length > 0 ? "filled_unverified" : "filled";
|
|
1518
|
+
const finalSize = reconciled ? reconciledSize : matchedSize;
|
|
1200
1519
|
logExecution({
|
|
1201
1520
|
type: "arb_entry", exchange: `${longExch}+${shortExch}`,
|
|
1202
|
-
symbol: sym, side: "entry", size:
|
|
1203
|
-
status: "success", dryRun: false,
|
|
1204
|
-
meta: { longExch, shortExch, matchedUsd, longSlippage: longCheck.slippagePct, shortSlippage: shortCheck.slippagePct },
|
|
1521
|
+
symbol: sym, side: "entry", size: finalSize,
|
|
1522
|
+
status: fillWarnings.length > 0 ? "unverified" : "success", dryRun: false,
|
|
1523
|
+
meta: { longExch, shortExch, matchedUsd, longSlippage: longCheck.slippagePct, shortSlippage: shortCheck.slippagePct, reconciled, ...(fillWarnings.length > 0 && { warnings: fillWarnings }) },
|
|
1205
1524
|
});
|
|
1206
1525
|
const result = {
|
|
1207
|
-
status:
|
|
1208
|
-
long: { exchange: longExch, side: "buy", size: longPos.size, slippagePct: longCheck.slippagePct },
|
|
1209
|
-
short: { exchange: shortExch, side: "sell", size: shortPos.size, slippagePct: shortCheck.slippagePct },
|
|
1526
|
+
status: fillStatus, symbol: sym, size: finalSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
|
|
1527
|
+
long: { exchange: longExch, side: "buy", size: reconciled ? reconciledSize : longPos.size, slippagePct: longCheck.slippagePct },
|
|
1528
|
+
short: { exchange: shortExch, side: "sell", size: reconciled ? reconciledSize : shortPos.size, slippagePct: shortCheck.slippagePct },
|
|
1529
|
+
...(reconciled && { reconciled: true }),
|
|
1530
|
+
...(fillWarnings.length > 0 && { warnings: fillWarnings }),
|
|
1210
1531
|
};
|
|
1211
1532
|
if (isJson())
|
|
1212
1533
|
return printJson(jsonOk(result));
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1534
|
+
if (fillWarnings.length > 0) {
|
|
1535
|
+
console.log(chalk.yellow(` FILLED (unverified): ${sym} ${finalSize} units (~$${matchedUsd.toFixed(2)})`));
|
|
1536
|
+
for (const w of fillWarnings)
|
|
1537
|
+
console.log(chalk.yellow(` ⚠ ${w}`));
|
|
1538
|
+
}
|
|
1539
|
+
else {
|
|
1540
|
+
console.log(chalk.green(` FILLED${reconciled ? " (reconciled)" : ""}: ${sym} ${finalSize} units (~$${matchedUsd.toFixed(2)})`));
|
|
1541
|
+
}
|
|
1542
|
+
console.log(chalk.green(` LONG ${longExch} ✓ (${reconciled ? reconciledSize : longPos.size})`));
|
|
1543
|
+
console.log(chalk.green(` SHORT ${shortExch} ✓ (${reconciled ? reconciledSize : shortPos.size})\n`));
|
|
1216
1544
|
}
|
|
1217
1545
|
else if (longOk !== shortOk) {
|
|
1218
1546
|
// One leg failed — rollback
|
|
@@ -1272,19 +1600,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1272
1600
|
console.log(chalk.red(` SHORT: ${result.shortError}\n`));
|
|
1273
1601
|
}
|
|
1274
1602
|
});
|
|
1275
|
-
// ──
|
|
1276
|
-
|
|
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) => {
|
|
1603
|
+
// ── spot-exec logic (called from exec when longExch starts with "spot:") ──
|
|
1604
|
+
async function handleSpotExec(symbol, spotExch, perpExch, sizeUsdStr, opts) {
|
|
1288
1605
|
const sym = symbol.toUpperCase();
|
|
1289
1606
|
const sizeUsd = parseFloat(sizeUsdStr);
|
|
1290
1607
|
const maxSlippage = parseFloat(opts.maxSlippage);
|
|
@@ -1692,25 +2009,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1692
2009
|
console.log(chalk.red(` SPOT: ${result.spotError}`));
|
|
1693
2010
|
console.log(chalk.red(` PERP: ${result.perpError}\n`));
|
|
1694
2011
|
}
|
|
1695
|
-
}
|
|
1696
|
-
// ──
|
|
1697
|
-
|
|
1698
|
-
.
|
|
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();
|
|
2012
|
+
}
|
|
2013
|
+
// ── Expose spot-close handler for consolidated "arb close" in arb/index.ts ──
|
|
2014
|
+
handleSpotClose = async (sym_arg, opts_arg, getAdapter, isJsonFn) => {
|
|
2015
|
+
const sym = sym_arg.toUpperCase();
|
|
1705
2016
|
// Try to find the position from state
|
|
1706
2017
|
const state = loadArbState();
|
|
1707
2018
|
const pos = state?.positions.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
|
|
1708
2019
|
const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
|
|
1709
|
-
const spotExch = aliasMap[
|
|
1710
|
-
const perpExch = aliasMap[
|
|
2020
|
+
const spotExch = aliasMap[opts_arg.spotExch?.toLowerCase() ?? ""] || opts_arg.spotExch?.toLowerCase() || pos?.spotExchange || pos?.longExchange;
|
|
2021
|
+
const perpExch = aliasMap[opts_arg.perpExch?.toLowerCase() ?? ""] || opts_arg.perpExch?.toLowerCase() || pos?.shortExchange;
|
|
1711
2022
|
if (!spotExch || !perpExch) {
|
|
1712
2023
|
const msg = "Cannot determine exchanges. Specify --spot-exch and --perp-exch, or ensure the position is tracked in state.";
|
|
1713
|
-
if (
|
|
2024
|
+
if (isJsonFn())
|
|
1714
2025
|
return printJson(jsonOk({ error: msg }));
|
|
1715
2026
|
console.log(chalk.red(` ${msg}`));
|
|
1716
2027
|
return;
|
|
@@ -1720,31 +2031,31 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1720
2031
|
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
1721
2032
|
let spotAdapter;
|
|
1722
2033
|
if (spotExch === "hyperliquid") {
|
|
1723
|
-
const hlAdapter = await
|
|
2034
|
+
const hlAdapter = await getAdapter("hyperliquid");
|
|
1724
2035
|
const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
|
|
1725
2036
|
await hlSpot.init();
|
|
1726
2037
|
spotAdapter = hlSpot;
|
|
1727
2038
|
}
|
|
1728
2039
|
else {
|
|
1729
|
-
const ltAdapter = await
|
|
2040
|
+
const ltAdapter = await getAdapter("lighter");
|
|
1730
2041
|
const ltSpot = new LighterSpotAdapter(ltAdapter);
|
|
1731
2042
|
await ltSpot.init();
|
|
1732
2043
|
spotAdapter = ltSpot;
|
|
1733
2044
|
}
|
|
1734
|
-
const perpAdapter = await
|
|
2045
|
+
const perpAdapter = await getAdapter(perpExch);
|
|
1735
2046
|
const spotSymbol = `${sym}/USDC`;
|
|
1736
2047
|
// Find how much to close
|
|
1737
2048
|
const perpPositions = await perpAdapter.getPositions();
|
|
1738
2049
|
const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
|
|
1739
2050
|
if (!perpPos) {
|
|
1740
2051
|
const msg = `No short perp position found for ${sym} on ${perpExch}`;
|
|
1741
|
-
if (
|
|
2052
|
+
if (isJsonFn())
|
|
1742
2053
|
return printJson(jsonOk({ error: msg }));
|
|
1743
2054
|
console.log(chalk.red(` ${msg}`));
|
|
1744
2055
|
return;
|
|
1745
2056
|
}
|
|
1746
2057
|
const closeSize = perpPos.size;
|
|
1747
|
-
if (!
|
|
2058
|
+
if (!isJsonFn()) {
|
|
1748
2059
|
console.log(chalk.cyan(`\n Closing Spot+Perp Arb: ${sym}`));
|
|
1749
2060
|
console.log(chalk.gray(` SPOT ${spotExch}: sell ${closeSize}`));
|
|
1750
2061
|
console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
|
|
@@ -1819,7 +2130,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1819
2130
|
const leftover = Number(usdcBal?.available ?? 0);
|
|
1820
2131
|
if (leftover > 0.01) {
|
|
1821
2132
|
await ltSpot.transferUsdcToPerp(leftover);
|
|
1822
|
-
if (!
|
|
2133
|
+
if (!isJsonFn())
|
|
1823
2134
|
console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot→perp on Lighter`));
|
|
1824
2135
|
}
|
|
1825
2136
|
}
|
|
@@ -1842,7 +2153,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1842
2153
|
mode: "spot-perp", symbol: sym, size: closeSize,
|
|
1843
2154
|
...(closeWarnings.length > 0 && { warnings: closeWarnings }),
|
|
1844
2155
|
};
|
|
1845
|
-
if (
|
|
2156
|
+
if (isJsonFn())
|
|
1846
2157
|
return printJson(jsonOk(closeResult));
|
|
1847
2158
|
if (closeVerified) {
|
|
1848
2159
|
console.log(chalk.green(` CLOSED (verified): ${sym} ${closeSize} units`));
|
|
@@ -1860,19 +2171,16 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1860
2171
|
if (!perpOk)
|
|
1861
2172
|
errors.push(`Perp buy failed: ${perpResult.reason}`);
|
|
1862
2173
|
const result = { status: "partial_close", symbol: sym, spotOk, perpOk, errors };
|
|
1863
|
-
if (
|
|
2174
|
+
if (isJsonFn())
|
|
1864
2175
|
return printJson(jsonOk(result));
|
|
1865
2176
|
console.log(chalk.red(` Close partially failed:`));
|
|
1866
2177
|
for (const e of errors)
|
|
1867
2178
|
console.log(chalk.red(` ${e}`));
|
|
1868
2179
|
console.log(chalk.yellow(` Manual intervention may be needed.\n`));
|
|
1869
2180
|
}
|
|
1870
|
-
}
|
|
1871
|
-
// ── arb pnl
|
|
1872
|
-
|
|
1873
|
-
.command("pnl")
|
|
1874
|
-
.description("Check PnL of current arb positions across exchanges")
|
|
1875
|
-
.action(async () => {
|
|
2181
|
+
};
|
|
2182
|
+
// ── arb pnl logic (extracted as inner function, no command registration) ──
|
|
2183
|
+
async function _handleArbPnl() {
|
|
1876
2184
|
if (!isJson())
|
|
1877
2185
|
console.log(chalk.cyan("\n Checking arb positions...\n"));
|
|
1878
2186
|
const exchangeNames = ["hyperliquid", "lighter", "pacifica"];
|
|
@@ -2022,125 +2330,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2022
2330
|
console.log(` Net (if closed now): ${netColor(`$${netPnl.toFixed(4)}`)}`);
|
|
2023
2331
|
console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker. Actual may vary.)`));
|
|
2024
2332
|
console.log(chalk.gray(` * Net includes actual settled funding where available.\n`));
|
|
2025
|
-
}
|
|
2026
|
-
// ──
|
|
2027
|
-
|
|
2028
|
-
.command("monitor")
|
|
2029
|
-
.description("Live-monitor funding spreads with liquidity data")
|
|
2030
|
-
.option("--min <pct>", "Min annual spread to show", "20")
|
|
2031
|
-
.option("--interval <sec>", "Refresh interval in seconds", "60")
|
|
2032
|
-
.option("--top <n>", "Show top N opportunities", "15")
|
|
2033
|
-
.option("--check-liquidity", "Check orderbook depth (slower)")
|
|
2034
|
-
.option("--hold-days <days>", "Expected hold period for net spread calc", "7")
|
|
2035
|
-
.option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
|
|
2036
|
-
.option("--size <usd>", "Position size per leg ($) for cost calc", "100")
|
|
2037
|
-
.action(async (opts) => {
|
|
2038
|
-
const minSpread = parseFloat(opts.min);
|
|
2039
|
-
const intervalSec = parseInt(opts.interval);
|
|
2040
|
-
const topN = parseInt(opts.top);
|
|
2041
|
-
const checkLiq = opts.checkLiquidity ?? false;
|
|
2042
|
-
const holdDays = parseFloat(opts.holdDays);
|
|
2043
|
-
const bridgeCostUsd = parseFloat(opts.bridgeCost);
|
|
2044
|
-
const sizeUsd = parseFloat(opts.size);
|
|
2045
|
-
let cycle = 0;
|
|
2046
|
-
if (!isJson()) {
|
|
2047
|
-
console.log(chalk.cyan.bold("\n Funding Arb Monitor"));
|
|
2048
|
-
console.log(chalk.gray(` Min spread: ${minSpread}% | Refresh: ${intervalSec}s | Top: ${topN}`));
|
|
2049
|
-
console.log(chalk.gray(` Net spread: ${holdDays}d hold, $${bridgeCostUsd} bridge, $${sizeUsd} size`));
|
|
2050
|
-
if (checkLiq)
|
|
2051
|
-
console.log(chalk.gray(` Liquidity check: ON`));
|
|
2052
|
-
console.log(chalk.gray(` Press Ctrl+C to stop\n`));
|
|
2053
|
-
}
|
|
2054
|
-
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
2055
|
-
while (true) {
|
|
2056
|
-
cycle++;
|
|
2057
|
-
const ts = new Date().toLocaleTimeString();
|
|
2058
|
-
try {
|
|
2059
|
-
const spreads = await fetchFundingSpreads();
|
|
2060
|
-
const filtered = spreads
|
|
2061
|
-
.filter(s => Math.abs(s.spread) >= minSpread)
|
|
2062
|
-
.slice(0, topN);
|
|
2063
|
-
// Clear previous output (move cursor up)
|
|
2064
|
-
if (cycle > 1) {
|
|
2065
|
-
const linesToClear = filtered.length + 4;
|
|
2066
|
-
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`);
|
|
2067
|
-
}
|
|
2068
|
-
console.log(chalk.gray(` ${ts} — Cycle ${cycle} | ${filtered.length} opportunities >= ${minSpread}%\n`));
|
|
2069
|
-
if (filtered.length === 0) {
|
|
2070
|
-
console.log(chalk.gray(` No opportunities found.\n`));
|
|
2071
|
-
}
|
|
2072
|
-
else {
|
|
2073
|
-
// Optionally check liquidity for top entries
|
|
2074
|
-
const liqData = new Map();
|
|
2075
|
-
if (checkLiq && filtered.length > 0) {
|
|
2076
|
-
const topCheck = filtered.slice(0, 5); // check liquidity for top 5 only
|
|
2077
|
-
await Promise.allSettled(topCheck.map(async (s) => {
|
|
2078
|
-
try {
|
|
2079
|
-
const [hlOB, ltOB] = await Promise.all([
|
|
2080
|
-
fetchHLOrderbook(s.symbol),
|
|
2081
|
-
fetchLighterOrderbook(s.symbol),
|
|
2082
|
-
]);
|
|
2083
|
-
const hlDepth = hlOB.reduce((sum, l) => sum + l[0] * l[1], 0);
|
|
2084
|
-
const ltDepth = ltOB.reduce((sum, l) => sum + l[0] * l[1], 0);
|
|
2085
|
-
// Price gap between best ask (buy side) and best bid (sell side)
|
|
2086
|
-
const hlBest = hlOB[0]?.[0] ?? 0;
|
|
2087
|
-
const ltBest = ltOB[0]?.[0] ?? 0;
|
|
2088
|
-
const gap = hlBest && ltBest
|
|
2089
|
-
? (Math.abs(hlBest - ltBest) / Math.min(hlBest, ltBest) * 100).toFixed(3)
|
|
2090
|
-
: "?";
|
|
2091
|
-
liqData.set(s.symbol, { hlDepth: Math.round(hlDepth), ltDepth: Math.round(ltDepth), gap });
|
|
2092
|
-
}
|
|
2093
|
-
catch { /* skip */ }
|
|
2094
|
-
}));
|
|
2095
|
-
}
|
|
2096
|
-
for (const s of filtered) {
|
|
2097
|
-
const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
|
|
2098
|
-
const grossSpread = Math.abs(s.spread);
|
|
2099
|
-
const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
|
|
2100
|
-
const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
|
|
2101
|
-
const grossColor = grossSpread >= 50 ? chalk.green.bold
|
|
2102
|
-
: grossSpread >= 30 ? chalk.green
|
|
2103
|
-
: chalk.yellow;
|
|
2104
|
-
const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
|
|
2105
|
-
const rates = [];
|
|
2106
|
-
if (s.pacRate)
|
|
2107
|
-
rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
|
|
2108
|
-
if (s.hlRate)
|
|
2109
|
-
rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
|
|
2110
|
-
if (s.ltRate)
|
|
2111
|
-
rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
|
|
2112
|
-
let liqInfo = "";
|
|
2113
|
-
const ld = liqData.get(s.symbol);
|
|
2114
|
-
if (ld) {
|
|
2115
|
-
liqInfo = chalk.gray(` | depth: HL $${ld.hlDepth.toLocaleString()} LT $${ld.ltDepth.toLocaleString()} gap:${ld.gap}%`);
|
|
2116
|
-
}
|
|
2117
|
-
console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
|
|
2118
|
-
`${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
|
|
2119
|
-
`${netColor(`net:${netSpread.toFixed(1)}%`.padEnd(12))} ` +
|
|
2120
|
-
`${direction.padEnd(7)} ` +
|
|
2121
|
-
rates.join(" ") +
|
|
2122
|
-
liqInfo);
|
|
2123
|
-
}
|
|
2124
|
-
console.log(chalk.gray(` * Net spreads are predicted estimates (${holdDays}d hold, $${bridgeCostUsd} bridge)`));
|
|
2125
|
-
console.log();
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
catch (err) {
|
|
2129
|
-
console.log(chalk.red(` ${ts} Error: ${err instanceof Error ? err.message : err}\n`));
|
|
2130
|
-
}
|
|
2131
|
-
await new Promise(r => setTimeout(r, intervalSec * 1000));
|
|
2132
|
-
}
|
|
2133
|
-
});
|
|
2134
|
-
// ── arb dex-monitor ── (live HIP-3 cross-dex monitoring)
|
|
2135
|
-
arb
|
|
2136
|
-
.command("dex-monitor")
|
|
2137
|
-
.description("Live-monitor HIP-3 cross-dex funding arb opportunities")
|
|
2138
|
-
.option("--min <pct>", "Min annual spread to show", "10")
|
|
2139
|
-
.option("--interval <sec>", "Refresh interval in seconds", "60")
|
|
2140
|
-
.option("--top <n>", "Show top N opportunities", "20")
|
|
2141
|
-
.option("--include-native", "Include native HL perps", true)
|
|
2142
|
-
.option("--no-include-native", "Exclude native HL perps")
|
|
2143
|
-
.action(async (opts) => {
|
|
2333
|
+
}
|
|
2334
|
+
// ── dex-monitor logic (accessed via 'arb scan --live --hip3') ──
|
|
2335
|
+
async function handleDexMonitor(opts) {
|
|
2144
2336
|
const minSpread = parseFloat(opts.min);
|
|
2145
2337
|
const intervalSec = parseInt(opts.interval);
|
|
2146
2338
|
const topN = parseInt(opts.top);
|
|
@@ -2199,191 +2391,194 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
2199
2391
|
}
|
|
2200
2392
|
await new Promise(r => setTimeout(r, intervalSec * 1000));
|
|
2201
2393
|
}
|
|
2202
|
-
}
|
|
2203
|
-
// ──
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
.
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
|
|
2279
|
-
symbol: pos.underlying, side: "close", size: String(pos.size),
|
|
2280
|
-
status: "failed", dryRun: false,
|
|
2281
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2282
|
-
meta: { longDex: pos.longDex, shortDex: pos.shortDex },
|
|
2283
|
-
});
|
|
2284
|
-
if (!isJson())
|
|
2285
|
-
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
2286
|
-
}
|
|
2394
|
+
}
|
|
2395
|
+
// ── dex-auto logic (accessed via 'arb auto --hip3') ── (HIP-3 cross-dex auto arb)
|
|
2396
|
+
async function handleDexAuto(opts) {
|
|
2397
|
+
if (!getHLAdapterForDex) {
|
|
2398
|
+
console.log(chalk.red(" HIP-3 dex adapter not available."));
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
const minSpread = parseFloat(opts.minSpread);
|
|
2402
|
+
const closeSpread = parseFloat(opts.closeSpread);
|
|
2403
|
+
const sizeUsd = parseFloat(opts.size);
|
|
2404
|
+
const maxPositions = parseInt(opts.maxPositions);
|
|
2405
|
+
const minOi = parseFloat(opts.minOi);
|
|
2406
|
+
const gradeOrder = { A: 0, B: 1, C: 2, D: 3 };
|
|
2407
|
+
const minGradeIdx = gradeOrder[opts.minGrade.toUpperCase()] ?? 2;
|
|
2408
|
+
const intervalMs = parseInt(opts.interval) * 1000;
|
|
2409
|
+
// Check both subcommand option and global/argv (Commander may route --dry-run to parent)
|
|
2410
|
+
const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
|
|
2411
|
+
const openPositions = [];
|
|
2412
|
+
if (!isJson()) {
|
|
2413
|
+
console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Bot\n"));
|
|
2414
|
+
console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
|
|
2415
|
+
console.log(` Enter spread: >= ${minSpread}% annual`);
|
|
2416
|
+
console.log(` Close spread: <= ${closeSpread}% annual`);
|
|
2417
|
+
console.log(` Size per leg: $${sizeUsd}`);
|
|
2418
|
+
console.log(` Max positions: ${maxPositions}`);
|
|
2419
|
+
console.log(` Min OI: $${formatUsd(minOi)}`);
|
|
2420
|
+
console.log(` Min grade: ${opts.minGrade.toUpperCase()}`);
|
|
2421
|
+
console.log(` Interval: ${opts.interval}s`);
|
|
2422
|
+
console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
|
|
2423
|
+
}
|
|
2424
|
+
const cycle = async () => {
|
|
2425
|
+
try {
|
|
2426
|
+
const pairs = await scanDexArb({
|
|
2427
|
+
minAnnualSpread: 0, // get all, filter ourselves
|
|
2428
|
+
includeNative: true,
|
|
2429
|
+
});
|
|
2430
|
+
const now = new Date().toLocaleTimeString();
|
|
2431
|
+
// Check close conditions
|
|
2432
|
+
for (let i = openPositions.length - 1; i >= 0; i--) {
|
|
2433
|
+
const pos = openPositions[i];
|
|
2434
|
+
// Find current pair for this position
|
|
2435
|
+
const current = pairs.find(p => p.underlying === pos.underlying &&
|
|
2436
|
+
((p.long.dex === pos.longDex && p.short.dex === pos.shortDex) ||
|
|
2437
|
+
(p.long.dex === pos.shortDex && p.short.dex === pos.longDex)));
|
|
2438
|
+
const currentSpread = current?.annualSpread ?? 0;
|
|
2439
|
+
if (currentSpread <= closeSpread || !current) {
|
|
2440
|
+
const reason = !current ? "pair disappeared" : `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
|
|
2441
|
+
if (!isJson())
|
|
2442
|
+
console.log(chalk.yellow(` ${now} CLOSE ${pos.underlying} — ${reason}`));
|
|
2443
|
+
if (!dryRun) {
|
|
2444
|
+
try {
|
|
2445
|
+
const longAdapter = await getHLAdapterForDex(pos.longDex);
|
|
2446
|
+
const shortAdapter = await getHLAdapterForDex(pos.shortDex);
|
|
2447
|
+
await Promise.all([
|
|
2448
|
+
longAdapter.marketOrder(pos.longSymbol, "sell", pos.size),
|
|
2449
|
+
shortAdapter.marketOrder(pos.shortSymbol, "buy", pos.size),
|
|
2450
|
+
]);
|
|
2451
|
+
logExecution({
|
|
2452
|
+
type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
|
|
2453
|
+
symbol: pos.underlying, side: "close", size: String(pos.size),
|
|
2454
|
+
status: "success", dryRun: false,
|
|
2455
|
+
meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
|
|
2456
|
+
});
|
|
2457
|
+
if (!isJson())
|
|
2458
|
+
console.log(chalk.green(` ${now} CLOSED ${pos.underlying} — both legs`));
|
|
2459
|
+
}
|
|
2460
|
+
catch (err) {
|
|
2461
|
+
logExecution({
|
|
2462
|
+
type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
|
|
2463
|
+
symbol: pos.underlying, side: "close", size: String(pos.size),
|
|
2464
|
+
status: "failed", dryRun: false,
|
|
2465
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2466
|
+
meta: { longDex: pos.longDex, shortDex: pos.shortDex },
|
|
2467
|
+
});
|
|
2468
|
+
if (!isJson())
|
|
2469
|
+
console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
2287
2470
|
}
|
|
2288
|
-
openPositions.splice(i, 1);
|
|
2289
2471
|
}
|
|
2472
|
+
openPositions.splice(i, 1);
|
|
2290
2473
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2474
|
+
}
|
|
2475
|
+
// Check entry conditions
|
|
2476
|
+
if (openPositions.length < maxPositions) {
|
|
2477
|
+
for (const pair of pairs) {
|
|
2478
|
+
if (openPositions.some(p => p.underlying === pair.underlying))
|
|
2479
|
+
continue;
|
|
2480
|
+
if (openPositions.length >= maxPositions)
|
|
2481
|
+
break;
|
|
2482
|
+
if (pair.annualSpread < minSpread)
|
|
2483
|
+
continue;
|
|
2484
|
+
if (pair.minOiUsd < minOi)
|
|
2485
|
+
continue;
|
|
2486
|
+
if (gradeOrder[pair.viability] > minGradeIdx)
|
|
2487
|
+
continue;
|
|
2488
|
+
// Calculate size in asset units
|
|
2489
|
+
const avgPrice = (pair.long.markPrice + pair.short.markPrice) / 2;
|
|
2490
|
+
if (avgPrice <= 0)
|
|
2491
|
+
continue;
|
|
2492
|
+
const szDecimals = Math.min(pair.long.szDecimals, pair.short.szDecimals);
|
|
2493
|
+
const rawSize = sizeUsd / avgPrice;
|
|
2494
|
+
const size = rawSize.toFixed(szDecimals);
|
|
2495
|
+
if (!isJson()) {
|
|
2496
|
+
console.log(chalk.green(` ${now} ENTER ${pair.underlying} — spread ${pair.annualSpread.toFixed(1)}% grade:${pair.viability} OI:$${formatUsd(pair.minOiUsd)}` +
|
|
2497
|
+
`\n Long ${pair.long.dex}:${pair.long.base} (${(pair.long.fundingRate * 100).toFixed(4)}%)` +
|
|
2498
|
+
` | Short ${pair.short.dex}:${pair.short.base} (${(pair.short.fundingRate * 100).toFixed(4)}%)` +
|
|
2499
|
+
` | ${size} units @ $${avgPrice.toFixed(2)}`));
|
|
2500
|
+
}
|
|
2501
|
+
if (!dryRun) {
|
|
2502
|
+
try {
|
|
2503
|
+
const longAdapter = await getHLAdapterForDex(pair.long.dex);
|
|
2504
|
+
const shortAdapter = await getHLAdapterForDex(pair.short.dex);
|
|
2505
|
+
await Promise.all([
|
|
2506
|
+
longAdapter.marketOrder(pair.long.raw, "buy", size),
|
|
2507
|
+
shortAdapter.marketOrder(pair.short.raw, "sell", size),
|
|
2508
|
+
]);
|
|
2509
|
+
logExecution({
|
|
2510
|
+
type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
|
|
2511
|
+
symbol: pair.underlying, side: "entry", size,
|
|
2512
|
+
status: "success", dryRun: false,
|
|
2513
|
+
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice },
|
|
2514
|
+
});
|
|
2515
|
+
if (!isJson())
|
|
2516
|
+
console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs`));
|
|
2316
2517
|
}
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
status: "success", dryRun: false,
|
|
2329
|
-
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice },
|
|
2330
|
-
});
|
|
2331
|
-
if (!isJson())
|
|
2332
|
-
console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs`));
|
|
2333
|
-
}
|
|
2334
|
-
catch (err) {
|
|
2335
|
-
logExecution({
|
|
2336
|
-
type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
|
|
2337
|
-
symbol: pair.underlying, side: "entry", size,
|
|
2338
|
-
status: "failed", dryRun: false,
|
|
2339
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2340
|
-
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
|
|
2341
|
-
});
|
|
2342
|
-
if (!isJson())
|
|
2343
|
-
console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
2344
|
-
continue;
|
|
2345
|
-
}
|
|
2518
|
+
catch (err) {
|
|
2519
|
+
logExecution({
|
|
2520
|
+
type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
|
|
2521
|
+
symbol: pair.underlying, side: "entry", size,
|
|
2522
|
+
status: "failed", dryRun: false,
|
|
2523
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2524
|
+
meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
|
|
2525
|
+
});
|
|
2526
|
+
if (!isJson())
|
|
2527
|
+
console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}`));
|
|
2528
|
+
continue;
|
|
2346
2529
|
}
|
|
2347
|
-
openPositions.push({
|
|
2348
|
-
underlying: pair.underlying,
|
|
2349
|
-
longDex: pair.long.dex,
|
|
2350
|
-
longSymbol: pair.long.raw,
|
|
2351
|
-
shortDex: pair.short.dex,
|
|
2352
|
-
shortSymbol: pair.short.raw,
|
|
2353
|
-
size,
|
|
2354
|
-
entrySpread: pair.annualSpread,
|
|
2355
|
-
entryTime: new Date().toISOString(),
|
|
2356
|
-
longPrice: pair.long.markPrice,
|
|
2357
|
-
shortPrice: pair.short.markPrice,
|
|
2358
|
-
});
|
|
2359
2530
|
}
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
}
|
|
2373
|
-
else {
|
|
2374
|
-
console.log(chalk.gray(` ${now} No positions. ${pairs.filter(p => p.annualSpread >= minSpread).length} pairs above ${minSpread}%`));
|
|
2531
|
+
openPositions.push({
|
|
2532
|
+
underlying: pair.underlying,
|
|
2533
|
+
longDex: pair.long.dex,
|
|
2534
|
+
longSymbol: pair.long.raw,
|
|
2535
|
+
shortDex: pair.short.dex,
|
|
2536
|
+
shortSymbol: pair.short.raw,
|
|
2537
|
+
size,
|
|
2538
|
+
entrySpread: pair.annualSpread,
|
|
2539
|
+
entryTime: new Date().toISOString(),
|
|
2540
|
+
longPrice: pair.long.markPrice,
|
|
2541
|
+
shortPrice: pair.short.markPrice,
|
|
2542
|
+
});
|
|
2375
2543
|
}
|
|
2376
2544
|
}
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2545
|
+
// Status
|
|
2546
|
+
if (isJson()) {
|
|
2547
|
+
printJson(jsonOk({
|
|
2548
|
+
timestamp: new Date().toISOString(),
|
|
2549
|
+
openPositions,
|
|
2550
|
+
availablePairs: pairs.filter(p => p.annualSpread >= minSpread && p.minOiUsd >= minOi).length,
|
|
2551
|
+
}));
|
|
2380
2552
|
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2553
|
+
else if (openPositions.length > 0) {
|
|
2554
|
+
console.log(chalk.gray(` ${now} Positions: ${openPositions.length}/${maxPositions} — ` +
|
|
2555
|
+
openPositions.map(p => `${p.underlying}(${p.entrySpread.toFixed(0)}%)`).join(", ")));
|
|
2556
|
+
}
|
|
2557
|
+
else {
|
|
2558
|
+
console.log(chalk.gray(` ${now} No positions. ${pairs.filter(p => p.annualSpread >= minSpread).length} pairs above ${minSpread}%`));
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
catch (err) {
|
|
2562
|
+
if (!isJson())
|
|
2563
|
+
console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
2564
|
+
}
|
|
2565
|
+
};
|
|
2566
|
+
await cycle();
|
|
2567
|
+
setInterval(cycle, intervalMs);
|
|
2568
|
+
await new Promise(() => { }); // keep alive
|
|
2386
2569
|
}
|
|
2570
|
+
// Expose inner handlers for external use
|
|
2571
|
+
handleArbPnlRef = _handleArbPnl;
|
|
2572
|
+
}
|
|
2573
|
+
// Module-level exported references (assigned inside registerArbAutoCommands)
|
|
2574
|
+
export let handleArbPnlRef;
|
|
2575
|
+
/**
|
|
2576
|
+
* Exported wrapper for handleArbPnl.
|
|
2577
|
+
*/
|
|
2578
|
+
export async function handleArbPnl(getAdapterForExchange, isJson) {
|
|
2579
|
+
if (handleArbPnlRef)
|
|
2580
|
+
return handleArbPnlRef();
|
|
2581
|
+
throw new Error("handleArbPnl not initialized -- call registerArbAutoCommands first");
|
|
2387
2582
|
}
|
|
2388
2583
|
// ── Orderbook helpers for monitor ──
|
|
2389
2584
|
async function fetchHLOrderbook(symbol) {
|