perp-cli 0.4.0 → 0.4.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.
@@ -2,6 +2,8 @@
2
2
  * Arb utilities: settlement timing strategy, basis risk monitoring, and webhook notifications.
3
3
  */
4
4
  import type { ExchangeAdapter } from "../exchanges/interface.js";
5
+ /** Settlement schedules per exchange (UTC hours when settlement occurs) */
6
+ export declare const SETTLEMENT_SCHEDULES: Record<string, number[]>;
5
7
  export type SettleStrategy = "block" | "aggressive" | "off";
6
8
  /**
7
9
  * Get the most recent settlement time for an exchange.
package/dist/arb/utils.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  // ── Settlement Timing Strategy ──
5
5
  /** Settlement schedules per exchange (UTC hours when settlement occurs) */
6
- const SETTLEMENT_SCHEDULES = {
6
+ export const SETTLEMENT_SCHEDULES = {
7
7
  hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
8
8
  pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
9
9
  lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
@@ -142,14 +142,14 @@ export function registerAgentCommands(program, getAdapter, isJson) {
142
142
  category: "trading",
143
143
  commands: [
144
144
  "perp trade check <symbol> <side> <size> --json",
145
- "perp trade market <symbol> <buy|sell> <size> --json --client-id <id>",
145
+ "perp trade market <symbol> <buy|sell> <size> --json [--smart] --client-id <id>",
146
146
  "perp trade limit <symbol> <buy|sell> <price> <size> --json --client-id <id>",
147
147
  "perp trade cancel <symbol> <orderId> --json",
148
148
  "perp trade cancel-all --json",
149
- "perp trade close <symbol> --json",
150
- "perp trade close-all --json",
151
- "perp trade flatten --json",
152
- "perp trade reduce <symbol> <percent> --json",
149
+ "perp trade close <symbol> --json [--smart]",
150
+ "perp trade close-all --json [--smart]",
151
+ "perp trade flatten --json [--smart]",
152
+ "perp trade reduce <symbol> <percent> --json [--smart]",
153
153
  "perp trade stop <symbol> <side> <stopPrice> <size> --json",
154
154
  "perp trade tpsl <symbol> <side> --tp <price> --sl <price> --json",
155
155
  "perp trade twap <symbol> <side> <size> <duration> --json",
@@ -4,12 +4,14 @@ import { readExecutionLog, logExecution } from "../../execution-log.js";
4
4
  import { computeAnnualSpread } from "../../funding.js";
5
5
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
6
6
  import { computeBasisRisk } from "../../arb-utils.js";
7
+ import { smartOrder } from "../../smart-order.js";
8
+ import { removePosition as persistRemovePosition } from "../../arb-state.js";
7
9
  import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
8
10
  import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
9
11
  import { computeEnhancedStats } from "../../arb-history-stats.js";
10
12
  // ── Helpers ──
13
+ import { DEFAULT_TAKER_FEE as TAKER_FEE } from "../../constants.js";
11
14
  const EXCHANGES = ["hyperliquid", "lighter", "pacifica"];
12
- const TAKER_FEE = 0.00035; // ~0.035% typical taker fee
13
15
  function formatDuration(ms) {
14
16
  const hours = Math.floor(ms / (1000 * 60 * 60));
15
17
  const days = Math.floor(hours / 24);
@@ -293,6 +295,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
293
295
  .description("Manually close an arb position on both exchanges")
294
296
  .option("--dry-run", "Show what would happen without executing")
295
297
  .option("--pair <pair>", "Specify arb pair as longExchange:shortExchange (e.g. pacifica:hyperliquid)")
298
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
296
299
  .action(async (symbol, opts) => {
297
300
  const sym = symbol.toUpperCase();
298
301
  const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
@@ -436,10 +439,15 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
436
439
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
437
440
  try {
438
441
  const adapter = await getAdapterForExchange(exchange);
439
- await adapter.marketOrder(rawSymbol, side, String(size));
442
+ if (opts.smart) {
443
+ await smartOrder(adapter, rawSymbol, side, String(size), { reduceOnly: true });
444
+ }
445
+ else {
446
+ await adapter.marketOrder(rawSymbol, side, String(size));
447
+ }
440
448
  results.push({ exchange, action: `${side} (close ${label})`, status: "success", retries: attempt });
441
449
  if (!isJson())
442
- console.log(chalk.green(` Closed ${label} on ${exchange}: ${side.toUpperCase()} ${size} ${sym}${attempt > 0 ? ` (retry ${attempt})` : ""}`));
450
+ console.log(chalk.green(` Closed ${label} on ${exchange}: ${side.toUpperCase()} ${size} ${sym}${opts.smart ? " (smart)" : ""}${attempt > 0 ? ` (retry ${attempt})` : ""}`));
443
451
  return;
444
452
  }
445
453
  catch (err) {
@@ -488,6 +496,13 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
488
496
  console.log(chalk.yellow(`\n Warning: Position may not be fully closed on: ${notClosed.map(v => v.exchange).join(", ")}`));
489
497
  }
490
498
  }
499
+ // Remove from persisted daemon state (so daemon doesn't try to re-close)
500
+ if (allSuccess) {
501
+ try {
502
+ persistRemovePosition(sym);
503
+ }
504
+ catch { /* state may not exist */ }
505
+ }
491
506
  // Log to execution log
492
507
  const arbPairId = `${sym}:${longPos.exchange}:${shortPos.exchange}`;
493
508
  logExecution({
@@ -5,19 +5,12 @@ import { computeAnnualSpread, toHourlyRate } from "../funding.js";
5
5
  import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
6
6
  import { scanDexArb } from "../dex-asset-map.js";
7
7
  import { logExecution, readExecutionLog } from "../execution-log.js";
8
+ import { smartOrder } from "../smart-order.js";
8
9
  import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
9
10
  import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
10
- import { loadArbState, saveArbState, createInitialState, } from "../arb-state.js";
11
+ import { loadArbState, saveArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, createInitialState, } from "../arb-state.js";
11
12
  // ── Fee-Adjusted Net Spread Calculation ──
12
- /** Default taker fee per exchange (as fraction, e.g. 0.00035 = 0.035%) */
13
- const TAKER_FEES = {
14
- hyperliquid: 0.00035,
15
- pacifica: 0.00035,
16
- lighter: 0.00035,
17
- };
18
- function getTakerFee(exchange) {
19
- return TAKER_FEES[exchange.toLowerCase()] ?? 0.00035;
20
- }
13
+ import { getTakerFee } from "../constants.js";
21
14
  /**
22
15
  * Compute the estimated round-trip cost as a percentage of notional.
23
16
  * Round-trip = 4 × taker fee + 2 × slippage (entry + exit for both legs).
@@ -50,12 +43,7 @@ export function computeNetSpread(grossAnnualPct, holdDays, roundTripCostPct, bri
50
43
  return grossAnnualPct - annualizedCostPct - bridgeCostAnnualPct;
51
44
  }
52
45
  // ── Funding Settlement Timing ──
53
- /** Settlement schedules per exchange (UTC hours when settlement occurs) */
54
- const SETTLEMENT_SCHEDULES = {
55
- hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
56
- pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
57
- lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
58
- };
46
+ import { SETTLEMENT_SCHEDULES } from "../arb-utils.js";
59
47
  /**
60
48
  * Get the next settlement time for an exchange.
61
49
  * @returns Date of next settlement
@@ -188,6 +176,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
188
176
  .option("--max-basis <pct>", "Max basis risk (mark price divergence %)", "3")
189
177
  .option("--notify <url>", "Webhook URL for notifications (Discord/Telegram/generic)")
190
178
  .option("--notify-events <events>", "Comma-separated events: entry,exit,reversal,margin,basis", "entry,exit,reversal,margin,basis")
179
+ .option("--cooldown <minutes>", "Minutes to wait before re-entering a closed symbol", "30")
191
180
  .option("--dry-run", "Simulate without executing trades")
192
181
  .option("--background", "Run in background (tmux)")
193
182
  .action(async (opts) => {
@@ -202,6 +191,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
202
191
  `--hold-days`, opts.holdDays,
203
192
  `--bridge-cost`, opts.bridgeCost,
204
193
  `--min-margin`, opts.minMargin,
194
+ `--cooldown`, opts.cooldown,
205
195
  ...(opts.symbols ? [`--symbols`, opts.symbols] : []),
206
196
  ...(opts.dryRun ? [`--dry-run`] : []),
207
197
  ...(opts.reversalExit === false ? [`--no-reversal-exit`] : []),
@@ -233,7 +223,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
233
223
  const bridgeCostUsd = parseFloat(opts.bridgeCost);
234
224
  const reversalExitEnabled = opts.reversalExit !== false;
235
225
  const settleAwareEnabled = opts.settleAware !== false;
236
- const settleStrategy = (opts.settleStrategy || "block");
226
+ // --no-settle-aware overrides --settle-strategy to "off"
227
+ const settleStrategy = !settleAwareEnabled ? "off" : (opts.settleStrategy || "block");
237
228
  const maxBasisPct = parseFloat(opts.maxBasis);
238
229
  const webhookUrl = opts.notify;
239
230
  const notifyEvents = opts.notifyEvents
@@ -242,9 +233,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
242
233
  const minSizeUsd = parseFloat(opts.minSize);
243
234
  const filterSymbols = opts.symbols?.split(",").map(s => s.trim().toUpperCase());
244
235
  const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
236
+ const cooldownMinutes = parseFloat(opts.cooldown);
245
237
  const openPositions = [];
246
238
  // Track which exchanges have low margin (block entries)
247
239
  const blockedExchanges = new Set();
240
+ // Track close times per symbol to prevent close→re-entry loops
241
+ const closeCooldowns = new Map(); // symbol → close timestamp ms
248
242
  // -- State Persistence: Initialize or recover --
249
243
  const daemonConfig = {
250
244
  minSpread,
@@ -263,7 +257,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
263
257
  symbol: persisted.symbol,
264
258
  longExchange: persisted.longExchange,
265
259
  shortExchange: persisted.shortExchange,
266
- size: String(persisted.longSize),
260
+ size: persisted.longSize,
267
261
  entrySpread: persisted.entrySpread,
268
262
  entryTime: persisted.entryTime,
269
263
  entryMarkPrice: persisted.entryLongPrice,
@@ -281,8 +275,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
281
275
  daemonState = createInitialState(daemonConfig);
282
276
  saveArbState(daemonState);
283
277
  }
284
- // SIGINT handler: save final state before exit
278
+ // SIGINT handler: clean up interval and save final state before exit
279
+ let cycleInterval = null;
285
280
  const handleSigint = () => {
281
+ if (cycleInterval)
282
+ clearInterval(cycleInterval);
286
283
  const finalState = loadArbState();
287
284
  if (finalState) {
288
285
  finalState.lastScanTime = new Date().toISOString();
@@ -308,12 +305,42 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
308
305
  console.log(` Max basis: ${maxBasisPct}% (warn on price divergence)`);
309
306
  console.log(` Reversal exit: ${reversalExitEnabled ? chalk.green("ON") : chalk.yellow("OFF")}`);
310
307
  console.log(` Settle strat: ${settleStrategy === "aggressive" ? chalk.cyan("AGGRESSIVE") : settleStrategy === "off" ? chalk.yellow("OFF") : chalk.green("BLOCK")}`);
308
+ console.log(` Cooldown: ${cooldownMinutes}m (re-entry delay after close)`);
311
309
  console.log(` Notifications: ${webhookUrl ? chalk.green("ON") : chalk.gray("OFF")}${webhookUrl ? ` (${notifyEvents.join(",")})` : ""}`);
312
310
  console.log(` Symbols: ${filterSymbols?.join(", ") || "all"}`);
313
311
  console.log(` Interval: ${opts.interval}s`);
314
312
  console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
315
313
  }
316
314
  const cycle = async () => {
315
+ // Sync in-memory positions with persisted state (handles CLI manual closes)
316
+ const syncState = loadArbState();
317
+ if (syncState) {
318
+ const persistedSymbols = new Set(syncState.positions.map(p => p.symbol));
319
+ // Remove positions that were closed externally (e.g. via `perp arb close`)
320
+ for (let i = openPositions.length - 1; i >= 0; i--) {
321
+ if (!persistedSymbols.has(openPositions[i].symbol)) {
322
+ console.log(chalk.gray(` ${new Date().toLocaleTimeString()} SYNC: ${openPositions[i].symbol} removed (closed externally)`));
323
+ openPositions.splice(i, 1);
324
+ }
325
+ }
326
+ // Recover positions that exist in state but not in memory (e.g. added by another process)
327
+ for (const persisted of syncState.positions) {
328
+ if (!openPositions.some(p => p.symbol === persisted.symbol)) {
329
+ console.log(chalk.gray(` ${new Date().toLocaleTimeString()} SYNC: recovering ${persisted.symbol} from state`));
330
+ openPositions.push({
331
+ symbol: persisted.symbol,
332
+ longExchange: persisted.longExchange,
333
+ shortExchange: persisted.shortExchange,
334
+ size: persisted.longSize,
335
+ entrySpread: persisted.entrySpread,
336
+ entryTime: persisted.entryTime,
337
+ entryMarkPrice: persisted.entryLongPrice,
338
+ accumulatedFundingUsd: persisted.accumulatedFunding,
339
+ lastCheckTime: new Date(persisted.lastCheckTime).getTime(),
340
+ });
341
+ }
342
+ }
343
+ }
317
344
  // Heartbeat check
318
345
  const heartbeatState = loadArbState();
319
346
  if (heartbeatState?.lastSuccessfulScanTime) {
@@ -375,45 +402,120 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
375
402
  }
376
403
  if (shouldClose) {
377
404
  console.log(chalk.yellow(` ${now} CLOSE ${pos.symbol} — ${closeReason}`));
405
+ const exitReason = closeReason.includes("REVERSAL") ? "reversal"
406
+ : closeReason.includes("spread") ? "spread"
407
+ : "manual";
378
408
  if (!dryRun) {
379
409
  try {
380
- // Close both legs
381
410
  const longAdapter = await getAdapterForExchange(pos.longExchange);
382
411
  const shortAdapter = await getAdapterForExchange(pos.shortExchange);
383
- await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
384
- await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
385
- // Determine exit reason tag
386
- const exitReason = closeReason.includes("REVERSAL") ? "reversal"
387
- : closeReason.includes("spread") ? "spread"
388
- : "manual";
389
- logExecution({
390
- type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
391
- symbol: pos.symbol, side: "close", size: pos.size,
392
- status: "success", dryRun: false,
393
- meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
394
- });
395
- console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
396
- await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
397
- symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
398
- pnl: pos.accumulatedFundingUsd,
399
- duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
400
- });
412
+ // Close both legs simultaneously with verification
413
+ const [longResult, shortResult] = await Promise.allSettled([
414
+ longAdapter.marketOrder(pos.symbol, "sell", String(pos.size)),
415
+ shortAdapter.marketOrder(pos.symbol, "buy", String(pos.size)),
416
+ ]);
417
+ const longOk = longResult.status === "fulfilled";
418
+ const shortOk = shortResult.status === "fulfilled";
419
+ if (longOk && shortOk) {
420
+ logExecution({
421
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
422
+ symbol: pos.symbol, side: "close", size: String(pos.size),
423
+ status: "success", dryRun: false,
424
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
425
+ });
426
+ console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
427
+ persistRemovePosition(pos.symbol);
428
+ closeCooldowns.set(pos.symbol, Date.now());
429
+ openPositions.splice(i, 1);
430
+ await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
431
+ symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
432
+ pnl: pos.accumulatedFundingUsd,
433
+ duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
434
+ });
435
+ }
436
+ else if (longOk !== shortOk) {
437
+ // One leg closed, one failed — retry the failed leg
438
+ const failedSide = longOk ? "short" : "long";
439
+ const failedAdapter = longOk ? shortAdapter : longAdapter;
440
+ const retryAction = longOk ? "buy" : "sell";
441
+ const failedErr = longOk
442
+ ? shortResult.reason
443
+ : longResult.reason;
444
+ console.log(chalk.yellow(` ${now} PARTIAL CLOSE ${pos.symbol}: ${failedSide} leg failed — retrying...`));
445
+ let retryOk = false;
446
+ for (let attempt = 1; attempt <= 2; attempt++) {
447
+ try {
448
+ await failedAdapter.marketOrder(pos.symbol, retryAction, String(pos.size));
449
+ retryOk = true;
450
+ console.log(chalk.green(` ${now} RETRY OK ${pos.symbol}: ${failedSide} leg closed (attempt ${attempt})`));
451
+ break;
452
+ }
453
+ catch (retryErr) {
454
+ console.log(chalk.red(` ${now} RETRY ${attempt}/2 FAILED ${pos.symbol}: ${retryErr instanceof Error ? retryErr.message : retryErr}`));
455
+ }
456
+ }
457
+ if (retryOk) {
458
+ logExecution({
459
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
460
+ symbol: pos.symbol, side: "close", size: String(pos.size),
461
+ status: "success", dryRun: false,
462
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason, retried: failedSide },
463
+ });
464
+ persistRemovePosition(pos.symbol);
465
+ closeCooldowns.set(pos.symbol, Date.now());
466
+ openPositions.splice(i, 1);
467
+ }
468
+ else {
469
+ // One leg is closed, other still open — log but DON'T remove from tracking
470
+ logExecution({
471
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
472
+ symbol: pos.symbol, side: "close", size: String(pos.size),
473
+ status: "failed", dryRun: false,
474
+ error: `Partial close: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Retry failed.`,
475
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason, partialClose: true },
476
+ });
477
+ console.log(chalk.red.bold(` ${now} PARTIAL CLOSE ${pos.symbol}: ${failedSide} still open — MANUAL CLOSE REQUIRED`));
478
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
479
+ exchange: longOk ? pos.shortExchange : pos.longExchange,
480
+ marginPct: 0, threshold: 0, symbol: pos.symbol,
481
+ message: `PARTIAL CLOSE: ${failedSide} leg still open after retry. Manual close required.`,
482
+ });
483
+ // Do NOT splice — keep tracking so next cycle retries
484
+ }
485
+ }
486
+ else {
487
+ // Both legs failed — keep position, retry next cycle
488
+ const longErr = longResult.reason;
489
+ const shortErr = shortResult.reason;
490
+ logExecution({
491
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
492
+ symbol: pos.symbol, side: "close", size: String(pos.size),
493
+ status: "failed", dryRun: false,
494
+ error: `Both legs failed. Long: ${longErr instanceof Error ? longErr.message : String(longErr)}, Short: ${shortErr instanceof Error ? shortErr.message : String(shortErr)}`,
495
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
496
+ });
497
+ console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: both legs — will retry next cycle`));
498
+ // Do NOT splice — retry next cycle
499
+ }
401
500
  }
402
501
  catch (err) {
403
- const exitReason = closeReason.includes("REVERSAL") ? "reversal"
404
- : closeReason.includes("spread") ? "spread"
405
- : "manual";
406
502
  logExecution({
407
503
  type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
408
- symbol: pos.symbol, side: "close", size: pos.size,
504
+ symbol: pos.symbol, side: "close", size: String(pos.size),
409
505
  status: "failed", dryRun: false,
410
506
  error: err instanceof Error ? err.message : String(err),
411
507
  meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
412
508
  });
413
509
  console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
510
+ // Do NOT splice — retry next cycle
414
511
  }
415
512
  }
416
- openPositions.splice(i, 1);
513
+ else {
514
+ // Dry run — remove from tracking
515
+ persistRemovePosition(pos.symbol);
516
+ closeCooldowns.set(pos.symbol, Date.now());
517
+ openPositions.splice(i, 1);
518
+ }
417
519
  }
418
520
  }
419
521
  // Log next settlement times and funding estimation
@@ -434,7 +536,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
434
536
  const fRateFor = (e) => e === "pacifica" ? fSnap.pacRate : e === "hyperliquid" ? fSnap.hlRate : fSnap.ltRate;
435
537
  const fHlHourly = toHourlyRate(fRateFor("hyperliquid"), "hyperliquid");
436
538
  const fPacHourly = toHourlyRate(fRateFor("pacifica"), "pacifica");
437
- const fNotional = parseFloat(fPos.size) * fSnap.markPrice;
539
+ const fNotional = fPos.size * fSnap.markPrice;
438
540
  const fEst = estimateFundingUntilSettlement(fHlHourly, fPacHourly, fNotional, hoursUntilPAC);
439
541
  console.log(chalk.gray(` ${now} ${fPos.symbol} Next PAC: ${hUTC}:00 UTC (${hoursUntilPAC.toFixed(1)}h) | ` +
440
542
  `HL cum: ~$${fEst.hlCumulative.toFixed(4)} | PAC pmt: ~$${fEst.pacPayment.toFixed(4)} | ` +
@@ -511,6 +613,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
511
613
  continue;
512
614
  if (openPositions.length >= maxPositions)
513
615
  break;
616
+ // Cooldown: skip if recently closed to prevent close→re-entry loops
617
+ const lastCloseMs = closeCooldowns.get(snap.symbol);
618
+ if (lastCloseMs && cooldownMinutes > 0) {
619
+ const elapsedMin = (Date.now() - lastCloseMs) / 60000;
620
+ if (elapsedMin < cooldownMinutes) {
621
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: cooldown ${(cooldownMinutes - elapsedMin).toFixed(0)}m remaining`));
622
+ continue;
623
+ }
624
+ // Cooldown expired, clean up
625
+ closeCooldowns.delete(snap.symbol);
626
+ }
514
627
  const grossSpread = Math.abs(snap.spread);
515
628
  // Determine direction using all 3 exchanges: short the high-funding, long the low-funding
516
629
  const longExchange = snap.longExch;
@@ -526,9 +639,6 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
526
639
  const roundTripCost = computeRoundTripCostPct(longExchange, shortExchange);
527
640
  const effectiveSizeUsd = sizeIsAuto ? 100 : sizeUsd; // Use $100 for net spread calc when auto
528
641
  const netSpread = computeNetSpread(grossSpread, holdDays, roundTripCost, bridgeCostUsd, effectiveSizeUsd);
529
- // --min-spread now compares against NET spread
530
- if (netSpread < minSpread)
531
- continue;
532
642
  // Settlement timing check with strategy
533
643
  if (settleStrategy === "block") {
534
644
  const settlCheck = isNearSettlement(longExchange, shortExchange);
@@ -545,6 +655,10 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
545
655
  console.log(chalk.cyan(` ${now} BOOST ${snap.symbol}: post-settlement ${((settleBoostMultiplier - 1) * 100).toFixed(0)}% score boost`));
546
656
  }
547
657
  }
658
+ // --min-spread compares against NET spread (with settle boost applied)
659
+ const effectiveNetSpread = netSpread * settleBoostMultiplier;
660
+ if (effectiveNetSpread < minSpread)
661
+ continue;
548
662
  const rateForExch = (e) => e === "pacifica" ? snap.pacRate : e === "hyperliquid" ? snap.hlRate : snap.ltRate;
549
663
  console.log(chalk.green(` ${now} ENTER ${snap.symbol} — gross ${grossSpread.toFixed(1)}% net ${netSpread.toFixed(1)}%` +
550
664
  ` | Long ${longExchange} (${(rateForExch(longExchange) * 100).toFixed(4)}%)` +
@@ -597,6 +711,44 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
597
711
  meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice },
598
712
  });
599
713
  console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${sizeInAsset} units ($${actualSizeUsd} / $${snap.markPrice.toFixed(2)})`));
714
+ // Verify actual fill sizes by querying positions
715
+ let verifiedLongSize = sizeInAsset;
716
+ let verifiedShortSize = sizeInAsset;
717
+ try {
718
+ const [longPositions, shortPositions] = await Promise.all([
719
+ longAdapter.getPositions(),
720
+ shortAdapter.getPositions(),
721
+ ]);
722
+ const longPos = longPositions.find(p => p.symbol.toUpperCase().includes(snap.symbol));
723
+ const shortPos = shortPositions.find(p => p.symbol.toUpperCase().includes(snap.symbol));
724
+ if (longPos)
725
+ verifiedLongSize = longPos.size;
726
+ if (shortPos)
727
+ verifiedShortSize = shortPos.size;
728
+ if (verifiedLongSize !== sizeInAsset || verifiedShortSize !== sizeInAsset) {
729
+ console.log(chalk.yellow(` ${now} SIZE MISMATCH ${snap.symbol}: requested ${sizeInAsset}, actual long=${verifiedLongSize} short=${verifiedShortSize}`));
730
+ }
731
+ }
732
+ catch {
733
+ // Position query failed — use requested size as fallback
734
+ console.log(chalk.gray(` ${now} Could not verify fill sizes for ${snap.symbol}, using requested size`));
735
+ }
736
+ // Persist to state file immediately (crash-safe)
737
+ const entryTime = new Date().toISOString();
738
+ persistAddPosition({
739
+ id: `${snap.symbol}-${Date.now()}`,
740
+ symbol: snap.symbol,
741
+ longExchange,
742
+ shortExchange,
743
+ longSize: parseFloat(verifiedLongSize),
744
+ shortSize: parseFloat(verifiedShortSize),
745
+ entryTime,
746
+ entrySpread: grossSpread,
747
+ entryLongPrice: snap.markPrice,
748
+ entryShortPrice: snap.markPrice,
749
+ accumulatedFunding: 0,
750
+ lastCheckTime: entryTime,
751
+ });
600
752
  await notifyIfEnabled(webhookUrl, notifyEvents, "entry", {
601
753
  symbol: snap.symbol, longExchange, shortExchange,
602
754
  size: actualSizeUsd, netSpread,
@@ -672,17 +824,35 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
672
824
  continue;
673
825
  }
674
826
  }
827
+ const posEntryTime = new Date().toISOString();
675
828
  openPositions.push({
676
829
  symbol: snap.symbol,
677
830
  longExchange,
678
831
  shortExchange,
679
- size: sizeInAsset,
832
+ size: parseFloat(sizeInAsset),
680
833
  entrySpread: grossSpread,
681
- entryTime: new Date().toISOString(),
834
+ entryTime: posEntryTime,
682
835
  entryMarkPrice: snap.markPrice,
683
836
  accumulatedFundingUsd: 0,
684
837
  lastCheckTime: Date.now(),
685
838
  });
839
+ // Persist for dry-run mode too (tracks what would have been entered)
840
+ if (dryRun) {
841
+ persistAddPosition({
842
+ id: `${snap.symbol}-${Date.now()}`,
843
+ symbol: snap.symbol,
844
+ longExchange,
845
+ shortExchange,
846
+ longSize: parseFloat(sizeInAsset),
847
+ shortSize: parseFloat(sizeInAsset),
848
+ entryTime: posEntryTime,
849
+ entrySpread: grossSpread,
850
+ entryLongPrice: snap.markPrice,
851
+ entryShortPrice: snap.markPrice,
852
+ accumulatedFunding: 0,
853
+ lastCheckTime: posEntryTime,
854
+ });
855
+ }
686
856
  }
687
857
  }
688
858
  // Accumulate estimated funding income & show status
@@ -693,11 +863,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
693
863
  if (!current)
694
864
  continue;
695
865
  const elapsedHours = (nowMs - pos.lastCheckTime) / (1000 * 60 * 60);
696
- const notional = parseFloat(pos.size) * current.markPrice;
697
- // Estimate funding collected: long collects from low-rate side, short from high-rate side
866
+ const notional = pos.size * current.markPrice;
867
+ // Estimate funding collected: normalize all rates to hourly before comparing
698
868
  const rateFor = (e) => e === "pacifica" ? current.pacRate : e === "hyperliquid" ? current.hlRate : current.ltRate;
699
- const longHourly = rateFor(pos.longExchange);
700
- const shortHourly = rateFor(pos.shortExchange);
869
+ const longHourly = toHourlyRate(rateFor(pos.longExchange), pos.longExchange);
870
+ const shortHourly = toHourlyRate(rateFor(pos.shortExchange), pos.shortExchange);
701
871
  // Income = short gets paid positive funding, long pays; net = (shortRate - longRate) * notional * hours
702
872
  const hourlyIncome = (shortHourly - longHourly) * notional;
703
873
  pos.accumulatedFundingUsd += hourlyIncome * elapsedHours;
@@ -722,7 +892,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
722
892
  }
723
893
  };
724
894
  await cycle();
725
- setInterval(cycle, intervalMs);
895
+ cycleInterval = setInterval(cycle, intervalMs);
726
896
  await new Promise(() => { }); // keep alive
727
897
  });
728
898
  // ── arb scan ── (one-shot spread scan)
@@ -798,6 +968,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
798
968
  .option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
799
969
  .option("--leverage <n>", "Set leverage before entry (both exchanges)")
800
970
  .option("--isolated", "Use isolated margin mode")
971
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
801
972
  .action(async (symbol, longExch, shortExch, sizeUsdStr, opts) => {
802
973
  const sym = symbol.toUpperCase();
803
974
  const sizeUsd = parseFloat(sizeUsdStr);
@@ -888,16 +1059,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
888
1059
  const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
889
1060
  const matchedUsd = matchedBase * avgPrice;
890
1061
  if (!isJson()) {
891
- console.log(chalk.cyan(`\n Arb Exec: ${sym}`));
1062
+ console.log(chalk.cyan(`\n Arb Exec: ${sym}${opts.smart ? " (smart order)" : ""}`));
892
1063
  console.log(chalk.gray(` LONG ${longExch}: buy ${matchedSize} (~$${(matchedBase * longCheck.avgFillPrice).toFixed(2)}, slippage ${longCheck.slippagePct.toFixed(3)}%)`));
893
1064
  console.log(chalk.gray(` SHORT ${shortExch}: sell ${matchedSize} (~$${(matchedBase * shortCheck.avgFillPrice).toFixed(2)}, slippage ${shortCheck.slippagePct.toFixed(3)}%)`));
894
1065
  console.log(chalk.gray(` Executing both legs simultaneously...\n`));
895
1066
  }
896
1067
  // 5. Execute both legs simultaneously
897
- const [longResult, shortResult] = await Promise.allSettled([
898
- longAdapter.marketOrder(sym, "buy", matchedSize),
899
- shortAdapter.marketOrder(sym, "sell", matchedSize),
900
- ]);
1068
+ const execLong = opts.smart
1069
+ ? smartOrder(longAdapter, sym, "buy", matchedSize).then(r => r.result)
1070
+ : longAdapter.marketOrder(sym, "buy", matchedSize);
1071
+ const execShort = opts.smart
1072
+ ? smartOrder(shortAdapter, sym, "sell", matchedSize).then(r => r.result)
1073
+ : shortAdapter.marketOrder(sym, "sell", matchedSize);
1074
+ const [longResult, shortResult] = await Promise.allSettled([execLong, execShort]);
901
1075
  const longOk = longResult.status === "fulfilled";
902
1076
  const shortOk = shortResult.status === "fulfilled";
903
1077
  if (longOk && shortOk) {
@@ -963,7 +1137,12 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
963
1137
  let rollbackOk = false;
964
1138
  for (let attempt = 1; attempt <= 2; attempt++) {
965
1139
  try {
966
- await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
1140
+ if (opts.smart) {
1141
+ await smartOrder(filledAdapter, sym, rollbackAction, matchedSize, { reduceOnly: true });
1142
+ }
1143
+ else {
1144
+ await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
1145
+ }
967
1146
  rollbackOk = true;
968
1147
  break;
969
1148
  }
@@ -1048,7 +1227,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1048
1227
  const spreads = await fetchFundingSpreads();
1049
1228
  const spreadMap = new Map(spreads.map(s => [s.symbol.toUpperCase(), s]));
1050
1229
  // Get fee rates (approximate)
1051
- const TAKER_FEE = 0.00035; // ~0.035% typical
1230
+ const TAKER_FEE = getTakerFee("default");
1052
1231
  // Fetch actual settled funding payments from each exchange
1053
1232
  const actualFundingByExSymbol = new Map(); // "exchange:SYMBOL" → total settled USD
1054
1233
  for (const exName of exchangeNames) {
@@ -1400,7 +1579,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1400
1579
  ]);
1401
1580
  logExecution({
1402
1581
  type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
1403
- symbol: pos.underlying, side: "close", size: pos.size,
1582
+ symbol: pos.underlying, side: "close", size: String(pos.size),
1404
1583
  status: "success", dryRun: false,
1405
1584
  meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
1406
1585
  });
@@ -1410,7 +1589,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1410
1589
  catch (err) {
1411
1590
  logExecution({
1412
1591
  type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
1413
- symbol: pos.underlying, side: "close", size: pos.size,
1592
+ symbol: pos.underlying, side: "close", size: String(pos.size),
1414
1593
  status: "failed", dryRun: false,
1415
1594
  error: err instanceof Error ? err.message : String(err),
1416
1595
  meta: { longDex: pos.longDex, shortDex: pos.shortDex },
@@ -1,2 +1,3 @@
1
1
  import { Command } from "commander";
2
- export declare function registerSettingsCommands(program: Command, isJson: () => boolean): void;
2
+ import type { ExchangeAdapter } from "../exchanges/interface.js";
3
+ export declare function registerSettingsCommands(program: Command, isJson: () => boolean, getAdapterForExchange?: (exchange: string) => Promise<ExchangeAdapter>): void;