perp-cli 0.12.17 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +247 -27
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +286 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +70 -92
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +316 -337
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1383 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +156 -684
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +7 -2
  287. package/dist/landing.js +19 -3
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +142 -55
  348. package/skills/perp-cli/references/agent-operations.md +110 -56
  349. package/skills/perp-cli/references/commands.md +266 -71
  350. package/skills/perp-cli/references/strategies.md +34 -31
  351. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  352. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,18 +1,229 @@
1
+ import { ARB_MARGIN_BUFFER, marketLeverageOf, requiredArbMargin } from "../rebalance.js";
2
+ import { ERROR_CODES, PerpError } from "../errors.js";
3
+ import { unsupported } from "../exchanges/trading-v2.js";
4
+ import { tradingV2Of } from "../exchanges/legacy-trading-v2.js";
5
+ import { invalidateCache } from "../cache.js";
6
+ import { isDryRun, networkOf, supportsDryRun } from "../cli-flags.js";
1
7
  import chalk from "chalk";
2
- import { formatUsd, printJson, jsonOk, makeTable, logSettledRejections } from "../utils.js";
8
+ import { formatUsd, printJson, jsonOk, jsonError, makeTable, logSettledRejections, symbolMatch } from "../utils.js";
3
9
  import { computeExecutableSize } from "../liquidity.js";
4
10
  import { computeMatchedSize, computeSpotPerpMatchedSize } from "../arb/index.js";
5
- import { computeAnnualSpread, toHourlyRate } from "../funding.js";
11
+ import { toHourlyRate } from "../funding.js";
12
+ import { toHourlyRateByBasis } from "../funding/normalize.js";
6
13
  import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
7
- import { scanDexArb } from "../dex-asset-map.js";
14
+ import { fetchAllDexAssets, findDexArbPairs, scanDexArb } from "../dex-asset-map.js";
8
15
  import { logExecution, readExecutionLog } from "../execution-log.js";
9
16
  import { smartOrder } from "../smart-order.js";
10
17
  import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk, notifyIfEnabled, } from "../arb-utils.js";
11
18
  import { checkChainMargins, isCriticalMargin, shouldBlockEntries, computeAutoSize, } from "../cross-chain-margin.js";
12
- import { loadArbState, saveArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, createInitialState, } from "../arb-state.js";
19
+ import { loadArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, updatePosition as persistUpdatePosition, createInitialState, createDaemonStateStore, } from "../arb-state.js";
13
20
  import { handleRates, handleBasisScan, handleDexScan, handleCompare, handleFundingHistory, handleFundingPositions, fetchAllPrices, printGapTable, printTrackSummary, formatGapPrice, } from "./arb.js";
14
21
  // ── Fee-Adjusted Net Spread Calculation ──
15
22
  import { getTakerFee } from "../constants.js";
23
+ import { assertFundingArbVenue, getVenue, listVenuesWithRole, networkConfig, resolveVenueId, tryResolveVenueId } from "../exchanges/venue.js";
24
+ import { sharedApiNetwork } from "../api/public/urls.js";
25
+ import { findSpotQuoteBalance, readSpotQuoteAvailableFresh, requireBotSpotVenue, requireSpotAdapter, spotNetworkOf, spotPairSymbol, spotSharesPerpMargin, sweepSpotQuoteToPerp, transferQuoteToPerp, transferQuoteToSpot } from "../exchanges/spot-factory.js";
26
+ import { fillSizeString, readSpotBalancesFresh, sizeDecimalsOf, spotLotStepOf, spotMarketFill } from "../exchanges/spot-result.js";
27
+ import { floorSize, floorToStep, lotStepOf } from "../exchanges/precision.js";
28
+ import { MAX_PRICE_DEVIATION_PCT } from "../exchanges/spot-interface.js";
29
+ import { isQuarantined, quarantineEntryFor, quarantineKey, quarantineSymbol, setQuarantineNetwork } from "../bot/quarantine.js";
30
+ import { clearPendingEntry, orphanedPendingEntries, writePendingEntry } from "../arb/pending-entry.js";
31
+ import { closePerpVerified, isOutcomeUnknown, placePerpVerified, rejectedLotSize, trimFirstLegToHedge } from "../bot/perp-order.js";
32
+ /**
33
+ * The venue's published order minimums for `sym` (VenueMarket.minSize / minNotional — the same
34
+ * source `trade scale-in` checks its levels against); null = the venue publishes none.
35
+ * A market missing from the venue's list throws (Rule #2: no assumed minimum).
36
+ */
37
+ export async function venueOrderMinimums(adapter, sym, network) {
38
+ const vm = (await tradingV2Of(adapter, network).getVenueMarkets()).find(m => symbolMatch(m.symbol, sym));
39
+ if (vm === undefined) {
40
+ throw new PerpError("SYMBOL_NOT_FOUND", `${adapter.name}: market ${sym} not found — its minimum order size is unknown`, {
41
+ exchange: adapter.name, remediation: `Check the symbol: perp market list -e ${adapter.name}`,
42
+ });
43
+ }
44
+ return { minSize: vm.minSize, minNotional: vm.minNotional };
45
+ }
46
+ /** The pair's minimums: the larger of the two legs' (both legs trade the same size); 0 = none published. */
47
+ export function pairOrderMinimums(legs) {
48
+ const parts = legs.map(l => {
49
+ const bits = [l.minNotional !== null ? `$${l.minNotional}` : null, l.minSize !== null ? `size ${l.minSize}` : null].filter(b => b !== null);
50
+ return `${l.exchange}: ${bits.length > 0 ? bits.join(", ") : "none published"}`;
51
+ });
52
+ return {
53
+ minNotional: Math.max(0, ...legs.map(l => l.minNotional !== null ? Number(l.minNotional) : 0)),
54
+ minSize: Math.max(0, ...legs.map(l => l.minSize !== null ? Number(l.minSize) : 0)),
55
+ describe: parts.join("; "),
56
+ legs,
57
+ };
58
+ }
59
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
60
+ function quarantinePair(source, sym, longExch, shortExch, reason, log) {
61
+ quarantineSymbol(source, sym, reason, `Check ${sym} on ${longExch} and ${shortExch} (perp -e ${longExch} account positions; perp -e ${shortExch} account positions); close or re-hedge by hand, then: perp strategy quarantine-clear ${sym}`, log);
62
+ }
63
+ /**
64
+ * Open long `size` on longExch, then short the long's verified fill on shortExch.
65
+ * `smart` sends the opening legs as IOC limits (smartOrder); rollback / trim are
66
+ * always reduce-only market orders.
67
+ */
68
+ export async function openPerpPairVerified(p) {
69
+ const { source, sym, longAdapter, longExch, longSym, shortAdapter, shortExch, shortSym, size, smart, log } = p;
70
+ let dec = sizeDecimalsOf(size);
71
+ const q = (reason, exposure) => {
72
+ quarantinePair(source, sym, longExch, shortExch, reason, log);
73
+ return { status: "quarantined", reason, exposure };
74
+ };
75
+ const smartPlace = (a, legSym, side, sz) => smart ? { place: () => smartOrder(a, legSym, side, sz).then(r => r.result) } : {};
76
+ let longFilled;
77
+ try {
78
+ longFilled = (await placePerpVerified(longAdapter, longExch, longSym, "buy", size, smartPlace(longAdapter, longSym, "buy", size))).filledSize;
79
+ }
80
+ catch (e) {
81
+ if (isOutcomeUnknown(e))
82
+ return q(`long ${size} on ${longExch} outcome unknown (${errText(e)}) — no short placed`, `long up to ${size} on ${longExch} (unconfirmed)`);
83
+ return { status: "not_opened", reason: `long leg on ${longExch}: ${errText(e)}`, rolledBack: null, lotSize: rejectedLotSize(e) };
84
+ }
85
+ const longOpen = fillSizeString(longFilled, dec);
86
+ let shortFilled;
87
+ let shortSize = longOpen;
88
+ try {
89
+ try {
90
+ shortFilled = (await placePerpVerified(shortAdapter, shortExch, shortSym, "sell", longOpen, smartPlace(shortAdapter, shortSym, "sell", longOpen))).filledSize;
91
+ }
92
+ catch (e) {
93
+ const lot = p.shortLotRetry === true ? rejectedLotSize(e) : null;
94
+ if (lot === null)
95
+ throw e;
96
+ const lotDec = Math.max(0, Math.ceil(-Math.log10(lot) - 1e-9));
97
+ dec = Math.max(dec, lotDec);
98
+ shortSize = (Math.floor(longFilled / lot + 1e-9) * lot).toFixed(lotDec);
99
+ if (!(Number(shortSize) > 0))
100
+ throw e;
101
+ log(` short leg on ${shortExch} rejected for lot size ${lot} — placing the short once at ${shortSize} (long ${longOpen} verified, not resubmitted)`);
102
+ shortFilled = (await placePerpVerified(shortAdapter, shortExch, shortSym, "sell", shortSize, smartPlace(shortAdapter, shortSym, "sell", shortSize))).filledSize;
103
+ }
104
+ }
105
+ catch (e) {
106
+ const msg = errText(e);
107
+ if (isOutcomeUnknown(e)) {
108
+ // The short may exist: rolling the long back could leave a naked short.
109
+ return q(`short ${shortSize} on ${shortExch} outcome unknown (${msg}); long ${longOpen} open on ${longExch} — not rolled back`, `long ${longOpen} on ${longExch}; short up to ${shortSize} on ${shortExch} (unconfirmed)`);
110
+ }
111
+ log(` short leg on ${shortExch} failed: ${msg} — rolling back long ${longOpen} on ${longExch} (reduce-only)`);
112
+ let rb;
113
+ try {
114
+ rb = await closePerpVerified(longAdapter, longExch, longSym, "sell", longOpen, { decimals: dec });
115
+ }
116
+ catch (rbErr) {
117
+ return q(`short failed (${msg}) and the long rollback ${isOutcomeUnknown(rbErr) ? "is unconfirmed" : "failed"} (${errText(rbErr)})`, `long ${longOpen} on ${longExch} (unhedged${isOutcomeUnknown(rbErr) ? ", rollback unconfirmed" : ""})`);
118
+ }
119
+ if (rb.residual > 0) {
120
+ const residual = fillSizeString(rb.residual, dec);
121
+ return q(`short failed (${msg}); the long rollback closed ${fillSizeString(rb.closed, dec)} of ${longOpen}${rb.note !== null ? ` (${rb.note})` : ""}`, `long ${residual} on ${longExch} (unhedged)`);
122
+ }
123
+ return { status: "not_opened", reason: `short leg on ${shortExch}: ${msg}`, rolledBack: longOpen, lotSize: null };
124
+ }
125
+ const hedged = fillSizeString(shortFilled, dec);
126
+ let trimmed;
127
+ try {
128
+ trimmed = await trimFirstLegToHedge(longAdapter, longExch, longSym, "buy", longFilled, shortFilled, dec);
129
+ }
130
+ catch (e) {
131
+ return q(`short filled ${hedged} of ${longOpen}; trimming the long's excess on ${longExch} ${isOutcomeUnknown(e) ? "is unconfirmed" : "failed"} (${errText(e)})`, `long ${longOpen} on ${longExch} vs short ${hedged} on ${shortExch}`);
132
+ }
133
+ return { status: "opened", hedged, longFilled, shortFilled, trimmed };
134
+ }
135
+ /**
136
+ * Close a perp-perp pair: long first, then short — reduce-only, verified, no retry.
137
+ * A definite failure of the long close changes nothing ("failed": the caller may retry
138
+ * later and should quarantine after repeated failures). Once the long is closed, any
139
+ * short failure, an unknown outcome, or a residual that survives one reduce-only residual
140
+ * close (closePerpVerified) quarantines.
141
+ */
142
+ export async function closePerpPairVerified(p) {
143
+ const { source, sym, longAdapter, longExch, longSym, longSize, shortAdapter, shortExch, shortSym, shortSize, smart, log } = p;
144
+ const closeOpts = (a, legSym, side, sz) => (smart ? { place: () => smartOrder(a, legSym, side, sz, { reduceOnly: true }).then(r => r.result) } : {});
145
+ const q = (reason) => {
146
+ quarantinePair(source, sym, longExch, shortExch, reason, log);
147
+ return { status: "quarantined", reason };
148
+ };
149
+ // Each leg: reduce-only verified close; a partial fill gets ONE reduce-only close of the
150
+ // measured residual (closePerpVerified); still not closed → quarantine.
151
+ let lc;
152
+ try {
153
+ lc = await closePerpVerified(longAdapter, longExch, longSym, "sell", longSize, closeOpts(longAdapter, longSym, "sell", longSize));
154
+ }
155
+ catch (e) {
156
+ if (isOutcomeUnknown(e))
157
+ return q(`close of long ${longSize} on ${longExch} outcome unknown (${errText(e)}); short ${shortSize} on ${shortExch} left open`);
158
+ return { status: "failed", reason: `long close on ${longExch}: ${errText(e)}` };
159
+ }
160
+ if (lc.residual > 0) {
161
+ return q(`close of long on ${longExch} closed ${lc.closed} of ${longSize}, ${lc.residual} still open${lc.note !== null ? ` (${lc.note})` : ""}; short ${shortSize} on ${shortExch} not closed (legs unbalanced)`);
162
+ }
163
+ let sc;
164
+ try {
165
+ sc = await closePerpVerified(shortAdapter, shortExch, shortSym, "buy", shortSize, closeOpts(shortAdapter, shortSym, "buy", shortSize));
166
+ }
167
+ catch (e) {
168
+ return q(`long closed on ${longExch}; close of short ${shortSize} on ${shortExch} ${isOutcomeUnknown(e) ? "has an unknown outcome" : "failed"} (${errText(e)}) — short unhedged`);
169
+ }
170
+ if (sc.residual > 0) {
171
+ return q(`long closed; close of short on ${shortExch} closed ${sc.closed} of ${shortSize}, ${sc.residual} still open${sc.note !== null ? ` (${sc.note})` : ""} — residual short unhedged`);
172
+ }
173
+ return { status: "closed" };
174
+ }
175
+ /**
176
+ * Graceful stop of a daemon loop: SIGINT / SIGTERM (`perp background stop`, Ctrl+C) only REQUEST
177
+ * the stop — the leg sequence (or rollback) in flight finishes, nothing new starts, then the loop
178
+ * ends. A repeated signal never exits between two legs (it is logged).
179
+ */
180
+ export function gracefulStop(name) {
181
+ let stopping = false;
182
+ const onSignal = (sig) => {
183
+ if (stopping) {
184
+ console.error(chalk.yellow(`\n ${name}: already stopping (${sig}) — finishing the current leg sequence first`));
185
+ return;
186
+ }
187
+ stopping = true;
188
+ console.error(chalk.yellow(`\n ${name}: stop requested (${sig}) — finishing the current cycle, no new entries`));
189
+ };
190
+ const onInt = () => onSignal("SIGINT");
191
+ const onTerm = () => onSignal("SIGTERM");
192
+ process.on("SIGINT", onInt);
193
+ process.on("SIGTERM", onTerm);
194
+ return {
195
+ requested: () => stopping,
196
+ dispose: () => { process.removeListener("SIGINT", onInt); process.removeListener("SIGTERM", onTerm); },
197
+ };
198
+ }
199
+ /** Sleep `ms`, waking within 250 ms once `stop()` is true. */
200
+ export async function interruptibleSleep(ms, stop) {
201
+ const until = Date.now() + ms;
202
+ while (!stop() && Date.now() < until)
203
+ await new Promise(r => setTimeout(r, Math.min(250, until - Date.now())));
204
+ }
205
+ /**
206
+ * Startup: pending-entry records `source` left behind by a process that died mid-entry → the
207
+ * symbol is quarantined (legs may be open), then the record is cleared. An unreadable pending
208
+ * file throws (the daemon does not start on unknown exposure).
209
+ */
210
+ export function quarantineOrphanedEntries(network, source, log) {
211
+ for (const e of orphanedPendingEntries(network, source)) {
212
+ quarantinePair(source, e.symbol, e.longExchange, e.shortExchange, `an entry (${e.size} per leg, started ${e.startedAt} by pid ${e.pid}) was in flight when that daemon stopped — legs may be open`, log);
213
+ clearPendingEntry(network, source, e.symbol);
214
+ }
215
+ }
216
+ /** The entry's outcome is recorded: drop its pending record (a failed drop is logged — the next start quarantines it). */
217
+ export function finishPendingEntry(network, source, symbol, log) {
218
+ try {
219
+ clearPendingEntry(network, source, symbol);
220
+ }
221
+ catch (e) {
222
+ log(` CRITICAL: could not clear the pending-entry record of ${symbol} (${e instanceof Error ? e.message : String(e)}) — the next daemon start will quarantine ${symbol}`);
223
+ }
224
+ }
225
+ /** Consecutive definite close failures before the daemons quarantine a pair. */
226
+ const MAX_PAIR_CLOSE_FAILURES = 3;
16
227
  /**
17
228
  * Compute the estimated round-trip cost as a percentage of notional.
18
229
  * Round-trip = 4 × taker fee + 2 × slippage (entry + exit for both legs).
@@ -45,46 +256,55 @@ export function computeNetSpread(grossAnnualPct, holdDays, roundTripCostPct, bri
45
256
  return grossAnnualPct - annualizedCostPct - bridgeCostAnnualPct;
46
257
  }
47
258
  // ── Funding Settlement Timing ──
48
- import { SETTLEMENT_SCHEDULES } from "../arb-utils.js";
259
+ import { getLastSettlement, settlementIntervalHours } from "../arb-utils.js";
260
+ /** The perp-perp snapshot only carries mark prices for the four legacy venues. */
261
+ function unknownArbVenue(ex) {
262
+ throw new PerpError("UNKNOWN_VENUE", `No mark price column for venue "${ex}" in the arb snapshot`, {
263
+ remediation: "Restrict the scan to pacifica, hyperliquid, lighter, aster",
264
+ });
265
+ }
266
+ /** Per-venue mark-price column of a snapshot + short display label (data-driven; one entry per scanned venue). */
267
+ const SNAPSHOT_MARK_COLUMNS = {
268
+ pacifica: { key: "pacMarkPrice", label: "PAC" },
269
+ hyperliquid: { key: "hlMarkPrice", label: "HL" },
270
+ lighter: { key: "ltMarkPrice", label: "LT" },
271
+ aster: { key: "astMarkPrice", label: "AST" },
272
+ };
273
+ /** Hourly funding rate of one venue column of a snapshot (Aster uses the symbol's own interval). */
274
+ function snapshotHourly(s, ex) {
275
+ switch (ex) {
276
+ case "pacifica": return toHourlyRate(s.pacRate, ex);
277
+ case "hyperliquid": return toHourlyRate(s.hlRate, ex);
278
+ case "lighter": return toHourlyRate(s.ltRate, ex);
279
+ case "aster":
280
+ if (s.astHours === null) {
281
+ throw new PerpError("INVALID_PARAMS", `Aster funding interval for ${s.symbol} is unknown`, {
282
+ remediation: "Aster funding basis is per symbol (/fapi/v3/fundingInfo); the symbol was not listed there",
283
+ });
284
+ }
285
+ return toHourlyRateByBasis(s.astRate, { kind: "perHours", hours: s.astHours });
286
+ default: return unknownArbVenue(ex);
287
+ }
288
+ }
49
289
  /**
50
- * Get the next settlement time for an exchange.
51
- * @returns Date of next settlement
290
+ * Get the next settlement time for an exchange (schedule anchored at 00:00 UTC).
291
+ * `marketIntervalHours` is the market's settlement interval; required for
292
+ * per-market venues (Aster). Unknown venues throw UNKNOWN_VENUE.
293
+ * A settlement exactly at `now` counts as the next one.
52
294
  */
53
- export function getNextSettlement(exchange, now = new Date()) {
54
- const schedule = SETTLEMENT_SCHEDULES[exchange.toLowerCase()];
55
- if (!schedule || schedule.length === 0) {
56
- // Default: every hour
57
- return getNextSettlement("pacifica", now);
58
- }
59
- const currentHour = now.getUTCHours();
60
- const currentMinutes = now.getUTCMinutes();
61
- const currentSeconds = now.getUTCSeconds();
62
- // Find the next settlement hour strictly in the future
63
- // A settlement at the current hour is "next" only if we haven't reached it yet (min=0, sec=0)
64
- for (const hour of schedule) {
65
- if (hour > currentHour || (hour === currentHour && currentMinutes === 0 && currentSeconds === 0)) {
66
- // This settlement is still in the future (or exactly now)
67
- // But skip if hour === currentHour and we're past minute 0
68
- if (hour === currentHour && (currentMinutes > 0 || currentSeconds > 0))
69
- continue;
70
- const next = new Date(now);
71
- next.setUTCHours(hour, 0, 0, 0);
72
- return next;
73
- }
74
- }
75
- // Wrap to next day's first settlement
76
- const next = new Date(now);
77
- next.setUTCDate(next.getUTCDate() + 1);
78
- next.setUTCHours(schedule[0], 0, 0, 0);
79
- return next;
295
+ export function getNextSettlement(exchange, now = new Date(), marketIntervalHours = null) {
296
+ const last = getLastSettlement(exchange, now, marketIntervalHours);
297
+ if (last.getTime() === now.getTime())
298
+ return last;
299
+ return new Date(last.getTime() + settlementIntervalHours(exchange, marketIntervalHours) * 3_600_000);
80
300
  }
81
301
  /**
82
302
  * Check if we are within N minutes before a settlement event on either exchange.
83
303
  * If so, we should avoid entering positions (rates may change).
84
304
  */
85
- export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5, now = new Date()) {
305
+ export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5, now = new Date(), marketIntervalFor = () => null) {
86
306
  for (const exch of [longExchange, shortExchange]) {
87
- const nextSettle = getNextSettlement(exch, now);
307
+ const nextSettle = getNextSettlement(exch, now, marketIntervalFor(exch));
88
308
  const minutesUntil = (nextSettle.getTime() - now.getTime()) / (1000 * 60);
89
309
  if (minutesUntil <= bufferMinutes && minutesUntil >= 0) {
90
310
  return { blocked: true, exchange: exch, minutesUntil };
@@ -98,13 +318,16 @@ export function isNearSettlement(longExchange, shortExchange, bufferMinutes = 5,
98
318
  * meaning we're now paying on both sides instead of collecting.
99
319
  */
100
320
  export function isSpreadReversed(longExchange, shortExchange, snapshot) {
101
- const rateFor = (e) => e === "pacifica" ? snapshot.pacRate : e === "hyperliquid" ? snapshot.hlRate : e === "aster" ? snapshot.astRate : snapshot.ltRate;
102
- const longHourly = toHourlyRate(rateFor(longExchange), longExchange);
103
- const shortHourly = toHourlyRate(rateFor(shortExchange), shortExchange);
321
+ const longHourly = snapshotHourly(snapshot, longExchange);
322
+ const shortHourly = snapshotHourly(snapshot, shortExchange);
104
323
  // Reversed if the long side rate exceeds the short side rate
105
324
  return longHourly > shortHourly;
106
325
  }
326
+ const _warnedArbPriceMismatch = new Set();
107
327
  async function fetchFundingSpreads() {
328
+ // Selected network (review L7): the Pacifica / HL / Lighter raw fetchers follow -n via
329
+ // setSharedApiNetwork; Aster's REST base comes from the venue definition for that network.
330
+ const asterRest = networkConfig(getVenue("aster"), sharedApiNetwork()).restUrl;
108
331
  // SSOT rule #2: each DEX fetch is independent. allSettled lets us keep the
109
332
  // multi-DEX comparison alive when one DEX errors out, while the rejection
110
333
  // reasons get logged to stderr (explicit failure, never silent).
@@ -113,14 +336,26 @@ async function fetchFundingSpreads() {
113
336
  fetchHyperliquidMetaRaw(),
114
337
  fetchLighterOrderBookDetailsRaw(),
115
338
  fetchLighterFundingRatesRaw(),
116
- fetch("https://fapi.asterdex.com/fapi/v1/premiumIndex").then(r => r.json()),
339
+ fetch(`${asterRest}/fapi/v1/premiumIndex`).then(r => r.json()),
340
+ fetch(`${asterRest}/fapi/v3/fundingInfo`).then(r => r.json()),
117
341
  ]);
118
- logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex"], "arb-auto");
342
+ logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex", "aster:fundingInfo"], "arb-auto");
119
343
  const pacRes = settled[0].status === "fulfilled" ? settled[0].value : null;
120
344
  const hlRes = settled[1].status === "fulfilled" ? settled[1].value : null;
121
345
  const ltDetailsRes = settled[2].status === "fulfilled" ? settled[2].value : null;
122
346
  const ltFundingRes = settled[3].status === "fulfilled" ? settled[3].value : null;
123
- const astPremiums = settled[4].status === "fulfilled" ? settled[4].value : [];
347
+ // Both Aster calls are needed: a rate without its per-symbol interval cannot be normalized.
348
+ const astPremiums = settled[4].status === "fulfilled" && settled[5].status === "fulfilled"
349
+ ? settled[4].value
350
+ : [];
351
+ const astFundingInfo = settled[5].status === "fulfilled" ? settled[5].value : [];
352
+ const astHoursBySym = new Map();
353
+ for (const row of Array.isArray(astFundingInfo) ? astFundingInfo : []) {
354
+ const rawSym = String(row.symbol ?? "");
355
+ const hours = Number(row.fundingIntervalHours);
356
+ if (rawSym.endsWith("USDT") && Number.isFinite(hours) && hours > 0)
357
+ astHoursBySym.set(rawSym.replace(/USDT$/, ""), hours);
358
+ }
124
359
  const { rates: pacRates, prices: pacPrices } = parsePacificaRaw(pacRes);
125
360
  const { rates: hlRates, prices: hlPrices } = parseHyperliquidMetaRaw(hlRes);
126
361
  const { rates: ltRates, prices: ltPrices } = parseLighterRaw(ltDetailsRes, ltFundingRes);
@@ -131,6 +366,7 @@ async function fetchFundingSpreads() {
131
366
  // Skip the symbol entirely if either field is missing or non-finite.
132
367
  const astRates = new Map();
133
368
  const astPrices = new Map();
369
+ const astNoInterval = [];
134
370
  for (const p of astPremiums ?? []) {
135
371
  const rawSym = String(p.symbol ?? "");
136
372
  if (!rawSym.endsWith("USDT"))
@@ -143,9 +379,16 @@ async function fetchFundingSpreads() {
143
379
  // symbol; skip rather than poison downstream math with a 0.
144
380
  continue;
145
381
  }
382
+ if (!astHoursBySym.has(sym)) {
383
+ astNoInterval.push(sym);
384
+ continue;
385
+ }
146
386
  astRates.set(sym, fundingRate);
147
387
  astPrices.set(sym, markPrice);
148
388
  }
389
+ if (astNoInterval.length > 0) {
390
+ process.stderr.write(`[arb-auto] aster: ${astNoInterval.length} symbol(s) excluded — no fundingIntervalHours in /fapi/v3/fundingInfo: ${astNoInterval.slice(0, 10).join(", ")}${astNoInterval.length > 10 ? ", …" : ""}\n`);
391
+ }
149
392
  const snapshots = [];
150
393
  const allSymbols = new Set([...pacRates.keys(), ...hlRates.keys(), ...ltRates.keys(), ...astRates.keys()]);
151
394
  for (const sym of allSymbols) {
@@ -165,11 +408,53 @@ async function fetchFundingSpreads() {
165
408
  available.push({ exchange: "aster", rate: ast });
166
409
  if (available.length < 2)
167
410
  continue;
168
- const norm = (r) => toHourlyRate(r.rate, r.exchange);
411
+ // Same bare ticker on two venues is not proof of the same asset (e.g. PRL at
412
+ // a 167% price gap). Keep only venues whose mark is within
413
+ // MAX_PRICE_DEVIATION_PCT of the cross-venue median; a venue without a mark
414
+ // price cannot be checked and is dropped from the pairing.
415
+ const priceOf = (ex) => ex === "pacifica" ? pacPrices.get(sym) : ex === "hyperliquid" ? hlPrices.get(sym) : ex === "lighter" ? ltPrices.get(sym) : astPrices.get(sym);
416
+ const priced = available.filter(r => { const px = priceOf(r.exchange); return px !== undefined && px > 0; });
417
+ if (priced.length < 2)
418
+ continue;
419
+ const pxs = priced.map(r => priceOf(r.exchange)).sort((x, y) => x - y);
420
+ const medianPx = pxs.length % 2 === 1 ? pxs[(pxs.length - 1) / 2] : (pxs[pxs.length / 2 - 1] + pxs[pxs.length / 2]) / 2;
421
+ const consistent = priced.filter(r => {
422
+ const devPct = Math.abs(priceOf(r.exchange) - medianPx) / medianPx * 100;
423
+ if (devPct <= MAX_PRICE_DEVIATION_PCT)
424
+ return true;
425
+ const key = `${sym}:${r.exchange}`;
426
+ if (!_warnedArbPriceMismatch.has(key)) {
427
+ _warnedArbPriceMismatch.add(key);
428
+ process.stderr.write(`[arb-auto] excluded ${r.exchange} ${sym}: mark deviates ${devPct.toFixed(1)}% from cross-venue median (likely a different asset with the same ticker)\n`);
429
+ }
430
+ return false;
431
+ });
432
+ if (consistent.length < 2)
433
+ continue;
434
+ available.length = 0;
435
+ available.push(...consistent);
436
+ const astHours = astHoursBySym.get(sym) ?? null;
437
+ const norm = (r) => r.exchange === "aster"
438
+ ? toHourlyRateByBasis(r.rate, { kind: "perHours", hours: astHours }) // present whenever ast is (filtered above)
439
+ : toHourlyRate(r.rate, r.exchange);
169
440
  available.sort((a, b) => norm(a) - norm(b));
170
441
  const lowest = available[0];
171
442
  const highest = available[available.length - 1];
172
- const spread = computeAnnualSpread(highest.rate, highest.exchange, lowest.rate, lowest.exchange);
443
+ // Median filtering keeps venues near the centre, but the chosen pair itself
444
+ // must also agree on price (100 / 104.5 / 109 all pass the median test while
445
+ // the 100↔109 pair is 8.6% apart).
446
+ const pairLo = priceOf(lowest.exchange);
447
+ const pairHi = priceOf(highest.exchange);
448
+ const pairGapPct = Math.abs(pairLo - pairHi) / ((pairLo + pairHi) / 2) * 100;
449
+ if (pairGapPct > MAX_PRICE_DEVIATION_PCT) {
450
+ const key = `${sym}:${lowest.exchange}:${highest.exchange}`;
451
+ if (!_warnedArbPriceMismatch.has(key)) {
452
+ _warnedArbPriceMismatch.add(key);
453
+ process.stderr.write(`[arb-auto] excluded ${sym} ${lowest.exchange}↔${highest.exchange}: pair mark gap ${pairGapPct.toFixed(1)}% > ${MAX_PRICE_DEVIATION_PCT}%\n`);
454
+ }
455
+ continue;
456
+ }
457
+ const spread = Math.abs(norm(highest) - norm(lowest)) * 24 * 365 * 100;
173
458
  // Use best available mark price (prefer HL as most liquid, then PAC, then LT, then AST)
174
459
  const markPrice = hlPrices.get(sym) ?? pacPrices.get(sym) ?? ltPrices.get(sym) ?? astPrices.get(sym) ?? 0;
175
460
  snapshots.push({
@@ -186,6 +471,7 @@ async function fetchFundingSpreads() {
186
471
  hlMarkPrice: hlPrices.get(sym) ?? 0,
187
472
  ltMarkPrice: ltPrices.get(sym) ?? 0,
188
473
  astMarkPrice: astPrices.get(sym) ?? 0,
474
+ astHours: ast !== undefined ? astHours : null,
189
475
  });
190
476
  }
191
477
  return snapshots.sort((a, b) => Math.abs(b.spread) - Math.abs(a.spread));
@@ -196,10 +482,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
196
482
  const arb = program.commands.find(c => c.name() === "arb");
197
483
  if (!arb)
198
484
  return;
485
+ // Quarantine checks / entries of every arb command are scoped to its -n network.
486
+ arb.hook("preAction", (_cmd, action) => setQuarantineNetwork(networkOf(action)));
199
487
  // ── arb auto ── (daemon mode)
200
- arb
201
- .command("auto")
202
- .description("Auto-execute funding rate arbitrage (daemon). Use --hip3 for HIP-3 cross-dex mode.")
488
+ supportsDryRun(arb.command("auto"))
489
+ .description("Auto-execute funding rate arbitrage (daemon). Use --hip3 for HIP-3 cross-dex mode. Supports --dry-run (also inherited by --background).")
203
490
  .option("--min-spread <pct>", "Min annual spread to enter (%)", "30")
204
491
  .option("--close-spread <pct>", "Close when spread drops below (%)", "5")
205
492
  .option("--size <usd>", "Position size per leg ($)", "100")
@@ -218,8 +505,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
218
505
  .option("--notify <url>", "Webhook URL for notifications (Discord/Telegram/generic)")
219
506
  .option("--notify-events <events>", "Comma-separated events: entry,exit,reversal,margin,basis", "entry,exit,reversal,margin,basis")
220
507
  .option("--cooldown <minutes>", "Minutes to wait before re-entering a closed symbol", "30")
221
- .option("--dry-run", "Simulate without executing trades")
222
508
  .option("--background", "Run in background (tmux)")
509
+ .option("--job-id <id>", "Job ID (set by the background runner)")
223
510
  .option("--hip3", "Run HIP-3 cross-dex auto arb instead of perp-perp")
224
511
  .option("--min-oi <usd>", "Min OI (USD) to enter (hip3 mode)", "50000")
225
512
  .option("--min-grade <grade>", "Min viability grade: A, B, C, D (hip3 mode)", "C")
@@ -234,12 +521,13 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
234
521
  minOi: opts.minOi,
235
522
  minGrade: opts.minGrade,
236
523
  interval: opts.interval,
237
- dryRun: opts.dryRun,
524
+ dryRun: isDryRun(program),
238
525
  });
239
526
  }
240
527
  if (opts.background) {
241
- const { startJob } = await import("../jobs.js");
528
+ const { startJob, jobSelectorsOf } = await import("../jobs.js");
242
529
  const cliArgs = [
530
+ `arb`, `auto`,
243
531
  `--min-spread`, opts.minSpread,
244
532
  `--close-spread`, opts.closeSpread,
245
533
  `--size`, opts.size,
@@ -250,14 +538,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
250
538
  `--min-margin`, opts.minMargin,
251
539
  `--cooldown`, opts.cooldown,
252
540
  ...(opts.symbols ? [`--symbols`, opts.symbols] : []),
253
- ...(opts.dryRun ? [`--dry-run`] : []),
254
541
  ...(opts.reversalExit === false ? [`--no-reversal-exit`] : []),
255
542
  ...(opts.settleAware === false ? [`--no-settle-aware`] : []),
256
- ...(opts.minSpread ? [`--auto-execute`] : []),
543
+ `--min-size`, opts.minSize,
544
+ `--settle-strategy`, opts.settleStrategy,
545
+ `--max-basis`, opts.maxBasis,
546
+ `--notify-events`, opts.notifyEvents,
547
+ ...(opts.notify ? [`--notify`, opts.notify] : []),
257
548
  ];
258
549
  const job = startJob({
259
550
  strategy: "funding-arb",
260
551
  exchange: "multi",
552
+ // The background child inherits the root -n and --dry-run.
553
+ network: networkOf(program), dryRun: isDryRun(program), selectors: jobSelectorsOf(program),
261
554
  params: { ...opts },
262
555
  cliArgs,
263
556
  });
@@ -289,13 +582,15 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
289
582
  const minMarginPct = parseFloat(opts.minMargin);
290
583
  const minSizeUsd = parseFloat(opts.minSize);
291
584
  const filterSymbols = opts.symbols?.split(",").map(s => s.trim().toUpperCase());
292
- const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
585
+ const dryRun = isDryRun(program);
293
586
  const cooldownMinutes = parseFloat(opts.cooldown);
294
587
  const openPositions = [];
295
588
  // Track which exchanges have low margin (block entries)
296
589
  const blockedExchanges = new Set();
297
590
  // Track close times per symbol to prevent close→re-entry loops
298
591
  const closeCooldowns = new Map(); // symbol → close timestamp ms
592
+ // Consecutive definite close failures per symbol (quarantined at MAX_PAIR_CLOSE_FAILURES)
593
+ const closeFailures = new Map();
299
594
  // -- State Persistence: Initialize or recover --
300
595
  const daemonConfig = {
301
596
  minSpread,
@@ -306,10 +601,59 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
306
601
  maxPositions,
307
602
  settleStrategy: settleAwareEnabled ? "aware" : "disabled",
308
603
  };
309
- let daemonState = loadArbState();
604
+ // The daemon manages perp-perp positions only. Spot-perp positions (persisted by
605
+ // `arb exec spot:<venue> ...`) are closed with `perp arb close <symbol>` (spot-perp
606
+ // path) — never as perp-perp. Warn once per symbol.
607
+ const warnedSpotPerp = new Set();
608
+ const isDaemonManaged = (p) => {
609
+ if (p.mode !== "spot-perp")
610
+ return true;
611
+ if (!warnedSpotPerp.has(p.symbol)) {
612
+ warnedSpotPerp.add(p.symbol);
613
+ console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} SKIP: ${p.symbol} is a spot-perp position (spot ${p.spotExchange ?? p.longExchange} / perp ${p.shortExchange}) — not managed by this daemon; close it with: perp arb close ${p.symbol}`));
614
+ }
615
+ return false;
616
+ };
617
+ // State access for this daemon. Dry-run NEVER writes the real per-network arb state
618
+ // (review N3): it works on an in-memory copy seeded read-only from the real state, so a
619
+ // simulated entry is never picked up by a live run and a simulated close never drops a
620
+ // real position from tracking.
621
+ const st = createDaemonStateStore(networkOf(program), dryRun, daemonConfig);
622
+ // State entries the venues do not confirm (no long on longExchange + short on shortExchange):
623
+ // never counted toward --max-positions, never managed; they still block a new entry on the
624
+ // same symbol (the state is keyed by symbol). Drop one with: perp arb close <sym> --state-only.
625
+ const staleSymbols = new Set();
626
+ const confirmedOnVenues = async (p) => {
627
+ try {
628
+ const [longs, shorts] = await Promise.all([
629
+ (await getAdapterForExchange(p.longExchange)).getPositions(),
630
+ (await getAdapterForExchange(p.shortExchange)).getPositions(),
631
+ ]);
632
+ const has = (list, side) => list.some(x => symbolMatch(x.symbol, p.symbol) && x.side === side && Math.abs(Number(x.size)) > 0);
633
+ if (has(longs, "long") && has(shorts, "short")) {
634
+ staleSymbols.delete(p.symbol);
635
+ return true;
636
+ }
637
+ if (!staleSymbols.has(p.symbol)) {
638
+ console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} STALE STATE: ${p.symbol} (long ${p.longExchange} / short ${p.shortExchange}, since ${p.entryTime}) is not on the venues — not counted, not managed; drop it: perp arb close ${p.symbol} --state-only`));
639
+ }
640
+ staleSymbols.add(p.symbol);
641
+ return false;
642
+ }
643
+ catch (e) {
644
+ // Unknown (read failed): counted, so a possibly-live pair is never doubled.
645
+ console.log(chalk.yellow(` ${new Date().toLocaleTimeString()} ${p.symbol}: could not confirm the state entry on the venues (${e instanceof Error ? e.message : String(e)}) — counted as open`));
646
+ return true;
647
+ }
648
+ };
649
+ let daemonState = st.load();
310
650
  if (daemonState && daemonState.positions.length > 0) {
311
- // Crash recovery: restore positions from persisted state
651
+ // Crash recovery: restore positions from persisted state — only those the venues confirm.
312
652
  for (const persisted of daemonState.positions) {
653
+ if (!isDaemonManaged(persisted))
654
+ continue;
655
+ if (!dryRun && !(await confirmedOnVenues(persisted)))
656
+ continue;
313
657
  openPositions.push({
314
658
  symbol: persisted.symbol,
315
659
  longExchange: persisted.longExchange,
@@ -323,30 +667,23 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
323
667
  });
324
668
  }
325
669
  daemonState.lastStartTime = new Date().toISOString();
326
- saveArbState(daemonState);
670
+ st.save(daemonState);
327
671
  if (!isJson()) {
328
- console.log(chalk.yellow(` Recovered ${openPositions.length} position(s) from previous session.`));
672
+ console.log(chalk.yellow(` Recovered ${openPositions.length} perp-perp position(s) from previous session.`));
329
673
  }
330
674
  }
331
675
  else {
332
676
  daemonState = createInitialState(daemonConfig);
333
- saveArbState(daemonState);
334
- }
335
- // SIGINT handler: clean up interval and save final state before exit
336
- let cycleInterval = null;
337
- const handleSigint = () => {
338
- if (cycleInterval)
339
- clearInterval(cycleInterval);
340
- const finalState = loadArbState();
341
- if (finalState) {
342
- finalState.lastScanTime = new Date().toISOString();
343
- saveArbState(finalState);
344
- }
345
- if (!isJson())
346
- console.log(chalk.yellow("\n Daemon stopped. State saved.\n"));
347
- process.exit(0);
348
- };
349
- process.on("SIGINT", handleSigint);
677
+ st.save(daemonState);
678
+ }
679
+ // A previous daemon that died between two legs (crash / kill) left its pending-entry
680
+ // record: what is open is unknown → quarantine the symbol (never re-entered blind).
681
+ if (!dryRun)
682
+ quarantineOrphanedEntries(networkOf(program), "arb-auto", (m) => console.log(chalk.red(m)));
683
+ // Graceful stop (SIGINT / SIGTERM — `perp background stop`): the current cycle finishes the
684
+ // leg sequence (or rollback) it is in, starts nothing new, then the state is saved and the
685
+ // daemon exits. Never an exit between two legs.
686
+ const stop = gracefulStop("arb auto");
350
687
  if (!isJson()) {
351
688
  console.log(chalk.cyan.bold("\n Funding Rate Arb Bot\n"));
352
689
  console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
@@ -370,7 +707,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
370
707
  }
371
708
  const cycle = async () => {
372
709
  // Sync in-memory positions with persisted state (handles CLI manual closes)
373
- const syncState = loadArbState();
710
+ const syncState = st.load();
374
711
  if (syncState) {
375
712
  const persistedSymbols = new Set(syncState.positions.map(p => p.symbol));
376
713
  // Remove positions that were closed externally (e.g. via `perp arb close`)
@@ -382,7 +719,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
382
719
  }
383
720
  // Recover positions that exist in state but not in memory (e.g. added by another process)
384
721
  for (const persisted of syncState.positions) {
722
+ if (!isDaemonManaged(persisted))
723
+ continue;
385
724
  if (!openPositions.some(p => p.symbol === persisted.symbol)) {
725
+ if (!dryRun && !(await confirmedOnVenues(persisted)))
726
+ continue;
386
727
  console.log(chalk.gray(` ${new Date().toLocaleTimeString()} SYNC: recovering ${persisted.symbol} from state`));
387
728
  openPositions.push({
388
729
  symbol: persisted.symbol,
@@ -399,7 +740,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
399
740
  }
400
741
  }
401
742
  // Heartbeat check
402
- const heartbeatState = loadArbState();
743
+ const heartbeatState = st.load();
403
744
  if (heartbeatState?.lastSuccessfulScanTime) {
404
745
  const lastSuccessMs = new Date(heartbeatState.lastSuccessfulScanTime).getTime();
405
746
  const minutesSinceSuccess = (Date.now() - lastSuccessMs) / (1000 * 60);
@@ -419,160 +760,150 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
419
760
  const now = new Date().toLocaleTimeString();
420
761
  // Check for close conditions on open positions
421
762
  for (let i = openPositions.length - 1; i >= 0; i--) {
763
+ if (stop.requested())
764
+ break; // stopping: no new close sequence
422
765
  const pos = openPositions[i];
423
- const current = filtered.find(s => s.symbol === pos.symbol);
424
- if (!current)
766
+ // Quarantined (design §12.3): hands off until a human clears it.
767
+ if (isQuarantined(pos.symbol, (m) => console.log(chalk.red(m))))
425
768
  continue;
426
- const currentSpread = Math.abs(current.spread);
427
- let shouldClose = false;
428
- let closeReason = "";
429
- // Check spread-based close
430
- if (currentSpread <= closeSpread) {
431
- shouldClose = true;
432
- closeReason = `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
433
- }
434
- // Check reversal-based close
435
- if (!shouldClose && reversalExitEnabled && isSpreadReversed(pos.longExchange, pos.shortExchange, current)) {
436
- shouldClose = true;
437
- closeReason = "REVERSAL DETECTED — long exchange now has higher rate than short";
438
- await notifyIfEnabled(webhookUrl, notifyEvents, "reversal", {
439
- symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
440
- });
441
- }
442
- // Check basis risk (mark price divergence) — use prices from fetchFundingSpreads, no extra API calls
443
- {
444
- const priceFor = (e) => e === "pacifica" ? current.pacMarkPrice : e === "hyperliquid" ? current.hlMarkPrice : current.ltMarkPrice;
445
- const bLP = priceFor(pos.longExchange);
446
- const bSP = priceFor(pos.shortExchange);
447
- if (bLP > 0 && bSP > 0) {
448
- const basis = computeBasisRisk(bLP, bSP, maxBasisPct);
449
- if (basis.warning) {
450
- const bExA = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
451
- console.log(chalk.yellow(` ${now} BASIS RISK ${pos.symbol}: Long ${bExA(pos.longExchange)} $${bLP.toFixed(4)} / ` +
452
- `Short ${bExA(pos.shortExchange)} $${bSP.toFixed(4)} | Divergence: ${basis.divergencePct.toFixed(1)}%`));
453
- await notifyIfEnabled(webhookUrl, notifyEvents, "basis", {
454
- symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
455
- divergencePct: basis.divergencePct,
456
- });
457
- }
769
+ // Per-position isolation: one position's failing exit check (e.g. an
770
+ // unknown funding basis in snapshotHourly) must not skip the others.
771
+ try {
772
+ const current = filtered.find(s => s.symbol === pos.symbol);
773
+ if (!current)
774
+ continue;
775
+ const currentSpread = Math.abs(current.spread);
776
+ let shouldClose = false;
777
+ let closeReason = "";
778
+ // Check spread-based close
779
+ if (currentSpread <= closeSpread) {
780
+ shouldClose = true;
781
+ closeReason = `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
458
782
  }
459
- }
460
- if (shouldClose) {
461
- console.log(chalk.yellow(` ${now} CLOSE ${pos.symbol} — ${closeReason}`));
462
- const exitReason = closeReason.includes("REVERSAL") ? "reversal"
463
- : closeReason.includes("spread") ? "spread"
464
- : "manual";
465
- if (!dryRun) {
466
- try {
467
- const longAdapter = await getAdapterForExchange(pos.longExchange);
468
- const shortAdapter = await getAdapterForExchange(pos.shortExchange);
469
- // Close both legs simultaneously with verification
470
- const [longResult, shortResult] = await Promise.allSettled([
471
- longAdapter.marketOrder(pos.symbol, "sell", String(pos.size)),
472
- shortAdapter.marketOrder(pos.symbol, "buy", String(pos.size)),
473
- ]);
474
- const longOk = longResult.status === "fulfilled";
475
- const shortOk = shortResult.status === "fulfilled";
476
- if (longOk && shortOk) {
477
- logExecution({
478
- type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
479
- symbol: pos.symbol, side: "close", size: String(pos.size),
480
- status: "success", dryRun: false,
481
- meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
482
- });
483
- console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs`));
484
- persistRemovePosition(pos.symbol);
485
- closeCooldowns.set(pos.symbol, Date.now());
486
- openPositions.splice(i, 1);
487
- await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
783
+ // Check reversal-based close
784
+ if (!shouldClose && reversalExitEnabled && isSpreadReversed(pos.longExchange, pos.shortExchange, current)) {
785
+ shouldClose = true;
786
+ closeReason = "REVERSAL DETECTED — long exchange now has higher rate than short";
787
+ await notifyIfEnabled(webhookUrl, notifyEvents, "reversal", {
788
+ symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
789
+ });
790
+ }
791
+ // Check basis risk (mark price divergence) — use prices from fetchFundingSpreads, no extra API calls
792
+ {
793
+ const longCol = SNAPSHOT_MARK_COLUMNS[pos.longExchange];
794
+ const shortCol = SNAPSHOT_MARK_COLUMNS[pos.shortExchange];
795
+ const bLP = longCol ? current[longCol.key] : 0;
796
+ const bSP = shortCol ? current[shortCol.key] : 0;
797
+ if (!longCol || !shortCol) {
798
+ const missing = [!longCol ? pos.longExchange : null, !shortCol ? pos.shortExchange : null].filter(Boolean).join(", ");
799
+ console.log(chalk.yellow(` ${now} BASIS CHECK SKIPPED ${pos.symbol}: no mark price column for venue ${missing}`));
800
+ }
801
+ else if (bLP > 0 && bSP > 0) {
802
+ const basis = computeBasisRisk(bLP, bSP, maxBasisPct);
803
+ if (basis.warning) {
804
+ console.log(chalk.yellow(` ${now} BASIS RISK ${pos.symbol}: Long ${longCol.label} $${bLP.toFixed(4)} / ` +
805
+ `Short ${shortCol.label} $${bSP.toFixed(4)} | Divergence: ${basis.divergencePct.toFixed(1)}%`));
806
+ await notifyIfEnabled(webhookUrl, notifyEvents, "basis", {
488
807
  symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
489
- pnl: pos.accumulatedFundingUsd,
490
- duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
808
+ divergencePct: basis.divergencePct,
491
809
  });
492
810
  }
493
- else if (longOk !== shortOk) {
494
- // One leg closed, one failed — retry the failed leg
495
- const failedSide = longOk ? "short" : "long";
496
- const failedAdapter = longOk ? shortAdapter : longAdapter;
497
- const retryAction = longOk ? "buy" : "sell";
498
- const failedErr = longOk
499
- ? shortResult.reason
500
- : longResult.reason;
501
- console.log(chalk.yellow(` ${now} PARTIAL CLOSE ${pos.symbol}: ${failedSide} leg failed — retrying...`));
502
- let retryOk = false;
503
- for (let attempt = 1; attempt <= 2; attempt++) {
504
- try {
505
- await failedAdapter.marketOrder(pos.symbol, retryAction, String(pos.size));
506
- retryOk = true;
507
- console.log(chalk.green(` ${now} RETRY OK ${pos.symbol}: ${failedSide} leg closed (attempt ${attempt})`));
508
- break;
509
- }
510
- catch (retryErr) {
511
- console.log(chalk.red(` ${now} RETRY ${attempt}/2 FAILED ${pos.symbol}: ${retryErr instanceof Error ? retryErr.message : retryErr}`));
512
- }
513
- }
514
- if (retryOk) {
811
+ }
812
+ }
813
+ if (shouldClose) {
814
+ console.log(chalk.yellow(` ${now} CLOSE ${pos.symbol} — ${closeReason}`));
815
+ const exitReason = closeReason.includes("REVERSAL") ? "reversal"
816
+ : closeReason.includes("spread") ? "spread"
817
+ : "manual";
818
+ if (!dryRun) {
819
+ try {
820
+ const longAdapter = await getAdapterForExchange(pos.longExchange);
821
+ const shortAdapter = await getAdapterForExchange(pos.shortExchange);
822
+ // Sequential, reduce-only, verified (design §12.3): long first, then
823
+ // short. A definite long-close failure changes nothing → retried next
824
+ // cycle, quarantined after MAX_PAIR_CLOSE_FAILURES in a row. An unknown
825
+ // outcome, a partial close or an unhedged leg quarantines (no retry).
826
+ const sizeStr = String(pos.size);
827
+ const closeRes = await closePerpPairVerified({
828
+ source: "arb-auto", sym: pos.symbol,
829
+ longAdapter, longExch: pos.longExchange, longSym: pos.symbol, longSize: sizeStr,
830
+ shortAdapter, shortExch: pos.shortExchange, shortSym: pos.symbol, shortSize: sizeStr, smart: false,
831
+ log: (m) => console.log(chalk.red(m)),
832
+ });
833
+ if (closeRes.status === "closed") {
834
+ closeFailures.delete(pos.symbol);
515
835
  logExecution({
516
836
  type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
517
- symbol: pos.symbol, side: "close", size: String(pos.size),
837
+ symbol: pos.symbol, side: "close", size: sizeStr,
518
838
  status: "success", dryRun: false,
519
- meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason, retried: failedSide },
839
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, currentSpread, reason: closeReason, exitReason },
520
840
  });
521
- persistRemovePosition(pos.symbol);
841
+ console.log(chalk.green(` ${now} CLOSED ${pos.symbol} — both legs (reduce-only, verified)`));
842
+ st.remove(pos.symbol);
522
843
  closeCooldowns.set(pos.symbol, Date.now());
523
844
  openPositions.splice(i, 1);
845
+ await notifyIfEnabled(webhookUrl, notifyEvents, "exit", {
846
+ symbol: pos.symbol, longExchange: pos.longExchange, shortExchange: pos.shortExchange,
847
+ pnl: pos.accumulatedFundingUsd,
848
+ duration: `${Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 3600000)}h`,
849
+ });
850
+ }
851
+ else if (closeRes.status === "failed") {
852
+ const n = (closeFailures.get(pos.symbol) ?? 0) + 1;
853
+ closeFailures.set(pos.symbol, n);
854
+ logExecution({
855
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
856
+ symbol: pos.symbol, side: "close", size: sizeStr,
857
+ status: "failed", dryRun: false,
858
+ error: `${closeRes.reason} (attempt ${n}/${MAX_PAIR_CLOSE_FAILURES}, nothing changed)`,
859
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
860
+ });
861
+ console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${closeRes.reason} — both legs still open (${n}/${MAX_PAIR_CLOSE_FAILURES})`));
862
+ if (n >= MAX_PAIR_CLOSE_FAILURES) {
863
+ quarantinePair("arb-auto", pos.symbol, pos.longExchange, pos.shortExchange, `close failed ${n} times in a row (last: ${closeRes.reason})`, (m) => console.log(chalk.red(m)));
864
+ }
865
+ // Do NOT splice — kept tracked; reduce-only makes the next attempt safe
524
866
  }
525
867
  else {
526
- // One leg is closed, other still open — log but DON'T remove from tracking
868
+ // Quarantined: kept in state (accurate: exposure may remain); hands off until cleared.
527
869
  logExecution({
528
870
  type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
529
- symbol: pos.symbol, side: "close", size: String(pos.size),
871
+ symbol: pos.symbol, side: "close", size: sizeStr,
530
872
  status: "failed", dryRun: false,
531
- error: `Partial close: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Retry failed.`,
532
- meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason, partialClose: true },
873
+ error: `QUARANTINED: ${closeRes.reason}`,
874
+ meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason, quarantined: true },
533
875
  });
534
- console.log(chalk.red.bold(` ${now} PARTIAL CLOSE ${pos.symbol}: ${failedSide} still open — MANUAL CLOSE REQUIRED`));
876
+ console.log(chalk.red.bold(` ${now} CLOSE ${pos.symbol} QUARANTINED — ${closeRes.reason}. MANUAL CHECK REQUIRED`));
535
877
  await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
536
- exchange: longOk ? pos.shortExchange : pos.longExchange,
878
+ exchange: `${pos.longExchange}+${pos.shortExchange}`,
537
879
  marginPct: 0, threshold: 0, symbol: pos.symbol,
538
- message: `PARTIAL CLOSE: ${failedSide} leg still open after retry. Manual close required.`,
880
+ message: `CLOSE QUARANTINED: ${closeRes.reason}. Manual check required.`,
539
881
  });
540
- // Do NOT splice — keep tracking so next cycle retries
541
882
  }
542
883
  }
543
- else {
544
- // Both legs failed — keep position, retry next cycle
545
- const longErr = longResult.reason;
546
- const shortErr = shortResult.reason;
884
+ catch (err) {
547
885
  logExecution({
548
886
  type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
549
887
  symbol: pos.symbol, side: "close", size: String(pos.size),
550
888
  status: "failed", dryRun: false,
551
- error: `Both legs failed. Long: ${longErr instanceof Error ? longErr.message : String(longErr)}, Short: ${shortErr instanceof Error ? shortErr.message : String(shortErr)}`,
889
+ error: err instanceof Error ? err.message : String(err),
552
890
  meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
553
891
  });
554
- console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: both legs — will retry next cycle`));
555
- // Do NOT splice — retry next cycle
892
+ console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
893
+ // Do NOT splice — nothing was sent (adapter creation failed); retry next cycle
556
894
  }
557
895
  }
558
- catch (err) {
559
- logExecution({
560
- type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`,
561
- symbol: pos.symbol, side: "close", size: String(pos.size),
562
- status: "failed", dryRun: false,
563
- error: err instanceof Error ? err.message : String(err),
564
- meta: { longExchange: pos.longExchange, shortExchange: pos.shortExchange, reason: closeReason, exitReason },
565
- });
566
- console.error(chalk.red(` ${now} CLOSE FAILED ${pos.symbol}: ${err instanceof Error ? err.message : err}`));
567
- // Do NOT splice — retry next cycle
896
+ else {
897
+ // Dry run — remove from the in-memory (simulated) tracking only
898
+ st.remove(pos.symbol);
899
+ closeCooldowns.set(pos.symbol, Date.now());
900
+ openPositions.splice(i, 1);
568
901
  }
569
902
  }
570
- else {
571
- // Dry run — remove from tracking
572
- persistRemovePosition(pos.symbol);
573
- closeCooldowns.set(pos.symbol, Date.now());
574
- openPositions.splice(i, 1);
575
- }
903
+ }
904
+ catch (err) {
905
+ const msg = err instanceof Error ? err.message : String(err);
906
+ console.error(chalk.red(` ${new Date().toLocaleTimeString()} EXIT CHECK FAILED ${pos.symbol} (${pos.longExchange}/${pos.shortExchange}): ${msg}`));
576
907
  }
577
908
  }
578
909
  // Log next settlement times and funding estimation
@@ -663,13 +994,53 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
663
994
  }
664
995
  }
665
996
  catch { /* margin check failed, continue without blocking */ }
666
- // Check for entry conditions
997
+ // Entry decisions: every monitored symbol that is not entered gets its reason — logged per
998
+ // symbol for --symbols, summarized per cycle for a full scan (a silent run is impossible).
999
+ const skipCounts = new Map();
1000
+ const skip = (sym, reason) => {
1001
+ if (filterSymbols)
1002
+ console.log(chalk.gray(` ${now} SKIP ${sym}: ${reason}`));
1003
+ else {
1004
+ const k = reason.replace(/[-\d.]+/g, "#");
1005
+ skipCounts.set(k, (skipCounts.get(k) ?? 0) + 1);
1006
+ }
1007
+ };
1008
+ for (const sym of filterSymbols ?? []) {
1009
+ if (!filtered.some(x => x.symbol === sym))
1010
+ console.log(chalk.gray(` ${now} SKIP ${sym}: not in this cycle's funding scan (no rate on two compared venues)`));
1011
+ }
1012
+ if (openPositions.length >= maxPositions) {
1013
+ console.log(chalk.gray(` ${now} NO ENTRY: max positions reached (${openPositions.length}/${maxPositions}: ${openPositions.map(p => `${p.symbol} ${p.longExchange}/${p.shortExchange}`).join(", ")})`));
1014
+ }
667
1015
  if (openPositions.length < maxPositions) {
668
1016
  for (const snap of filtered) {
669
- if (openPositions.some(p => p.symbol === snap.symbol))
1017
+ if (stop.requested()) {
1018
+ console.log(chalk.yellow(` ${now} STOP requested — no new entries`));
1019
+ break;
1020
+ }
1021
+ if (openPositions.some(p => p.symbol === snap.symbol)) {
1022
+ skip(snap.symbol, "already open");
670
1023
  continue;
671
- if (openPositions.length >= maxPositions)
1024
+ }
1025
+ // Global quarantine (design §12.3): unknown / unhedged outcome on this symbol somewhere.
1026
+ if (isQuarantined(snap.symbol, (m) => console.log(chalk.red(m)))) {
1027
+ skip(snap.symbol, "quarantined");
1028
+ continue;
1029
+ }
1030
+ // A persisted spot-perp position on this symbol (not daemon-managed) blocks entry:
1031
+ // the state is keyed by symbol, so a new perp-perp entry would overwrite it.
1032
+ if (syncState?.positions.some(p => p.mode === "spot-perp" && p.symbol === snap.symbol)) {
1033
+ skip(snap.symbol, "a spot-perp arb state entry exists for this symbol");
1034
+ continue;
1035
+ }
1036
+ if (staleSymbols.has(snap.symbol)) {
1037
+ skip(snap.symbol, `a stale arb state entry exists (drop it: perp arb close ${snap.symbol} --state-only)`);
1038
+ continue;
1039
+ }
1040
+ if (openPositions.length >= maxPositions) {
1041
+ skip(snap.symbol, `max positions reached (${maxPositions})`);
672
1042
  break;
1043
+ }
673
1044
  // Cooldown: skip if recently closed to prevent close→re-entry loops
674
1045
  const lastCloseMs = closeCooldowns.get(snap.symbol);
675
1046
  if (lastCloseMs && cooldownMinutes > 0) {
@@ -687,18 +1058,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
687
1058
  const shortExchange = snap.shortExch;
688
1059
  // Block entries if either exchange has low margin
689
1060
  if (blockedExchanges.has(longExchange) || blockedExchanges.has(shortExchange)) {
690
- if (!isJson()) {
691
- console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: margin too low on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange}`));
692
- }
1061
+ skip(snap.symbol, `entries blocked on ${blockedExchanges.has(longExchange) ? longExchange : shortExchange} (margin low / venue down)`);
693
1062
  continue;
694
1063
  }
695
1064
  // Compute net spread after fees and bridge costs
696
1065
  const roundTripCost = computeRoundTripCostPct(longExchange, shortExchange);
697
1066
  const effectiveSizeUsd = sizeIsAuto ? 100 : sizeUsd; // Use $100 for net spread calc when auto
698
1067
  const netSpread = computeNetSpread(grossSpread, holdDays, roundTripCost, bridgeCostUsd, effectiveSizeUsd);
699
- // Settlement timing check with strategy
1068
+ // Settlement timing check with strategy (Aster interval is per symbol)
1069
+ const snapMarketInterval = (ex) => ex === "aster" ? snap.astHours : null;
700
1070
  if (settleStrategy === "block") {
701
- const settlCheck = isNearSettlement(longExchange, shortExchange);
1071
+ const settlCheck = isNearSettlement(longExchange, shortExchange, 5, new Date(), snapMarketInterval);
702
1072
  if (settlCheck.blocked) {
703
1073
  console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: ${settlCheck.minutesUntil?.toFixed(1)}m before ${settlCheck.exchange} settlement`));
704
1074
  continue;
@@ -707,15 +1077,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
707
1077
  // In aggressive mode, boost score for post-settlement entries
708
1078
  let settleBoostMultiplier = 1.0;
709
1079
  if (settleStrategy === "aggressive") {
710
- settleBoostMultiplier = aggressiveSettleBoost(longExchange, shortExchange, 10, new Date());
1080
+ settleBoostMultiplier = aggressiveSettleBoost(longExchange, shortExchange, 10, new Date(), snapMarketInterval);
711
1081
  if (settleBoostMultiplier > 1.0) {
712
1082
  console.log(chalk.cyan(` ${now} BOOST ${snap.symbol}: post-settlement ${((settleBoostMultiplier - 1) * 100).toFixed(0)}% score boost`));
713
1083
  }
714
1084
  }
715
1085
  // --min-spread compares against NET spread (with settle boost applied)
716
1086
  const effectiveNetSpread = netSpread * settleBoostMultiplier;
717
- if (effectiveNetSpread < minSpread)
1087
+ if (effectiveNetSpread < minSpread) {
1088
+ skip(snap.symbol, `net spread ${effectiveNetSpread.toFixed(2)}% < --min-spread ${minSpread}% (gross ${grossSpread.toFixed(2)}%, round-trip cost ${roundTripCost.toFixed(3)}% over ${holdDays}d; long ${longExchange} / short ${shortExchange})`);
718
1089
  continue;
1090
+ }
719
1091
  const rateForExch = (e) => e === "pacifica" ? snap.pacRate : e === "hyperliquid" ? snap.hlRate : e === "aster" ? snap.astRate : snap.ltRate;
720
1092
  console.log(chalk.green(` ${now} ENTER ${snap.symbol} — gross ${grossSpread.toFixed(1)}% net ${netSpread.toFixed(1)}%` +
721
1093
  ` | Long ${longExchange} (${(rateForExch(longExchange) * 100).toFixed(4)}%)` +
@@ -747,126 +1119,106 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
747
1119
  console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size $${actualSizeUsd} below min $${minSizeUsd}`));
748
1120
  continue;
749
1121
  }
750
- const sizeInAsset = (actualSizeUsd / snap.markPrice).toFixed(4);
1122
+ // Floored (never rounds up past the requested notional).
1123
+ const sizeInAsset = fillSizeString(actualSizeUsd / snap.markPrice, 4);
1124
+ if (!(Number(sizeInAsset) > 0)) {
1125
+ console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: size rounds to 0 ($${actualSizeUsd} / $${snap.markPrice})`));
1126
+ continue;
1127
+ }
1128
+ /** Size actually hedged on both legs (verified); the requested size in dry-run. */
1129
+ let openedSize = sizeInAsset;
751
1130
  if (!dryRun) {
1131
+ // Pending-entry record BEFORE the first leg (crash between legs → quarantine at the next start).
1132
+ const net = networkOf(program);
1133
+ try {
1134
+ writePendingEntry(net, { source: "arb-auto", symbol: snap.symbol, longExchange, shortExchange, size: sizeInAsset });
1135
+ }
1136
+ catch (e) {
1137
+ console.error(chalk.red(` ${now} SKIP ${snap.symbol}: could not record the pending entry (${e instanceof Error ? e.message : String(e)}) — nothing sent`));
1138
+ continue;
1139
+ }
1140
+ const donePending = () => finishPendingEntry(net, "arb-auto", snap.symbol, (m) => console.error(chalk.red(m)));
1141
+ let legsStarted = false;
752
1142
  try {
753
1143
  const longAdapter = await getAdapterForExchange(longExchange);
754
1144
  const shortAdapter = await getAdapterForExchange(shortExchange);
755
- // Use Promise.allSettled for safe dual-leg entry
756
- const [longResult, shortResult] = await Promise.allSettled([
757
- longAdapter.marketOrder(snap.symbol, "buy", sizeInAsset),
758
- shortAdapter.marketOrder(snap.symbol, "sell", sizeInAsset),
759
- ]);
760
- const longOk = longResult.status === "fulfilled";
761
- const shortOk = shortResult.status === "fulfilled";
762
- if (longOk && shortOk) {
763
- // Both legs filled successfully
1145
+ // Sequential verified legs (design §12.3): long, then short sized to the
1146
+ // long's verified fill; reduce-only rollback / trim; unknown → quarantine.
1147
+ legsStarted = true;
1148
+ const res = await openPerpPairVerified({
1149
+ source: "arb-auto", sym: snap.symbol,
1150
+ longAdapter, longExch: longExchange, longSym: snap.symbol, shortAdapter, shortExch: shortExchange, shortSym: snap.symbol,
1151
+ size: sizeInAsset, smart: false, log: (m) => console.log(chalk.red(m)),
1152
+ });
1153
+ legsStarted = false; // outcome known (opened / not opened / quarantined)
1154
+ if (res.status === "opened") {
1155
+ openedSize = res.hedged;
764
1156
  logExecution({
765
1157
  type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
766
- symbol: snap.symbol, side: "entry", size: sizeInAsset,
1158
+ symbol: snap.symbol, side: "entry", size: res.hedged,
767
1159
  status: "success", dryRun: false,
768
- meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice },
1160
+ meta: { longExchange, shortExchange, grossSpread, netSpread, roundTripCost, markPrice: snap.markPrice, requested: sizeInAsset, trimmed: res.trimmed },
769
1161
  });
770
- console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${sizeInAsset} units ($${actualSizeUsd} / $${snap.markPrice.toFixed(2)})`));
771
- // Verify actual fill sizes by querying positions
772
- let verifiedLongSize = sizeInAsset;
773
- let verifiedShortSize = sizeInAsset;
1162
+ console.log(chalk.green(` ${now} FILLED ${snap.symbol} — both legs @ ${res.hedged} units (requested ${sizeInAsset}; $${actualSizeUsd} / $${snap.markPrice.toFixed(2)})`));
1163
+ // Persist to state file immediately (crash-safe) — the verified hedged size.
1164
+ const entryTime = new Date().toISOString();
774
1165
  try {
775
- const [longPositions, shortPositions] = await Promise.all([
776
- longAdapter.getPositions(),
777
- shortAdapter.getPositions(),
778
- ]);
779
- const longPos = longPositions.find(p => p.symbol.toUpperCase().includes(snap.symbol));
780
- const shortPos = shortPositions.find(p => p.symbol.toUpperCase().includes(snap.symbol));
781
- if (longPos)
782
- verifiedLongSize = longPos.size;
783
- if (shortPos)
784
- verifiedShortSize = shortPos.size;
785
- if (verifiedLongSize !== sizeInAsset || verifiedShortSize !== sizeInAsset) {
786
- console.log(chalk.yellow(` ${now} SIZE MISMATCH ${snap.symbol}: requested ${sizeInAsset}, actual long=${verifiedLongSize} short=${verifiedShortSize}`));
787
- }
1166
+ st.add({
1167
+ id: `${snap.symbol}-${Date.now()}`,
1168
+ symbol: snap.symbol,
1169
+ longExchange,
1170
+ shortExchange,
1171
+ longSize: parseFloat(res.hedged),
1172
+ shortSize: parseFloat(res.hedged),
1173
+ entryTime,
1174
+ entrySpread: grossSpread,
1175
+ entryLongPrice: snap.markPrice,
1176
+ entryShortPrice: snap.markPrice,
1177
+ accumulatedFunding: 0,
1178
+ lastCheckTime: entryTime,
1179
+ });
788
1180
  }
789
- catch {
790
- // Position query failed — use requested size as fallback
791
- console.log(chalk.gray(` ${now} Could not verify fill sizes for ${snap.symbol}, using requested size`));
1181
+ catch (e) {
1182
+ // A hedged pair the state cannot record (write failed / conflicting entry): never
1183
+ // left as silent exposure — quarantined with what is open.
1184
+ quarantinePair("arb-auto", snap.symbol, longExchange, shortExchange, `entry opened (long ${res.hedged} on ${longExchange} / short ${res.hedged} on ${shortExchange}) but the arb state could not record it: ${e instanceof Error ? e.message : String(e)}`, (m) => console.log(chalk.red(m)));
792
1185
  }
793
- // Persist to state file immediately (crash-safe)
794
- const entryTime = new Date().toISOString();
795
- persistAddPosition({
796
- id: `${snap.symbol}-${Date.now()}`,
797
- symbol: snap.symbol,
798
- longExchange,
799
- shortExchange,
800
- longSize: parseFloat(verifiedLongSize),
801
- shortSize: parseFloat(verifiedShortSize),
802
- entryTime,
803
- entrySpread: grossSpread,
804
- entryLongPrice: snap.markPrice,
805
- entryShortPrice: snap.markPrice,
806
- accumulatedFunding: 0,
807
- lastCheckTime: entryTime,
808
- });
1186
+ donePending();
809
1187
  await notifyIfEnabled(webhookUrl, notifyEvents, "entry", {
810
1188
  symbol: snap.symbol, longExchange, shortExchange,
811
1189
  size: actualSizeUsd, netSpread,
812
1190
  });
813
1191
  }
814
- else if (longOk !== shortOk) {
815
- // One leg filled, one failed — ROLLBACK the successful leg
816
- const filledSide = longOk ? "long" : "short";
817
- const failedSide = longOk ? "short" : "long";
818
- const filledAdapter = longOk ? longAdapter : shortAdapter;
819
- const rollbackAction = longOk ? "sell" : "buy"; // reverse the filled side
820
- const failedErr = longOk
821
- ? shortResult.reason
822
- : longResult.reason;
823
- console.log(chalk.yellow(` ${now} PARTIAL FILL ${snap.symbol}: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
824
- // Attempt rollback with max 2 retries
825
- let rollbackOk = false;
826
- for (let attempt = 1; attempt <= 2; attempt++) {
827
- try {
828
- await filledAdapter.marketOrder(snap.symbol, rollbackAction, sizeInAsset);
829
- rollbackOk = true;
830
- console.log(chalk.green(` ${now} ROLLBACK ${snap.symbol}: ${filledSide} leg closed (attempt ${attempt})`));
831
- break;
832
- }
833
- catch (rollbackErr) {
834
- console.log(chalk.red(` ${now} ROLLBACK ATTEMPT ${attempt}/2 FAILED ${snap.symbol}: ${rollbackErr instanceof Error ? rollbackErr.message : rollbackErr}`));
835
- }
836
- }
1192
+ else if (res.status === "not_opened") {
837
1193
  logExecution({
838
1194
  type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
839
1195
  symbol: snap.symbol, side: "entry", size: sizeInAsset,
840
1196
  status: "failed", dryRun: false,
841
- error: `Partial fill: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Rollback: ${rollbackOk ? "success" : "FAILED"}`,
842
- meta: { longExchange, shortExchange, grossSpread, netSpread, partialFill: filledSide, rollbackSuccess: rollbackOk },
1197
+ error: `${res.reason}${res.rolledBack !== null ? ` — long ${res.rolledBack} rolled back (reduce-only, verified)` : ""}`,
1198
+ meta: { longExchange, shortExchange, grossSpread, netSpread },
843
1199
  });
844
- if (!rollbackOk) {
845
- // Critical: manual intervention required
846
- console.log(chalk.red.bold(` ${now} IMBALANCE ${snap.symbol}: ${filledSide} leg open, rollback failed — MANUAL CLOSE REQUIRED`));
847
- await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
848
- exchange: longOk ? longExchange : shortExchange,
849
- marginPct: 0,
850
- threshold: 0,
851
- symbol: snap.symbol,
852
- message: `IMBALANCE: ${filledSide} leg filled on ${longOk ? longExchange : shortExchange}, ${failedSide} failed, rollback failed. Manual close required.`,
853
- });
854
- }
855
- continue; // don't add to openPositions
1200
+ console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: ${res.reason}${res.rolledBack !== null ? ` — rolled back long ${res.rolledBack}; no open exposure` : " — nothing opened"}`));
1201
+ donePending();
1202
+ continue;
856
1203
  }
857
1204
  else {
858
- // Both legs failed
859
- const longErr = longResult.reason;
860
- const shortErr = shortResult.reason;
1205
+ donePending(); // quarantined (persisted) by openPerpPairVerified
861
1206
  logExecution({
862
1207
  type: "arb_entry", exchange: `${longExchange}+${shortExchange}`,
863
1208
  symbol: snap.symbol, side: "entry", size: sizeInAsset,
864
1209
  status: "failed", dryRun: false,
865
- error: `Both legs failed. Long: ${longErr instanceof Error ? longErr.message : String(longErr)}, Short: ${shortErr instanceof Error ? shortErr.message : String(shortErr)}`,
866
- meta: { longExchange, shortExchange, grossSpread, netSpread },
1210
+ error: `QUARANTINED: ${res.reason}`,
1211
+ meta: { longExchange, shortExchange, grossSpread, netSpread, quarantined: true, exposure: res.exposure },
867
1212
  });
868
- console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: both legs rejected`));
869
- continue;
1213
+ console.log(chalk.red.bold(` ${now} IMBALANCE ${snap.symbol}: ${res.exposure} — quarantined, MANUAL CHECK REQUIRED`));
1214
+ await notifyIfEnabled(webhookUrl, notifyEvents, "margin", {
1215
+ exchange: `${longExchange}+${shortExchange}`,
1216
+ marginPct: 0,
1217
+ threshold: 0,
1218
+ symbol: snap.symbol,
1219
+ message: `ENTRY QUARANTINED: ${res.reason}. Possible exposure: ${res.exposure}. Manual check required.`,
1220
+ });
1221
+ continue; // not tracked as a hedged pair; the quarantine blocks every path
870
1222
  }
871
1223
  }
872
1224
  catch (err) {
@@ -877,6 +1229,11 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
877
1229
  error: err instanceof Error ? err.message : String(err),
878
1230
  meta: { longExchange, shortExchange, grossSpread, netSpread },
879
1231
  });
1232
+ if (legsStarted) {
1233
+ // Threw inside the leg sequence: what is open is unknown → quarantine (never retried).
1234
+ quarantinePair("arb-auto", snap.symbol, longExchange, shortExchange, `entry threw mid-sequence (${err instanceof Error ? err.message : String(err)}) — legs may be open`, (m) => console.log(chalk.red(m)));
1235
+ }
1236
+ donePending();
880
1237
  console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: ${err instanceof Error ? err.message : err}`));
881
1238
  continue;
882
1239
  }
@@ -886,16 +1243,16 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
886
1243
  symbol: snap.symbol,
887
1244
  longExchange,
888
1245
  shortExchange,
889
- size: parseFloat(sizeInAsset),
1246
+ size: parseFloat(openedSize),
890
1247
  entrySpread: grossSpread,
891
1248
  entryTime: posEntryTime,
892
1249
  entryMarkPrice: snap.markPrice,
893
1250
  accumulatedFundingUsd: 0,
894
1251
  lastCheckTime: Date.now(),
895
1252
  });
896
- // Persist for dry-run mode too (tracks what would have been entered)
1253
+ // Dry-run: record the simulated entry in the in-memory state only (never the real file).
897
1254
  if (dryRun) {
898
- persistAddPosition({
1255
+ st.add({
899
1256
  id: `${snap.symbol}-${Date.now()}`,
900
1257
  symbol: snap.symbol,
901
1258
  longExchange,
@@ -912,6 +1269,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
912
1269
  }
913
1270
  }
914
1271
  }
1272
+ if (skipCounts.size > 0) {
1273
+ console.log(chalk.gray(` ${now} NO ENTRY (${[...skipCounts.values()].reduce((x, y) => x + y, 0)} symbols): ${[...skipCounts].map(([k, n]) => `${n}× ${k}`).join("; ")}`));
1274
+ }
915
1275
  // Accumulate estimated funding income & show status
916
1276
  if (openPositions.length > 0) {
917
1277
  const nowMs = Date.now();
@@ -922,9 +1282,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
922
1282
  const elapsedHours = (nowMs - pos.lastCheckTime) / (1000 * 60 * 60);
923
1283
  const notional = pos.size * current.markPrice;
924
1284
  // Estimate funding collected: normalize all rates to hourly before comparing
925
- const rateFor = (e) => e === "pacifica" ? current.pacRate : e === "hyperliquid" ? current.hlRate : e === "aster" ? current.astRate : current.ltRate;
926
- const longHourly = toHourlyRate(rateFor(pos.longExchange), pos.longExchange);
927
- const shortHourly = toHourlyRate(rateFor(pos.shortExchange), pos.shortExchange);
1285
+ const longHourly = snapshotHourly(current, pos.longExchange);
1286
+ const shortHourly = snapshotHourly(current, pos.shortExchange);
928
1287
  // Income = short gets paid positive funding, long pays; net = (shortRate - longRate) * notional * hours
929
1288
  const hourlyIncome = (shortHourly - longHourly) * notional;
930
1289
  pos.accumulatedFundingUsd += hourlyIncome * elapsedHours;
@@ -937,20 +1296,31 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
937
1296
  openPositions.map(p => `${p.symbol}(${p.entrySpread.toFixed(0)}% $${p.accumulatedFundingUsd.toFixed(3)})`).join(", ")));
938
1297
  }
939
1298
  // Update heartbeat: mark successful scan
940
- const stateForHeartbeat = loadArbState();
1299
+ const stateForHeartbeat = st.load();
941
1300
  if (stateForHeartbeat) {
942
1301
  stateForHeartbeat.lastSuccessfulScanTime = new Date().toISOString();
943
1302
  stateForHeartbeat.lastScanTime = new Date().toISOString();
944
- saveArbState(stateForHeartbeat);
1303
+ st.save(stateForHeartbeat);
945
1304
  }
946
1305
  }
947
1306
  catch (err) {
948
1307
  console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
949
1308
  }
950
1309
  };
951
- await cycle();
952
- cycleInterval = setInterval(cycle, intervalMs);
953
- await new Promise(() => { }); // keep alive
1310
+ // Serialized cycles (never overlapping): the next starts only after this one finished.
1311
+ while (!stop.requested()) {
1312
+ await cycle();
1313
+ await interruptibleSleep(intervalMs, stop.requested);
1314
+ }
1315
+ const finalState = st.load();
1316
+ if (finalState) {
1317
+ finalState.lastScanTime = new Date().toISOString();
1318
+ st.save(finalState);
1319
+ }
1320
+ stop.dispose();
1321
+ if (!isJson())
1322
+ console.log(chalk.yellow("\n Daemon stopped (current cycle finished). State saved.\n"));
1323
+ process.exit(0);
954
1324
  });
955
1325
  // ── arb scan ── (one-shot spread scan)
956
1326
  arb
@@ -972,7 +1342,6 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
972
1342
  .option("--symbols <list>", "Comma-separated list of symbols")
973
1343
  .option("--all", "Include all symbols (not just top)")
974
1344
  .option("--hours <n>", "Hours of history to show", "24")
975
- .option("--exchange <ex>", "Filter by exchange")
976
1345
  .option("--max-gap <pct>", "Max price gap % (hip3 mode)")
977
1346
  .option("--include-native", "Include native HL perps (hip3 mode)", true)
978
1347
  .option("--no-include-native", "Exclude native HL perps (hip3 mode)")
@@ -984,6 +1353,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
984
1353
  .option("--duration <minutes>", "Track duration in minutes (with --track)", "10")
985
1354
  .option("--beep", "Beep on large gaps (with --gaps)")
986
1355
  .option("--check-liquidity", "Check orderbook depth in live mode (slower)")
1356
+ .option("--include-unverified", "With --rates / --compare: also show venues whose funding rate is unverified (qfex) — display only, never ranked for trading")
987
1357
  .action(async (opts) => {
988
1358
  // ── Price gap modes ──
989
1359
  if (opts.gaps || opts.track || opts.alert) {
@@ -1178,18 +1548,26 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1178
1548
  }
1179
1549
  }
1180
1550
  // Route to specialized handlers
1551
+ if (opts.includeUnverified === true && !opts.compare && !opts.rates) {
1552
+ throw new PerpError("INVALID_PARAMS", "--include-unverified applies to --rates / --compare only: unverified funding rates are shown, never ranked as arb opportunities", {
1553
+ remediation: "perp arb scan --rates --include-unverified (or: --compare <symbol> --include-unverified)",
1554
+ });
1555
+ }
1181
1556
  if (opts.compare)
1182
- return handleCompare(isJson, opts.compare);
1557
+ return handleCompare(isJson, opts.compare, opts.includeUnverified === true);
1183
1558
  if (opts.rates)
1184
- return handleRates(isJson, { symbol: opts.symbol, symbols: opts.symbols, all: opts.all, minSpread: opts.min, top: opts.top });
1559
+ return handleRates(isJson, { symbol: opts.symbol, symbols: opts.symbols, all: opts.all, minSpread: opts.min, top: opts.top, includeUnverified: opts.includeUnverified === true });
1185
1560
  if (opts.basis)
1186
1561
  return handleBasisScan(isJson, { minBasis: opts.min, symbol: typeof opts.basis === "string" ? opts.basis : opts.symbol });
1187
1562
  if (opts.hip3)
1188
1563
  return handleDexScan(isJson, { minSpread: opts.min, maxGap: opts.maxGap || "5", includeNative: opts.includeNative !== false, top: opts.top || "20" });
1564
+ // The exchange filter (--history / --positions) is the ROOT -e/--exchange (commander gives it
1565
+ // to the root program): used only when passed on the command line, never the root default venue.
1566
+ const exchangeFilter = program.getOptionValueSource("exchange") === "cli" ? String(program.opts().exchange) : undefined;
1189
1567
  if (opts.history)
1190
- return handleFundingHistory(isJson, { symbol: (typeof opts.history === "string" ? opts.history : opts.symbol) || "ETH", hours: opts.hours || "24", exchange: opts.exchange });
1568
+ return handleFundingHistory(isJson, { symbol: (typeof opts.history === "string" ? opts.history : opts.symbol) || "ETH", hours: opts.hours || "24", exchange: exchangeFilter });
1191
1569
  if (opts.positions)
1192
- return handleFundingPositions(isJson, getAdapterForExchange, { exchanges: opts.exchange });
1570
+ return handleFundingPositions(isJson, getAdapterForExchange, { exchanges: exchangeFilter });
1193
1571
  // Default: existing scan logic continues below
1194
1572
  const minSpread = parseFloat(opts.min);
1195
1573
  const holdDays = parseFloat(opts.holdDays);
@@ -1272,7 +1650,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1272
1650
  const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
1273
1651
  const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
1274
1652
  // Compute price gap between long and short exchanges
1275
- const pFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : s.astMarkPrice;
1653
+ const pFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : ex === "aster" ? s.astMarkPrice : unknownArbVenue(ex);
1276
1654
  const longPrice = pFor(s.longExch);
1277
1655
  const shortPrice = pFor(s.shortExch);
1278
1656
  const mid = (longPrice + shortPrice) / 2;
@@ -1310,7 +1688,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1310
1688
  const netSpread = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
1311
1689
  const grossColor = grossSpread >= 50 ? chalk.green.bold : grossSpread >= 30 ? chalk.green : chalk.yellow;
1312
1690
  const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
1313
- const priceFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : s.astMarkPrice;
1691
+ const priceFor = (ex) => ex === "pacifica" ? s.pacMarkPrice : ex === "hyperliquid" ? s.hlMarkPrice : ex === "lighter" ? s.ltMarkPrice : ex === "aster" ? s.astMarkPrice : unknownArbVenue(ex);
1314
1692
  const longPrice = priceFor(s.longExch);
1315
1693
  const shortPrice = priceFor(s.shortExch);
1316
1694
  const mid = (longPrice + shortPrice) / 2;
@@ -1342,19 +1720,34 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1342
1720
  console.log(chalk.gray(` Use 'perp arb auto --min-spread ${minSpread}' to auto-trade\n`));
1343
1721
  });
1344
1722
  // ── arb exec ── (validate orderbook + simultaneous dual-leg entry)
1345
- arb
1346
- .command("exec")
1347
- .description("Execute arb: validate orderbook depth, then enter both legs simultaneously. Use spot:<exch> for spot+perp mode.")
1723
+ /**
1724
+ * arb exec outcome that is NOT a success (not entered / rolled back / pending / quarantined):
1725
+ * exit code 1, and in JSON mode an ok:false envelope carrying the structured result as
1726
+ * `details` — so a script never reads a failed or unconfirmed entry as success.
1727
+ */
1728
+ const reportExecFailure = (code, message, details) => {
1729
+ process.exitCode = 1;
1730
+ if (!isJson())
1731
+ return;
1732
+ const remediation = typeof details.remediation === "string" ? details.remediation : undefined;
1733
+ printJson(jsonError(code, message, {
1734
+ status: ERROR_CODES[code].status,
1735
+ retryable: ERROR_CODES[code].retryable,
1736
+ details,
1737
+ ...(remediation !== undefined ? { remediation } : {}),
1738
+ }));
1739
+ };
1740
+ supportsDryRun(arb.command("exec"))
1741
+ .description("Execute arb (supports --dry-run): validate orderbook depth, then enter the legs sequentially (long, then short sized to the verified long fill). Use spot:<exch> for spot+perp mode.")
1348
1742
  .argument("<symbol>", "Symbol (e.g. BTC, ETH, ICP)")
1349
- .argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter, or spot:hl / spot:lt for spot+perp)")
1743
+ .argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter, or spot:<venue> for spot+perp — hl, lt, lrh, aster, ondo)")
1350
1744
  .argument("<shortExch>", "Exchange to go SHORT on (hl, pac, lighter)")
1351
1745
  .argument("<sizeUsd>", "Position size per leg in USD")
1352
1746
  .option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
1353
1747
  .option("--leverage <n>", "Set leverage before entry (both exchanges)")
1354
1748
  .option("--isolated", "Use isolated margin mode")
1355
1749
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
1356
- .option("--dry-run", "Simulate without executing trades")
1357
- .option("--reconcile", "Auto-adjust leg sizes to match after execution (trim larger side)")
1750
+ .option("--reconcile", "(always on) the long is trimmed reduce-only to the short's verified fill")
1358
1751
  .action(async (symbol, longExch, shortExch, sizeUsdStr, opts) => {
1359
1752
  // Detect spot: prefix -> route to spot-exec logic
1360
1753
  if (longExch.startsWith("spot:")) {
@@ -1364,21 +1757,39 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1364
1757
  const sym = symbol.toUpperCase();
1365
1758
  const sizeUsd = parseFloat(sizeUsdStr);
1366
1759
  const maxSlippage = parseFloat(opts.maxSlippage);
1367
- const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
1760
+ const dryRun = isDryRun(program);
1368
1761
  // Resolve exchange aliases
1369
- const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
1370
- longExch = aliasMap[longExch.toLowerCase()] || longExch.toLowerCase();
1371
- shortExch = aliasMap[shortExch.toLowerCase()] || shortExch.toLowerCase();
1762
+ longExch = resolveVenueId(longExch);
1763
+ shortExch = resolveVenueId(shortExch);
1764
+ // Only venues with a trading role can hold a leg.
1765
+ for (const ex of [longExch, shortExch]) {
1766
+ const venue = getVenue(ex);
1767
+ if (!venue.roles.includes("trading")) {
1768
+ throw unsupported(venue.displayName, "trading", "no trading API — it cannot be an arb leg", `Use a trading venue: ${listVenuesWithRole("trading").map(v => v.id).join(", ")}`);
1769
+ }
1770
+ }
1372
1771
  if (longExch === shortExch) {
1772
+ reportExecFailure("INVALID_PARAMS", "longExch and shortExch must be different", { error: "longExch and shortExch must be different" });
1373
1773
  if (isJson())
1374
- return printJson(jsonOk({ error: "longExch and shortExch must be different" }));
1774
+ return;
1375
1775
  console.log(chalk.red(" Long and short exchange must be different."));
1376
1776
  return;
1377
1777
  }
1778
+ // Global quarantine (design §12.3): an unknown / unhedged outcome on this symbol
1779
+ // anywhere blocks new trades until a human clears it.
1780
+ const quarantined = quarantineEntryFor(sym, (m) => console.error(m));
1781
+ if (quarantined !== null) {
1782
+ throw new PerpError("RISK_VIOLATION", `${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})`, {
1783
+ remediation: `${quarantined.remediation} — then: perp strategy quarantine-clear ${sym}`,
1784
+ });
1785
+ }
1786
+ // A venue whose funding rate is unverified is never a funding-arb leg (design §14.12 #6).
1787
+ assertFundingArbVenue(longExch);
1788
+ assertFundingArbVenue(shortExch);
1378
1789
  const longAdapter = await getAdapterForExchange(longExch);
1379
1790
  const shortAdapter = await getAdapterForExchange(shortExch);
1380
- // 1. Set leverage if requested
1381
- if (opts.leverage) {
1791
+ // 1. Set leverage if requested (a venue write — skipped under --dry-run, reported in the plan)
1792
+ if (opts.leverage && !dryRun) {
1382
1793
  const lev = parseInt(opts.leverage);
1383
1794
  const mode = opts.isolated ? "isolated" : "cross";
1384
1795
  if (!isJson())
@@ -1389,15 +1800,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1389
1800
  ]);
1390
1801
  if (longLev.status === "rejected") {
1391
1802
  const msg = `Failed to set leverage on ${longExch}: ${longLev.reason instanceof Error ? longLev.reason.message : longLev.reason}`;
1803
+ reportExecFailure("EXCHANGE_ERROR", msg, { error: msg });
1392
1804
  if (isJson())
1393
- return printJson(jsonOk({ error: msg }));
1805
+ return;
1394
1806
  console.log(chalk.red(` ${msg}`));
1395
1807
  return;
1396
1808
  }
1397
1809
  if (shortLev.status === "rejected") {
1398
1810
  const msg = `Failed to set leverage on ${shortExch}: ${shortLev.reason instanceof Error ? shortLev.reason.message : shortLev.reason}`;
1811
+ reportExecFailure("EXCHANGE_ERROR", msg, { error: msg });
1399
1812
  if (isJson())
1400
- return printJson(jsonOk({ error: msg }));
1813
+ return;
1401
1814
  console.log(chalk.red(` ${msg}`));
1402
1815
  return;
1403
1816
  }
@@ -1425,8 +1838,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1425
1838
  fillableUsd: Math.round(fillableUsd * 100) / 100,
1426
1839
  validation,
1427
1840
  };
1841
+ reportExecFailure("SIZE_TOO_LARGE", `Insufficient orderbook depth for $${sizeUsd} (fillable ~$${result.fillableUsd})`, result);
1428
1842
  if (isJson())
1429
- return printJson(jsonOk(result));
1843
+ return;
1430
1844
  console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
1431
1845
  console.log(chalk.gray(` ${longExch} asks: $${longCheck.depthUsd.toFixed(0)} depth, ${longCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
1432
1846
  console.log(chalk.gray(` ${shortExch} bids: $${shortCheck.depthUsd.toFixed(0)} depth, ${shortCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
@@ -1448,11 +1862,17 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1448
1862
  const matchedBase = Math.min(longCheck.recommendedSize, shortCheck.recommendedSize);
1449
1863
  const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
1450
1864
  const matchedUsdRaw = matchedBase * avgPrice;
1451
- const sizing = computeMatchedSize(matchedUsdRaw, avgPrice, longExch, shortExch, { longSizeDecimals: longDecimals, shortSizeDecimals: shortDecimals });
1452
- if (!sizing) {
1453
- const msg = `Order too small: $${matchedUsdRaw.toFixed(2)} below exchange minimum. Request a larger size.`;
1865
+ // Each leg's minimum is the venue's own published one (market info), never a fixed table.
1866
+ const pairMin = pairOrderMinimums([
1867
+ { exchange: longExch, ...(await venueOrderMinimums(longAdapter, sym, networkOf(program))) },
1868
+ { exchange: shortExch, ...(await venueOrderMinimums(shortAdapter, sym, networkOf(program))) },
1869
+ ]);
1870
+ const sizing = computeMatchedSize(matchedUsdRaw, avgPrice, longExch, shortExch, { longSizeDecimals: longDecimals, shortSizeDecimals: shortDecimals, minNotional: pairMin.minNotional });
1871
+ if (!sizing || Number(sizing.size) < pairMin.minSize) {
1872
+ const msg = `Order too small: $${matchedUsdRaw.toFixed(2)} is below the venue minimum (${pairMin.describe}). Request a larger size.`;
1873
+ reportExecFailure("SIZE_TOO_SMALL", msg, { error: msg, computedUsd: matchedUsdRaw, minimums: pairMin.legs });
1454
1874
  if (isJson())
1455
- return printJson(jsonOk({ error: msg, computedUsd: matchedUsdRaw }));
1875
+ return;
1456
1876
  console.log(chalk.red(`\n ${msg}`));
1457
1877
  return;
1458
1878
  }
@@ -1466,7 +1886,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1466
1886
  // 4b. Dry-run: return simulated result without executing
1467
1887
  if (dryRun) {
1468
1888
  const result = {
1469
- status: "filled", symbol: sym, dryRun: true,
1889
+ status: "filled", symbol: sym, dryRun: true, sent: false,
1890
+ leverage: opts.leverage ? { value: parseInt(opts.leverage), mode: opts.isolated ? "isolated" : "cross" } : null,
1470
1891
  size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
1471
1892
  long: { exchange: longExch, side: "buy", size: matchedSize, slippagePct: longCheck.slippagePct },
1472
1893
  short: { exchange: shortExch, side: "sell", size: matchedSize, slippagePct: shortCheck.slippagePct },
@@ -1477,192 +1898,75 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1477
1898
  return;
1478
1899
  }
1479
1900
  if (!isJson())
1480
- console.log(chalk.gray(` Executing both legs simultaneously...\n`));
1481
- // 5. Execute both legs simultaneously
1482
- const execLong = opts.smart
1483
- ? smartOrder(longAdapter, sym, "buy", matchedSize).then(r => r.result)
1484
- : longAdapter.marketOrder(sym, "buy", matchedSize);
1485
- const execShort = opts.smart
1486
- ? smartOrder(shortAdapter, sym, "sell", matchedSize).then(r => r.result)
1487
- : shortAdapter.marketOrder(sym, "sell", matchedSize);
1488
- const [longResult, shortResult] = await Promise.allSettled([execLong, execShort]);
1489
- const longOk = longResult.status === "fulfilled";
1490
- const shortOk = shortResult.status === "fulfilled";
1491
- if (longOk && shortOk) {
1492
- // 6. Verify both positions actually exist (some exchanges return success but silently reject)
1493
- // SSOT rule #2: distinguish "API error" from "no position opened".
1494
- // Previously `.catch(() => [])` collapsed both into the same code path,
1495
- // so a transient verify-side network blip would be misclassified as a
1496
- // silent exchange reject and trigger an unnecessary rollback.
1497
- const [longSettled, shortSettled] = await Promise.allSettled([
1498
- longAdapter.getPositions(),
1499
- shortAdapter.getPositions(),
1500
- ]);
1501
- if (longSettled.status === "rejected" || shortSettled.status === "rejected") {
1502
- const longErr = longSettled.status === "rejected" ? (longSettled.reason instanceof Error ? longSettled.reason.message : String(longSettled.reason)) : "ok";
1503
- const shortErr = shortSettled.status === "rejected" ? (shortSettled.reason instanceof Error ? shortSettled.reason.message : String(shortSettled.reason)) : "ok";
1504
- throw new Error(`arb-auto: cannot verify post-fill positions for ${sym} ` +
1505
- `(long ${longExch}: ${longErr}; short ${shortExch}: ${shortErr}). ` +
1506
- `Refusing to silently misclassify API failure as a silent reject.`);
1507
- }
1508
- const longPositions = longSettled.value;
1509
- const shortPositions = shortSettled.value;
1510
- const longPos = longPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
1511
- const shortPos = shortPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
1512
- if (!longPos && !shortPos) {
1513
- // Both silently rejected
1514
- const result = { status: "both_rejected", symbol: sym, size: matchedSize, message: "Both exchanges accepted order but no positions opened. Check min order size." };
1515
- if (isJson())
1516
- return printJson(jsonOk(result));
1517
- console.log(chalk.red(` Both orders accepted but no positions opened. Min order size not met?\n`));
1518
- return;
1519
- }
1520
- if (!longPos || !shortPos) {
1521
- // One side silently rejected — close the other
1522
- const openSide = longPos ? "long" : "short";
1523
- const openAdapter = longPos ? longAdapter : shortAdapter;
1524
- if (!isJson())
1525
- console.log(chalk.yellow(` ${openSide} opened but other side silently rejected — closing ${openSide}...`));
1526
- try {
1527
- await openAdapter.marketOrder(sym, longPos ? "sell" : "buy", matchedSize);
1528
- }
1529
- catch { /* best effort */ }
1530
- const result = { status: "silent_reject", symbol: sym, openedSide: openSide, closedSide: longPos ? "short" : "long", message: "One leg silently rejected (likely min order size). Rolled back." };
1531
- if (isJson())
1532
- return printJson(jsonOk(result));
1533
- console.log(chalk.yellow(` Rolled back ${openSide} leg. Check exchange min order sizes.\n`));
1534
- return;
1535
- }
1536
- // 7. Verify fill sizes match expected
1537
- const expectedSize = parseFloat(matchedSize);
1538
- const longFilled = Number(longPos.size);
1539
- const shortFilled = Number(shortPos.size);
1540
- const fillWarnings = [];
1541
- if (longFilled < expectedSize * 0.5)
1542
- fillWarnings.push(`Long fill ${longPos.size} < expected ${matchedSize} — partial fill?`);
1543
- if (shortFilled < expectedSize * 0.5)
1544
- fillWarnings.push(`Short fill ${shortPos.size} < expected ${matchedSize} — partial fill?`);
1545
- // Size mismatch between legs
1546
- const sizeDiffPct = Math.abs(longFilled - shortFilled) / Math.max(longFilled, shortFilled) * 100;
1547
- if (sizeDiffPct > 5)
1548
- fillWarnings.push(`Leg size mismatch: long ${longPos.size} vs short ${shortPos.size} (${sizeDiffPct.toFixed(1)}% diff)`);
1549
- // 8. Reconcile — trim larger leg to match smaller
1550
- let reconciled = false;
1551
- let reconciledSize = "";
1552
- if (opts.reconcile && sizeDiffPct > 1 && longFilled > 0 && shortFilled > 0) {
1553
- const targetSize = Math.min(longFilled, shortFilled);
1554
- const trimSide = longFilled > shortFilled ? "long" : "short";
1555
- const trimAdapter = trimSide === "long" ? longAdapter : shortAdapter;
1556
- const trimAction = trimSide === "long" ? "sell" : "buy"; // reduce long = sell, reduce short = buy
1557
- const excess = Math.abs(longFilled - shortFilled);
1558
- const trimDecimals = matchedSize.includes(".") ? matchedSize.split(".")[1].length : 0;
1559
- const excessStr = excess.toFixed(trimDecimals);
1560
- if (!isJson())
1561
- console.log(chalk.cyan(` Reconciling: ${trimSide} has ${excess.toFixed(trimDecimals)} excess → trimming...`));
1562
- try {
1563
- if (opts.smart) {
1564
- await smartOrder(trimAdapter, sym, trimAction, excessStr, { reduceOnly: true });
1565
- }
1566
- else {
1567
- await trimAdapter.marketOrder(sym, trimAction, excessStr);
1568
- }
1569
- reconciled = true;
1570
- reconciledSize = targetSize.toFixed(trimDecimals);
1571
- fillWarnings.length = 0; // clear mismatch warnings
1572
- if (!isJson())
1573
- console.log(chalk.green(` Reconciled: both legs now ~${reconciledSize} units`));
1574
- }
1575
- catch (reconErr) {
1576
- const msg = reconErr instanceof Error ? reconErr.message : String(reconErr);
1577
- fillWarnings.push(`Reconcile failed (${trimSide} trim): ${msg}`);
1578
- if (!isJson())
1579
- console.log(chalk.yellow(` Reconcile failed: ${msg}`));
1580
- }
1581
- }
1582
- const fillStatus = fillWarnings.length > 0 ? "filled_unverified" : "filled";
1583
- const finalSize = reconciled ? reconciledSize : matchedSize;
1901
+ console.log(chalk.gray(` Executing legs sequentially (long, then short sized to the verified long fill)...\n`));
1902
+ // 5. Sequential verified legs (design §12.3). The second leg is sized to the
1903
+ // first leg's VERIFIED fill, any shortfall is trimmed off the long
1904
+ // (reduce-only, verified — always, --reconcile is implied), a definite short
1905
+ // failure rolls the long back reduce-only, and an unknown outcome (incl. a
1906
+ // venue that accepted the order but the position did not move) quarantines
1907
+ // the symbol — nothing is retried.
1908
+ const res = await openPerpPairVerified({
1909
+ source: "arb-exec", sym, longAdapter, longExch, longSym: sym, shortAdapter, shortExch, shortSym: sym,
1910
+ size: matchedSize, smart: !!opts.smart, log: (m) => console.error(chalk.red(m)),
1911
+ });
1912
+ if (res.status === "opened") {
1913
+ const finalSize = res.hedged;
1914
+ const reconciled = res.trimmed !== "0";
1584
1915
  logExecution({
1585
1916
  type: "arb_entry", exchange: `${longExch}+${shortExch}`,
1586
1917
  symbol: sym, side: "entry", size: finalSize,
1587
- status: fillWarnings.length > 0 ? "unverified" : "success", dryRun: false,
1588
- meta: { longExch, shortExch, matchedUsd, longSlippage: longCheck.slippagePct, shortSlippage: shortCheck.slippagePct, reconciled, ...(fillWarnings.length > 0 && { warnings: fillWarnings }) },
1918
+ status: "success", dryRun: false,
1919
+ // arbPairId / longExchange / shortExchange: the keys `arb status` provenance matches on.
1920
+ meta: { arbPairId: `${sym}:${longExch}:${shortExch}`, longExchange: longExch, shortExchange: shortExch, longExch, shortExch, matchedUsd, requested: matchedSize, longSlippage: longCheck.slippagePct, shortSlippage: shortCheck.slippagePct, reconciled, trimmed: res.trimmed },
1589
1921
  });
1590
1922
  const result = {
1591
- status: fillStatus, symbol: sym, size: finalSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
1592
- long: { exchange: longExch, side: "buy", size: reconciled ? reconciledSize : longPos.size, slippagePct: longCheck.slippagePct },
1593
- short: { exchange: shortExch, side: "sell", size: reconciled ? reconciledSize : shortPos.size, slippagePct: shortCheck.slippagePct },
1594
- ...(reconciled && { reconciled: true }),
1595
- ...(fillWarnings.length > 0 && { warnings: fillWarnings }),
1923
+ status: "filled", symbol: sym, size: finalSize, requestedSize: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
1924
+ long: { exchange: longExch, side: "buy", size: finalSize, slippagePct: longCheck.slippagePct },
1925
+ short: { exchange: shortExch, side: "sell", size: finalSize, slippagePct: shortCheck.slippagePct },
1926
+ ...(reconciled && { reconciled: true, trimmed: res.trimmed }),
1596
1927
  };
1597
1928
  if (isJson())
1598
1929
  return printJson(jsonOk(result));
1599
- if (fillWarnings.length > 0) {
1600
- console.log(chalk.yellow(` FILLED (unverified): ${sym} ${finalSize} units (~$${matchedUsd.toFixed(2)})`));
1601
- for (const w of fillWarnings)
1602
- console.log(chalk.yellow(` ⚠ ${w}`));
1603
- }
1604
- else {
1605
- console.log(chalk.green(` FILLED${reconciled ? " (reconciled)" : ""}: ${sym} ${finalSize} units (~$${matchedUsd.toFixed(2)})`));
1606
- }
1607
- console.log(chalk.green(` LONG ${longExch} ✓ (${reconciled ? reconciledSize : longPos.size})`));
1608
- console.log(chalk.green(` SHORT ${shortExch} ✓ (${reconciled ? reconciledSize : shortPos.size})\n`));
1609
- }
1610
- else if (longOk !== shortOk) {
1611
- // One leg failed — rollback
1612
- const filledSide = longOk ? "long" : "short";
1613
- const failedSide = longOk ? "short" : "long";
1614
- const filledAdapter = longOk ? longAdapter : shortAdapter;
1615
- const rollbackAction = longOk ? "sell" : "buy";
1616
- const failedErr = longOk
1617
- ? shortResult.reason
1618
- : longResult.reason;
1619
- if (!isJson())
1620
- console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
1621
- let rollbackOk = false;
1622
- for (let attempt = 1; attempt <= 2; attempt++) {
1623
- try {
1624
- if (opts.smart) {
1625
- await smartOrder(filledAdapter, sym, rollbackAction, matchedSize, { reduceOnly: true });
1626
- }
1627
- else {
1628
- await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
1629
- }
1630
- rollbackOk = true;
1631
- break;
1632
- }
1633
- catch { /* retry */ }
1634
- }
1930
+ console.log(chalk.green(` FILLED${reconciled ? ` (long trimmed by ${res.trimmed})` : ""}: ${sym} ${finalSize} units (requested ${matchedSize}, ~$${matchedUsd.toFixed(2)})`));
1931
+ console.log(chalk.green(` LONG ${longExch} ✓ (${finalSize}, verified)`));
1932
+ console.log(chalk.green(` SHORT ${shortExch} ✓ (${finalSize}, verified)\n`));
1933
+ }
1934
+ else if (res.status === "not_opened") {
1635
1935
  logExecution({
1636
1936
  type: "arb_entry", exchange: `${longExch}+${shortExch}`,
1637
1937
  symbol: sym, side: "entry", size: matchedSize,
1638
1938
  status: "failed", dryRun: false,
1639
- error: `Partial: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Rollback: ${rollbackOk ? "ok" : "FAILED"}`,
1939
+ error: `${res.reason}${res.rolledBack !== null ? ` — long ${res.rolledBack} rolled back (reduce-only, verified)` : ""}`,
1640
1940
  });
1641
1941
  const result = {
1642
- status: "partial_fill", symbol: sym, filledSide, failedSide,
1643
- failedError: failedErr instanceof Error ? failedErr.message : String(failedErr),
1644
- rollback: rollbackOk ? "success" : "FAILED — MANUAL CLOSE REQUIRED",
1942
+ status: res.rolledBack !== null ? "rolled_back" : "not_filled", symbol: sym,
1943
+ error: res.reason, rolledBack: res.rolledBack, openExposure: null,
1645
1944
  };
1945
+ reportExecFailure("ORDER_REJECTED", `arb exec ${sym}: entry failed (${result.status}) — ${res.reason}`, result);
1646
1946
  if (isJson())
1647
- return printJson(jsonOk(result));
1648
- console.log(rollbackOk
1649
- ? chalk.yellow(` Rollback OK — no open exposure.\n`)
1650
- : chalk.red.bold(` ROLLBACK FAILED — ${filledSide} leg still open. Close manually!\n`));
1947
+ return;
1948
+ console.log(chalk.red(` Entry failed: ${res.reason}`));
1949
+ console.log(res.rolledBack !== null
1950
+ ? chalk.yellow(` Rolled back long ${res.rolledBack} on ${longExch} (reduce-only, verified flat) — no open exposure from this trade.\n`)
1951
+ : chalk.yellow(` Nothing was opened.\n`));
1651
1952
  }
1652
1953
  else {
1653
- // Both failed
1654
- const longErr = longResult.reason;
1655
- const shortErr = shortResult.reason;
1954
+ logExecution({
1955
+ type: "arb_entry", exchange: `${longExch}+${shortExch}`,
1956
+ symbol: sym, side: "entry", size: matchedSize,
1957
+ status: "failed", dryRun: false,
1958
+ error: `QUARANTINED: ${res.reason}`,
1959
+ meta: { exposure: res.exposure },
1960
+ });
1656
1961
  const result = {
1657
- status: "both_failed", symbol: sym,
1658
- longError: longErr instanceof Error ? longErr.message : String(longErr),
1659
- shortError: shortErr instanceof Error ? shortErr.message : String(shortErr),
1962
+ status: "quarantined", symbol: sym, error: res.reason, openExposure: res.exposure,
1963
+ remediation: `Check ${sym} on ${longExch} and ${shortExch}; close or re-hedge by hand, then: perp strategy quarantine-clear ${sym}`,
1660
1964
  };
1965
+ reportExecFailure("ORDER_PARTIAL", `arb exec ${sym}: QUARANTINED — ${res.reason}`, result);
1661
1966
  if (isJson())
1662
- return printJson(jsonOk(result));
1663
- console.log(chalk.red(` Both legs failed:`));
1664
- console.log(chalk.red(` LONG: ${result.longError}`));
1665
- console.log(chalk.red(` SHORT: ${result.shortError}\n`));
1967
+ return;
1968
+ console.log(chalk.red.bold(` QUARANTINED ${sym}: ${res.reason}`));
1969
+ console.log(chalk.red.bold(` Possible open exposure: ${res.exposure} — check manually!\n`));
1666
1970
  }
1667
1971
  });
1668
1972
  // ── spot-exec logic (called from exec when longExch starts with "spot:") ──
@@ -1670,178 +1974,121 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1670
1974
  const sym = symbol.toUpperCase();
1671
1975
  const sizeUsd = parseFloat(sizeUsdStr);
1672
1976
  const maxSlippage = parseFloat(opts.maxSlippage);
1673
- const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
1977
+ const dryRun = isDryRun(program);
1674
1978
  // Resolve exchange aliases
1675
- const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
1676
- spotExch = aliasMap[spotExch.toLowerCase()] || spotExch.toLowerCase();
1677
- perpExch = aliasMap[perpExch.toLowerCase()] || perpExch.toLowerCase();
1678
- // Validate spot exchange
1679
- if (!["hyperliquid", "lighter"].includes(spotExch)) {
1680
- const msg = `Spot exchange must be hl or lt (got ${spotExch}). Pacifica is perp-only.`;
1979
+ spotExch = resolveVenueId(spotExch);
1980
+ perpExch = resolveVenueId(perpExch);
1981
+ // Validate spot exchange: bot/arb spot scope (§12.4 #3) — throws CAPABILITY_UNSUPPORTED
1982
+ // for venues without spot or outside the scope (pacifica shared margin, arcus swaps).
1983
+ const spotDef = requireBotSpotVenue(spotExch.replace(/-spot$/, ""));
1984
+ spotExch = spotDef.id;
1985
+ perpExch = tryResolveVenueId(perpExch) ?? perpExch;
1986
+ if (!getVenue(perpExch).roles.includes("trading")) {
1987
+ throw unsupported(perpExch, "trading", "no trading API — it cannot be the perp leg", `Use a trading venue: ${listVenuesWithRole("trading").map(v => v.id).join(", ")}`);
1988
+ }
1989
+ assertFundingArbVenue(perpExch);
1990
+ const spotAdapter = await requireSpotAdapter(spotDef.id, await getAdapterForExchange(spotDef.id));
1991
+ const perpAdapter = await getAdapterForExchange(perpExch);
1992
+ // Order of operations (C3 / M6): every validation (balances, orderbooks, price
1993
+ // identity, depth, sizing) runs BEFORE anything moves; dry-run returns before the
1994
+ // leverage change and any transfer; after a transfer, every abort transfers back.
1995
+ const lev = opts.leverage ? parseInt(opts.leverage) : null;
1996
+ const levMode = opts.isolated ? "isolated" : "cross";
1997
+ const sameVenue = spotDef.id === (tryResolveVenueId(perpExch) ?? perpExch);
1998
+ const fail = (msg, extra = {}) => {
1999
+ reportExecFailure("ORDER_REJECTED", msg, { error: msg, ...extra });
1681
2000
  if (isJson())
1682
- return printJson(jsonOk({ error: msg }));
2001
+ return;
1683
2002
  console.log(chalk.red(` ${msg}`));
1684
- return;
1685
- }
1686
- // Load spot adapter
1687
- const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1688
- const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1689
- let spotAdapter;
1690
- if (spotExch === "hyperliquid") {
1691
- const hlAdapter = await getAdapterForExchange("hyperliquid");
1692
- const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
1693
- await hlSpot.init();
1694
- spotAdapter = hlSpot;
2003
+ };
2004
+ // Global quarantine (design §12.3): checked before anything is read or moved.
2005
+ const quarantined = quarantineEntryFor(sym, (m) => console.error(m));
2006
+ if (quarantined !== null) {
2007
+ throw new PerpError("RISK_VIOLATION", `${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})`, {
2008
+ remediation: `${quarantined.remediation} — then: perp strategy quarantine-clear ${sym}`,
2009
+ });
1695
2010
  }
1696
- else {
1697
- const ltAdapter = await getAdapterForExchange("lighter");
1698
- const ltSpot = new LighterSpotAdapter(ltAdapter);
1699
- await ltSpot.init();
1700
- spotAdapter = ltSpot;
2011
+ // 1. Balance validation (errors propagate — no "continue anyway").
2012
+ // Shared spot/perp (HL unified): perp available is the spot purchasing power.
2013
+ // Separate: spot quote available + what can be transferred from the spot venue's perp account.
2014
+ const spotVenuePerp = await getAdapterForExchange(spotDef.id);
2015
+ const isShared = spotSharesPerpMargin(spotDef, spotVenuePerp);
2016
+ const spotVenuePerpAvail = Number((await spotVenuePerp.getBalance()).available);
2017
+ const perpAvail = sameVenue ? spotVenuePerpAvail : Number((await perpAdapter.getBalance()).available);
2018
+ if (!Number.isFinite(spotVenuePerpAvail) || !Number.isFinite(perpAvail)) {
2019
+ return fail(`Balance read returned a non-numeric available balance (spot venue ${spotVenuePerpAvail}, perp ${perpAvail})`);
1701
2020
  }
1702
- const perpAdapter = await getAdapterForExchange(perpExch);
1703
- // 1. Set perp leverage if requested
1704
- if (opts.leverage) {
1705
- const lev = parseInt(opts.leverage);
1706
- const mode = opts.isolated ? "isolated" : "cross";
1707
- if (!isJson())
1708
- console.log(chalk.gray(` Setting perp leverage ${lev}x ${mode} on ${perpExch}...`));
1709
- try {
1710
- await perpAdapter.setLeverage(sym, lev, mode);
1711
- }
1712
- catch (e) {
1713
- const msg = `Failed to set leverage: ${e instanceof Error ? e.message : e}`;
1714
- if (isJson())
1715
- return printJson(jsonOk({ error: msg }));
1716
- console.log(chalk.red(` ${msg}`));
1717
- return;
1718
- }
2021
+ const quoteRow = isShared ? null : findSpotQuoteBalance(spotDef, spotNetworkOf(spotAdapter), await readSpotBalancesFresh(spotAdapter));
2022
+ const spotQuoteHave = isShared ? spotVenuePerpAvail : (quoteRow === null ? 0 : Number(quoteRow.available));
2023
+ const spotTarget = Math.ceil(sizeUsd * 1.1);
2024
+ // Separate model: only the shortfall is transferred from the spot venue's perp account.
2025
+ const transferWant = isShared ? 0 : Math.max(0, Math.ceil(spotTarget - spotQuoteHave));
2026
+ // Perp leg margin: notional / the leverage the venue applies to the market × ARB_MARGIN_BUFFER
2027
+ // (requiredArbMargin — the same rule as funding-arb; unknown leverage → 1x). Spot leg: 100%.
2028
+ let perpLeverage;
2029
+ try {
2030
+ perpLeverage = await marketLeverageOf(perpAdapter, sym);
1719
2031
  }
1720
- // 2. Auto-transfer USDC to spot account if needed
1721
- if (spotExch === "hyperliquid") {
1722
- const hlSpot = spotAdapter;
1723
- const transferAmt = Math.ceil(sizeUsd * 1.1);
1724
- try {
1725
- const xferResult = await hlSpot.transferUsdcToSpot(transferAmt);
1726
- if (xferResult?.status === "err" && xferResult.response?.includes("unified")) {
1727
- if (!isJson())
1728
- console.log(chalk.gray(` Unified account — no USDC transfer needed`));
1729
- }
1730
- else if (xferResult?.status !== "err") {
1731
- if (!isJson())
1732
- console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot`));
1733
- await new Promise(r => setTimeout(r, 500));
1734
- }
1735
- }
1736
- catch {
1737
- // Non-critical — may be unified account or already have balance
1738
- }
2032
+ catch (e) {
2033
+ return fail(`Could not read the ${sym} leverage on ${perpExch}: ${e instanceof Error ? e.message : String(e)}`);
1739
2034
  }
1740
- else if (spotExch === "lighter") {
1741
- // Lighter requires explicit USDC transfer from perp → spot account
1742
- const ltSpot = spotAdapter;
1743
- const transferAmt = Math.ceil(sizeUsd * 1.1);
1744
- try {
1745
- const xferResult = await ltSpot.transferUsdcToSpot(transferAmt);
1746
- if (!isJson())
1747
- console.log(chalk.gray(` Transferred $${transferAmt} USDC perp→spot on Lighter`));
1748
- await new Promise(r => setTimeout(r, 300));
1749
- }
1750
- catch (e) {
1751
- const msg = e instanceof Error ? e.message : String(e);
1752
- if (!isJson())
1753
- console.log(chalk.yellow(` USDC transfer warning: ${msg}`));
1754
- // Continue — spot order may still work if spot balance already exists
2035
+ const perpMarginNeed = requiredArbMargin(sizeUsd, perpLeverage);
2036
+ const marginText = `~$${perpMarginNeed.toFixed(2)} perp margin (${perpLeverage !== null ? `${perpLeverage}x` : "leverage unknown → 1x"}, ×${ARB_MARGIN_BUFFER} buffer)`;
2037
+ if (isShared) {
2038
+ const need = sameVenue ? sizeUsd + perpMarginNeed : sizeUsd;
2039
+ if (spotQuoteHave < need) {
2040
+ return fail(`Insufficient balance: have $${spotQuoteHave.toFixed(2)} on ${spotDef.displayName}, need ~$${need.toFixed(2)} ($${sizeUsd} spot${sameVenue ? ` + ${marginText}` : ""})`, { available: spotQuoteHave, required: need, perpLeverage });
1755
2041
  }
1756
2042
  }
1757
- // 2b. Verify balance is sufficient after transfer
1758
- try {
1759
- // HL unified account: perp equity IS spot balance (shared)
1760
- const isUnified = spotExch === "hyperliquid";
1761
- let spotUsdcAvail;
1762
- if (isUnified) {
1763
- // Unified account: perp adapter's available balance = spot purchasing power
1764
- const hlBalance = await perpAdapter.getBalance();
1765
- spotUsdcAvail = Number(hlBalance?.available ?? 0);
1766
- }
1767
- else {
1768
- const spotBalances = await spotAdapter.getSpotBalances();
1769
- const spotUsdc = spotBalances.find(b => b.token === "USDC_SPOT" || b.token === "USDC");
1770
- spotUsdcAvail = Number(spotUsdc?.available ?? 0);
1771
- }
1772
- // Check perp balance (for perp leg margin)
1773
- const perpBalance = await perpAdapter.getBalance();
1774
- const perpAvail = Number(perpBalance?.available ?? 0);
1775
- // For unified accounts (same exchange), total needed = spotSize + perpMargin from same pool
1776
- const totalNeeded = spotExch === perpExch ? sizeUsd * 1.5 : sizeUsd;
1777
- const checkBalance = spotExch === perpExch ? spotUsdcAvail : spotUsdcAvail;
1778
- if (checkBalance < totalNeeded * 0.9) {
1779
- const msg = `Insufficient balance: have $${spotUsdcAvail.toFixed(2)}, need ~$${totalNeeded.toFixed(0)} (spot + perp margin)`;
1780
- if (isJson())
1781
- return printJson(jsonOk({ error: msg, spotUsdcAvail, required: totalNeeded }));
1782
- console.log(chalk.red(` ${msg}`));
1783
- return;
1784
- }
1785
- if (spotExch !== perpExch && perpAvail < sizeUsd * 0.5) {
1786
- const msg = `Insufficient perp balance: have $${perpAvail.toFixed(2)} available on ${perpExch}, need margin for ~$${sizeUsd}`;
1787
- if (isJson())
1788
- return printJson(jsonOk({ error: msg, perpAvail, required: sizeUsd }));
1789
- console.log(chalk.red(` ${msg}`));
1790
- return;
2043
+ else {
2044
+ const fromSpotVenuePerp = transferWant + (sameVenue ? perpMarginNeed : 0);
2045
+ if (spotVenuePerpAvail < fromSpotVenuePerp) {
2046
+ return fail(`Insufficient balance on ${spotDef.displayName}: spot ${quoteRow?.token ?? "quote"} $${spotQuoteHave.toFixed(2)} + transferable perp $${spotVenuePerpAvail.toFixed(2)}; need $${spotTarget} in spot${sameVenue ? ` + ${marginText}` : ""}`, { spotQuoteAvail: spotQuoteHave, perpAvail: spotVenuePerpAvail, required: fromSpotVenuePerp, perpLeverage });
1791
2047
  }
1792
2048
  }
1793
- catch {
1794
- // Balance check failed — continue with execution, let order placement catch errors
2049
+ if (!sameVenue && perpAvail < perpMarginNeed) {
2050
+ return fail(`Insufficient perp balance: have $${perpAvail.toFixed(2)} available on ${perpExch}, need ${marginText} for $${sizeUsd}`, { perpAvail, required: perpMarginNeed, perpLeverage });
1795
2051
  }
1796
- // 3. Fetch orderbooks simultaneously
1797
- const spotSymbol = `${sym}/USDC`;
2052
+ // 2. Fetch orderbooks simultaneously
2053
+ const spotSymbol = spotPairSymbol(spotDef, spotNetworkOf(spotAdapter), sym);
1798
2054
  if (!isJson())
1799
2055
  console.log(chalk.gray(` Checking orderbook depth (spot ${spotExch} + perp ${perpExch})...`));
1800
2056
  const [spotBook, perpBook] = await Promise.all([
1801
2057
  spotAdapter.getSpotOrderbook(spotSymbol),
1802
2058
  perpAdapter.getOrderbook(sym),
1803
2059
  ]);
1804
- // 3a. Price cross-validation: verify spot and perp are the same underlying
1805
- // Prevents wrong-token trades if index mapping is incorrect
2060
+ // 2a. Price cross-validation: spot and perp must be the same underlying
1806
2061
  const spotMid = spotBook.bids.length > 0 && spotBook.asks.length > 0
1807
2062
  ? (Number(spotBook.bids[0][0]) + Number(spotBook.asks[0][0])) / 2 : 0;
1808
2063
  const perpMid = perpBook.bids.length > 0 && perpBook.asks.length > 0
1809
2064
  ? (Number(perpBook.bids[0][0]) + Number(perpBook.asks[0][0])) / 2 : 0;
1810
- if (spotMid > 0 && perpMid > 0) {
1811
- const priceDeviation = Math.abs(spotMid - perpMid) / perpMid * 100;
1812
- if (priceDeviation > 5) {
1813
- const msg = `Price mismatch: spot mid $${spotMid.toFixed(4)} vs perp mid $${perpMid.toFixed(4)} (${priceDeviation.toFixed(1)}% deviation). Possible wrong token index — aborting.`;
1814
- if (isJson())
1815
- return printJson(jsonOk({ error: msg, spotMid, perpMid, deviationPct: priceDeviation }));
1816
- console.log(chalk.red(`\n ${msg}`));
1817
- return;
1818
- }
2065
+ if (spotMid === 0)
2066
+ return fail(`Empty spot orderbook for ${sym} — cannot verify token identity`);
2067
+ if (perpMid === 0)
2068
+ return fail(`Empty perp orderbook for ${sym} on ${perpExch} — cannot verify token identity`);
2069
+ const priceDeviation = Math.abs(spotMid - perpMid) / perpMid * 100;
2070
+ if (priceDeviation > MAX_PRICE_DEVIATION_PCT) {
2071
+ return fail(`Price mismatch: spot mid $${spotMid.toFixed(4)} vs perp mid $${perpMid.toFixed(4)} (${priceDeviation.toFixed(1)}% deviation). Possible wrong token index — aborting.`, { spotMid, perpMid, deviationPct: priceDeviation });
1819
2072
  }
1820
- else if (spotMid === 0) {
1821
- const msg = `Empty spot orderbook for ${sym} — cannot verify token identity`;
1822
- if (isJson())
1823
- return printJson(jsonOk({ error: msg }));
1824
- console.log(chalk.red(`\n ${msg}`));
1825
- return;
1826
- }
1827
- // 3b. Validate depth
2073
+ // 2b. Validate depth
1828
2074
  const spotCheck = computeExecutableSize(spotBook.asks, sizeUsd, maxSlippage);
1829
2075
  const perpCheck = computeExecutableSize(perpBook.bids, sizeUsd, maxSlippage);
1830
2076
  if (!spotCheck.canFillFull || !perpCheck.canFillFull) {
1831
2077
  const fillableUsd = Math.min(spotCheck.maxSize * spotCheck.avgFillPrice, perpCheck.maxSize * perpCheck.avgFillPrice);
2078
+ reportExecFailure("SIZE_TOO_LARGE", `Insufficient orderbook depth for $${sizeUsd} (fillable ~$${(Math.round(fillableUsd * 100) / 100)})`, {
2079
+ error: "Insufficient orderbook depth",
2080
+ requestedUsd: sizeUsd, fillableUsd: Math.round(fillableUsd * 100) / 100,
2081
+ spot: { exchange: spotExch, depthUsd: spotCheck.depthUsd, canFill: spotCheck.canFillFull },
2082
+ perp: { exchange: perpExch, depthUsd: perpCheck.depthUsd, canFill: perpCheck.canFillFull },
2083
+ });
1832
2084
  if (isJson())
1833
- return printJson(jsonOk({
1834
- error: "Insufficient orderbook depth",
1835
- requestedUsd: sizeUsd, fillableUsd: Math.round(fillableUsd * 100) / 100,
1836
- spot: { exchange: spotExch, depthUsd: spotCheck.depthUsd, canFill: spotCheck.canFillFull },
1837
- perp: { exchange: perpExch, depthUsd: perpCheck.depthUsd, canFill: perpCheck.canFillFull },
1838
- }));
2085
+ return;
1839
2086
  console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
1840
2087
  console.log(chalk.gray(` ${spotExch} spot asks: $${spotCheck.depthUsd.toFixed(0)}`));
1841
2088
  console.log(chalk.gray(` ${perpExch} perp bids: $${perpCheck.depthUsd.toFixed(0)}`));
1842
2089
  return;
1843
2090
  }
1844
- // 4. Compute matched size (with min order value enforcement)
2091
+ // 3. Compute matched size (with min order value enforcement)
1845
2092
  const avgPrice = (spotCheck.avgFillPrice + perpCheck.avgFillPrice) / 2;
1846
2093
  const inferDecimals = (levels) => {
1847
2094
  for (const [, s] of levels) {
@@ -1854,146 +2101,229 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1854
2101
  const spotDecimals = inferDecimals(spotBook.asks);
1855
2102
  const matchedBase = Math.min(spotCheck.recommendedSize, perpCheck.recommendedSize);
1856
2103
  const matchedUsdRaw = matchedBase * avgPrice;
1857
- // Use computeSpotPerpMatchedSize which enforces exchange min order values
1858
- // (e.g. HL $10 min) — rounds UP if floor would drop below minimum
2104
+ // computeSpotPerpMatchedSize enforces exchange min order values (e.g. HL $10 min)
1859
2105
  const sizing = computeSpotPerpMatchedSize(matchedUsdRaw, avgPrice, spotExch, perpExch, spotDecimals);
1860
- if (!sizing) {
1861
- const msg = `Order too small: $${matchedUsdRaw.toFixed(2)} below exchange minimum. Request a larger size.`;
1862
- if (isJson())
1863
- return printJson(jsonOk({ error: msg, computedUsd: matchedUsdRaw }));
1864
- console.log(chalk.red(`\n ${msg}`));
1865
- return;
1866
- }
1867
- const matchedSize = sizing.size;
2106
+ if (!sizing)
2107
+ return fail(`Order too small: $${matchedUsdRaw.toFixed(2)} below exchange minimum. Request a larger size.`, { computedUsd: matchedUsdRaw });
2108
+ // Lot steps from the venue market info (unknown → throws, Rule #2): both legs are sent on
2109
+ // the lot grid, the hedge is floored to the perp lot and fills are judged with half-lot tolerance.
2110
+ const perpStep = await lotStepOf(perpAdapter, sym);
2111
+ const spotStep = await spotLotStepOf(spotAdapter, spotSymbol);
2112
+ const matchedSize = floorSize(floorSize(sizing.size, perpStep), spotStep);
1868
2113
  const matchedUsd = sizing.notional;
2114
+ const sizeDec = sizeDecimalsOf(matchedSize);
1869
2115
  if (!isJson()) {
1870
2116
  console.log(chalk.cyan(`\n Spot+Perp Arb: ${sym}`));
1871
2117
  console.log(chalk.gray(` SPOT ${spotExch}: buy ${matchedSize} (~$${(matchedBase * spotCheck.avgFillPrice).toFixed(2)})`));
1872
2118
  console.log(chalk.gray(` PERP ${perpExch}: sell ${matchedSize} (~$${(matchedBase * perpCheck.avgFillPrice).toFixed(2)})`));
1873
- console.log(chalk.gray(` Executing both legs simultaneously...\n`));
1874
2119
  }
1875
- // 5. Execute both legs (or return dry-run result)
2120
+ // 4. Dry-run: report the plan; nothing is changed (no leverage, no transfer, no order).
1876
2121
  if (dryRun) {
1877
2122
  const result = {
1878
- status: "filled", mode: "spot-perp", symbol: sym, dryRun: true,
2123
+ status: "simulated", mode: "spot-perp", symbol: sym, dryRun: true, sent: false,
1879
2124
  size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
2125
+ transfer: transferWant > 0 ? { from: "perp", to: "spot", venue: spotDef.id, amount: transferWant } : null,
2126
+ leverage: lev === null ? null : { exchange: perpExch, leverage: lev, mode: levMode },
1880
2127
  spot: { exchange: spotExch, side: "buy", size: matchedSize },
1881
2128
  perp: { exchange: perpExch, side: "sell", size: matchedSize },
1882
2129
  };
1883
2130
  if (isJson())
1884
2131
  return printJson(jsonOk(result));
2132
+ if (transferWant > 0)
2133
+ console.log(chalk.cyan(` [DRY-RUN] Would transfer $${transferWant} perp→spot on ${spotDef.displayName}`));
2134
+ if (lev !== null)
2135
+ console.log(chalk.cyan(` [DRY-RUN] Would set perp leverage ${lev}x ${levMode} on ${perpExch}`));
1885
2136
  console.log(chalk.cyan(` [DRY-RUN] Would execute: spot buy ${matchedSize} on ${spotExch} + perp sell ${matchedSize} on ${perpExch}`));
1886
2137
  return;
1887
2138
  }
1888
- // Same exchange (e.g. Lighter) shares nonce → must execute sequentially
1889
- // Different exchanges → execute simultaneously for speed
1890
- const validatePerpFill = (r) => {
1891
- const res = r;
1892
- const statuses = res?.response?.data?.statuses;
1893
- if (statuses && statuses.length > 0) {
1894
- const st = statuses[0];
1895
- if (st.error)
1896
- throw new Error(`Perp sell ${sym}: ${st.error}`);
1897
- const filled = st.filled;
1898
- if (filled && Number(filled.totalSz ?? 0) === 0) {
1899
- throw new Error(`Perp sell ${sym}: 0 fill`);
1900
- }
2139
+ // 5. Set perp leverage if requested (before any funds move)
2140
+ if (lev !== null) {
2141
+ if (!isJson())
2142
+ console.log(chalk.gray(` Setting perp leverage ${lev}x ${levMode} on ${perpExch}...`));
2143
+ try {
2144
+ await perpAdapter.setLeverage(sym, lev, levMode);
2145
+ }
2146
+ catch (e) {
2147
+ return fail(`Failed to set leverage: ${e instanceof Error ? e.message : e}`);
2148
+ }
2149
+ }
2150
+ // 6. Transfer the spot quote shortfall perp → spot (separate model only). A failed transfer stops here.
2151
+ let transferred = 0;
2152
+ if (transferWant > 0) {
2153
+ if (await transferQuoteToSpot(spotAdapter, transferWant)) {
2154
+ transferred = transferWant;
2155
+ if (!isJson())
2156
+ console.log(chalk.gray(` Transferred $${transferWant} perp→spot on ${spotDef.displayName}`));
2157
+ await new Promise(r => setTimeout(r, 500));
2158
+ }
2159
+ }
2160
+ else if (!isJson()) {
2161
+ console.log(chalk.gray(isShared
2162
+ ? ` Shared spot/perp balance on ${spotDef.displayName} — no transfer needed`
2163
+ : ` Spot ${quoteRow?.token ?? "quote"} balance already covers the order — no transfer`));
2164
+ }
2165
+ /** Abort path after a transfer: move it back and report the outcome (never silent). */
2166
+ const returnTransfer = async (warnings) => {
2167
+ if (transferred <= 0)
2168
+ return;
2169
+ try {
2170
+ await transferQuoteToPerp(spotAdapter, transferred);
2171
+ if (!isJson())
2172
+ console.log(chalk.gray(` Returned $${transferred} spot→perp on ${spotDef.displayName}`));
2173
+ }
2174
+ catch (e) {
2175
+ const w = `Transfer back of $${transferred} failed (funds remain in ${spotDef.id} spot): ${e instanceof Error ? e.message : String(e)}`;
2176
+ warnings.push(w);
2177
+ if (!isJson())
2178
+ console.log(chalk.yellow(` ⚠ ${w}`));
1901
2179
  }
1902
- return r;
1903
2180
  };
2181
+ // 7. Execute both legs. Same exchange shares a nonce → sequential; else parallel.
2182
+ if (!isJson())
2183
+ console.log(chalk.gray(` Executing ${sameVenue ? "sequentially" : "both legs simultaneously"}...\n`));
2184
+ // The perp leg goes through placePerpVerified (design §12.3): a fill is the measured
2185
+ // position delta; an unknown outcome is ORDER_PENDING (quarantine, never roll back).
1904
2186
  let spotResult;
1905
2187
  let perpResult;
1906
- if (spotExch === perpExch) {
1907
- // Sequential: spot first, then perp (avoids nonce collision on same exchange)
1908
- spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "buy", matchedSize)
2188
+ if (sameVenue) {
2189
+ // Sequential: spot first; the perp leg is sized to what the spot leg filled.
2190
+ spotResult = await spotMarketFill(spotAdapter, spotSymbol, "buy", matchedSize, spotStep)
1909
2191
  .then(r => ({ status: "fulfilled", value: r }))
1910
2192
  .catch(e => ({ status: "rejected", reason: e }));
1911
2193
  if (spotResult.status === "fulfilled") {
1912
- perpResult = await perpAdapter.marketOrder(sym, "sell", matchedSize)
1913
- .then(validatePerpFill)
1914
- .then(r => ({ status: "fulfilled", value: r }))
1915
- .catch(e => ({ status: "rejected", reason: e }));
2194
+ const perpSize = floorToStep(spotResult.value.filledSize, perpStep);
2195
+ perpResult = Number(perpSize) > 0
2196
+ ? await placePerpVerified(perpAdapter, perpExch, sym, "sell", perpSize, { stepSize: perpStep })
2197
+ .then(r => ({ status: "fulfilled", value: r }))
2198
+ .catch(e => ({ status: "rejected", reason: e }))
2199
+ : { status: "rejected", reason: new PerpError("ORDER_REJECTED", `Skipped: spot filled only dust (${spotResult.value.filledSize})`) };
1916
2200
  }
1917
2201
  else {
1918
- perpResult = { status: "rejected", reason: new Error("Skipped: spot failed") };
2202
+ perpResult = { status: "rejected", reason: new PerpError("ORDER_REJECTED", "Skipped: spot failed") };
1919
2203
  }
1920
2204
  }
1921
2205
  else {
1922
- // Parallel: different exchanges, no nonce conflict
1923
2206
  [spotResult, perpResult] = await Promise.allSettled([
1924
- spotAdapter.spotMarketOrder(spotSymbol, "buy", matchedSize),
1925
- perpAdapter.marketOrder(sym, "sell", matchedSize).then(validatePerpFill),
2207
+ spotMarketFill(spotAdapter, spotSymbol, "buy", matchedSize, spotStep),
2208
+ placePerpVerified(perpAdapter, perpExch, sym, "sell", matchedSize, { stepSize: perpStep }),
1926
2209
  ]);
1927
2210
  }
1928
- const spotOk = spotResult.status === "fulfilled";
2211
+ const errMsg = (e) => e instanceof Error ? e.message : String(e);
2212
+ const spotPending = spotResult.status === "rejected" && spotResult.reason instanceof PerpError && spotResult.reason.structured.code === "ORDER_PENDING";
2213
+ const spotFilled = spotResult.status === "fulfilled" ? spotResult.value.filledSize : 0;
2214
+ const hedgeSize = floorToStep(spotFilled, perpStep);
1929
2215
  const perpOk = perpResult.status === "fulfilled";
1930
- if (spotOk && perpOk) {
1931
- // ── Post-execution fill verification ──
1932
- // Wait for exchange state to settle, then verify both legs actually filled
1933
- let fillVerified = true;
1934
- let verifyWarnings = [];
2216
+ const warnings = [];
2217
+ /** Set when a perp outcome is unknown: quarantine and stop (no spot sell-back, no transfer back). */
2218
+ let perpUnknown = perpResult.status === "rejected" && isOutcomeUnknown(perpResult.reason)
2219
+ ? `perp sell on ${perpExch} outcome unknown: ${errMsg(perpResult.reason)}` : null;
2220
+ // 7a. Perp leg open (verified) while the spot leg filled less (failed / pending / partial):
2221
+ // reduce the perp short to the spot fill (reduce-only, verified) — never a naked short.
2222
+ let shortOpen = perpResult.status === "fulfilled" ? floorToStep(perpResult.value.filledSize, perpStep) : "0";
2223
+ if (perpOk && Number(hedgeSize) < Number(shortOpen)) {
2224
+ const excess = floorToStep(Number(shortOpen) - Number(hedgeSize), perpStep);
2225
+ const why = spotResult.status === "rejected"
2226
+ ? `spot leg ${spotPending ? "PENDING" : "failed"}: ${errMsg(spotResult.reason)}`
2227
+ : `spot leg filled ${spotFilled} of ${matchedSize}`;
2228
+ if (!isJson())
2229
+ console.log(chalk.yellow(` ${why} — reducing perp short by ${excess} to match`));
2230
+ // One verified reduce-only close (+ one residual close inside closePerpVerified); never resubmitted.
1935
2231
  try {
1936
- await new Promise(r => setTimeout(r, 800));
1937
- // Verify spot: check base token balance appeared
1938
- const spotBals = await spotAdapter.getSpotBalances();
1939
- const baseToken = sym; // e.g. "ETH"
1940
- const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(baseToken));
1941
- const spotTokenAmt = Number(spotTokenBal?.total ?? 0);
1942
- const expectedSize = parseFloat(matchedSize);
1943
- if (spotTokenAmt < expectedSize * 0.5) {
1944
- fillVerified = false;
1945
- verifyWarnings.push(`Spot buy may not have filled: expected ~${matchedSize} ${baseToken}, found ${spotTokenAmt.toFixed(6)}`);
1946
- }
1947
- // Verify perp: check short position exists
1948
- const perpPositions = await perpAdapter.getPositions();
1949
- const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
1950
- if (!perpPos) {
1951
- fillVerified = false;
1952
- verifyWarnings.push(`Perp short position not found for ${sym} on ${perpExch} — fill may not have executed`);
1953
- }
1954
- else if (Number(perpPos.size) < expectedSize * 0.5) {
1955
- verifyWarnings.push(`Perp short size ${perpPos.size} < expected ${matchedSize} — partial fill?`);
1956
- }
2232
+ const c = await closePerpVerified(perpAdapter, perpExch, sym, "buy", excess, { decimals: sizeDecimalsOf(perpStep), stepSize: perpStep });
2233
+ shortOpen = floorToStep(Math.max(0, Number(shortOpen) - c.closed), perpStep);
2234
+ if (c.residual > 0)
2235
+ warnings.push(`Perp reduce of ${excess} ${sym} on ${perpExch} closed ${c.closed}, ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""} — MANUAL CLOSE REQUIRED`);
1957
2236
  }
1958
- catch (verifyErr) {
1959
- // Verification check itself failed — log but don't block
1960
- verifyWarnings.push(`Fill verification check failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
2237
+ catch (e) {
2238
+ if (isOutcomeUnknown(e))
2239
+ perpUnknown = `perp reduce of ${excess} on ${perpExch} outcome unknown: ${errMsg(e)} (short ${shortOpen} was open)`;
2240
+ else
2241
+ warnings.push(`Perp reduce of ${excess} ${sym} on ${perpExch} FAILED: ${errMsg(e)} — MANUAL CLOSE REQUIRED`);
1961
2242
  }
1962
- if (verifyWarnings.length > 0 && !isJson()) {
1963
- for (const w of verifyWarnings) {
1964
- console.log(chalk.yellow(` ⚠ ${w}`));
1965
- }
2243
+ }
2244
+ // 7a'. Unknown perp outcome: stop here. Selling the spot back could leave a naked short
2245
+ // and the short may or may not exist — quarantine for a human.
2246
+ if (perpUnknown !== null) {
2247
+ const pendingNote = spotPending ? `; spot buy ${matchedSize} also PENDING` : `; spot bought ${spotFilled}`;
2248
+ quarantineSymbol("arb-exec", sym, `arb exec spot:${spotExch}: ${perpUnknown}${pendingNote} — not rolled back`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance / spot-orders) and the ${perpExch} perp (perp -e ${perpExch} positions); hedge or close by hand`, (m) => console.error(m));
2249
+ logExecution({
2250
+ type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
2251
+ symbol: sym, side: "entry", size: matchedSize,
2252
+ status: "failed", dryRun: false,
2253
+ error: `${perpUnknown} — MANUAL CHECK REQUIRED`,
2254
+ meta: { mode: "spot-perp", spotPending, spotFilled, perpPending: true, ...(warnings.length > 0 && { warnings }) },
2255
+ });
2256
+ const result = {
2257
+ status: "perp_pending", mode: "spot-perp", symbol: sym,
2258
+ spotFilled, spotPending, perpError: perpUnknown,
2259
+ remediation: `Perp order outcome unknown — do NOT resubmit and do NOT sell the spot back blindly. Check: perp -e ${perpExch} positions; perp -e ${spotDef.id} trade spot-balance`,
2260
+ ...(warnings.length > 0 && { warnings }),
2261
+ };
2262
+ reportExecFailure("ORDER_PENDING", `arb exec spot:${spotExch} ${sym}: perp leg outcome unknown (quarantined) — ${perpUnknown}`, result);
2263
+ if (isJson())
2264
+ return;
2265
+ for (const w of warnings)
2266
+ console.log(chalk.red.bold(` ${w}`));
2267
+ console.log(chalk.red.bold(` PERP ORDER PENDING — ${perpUnknown}. Spot ${spotPending ? "PENDING" : `bought ${spotFilled}`}. Nothing rolled back; ${sym} quarantined.\n`));
2268
+ return;
2269
+ }
2270
+ // 7b. Spot leg filled while the perp leg DEFINITELY failed: sell the spot fill back.
2271
+ let spotHeld = spotFilled;
2272
+ if (!perpOk && spotFilled > 0) {
2273
+ const perpErr = perpResult.reason;
2274
+ if (!isJson())
2275
+ console.log(chalk.yellow(` Perp leg failed (${errMsg(perpErr)}) — selling back ${hedgeSize} spot`));
2276
+ try {
2277
+ const rb = Number(hedgeSize) > 0 ? await spotMarketFill(spotAdapter, spotSymbol, "sell", hedgeSize, spotStep) : null;
2278
+ spotHeld = rb === null ? spotFilled : Math.max(0, spotFilled - rb.filledSize);
2279
+ if (spotHeld > 0)
2280
+ warnings.push(`Spot rollback left ${spotHeld} ${sym} on ${spotExch} — MANUAL CLOSE REQUIRED`);
1966
2281
  }
1967
- if (!fillVerified) {
1968
- // Fills couldn't be confirmed — log as unverified but still persist
1969
- // (rolling back based on uncertain data is worse than persisting)
1970
- logExecution({
1971
- type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
1972
- symbol: sym, side: "entry", size: matchedSize,
1973
- status: "unverified", dryRun: false,
1974
- meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd, warnings: verifyWarnings },
1975
- });
2282
+ catch (e) {
2283
+ warnings.push(`Spot rollback FAILED: ${errMsg(e)} — ${spotFilled} ${sym} still held on ${spotExch}, MANUAL CLOSE REQUIRED`);
1976
2284
  }
1977
- else {
1978
- // Log execution — verified
1979
- logExecution({
1980
- type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
1981
- symbol: sym, side: "entry", size: matchedSize,
1982
- status: "success", dryRun: false,
1983
- meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd },
1984
- });
2285
+ }
2286
+ // Hedged only when the perp short is VERIFIED (placePerpVerified measured it) and spot is held.
2287
+ const hedged = perpOk && Number(shortOpen) > 0 && spotHeld > 0;
2288
+ if (hedged) {
2289
+ if (spotResult.status === "fulfilled" && spotResult.value.status === "partial") {
2290
+ warnings.push(`Spot leg partially filled: ${spotFilled} of ${matchedSize} ${sym}; perp short sized to match`);
2291
+ }
2292
+ // Legs must match: an excess short whose reduce failed, or a verified short below the
2293
+ // spot held, is real unhedged exposure → quarantine and report it, never "filled".
2294
+ // Spot held on the perp lot grid: sub-lot spot dust cannot be hedged and is not an imbalance.
2295
+ const heldStr = floorToStep(spotHeld, perpStep);
2296
+ let unbalanced = null;
2297
+ if (Number(heldStr) > Number(shortOpen)) {
2298
+ unbalanced = `verified perp short ${shortOpen} < spot held ${heldStr} ${sym} — ${fillSizeString(spotHeld - Number(shortOpen), sizeDec)} spot is unhedged`;
2299
+ }
2300
+ else if (Number(shortOpen) > Number(heldStr)) {
2301
+ unbalanced = `perp short ${shortOpen} on ${perpExch} > spot held ${heldStr} ${sym} — ${fillSizeString(Number(shortOpen) - spotHeld, sizeDec)} short is naked (reduce did not complete)`;
2302
+ }
2303
+ if (unbalanced !== null) {
2304
+ warnings.push(unbalanced);
2305
+ quarantineSymbol("arb-exec", sym, `arb exec spot:${spotExch}: ${unbalanced}`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance) and the ${perpExch} perp (perp -e ${perpExch} positions); re-hedge or close by hand, then: perp strategy quarantine-clear ${sym}`, (m) => console.error(m));
1985
2306
  }
1986
- // Persist position state
1987
- persistAddPosition({
2307
+ if (!isJson())
2308
+ for (const w of warnings)
2309
+ console.log(chalk.yellow(` ⚠ ${w}`));
2310
+ logExecution({
2311
+ type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
2312
+ symbol: sym, side: "entry", size: hedgeSize,
2313
+ status: "success", dryRun: false,
2314
+ meta: { mode: "spot-perp", spotExch, perpExch, matchedUsd, requestedSize: matchedSize, spotFilled, shortOpen, perpVerified: true, quarantined: unbalanced !== null, ...(warnings.length > 0 && { warnings }) },
2315
+ });
2316
+ // Persist the actual leg sizes
2317
+ persistAddPosition(networkOf(program), {
1988
2318
  id: `${sym}-spot-${Date.now()}`,
1989
2319
  symbol: sym,
1990
2320
  longExchange: spotExch,
1991
2321
  shortExchange: perpExch,
1992
- longSize: parseFloat(matchedSize),
1993
- shortSize: parseFloat(matchedSize),
2322
+ longSize: spotHeld,
2323
+ shortSize: parseFloat(shortOpen),
1994
2324
  entryTime: new Date().toISOString(),
1995
2325
  entrySpread: 0, // Will be updated on next scan
1996
- entryLongPrice: spotCheck.avgFillPrice,
2326
+ entryLongPrice: spotResult.status === "fulfilled" && spotResult.value.avgPrice !== null ? spotResult.value.avgPrice : spotCheck.avgFillPrice,
1997
2327
  entryShortPrice: perpCheck.avgFillPrice,
1998
2328
  accumulatedFunding: 0,
1999
2329
  lastCheckTime: new Date().toISOString(),
@@ -2001,143 +2331,120 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2001
2331
  spotExchange: spotExch,
2002
2332
  spotSymbol: spotSymbol,
2003
2333
  });
2004
- // Return leftover USDC from spot back to perp (non-unified HL accounts)
2005
- if (spotExch === "hyperliquid") {
2006
- try {
2007
- const hlSpot = spotAdapter;
2008
- const bals = await hlSpot.getSpotBalances();
2009
- const usdcBal = bals.find(b => b.token.startsWith("USDC"));
2010
- const leftover = Number(usdcBal?.available ?? 0);
2011
- if (leftover > 1) {
2012
- const xfer = await hlSpot.transferUsdcToPerp(Math.floor(leftover));
2013
- if (xfer?.status !== "err") {
2014
- if (!isJson())
2015
- console.log(chalk.gray(` Returned ~$${Math.floor(leftover)} USDC to perp account`));
2016
- }
2017
- }
2018
- }
2019
- catch { /* non-critical — unified accounts don't need transfer */ }
2020
- }
2021
- const result = {
2022
- status: fillVerified ? "filled" : "filled_unverified",
2023
- mode: "spot-perp", symbol: sym,
2024
- size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
2025
- spot: { exchange: spotExch, side: "buy", size: matchedSize },
2026
- perp: { exchange: perpExch, side: "sell", size: matchedSize },
2027
- ...(verifyWarnings.length > 0 && { warnings: verifyWarnings }),
2028
- };
2029
- if (isJson())
2030
- return printJson(jsonOk(result));
2031
- if (fillVerified) {
2032
- console.log(chalk.green(` FILLED (verified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
2033
- }
2034
- else {
2035
- console.log(chalk.yellow(` FILLED (unverified): ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
2334
+ // Return the leftover of what THIS trade transferred in (pre-existing spot quote is
2335
+ // never swept; no-op when shared with perp or nothing was transferred).
2336
+ try {
2337
+ const moved = transferred > 0 ? await sweepSpotQuoteToPerp(spotAdapter, 1, { keep: spotQuoteHave, max: transferred }) : 0;
2338
+ if (moved > 0 && !isJson())
2339
+ console.log(chalk.gray(` Returned ~$${moved.toFixed(2)} to perp account`));
2036
2340
  }
2037
- console.log(chalk.green(` SPOT ${spotExch} ✓ (bought ${matchedSize})`));
2038
- console.log(chalk.green(` PERP ${perpExch} ✓ (shorted ${matchedSize})\n`));
2039
- }
2040
- else if (spotOk !== perpOk) {
2041
- // One leg failed — rollback
2042
- const filledSide = spotOk ? "spot" : "perp";
2043
- const failedSide = spotOk ? "perp" : "spot";
2044
- const failedErr = spotOk
2045
- ? perpResult.reason
2046
- : spotResult.reason;
2047
- const failedErrMsg = failedErr instanceof Error ? failedErr.message : String(failedErr);
2048
- if (!isJson()) {
2049
- console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
2050
- console.log(chalk.red(` Reason: ${failedErrMsg}`));
2341
+ catch (e) {
2342
+ const w = `Leftover spot→perp transfer failed (funds remain in ${spotDef.id} spot): ${errMsg(e)}`;
2343
+ warnings.push(w);
2344
+ if (!isJson())
2345
+ console.log(chalk.yellow(` ⚠ ${w}`));
2051
2346
  }
2052
- let rollbackOk = false;
2053
- for (let attempt = 1; attempt <= 2; attempt++) {
2054
- try {
2055
- if (spotOk) {
2056
- await spotAdapter.spotMarketOrder(spotSymbol, "sell", matchedSize);
2057
- }
2058
- else {
2059
- await perpAdapter.marketOrder(sym, "buy", matchedSize);
2060
- }
2061
- rollbackOk = true;
2062
- break;
2063
- }
2064
- catch { /* retry */ }
2347
+ if (unbalanced !== null) {
2348
+ const result = {
2349
+ status: "quarantined", mode: "spot-perp", symbol: sym,
2350
+ exposure: { spotHeld: heldStr, perpShort: shortOpen, spotExchange: spotExch, perpExchange: perpExch },
2351
+ reason: unbalanced,
2352
+ remediation: `Legs unbalanced — do NOT resubmit. Re-hedge or close by hand (perp -e ${perpExch} positions; perp -e ${spotDef.id} trade spot-balance), then: perp strategy quarantine-clear ${sym}`,
2353
+ ...(warnings.length > 0 && { warnings }),
2354
+ };
2355
+ reportExecFailure("ORDER_PARTIAL", `arb exec spot:${spotExch} ${sym}: QUARANTINED — ${unbalanced}`, result);
2356
+ if (isJson())
2357
+ return;
2358
+ console.log(chalk.red.bold(` QUARANTINED: ${sym} spot ${heldStr} / perp short ${shortOpen} — ${unbalanced}\n`));
2359
+ return;
2065
2360
  }
2066
- logExecution({
2067
- type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
2068
- symbol: sym, side: "entry", size: matchedSize,
2069
- status: "failed", dryRun: false,
2070
- error: `Partial: ${failedSide} failed (${failedErrMsg}). Rollback: ${rollbackOk ? "ok" : "FAILED"}`,
2071
- });
2072
2361
  const result = {
2073
- status: "partial_fill", mode: "spot-perp", symbol: sym,
2074
- filledSide, failedSide,
2075
- failedError: failedErrMsg,
2076
- rollback: rollbackOk ? "success" : "FAILED — MANUAL CLOSE REQUIRED",
2362
+ status: spotFilled < Number(matchedSize) ? "partial_filled" : "filled",
2363
+ mode: "spot-perp", symbol: sym,
2364
+ size: hedgeSize, requestedSize: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
2365
+ spot: { exchange: spotExch, side: "buy", size: String(spotHeld) },
2366
+ perp: { exchange: perpExch, side: "sell", size: shortOpen },
2367
+ ...(warnings.length > 0 && { warnings }),
2077
2368
  };
2078
2369
  if (isJson())
2079
2370
  return printJson(jsonOk(result));
2080
- console.log(rollbackOk
2081
- ? chalk.yellow(` Rollback OK — no open exposure.\n`)
2082
- : chalk.red.bold(` ROLLBACK FAILED — ${filledSide} leg still open. Close manually!\n`));
2371
+ console.log(chalk.green(` FILLED (perp verified): ${sym} spot ${spotHeld} / perp short ${shortOpen} (~$${matchedUsd.toFixed(2)} requested)`));
2372
+ console.log(chalk.green(` SPOT ${spotExch} ✓ (bought ${spotFilled})`));
2373
+ console.log(chalk.green(` PERP ${perpExch} ✓ (short ${shortOpen})\n`));
2374
+ return;
2083
2375
  }
2084
- else {
2085
- // Both failed
2086
- const spotErr = spotResult.reason;
2087
- const perpErr = perpResult.reason;
2088
- const result = {
2089
- status: "both_failed", mode: "spot-perp", symbol: sym,
2090
- spotError: spotErr instanceof Error ? spotErr.message : String(spotErr),
2091
- perpError: perpErr instanceof Error ? perpErr.message : String(perpErr),
2092
- };
2093
- if (isJson())
2094
- return printJson(jsonOk(result));
2095
- console.log(chalk.red(` Both legs failed:`));
2096
- console.log(chalk.red(` SPOT: ${result.spotError}`));
2097
- console.log(chalk.red(` PERP: ${result.perpError}\n`));
2376
+ // Not hedged: nothing (or only an unrecoverable remainder) is open. Return the transfer
2377
+ // unless the spot leg is pending (the quote may already be spent).
2378
+ if (!spotPending)
2379
+ await returnTransfer(warnings);
2380
+ const spotErrMsg = spotResult.status === "rejected" ? errMsg(spotResult.reason) : null;
2381
+ const perpErrMsg = perpResult.status === "rejected" ? errMsg(perpResult.reason) : null;
2382
+ const manual = warnings.some(w => w.includes("MANUAL")) || spotPending;
2383
+ if (manual) {
2384
+ // Persisted globally so the `arb auto` daemon, the bots and later `arb exec` runs
2385
+ // leave this symbol alone until a human resolves it.
2386
+ quarantineSymbol("arb-exec", sym, spotPending ? `arb exec spot:${spotExch}: spot buy ${matchedSize} PENDING (outcome unknown)` : `arb exec spot:${spotExch}: rollback incomplete — ${warnings.join("; ")}`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance / spot-orders) and the ${perpExch} perp (perp -e ${perpExch} positions)`, (m) => console.error(m));
2098
2387
  }
2388
+ logExecution({
2389
+ type: "arb_entry", exchange: `spot:${spotExch}+${perpExch}`,
2390
+ symbol: sym, side: "entry", size: matchedSize,
2391
+ status: "failed", dryRun: false,
2392
+ error: `spot: ${spotErrMsg ?? `filled ${spotFilled}`}; perp: ${perpErrMsg ?? "ok"}${manual ? " — MANUAL CHECK REQUIRED" : ""}`,
2393
+ meta: { mode: "spot-perp", spotPending, spotHeld, shortOpen, ...(warnings.length > 0 && { warnings }) },
2394
+ });
2395
+ const result = {
2396
+ status: spotPending ? "spot_pending" : (spotErrMsg !== null && perpErrMsg !== null ? "both_failed" : "rolled_back"),
2397
+ mode: "spot-perp", symbol: sym,
2398
+ spotError: spotErrMsg, perpError: perpErrMsg,
2399
+ spotHeld, perpShortOpen: shortOpen,
2400
+ ...(spotPending && { remediation: `Spot order outcome unknown — do NOT resubmit. Check: perp -e ${spotDef.id} trade spot-balance; the perp leg was reduced to match.` }),
2401
+ ...(warnings.length > 0 && { warnings }),
2402
+ };
2403
+ // pending spot → ORDER_PENDING; an incomplete rollback (quarantined) → ORDER_PARTIAL; else nothing open → ORDER_REJECTED.
2404
+ reportExecFailure(spotPending ? "ORDER_PENDING" : manual ? "ORDER_PARTIAL" : "ORDER_REJECTED", `arb exec spot:${spotExch} ${sym}: not hedged (${result.status}${manual ? ", quarantined" : ""})`, result);
2405
+ if (isJson())
2406
+ return;
2407
+ if (spotErrMsg !== null)
2408
+ console.log(chalk.red(` SPOT: ${spotErrMsg}`));
2409
+ if (perpErrMsg !== null)
2410
+ console.log(chalk.red(` PERP: ${perpErrMsg}`));
2411
+ for (const w of warnings)
2412
+ console.log(chalk.red.bold(` ${w}`));
2413
+ if (spotPending)
2414
+ console.log(chalk.red.bold(` SPOT ORDER PENDING — outcome unknown, do NOT resubmit. Verify ${spotDef.id} spot balance manually.\n`));
2415
+ else if (!manual)
2416
+ console.log(chalk.yellow(` Not hedged — entry aborted: spot held ${spotHeld} ${sym}, perp short ${shortOpen}${spotErrMsg !== null && perpErrMsg !== null ? " (both legs rejected)" : " (the filled leg was rolled back)"}${warnings.length > 0 ? "; see warnings above" : ""}.\n`));
2099
2417
  }
2100
2418
  // ── Expose spot-close handler for consolidated "arb close" in arb/index.ts ──
2101
2419
  handleSpotClose = async (sym_arg, opts_arg, getAdapter, isJsonFn) => {
2102
2420
  const sym = sym_arg.toUpperCase();
2103
2421
  // Try to find the position from state
2104
- const state = loadArbState();
2422
+ const state = loadArbState(networkOf(program));
2105
2423
  const pos = state?.positions.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
2106
- const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
2107
- const spotExch = aliasMap[opts_arg.spotExch?.toLowerCase() ?? ""] || opts_arg.spotExch?.toLowerCase() || pos?.spotExchange || pos?.longExchange;
2108
- const perpExch = aliasMap[opts_arg.perpExch?.toLowerCase() ?? ""] || opts_arg.perpExch?.toLowerCase() || pos?.shortExchange;
2424
+ const venueIdOf = (v) => resolveVenueId(v.replace(/-spot$/i, ""));
2425
+ const spotRaw = opts_arg.spotExch ?? pos?.spotExchange ?? pos?.longExchange;
2426
+ const perpRaw = opts_arg.perpExch ?? pos?.shortExchange;
2427
+ const spotExch = spotRaw === undefined ? undefined : venueIdOf(spotRaw);
2428
+ const perpExch = perpRaw === undefined ? undefined : venueIdOf(perpRaw);
2109
2429
  if (!spotExch || !perpExch) {
2110
- const msg = "Cannot determine exchanges. Specify --spot-exch and --perp-exch, or ensure the position is tracked in state.";
2111
- if (isJsonFn())
2112
- return printJson(jsonOk({ error: msg }));
2113
- console.log(chalk.red(` ${msg}`));
2114
- return;
2115
- }
2116
- // Load adapters
2117
- const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
2118
- const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
2119
- let spotAdapter;
2120
- if (spotExch === "hyperliquid") {
2121
- const hlAdapter = await getAdapter("hyperliquid");
2122
- const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
2123
- await hlSpot.init();
2124
- spotAdapter = hlSpot;
2125
- }
2126
- else {
2127
- const ltAdapter = await getAdapter("lighter");
2128
- const ltSpot = new LighterSpotAdapter(ltAdapter);
2129
- await ltSpot.init();
2130
- spotAdapter = ltSpot;
2430
+ throw new PerpError("INVALID_PARAMS", "Cannot determine the spot and perp exchanges of this spot-perp position", {
2431
+ remediation: `Pass both: perp arb close ${sym} --spot-exch <spot venue> --perp-exch <perp venue>`,
2432
+ });
2131
2433
  }
2434
+ // Load adapters (bot/arb spot scope — §12.4 #3)
2435
+ const spotDef = requireBotSpotVenue(spotExch.replace(/-spot$/, ""));
2436
+ const spotAdapter = await requireSpotAdapter(spotDef.id, await getAdapter(spotDef.id));
2132
2437
  const perpAdapter = await getAdapter(perpExch);
2133
- const spotSymbol = `${sym}/USDC`;
2134
- // Find how much to close
2438
+ const spotSymbol = spotPairSymbol(spotDef, spotNetworkOf(spotAdapter), sym);
2439
+ // Find how much to close — fresh read (the reduce-only close is sized from it, never a cached position)
2440
+ invalidateCache("acct");
2135
2441
  const perpPositions = await perpAdapter.getPositions();
2136
2442
  const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
2137
2443
  if (!perpPos) {
2138
2444
  const msg = `No short perp position found for ${sym} on ${perpExch}`;
2445
+ process.exitCode = 1;
2139
2446
  if (isJsonFn())
2140
- return printJson(jsonOk({ error: msg }));
2447
+ return printJson(jsonError("POSITION_NOT_FOUND", msg));
2141
2448
  console.log(chalk.red(` ${msg}`));
2142
2449
  return;
2143
2450
  }
@@ -2148,122 +2455,168 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2148
2455
  console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
2149
2456
  console.log(chalk.gray(` Executing...\n`));
2150
2457
  }
2151
- // Same exchange → sequential (nonce collision), different → parallel
2458
+ // Spot quote before the sale (uncached): only the proceeds above it are swept back.
2459
+ // Read before anything moves — a failure aborts the close.
2460
+ const preCloseQuote = await readSpotQuoteAvailableFresh(spotAdapter);
2461
+ // Same exchange → sequential (nonce collision), different → parallel.
2462
+ // The spot leg's REAL fill decides the outcome (§12.4 #5): only a confirmed spot sale
2463
+ // counts as closed; legs are re-matched so no naked short / unhedged spot is left.
2464
+ const sameVenue = spotDef.id === (tryResolveVenueId(perpExch) ?? perpExch);
2465
+ const closeDec = sizeDecimalsOf(closeSize);
2466
+ // The perp buy-back and every re-match order go through placePerpVerified (design
2467
+ // §12.3): only a measured position change counts; an unknown outcome stops the close.
2152
2468
  let spotResult;
2153
2469
  let perpResult;
2154
- if (spotExch === perpExch) {
2155
- // Sequential: sell spot first, then close perp
2156
- spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize)
2470
+ if (sameVenue) {
2471
+ spotResult = await spotMarketFill(spotAdapter, spotSymbol, "sell", closeSize)
2157
2472
  .then(r => ({ status: "fulfilled", value: r }))
2158
2473
  .catch(e => ({ status: "rejected", reason: e }));
2159
- perpResult = await perpAdapter.marketOrder(sym, "buy", closeSize)
2474
+ perpResult = await placePerpVerified(perpAdapter, perpExch, sym, "buy", closeSize, { reduceOnly: true })
2160
2475
  .then(r => ({ status: "fulfilled", value: r }))
2161
2476
  .catch(e => ({ status: "rejected", reason: e }));
2162
2477
  }
2163
2478
  else {
2164
2479
  [spotResult, perpResult] = await Promise.allSettled([
2165
- spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize),
2166
- perpAdapter.marketOrder(sym, "buy", closeSize),
2480
+ spotMarketFill(spotAdapter, spotSymbol, "sell", closeSize),
2481
+ placePerpVerified(perpAdapter, perpExch, sym, "buy", closeSize, { reduceOnly: true }),
2167
2482
  ]);
2168
2483
  }
2169
- const spotOk = spotResult.status === "fulfilled";
2484
+ const errMsg = (e) => e instanceof Error ? e.message : String(e);
2485
+ const spotPending = spotResult.status === "rejected" && spotResult.reason instanceof PerpError && spotResult.reason.structured.code === "ORDER_PENDING";
2486
+ const sold = spotResult.status === "fulfilled" ? Math.min(spotResult.value.filledSize, Number(closeSize)) : 0;
2487
+ const soldStr = fillSizeString(sold, closeDec);
2170
2488
  const perpOk = perpResult.status === "fulfilled";
2171
- if (spotOk && perpOk) {
2172
- // ── Post-close fill verification ──
2173
- let closeVerified = true;
2174
- let closeWarnings = [];
2175
- try {
2176
- await new Promise(r => setTimeout(r, 800));
2177
- // Verify spot token was sold (balance should be near zero or gone)
2178
- const spotBals = await spotAdapter.getSpotBalances();
2179
- const baseToken = sym;
2180
- const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(baseToken));
2181
- const remainingSpotAmt = Number(spotTokenBal?.total ?? 0);
2182
- const closedSize = parseFloat(closeSize);
2183
- if (remainingSpotAmt > closedSize * 0.5) {
2184
- closeVerified = false;
2185
- closeWarnings.push(`Spot sell may not have filled: still have ${remainingSpotAmt.toFixed(6)} ${baseToken} (expected ~0)`);
2489
+ const closeWarnings = [];
2490
+ /** Set when a perp outcome is unknown: quarantine and stop (no re-short, no further reduce). */
2491
+ let perpUnknown = perpResult.status === "rejected" && isOutcomeUnknown(perpResult.reason)
2492
+ ? `perp buy-back ${closeSize} on ${perpExch} outcome unknown: ${errMsg(perpResult.reason)}` : null;
2493
+ // Size still short on the perp (verified buy-back only), and spot still held (in closeSize units).
2494
+ const perpClosed = perpResult.status === "fulfilled" ? Number(fillSizeString(perpResult.value.filledSize, closeDec)) : 0;
2495
+ let shortLeft = Number(closeSize) - perpClosed;
2496
+ const spotLeft = Number(closeSize) - sold;
2497
+ if (perpUnknown === null && !spotPending) {
2498
+ const unhedgedSpot = Number(fillSizeString(Math.max(0, spotLeft - shortLeft), closeDec));
2499
+ const nakedShort = Number(fillSizeString(Math.max(0, shortLeft - spotLeft), closeDec));
2500
+ if (unhedgedSpot > 0) {
2501
+ // Only reachable after a VERIFIED buy-back: spot sold less — re-short the unsold spot to stay hedged.
2502
+ const reShort = fillSizeString(unhedgedSpot, closeDec);
2503
+ try {
2504
+ const rs = await placePerpVerified(perpAdapter, perpExch, sym, "sell", reShort);
2505
+ const reShorted = fillSizeString(rs.filledSize, closeDec);
2506
+ shortLeft += Number(reShorted);
2507
+ // A partial re-short (accepted partial fill) leaves part of the unsold spot unhedged.
2508
+ closeWarnings.push(Number(reShorted) < Number(reShort)
2509
+ ? `Spot sold ${soldStr} of ${closeSize} ${sym}; re-short on ${perpExch} filled ${reShorted} of ${reShort} — ${fillSizeString(Number(reShort) - Number(reShorted), closeDec)} spot unhedged, MANUAL ACTION REQUIRED`
2510
+ : `Spot sold ${soldStr} of ${closeSize} ${sym}; re-shorted ${reShort} on ${perpExch} (verified) — stays open (hedged)`);
2186
2511
  }
2187
- // Verify perp short was closed (position should be gone or reduced)
2188
- const perpPositionsAfter = await perpAdapter.getPositions();
2189
- const remainingPerpPos = perpPositionsAfter.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
2190
- if (remainingPerpPos && Number(remainingPerpPos.size) > closedSize * 0.5) {
2191
- closeVerified = false;
2192
- closeWarnings.push(`Perp short still open: ${remainingPerpPos.size} ${sym} on ${perpExch} (expected closed)`);
2512
+ catch (e) {
2513
+ if (isOutcomeUnknown(e))
2514
+ perpUnknown = `re-short of the unsold ${reShort} ${sym} on ${perpExch} outcome unknown: ${errMsg(e)}`;
2515
+ else
2516
+ closeWarnings.push(`Re-short of the unsold ${reShort} ${sym} FAILED: ${errMsg(e)} — unhedged spot on ${spotExch}, MANUAL ACTION REQUIRED`);
2193
2517
  }
2194
2518
  }
2195
- catch (verifyErr) {
2196
- closeWarnings.push(`Close verification failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
2197
- }
2198
- if (closeWarnings.length > 0 && !isJson()) {
2199
- for (const w of closeWarnings) {
2200
- console.log(chalk.yellow(` ⚠ ${w}`));
2519
+ else if (nakedShort > 0) {
2520
+ // Spot sold more than the perp bought back: reduce the short to the spot left (no naked short).
2521
+ const reduce = fillSizeString(nakedShort, closeDec);
2522
+ try {
2523
+ const rd = await placePerpVerified(perpAdapter, perpExch, sym, "buy", reduce, { reduceOnly: true });
2524
+ const reduced = fillSizeString(rd.filledSize, closeDec);
2525
+ shortLeft -= Number(reduced);
2526
+ // A partial reduce (accepted partial fill) leaves part of the short naked.
2527
+ if (Number(reduced) < Number(reduce))
2528
+ closeWarnings.push(`Perp reduce on ${perpExch} filled ${reduced} of ${reduce} ${sym} — ${fillSizeString(Number(reduce) - Number(reduced), closeDec)} short naked, MANUAL CLOSE REQUIRED`);
2529
+ }
2530
+ catch (e) {
2531
+ if (isOutcomeUnknown(e))
2532
+ perpUnknown = `perp reduce of ${reduce} ${sym} on ${perpExch} outcome unknown: ${errMsg(e)}`;
2533
+ else
2534
+ closeWarnings.push(`Perp still short ${fillSizeString(shortLeft, closeDec)} ${sym} on ${perpExch} while ${soldStr} spot was sold (${errMsg(e)}) — MANUAL CLOSE REQUIRED`);
2201
2535
  }
2202
2536
  }
2203
- // Remove from state
2204
- persistRemovePosition(sym);
2537
+ }
2538
+ if (spotPending) {
2539
+ closeWarnings.push(`Spot sell PENDING (${errMsg(spotResult.reason)}) — outcome unknown, do NOT resubmit; verify ${spotDef.id} spot balance and the ${perpExch} short manually`);
2540
+ }
2541
+ if (perpUnknown !== null) {
2542
+ closeWarnings.push(`${perpUnknown} — do NOT resubmit; verify the ${perpExch} short and ${spotDef.id} spot balance manually`);
2543
+ }
2544
+ if (spotPending || perpUnknown !== null || closeWarnings.some(w => w.includes("MANUAL"))) {
2545
+ // Persisted globally: bots, the daemon and later arb runs leave the symbol alone.
2546
+ quarantineSymbol("arb-close", sym, `arb close spot:${spotExch}: ${spotPending || perpUnknown !== null ? closeWarnings.filter(w => w.includes("PENDING") || w.includes("unknown")).join("; ") : closeWarnings.join("; ")}`, `Check ${sym} on ${spotDef.id} spot (perp -e ${spotDef.id} trade spot-balance / spot-orders) and the ${perpExch} perp (perp -e ${perpExch} positions)`, (m) => console.error(m));
2547
+ }
2548
+ if (perpUnknown !== null) {
2549
+ // Stop: the perp position is unknown, so neither the stored legs nor the proceeds are touched.
2205
2550
  logExecution({
2206
2551
  type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
2207
- symbol: sym, side: "exit", size: closeSize,
2208
- status: closeVerified ? "success" : "unverified", dryRun: false,
2209
- meta: { mode: "spot-perp", ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
2552
+ symbol: sym, side: "exit", size: soldStr,
2553
+ status: "failed", dryRun: false,
2554
+ error: `${perpUnknown} — MANUAL CHECK REQUIRED`,
2555
+ meta: { mode: "spot-perp", requestedSize: closeSize, spotSold: sold, spotPending, perpPending: true, warnings: closeWarnings },
2210
2556
  });
2211
- // Return spot USDC proceeds back to perp account
2212
- if (spotExch === "lighter") {
2213
- try {
2214
- const ltSpot = spotAdapter;
2215
- const bals = await ltSpot.getSpotBalances();
2216
- const usdcBal = bals.find(b => b.token === "USDC");
2217
- const leftover = Number(usdcBal?.available ?? 0);
2218
- if (leftover > 0.01) {
2219
- await ltSpot.transferUsdcToPerp(leftover);
2220
- if (!isJsonFn())
2221
- console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot→perp on Lighter`));
2222
- }
2223
- }
2224
- catch { /* non-critical */ }
2225
- }
2226
- else if (spotExch === "hyperliquid") {
2227
- try {
2228
- const hlSpot = spotAdapter;
2229
- const bals = await hlSpot.getSpotBalances();
2230
- const usdcBal = bals.find(b => b.token.startsWith("USDC"));
2231
- const leftover = Number(usdcBal?.available ?? 0);
2232
- if (leftover > 1) {
2233
- await hlSpot.transferUsdcToPerp(Math.floor(leftover));
2234
- }
2235
- }
2236
- catch { /* non-critical */ }
2237
- }
2238
- const closeResult = {
2239
- status: closeVerified ? "closed" : "closed_unverified",
2240
- mode: "spot-perp", symbol: sym, size: closeSize,
2241
- ...(closeWarnings.length > 0 && { warnings: closeWarnings }),
2557
+ const result = {
2558
+ status: "perp_pending", mode: "spot-perp", symbol: sym,
2559
+ spotSold: soldStr, spotPending, perpError: perpUnknown, warnings: closeWarnings,
2560
+ remediation: `Perp order outcome unknown — do NOT resubmit. Check: perp -e ${perpExch} positions; perp -e ${spotDef.id} trade spot-balance`,
2242
2561
  };
2243
2562
  if (isJsonFn())
2244
- return printJson(jsonOk(closeResult));
2245
- if (closeVerified) {
2246
- console.log(chalk.green(` CLOSED (verified): ${sym} ${closeSize} units`));
2563
+ return printJson(jsonOk(result));
2564
+ for (const w of closeWarnings)
2565
+ console.log(chalk.red.bold(` ${w}`));
2566
+ console.log(chalk.red.bold(` PERP ORDER PENDING — close stopped; spot ${spotPending ? "PENDING" : `sold ${soldStr}`}. ${sym} quarantined.\n`));
2567
+ return;
2568
+ }
2569
+ shortLeft = Number(fillSizeString(Math.max(0, shortLeft), closeDec));
2570
+ const fullyClosed = perpOk && sold > 0 && Number(soldStr) >= Number(closeSize) && shortLeft === 0 && !spotPending;
2571
+ if (fullyClosed) {
2572
+ persistRemovePosition(networkOf(program), sym);
2573
+ }
2574
+ else if (!spotPending && (shortLeft > 0 || spotLeft > 0)) {
2575
+ // Persist the legs that remain (actual sizes).
2576
+ persistUpdatePosition(networkOf(program), sym, { longSize: spotLeft, shortSize: shortLeft });
2577
+ }
2578
+ // Return spot quote proceeds back to perp account (no-op when shared with perp)
2579
+ if (sold > 0) {
2580
+ try {
2581
+ const moved = await sweepSpotQuoteToPerp(spotAdapter, 0.01, { keep: preCloseQuote, max: null });
2582
+ if (moved > 0 && !isJsonFn())
2583
+ console.log(chalk.gray(` Returned ~$${moved.toFixed(2)} spot→perp on ${spotDef.displayName}`));
2247
2584
  }
2248
- else {
2249
- console.log(chalk.yellow(` CLOSED (unverified): ${sym} ${closeSize} units — check positions manually`));
2585
+ catch (e) {
2586
+ closeWarnings.push(`Spot→perp transfer of proceeds failed (funds remain in ${spotDef.id} spot): ${errMsg(e)}`);
2250
2587
  }
2251
- console.log(chalk.green(` SPOT ${spotExch}: sold ✓`));
2588
+ }
2589
+ logExecution({
2590
+ type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
2591
+ symbol: sym, side: "exit", size: soldStr,
2592
+ status: fullyClosed ? "success" : "failed", dryRun: false,
2593
+ meta: { mode: "spot-perp", requestedSize: closeSize, spotSold: sold, perpOk, spotPending, shortLeft, spotLeft, ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
2594
+ });
2595
+ const closeResult = {
2596
+ status: fullyClosed ? "closed" : spotPending ? "spot_pending" : "partial_close",
2597
+ mode: "spot-perp", symbol: sym, size: closeSize,
2598
+ spotSold: soldStr, perpClosed: perpOk,
2599
+ spotError: spotResult.status === "rejected" ? errMsg(spotResult.reason) : null,
2600
+ perpError: perpResult.status === "rejected" ? errMsg(perpResult.reason) : null,
2601
+ remaining: { spot: spotLeft, perpShort: shortLeft },
2602
+ ...(closeWarnings.length > 0 && { warnings: closeWarnings }),
2603
+ };
2604
+ if (isJsonFn())
2605
+ return printJson(jsonOk(closeResult));
2606
+ for (const w of closeWarnings)
2607
+ console.log(chalk.yellow(` ⚠ ${w}`));
2608
+ if (fullyClosed) {
2609
+ console.log(chalk.green(` CLOSED: ${sym} ${closeSize} units`));
2610
+ console.log(chalk.green(` SPOT ${spotExch}: sold ${soldStr} ✓`));
2252
2611
  console.log(chalk.green(` PERP ${perpExch}: bought (closed short) ✓\n`));
2253
2612
  }
2254
2613
  else {
2255
- const errors = [];
2256
- if (!spotOk)
2257
- errors.push(`Spot sell failed: ${spotResult.reason}`);
2258
- if (!perpOk)
2259
- errors.push(`Perp buy failed: ${perpResult.reason}`);
2260
- const result = { status: "partial_close", symbol: sym, spotOk, perpOk, errors };
2261
- if (isJsonFn())
2262
- return printJson(jsonOk(result));
2263
- console.log(chalk.red(` Close partially failed:`));
2264
- for (const e of errors)
2265
- console.log(chalk.red(` ${e}`));
2266
- console.log(chalk.yellow(` Manual intervention may be needed.\n`));
2614
+ console.log(chalk.red(` Close incomplete: spot sold ${soldStr} of ${closeSize}, perp ${perpOk ? "closed" : "NOT closed"}`));
2615
+ if (closeResult.spotError)
2616
+ console.log(chalk.red(` SPOT: ${closeResult.spotError}`));
2617
+ if (closeResult.perpError)
2618
+ console.log(chalk.red(` PERP: ${closeResult.perpError}`));
2619
+ console.log(chalk.yellow(` Remaining: spot ${spotLeft} / perp short ${shortLeft}\n`));
2267
2620
  }
2268
2621
  };
2269
2622
  // ── arb pnl logic (extracted as inner function, no command registration) ──
@@ -2493,9 +2846,77 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2493
2846
  const gradeOrder = { A: 0, B: 1, C: 2, D: 3 };
2494
2847
  const minGradeIdx = gradeOrder[opts.minGrade.toUpperCase()] ?? 2;
2495
2848
  const intervalMs = parseInt(opts.interval) * 1000;
2496
- // Check both subcommand option and global/argv (Commander may route --dry-run to parent)
2497
- const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
2849
+ // Root --dry-run flag (never re-declared on the subcommand — the root would swallow it).
2850
+ const dryRun = isDryRun(program);
2498
2851
  const openPositions = [];
2852
+ // Consecutive definite close failures per underlying (quarantined at MAX_PAIR_CLOSE_FAILURES)
2853
+ const dexCloseFailures = new Map();
2854
+ const net = networkOf(program);
2855
+ const qlog = (m) => console.error(chalk.red(m));
2856
+ if (!dryRun) {
2857
+ // A previous daemon that died between two legs → quarantine (legs may be open).
2858
+ quarantineOrphanedEntries(net, "arb-dex-auto", qlog);
2859
+ // Positions live only in memory: a restart re-reads what is held on the dexes, so a
2860
+ // held underlying is managed (closed when the spread converges), never re-entered.
2861
+ // Unpaired / size-mismatched legs are quarantined. An unreadable dex → not started.
2862
+ const assets = await fetchAllDexAssets();
2863
+ const pairsNow = findDexArbPairs(assets, { minAnnualSpread: 0, includeNative: true });
2864
+ const legs = new Map();
2865
+ for (const dex of [...new Set(assets.map(a => a.dex))]) {
2866
+ let list;
2867
+ try {
2868
+ list = await (await getHLAdapterForDex(dex)).getPositions();
2869
+ }
2870
+ catch (e) {
2871
+ throw new PerpError("EXCHANGE_ERROR", `arb auto --hip3: ${dex} positions unreadable at start (${e instanceof Error ? e.message : String(e)}) — not started (held pairs unknown)`, {
2872
+ exchange: "hyperliquid", remediation: `Check connectivity (perp -e hyperliquid account positions), then restart`,
2873
+ });
2874
+ }
2875
+ for (const p of list) {
2876
+ const size = Math.abs(Number(p.size));
2877
+ if (!(size > 0))
2878
+ continue;
2879
+ const raw = p.symbol.toUpperCase();
2880
+ const asset = assets.find(a => a.dex === dex && (a.raw.toUpperCase() === raw || (dex === "hl" && a.base.toUpperCase() === raw)))
2881
+ ?? assets.find(a => a.raw.toUpperCase() === raw);
2882
+ const assetRaw = asset?.raw ?? p.symbol;
2883
+ const pairOf = pairsNow.find(x => x.long.raw === assetRaw || x.short.raw === assetRaw);
2884
+ // One entry per venue symbol (a native read that also lists HIP-3 positions is not counted twice).
2885
+ legs.set(assetRaw.toUpperCase(), {
2886
+ dex: asset?.dex ?? dex, raw: assetRaw, base: asset?.base ?? raw, side: p.side, size, sizeStr: p.size.replace(/^-/, ""),
2887
+ underlying: pairOf?.underlying ?? quarantineKey(asset?.base ?? raw),
2888
+ });
2889
+ }
2890
+ }
2891
+ const all = [...legs.values()];
2892
+ const used = new Set();
2893
+ for (const l of all) {
2894
+ if (l.side !== "long" || used.has(l))
2895
+ continue;
2896
+ const sh = all.find(x => !used.has(x) && x.side === "short" && x.underlying === l.underlying && x.dex !== l.dex && x.size === l.size);
2897
+ if (sh === undefined)
2898
+ continue;
2899
+ used.add(l);
2900
+ used.add(sh);
2901
+ openPositions.push({ underlying: l.underlying, longDex: l.dex, longSymbol: l.raw, shortDex: sh.dex, shortSymbol: sh.raw, size: l.sizeStr, entrySpread: 0, entryTime: new Date().toISOString(), longPrice: 0, shortPrice: 0 });
2902
+ }
2903
+ const leftover = new Map();
2904
+ for (const l of all)
2905
+ if (!used.has(l))
2906
+ leftover.set(l.underlying, [...(leftover.get(l.underlying) ?? []), l]);
2907
+ for (const [u, group] of leftover) {
2908
+ if (isQuarantined(u, qlog))
2909
+ continue;
2910
+ const desc = group.map(l => `${l.side} ${l.sizeStr} ${l.raw}`).join(", ");
2911
+ const mismatched = group.some(l => l.side === "long") && group.some(l => l.side === "short");
2912
+ quarantineSymbol("arb-dex-auto", u, mismatched ? `recovery: ${u} long/short sizes do not match (${desc}) — not tracked as a pair` : `recovery: unpaired ${u} leg (${desc}) — no hedge found, not tracked`, `Check ${u} on Hyperliquid (perp -e hyperliquid account positions; every HIP-3 dex); close or re-hedge by hand, then: perp strategy quarantine-clear ${u}`, qlog);
2913
+ }
2914
+ if (openPositions.length > 0 && !isJson()) {
2915
+ console.log(chalk.yellow(` Recovered ${openPositions.length} pair(s) from the dexes: ${openPositions.map(p => `${p.underlying} ${p.longDex}/${p.shortDex} ${p.size}`).join(", ")}`));
2916
+ }
2917
+ }
2918
+ // Graceful stop: the leg sequence in flight finishes, nothing new starts, then exit.
2919
+ const stop = gracefulStop("arb auto --hip3");
2499
2920
  if (!isJson()) {
2500
2921
  console.log(chalk.cyan.bold("\n HIP-3 Cross-Dex Arb Bot\n"));
2501
2922
  console.log(` Mode: ${dryRun ? chalk.yellow("DRY RUN") : chalk.green("LIVE")}`);
@@ -2517,6 +2938,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2517
2938
  const now = new Date().toLocaleTimeString();
2518
2939
  // Check close conditions
2519
2940
  for (let i = openPositions.length - 1; i >= 0; i--) {
2941
+ if (stop.requested())
2942
+ break; // stopping: no new close sequence
2520
2943
  const pos = openPositions[i];
2521
2944
  // Find current pair for this position
2522
2945
  const current = pairs.find(p => p.underlying === pos.underlying &&
@@ -2524,6 +2947,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2524
2947
  (p.long.dex === pos.shortDex && p.short.dex === pos.longDex)));
2525
2948
  const currentSpread = current?.annualSpread ?? 0;
2526
2949
  if (currentSpread <= closeSpread || !current) {
2950
+ // Quarantined (design §12.3): hands off until a human clears it.
2951
+ if (isQuarantined(pos.underlying, (m) => console.error(chalk.red(m))))
2952
+ continue;
2527
2953
  const reason = !current ? "pair disappeared" : `spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`;
2528
2954
  if (!isJson())
2529
2955
  console.log(chalk.yellow(` ${now} CLOSE ${pos.underlying} — ${reason}`));
@@ -2531,18 +2957,46 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2531
2957
  try {
2532
2958
  const longAdapter = await getHLAdapterForDex(pos.longDex);
2533
2959
  const shortAdapter = await getHLAdapterForDex(pos.shortDex);
2534
- await Promise.all([
2535
- longAdapter.marketOrder(pos.longSymbol, "sell", pos.size),
2536
- shortAdapter.marketOrder(pos.shortSymbol, "buy", pos.size),
2537
- ]);
2538
- logExecution({
2539
- type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
2540
- symbol: pos.underlying, side: "close", size: String(pos.size),
2541
- status: "success", dryRun: false,
2542
- meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
2960
+ // Sequential, reduce-only, verified; unknown / unhedged → quarantine.
2961
+ const res = await closePerpPairVerified({
2962
+ source: "arb-dex-auto", sym: pos.underlying,
2963
+ longAdapter, longExch: pos.longDex, longSym: pos.longSymbol, longSize: pos.size,
2964
+ shortAdapter, shortExch: pos.shortDex, shortSym: pos.shortSymbol, shortSize: pos.size, smart: false,
2965
+ log: (m) => console.error(chalk.red(m)),
2543
2966
  });
2544
- if (!isJson())
2545
- console.log(chalk.green(` ${now} CLOSED ${pos.underlying} — both legs`));
2967
+ if (res.status === "closed") {
2968
+ dexCloseFailures.delete(pos.underlying);
2969
+ logExecution({
2970
+ type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
2971
+ symbol: pos.underlying, side: "close", size: String(pos.size),
2972
+ status: "success", dryRun: false,
2973
+ meta: { longDex: pos.longDex, shortDex: pos.shortDex, reason, longSymbol: pos.longSymbol, shortSymbol: pos.shortSymbol },
2974
+ });
2975
+ if (!isJson())
2976
+ console.log(chalk.green(` ${now} CLOSED ${pos.underlying} — both legs (reduce-only, verified)`));
2977
+ openPositions.splice(i, 1);
2978
+ }
2979
+ else {
2980
+ let note = res.reason;
2981
+ if (res.status === "failed") {
2982
+ const n = (dexCloseFailures.get(pos.underlying) ?? 0) + 1;
2983
+ dexCloseFailures.set(pos.underlying, n);
2984
+ note = `${res.reason} — both legs still open (${n}/${MAX_PAIR_CLOSE_FAILURES})`;
2985
+ if (n >= MAX_PAIR_CLOSE_FAILURES) {
2986
+ quarantinePair("arb-dex-auto", pos.underlying, pos.longDex, pos.shortDex, `close failed ${n} times in a row (last: ${res.reason})`, (m) => console.error(chalk.red(m)));
2987
+ }
2988
+ }
2989
+ logExecution({
2990
+ type: "arb_close", exchange: `${pos.longDex}+${pos.shortDex}`,
2991
+ symbol: pos.underlying, side: "close", size: String(pos.size),
2992
+ status: "failed", dryRun: false,
2993
+ error: res.status === "quarantined" ? `QUARANTINED: ${note}` : note,
2994
+ meta: { longDex: pos.longDex, shortDex: pos.shortDex },
2995
+ });
2996
+ if (!isJson())
2997
+ console.error(chalk.red(` ${now} CLOSE ${res.status === "quarantined" ? "QUARANTINED" : "FAILED"} ${pos.underlying}: ${note}`));
2998
+ // Kept tracked: exposure remains (retried only after a definite, no-change failure).
2999
+ }
2546
3000
  }
2547
3001
  catch (err) {
2548
3002
  logExecution({
@@ -2553,15 +3007,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2553
3007
  meta: { longDex: pos.longDex, shortDex: pos.shortDex },
2554
3008
  });
2555
3009
  if (!isJson())
2556
- console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err}`));
3010
+ console.error(chalk.red(` ${now} CLOSE FAILED ${pos.underlying}: ${err instanceof Error ? err.message : err} (nothing sent)`));
2557
3011
  }
2558
3012
  }
2559
- openPositions.splice(i, 1);
3013
+ else {
3014
+ openPositions.splice(i, 1);
3015
+ }
2560
3016
  }
2561
3017
  }
2562
3018
  // Check entry conditions
2563
3019
  if (openPositions.length < maxPositions) {
2564
3020
  for (const pair of pairs) {
3021
+ if (stop.requested())
3022
+ break; // stopping: no new entries
2565
3023
  if (openPositions.some(p => p.underlying === pair.underlying))
2566
3024
  continue;
2567
3025
  if (openPositions.length >= maxPositions)
@@ -2572,35 +3030,73 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2572
3030
  continue;
2573
3031
  if (gradeOrder[pair.viability] > minGradeIdx)
2574
3032
  continue;
3033
+ if (isQuarantined(pair.underlying, (m) => console.error(chalk.red(m))))
3034
+ continue;
2575
3035
  // Calculate size in asset units
2576
3036
  const avgPrice = (pair.long.markPrice + pair.short.markPrice) / 2;
2577
3037
  if (avgPrice <= 0)
2578
3038
  continue;
2579
3039
  const szDecimals = Math.min(pair.long.szDecimals, pair.short.szDecimals);
2580
3040
  const rawSize = sizeUsd / avgPrice;
2581
- const size = rawSize.toFixed(szDecimals);
3041
+ const size = fillSizeString(rawSize, szDecimals); // floored
3042
+ if (!(Number(size) > 0))
3043
+ continue;
2582
3044
  if (!isJson()) {
2583
3045
  console.log(chalk.green(` ${now} ENTER ${pair.underlying} — spread ${pair.annualSpread.toFixed(1)}% grade:${pair.viability} OI:$${formatUsd(pair.minOiUsd)}` +
2584
3046
  `\n Long ${pair.long.dex}:${pair.long.base} (${(pair.long.fundingRate * 100).toFixed(4)}%)` +
2585
3047
  ` | Short ${pair.short.dex}:${pair.short.base} (${(pair.short.fundingRate * 100).toFixed(4)}%)` +
2586
3048
  ` | ${size} units @ $${avgPrice.toFixed(2)}`));
2587
3049
  }
3050
+ let openedSize = size;
2588
3051
  if (!dryRun) {
3052
+ // Pending-entry record BEFORE the first leg (crash between legs → quarantine at the next start).
3053
+ try {
3054
+ writePendingEntry(net, { source: "arb-dex-auto", symbol: pair.underlying, longExchange: pair.long.dex, shortExchange: pair.short.dex, size });
3055
+ }
3056
+ catch (e) {
3057
+ if (!isJson())
3058
+ console.error(chalk.red(` ${now} SKIP ${pair.underlying}: could not record the pending entry (${e instanceof Error ? e.message : String(e)}) — nothing sent`));
3059
+ continue;
3060
+ }
3061
+ let legsStarted = false;
2589
3062
  try {
2590
3063
  const longAdapter = await getHLAdapterForDex(pair.long.dex);
2591
3064
  const shortAdapter = await getHLAdapterForDex(pair.short.dex);
2592
- await Promise.all([
2593
- longAdapter.marketOrder(pair.long.raw, "buy", size),
2594
- shortAdapter.marketOrder(pair.short.raw, "sell", size),
2595
- ]);
3065
+ // Sequential verified legs; reduce-only rollback / trim; unknown → quarantine.
3066
+ legsStarted = true;
3067
+ const res = await openPerpPairVerified({
3068
+ source: "arb-dex-auto", sym: pair.underlying,
3069
+ longAdapter, longExch: pair.long.dex, longSym: pair.long.raw,
3070
+ shortAdapter, shortExch: pair.short.dex, shortSym: pair.short.raw,
3071
+ size, smart: false, log: (m) => console.error(chalk.red(m)),
3072
+ });
3073
+ legsStarted = false; // outcome known
3074
+ if (res.status !== "opened") {
3075
+ finishPendingEntry(net, "arb-dex-auto", pair.underlying, qlog);
3076
+ const error = res.status === "quarantined"
3077
+ ? `QUARANTINED: ${res.reason} (possible exposure: ${res.exposure})`
3078
+ : `${res.reason}${res.rolledBack !== null ? ` — long ${res.rolledBack} rolled back` : ""}`;
3079
+ logExecution({
3080
+ type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
3081
+ symbol: pair.underlying, side: "entry", size,
3082
+ status: "failed", dryRun: false, error,
3083
+ meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
3084
+ });
3085
+ if (!isJson())
3086
+ console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${error}`));
3087
+ continue;
3088
+ }
3089
+ openedSize = res.hedged;
3090
+ // Tracked in memory below (synchronously, before anything else can throw); a restart recovers it from the dexes.
3091
+ finishPendingEntry(net, "arb-dex-auto", pair.underlying, qlog);
2596
3092
  logExecution({
2597
3093
  type: "arb_entry", exchange: `${pair.long.dex}+${pair.short.dex}`,
2598
- symbol: pair.underlying, side: "entry", size,
3094
+ symbol: pair.underlying, side: "entry", size: res.hedged,
2599
3095
  status: "success", dryRun: false,
2600
- meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice },
3096
+ meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread, viability: pair.viability, avgPrice, requested: size },
2601
3097
  });
2602
3098
  if (!isJson())
2603
- console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs`));
3099
+ console.log(chalk.green(` ${now} FILLED ${pair.underlying} — both legs @ ${res.hedged} (verified)`));
2604
3100
  }
2605
3101
  catch (err) {
2606
3102
  logExecution({
@@ -2610,8 +3106,13 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2610
3106
  error: err instanceof Error ? err.message : String(err),
2611
3107
  meta: { longDex: pair.long.dex, shortDex: pair.short.dex, spread: pair.annualSpread },
2612
3108
  });
3109
+ if (legsStarted) {
3110
+ // Threw inside the leg sequence: what is open is unknown → quarantine (never retried).
3111
+ quarantinePair("arb-dex-auto", pair.underlying, pair.long.dex, pair.short.dex, `entry threw mid-sequence (${err instanceof Error ? err.message : String(err)}) — legs may be open`, qlog);
3112
+ }
3113
+ finishPendingEntry(net, "arb-dex-auto", pair.underlying, qlog);
2613
3114
  if (!isJson())
2614
- console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}`));
3115
+ console.error(chalk.red(` ${now} ENTRY FAILED ${pair.underlying}: ${err instanceof Error ? err.message : err}${legsStarted ? " (quarantined)" : " (nothing sent)"}`));
2615
3116
  continue;
2616
3117
  }
2617
3118
  }
@@ -2621,7 +3122,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2621
3122
  longSymbol: pair.long.raw,
2622
3123
  shortDex: pair.short.dex,
2623
3124
  shortSymbol: pair.short.raw,
2624
- size,
3125
+ size: openedSize,
2625
3126
  entrySpread: pair.annualSpread,
2626
3127
  entryTime: new Date().toISOString(),
2627
3128
  longPrice: pair.long.markPrice,
@@ -2650,9 +3151,15 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
2650
3151
  console.error(chalk.gray(` Error: ${err instanceof Error ? err.message : String(err)}`));
2651
3152
  }
2652
3153
  };
2653
- await cycle();
2654
- setInterval(cycle, intervalMs);
2655
- await new Promise(() => { }); // keep alive
3154
+ // Serialized cycles (never overlapping): the next starts only after this one finished.
3155
+ while (!stop.requested()) {
3156
+ await cycle();
3157
+ await interruptibleSleep(intervalMs, stop.requested);
3158
+ }
3159
+ stop.dispose();
3160
+ if (!isJson())
3161
+ console.log(chalk.yellow(`\n HIP-3 daemon stopped (current cycle finished). ${openPositions.length} pair(s) left open: ${openPositions.map(p => `${p.underlying} ${p.longDex}/${p.shortDex}`).join(", ") || "none"}\n`));
3162
+ process.exit(0);
2656
3163
  }
2657
3164
  // Expose inner handlers for external use
2658
3165
  handleArbPnlRef = _handleArbPnl;