perp-cli 0.4.12 → 0.5.2

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.
@@ -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.5")
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 current funding rate spreads (perp-perp or spot-perp)")
902
- .option("--mode <mode>", "Scan mode: perp-perp, spot-perp, all", "perp-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.5")
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
- 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"))) {
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
- 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%)`));
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
- // Header
1016
- console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"GAP".padEnd(8)} ${"DIR".padEnd(7)} RATES`));
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
- console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
1039
- `${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
1040
- `${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
1041
- `${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
1042
- `${gapColor(`${priceGapPct.toFixed(3)}%`.padEnd(8))} ` +
1043
- `${direction.padEnd(7)} ` +
1044
- rates.join(" "));
1045
- }
1046
- console.log(chalk.gray(`\n ${filtered.length} opportunities above ${minSpread}% gross annual spread`));
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 on both exchanges, then enter both legs simultaneously")
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: matchedSize,
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: "filled", symbol: sym, size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
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
- console.log(chalk.green(` FILLED: ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
1214
- console.log(chalk.green(` LONG ${longExch} ✓ (${longPos.size})`));
1215
- console.log(chalk.green(` SHORT ${shortExch} ✓ (${shortPos.size})\n`));
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
- // ── 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) => {
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
- // ── 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();
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[opts.spotExch?.toLowerCase() ?? ""] || opts.spotExch?.toLowerCase() || pos?.spotExchange || pos?.longExchange;
1710
- const perpExch = aliasMap[opts.perpExch?.toLowerCase() ?? ""] || opts.perpExch?.toLowerCase() || pos?.shortExchange;
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 (isJson())
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 getAdapterForExchange("hyperliquid");
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 getAdapterForExchange("lighter");
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 getAdapterForExchange(perpExch);
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 (isJson())
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 (!isJson()) {
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 (!isJson())
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 (isJson())
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 (isJson())
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 ── (check arb position PnL)
1872
- arb
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
- // ── arb monitor ── (live monitoring with liquidity)
2027
- arb
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
- // ── arb dex-auto ── (HIP-3 cross-dex auto arb)
2204
- if (getHLAdapterForDex) {
2205
- arb
2206
- .command("dex-auto")
2207
- .description("Auto-execute HIP-3 cross-dex funding arb (long low-funding, short high-funding)")
2208
- .option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
2209
- .option("--close-spread <pct>", "Close when spread drops below (%)", "5")
2210
- .option("--size <usd>", "Position size per leg ($)", "100")
2211
- .option("--max-positions <n>", "Max simultaneous arb positions", "5")
2212
- .option("--min-oi <usd>", "Min OI (USD) to enter", "50000")
2213
- .option("--min-grade <grade>", "Min viability grade: A, B, C, D", "C")
2214
- .option("--interval <seconds>", "Check interval", "120")
2215
- .option("--dry-run", "Simulate without executing trades")
2216
- .action(async (opts) => {
2217
- const minSpread = parseFloat(opts.minSpread);
2218
- const closeSpread = parseFloat(opts.closeSpread);
2219
- const sizeUsd = parseFloat(opts.size);
2220
- const maxPositions = parseInt(opts.maxPositions);
2221
- const minOi = parseFloat(opts.minOi);
2222
- const gradeOrder = { A: 0, B: 1, C: 2, D: 3 };
2223
- const minGradeIdx = gradeOrder[opts.minGrade.toUpperCase()] ?? 2;
2224
- const intervalMs = parseInt(opts.interval) * 1000;
2225
- // Check both subcommand option and global/argv (Commander may route --dry-run to parent)
2226
- const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
2227
- const openPositions = [];
2228
- if (!isJson()) {
2229
- console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Bot\n"));
2230
- console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
2231
- console.log(` Enter spread: >= ${minSpread}% annual`);
2232
- console.log(` Close spread: <= ${closeSpread}% annual`);
2233
- console.log(` Size per leg: $${sizeUsd}`);
2234
- console.log(` Max positions: ${maxPositions}`);
2235
- console.log(` Min OI: $${formatUsd(minOi)}`);
2236
- console.log(` Min grade: ${opts.minGrade.toUpperCase()}`);
2237
- console.log(` Interval: ${opts.interval}s`);
2238
- console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
2239
- }
2240
- const cycle = async () => {
2241
- try {
2242
- const pairs = await scanDexArb({
2243
- minAnnualSpread: 0, // get all, filter ourselves
2244
- includeNative: true,
2245
- });
2246
- const now = new Date().toLocaleTimeString();
2247
- // Check close conditions
2248
- for (let i = openPositions.length - 1; i >= 0; i--) {
2249
- const pos = openPositions[i];
2250
- // Find current pair for this position
2251
- const current = pairs.find(p => p.underlying === pos.underlying &&
2252
- ((p.long.dex === pos.longDex && p.short.dex === pos.shortDex) ||
2253
- (p.long.dex === pos.shortDex && p.short.dex === pos.longDex)));
2254
- const currentSpread = current?.annualSpread ?? 0;
2255
- if (currentSpread <= closeSpread || !current) {
2256
- const reason = !current ? "pair disappeared" : `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
2257
- if (!isJson())
2258
- console.log(chalk.yellow(` ${now} CLOSE ${pos.underlying} — ${reason}`));
2259
- if (!dryRun) {
2260
- try {
2261
- const longAdapter = await getHLAdapterForDex(pos.longDex);
2262
- const shortAdapter = await getHLAdapterForDex(pos.shortDex);
2263
- await Promise.all([
2264
- longAdapter.marketOrder(pos.longSymbol, "sell", pos.size),
2265
- shortAdapter.marketOrder(pos.shortSymbol, "buy", pos.size),
2266
- ]);
2267
- logExecution({
2268
- type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
2269
- symbol: pos.underlying, side: "close", size: String(pos.size),
2270
- status: "success", dryRun: false,
2271
- meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
2272
- });
2273
- if (!isJson())
2274
- console.log(chalk.green(` ${now} CLOSED ${pos.underlying} — both legs`));
2275
- }
2276
- catch (err) {
2277
- logExecution({
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
- // Check entry conditions
2292
- if (openPositions.length < maxPositions) {
2293
- for (const pair of pairs) {
2294
- if (openPositions.some(p => p.underlying === pair.underlying))
2295
- continue;
2296
- if (openPositions.length >= maxPositions)
2297
- break;
2298
- if (pair.annualSpread < minSpread)
2299
- continue;
2300
- if (pair.minOiUsd < minOi)
2301
- continue;
2302
- if (gradeOrder[pair.viability] > minGradeIdx)
2303
- continue;
2304
- // Calculate size in asset units
2305
- const avgPrice = (pair.long.markPrice + pair.short.markPrice) / 2;
2306
- if (avgPrice <= 0)
2307
- continue;
2308
- const szDecimals = Math.min(pair.long.szDecimals, pair.short.szDecimals);
2309
- const rawSize = sizeUsd / avgPrice;
2310
- const size = rawSize.toFixed(szDecimals);
2311
- if (!isJson()) {
2312
- console.log(chalk.green(` ${now} ENTER ${pair.underlying} — spread ${pair.annualSpread.toFixed(1)}% grade:${pair.viability} OI:$${formatUsd(pair.minOiUsd)}` +
2313
- `\n Long ${pair.long.dex}:${pair.long.base} (${(pair.long.fundingRate * 100).toFixed(4)}%)` +
2314
- ` | Short ${pair.short.dex}:${pair.short.base} (${(pair.short.fundingRate * 100).toFixed(4)}%)` +
2315
- ` | ${size} units @ $${avgPrice.toFixed(2)}`));
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
- if (!dryRun) {
2318
- try {
2319
- const longAdapter = await getHLAdapterForDex(pair.long.dex);
2320
- const shortAdapter = await getHLAdapterForDex(pair.short.dex);
2321
- await Promise.all([
2322
- longAdapter.marketOrder(pair.long.raw, "buy", size),
2323
- shortAdapter.marketOrder(pair.short.raw, "sell", size),
2324
- ]);
2325
- logExecution({
2326
- type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
2327
- symbol: pair.underlying, side: "entry", size,
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
- // Status
2362
- if (isJson()) {
2363
- printJson(jsonOk({
2364
- timestamp: new Date().toISOString(),
2365
- openPositions,
2366
- availablePairs: pairs.filter(p => p.annualSpread >= minSpread && p.minOiUsd >= minOi).length,
2367
- }));
2368
- }
2369
- else if (openPositions.length > 0) {
2370
- console.log(chalk.gray(` ${now} Positions: ${openPositions.length}/${maxPositions} — ` +
2371
- openPositions.map(p => `${p.underlying}(${p.entrySpread.toFixed(0)}%)`).join(", ")));
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
- catch (err) {
2378
- if (!isJson())
2379
- console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
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
- await cycle();
2383
- setInterval(cycle, intervalMs);
2384
- await new Promise(() => { }); // keep alive
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) {