perp-cli 0.3.3

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.
Files changed (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -0
  3. package/dist/__tests__/alert-logic.test.d.ts +1 -0
  4. package/dist/__tests__/alert-logic.test.js +107 -0
  5. package/dist/__tests__/arb-auto-3dex.test.d.ts +1 -0
  6. package/dist/__tests__/arb-auto-3dex.test.js +397 -0
  7. package/dist/__tests__/arb-history-stats.test.d.ts +1 -0
  8. package/dist/__tests__/arb-history-stats.test.js +176 -0
  9. package/dist/__tests__/arb-logic.test.d.ts +1 -0
  10. package/dist/__tests__/arb-logic.test.js +84 -0
  11. package/dist/__tests__/arb-manage.test.d.ts +1 -0
  12. package/dist/__tests__/arb-manage.test.js +253 -0
  13. package/dist/__tests__/arb-new-features.test.d.ts +1 -0
  14. package/dist/__tests__/arb-new-features.test.js +457 -0
  15. package/dist/__tests__/arb-sizing.test.d.ts +1 -0
  16. package/dist/__tests__/arb-sizing.test.js +48 -0
  17. package/dist/__tests__/arb-state.test.d.ts +1 -0
  18. package/dist/__tests__/arb-state.test.js +284 -0
  19. package/dist/__tests__/arb-userflow.test.d.ts +1 -0
  20. package/dist/__tests__/arb-userflow.test.js +945 -0
  21. package/dist/__tests__/arb-utils.test.d.ts +1 -0
  22. package/dist/__tests__/arb-utils.test.js +264 -0
  23. package/dist/__tests__/bot-conditions.test.d.ts +1 -0
  24. package/dist/__tests__/bot-conditions.test.js +341 -0
  25. package/dist/__tests__/client-id-tracker.test.d.ts +1 -0
  26. package/dist/__tests__/client-id-tracker.test.js +137 -0
  27. package/dist/__tests__/commands/new-atomic-commands.test.d.ts +1 -0
  28. package/dist/__tests__/commands/new-atomic-commands.test.js +502 -0
  29. package/dist/__tests__/commands/order-intent.test.d.ts +1 -0
  30. package/dist/__tests__/commands/order-intent.test.js +600 -0
  31. package/dist/__tests__/commands/trade-commands.test.d.ts +1 -0
  32. package/dist/__tests__/commands/trade-commands.test.js +821 -0
  33. package/dist/__tests__/config.test.d.ts +1 -0
  34. package/dist/__tests__/config.test.js +86 -0
  35. package/dist/__tests__/cross-chain-margin.test.d.ts +1 -0
  36. package/dist/__tests__/cross-chain-margin.test.js +287 -0
  37. package/dist/__tests__/dex-asset-map.test.d.ts +1 -0
  38. package/dist/__tests__/dex-asset-map.test.js +191 -0
  39. package/dist/__tests__/errors.test.d.ts +1 -0
  40. package/dist/__tests__/errors.test.js +110 -0
  41. package/dist/__tests__/event-stream.test.d.ts +1 -0
  42. package/dist/__tests__/event-stream.test.js +276 -0
  43. package/dist/__tests__/exchanges/interface.test.d.ts +1 -0
  44. package/dist/__tests__/exchanges/interface.test.js +132 -0
  45. package/dist/__tests__/exchanges/mock-adapter.d.ts +69 -0
  46. package/dist/__tests__/exchanges/mock-adapter.js +137 -0
  47. package/dist/__tests__/execution-log.test.d.ts +1 -0
  48. package/dist/__tests__/execution-log.test.js +106 -0
  49. package/dist/__tests__/funding-calc.test.d.ts +1 -0
  50. package/dist/__tests__/funding-calc.test.js +71 -0
  51. package/dist/__tests__/funding-history.test.d.ts +1 -0
  52. package/dist/__tests__/funding-history.test.js +343 -0
  53. package/dist/__tests__/funding-rates.test.d.ts +1 -0
  54. package/dist/__tests__/funding-rates.test.js +342 -0
  55. package/dist/__tests__/funding.test.d.ts +1 -0
  56. package/dist/__tests__/funding.test.js +173 -0
  57. package/dist/__tests__/gap-logic.test.d.ts +1 -0
  58. package/dist/__tests__/gap-logic.test.js +43 -0
  59. package/dist/__tests__/hip3-dex.test.d.ts +1 -0
  60. package/dist/__tests__/hip3-dex.test.js +234 -0
  61. package/dist/__tests__/integration/agent-features.integration.test.d.ts +1 -0
  62. package/dist/__tests__/integration/agent-features.integration.test.js +553 -0
  63. package/dist/__tests__/integration/atomic-commands.integration.test.d.ts +13 -0
  64. package/dist/__tests__/integration/atomic-commands.integration.test.js +246 -0
  65. package/dist/__tests__/integration/bridge-simulation.integration.test.d.ts +1 -0
  66. package/dist/__tests__/integration/bridge-simulation.integration.test.js +453 -0
  67. package/dist/__tests__/integration/bridge-strict.integration.test.d.ts +1 -0
  68. package/dist/__tests__/integration/bridge-strict.integration.test.js +812 -0
  69. package/dist/__tests__/integration/bridge.integration.test.d.ts +1 -0
  70. package/dist/__tests__/integration/bridge.integration.test.js +309 -0
  71. package/dist/__tests__/integration/cli-e2e.integration.test.d.ts +1 -0
  72. package/dist/__tests__/integration/cli-e2e.integration.test.js +202 -0
  73. package/dist/__tests__/integration/dex-arb.integration.test.d.ts +1 -0
  74. package/dist/__tests__/integration/dex-arb.integration.test.js +116 -0
  75. package/dist/__tests__/integration/envelope-consistency.integration.test.d.ts +13 -0
  76. package/dist/__tests__/integration/envelope-consistency.integration.test.js +205 -0
  77. package/dist/__tests__/integration/hip3-dex.integration.test.d.ts +1 -0
  78. package/dist/__tests__/integration/hip3-dex.integration.test.js +147 -0
  79. package/dist/__tests__/integration/hyperliquid.integration.test.d.ts +1 -0
  80. package/dist/__tests__/integration/hyperliquid.integration.test.js +79 -0
  81. package/dist/__tests__/integration/lighter.integration.test.d.ts +1 -0
  82. package/dist/__tests__/integration/lighter.integration.test.js +53 -0
  83. package/dist/__tests__/integration/new-commands-e2e.integration.test.d.ts +9 -0
  84. package/dist/__tests__/integration/new-commands-e2e.integration.test.js +236 -0
  85. package/dist/__tests__/integration/order-verification.integration.test.d.ts +1 -0
  86. package/dist/__tests__/integration/order-verification.integration.test.js +321 -0
  87. package/dist/__tests__/integration/pacifica.integration.test.d.ts +1 -0
  88. package/dist/__tests__/integration/pacifica.integration.test.js +75 -0
  89. package/dist/__tests__/integration/response-shapes.integration.test.d.ts +1 -0
  90. package/dist/__tests__/integration/response-shapes.integration.test.js +278 -0
  91. package/dist/__tests__/liquidity.test.d.ts +1 -0
  92. package/dist/__tests__/liquidity.test.js +225 -0
  93. package/dist/__tests__/plan-executor.test.d.ts +1 -0
  94. package/dist/__tests__/plan-executor.test.js +314 -0
  95. package/dist/__tests__/position-history.test.d.ts +1 -0
  96. package/dist/__tests__/position-history.test.js +367 -0
  97. package/dist/__tests__/retry.test.d.ts +1 -0
  98. package/dist/__tests__/retry.test.js +310 -0
  99. package/dist/__tests__/risk-assessment.test.d.ts +1 -0
  100. package/dist/__tests__/risk-assessment.test.js +145 -0
  101. package/dist/__tests__/security-adversarial.test.d.ts +1 -0
  102. package/dist/__tests__/security-adversarial.test.js +574 -0
  103. package/dist/__tests__/strategies.test.d.ts +1 -0
  104. package/dist/__tests__/strategies.test.js +539 -0
  105. package/dist/__tests__/trade-execution.test.d.ts +1 -0
  106. package/dist/__tests__/trade-execution.test.js +129 -0
  107. package/dist/__tests__/trade-validator.test.d.ts +1 -0
  108. package/dist/__tests__/trade-validator.test.js +655 -0
  109. package/dist/__tests__/utils.test.d.ts +1 -0
  110. package/dist/__tests__/utils.test.js +76 -0
  111. package/dist/api/public/hyperliquid.d.ts +18 -0
  112. package/dist/api/public/hyperliquid.js +82 -0
  113. package/dist/api/public/index.d.ts +8 -0
  114. package/dist/api/public/index.js +8 -0
  115. package/dist/api/public/lighter.d.ts +24 -0
  116. package/dist/api/public/lighter.js +100 -0
  117. package/dist/api/public/pacifica.d.ts +17 -0
  118. package/dist/api/public/pacifica.js +54 -0
  119. package/dist/api/public/urls.d.ts +12 -0
  120. package/dist/api/public/urls.js +33 -0
  121. package/dist/arb/history-stats.d.ts +44 -0
  122. package/dist/arb/history-stats.js +135 -0
  123. package/dist/arb/index.d.ts +4 -0
  124. package/dist/arb/index.js +4 -0
  125. package/dist/arb/sizing.d.ts +23 -0
  126. package/dist/arb/sizing.js +96 -0
  127. package/dist/arb/state.d.ts +51 -0
  128. package/dist/arb/state.js +112 -0
  129. package/dist/arb/utils.d.ts +81 -0
  130. package/dist/arb/utils.js +267 -0
  131. package/dist/arb-history-stats.d.ts +5 -0
  132. package/dist/arb-history-stats.js +5 -0
  133. package/dist/arb-sizing.d.ts +5 -0
  134. package/dist/arb-sizing.js +5 -0
  135. package/dist/arb-state.d.ts +5 -0
  136. package/dist/arb-state.js +5 -0
  137. package/dist/arb-utils.d.ts +5 -0
  138. package/dist/arb-utils.js +5 -0
  139. package/dist/bot/conditions.d.ts +32 -0
  140. package/dist/bot/conditions.js +141 -0
  141. package/dist/bot/config.d.ts +76 -0
  142. package/dist/bot/config.js +160 -0
  143. package/dist/bot/engine.d.ts +8 -0
  144. package/dist/bot/engine.js +519 -0
  145. package/dist/bot/presets.d.ts +11 -0
  146. package/dist/bot/presets.js +296 -0
  147. package/dist/bridge-engine.d.ts +133 -0
  148. package/dist/bridge-engine.js +1487 -0
  149. package/dist/cache.d.ts +25 -0
  150. package/dist/cache.js +99 -0
  151. package/dist/cli-spec.d.ts +50 -0
  152. package/dist/cli-spec.js +75 -0
  153. package/dist/client-id-tracker.d.ts +25 -0
  154. package/dist/client-id-tracker.js +76 -0
  155. package/dist/commands/account.d.ts +3 -0
  156. package/dist/commands/account.js +425 -0
  157. package/dist/commands/agent.d.ts +3 -0
  158. package/dist/commands/agent.js +386 -0
  159. package/dist/commands/alert.d.ts +2 -0
  160. package/dist/commands/alert.js +421 -0
  161. package/dist/commands/analytics.d.ts +3 -0
  162. package/dist/commands/analytics.js +311 -0
  163. package/dist/commands/arb/index.d.ts +3 -0
  164. package/dist/commands/arb/index.js +921 -0
  165. package/dist/commands/arb-auto.d.ts +54 -0
  166. package/dist/commands/arb-auto.js +1328 -0
  167. package/dist/commands/arb-manage.d.ts +5 -0
  168. package/dist/commands/arb-manage.js +5 -0
  169. package/dist/commands/arb.d.ts +2 -0
  170. package/dist/commands/arb.js +347 -0
  171. package/dist/commands/backtest.d.ts +2 -0
  172. package/dist/commands/backtest.js +327 -0
  173. package/dist/commands/bot.d.ts +3 -0
  174. package/dist/commands/bot.js +412 -0
  175. package/dist/commands/bridge.d.ts +2 -0
  176. package/dist/commands/bridge.js +396 -0
  177. package/dist/commands/dashboard.d.ts +3 -0
  178. package/dist/commands/dashboard.js +176 -0
  179. package/dist/commands/deposit.d.ts +4 -0
  180. package/dist/commands/deposit.js +573 -0
  181. package/dist/commands/dex.d.ts +3 -0
  182. package/dist/commands/dex.js +114 -0
  183. package/dist/commands/env.d.ts +2 -0
  184. package/dist/commands/env.js +136 -0
  185. package/dist/commands/funding.d.ts +2 -0
  186. package/dist/commands/funding.js +347 -0
  187. package/dist/commands/gap.d.ts +2 -0
  188. package/dist/commands/gap.js +305 -0
  189. package/dist/commands/health.d.ts +2 -0
  190. package/dist/commands/health.js +67 -0
  191. package/dist/commands/history.d.ts +2 -0
  192. package/dist/commands/history.js +235 -0
  193. package/dist/commands/init.d.ts +15 -0
  194. package/dist/commands/init.js +266 -0
  195. package/dist/commands/jobs.d.ts +2 -0
  196. package/dist/commands/jobs.js +133 -0
  197. package/dist/commands/manage.d.ts +4 -0
  198. package/dist/commands/manage.js +309 -0
  199. package/dist/commands/market.d.ts +3 -0
  200. package/dist/commands/market.js +225 -0
  201. package/dist/commands/plan.d.ts +3 -0
  202. package/dist/commands/plan.js +95 -0
  203. package/dist/commands/portfolio.d.ts +3 -0
  204. package/dist/commands/portfolio.js +169 -0
  205. package/dist/commands/rebalance.d.ts +3 -0
  206. package/dist/commands/rebalance.js +293 -0
  207. package/dist/commands/risk.d.ts +3 -0
  208. package/dist/commands/risk.js +169 -0
  209. package/dist/commands/run.d.ts +3 -0
  210. package/dist/commands/run.js +202 -0
  211. package/dist/commands/settings.d.ts +2 -0
  212. package/dist/commands/settings.js +102 -0
  213. package/dist/commands/stream.d.ts +5 -0
  214. package/dist/commands/stream.js +123 -0
  215. package/dist/commands/trade.d.ts +3 -0
  216. package/dist/commands/trade.js +1273 -0
  217. package/dist/commands/wallet.d.ts +14 -0
  218. package/dist/commands/wallet.js +602 -0
  219. package/dist/commands/withdraw.d.ts +3 -0
  220. package/dist/commands/withdraw.js +187 -0
  221. package/dist/config.d.ts +5 -0
  222. package/dist/config.js +68 -0
  223. package/dist/cross-chain-margin.d.ts +46 -0
  224. package/dist/cross-chain-margin.js +107 -0
  225. package/dist/dashboard/server.d.ts +80 -0
  226. package/dist/dashboard/server.js +340 -0
  227. package/dist/dashboard/ui.d.ts +4 -0
  228. package/dist/dashboard/ui.js +538 -0
  229. package/dist/dashboard/ws-feeds.d.ts +29 -0
  230. package/dist/dashboard/ws-feeds.js +660 -0
  231. package/dist/dex-asset-map.d.ts +80 -0
  232. package/dist/dex-asset-map.js +201 -0
  233. package/dist/errors.d.ts +109 -0
  234. package/dist/errors.js +84 -0
  235. package/dist/event-stream.d.ts +25 -0
  236. package/dist/event-stream.js +168 -0
  237. package/dist/exchanges/hyperliquid.d.ts +212 -0
  238. package/dist/exchanges/hyperliquid.js +931 -0
  239. package/dist/exchanges/interface.d.ts +95 -0
  240. package/dist/exchanges/interface.js +5 -0
  241. package/dist/exchanges/lighter.d.ts +159 -0
  242. package/dist/exchanges/lighter.js +793 -0
  243. package/dist/exchanges/pacifica.d.ts +51 -0
  244. package/dist/exchanges/pacifica.js +248 -0
  245. package/dist/execution-log.d.ts +36 -0
  246. package/dist/execution-log.js +102 -0
  247. package/dist/funding/history.d.ts +63 -0
  248. package/dist/funding/history.js +266 -0
  249. package/dist/funding/index.d.ts +3 -0
  250. package/dist/funding/index.js +3 -0
  251. package/dist/funding/normalize.d.ts +39 -0
  252. package/dist/funding/normalize.js +66 -0
  253. package/dist/funding/rates.d.ts +45 -0
  254. package/dist/funding/rates.js +172 -0
  255. package/dist/funding-history.d.ts +5 -0
  256. package/dist/funding-history.js +5 -0
  257. package/dist/funding-rates.d.ts +5 -0
  258. package/dist/funding-rates.js +5 -0
  259. package/dist/funding.d.ts +5 -0
  260. package/dist/funding.js +5 -0
  261. package/dist/index.d.ts +2 -0
  262. package/dist/index.js +458 -0
  263. package/dist/jobs.d.ts +37 -0
  264. package/dist/jobs.js +152 -0
  265. package/dist/liquidity.d.ts +34 -0
  266. package/dist/liquidity.js +100 -0
  267. package/dist/mcp-server.d.ts +9 -0
  268. package/dist/mcp-server.js +1206 -0
  269. package/dist/pacifica/client.d.ts +111 -0
  270. package/dist/pacifica/client.js +310 -0
  271. package/dist/pacifica/constants.d.ts +27 -0
  272. package/dist/pacifica/constants.js +47 -0
  273. package/dist/pacifica/deposit.d.ts +14 -0
  274. package/dist/pacifica/deposit.js +78 -0
  275. package/dist/pacifica/index.d.ts +6 -0
  276. package/dist/pacifica/index.js +11 -0
  277. package/dist/pacifica/signing.d.ts +49 -0
  278. package/dist/pacifica/signing.js +97 -0
  279. package/dist/pacifica/types/account.d.ts +42 -0
  280. package/dist/pacifica/types/account.js +1 -0
  281. package/dist/pacifica/types/index.d.ts +6 -0
  282. package/dist/pacifica/types/index.js +6 -0
  283. package/dist/pacifica/types/lake.d.ts +18 -0
  284. package/dist/pacifica/types/lake.js +1 -0
  285. package/dist/pacifica/types/market.d.ts +64 -0
  286. package/dist/pacifica/types/market.js +1 -0
  287. package/dist/pacifica/types/order.d.ts +92 -0
  288. package/dist/pacifica/types/order.js +1 -0
  289. package/dist/pacifica/types/position.d.ts +25 -0
  290. package/dist/pacifica/types/position.js +1 -0
  291. package/dist/pacifica/types/ws.d.ts +34 -0
  292. package/dist/pacifica/types/ws.js +41 -0
  293. package/dist/pacifica/ws-client.d.ts +42 -0
  294. package/dist/pacifica/ws-client.js +180 -0
  295. package/dist/plan-executor.d.ts +48 -0
  296. package/dist/plan-executor.js +280 -0
  297. package/dist/position-history.d.ts +68 -0
  298. package/dist/position-history.js +222 -0
  299. package/dist/rebalance.d.ts +64 -0
  300. package/dist/rebalance.js +142 -0
  301. package/dist/retry.d.ts +74 -0
  302. package/dist/retry.js +129 -0
  303. package/dist/risk.d.ts +48 -0
  304. package/dist/risk.js +156 -0
  305. package/dist/settings.d.ts +19 -0
  306. package/dist/settings.js +45 -0
  307. package/dist/shared-api.d.ts +5 -0
  308. package/dist/shared-api.js +5 -0
  309. package/dist/strategies/dca.d.ts +25 -0
  310. package/dist/strategies/dca.js +114 -0
  311. package/dist/strategies/funding-arb.d.ts +15 -0
  312. package/dist/strategies/funding-arb.js +281 -0
  313. package/dist/strategies/grid.d.ts +34 -0
  314. package/dist/strategies/grid.js +185 -0
  315. package/dist/strategies/trailing-stop.d.ts +17 -0
  316. package/dist/strategies/trailing-stop.js +121 -0
  317. package/dist/strategies/twap.d.ts +20 -0
  318. package/dist/strategies/twap.js +78 -0
  319. package/dist/trade-validator.d.ts +39 -0
  320. package/dist/trade-validator.js +154 -0
  321. package/dist/utils.d.ts +38 -0
  322. package/dist/utils.js +110 -0
  323. package/package.json +63 -0
  324. package/skills/perp-cli/SKILL.md +149 -0
  325. package/skills/perp-cli/references/commands.md +143 -0
@@ -0,0 +1,1328 @@
1
+ import chalk from "chalk";
2
+ import { formatUsd, printJson, jsonOk } from "../utils.js";
3
+ import { computeAnnualSpread, toHourlyRate } from "../funding.js";
4
+ import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
5
+ import { scanDexArb } from "../dex-asset-map.js";
6
+ import { logExecution, readExecutionLog } from "../execution-log.js";
7
+ import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
8
+ import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
9
+ import { loadArbState, saveArbState, createInitialState, } from "../arb-state.js";
10
+ // ── Fee-Adjusted Net Spread Calculation ──
11
+ /** Default taker fee per exchange (as fraction, e.g. 0.00035 = 0.035%) */
12
+ const TAKER_FEES = {
13
+ hyperliquid: 0.00035,
14
+ pacifica: 0.00035,
15
+ lighter: 0.00035,
16
+ };
17
+ function getTakerFee(exchange) {
18
+ return TAKER_FEES[exchange.toLowerCase()] ?? 0.00035;
19
+ }
20
+ /**
21
+ * Compute the estimated round-trip cost as a percentage of notional.
22
+ * Round-trip = 4 × taker fee + 2 × slippage (entry + exit for both legs).
23
+ */
24
+ export function computeRoundTripCostPct(longExchange, shortExchange, slippagePct = 0.05) {
25
+ const longFee = getTakerFee(longExchange) * 100; // convert to pct
26
+ const shortFee = getTakerFee(shortExchange) * 100;
27
+ // Entry: long taker + short taker + slippage on each
28
+ // Exit: long taker + short taker + slippage on each
29
+ return 2 * (longFee + shortFee) + 2 * slippagePct;
30
+ }
31
+ /**
32
+ * Compute net annualized spread after deducting round-trip costs amortized over hold period.
33
+ *
34
+ * Net = grossAnnualPct - (roundTripCostPct / holdDays * 365) - (bridgeCostPct annualized)
35
+ *
36
+ * @param grossAnnualPct - Gross annual spread in %
37
+ * @param holdDays - Expected holding period in days for cost amortization
38
+ * @param roundTripCostPct - Total round-trip cost as % of notional
39
+ * @param bridgeCostUsd - One-way bridge cost in USD (doubled for round-trip)
40
+ * @param positionSizeUsd - Position size per leg in USD (for bridge cost %)
41
+ */
42
+ export function computeNetSpread(grossAnnualPct, holdDays, roundTripCostPct, bridgeCostUsd = 0, positionSizeUsd = 0) {
43
+ const annualizedCostPct = (roundTripCostPct / holdDays) * 365;
44
+ let bridgeCostAnnualPct = 0;
45
+ if (bridgeCostUsd > 0 && positionSizeUsd > 0) {
46
+ const bridgeRoundTripPct = (bridgeCostUsd * 2 / positionSizeUsd) * 100;
47
+ bridgeCostAnnualPct = (bridgeRoundTripPct / holdDays) * 365;
48
+ }
49
+ return grossAnnualPct - annualizedCostPct - bridgeCostAnnualPct;
50
+ }
51
+ // ── Funding Settlement Timing ──
52
+ /** Settlement schedules per exchange (UTC hours when settlement occurs) */
53
+ const SETTLEMENT_SCHEDULES = {
54
+ hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
55
+ pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
56
+ lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
57
+ };
58
+ /**
59
+ * Get the next settlement time for an exchange.
60
+ * @returns Date of next settlement
61
+ */
62
+ export function getNextSettlement(exchange, now = new Date()) {
63
+ const schedule = SETTLEMENT_SCHEDULES[exchange.toLowerCase()];
64
+ if (!schedule || schedule.length === 0) {
65
+ // Default: every hour
66
+ return getNextSettlement("pacifica", now);
67
+ }
68
+ const currentHour = now.getUTCHours();
69
+ const currentMinutes = now.getUTCMinutes();
70
+ const currentSeconds = now.getUTCSeconds();
71
+ // Find the next settlement hour strictly in the future
72
+ // A settlement at the current hour is "next" only if we haven't reached it yet (min=0, sec=0)
73
+ for (const hour of schedule) {
74
+ if (hour > currentHour || (hour === currentHour && currentMinutes === 0 && currentSeconds === 0)) {
75
+ // This settlement is still in the future (or exactly now)
76
+ // But skip if hour === currentHour and we're past minute 0
77
+ if (hour === currentHour && (currentMinutes > 0 || currentSeconds > 0))
78
+ continue;
79
+ const next = new Date(now);
80
+ next.setUTCHours(hour, 0, 0, 0);
81
+ return next;
82
+ }
83
+ }
84
+ // Wrap to next day's first settlement
85
+ const next = new Date(now);
86
+ next.setUTCDate(next.getUTCDate() + 1);
87
+ next.setUTCHours(schedule[0], 0, 0, 0);
88
+ return next;
89
+ }
90
+ /**
91
+ * Check if we are within N minutes before a settlement event on either exchange.
92
+ * If so, we should avoid entering positions (rates may change).
93
+ */
94
+ export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5, now = new Date()) {
95
+ for (const exch of [longExchange, shortExchange]) {
96
+ const nextSettle = getNextSettlement(exch, now);
97
+ const minutesUntil = (nextSettle.getTime() - now.getTime()) / (1000 * 60);
98
+ if (minutesUntil <= bufferMinutes && minutesUntil >= 0) {
99
+ return { blocked: true, exchange: exch, minutesUntil };
100
+ }
101
+ }
102
+ return { blocked: false };
103
+ }
104
+ /**
105
+ * Detect if the funding spread has reversed direction for an open position.
106
+ * A reversal means the long exchange now has a HIGHER hourly rate than the short exchange,
107
+ * meaning we're now paying on both sides instead of collecting.
108
+ */
109
+ export function isSpreadReversed(longExchange, shortExchange, snapshot) {
110
+ const rateFor = (e) => e === "pacifica" ? snapshot.pacRate : e === "hyperliquid" ? snapshot.hlRate : snapshot.ltRate;
111
+ const longHourly = toHourlyRate(rateFor(longExchange), longExchange);
112
+ const shortHourly = toHourlyRate(rateFor(shortExchange), shortExchange);
113
+ // Reversed if the long side rate exceeds the short side rate
114
+ return longHourly > shortHourly;
115
+ }
116
+ async function fetchFundingSpreads() {
117
+ const [pacRes, hlRes, ltDetailsRes, ltFundingRes] = await Promise.all([
118
+ fetchPacificaPricesRaw(),
119
+ fetchHyperliquidMetaRaw(),
120
+ fetchLighterOrderBookDetailsRaw(),
121
+ fetchLighterFundingRatesRaw(),
122
+ ]);
123
+ const { rates: pacRates, prices: pacPrices } = parsePacificaRaw(pacRes);
124
+ const { rates: hlRates, prices: hlPrices } = parseHyperliquidMetaRaw(hlRes);
125
+ const { rates: ltRates, prices: ltPrices } = parseLighterRaw(ltDetailsRes, ltFundingRes);
126
+ const snapshots = [];
127
+ const allSymbols = new Set([...pacRates.keys(), ...hlRates.keys(), ...ltRates.keys()]);
128
+ for (const sym of allSymbols) {
129
+ const pac = pacRates.get(sym);
130
+ const hl = hlRates.get(sym);
131
+ const lt = ltRates.get(sym);
132
+ // Need at least 2 exchanges
133
+ const available = [];
134
+ if (pac !== undefined)
135
+ available.push({ exchange: "pacifica", rate: pac });
136
+ if (hl !== undefined)
137
+ available.push({ exchange: "hyperliquid", rate: hl });
138
+ if (lt !== undefined)
139
+ available.push({ exchange: "lighter", rate: lt });
140
+ if (available.length < 2)
141
+ continue;
142
+ const norm = (r) => toHourlyRate(r.rate, r.exchange);
143
+ available.sort((a, b) => norm(a) - norm(b));
144
+ const lowest = available[0];
145
+ const highest = available[available.length - 1];
146
+ const spread = computeAnnualSpread(highest.rate, highest.exchange, lowest.rate, lowest.exchange);
147
+ // Use best available mark price (prefer HL as most liquid, then PAC, then LT)
148
+ const markPrice = hlPrices.get(sym) ?? pacPrices.get(sym) ?? ltPrices.get(sym) ?? 0;
149
+ snapshots.push({
150
+ symbol: sym,
151
+ pacRate: pac ?? 0,
152
+ hlRate: hl ?? 0,
153
+ ltRate: lt ?? 0,
154
+ spread,
155
+ longExch: lowest.exchange, // long where funding is lowest
156
+ shortExch: highest.exchange, // short where funding is highest
157
+ markPrice,
158
+ pacMarkPrice: pacPrices.get(sym) ?? 0,
159
+ hlMarkPrice: hlPrices.get(sym) ?? 0,
160
+ ltMarkPrice: ltPrices.get(sym) ?? 0,
161
+ });
162
+ }
163
+ return snapshots.sort((a, b) => Math.abs(b.spread) - Math.abs(a.spread));
164
+ }
165
+ export function registerArbAutoCommands(program, getAdapterForExchange, isJson, getHLAdapterForDex) {
166
+ const arb = program.commands.find(c => c.name() === "arb");
167
+ if (!arb)
168
+ return;
169
+ // ── arb auto ── (daemon mode)
170
+ arb
171
+ .command("auto")
172
+ .description("Auto-execute funding rate arbitrage (daemon)")
173
+ .option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
174
+ .option("--close-spread <pct>", "Close when spread drops below (%)", "5")
175
+ .option("--size <usd>", "Position size per leg ($)", "100")
176
+ .option("--min-size <usd>", "Min position size floor for auto-sizing ($ notional)", "30")
177
+ .option("--max-positions <n>", "Max simultaneous arb positions", "5")
178
+ .option("--symbols <list>", "Comma-separated symbols to monitor (default: all)")
179
+ .option("--interval <seconds>", "Check interval", "60")
180
+ .option("--hold-days <days>", "Expected hold period for cost amortization", "7")
181
+ .option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
182
+ .option("--no-reversal-exit", "Disable emergency exit on spread reversal")
183
+ .option("--settle-aware", "Avoid entries near funding settlement (default: true)")
184
+ .option("--no-settle-aware", "Disable settlement timing awareness")
185
+ .option("--min-margin <pct>", "Warn/block when margin ratio drops below this %", "30")
186
+ .option("--settle-strategy <mode>", "Settlement timing: block (default), aggressive, off", "block")
187
+ .option("--max-basis <pct>", "Max basis risk (mark price divergence %)", "3")
188
+ .option("--notify <url>", "Webhook URL for notifications (Discord/Telegram/generic)")
189
+ .option("--notify-events <events>", "Comma-separated events: entry,exit,reversal,margin,basis", "entry,exit,reversal,margin,basis")
190
+ .option("--dry-run", "Simulate without executing trades")
191
+ .option("--background", "Run in background (tmux)")
192
+ .action(async (opts) => {
193
+ if (opts.background) {
194
+ const { startJob } = await import("../jobs.js");
195
+ const cliArgs = [
196
+ `--min-spread`, opts.minSpread,
197
+ `--close-spread`, opts.closeSpread,
198
+ `--size`, opts.size,
199
+ `--max-positions`, opts.maxPositions,
200
+ `--interval`, opts.interval,
201
+ `--hold-days`, opts.holdDays,
202
+ `--bridge-cost`, opts.bridgeCost,
203
+ `--min-margin`, opts.minMargin,
204
+ ...(opts.symbols ? [`--symbols`, opts.symbols] : []),
205
+ ...(opts.dryRun ? [`--dry-run`] : []),
206
+ ...(opts.reversalExit === false ? [`--no-reversal-exit`] : []),
207
+ ...(opts.settleAware === false ? [`--no-settle-aware`] : []),
208
+ ...(opts.minSpread ? [`--auto-execute`] : []),
209
+ ];
210
+ const job = startJob({
211
+ strategy: "funding-arb",
212
+ exchange: "multi",
213
+ params: { ...opts },
214
+ cliArgs,
215
+ });
216
+ if (isJson())
217
+ return printJson(jsonOk(job));
218
+ console.log(chalk.green(`\n Funding arb bot started in background.`));
219
+ console.log(` ID: ${chalk.white.bold(job.id)}`);
220
+ console.log(` Min spread: ${opts.minSpread}% | Size: $${opts.size}`);
221
+ console.log(` Logs: ${chalk.gray(`perp jobs logs ${job.id}`)}`);
222
+ console.log(` Stop: ${chalk.gray(`perp jobs stop ${job.id}`)}\n`);
223
+ return;
224
+ }
225
+ const minSpread = parseFloat(opts.minSpread);
226
+ const closeSpread = parseFloat(opts.closeSpread);
227
+ const sizeIsAuto = opts.size.toLowerCase() === "auto";
228
+ const sizeUsd = sizeIsAuto ? 0 : parseFloat(opts.size);
229
+ const maxPositions = parseInt(opts.maxPositions);
230
+ const intervalMs = parseInt(opts.interval) * 1000;
231
+ const holdDays = parseFloat(opts.holdDays);
232
+ const bridgeCostUsd = parseFloat(opts.bridgeCost);
233
+ const reversalExitEnabled = opts.reversalExit !== false;
234
+ const settleAwareEnabled = opts.settleAware !== false;
235
+ const settleStrategy = (opts.settleStrategy || "block");
236
+ const maxBasisPct = parseFloat(opts.maxBasis);
237
+ const webhookUrl = opts.notify;
238
+ const notifyEvents = opts.notifyEvents
239
+ .split(",").map(e => e.trim()).filter(Boolean);
240
+ const minMarginPct = parseFloat(opts.minMargin);
241
+ const minSizeUsd = parseFloat(opts.minSize);
242
+ const filterSymbols = opts.symbols?.split(",").map(s => s.trim().toUpperCase());
243
+ const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
244
+ const openPositions = [];
245
+ // Track which exchanges have low margin (block entries)
246
+ const blockedExchanges = new Set();
247
+ // -- State Persistence: Initialize or recover --
248
+ const daemonConfig = {
249
+ minSpread,
250
+ closeSpread,
251
+ size: (typeof sizeIsAuto !== "undefined" && sizeIsAuto ? "auto" : sizeUsd),
252
+ holdDays,
253
+ bridgeCost: bridgeCostUsd,
254
+ maxPositions,
255
+ settleStrategy: settleAwareEnabled ? "aware" : "disabled",
256
+ };
257
+ let daemonState = loadArbState();
258
+ if (daemonState && daemonState.positions.length > 0) {
259
+ // Crash recovery: restore positions from persisted state
260
+ for (const persisted of daemonState.positions) {
261
+ openPositions.push({
262
+ symbol: persisted.symbol,
263
+ longExchange: persisted.longExchange,
264
+ shortExchange: persisted.shortExchange,
265
+ size: String(persisted.longSize),
266
+ entrySpread: persisted.entrySpread,
267
+ entryTime: persisted.entryTime,
268
+ entryMarkPrice: persisted.entryLongPrice,
269
+ accumulatedFundingUsd: persisted.accumulatedFunding,
270
+ lastCheckTime: new Date(persisted.lastCheckTime).getTime(),
271
+ });
272
+ }
273
+ daemonState.lastStartTime = new Date().toISOString();
274
+ saveArbState(daemonState);
275
+ if (!isJson()) {
276
+ console.log(chalk.yellow(` Recovered ${openPositions.length} position(s) from previous session.`));
277
+ }
278
+ }
279
+ else {
280
+ daemonState = createInitialState(daemonConfig);
281
+ saveArbState(daemonState);
282
+ }
283
+ // SIGINT handler: save final state before exit
284
+ const handleSigint = () => {
285
+ const finalState = loadArbState();
286
+ if (finalState) {
287
+ finalState.lastScanTime = new Date().toISOString();
288
+ saveArbState(finalState);
289
+ }
290
+ if (!isJson())
291
+ console.log(chalk.yellow("\n Daemon stopped. State saved.\n"));
292
+ process.exit(0);
293
+ };
294
+ process.on("SIGINT", handleSigint);
295
+ if (!isJson()) {
296
+ console.log(chalk.cyan.bold("\n Funding Rate Arb Bot\n"));
297
+ console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
298
+ console.log(` Enter spread: >= ${minSpread}% annual (net, after fees)`);
299
+ console.log(` Close spread: <= ${closeSpread}% annual`);
300
+ console.log(` Size per leg: ${sizeIsAuto ? chalk.cyan("auto (dynamic)") : `$${sizeUsd}`}`);
301
+ if (sizeIsAuto)
302
+ console.log(` Min size: $${minSizeUsd} (floor for auto)`);
303
+ console.log(` Max positions: ${maxPositions}`);
304
+ console.log(` Hold period: ${holdDays} days (cost amortization)`);
305
+ console.log(` Bridge cost: $${bridgeCostUsd} per transfer`);
306
+ console.log(` Min margin: ${minMarginPct}% (block entries below this)`);
307
+ console.log(` Max basis: ${maxBasisPct}% (warn on price divergence)`);
308
+ console.log(` Reversal exit: ${reversalExitEnabled ? chalk.green("ON") : chalk.yellow("OFF")}`);
309
+ console.log(` Settle strat: ${settleStrategy === "aggressive" ? chalk.cyan("AGGRESSIVE") : settleStrategy === "off" ? chalk.yellow("OFF") : chalk.green("BLOCK")}`);
310
+ console.log(` Notifications: ${webhookUrl ? chalk.green("ON") : chalk.gray("OFF")}${webhookUrl ? ` (${notifyEvents.join(",")})` : ""}`);
311
+ console.log(` Symbols: ${filterSymbols?.join(", ") || "all"}`);
312
+ console.log(` Interval: ${opts.interval}s`);
313
+ console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
314
+ }
315
+ const cycle = async () => {
316
+ // Heartbeat check
317
+ const heartbeatState = loadArbState();
318
+ if (heartbeatState?.lastSuccessfulScanTime) {
319
+ const lastSuccessMs = new Date(heartbeatState.lastSuccessfulScanTime).getTime();
320
+ const minutesSinceSuccess = (Date.now() - lastSuccessMs) / (1000 * 60);
321
+ if (minutesSinceSuccess > 5) {
322
+ console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} HEARTBEAT WARNING: no successful scan for ${minutesSinceSuccess.toFixed(0)} minutes`));
323
+ await notifyIfEnabled(webhookUrl, notifyEvents, "heartbeat", {
324
+ lastScanTime: heartbeatState.lastSuccessfulScanTime,
325
+ minutesAgo: minutesSinceSuccess,
326
+ });
327
+ }
328
+ }
329
+ try {
330
+ const spreads = await fetchFundingSpreads();
331
+ const filtered = filterSymbols
332
+ ? spreads.filter(s => filterSymbols.includes(s.symbol))
333
+ : spreads;
334
+ const now = new Date().toLocaleTimeString();
335
+ // Check for close conditions on open positions
336
+ for (let i = openPositions.length - 1; i >= 0; i--) {
337
+ const pos = openPositions[i];
338
+ const current = filtered.find(s => s.symbol === pos.symbol);
339
+ if (!current)
340
+ continue;
341
+ const currentSpread = Math.abs(current.spread);
342
+ let shouldClose = false;
343
+ let closeReason = "";
344
+ // Check spread-based close
345
+ if (currentSpread <= closeSpread) {
346
+ shouldClose = true;
347
+ closeReason = `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
348
+ }
349
+ // Check reversal-based close
350
+ if (!shouldClose && reversalExitEnabled && isSpreadReversed(pos.longExchange, pos.shortExchange, current)) {
351
+ shouldClose = true;
352
+ closeReason = "REVERSAL DETECTED — long exchange now has higher rate than short";
353
+ await notifyIfEnabled(webhookUrl, notifyEvents, "reversal", {
354
+ symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
355
+ });
356
+ }
357
+ // Check basis risk (mark price divergence) — use prices from fetchFundingSpreads, no extra API calls
358
+ {
359
+ const priceFor = (e) => e === "pacifica" ? current.pacMarkPrice : e === "hyperliquid" ? current.hlMarkPrice : current.ltMarkPrice;
360
+ const bLP = priceFor(pos.longExchange);
361
+ const bSP = priceFor(pos.shortExchange);
362
+ if (bLP > 0 && bSP > 0) {
363
+ const basis = computeBasisRisk(bLP, bSP, maxBasisPct);
364
+ if (basis.warning) {
365
+ const bExA = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
366
+ console.log(chalk.yellow(` ${now} BASIS RISK ${pos.symbol}: Long ${bExA(pos.longExchange)} $${bLP.toFixed(4)} / ` +
367
+ `Short ${bExA(pos.shortExchange)} $${bSP.toFixed(4)} | Divergence: ${basis.divergencePct.toFixed(1)}%`));
368
+ await notifyIfEnabled(webhookUrl, notifyEvents, "basis", {
369
+ symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
370
+ divergencePct: basis.divergencePct,
371
+ });
372
+ }
373
+ }
374
+ }
375
+ if (shouldClose) {
376
+ console.log(chalk.yellow(` ${now} CLOSE ${pos.symbol} — ${closeReason}`));
377
+ if (!dryRun) {
378
+ try {
379
+ // Close both legs
380
+ const longAdapter = await getAdapterForExchange(pos.longExchange);
381
+ const shortAdapter = await getAdapterForExchange(pos.shortExchange);
382
+ await longAdapter.marketOrder(pos.symbol, "sell", pos.size);
383
+ await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
384
+ // Determine exit reason tag
385
+ const exitReason = closeReason.includes("REVERSAL") ? "reversal"
386
+ : closeReason.includes("spread") ? "spread"
387
+ : "manual";
388
+ logExecution({
389
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
390
+ symbol: pos.symbol, side: "close", size: pos.size,
391
+ status: "success", dryRun: false,
392
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
393
+ });
394
+ console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
395
+ await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
396
+ symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
397
+ pnl: pos.accumulatedFundingUsd,
398
+ duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
399
+ });
400
+ }
401
+ catch (err) {
402
+ const exitReason = closeReason.includes("REVERSAL") ? "reversal"
403
+ : closeReason.includes("spread") ? "spread"
404
+ : "manual";
405
+ logExecution({
406
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
407
+ symbol: pos.symbol, side: "close", size: pos.size,
408
+ status: "failed", dryRun: false,
409
+ error: err instanceof Error ? err.message : String(err),
410
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
411
+ });
412
+ console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
413
+ }
414
+ }
415
+ openPositions.splice(i, 1);
416
+ }
417
+ }
418
+ // Log next settlement times and funding estimation
419
+ if (!isJson() && settleStrategy !== "off") {
420
+ const nowDate = new Date();
421
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
422
+ const nextHL = getNextSettlement("hyperliquid", nowDate);
423
+ const nextPAC = getNextSettlement("pacifica", nowDate);
424
+ const nextLT = getNextSettlement("lighter", nowDate);
425
+ const fmtMin = (d) => Math.max(0, Math.round((d.getTime() - nowDate.getTime()) / 60000));
426
+ const hoursUntilPAC = (nextPAC.getTime() - nowDate.getTime()) / 3600000;
427
+ const hUTC = nextPAC.getUTCHours().toString().padStart(2, "0");
428
+ console.log(chalk.gray(` ${now} Next settlements: HL ${fmtMin(nextHL)}m | PAC ${fmtMin(nextPAC)}m | LT ${fmtMin(nextLT)}m`));
429
+ for (const fPos of openPositions) {
430
+ const fSnap = filtered.find(s => s.symbol === fPos.symbol);
431
+ if (!fSnap)
432
+ continue;
433
+ const fRateFor = (e) => e === "pacifica" ? fSnap.pacRate : e === "hyperliquid" ? fSnap.hlRate : fSnap.ltRate;
434
+ const fHlHourly = toHourlyRate(fRateFor("hyperliquid"), "hyperliquid");
435
+ const fPacHourly = toHourlyRate(fRateFor("pacifica"), "pacifica");
436
+ const fNotional = parseFloat(fPos.size) * fSnap.markPrice;
437
+ const fEst = estimateFundingUntilSettlement(fHlHourly, fPacHourly, fNotional, hoursUntilPAC);
438
+ console.log(chalk.gray(` ${now} ${fPos.symbol} Next PAC: ${hUTC}:00 UTC (${hoursUntilPAC.toFixed(1)}h) | ` +
439
+ `HL cum: ~$${fEst.hlCumulative.toFixed(4)} | PAC pmt: ~$${fEst.pacPayment.toFixed(4)} | ` +
440
+ `Net: ~$${fEst.netFunding.toFixed(4)}`));
441
+ }
442
+ }
443
+ // ── Cross-chain margin monitoring ──
444
+ blockedExchanges.clear();
445
+ // Exchange status check — infer from fetchFundingSpreads results
446
+ // If an exchange returned rates in the scan, it's up. No extra API calls needed.
447
+ const downExchanges = new Set();
448
+ const hasHL = filtered.some(s => s.hlRate !== 0) || spreads.some(s => s.hlRate !== 0);
449
+ const hasLT = filtered.some(s => s.ltRate !== 0) || spreads.some(s => s.ltRate !== 0);
450
+ const hasPAC = filtered.some(s => s.pacRate !== 0) || spreads.some(s => s.pacRate !== 0);
451
+ if (!hasHL) {
452
+ downExchanges.add("hyperliquid");
453
+ blockedExchanges.add("hyperliquid");
454
+ }
455
+ if (!hasLT) {
456
+ downExchanges.add("lighter");
457
+ blockedExchanges.add("lighter");
458
+ }
459
+ if (!hasPAC) {
460
+ downExchanges.add("pacifica");
461
+ blockedExchanges.add("pacifica");
462
+ }
463
+ for (const name of downExchanges) {
464
+ console.log(chalk.red(` ${now} EXCHANGE DOWN: ${name} — blocking new entries`));
465
+ }
466
+ // Mark existing positions on down exchanges as degraded
467
+ for (const pos of openPositions) {
468
+ if (downExchanges.has(pos.longExchange) || downExchanges.has(pos.shortExchange)) {
469
+ console.log(chalk.yellow(` ${now} DEGRADED ${pos.symbol}: ${downExchanges.has(pos.longExchange) ? pos.longExchange : pos.shortExchange} is down`));
470
+ }
471
+ }
472
+ try {
473
+ // Only check margins when we have open positions (avoid slow adapter init)
474
+ const needMarginCheck = openPositions.length > 0;
475
+ const adaptersMap = new Map();
476
+ if (needMarginCheck) {
477
+ const marginExchanges = ["hyperliquid", "lighter", "pacifica"];
478
+ for (const name of marginExchanges) {
479
+ try {
480
+ adaptersMap.set(name, await getAdapterForExchange(name));
481
+ }
482
+ catch { /* skip */ }
483
+ }
484
+ }
485
+ if (adaptersMap.size > 0) {
486
+ const marginStatuses = await checkChainMargins(adaptersMap, minMarginPct);
487
+ for (const ms of marginStatuses) {
488
+ if (isCriticalMargin(ms)) {
489
+ console.log(chalk.red.bold(` ${now} EMERGENCY: ${ms.exchange} margin ratio ${ms.marginRatio.toFixed(1)}% — CRITICAL (below 15%)`));
490
+ blockedExchanges.add(ms.exchange);
491
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
492
+ exchange: ms.exchange, marginPct: ms.marginRatio, threshold: 15,
493
+ });
494
+ }
495
+ else if (shouldBlockEntries(ms, minMarginPct)) {
496
+ console.log(chalk.yellow(` ${now} WARNING: ${ms.exchange} margin ${ms.marginRatio.toFixed(1)}% below ${minMarginPct}% — blocking new entries`));
497
+ blockedExchanges.add(ms.exchange);
498
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
499
+ exchange: ms.exchange, marginPct: ms.marginRatio, threshold: minMarginPct,
500
+ });
501
+ }
502
+ }
503
+ }
504
+ }
505
+ catch { /* margin check failed, continue without blocking */ }
506
+ // Check for entry conditions
507
+ if (openPositions.length < maxPositions) {
508
+ for (const snap of filtered) {
509
+ if (openPositions.some(p => p.symbol === snap.symbol))
510
+ continue;
511
+ if (openPositions.length >= maxPositions)
512
+ break;
513
+ const grossSpread = Math.abs(snap.spread);
514
+ // Determine direction using all 3 exchanges: short the high-funding, long the low-funding
515
+ const longExchange = snap.longExch;
516
+ const shortExchange = snap.shortExch;
517
+ // Block entries if either exchange has low margin
518
+ if (blockedExchanges.has(longExchange) || blockedExchanges.has(shortExchange)) {
519
+ if (!isJson()) {
520
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: margin too low on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange}`));
521
+ }
522
+ continue;
523
+ }
524
+ // Compute net spread after fees and bridge costs
525
+ const roundTripCost = computeRoundTripCostPct(longExchange, shortExchange);
526
+ const effectiveSizeUsd = sizeIsAuto ? 100 : sizeUsd; // Use $100 for net spread calc when auto
527
+ const netSpread = computeNetSpread(grossSpread, holdDays, roundTripCost, bridgeCostUsd, effectiveSizeUsd);
528
+ // --min-spread now compares against NET spread
529
+ if (netSpread < minSpread)
530
+ continue;
531
+ // Settlement timing check with strategy
532
+ if (settleStrategy === "block") {
533
+ const settlCheck = isNearSettlement(longExchange, shortExchange);
534
+ if (settlCheck.blocked) {
535
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: ${settlCheck.minutesUntil?.toFixed(1)}m before ${settlCheck.exchange} settlement`));
536
+ continue;
537
+ }
538
+ }
539
+ // In aggressive mode, boost score for post-settlement entries
540
+ let settleBoostMultiplier = 1.0;
541
+ if (settleStrategy === "aggressive") {
542
+ settleBoostMultiplier = aggressiveSettleBoost(longExchange, shortExchange, 10, new Date());
543
+ if (settleBoostMultiplier > 1.0) {
544
+ console.log(chalk.cyan(` ${now} BOOST ${snap.symbol}: post-settlement ${((settleBoostMultiplier - 1) * 100).toFixed(0)}% score boost`));
545
+ }
546
+ }
547
+ const rateForExch = (e) => e === "pacifica" ? snap.pacRate : e === "hyperliquid" ? snap.hlRate : snap.ltRate;
548
+ console.log(chalk.green(` ${now} ENTER ${snap.symbol} — gross ${grossSpread.toFixed(1)}% net ${netSpread.toFixed(1)}%` +
549
+ ` | Long ${longExchange} (${(rateForExch(longExchange) * 100).toFixed(4)}%)` +
550
+ ` | Short ${shortExchange} (${(rateForExch(shortExchange) * 100).toFixed(4)}%)`));
551
+ // Calculate size in asset units from USD and mark price
552
+ if (snap.markPrice <= 0) {
553
+ console.error(chalk.red(` ${now} SKIP ${snap.symbol}: no mark price available`));
554
+ continue;
555
+ }
556
+ // Dynamic sizing: compute auto size if --size auto
557
+ let actualSizeUsd = sizeUsd;
558
+ if (sizeIsAuto) {
559
+ try {
560
+ const longAdapter = await getAdapterForExchange(longExchange);
561
+ const shortAdapter = await getAdapterForExchange(shortExchange);
562
+ actualSizeUsd = await computeAutoSize(longAdapter, shortAdapter, snap.symbol, 0.3);
563
+ if (actualSizeUsd <= 0) {
564
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size returned $0 (insufficient depth/margin)`));
565
+ continue;
566
+ }
567
+ console.log(chalk.gray(` ${now} Auto-size ${snap.symbol}: $${actualSizeUsd}`));
568
+ }
569
+ catch (err) {
570
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size failed — ${err instanceof Error ? err.message : err}`));
571
+ continue;
572
+ }
573
+ }
574
+ if (sizeIsAuto && actualSizeUsd < minSizeUsd) {
575
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size $${actualSizeUsd} below min $${minSizeUsd}`));
576
+ continue;
577
+ }
578
+ const sizeInAsset = (actualSizeUsd / snap.markPrice).toFixed(4);
579
+ if (!dryRun) {
580
+ try {
581
+ const longAdapter = await getAdapterForExchange(longExchange);
582
+ const shortAdapter = await getAdapterForExchange(shortExchange);
583
+ // Use Promise.allSettled for safe dual-leg entry
584
+ const [longResult, shortResult] = await Promise.allSettled([
585
+ longAdapter.marketOrder(snap.symbol, "buy", sizeInAsset),
586
+ shortAdapter.marketOrder(snap.symbol, "sell", sizeInAsset),
587
+ ]);
588
+ const longOk = longResult.status === "fulfilled";
589
+ const shortOk = shortResult.status === "fulfilled";
590
+ if (longOk && shortOk) {
591
+ // Both legs filled successfully
592
+ logExecution({
593
+ type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
594
+ symbol: snap.symbol, side: "entry", size: sizeInAsset,
595
+ status: "success", dryRun: false,
596
+ meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice },
597
+ });
598
+ console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${sizeInAsset} units ($${actualSizeUsd} / $${snap.markPrice.toFixed(2)})`));
599
+ await notifyIfEnabled(webhookUrl, notifyEvents, "entry", {
600
+ symbol: snap.symbol, longExchange, shortExchange,
601
+ size: actualSizeUsd, netSpread,
602
+ });
603
+ }
604
+ else if (longOk !== shortOk) {
605
+ // One leg filled, one failed — ROLLBACK the successful leg
606
+ const filledSide = longOk ? "long" : "short";
607
+ const failedSide = longOk ? "short" : "long";
608
+ const filledAdapter = longOk ? longAdapter : shortAdapter;
609
+ const rollbackAction = longOk ? "sell" : "buy"; // reverse the filled side
610
+ const failedErr = longOk
611
+ ? shortResult.reason
612
+ : longResult.reason;
613
+ console.log(chalk.yellow(` ${now} PARTIAL FILL ${snap.symbol}: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
614
+ // Attempt rollback with max 2 retries
615
+ let rollbackOk = false;
616
+ for (let attempt = 1; attempt <= 2; attempt++) {
617
+ try {
618
+ await filledAdapter.marketOrder(snap.symbol, rollbackAction, sizeInAsset);
619
+ rollbackOk = true;
620
+ console.log(chalk.green(` ${now} ROLLBACK ${snap.symbol}: ${filledSide} leg closed (attempt ${attempt})`));
621
+ break;
622
+ }
623
+ catch (rollbackErr) {
624
+ console.log(chalk.red(` ${now} ROLLBACK ATTEMPT ${attempt}/2 FAILED ${snap.symbol}: ${rollbackErr instanceof Error ? rollbackErr.message : rollbackErr}`));
625
+ }
626
+ }
627
+ logExecution({
628
+ type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
629
+ symbol: snap.symbol, side: "entry", size: sizeInAsset,
630
+ status: "failed", dryRun: false,
631
+ error: `Partial fill: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Rollback: ${rollbackOk ? "success" : "FAILED"}`,
632
+ meta: { longExchange, shortExchange, grossSpread, netSpread, partialFill: filledSide, rollbackSuccess: rollbackOk },
633
+ });
634
+ if (!rollbackOk) {
635
+ // Critical: manual intervention required
636
+ console.log(chalk.red.bold(` ${now} IMBALANCE ${snap.symbol}: ${filledSide} leg open, rollback failed — MANUAL CLOSE REQUIRED`));
637
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
638
+ exchange: longOk ? longExchange : shortExchange,
639
+ marginPct: 0,
640
+ threshold: 0,
641
+ symbol: snap.symbol,
642
+ message: `IMBALANCE: ${filledSide} leg filled on ${longOk ? longExchange : shortExchange}, ${failedSide} failed, rollback failed. Manual close required.`,
643
+ });
644
+ }
645
+ continue; // don't add to openPositions
646
+ }
647
+ else {
648
+ // Both legs failed
649
+ const longErr = longResult.reason;
650
+ const shortErr = shortResult.reason;
651
+ logExecution({
652
+ type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
653
+ symbol: snap.symbol, side: "entry", size: sizeInAsset,
654
+ status: "failed", dryRun: false,
655
+ error: `Both legs failed. Long: ${longErr instanceof Error ? longErr.message : String(longErr)}, Short: ${shortErr instanceof Error ? shortErr.message : String(shortErr)}`,
656
+ meta: { longExchange, shortExchange, grossSpread, netSpread },
657
+ });
658
+ console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: both legs rejected`));
659
+ continue;
660
+ }
661
+ }
662
+ catch (err) {
663
+ logExecution({
664
+ type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
665
+ symbol: snap.symbol, side: "entry", size: sizeInAsset,
666
+ status: "failed", dryRun: false,
667
+ error: err instanceof Error ? err.message : String(err),
668
+ meta: { longExchange, shortExchange, grossSpread, netSpread },
669
+ });
670
+ console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: ${err instanceof Error ? err.message : err}`));
671
+ continue;
672
+ }
673
+ }
674
+ openPositions.push({
675
+ symbol: snap.symbol,
676
+ longExchange,
677
+ shortExchange,
678
+ size: sizeInAsset,
679
+ entrySpread: grossSpread,
680
+ entryTime: new Date().toISOString(),
681
+ entryMarkPrice: snap.markPrice,
682
+ accumulatedFundingUsd: 0,
683
+ lastCheckTime: Date.now(),
684
+ });
685
+ }
686
+ }
687
+ // Accumulate estimated funding income & show status
688
+ if (openPositions.length > 0) {
689
+ const nowMs = Date.now();
690
+ for (const pos of openPositions) {
691
+ const current = filtered.find(s => s.symbol === pos.symbol);
692
+ if (!current)
693
+ continue;
694
+ const elapsedHours = (nowMs - pos.lastCheckTime) / (1000 * 60 * 60);
695
+ const notional = parseFloat(pos.size) * current.markPrice;
696
+ // Estimate funding collected: long collects from low-rate side, short from high-rate side
697
+ const rateFor = (e) => e === "pacifica" ? current.pacRate : e === "hyperliquid" ? current.hlRate : current.ltRate;
698
+ const longHourly = rateFor(pos.longExchange);
699
+ const shortHourly = rateFor(pos.shortExchange);
700
+ // Income = short gets paid positive funding, long pays; net = (shortRate - longRate) * notional * hours
701
+ const hourlyIncome = (shortHourly - longHourly) * notional;
702
+ pos.accumulatedFundingUsd += hourlyIncome * elapsedHours;
703
+ pos.lastCheckTime = nowMs;
704
+ }
705
+ const totalFunding = openPositions.reduce((s, p) => s + p.accumulatedFundingUsd, 0);
706
+ const fundingColor = totalFunding >= 0 ? chalk.green : chalk.red;
707
+ console.log(chalk.gray(` ${now} Status: ${openPositions.length} positions | ` +
708
+ `Est. funding: ${fundingColor(`$${totalFunding.toFixed(4)}`)} — ` +
709
+ openPositions.map(p => `${p.symbol}(${p.entrySpread.toFixed(0)}% $${p.accumulatedFundingUsd.toFixed(3)})`).join(", ")));
710
+ }
711
+ // Update heartbeat: mark successful scan
712
+ const stateForHeartbeat = loadArbState();
713
+ if (stateForHeartbeat) {
714
+ stateForHeartbeat.lastSuccessfulScanTime = new Date().toISOString();
715
+ stateForHeartbeat.lastScanTime = new Date().toISOString();
716
+ saveArbState(stateForHeartbeat);
717
+ }
718
+ }
719
+ catch (err) {
720
+ console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
721
+ }
722
+ };
723
+ await cycle();
724
+ setInterval(cycle, intervalMs);
725
+ await new Promise(() => { }); // keep alive
726
+ });
727
+ // ── arb scan ── (one-shot spread scan)
728
+ arb
729
+ .command("scan")
730
+ .description("Scan current funding rate spreads")
731
+ .option("--min <pct>", "Min annual spread to show", "10")
732
+ .option("--hold-days <days>", "Expected hold period for cost calc", "7")
733
+ .option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
734
+ .option("--size <usd>", "Position size per leg ($) for cost calc", "100")
735
+ .action(async (opts) => {
736
+ const minSpread = parseFloat(opts.min);
737
+ const holdDays = parseFloat(opts.holdDays);
738
+ const bridgeCostUsd = parseFloat(opts.bridgeCost);
739
+ const sizeUsd = parseFloat(opts.size);
740
+ if (!isJson())
741
+ console.log(chalk.cyan("\n Scanning funding rate spreads...\n"));
742
+ const spreads = await fetchFundingSpreads();
743
+ const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
744
+ if (isJson()) {
745
+ const enriched = filtered.map(s => {
746
+ const grossSpread = Math.abs(s.spread);
747
+ const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
748
+ const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
749
+ return { ...s, grossSpread, netSpread: net, estFeesPct: rtCost };
750
+ });
751
+ return printJson(jsonOk(enriched));
752
+ }
753
+ if (filtered.length === 0) {
754
+ console.log(chalk.gray(` No spreads above ${minSpread}%\n`));
755
+ return;
756
+ }
757
+ // Header
758
+ console.log(chalk.gray(` ${"SYMBOL".padEnd(8)} ${"GROSS".padEnd(8)} ${"NET".padEnd(8)} ${"FEES".padEnd(7)} ${"DIR".padEnd(7)} RATES`));
759
+ for (const s of filtered) {
760
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
761
+ const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
762
+ const grossSpread = Math.abs(s.spread);
763
+ const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
764
+ const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
765
+ const grossColor = grossSpread >= 30 ? chalk.green : chalk.yellow;
766
+ const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
767
+ const rates = [];
768
+ if (s.pacRate)
769
+ rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
770
+ if (s.hlRate)
771
+ rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
772
+ if (s.ltRate)
773
+ rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
774
+ console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
775
+ `${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
776
+ `${netColor(`${netSpread.toFixed(1)}%`.padEnd(8))} ` +
777
+ `${chalk.gray(`${rtCost.toFixed(2)}%`.padEnd(7))} ` +
778
+ `${direction.padEnd(7)} ` +
779
+ rates.join(" "));
780
+ }
781
+ console.log(chalk.gray(`\n ${filtered.length} opportunities above ${minSpread}% gross annual spread`));
782
+ console.log(chalk.gray(` Net spread assumes ${holdDays}d hold, $${bridgeCostUsd} bridge cost, $${sizeUsd} size`));
783
+ console.log(chalk.gray(` * Spreads are estimates based on current rates — actual may vary`));
784
+ console.log(chalk.gray(` Use 'perp arb auto --min-spread ${minSpread}' to auto-trade\n`));
785
+ });
786
+ // ── arb pnl ── (check arb position PnL)
787
+ arb
788
+ .command("pnl")
789
+ .description("Check PnL of current arb positions across exchanges")
790
+ .action(async () => {
791
+ if (!isJson())
792
+ console.log(chalk.cyan("\n Checking arb positions...\n"));
793
+ const exchangeNames = ["hyperliquid", "lighter", "pacifica"];
794
+ const allPositions = [];
795
+ for (const exName of exchangeNames) {
796
+ try {
797
+ const adapter = await getAdapterForExchange(exName);
798
+ const positions = await adapter.getPositions();
799
+ for (const p of positions) {
800
+ allPositions.push({
801
+ exchange: exName,
802
+ symbol: p.symbol.replace("-PERP", ""),
803
+ side: p.side,
804
+ size: Math.abs(Number(p.size)),
805
+ entry: Number(p.entryPrice),
806
+ mark: Number(p.markPrice),
807
+ upnl: Number(p.unrealizedPnl),
808
+ lev: p.leverage,
809
+ });
810
+ }
811
+ }
812
+ catch {
813
+ // exchange not configured, skip
814
+ }
815
+ }
816
+ if (allPositions.length === 0) {
817
+ console.log(chalk.gray(" No positions found on any exchange.\n"));
818
+ return;
819
+ }
820
+ // Group by symbol to find arb pairs
821
+ const bySymbol = new Map();
822
+ for (const p of allPositions) {
823
+ const key = p.symbol.toUpperCase();
824
+ if (!bySymbol.has(key))
825
+ bySymbol.set(key, []);
826
+ bySymbol.get(key).push(p);
827
+ }
828
+ // Get current funding rates
829
+ const spreads = await fetchFundingSpreads();
830
+ const spreadMap = new Map(spreads.map(s => [s.symbol.toUpperCase(), s]));
831
+ // Get fee rates (approximate)
832
+ const TAKER_FEE = 0.00035; // ~0.035% typical
833
+ // Fetch actual settled funding payments from each exchange
834
+ const actualFundingByExSymbol = new Map(); // "exchange:SYMBOL" → total settled USD
835
+ for (const exName of exchangeNames) {
836
+ try {
837
+ const adapter = await getAdapterForExchange(exName);
838
+ const payments = await adapter.getFundingPayments(100);
839
+ for (const fp of payments) {
840
+ const sym = fp.symbol.replace("-PERP", "").toUpperCase();
841
+ const key = `${exName}:${sym}`;
842
+ actualFundingByExSymbol.set(key, (actualFundingByExSymbol.get(key) ?? 0) + Number(fp.payment));
843
+ }
844
+ }
845
+ catch {
846
+ // exchange not configured or API error, skip
847
+ }
848
+ }
849
+ if (isJson()) {
850
+ const result = [...bySymbol.entries()].map(([symbol, positions]) => {
851
+ const spread = spreadMap.get(symbol);
852
+ // Sum actual funding across exchanges for this symbol
853
+ let actualFunding = 0;
854
+ for (const p of positions) {
855
+ actualFunding += actualFundingByExSymbol.get(`${p.exchange}:${symbol}`) ?? 0;
856
+ }
857
+ return { symbol, positions, currentSpread: spread?.spread ?? 0, actualFunding };
858
+ });
859
+ return printJson(jsonOk(result));
860
+ }
861
+ let totalUpnl = 0;
862
+ let totalFees = 0;
863
+ let totalEstFunding = 0;
864
+ let totalActualFunding = 0;
865
+ for (const [symbol, positions] of bySymbol) {
866
+ const isArb = positions.length >= 2 && positions.some(p => p.side === "long") && positions.some(p => p.side === "short");
867
+ const spread = spreadMap.get(symbol);
868
+ console.log(chalk.white.bold(` ${symbol}`) + (isArb ? chalk.green(" [ARB PAIR]") : chalk.gray(" [single]")));
869
+ for (const p of positions) {
870
+ const sideColor = p.side === "long" ? chalk.green : chalk.red;
871
+ const pnlColor = p.upnl >= 0 ? chalk.green : chalk.red;
872
+ const notional = p.size * p.mark;
873
+ const entryFee = p.size * p.entry * TAKER_FEE;
874
+ totalFees += entryFee;
875
+ totalUpnl += p.upnl;
876
+ console.log(` ${sideColor(p.side.toUpperCase().padEnd(6))} ${p.exchange.padEnd(13)} ` +
877
+ `size: ${p.size.toFixed(2).padEnd(8)} entry: $${p.entry.toFixed(4).padEnd(10)} ` +
878
+ `mark: $${p.mark.toFixed(4).padEnd(10)} uPnL: ${pnlColor(p.upnl >= 0 ? "+" : "")}$${p.upnl.toFixed(4)}`);
879
+ console.log(chalk.gray(` notional: $${notional.toFixed(2)} est.entry fee: $${entryFee.toFixed(4)} lev: ${p.lev}x`));
880
+ }
881
+ if (spread) {
882
+ const grossSpread = Math.abs(spread.spread);
883
+ const rtCost = computeRoundTripCostPct(spread.longExch, spread.shortExch);
884
+ const netSpread = computeNetSpread(grossSpread, 7, rtCost, 0.5, 100);
885
+ const grossColor = grossSpread >= 20 ? chalk.green : grossSpread >= 10 ? chalk.yellow : chalk.gray;
886
+ const netColor = netSpread >= 15 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
887
+ // Estimate hourly funding income for this pair
888
+ const longPos = positions.find(p => p.side === "long");
889
+ const shortPos = positions.find(p => p.side === "short");
890
+ if (longPos && shortPos) {
891
+ const avgNotional = (longPos.size * longPos.mark + shortPos.size * shortPos.mark) / 2;
892
+ const hourlyIncome = (grossSpread / 100) / (24 * 365) * avgNotional;
893
+ const dailyIncome = hourlyIncome * 24;
894
+ console.log(chalk.cyan(` Gross: ${grossColor(`${grossSpread.toFixed(1)}%`)} | ` +
895
+ `Net: ${netColor(`${netSpread.toFixed(1)}%`)} | ` +
896
+ `Fees: ${chalk.gray(`${rtCost.toFixed(2)}%`)} | ` +
897
+ `Est. income: $${hourlyIncome.toFixed(4)}/hr, $${dailyIncome.toFixed(3)}/day`));
898
+ // Look up entry time from execution log for funding estimation
899
+ const entryLog = readExecutionLog({ type: "arb_entry", symbol }).filter(e => e.status === "success")[0];
900
+ const entryTime = entryLog?.timestamp ? new Date(entryLog.timestamp).getTime() : null;
901
+ const holdHours = entryTime ? (Date.now() - entryTime) / (1000 * 60 * 60) : null;
902
+ // Estimated funding based on current spread × hold time
903
+ const estFunding = holdHours !== null ? hourlyIncome * holdHours : 0;
904
+ // Actual settled funding from exchange APIs
905
+ let actualFunding = 0;
906
+ for (const p of positions) {
907
+ actualFunding += actualFundingByExSymbol.get(`${p.exchange}:${symbol}`) ?? 0;
908
+ }
909
+ totalEstFunding += estFunding;
910
+ totalActualFunding += actualFunding;
911
+ const diff = actualFunding - estFunding;
912
+ const diffColor = Math.abs(diff) < 0.01 ? chalk.gray : diff >= 0 ? chalk.green : chalk.red;
913
+ const fmtVal = (v) => v >= 0 ? `$${v.toFixed(4)}` : `-$${Math.abs(v).toFixed(4)}`;
914
+ console.log(chalk.white(` Funding: Est. ${chalk.yellow(fmtVal(estFunding))} / ` +
915
+ `Actual ${chalk.cyan(fmtVal(actualFunding))} / ` +
916
+ `Diff: ${diffColor(fmtVal(diff))}`));
917
+ }
918
+ }
919
+ console.log();
920
+ }
921
+ // Summary
922
+ const exitFees = allPositions.reduce((s, p) => s + p.size * p.mark * TAKER_FEE, 0);
923
+ totalFees += exitFees;
924
+ const netPnl = totalUpnl - totalFees + totalActualFunding;
925
+ console.log(chalk.white.bold(" Summary"));
926
+ const upnlColor = totalUpnl >= 0 ? chalk.green : chalk.red;
927
+ const netColor = netPnl >= 0 ? chalk.green : chalk.red;
928
+ console.log(` Unrealized PnL: ${upnlColor(`$${totalUpnl.toFixed(4)}`)}`);
929
+ console.log(` Est. fees (in+out): ${chalk.red(`-$${totalFees.toFixed(4)}`)}`);
930
+ if (totalActualFunding !== 0 || totalEstFunding !== 0) {
931
+ const fundingDiff = totalActualFunding - totalEstFunding;
932
+ const diffPct = totalEstFunding !== 0 ? ((fundingDiff / Math.abs(totalEstFunding)) * 100).toFixed(1) : "N/A";
933
+ console.log(` Est. funding: ${chalk.yellow(`$${totalEstFunding.toFixed(4)}`)}`);
934
+ console.log(` Actual funding: ${chalk.cyan(`$${totalActualFunding.toFixed(4)}`)}`);
935
+ console.log(` Funding diff: ${fundingDiff >= 0 ? chalk.green(`+$${fundingDiff.toFixed(4)}`) : chalk.red(`-$${Math.abs(fundingDiff).toFixed(4)}`)} (${diffPct}%)`);
936
+ }
937
+ console.log(` Net (if closed now): ${netColor(`$${netPnl.toFixed(4)}`)}`);
938
+ console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker. Actual may vary.)`));
939
+ console.log(chalk.gray(` * Net includes actual settled funding where available.\n`));
940
+ });
941
+ // ── arb monitor ── (live monitoring with liquidity)
942
+ arb
943
+ .command("monitor")
944
+ .description("Live-monitor funding spreads with liquidity data")
945
+ .option("--min <pct>", "Min annual spread to show", "20")
946
+ .option("--interval <sec>", "Refresh interval in seconds", "60")
947
+ .option("--top <n>", "Show top N opportunities", "15")
948
+ .option("--check-liquidity", "Check orderbook depth (slower)")
949
+ .option("--hold-days <days>", "Expected hold period for net spread calc", "7")
950
+ .option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
951
+ .option("--size <usd>", "Position size per leg ($) for cost calc", "100")
952
+ .action(async (opts) => {
953
+ const minSpread = parseFloat(opts.min);
954
+ const intervalSec = parseInt(opts.interval);
955
+ const topN = parseInt(opts.top);
956
+ const checkLiq = opts.checkLiquidity ?? false;
957
+ const holdDays = parseFloat(opts.holdDays);
958
+ const bridgeCostUsd = parseFloat(opts.bridgeCost);
959
+ const sizeUsd = parseFloat(opts.size);
960
+ let cycle = 0;
961
+ if (!isJson()) {
962
+ console.log(chalk.cyan.bold("\n Funding Arb Monitor"));
963
+ console.log(chalk.gray(` Min spread: ${minSpread}% | Refresh: ${intervalSec}s | Top: ${topN}`));
964
+ console.log(chalk.gray(` Net spread: ${holdDays}d hold, $${bridgeCostUsd} bridge, $${sizeUsd} size`));
965
+ if (checkLiq)
966
+ console.log(chalk.gray(` Liquidity check: ON`));
967
+ console.log(chalk.gray(` Press Ctrl+C to stop\n`));
968
+ }
969
+ const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
970
+ while (true) {
971
+ cycle++;
972
+ const ts = new Date().toLocaleTimeString();
973
+ try {
974
+ const spreads = await fetchFundingSpreads();
975
+ const filtered = spreads
976
+ .filter(s => Math.abs(s.spread) >= minSpread)
977
+ .slice(0, topN);
978
+ // Clear previous output (move cursor up)
979
+ if (cycle > 1) {
980
+ const linesToClear = filtered.length + 4;
981
+ process.stdout.write(`\x1b[${linesToClear}A\x1b[J`);
982
+ }
983
+ console.log(chalk.gray(` ${ts} — Cycle ${cycle} | ${filtered.length} opportunities >= ${minSpread}%\n`));
984
+ if (filtered.length === 0) {
985
+ console.log(chalk.gray(` No opportunities found.\n`));
986
+ }
987
+ else {
988
+ // Optionally check liquidity for top entries
989
+ const liqData = new Map();
990
+ if (checkLiq && filtered.length > 0) {
991
+ const topCheck = filtered.slice(0, 5); // check liquidity for top 5 only
992
+ await Promise.allSettled(topCheck.map(async (s) => {
993
+ try {
994
+ const [hlOB, ltOB] = await Promise.all([
995
+ fetchHLOrderbook(s.symbol),
996
+ fetchLighterOrderbook(s.symbol),
997
+ ]);
998
+ const hlDepth = hlOB.reduce((sum, l) => sum + l[0] * l[1], 0);
999
+ const ltDepth = ltOB.reduce((sum, l) => sum + l[0] * l[1], 0);
1000
+ // Price gap between best ask (buy side) and best bid (sell side)
1001
+ const hlBest = hlOB[0]?.[0] ?? 0;
1002
+ const ltBest = ltOB[0]?.[0] ?? 0;
1003
+ const gap = hlBest && ltBest
1004
+ ? (Math.abs(hlBest - ltBest) / Math.min(hlBest, ltBest) * 100).toFixed(3)
1005
+ : "?";
1006
+ liqData.set(s.symbol, { hlDepth: Math.round(hlDepth), ltDepth: Math.round(ltDepth), gap });
1007
+ }
1008
+ catch { /* skip */ }
1009
+ }));
1010
+ }
1011
+ for (const s of filtered) {
1012
+ const direction = `${exAbbr(s.shortExch)}>${exAbbr(s.longExch)}`;
1013
+ const grossSpread = Math.abs(s.spread);
1014
+ const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
1015
+ const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
1016
+ const grossColor = grossSpread >= 50 ? chalk.green.bold
1017
+ : grossSpread >= 30 ? chalk.green
1018
+ : chalk.yellow;
1019
+ const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
1020
+ const rates = [];
1021
+ if (s.pacRate)
1022
+ rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
1023
+ if (s.hlRate)
1024
+ rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
1025
+ if (s.ltRate)
1026
+ rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
1027
+ let liqInfo = "";
1028
+ const ld = liqData.get(s.symbol);
1029
+ if (ld) {
1030
+ liqInfo = chalk.gray(` | depth: HL $${ld.hlDepth.toLocaleString()} LT $${ld.ltDepth.toLocaleString()} gap:${ld.gap}%`);
1031
+ }
1032
+ console.log(` ${chalk.white.bold(s.symbol.padEnd(8))} ` +
1033
+ `${grossColor(`${grossSpread.toFixed(1)}%`.padEnd(8))} ` +
1034
+ `${netColor(`net:${netSpread.toFixed(1)}%`.padEnd(12))} ` +
1035
+ `${direction.padEnd(7)} ` +
1036
+ rates.join(" ") +
1037
+ liqInfo);
1038
+ }
1039
+ console.log(chalk.gray(` * Net spreads are predicted estimates (${holdDays}d hold, $${bridgeCostUsd} bridge)`));
1040
+ console.log();
1041
+ }
1042
+ }
1043
+ catch (err) {
1044
+ console.log(chalk.red(` ${ts} Error: ${err instanceof Error ? err.message : err}\n`));
1045
+ }
1046
+ await new Promise(r => setTimeout(r, intervalSec * 1000));
1047
+ }
1048
+ });
1049
+ // ── arb dex-monitor ── (live HIP-3 cross-dex monitoring)
1050
+ arb
1051
+ .command("dex-monitor")
1052
+ .description("Live-monitor HIP-3 cross-dex funding arb opportunities")
1053
+ .option("--min <pct>", "Min annual spread to show", "10")
1054
+ .option("--interval <sec>", "Refresh interval in seconds", "60")
1055
+ .option("--top <n>", "Show top N opportunities", "20")
1056
+ .option("--include-native", "Include native HL perps", true)
1057
+ .option("--no-include-native", "Exclude native HL perps")
1058
+ .action(async (opts) => {
1059
+ const minSpread = parseFloat(opts.min);
1060
+ const intervalSec = parseInt(opts.interval);
1061
+ const topN = parseInt(opts.top);
1062
+ let cycle = 0;
1063
+ if (!isJson()) {
1064
+ console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Monitor"));
1065
+ console.log(chalk.gray(` Min spread: ${minSpread}% | Refresh: ${intervalSec}s | Top: ${topN}`));
1066
+ console.log(chalk.gray(` Native HL: ${opts.includeNative ? "ON" : "OFF"}`));
1067
+ console.log(chalk.gray(` Press Ctrl+C to stop\n`));
1068
+ }
1069
+ while (true) {
1070
+ cycle++;
1071
+ const ts = new Date().toLocaleTimeString();
1072
+ try {
1073
+ const pairs = await scanDexArb({
1074
+ minAnnualSpread: minSpread,
1075
+ includeNative: opts.includeNative,
1076
+ });
1077
+ const shown = pairs.slice(0, topN);
1078
+ // Clear previous output
1079
+ if (cycle > 1) {
1080
+ const linesToClear = shown.length + 4;
1081
+ process.stdout.write(`\x1b[${linesToClear}A\x1b[J`);
1082
+ }
1083
+ console.log(chalk.gray(` ${ts} — Cycle ${cycle} | ${shown.length}/${pairs.length} opportunities >= ${minSpread}%\n`));
1084
+ if (shown.length === 0) {
1085
+ console.log(chalk.gray(` No opportunities found.\n`));
1086
+ }
1087
+ else {
1088
+ for (const p of shown) {
1089
+ const spreadColor = p.annualSpread >= 50 ? chalk.green.bold
1090
+ : p.annualSpread >= 20 ? chalk.green : chalk.yellow;
1091
+ const viabilityColor = p.viability === "A" ? chalk.green.bold
1092
+ : p.viability === "B" ? chalk.green
1093
+ : p.viability === "C" ? chalk.yellow
1094
+ : chalk.red;
1095
+ const longFund = (p.long.fundingRate * 100).toFixed(4);
1096
+ const shortFund = (p.short.fundingRate * 100).toFixed(4);
1097
+ const fmtOi = p.minOiUsd >= 1_000_000 ? `$${(p.minOiUsd / 1_000_000).toFixed(1)}M`
1098
+ : p.minOiUsd >= 1_000 ? `$${(p.minOiUsd / 1_000).toFixed(0)}K`
1099
+ : `$${p.minOiUsd.toFixed(0)}`;
1100
+ console.log(` ${chalk.white.bold(p.underlying.padEnd(10))} ` +
1101
+ `${spreadColor(`${p.annualSpread.toFixed(1)}%`.padEnd(8))} ` +
1102
+ `${viabilityColor(p.viability)} ` +
1103
+ `L:${p.long.dex}(${longFund}%) ` +
1104
+ `S:${p.short.dex}(${shortFund}%) ` +
1105
+ `$${formatUsd(p.long.markPrice)} ` +
1106
+ chalk.gray(`gap:${p.priceGapPct.toFixed(3)}%`) + ` ` +
1107
+ viabilityColor(`OI:${fmtOi}`));
1108
+ }
1109
+ console.log();
1110
+ }
1111
+ }
1112
+ catch (err) {
1113
+ console.log(chalk.red(` ${ts} Error: ${err instanceof Error ? err.message : err}\n`));
1114
+ }
1115
+ await new Promise(r => setTimeout(r, intervalSec * 1000));
1116
+ }
1117
+ });
1118
+ // ── arb dex-auto ── (HIP-3 cross-dex auto arb)
1119
+ if (getHLAdapterForDex) {
1120
+ arb
1121
+ .command("dex-auto")
1122
+ .description("Auto-execute HIP-3 cross-dex funding arb (long low-funding, short high-funding)")
1123
+ .option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
1124
+ .option("--close-spread <pct>", "Close when spread drops below (%)", "5")
1125
+ .option("--size <usd>", "Position size per leg ($)", "100")
1126
+ .option("--max-positions <n>", "Max simultaneous arb positions", "5")
1127
+ .option("--min-oi <usd>", "Min OI (USD) to enter", "50000")
1128
+ .option("--min-grade <grade>", "Min viability grade: A, B, C, D", "C")
1129
+ .option("--interval <seconds>", "Check interval", "120")
1130
+ .option("--dry-run", "Simulate without executing trades")
1131
+ .action(async (opts) => {
1132
+ const minSpread = parseFloat(opts.minSpread);
1133
+ const closeSpread = parseFloat(opts.closeSpread);
1134
+ const sizeUsd = parseFloat(opts.size);
1135
+ const maxPositions = parseInt(opts.maxPositions);
1136
+ const minOi = parseFloat(opts.minOi);
1137
+ const gradeOrder = { A: 0, B: 1, C: 2, D: 3 };
1138
+ const minGradeIdx = gradeOrder[opts.minGrade.toUpperCase()] ?? 2;
1139
+ const intervalMs = parseInt(opts.interval) * 1000;
1140
+ // Check both subcommand option and global/argv (Commander may route --dry-run to parent)
1141
+ const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
1142
+ const openPositions = [];
1143
+ if (!isJson()) {
1144
+ console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Bot\n"));
1145
+ console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
1146
+ console.log(` Enter spread: >= ${minSpread}% annual`);
1147
+ console.log(` Close spread: <= ${closeSpread}% annual`);
1148
+ console.log(` Size per leg: $${sizeUsd}`);
1149
+ console.log(` Max positions: ${maxPositions}`);
1150
+ console.log(` Min OI: $${formatUsd(minOi)}`);
1151
+ console.log(` Min grade: ${opts.minGrade.toUpperCase()}`);
1152
+ console.log(` Interval: ${opts.interval}s`);
1153
+ console.log(chalk.gray("\n Monitoring... (Ctrl+C to stop)\n"));
1154
+ }
1155
+ const cycle = async () => {
1156
+ try {
1157
+ const pairs = await scanDexArb({
1158
+ minAnnualSpread: 0, // get all, filter ourselves
1159
+ includeNative: true,
1160
+ });
1161
+ const now = new Date().toLocaleTimeString();
1162
+ // Check close conditions
1163
+ for (let i = openPositions.length - 1; i >= 0; i--) {
1164
+ const pos = openPositions[i];
1165
+ // Find current pair for this position
1166
+ const current = pairs.find(p => p.underlying === pos.underlying &&
1167
+ ((p.long.dex === pos.longDex && p.short.dex === pos.shortDex) ||
1168
+ (p.long.dex === pos.shortDex && p.short.dex === pos.longDex)));
1169
+ const currentSpread = current?.annualSpread ?? 0;
1170
+ if (currentSpread <= closeSpread || !current) {
1171
+ const reason = !current ? "pair disappeared" : `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
1172
+ if (!isJson())
1173
+ console.log(chalk.yellow(` ${now} CLOSE ${pos.underlying} — ${reason}`));
1174
+ if (!dryRun) {
1175
+ try {
1176
+ const longAdapter = await getHLAdapterForDex(pos.longDex);
1177
+ const shortAdapter = await getHLAdapterForDex(pos.shortDex);
1178
+ await Promise.all([
1179
+ longAdapter.marketOrder(pos.longSymbol, "sell", pos.size),
1180
+ shortAdapter.marketOrder(pos.shortSymbol, "buy", pos.size),
1181
+ ]);
1182
+ logExecution({
1183
+ type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
1184
+ symbol: pos.underlying, side: "close", size: pos.size,
1185
+ status: "success", dryRun: false,
1186
+ meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
1187
+ });
1188
+ if (!isJson())
1189
+ console.log(chalk.green(` ${now} CLOSED ${pos.underlying} — both legs`));
1190
+ }
1191
+ catch (err) {
1192
+ logExecution({
1193
+ type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
1194
+ symbol: pos.underlying, side: "close", size: pos.size,
1195
+ status: "failed", dryRun: false,
1196
+ error: err instanceof Error ? err.message : String(err),
1197
+ meta: { longDex: pos.longDex, shortDex: pos.shortDex },
1198
+ });
1199
+ if (!isJson())
1200
+ console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err}`));
1201
+ }
1202
+ }
1203
+ openPositions.splice(i, 1);
1204
+ }
1205
+ }
1206
+ // Check entry conditions
1207
+ if (openPositions.length < maxPositions) {
1208
+ for (const pair of pairs) {
1209
+ if (openPositions.some(p => p.underlying === pair.underlying))
1210
+ continue;
1211
+ if (openPositions.length >= maxPositions)
1212
+ break;
1213
+ if (pair.annualSpread < minSpread)
1214
+ continue;
1215
+ if (pair.minOiUsd < minOi)
1216
+ continue;
1217
+ if (gradeOrder[pair.viability] > minGradeIdx)
1218
+ continue;
1219
+ // Calculate size in asset units
1220
+ const avgPrice = (pair.long.markPrice + pair.short.markPrice) / 2;
1221
+ if (avgPrice <= 0)
1222
+ continue;
1223
+ const szDecimals = Math.min(pair.long.szDecimals, pair.short.szDecimals);
1224
+ const rawSize = sizeUsd / avgPrice;
1225
+ const size = rawSize.toFixed(szDecimals);
1226
+ if (!isJson()) {
1227
+ console.log(chalk.green(` ${now} ENTER ${pair.underlying} — spread ${pair.annualSpread.toFixed(1)}% grade:${pair.viability} OI:$${formatUsd(pair.minOiUsd)}` +
1228
+ `\n Long ${pair.long.dex}:${pair.long.base} (${(pair.long.fundingRate * 100).toFixed(4)}%)` +
1229
+ ` | Short ${pair.short.dex}:${pair.short.base} (${(pair.short.fundingRate * 100).toFixed(4)}%)` +
1230
+ ` | ${size} units @ $${avgPrice.toFixed(2)}`));
1231
+ }
1232
+ if (!dryRun) {
1233
+ try {
1234
+ const longAdapter = await getHLAdapterForDex(pair.long.dex);
1235
+ const shortAdapter = await getHLAdapterForDex(pair.short.dex);
1236
+ await Promise.all([
1237
+ longAdapter.marketOrder(pair.long.raw, "buy", size),
1238
+ shortAdapter.marketOrder(pair.short.raw, "sell", size),
1239
+ ]);
1240
+ logExecution({
1241
+ type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
1242
+ symbol: pair.underlying, side: "entry", size,
1243
+ status: "success", dryRun: false,
1244
+ meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice },
1245
+ });
1246
+ if (!isJson())
1247
+ console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs`));
1248
+ }
1249
+ catch (err) {
1250
+ logExecution({
1251
+ type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
1252
+ symbol: pair.underlying, side: "entry", size,
1253
+ status: "failed", dryRun: false,
1254
+ error: err instanceof Error ? err.message : String(err),
1255
+ meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
1256
+ });
1257
+ if (!isJson())
1258
+ console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}`));
1259
+ continue;
1260
+ }
1261
+ }
1262
+ openPositions.push({
1263
+ underlying: pair.underlying,
1264
+ longDex: pair.long.dex,
1265
+ longSymbol: pair.long.raw,
1266
+ shortDex: pair.short.dex,
1267
+ shortSymbol: pair.short.raw,
1268
+ size,
1269
+ entrySpread: pair.annualSpread,
1270
+ entryTime: new Date().toISOString(),
1271
+ longPrice: pair.long.markPrice,
1272
+ shortPrice: pair.short.markPrice,
1273
+ });
1274
+ }
1275
+ }
1276
+ // Status
1277
+ if (isJson()) {
1278
+ printJson(jsonOk({
1279
+ timestamp: new Date().toISOString(),
1280
+ openPositions,
1281
+ availablePairs: pairs.filter(p => p.annualSpread >= minSpread && p.minOiUsd >= minOi).length,
1282
+ }));
1283
+ }
1284
+ else if (openPositions.length > 0) {
1285
+ console.log(chalk.gray(` ${now} Positions: ${openPositions.length}/${maxPositions} — ` +
1286
+ openPositions.map(p => `${p.underlying}(${p.entrySpread.toFixed(0)}%)`).join(", ")));
1287
+ }
1288
+ else {
1289
+ console.log(chalk.gray(` ${now} No positions. ${pairs.filter(p => p.annualSpread >= minSpread).length} pairs above ${minSpread}%`));
1290
+ }
1291
+ }
1292
+ catch (err) {
1293
+ if (!isJson())
1294
+ console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
1295
+ }
1296
+ };
1297
+ await cycle();
1298
+ setInterval(cycle, intervalMs);
1299
+ await new Promise(() => { }); // keep alive
1300
+ });
1301
+ }
1302
+ }
1303
+ // ── Orderbook helpers for monitor ──
1304
+ async function fetchHLOrderbook(symbol) {
1305
+ const { HYPERLIQUID_API_URL } = await import("../shared-api.js");
1306
+ const res = await fetch(HYPERLIQUID_API_URL, {
1307
+ method: "POST",
1308
+ headers: { "Content-Type": "application/json" },
1309
+ body: JSON.stringify({ type: "l2Book", coin: symbol }),
1310
+ });
1311
+ const json = await res.json();
1312
+ const bids = json.levels?.[0] ?? [];
1313
+ return bids.slice(0, 10).map((l) => [Number(l.px), Number(l.sz)]);
1314
+ }
1315
+ async function fetchLighterOrderbook(symbol) {
1316
+ const { LIGHTER_API_URL } = await import("../shared-api.js");
1317
+ const detailsRes = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookDetails`);
1318
+ const details = await detailsRes.json();
1319
+ const m = (details.order_book_details ?? [])
1320
+ .find(d => d.symbol === symbol);
1321
+ if (!m)
1322
+ return [];
1323
+ const marketId = Number(m.market_id);
1324
+ const obRes = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookOrders?market_id=${marketId}&limit=10`);
1325
+ const ob = await obRes.json();
1326
+ const bids = ob.bids ?? [];
1327
+ return bids.map(l => [Number(l.price), Number(l.remaining_base_amount)]);
1328
+ }