perp-cli 0.12.15 → 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 +278 -31
  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 +74 -84
  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 +157 -682
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +14 -0
  287. package/dist/landing.js +36 -0
  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 +338 -134
  348. package/skills/perp-cli/references/agent-operations.md +181 -92
  349. package/skills/perp-cli/references/commands.md +475 -132
  350. package/skills/perp-cli/references/strategies.md +44 -38
  351. package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
  352. package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
  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 +33 -16
  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,58 @@
1
+ import { isDryRun, networkOf, supportsDryRun } from "../../cli-flags.js";
1
2
  import chalk from "chalk";
2
3
  import { makeTable, formatUsd, formatPnl, printJson, jsonOk, logSettledRejections } from "../../utils.js";
3
4
  import { SPOT_PERP_TOKEN_MAP } from "../../exchanges/index.js";
4
5
  import { readExecutionLog, logExecution } from "../../execution-log.js";
5
- import { toHourlyRate, computeAnnualSpread } from "../../funding.js";
6
+ import { toHourlyRate } from "../../funding.js";
6
7
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
7
8
  import { computeBasisRisk } from "../../arb-utils.js";
8
- import { smartOrder } from "../../smart-order.js";
9
9
  import { removePosition as persistRemovePosition, getPositions as getArbStatePositions, loadArbState } from "../../arb-state.js";
10
10
  import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
11
11
  import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
12
12
  import { computeEnhancedStats } from "../../arb-history-stats.js";
13
+ import { listBotSpotVenues, nonQuoteSpotBalances, requireSpotAdapter, spotNetworkOf } from "../../exchanges/spot-factory.js";
14
+ import { listConfiguredAccountVenues } from "../../config.js";
15
+ import { closePerpPairVerified, handleSpotClose } from "../arb-auto.js";
16
+ import { quarantineEntryFor } from "../../bot/quarantine.js";
17
+ import { PerpError, classifyError, parsePositiveDecimal } from "../../errors.js";
18
+ import { listVenuesWithRole, resolveVenueId } from "../../exchanges/venue.js";
13
19
  // ── Helpers ──
14
20
  import { DEFAULT_TAKER_FEE as TAKER_FEE } from "../../constants.js";
15
- const EXCHANGES = ["hyperliquid", "lighter", "pacifica"];
21
+ /**
22
+ * Venues an arb leg can sit on: every registered trading venue with a configured
23
+ * credential source (registry order). Never a hard-coded list — a venue added to the
24
+ * registry is covered by `arb status` / `arb close` without touching this file.
25
+ */
26
+ export async function listArbVenues() {
27
+ const configured = await listConfiguredAccountVenues(); // registers the venues
28
+ const trading = new Set(listVenuesWithRole("trading").map(v => v.id));
29
+ return configured.filter(v => trading.has(v));
30
+ }
31
+ /**
32
+ * Positions of every venue in `venues`. A venue whose adapter cannot be built or whose
33
+ * positions read fails is an error (Rule #2): an unread venue is not "no positions" —
34
+ * a hidden leg would make a live pair look closed.
35
+ */
36
+ export async function readArbVenuePositions(venues, getAdapter) {
37
+ const out = [];
38
+ for (const exchange of venues) {
39
+ let positions;
40
+ try {
41
+ positions = await (await getAdapter(exchange)).getPositions();
42
+ }
43
+ catch (e) {
44
+ const c = classifyError(e, exchange);
45
+ throw new PerpError(c.code, `Cannot read positions on ${exchange}: ${c.message}`, {
46
+ exchange,
47
+ venuesChecked: [...venues],
48
+ remediation: `Arb pairs are detected from every configured venue's positions, so none may be skipped. Check the venue: perp account positions -e ${exchange}`,
49
+ });
50
+ }
51
+ for (const position of positions)
52
+ out.push({ exchange, position });
53
+ }
54
+ return out;
55
+ }
16
56
  function formatDuration(ms) {
17
57
  const hours = Math.floor(ms / (1000 * 60 * 60));
18
58
  const days = Math.floor(hours / 24);
@@ -24,6 +64,10 @@ function formatDuration(ms) {
24
64
  const minutes = Math.floor(ms / (1000 * 60));
25
65
  return `${minutes}m`;
26
66
  }
67
+ /** hourly rate of a rate entry: the entry's own interval (Aster, per symbol) or the venue basis. */
68
+ function entryHourly(e) {
69
+ return e.fundingHours !== null ? e.rate / e.fundingHours : toHourlyRate(e.rate, e.exchange);
70
+ }
27
71
  async function fetchFundingRatesMap() {
28
72
  const rateMap = new Map();
29
73
  // SSOT rule #2: each DEX is independent. allSettled keeps the comparison
@@ -35,19 +79,28 @@ async function fetchFundingRatesMap() {
35
79
  fetchLighterOrderBookDetails(),
36
80
  fetchLighterFundingRates(),
37
81
  fetch("https://fapi.asterdex.com/fapi/v1/premiumIndex").then(r => r.json()),
82
+ fetch("https://fapi.asterdex.com/fapi/v3/fundingInfo").then(r => r.json()),
38
83
  ]);
39
- logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex"], "arb");
84
+ logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex", "aster:fundingInfo"], "arb");
40
85
  const pacAssets = settled[0].status === "fulfilled" ? settled[0].value : [];
41
86
  const hlAssets = settled[1].status === "fulfilled" ? settled[1].value : [];
42
87
  const ltDetails = settled[2].status === "fulfilled" ? settled[2].value : [];
43
88
  const ltFunding = settled[3].status === "fulfilled" ? settled[3].value : [];
44
- const asterPremiums = settled[4].status === "fulfilled" ? settled[4].value : [];
45
- const addRate = (sym, exchange, rate, markPrice) => {
89
+ // Aster funding basis is per symbol: a rate is usable only with its fundingInfo interval.
90
+ const asterPremiums = settled[4].status === "fulfilled" && settled[5].status === "fulfilled" ? settled[4].value : [];
91
+ const asterInfo = settled[5].status === "fulfilled" ? settled[5].value : [];
92
+ const asterHours = new Map();
93
+ for (const row of Array.isArray(asterInfo) ? asterInfo : []) {
94
+ const h = Number(row.fundingIntervalHours);
95
+ if (typeof row.symbol === "string" && Number.isFinite(h) && h > 0)
96
+ asterHours.set(row.symbol, h);
97
+ }
98
+ const addRate = (sym, exchange, rate, markPrice, fundingHours = null) => {
46
99
  if (!sym)
47
100
  return;
48
101
  if (!rateMap.has(sym))
49
102
  rateMap.set(sym, []);
50
- rateMap.get(sym).push({ exchange, rate, markPrice });
103
+ rateMap.get(sym).push({ exchange, rate, markPrice, fundingHours });
51
104
  };
52
105
  for (const p of pacAssets)
53
106
  addRate(p.symbol, "pacifica", p.funding, p.mark);
@@ -87,10 +140,71 @@ async function fetchFundingRatesMap() {
87
140
  const markPrice = Number(p.markPrice);
88
141
  if (!Number.isFinite(fundingRate) || !Number.isFinite(markPrice) || markPrice <= 0)
89
142
  continue;
90
- addRate(rawSym.replace(/USDT$/, ""), "aster", fundingRate, markPrice);
143
+ const hours = asterHours.get(rawSym);
144
+ if (hours === undefined)
145
+ continue; // not in /fapi/v3/fundingInfo → cannot normalize
146
+ addRate(rawSym.replace(/USDT$/, ""), "aster", fundingRate, markPrice, hours);
91
147
  }
92
148
  return rateMap;
93
149
  }
150
+ /**
151
+ * State-file entries the venues do not confirm: no live pair with the same symbol and
152
+ * legs. `arb status` reports them apart from the live pairs (the venue is the source of truth).
153
+ */
154
+ export function staleArbStateEntries(persisted, livePerpPerp, liveSpotPerp) {
155
+ const out = [];
156
+ for (const st of persisted) {
157
+ const sym = st.symbol.toUpperCase();
158
+ const live = st.mode === "spot-perp"
159
+ ? liveSpotPerp.some(p => p.symbol === sym && p.perpExchange === st.shortExchange)
160
+ : livePerpPerp.some(p => p.symbol === sym && p.longExchange === st.longExchange && p.shortExchange === st.shortExchange);
161
+ if (live)
162
+ continue;
163
+ out.push({
164
+ id: st.id, symbol: sym, mode: st.mode === "spot-perp" ? "spot-perp" : "perp-perp",
165
+ longExchange: st.longExchange, shortExchange: st.shortExchange, entryTime: st.entryTime,
166
+ reason: st.mode === "spot-perp"
167
+ ? `no ${sym} perp short on ${st.shortExchange}`
168
+ : `no ${sym} long on ${st.longExchange} with a short on ${st.shortExchange}`,
169
+ });
170
+ }
171
+ return out;
172
+ }
173
+ /** Per-leg engine entries (funding-arb-strategy: one record per leg) within this window form one pair entry. */
174
+ const LEG_PAIRING_WINDOW_MS = 10 * 60_000;
175
+ /**
176
+ * Whether a live long/short pair was opened by arb: an arb state entry for exactly these legs
177
+ * ("state"), else the latest successful arb_entry for exactly these legs in the execution log —
178
+ * combined (meta.arbPairId / meta.longExchange + shortExchange) or per-leg (meta.leg long on the
179
+ * long venue + leg short on the short venue within 10 min) — with no successful arb_close of the
180
+ * pair after it ("executionLog"). Anything else is "inferred". Never a symbol-only match.
181
+ */
182
+ export function arbPairProvenance(symbol, longExchange, shortExchange, persisted, records) {
183
+ const sym = symbol.toUpperCase();
184
+ if (persisted.some(p => p.mode !== "spot-perp" && p.symbol.toUpperCase() === sym && p.longExchange === longExchange && p.shortExchange === shortExchange))
185
+ return "state";
186
+ const pairId = `${sym}:${longExchange}:${shortExchange}`;
187
+ const mine = records.filter(r => r.status === "success" && r.symbol.toUpperCase() === sym);
188
+ const combined = (r) => r.meta?.arbPairId === pairId || (r.meta?.longExchange === longExchange && r.meta?.shortExchange === shortExchange);
189
+ const t = (r) => new Date(r.timestamp).getTime();
190
+ let entryAt = -Infinity;
191
+ for (const r of mine.filter(x => x.type === "arb_entry")) {
192
+ if (combined(r)) {
193
+ entryAt = Math.max(entryAt, t(r));
194
+ continue;
195
+ }
196
+ if (r.meta?.leg === "long" && r.exchange === longExchange) {
197
+ const short = mine.find(x => x.type === "arb_entry" && x.meta?.leg === "short" && x.exchange === shortExchange && Math.abs(t(x) - t(r)) <= LEG_PAIRING_WINDOW_MS);
198
+ if (short !== undefined)
199
+ entryAt = Math.max(entryAt, t(r), t(short));
200
+ }
201
+ }
202
+ if (entryAt === -Infinity)
203
+ return "inferred";
204
+ const closedAfter = mine.some(r => r.type === "arb_close" && t(r) > entryAt
205
+ && (combined(r) || (r.meta?.leg === "long" && r.exchange === longExchange) || (r.meta?.leg === "short" && r.exchange === shortExchange)));
206
+ return closedAfter ? "inferred" : "executionLog";
207
+ }
94
208
  function findArbEntryForPair(symbol, longExchange, shortExchange) {
95
209
  const entries = readExecutionLog({ type: "arb_entry", symbol });
96
210
  const successful = entries.filter(e => e.status === "success");
@@ -118,7 +232,7 @@ function getCurrentSpreadForSymbol(symbol, longExchange, shortExchange, rateMap)
118
232
  const shortRate = rates.find(r => r.exchange === shortExchange);
119
233
  if (!longRate || !shortRate)
120
234
  return null;
121
- return computeAnnualSpread(shortRate.rate, shortRate.exchange, longRate.rate, longRate.exchange);
235
+ return Math.abs(entryHourly(shortRate) - entryHourly(longRate)) * 24 * 365 * 100;
122
236
  }
123
237
  // ── Registration ──
124
238
  export function registerArbManageCommands(program, getAdapterForExchange, isJson) {
@@ -136,26 +250,18 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
136
250
  console.log(chalk.cyan("\n Checking arb positions across exchanges...\n"));
137
251
  // Fetch positions from all exchanges
138
252
  const allPositions = [];
139
- for (const exName of EXCHANGES) {
140
- try {
141
- const adapter = await getAdapterForExchange(exName);
142
- const positions = await adapter.getPositions();
143
- for (const p of positions) {
144
- allPositions.push({
145
- exchange: exName,
146
- symbol: p.symbol.replace("-PERP", "").toUpperCase(),
147
- side: p.side,
148
- size: Math.abs(Number(p.size)),
149
- entryPrice: Number(p.entryPrice),
150
- markPrice: Number(p.markPrice),
151
- unrealizedPnl: Number(p.unrealizedPnl),
152
- leverage: p.leverage,
153
- });
154
- }
155
- }
156
- catch {
157
- // exchange not configured, skip
158
- }
253
+ const venuesChecked = await listArbVenues();
254
+ for (const { exchange, position: p } of await readArbVenuePositions(venuesChecked, getAdapterForExchange)) {
255
+ allPositions.push({
256
+ exchange,
257
+ symbol: p.symbol.replace("-PERP", "").toUpperCase(),
258
+ side: p.side,
259
+ size: Math.abs(Number(p.size)),
260
+ entryPrice: Number(p.entryPrice),
261
+ markPrice: Number(p.markPrice),
262
+ unrealizedPnl: Number(p.unrealizedPnl),
263
+ leverage: p.leverage,
264
+ });
159
265
  }
160
266
  // Group by symbol to detect arb pairs
161
267
  const bySymbol = new Map();
@@ -166,6 +272,8 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
166
272
  }
167
273
  // Find arb pairs: same symbol, different exchanges, one long + one short
168
274
  const arbPairs = [];
275
+ const persistedForPairs = getArbStatePositions(networkOf(program));
276
+ const arbRecords = [...readExecutionLog({ type: "arb_entry" }), ...readExecutionLog({ type: "arb_close" })];
169
277
  // Fetch current funding rates for spread calculation
170
278
  let rateMap;
171
279
  try {
@@ -222,6 +330,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
222
330
  symbol,
223
331
  longExchange: longPos.exchange,
224
332
  shortExchange: shortPos.exchange,
333
+ provenance: arbPairProvenance(symbol, longPos.exchange, shortPos.exchange, persistedForPairs, arbRecords),
225
334
  longPosition: {
226
335
  side: "long",
227
336
  size: longPos.size,
@@ -270,34 +379,23 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
270
379
  };
271
380
  // Collect spot balances per exchange
272
381
  const spotBalancesByExchange = new Map();
273
- const SPOT_EXCHANGES = ["hyperliquid", "lighter"];
274
- for (const exName of SPOT_EXCHANGES) {
382
+ // Bot/arb spot scope (§12.4 #3): configured venues with a separate spot balance + transfer.
383
+ const configuredVenues = new Set(await listConfiguredAccountVenues());
384
+ for (const def of listBotSpotVenues()) {
385
+ if (!configuredVenues.has(def.id))
386
+ continue;
275
387
  try {
276
- const adapter = await getAdapterForExchange(exName);
277
- if (exName === "hyperliquid") {
278
- const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
279
- const hlSpot = new HyperliquidSpotAdapter(adapter);
280
- await hlSpot.init();
281
- const balances = await hlSpot.getSpotBalances();
282
- spotBalancesByExchange.set(exName, balances.filter(b => {
283
- const base = b.token.replace(/-SPOT$/i, "").toUpperCase();
284
- return base !== "USDC" && Number(b.total) > 0;
285
- }));
286
- }
287
- else if (exName === "lighter") {
288
- const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
289
- const ltSpot = new LighterSpotAdapter(adapter);
290
- await ltSpot.init();
291
- const balances = await ltSpot.getSpotBalances();
292
- spotBalancesByExchange.set(exName, balances.filter(b => b.token !== "USDC" && b.token !== "USDC_SPOT" && Number(b.total) > 0));
293
- }
388
+ const adapter = await getAdapterForExchange(def.id);
389
+ const spot = await requireSpotAdapter(def.id, adapter);
390
+ spotBalancesByExchange.set(def.id, nonQuoteSpotBalances(def, spotNetworkOf(spot), await spot.getSpotBalances()));
294
391
  }
295
- catch {
296
- // exchange not configured or no spot support
392
+ catch (e) {
393
+ process.stderr.write(`[warn] ${def.displayName} spot balances unavailable: ${e instanceof Error ? e.message : String(e)}\n`);
297
394
  }
298
395
  }
299
396
  // Load persisted arb state for spot-perp entries
300
- const persistedSpotPerp = getArbStatePositions().filter(p => p.mode === "spot-perp");
397
+ const persistedAll = getArbStatePositions(networkOf(program));
398
+ const persistedSpotPerp = persistedAll.filter(p => p.mode === "spot-perp");
301
399
  const persistedSymbols = new Set(persistedSpotPerp.map(p => p.symbol.toUpperCase()));
302
400
  // Match: spot balance + perp short on same symbol (different or same exchange)
303
401
  for (const [spotEx, balances] of spotBalancesByExchange) {
@@ -313,14 +411,18 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
313
411
  if (refPrice > 0 && spotAmount * refPrice < 1)
314
412
  continue;
315
413
  for (const shortPos of matchingShorts) {
316
- // Check if this short is already used by a perp-perp pair
317
- const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange);
414
+ // Check if this short is already used by a CONFIRMED perp-perp pair
415
+ const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange && ap.provenance !== "inferred");
318
416
  if (usedByPerpPerp)
319
417
  continue;
320
418
  // Look up persisted state for hold time and entry info
321
419
  const persisted = persistedSpotPerp.find(p => p.symbol.toUpperCase() === perpSymbol
322
420
  && p.spotExchange === spotEx
323
421
  && p.shortExchange === shortPos.exchange);
422
+ // The arb's own spot size (the state entry), never the whole holding (pre-existing spot is reported apart).
423
+ const heldSpotAmount = spotAmount;
424
+ const arbSpotAmount = persisted !== undefined ? Math.min(persisted.longSize, heldSpotAmount) : heldSpotAmount;
425
+ const otherSpotAmount = Math.max(0, heldSpotAmount - arbSpotAmount);
324
426
  const entryTime = persisted?.entryTime ?? null;
325
427
  const holdDurationMs = entryTime ? Date.now() - new Date(entryTime).getTime() : null;
326
428
  const holdDuration = holdDurationMs ? formatDuration(holdDurationMs) : null;
@@ -328,10 +430,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
328
430
  const rates = rateMap.get(perpSymbol);
329
431
  const perpRate = rates?.find(r => r.exchange === shortPos.exchange);
330
432
  const fundingRate = perpRate?.rate ?? 0;
331
- const hourlyRate = fundingRate ? toHourlyRate(fundingRate, shortPos.exchange) : 0;
433
+ const hourlyRate = perpRate && fundingRate ? entryHourly(perpRate) : 0;
332
434
  const annualPct = hourlyRate * 8760 * 100;
333
435
  const perpNotional = shortPos.size * shortPos.markPrice;
334
- const spotValueUsd = spotAmount * shortPos.markPrice; // approx using perp mark price
436
+ const spotValueUsd = arbSpotAmount * shortPos.markPrice; // approx using perp mark price
335
437
  // Funding income: fetch actual payments from exchange API
336
438
  let actualFundingIncome = 0;
337
439
  try {
@@ -368,7 +470,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
368
470
  spotExchange: spotEx,
369
471
  perpExchange: shortPos.exchange,
370
472
  spotToken: bal.token.replace(/-SPOT$/i, ""),
371
- spotAmount,
473
+ spotAmount: arbSpotAmount,
474
+ heldSpotAmount,
475
+ otherSpotAmount,
476
+ confirmed: persisted !== undefined,
372
477
  spotValueUsd,
373
478
  perpSide: "short",
374
479
  perpSize: shortPos.size,
@@ -383,7 +488,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
383
488
  holdDurationMs,
384
489
  estimatedFundingIncome,
385
490
  estimatedFees: entryFees,
386
- netPnl: (spotValueUsd - shortPos.entryPrice * spotAmount) + shortPos.unrealizedPnl + estimatedFundingIncome - entryFees,
491
+ netPnl: (spotValueUsd - shortPos.entryPrice * arbSpotAmount) + shortPos.unrealizedPnl + estimatedFundingIncome - entryFees,
387
492
  dailyFundingEstimate,
388
493
  });
389
494
  }
@@ -402,7 +507,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
402
507
  const holdDurationMs = Date.now() - new Date(persisted.entryTime).getTime();
403
508
  const rates = rateMap.get(sym);
404
509
  const perpRate = rates?.find(r => r.exchange === persisted.shortExchange);
405
- const hourlyRate = perpRate ? toHourlyRate(perpRate.rate, persisted.shortExchange) : 0;
510
+ const hourlyRate = perpRate ? entryHourly(perpRate) : 0;
406
511
  const perpNotional = shortPos.size * shortPos.markPrice;
407
512
  spotPerpPositions.push({
408
513
  symbol: sym,
@@ -411,6 +516,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
411
516
  perpExchange: persisted.shortExchange,
412
517
  spotToken: "?",
413
518
  spotAmount: 0,
519
+ heldSpotAmount: 0,
520
+ otherSpotAmount: 0,
521
+ confirmed: true,
414
522
  spotValueUsd: 0,
415
523
  perpSide: "short",
416
524
  perpSize: shortPos.size,
@@ -429,6 +537,13 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
429
537
  dailyFundingEstimate: Math.abs(hourlyRate) * perpNotional * 24,
430
538
  });
431
539
  }
540
+ // State vs venue: the venue is the source of truth. A pair is reported only when the
541
+ // venues show its legs; the state file contributes entry time / funding to a pair that
542
+ // is live. State entries with no live counterpart are listed apart, never as positions.
543
+ const staleStateEntries = staleArbStateEntries(persistedAll, arbPairs, spotPerpPositions);
544
+ // Pairs only inferred from a long and a short sharing the symbol are reported apart (never as arb positions).
545
+ const unconfirmedPairs = arbPairs.filter(p => p.provenance === "inferred");
546
+ arbPairs.splice(0, arbPairs.length, ...arbPairs.filter(p => p.provenance !== "inferred"));
432
547
  // ── JSON output ──
433
548
  if (isJson()) {
434
549
  const totalNotionalPP = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
@@ -440,6 +555,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
440
555
  return printJson(jsonOk({
441
556
  perpPerpPairs: arbPairs,
442
557
  spotPerpPairs: spotPerpPositions,
558
+ venuesChecked,
559
+ staleStateEntries,
560
+ unconfirmedPairs,
443
561
  aggregate: {
444
562
  perpPerpCount: arbPairs.length,
445
563
  spotPerpCount: spotPerpPositions.length,
@@ -454,8 +572,14 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
454
572
  },
455
573
  }));
456
574
  }
575
+ for (const st of staleStateEntries) {
576
+ console.log(chalk.yellow(` Stale state entry (not on the venue, not counted): ${st.symbol} ${st.mode} ${st.longExchange}/${st.shortExchange} since ${st.entryTime} — drop it: perp arb close ${st.symbol} --state-only`));
577
+ }
578
+ for (const u of unconfirmedPairs) {
579
+ console.log(chalk.gray(` Unconfirmed (inferred, not counted): ${u.symbol} long ${u.longExchange} ${u.longPosition.size} / short ${u.shortExchange} ${u.shortPosition.size} — no arb state / entry record for these legs; close only knowingly: perp arb close ${u.symbol} --pair ${u.longExchange}:${u.shortExchange}`));
580
+ }
457
581
  if (arbPairs.length === 0 && spotPerpPositions.length === 0) {
458
- console.log(chalk.gray(" No open arb positions found.\n"));
582
+ console.log(chalk.gray(` No open arb positions found on ${venuesChecked.join(", ")}.\n`));
459
583
  return;
460
584
  }
461
585
  // ── Perp-Perp table ──
@@ -505,7 +629,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
505
629
  const legRows = [
506
630
  [
507
631
  chalk.green("Spot"),
508
- `${sp.spotAmount.toFixed(4)} ${sp.spotToken}`,
632
+ `${sp.spotAmount.toFixed(4)} ${sp.spotToken}${sp.otherSpotAmount > 0 ? chalk.gray(` (+${sp.otherSpotAmount.toFixed(4)} other)`) : ""}${sp.confirmed ? "" : chalk.yellow(" (unconfirmed)")}`,
509
633
  `$${sp.perpEntryPrice.toFixed(4)}`,
510
634
  `$${spotPriceEach.toFixed(4)}`,
511
635
  formatPnl(spotPnl),
@@ -564,178 +688,107 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
564
688
  // Always show actual funding payments earned
565
689
  await printFundingEarned(getAdapterForExchange, isJson, { period: opts.period, symbol: opts.symbol });
566
690
  });
691
+ /**
692
+ * `arb close <sym> --state-only`: delete the arb state entry of `sym` ONLY when the venues show
693
+ * no live position for it (the same reconciliation as `arb status` staleStateEntries). Without
694
+ * --yes (or with --dry-run) the entry is only shown. Nothing is sent to any venue.
695
+ */
696
+ async function dropStaleStateEntry(sym, yes, dryRun) {
697
+ const net = networkOf(program);
698
+ const entries = getArbStatePositions(net).filter(p => p.symbol.toUpperCase() === sym);
699
+ if (entries.length === 0) {
700
+ throw new PerpError("POSITION_NOT_FOUND", `No arb state entry for ${sym} on ${net}`, { remediation: "See the state entries: perp arb status" });
701
+ }
702
+ const venuesChecked = await listArbVenues();
703
+ const live = (await readArbVenuePositions(venuesChecked, getAdapterForExchange))
704
+ .filter(x => x.position.symbol.replace("-PERP", "").toUpperCase() === sym)
705
+ .map(x => ({ exchange: x.exchange, side: x.position.side }));
706
+ const livePerp = live.filter(l => l.side === "long").flatMap(l => live.filter(s => s.side === "short" && s.exchange !== l.exchange).map(s => ({ symbol: sym, longExchange: l.exchange, shortExchange: s.exchange })));
707
+ const liveSpot = live.filter(l => l.side === "short").map(s => ({ symbol: sym, spotExchange: "", perpExchange: s.exchange }));
708
+ const stale = staleArbStateEntries(entries, livePerp, liveSpot);
709
+ if (stale.length !== entries.length) {
710
+ throw new PerpError("INVALID_PARAMS", `The ${sym} arb state entry is not stale: the venues still show its legs — close it instead: perp arb close ${sym}`, {
711
+ live, remediation: `Close the position: perp arb close ${sym}`,
712
+ });
713
+ }
714
+ if (!yes || dryRun) {
715
+ if (isJson())
716
+ return printJson(jsonOk({ symbol: sym, staleStateEntries: stale, deleted: false, remediation: `Confirm: perp arb close ${sym} --state-only --yes` }));
717
+ for (const st of stale)
718
+ console.log(chalk.yellow(` Stale ${st.mode} ${st.symbol} ${st.longExchange}/${st.shortExchange} since ${st.entryTime} (${st.reason})`));
719
+ console.log(chalk.gray(` Nothing deleted. Confirm: perp arb close ${sym} --state-only --yes\n`));
720
+ return;
721
+ }
722
+ persistRemovePosition(net, sym);
723
+ if (isJson())
724
+ return printJson(jsonOk({ symbol: sym, staleStateEntries: stale, deleted: true }));
725
+ console.log(chalk.green(` Dropped the stale ${sym} arb state entry (${net}). No order was sent.\n`));
726
+ }
567
727
  // ── arb close ──
568
- arb
569
- .command("close <symbol>")
570
- .description("Manually close an arb position on both exchanges")
571
- .option("--dry-run", "Show what would happen without executing")
728
+ supportsDryRun(arb.command("close <symbol>"))
729
+ .description("Manually close an arb position on both exchanges (supports --dry-run)")
572
730
  .option("--pair <pair>", "Specify arb pair as longExchange:shortExchange (e.g. pacifica:hyperliquid)")
573
731
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
574
- .option("--spot-exch <exchange>", "Override spot exchange for spot-perp close (hl or lt)")
575
- .option("--perp-exch <exchange>", "Override perp exchange for spot-perp close")
576
- .action(async (symbol, opts) => {
732
+ .option("--spot-exch <exchange>", "Override spot exchange for spot-perp close (a bot spot venue: hl, lt, lrh, aster, ondo)")
733
+ .option("--perp-exch <exchange>", "Override perp exchange for spot-perp close. With both --spot-exch and --perp-exch the close needs no arb-state entry: it sells the spot and buys back the live perp short of <symbol> on those venues")
734
+ .option("--force", "Act on a quarantined symbol (after checking its positions by hand)")
735
+ .option("--state-only", "Drop a STALE arb state entry of <symbol> (one `arb status` lists under staleStateEntries: no live position on the venues) — sends no order; refused while the venues still show it")
736
+ .option("--yes", "With --state-only: confirm the deletion (without it, the entry is only shown)")
737
+ .action(async (symbol, opts, cmd) => {
577
738
  const sym = symbol.toUpperCase();
578
- const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
739
+ const dryRun = isDryRun(cmd);
740
+ if (opts.stateOnly) {
741
+ await dropStaleStateEntry(sym, opts.yes === true, dryRun);
742
+ return;
743
+ }
744
+ // Global quarantine (design §12.3): an unknown / unhedged outcome was recorded for
745
+ // this symbol. A human may still close it, but only knowingly (--force).
746
+ const quarantined = quarantineEntryFor(sym, (m) => console.error(chalk.red(m)));
747
+ if (quarantined !== null) {
748
+ if (!opts.force && !dryRun) {
749
+ throw new PerpError("RISK_VIOLATION", `${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})`, {
750
+ remediation: `${quarantined.remediation}. To close it anyway after checking the positions: perp arb close ${sym} --force`,
751
+ });
752
+ }
753
+ if (!isJson())
754
+ console.error(chalk.yellow(` WARNING: ${sym} is quarantined (${quarantined.strategy}: ${quarantined.reason})${opts.force ? " — proceeding (--force)" : ""}. The quarantine is NOT cleared by this close: perp strategy quarantine-clear ${sym}`));
755
+ }
579
756
  // Check if this is a spot-perp position (auto-detect from arb state)
580
- const arbState = loadArbState();
757
+ const arbState = loadArbState(networkOf(program));
581
758
  const statePos = arbState?.positions?.find(p => p.symbol.toUpperCase() === sym && p.mode === "spot-perp");
582
- if (statePos) {
759
+ // Explicit --spot-exch + --perp-exch select the spot-perp close without a state entry
760
+ // (e.g. a pair opened by a strategy bot); the handler sizes it from live positions.
761
+ if ((opts.spotExch === undefined) !== (opts.perpExch === undefined)) {
762
+ throw new PerpError("INVALID_PARAMS", "--spot-exch and --perp-exch must be given together", {
763
+ remediation: `perp arb close ${sym} --spot-exch <spot venue> --perp-exch <perp venue>`,
764
+ });
765
+ }
766
+ const explicitSpotPerp = opts.spotExch !== undefined && opts.perpExch !== undefined;
767
+ if (statePos || explicitSpotPerp) {
583
768
  // ── Inline spot-perp close logic ──
584
- const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
585
- const spotExch = aliasMap[opts.spotExch?.toLowerCase() ?? ""] || opts.spotExch?.toLowerCase() || statePos.spotExchange || statePos.longExchange;
586
- const perpExch = aliasMap[opts.perpExch?.toLowerCase() ?? ""] || opts.perpExch?.toLowerCase() || statePos.shortExchange;
769
+ // Venue ids through the registry (every alias, e.g. lrh / ast); a stored spot venue may carry "-spot".
770
+ const venueIdOf = (v) => resolveVenueId(v.replace(/-spot$/i, ""));
771
+ const spotRaw = opts.spotExch ?? statePos?.spotExchange ?? statePos?.longExchange;
772
+ const perpRaw = opts.perpExch ?? statePos?.shortExchange;
773
+ const spotExch = spotRaw === undefined ? undefined : venueIdOf(spotRaw);
774
+ const perpExch = perpRaw === undefined ? undefined : venueIdOf(perpRaw);
587
775
  if (!spotExch || !perpExch) {
588
- const msg = "Cannot determine exchanges. Specify --spot-exch and --perp-exch, or ensure the position is tracked in state.";
589
- if (isJson())
590
- return printJson(jsonOk({ error: msg }));
591
- console.log(chalk.red(` ${msg}`));
592
- return;
593
- }
594
- // Load spot adapter
595
- const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
596
- const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
597
- let spotAdapter;
598
- if (spotExch === "hyperliquid") {
599
- const hlAdapter = await getAdapterForExchange("hyperliquid");
600
- const hlSpot = new HyperliquidSpotAdapter(hlAdapter);
601
- await hlSpot.init();
602
- spotAdapter = hlSpot;
603
- }
604
- else {
605
- const ltAdapter = await getAdapterForExchange("lighter");
606
- const ltSpot = new LighterSpotAdapter(ltAdapter);
607
- await ltSpot.init();
608
- spotAdapter = ltSpot;
609
- }
610
- const perpAdapter = await getAdapterForExchange(perpExch);
611
- const spotSymbol = `${sym}/USDC`;
612
- // Find how much to close
613
- const perpPositions = await perpAdapter.getPositions();
614
- const perpPos = perpPositions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
615
- if (!perpPos) {
616
- const msg = `No short perp position found for ${sym} on ${perpExch}`;
617
- if (isJson())
618
- return printJson(jsonOk({ error: msg }));
619
- console.log(chalk.red(` ${msg}`));
620
- return;
621
- }
622
- const closeSize = perpPos.size;
623
- if (!isJson()) {
624
- console.log(chalk.cyan(`\n Closing Spot+Perp Arb: ${sym}`));
625
- console.log(chalk.gray(` SPOT ${spotExch}: sell ${closeSize}`));
626
- console.log(chalk.gray(` PERP ${perpExch}: buy ${closeSize} (close short)`));
627
- console.log(chalk.gray(` Executing...\n`));
628
- }
629
- // Same exchange -> sequential (nonce collision), different -> parallel
630
- let spotResult;
631
- let perpResult;
632
- if (spotExch === perpExch) {
633
- spotResult = await spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize)
634
- .then(r => ({ status: "fulfilled", value: r }))
635
- .catch(e => ({ status: "rejected", reason: e }));
636
- perpResult = await perpAdapter.marketOrder(sym, "buy", closeSize)
637
- .then(r => ({ status: "fulfilled", value: r }))
638
- .catch(e => ({ status: "rejected", reason: e }));
639
- }
640
- else {
641
- [spotResult, perpResult] = await Promise.allSettled([
642
- spotAdapter.spotMarketOrder(spotSymbol, "sell", closeSize),
643
- perpAdapter.marketOrder(sym, "buy", closeSize),
644
- ]);
645
- }
646
- const spotOk = spotResult.status === "fulfilled";
647
- const perpOk = perpResult.status === "fulfilled";
648
- if (spotOk && perpOk) {
649
- // Post-close fill verification
650
- let closeVerified = true;
651
- const closeWarnings = [];
652
- try {
653
- await new Promise(r => setTimeout(r, 800));
654
- const spotBals = await spotAdapter.getSpotBalances();
655
- const spotTokenBal = spotBals.find(b => b.token.toUpperCase().startsWith(sym));
656
- const remainingSpotAmt = Number(spotTokenBal?.total ?? 0);
657
- const closedSize = parseFloat(closeSize);
658
- if (remainingSpotAmt > closedSize * 0.5) {
659
- closeVerified = false;
660
- closeWarnings.push(`Spot sell may not have filled: still have ${remainingSpotAmt.toFixed(6)} ${sym} (expected ~0)`);
661
- }
662
- const perpPositionsAfter = await perpAdapter.getPositions();
663
- const remainingPerpPos = perpPositionsAfter.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym && p.side === "short");
664
- if (remainingPerpPos && Number(remainingPerpPos.size) > closedSize * 0.5) {
665
- closeVerified = false;
666
- closeWarnings.push(`Perp short still open: ${remainingPerpPos.size} ${sym} on ${perpExch} (expected closed)`);
667
- }
668
- }
669
- catch (verifyErr) {
670
- closeWarnings.push(`Close verification failed: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
671
- }
672
- if (closeWarnings.length > 0 && !isJson()) {
673
- for (const w of closeWarnings)
674
- console.log(chalk.yellow(` Warning: ${w}`));
675
- }
676
- persistRemovePosition(sym);
677
- logExecution({
678
- type: "arb_close", exchange: `spot:${spotExch}+${perpExch}`,
679
- symbol: sym, side: "exit", size: closeSize,
680
- status: closeVerified ? "success" : "unverified", dryRun: false,
681
- meta: { mode: "spot-perp", ...(closeWarnings.length > 0 && { warnings: closeWarnings }) },
776
+ throw new PerpError("INVALID_PARAMS", "Cannot determine the spot and perp exchanges of this spot-perp position", {
777
+ remediation: `Pass both: perp arb close ${sym} --spot-exch <spot venue> --perp-exch <perp venue>`,
682
778
  });
683
- // Return spot USDC proceeds back to perp account
684
- if (spotExch === "lighter") {
685
- try {
686
- const ltSpot = spotAdapter;
687
- const bals = await ltSpot.getSpotBalances();
688
- const usdcBal = bals.find(b => b.token === "USDC");
689
- const leftover = Number(usdcBal?.available ?? 0);
690
- if (leftover > 0.01) {
691
- await ltSpot.transferUsdcToPerp(leftover);
692
- if (!isJson())
693
- console.log(chalk.gray(` Returned ~$${leftover.toFixed(2)} USDC spot->perp on Lighter`));
694
- }
695
- }
696
- catch { /* non-critical */ }
697
- }
698
- else if (spotExch === "hyperliquid") {
699
- try {
700
- const hlSpot = spotAdapter;
701
- const bals = await hlSpot.getSpotBalances();
702
- const usdcBal = bals.find(b => b.token.startsWith("USDC"));
703
- const leftover = Number(usdcBal?.available ?? 0);
704
- if (leftover > 1)
705
- await hlSpot.transferUsdcToPerp(Math.floor(leftover));
706
- }
707
- catch { /* non-critical */ }
708
- }
709
- const closeResult = {
710
- status: closeVerified ? "closed" : "closed_unverified",
711
- mode: "spot-perp", symbol: sym, size: closeSize,
712
- ...(closeWarnings.length > 0 && { warnings: closeWarnings }),
713
- };
714
- if (isJson())
715
- return printJson(jsonOk(closeResult));
716
- if (closeVerified) {
717
- console.log(chalk.green(` CLOSED (verified): ${sym} ${closeSize} units`));
718
- }
719
- else {
720
- console.log(chalk.yellow(` CLOSED (unverified): ${sym} ${closeSize} units -- check positions manually`));
721
- }
722
- console.log(chalk.green(` SPOT ${spotExch}: sold`));
723
- console.log(chalk.green(` PERP ${perpExch}: bought (closed short)\n`));
724
779
  }
725
- else {
726
- const errors = [];
727
- if (!spotOk)
728
- errors.push(`Spot sell failed: ${spotResult.reason}`);
729
- if (!perpOk)
730
- errors.push(`Perp buy failed: ${perpResult.reason}`);
731
- const result = { status: "partial_close", symbol: sym, spotOk, perpOk, errors };
780
+ if (dryRun) {
781
+ // Dry-run: nothing is sold, bought or transferred.
782
+ const plan = { status: "simulated", dryRun: true, sent: false, mode: "spot-perp", symbol: sym, spotExchange: spotExch, perpExchange: perpExch, longSize: statePos ? statePos.longSize : null, shortSize: statePos ? statePos.shortSize : null };
732
783
  if (isJson())
733
- return printJson(jsonOk(result));
734
- console.log(chalk.red(` Close partially failed:`));
735
- for (const e of errors)
736
- console.log(chalk.red(` ${e}`));
737
- console.log(chalk.yellow(` Manual intervention may be needed.\n`));
784
+ return printJson(jsonOk(plan));
785
+ console.log(chalk.cyan(` [DRY-RUN] Would close spot-perp ${sym}: sell spot on ${spotExch} + buy back the perp short on ${perpExch}`));
786
+ return;
738
787
  }
788
+ // One implementation for spot-perp close (fill-checked legs, §12.4 #5): arb-auto's handler.
789
+ if (handleSpotClose === undefined)
790
+ throw new Error("spot-perp close handler is not registered (registerArbAutoCommands not called)");
791
+ await handleSpotClose(sym, { spotExch, perpExch }, getAdapterForExchange, isJson);
739
792
  return;
740
793
  }
741
794
  // ── Perp-perp close logic (original) ──
@@ -746,91 +799,84 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
746
799
  }
747
800
  // Find positions for this symbol across all exchanges
748
801
  const allPositions = [];
749
- for (const exName of EXCHANGES) {
750
- try {
751
- const adapter = await getAdapterForExchange(exName);
752
- const positions = await adapter.getPositions();
753
- for (const p of positions) {
754
- const normalized = p.symbol.replace("-PERP", "").toUpperCase();
755
- if (normalized === sym) {
756
- allPositions.push({
757
- exchange: exName,
758
- symbol: normalized,
759
- rawSymbol: p.symbol,
760
- side: p.side,
761
- size: Math.abs(Number(p.size)),
762
- entryPrice: Number(p.entryPrice),
763
- markPrice: Number(p.markPrice),
764
- unrealizedPnl: Number(p.unrealizedPnl),
765
- });
766
- }
767
- }
768
- }
769
- catch {
770
- // exchange not configured, skip
771
- }
802
+ const venuesChecked = await listArbVenues();
803
+ for (const { exchange, position: p } of await readArbVenuePositions(venuesChecked, getAdapterForExchange)) {
804
+ const normalized = p.symbol.replace("-PERP", "").toUpperCase();
805
+ if (normalized !== sym)
806
+ continue;
807
+ allPositions.push({
808
+ exchange,
809
+ symbol: normalized,
810
+ rawSymbol: p.symbol,
811
+ side: p.side,
812
+ size: Math.abs(Number(p.size)),
813
+ sizeRaw: String(p.size).trim().replace(/^[-+]/, ""), // venue's own size string, used verbatim for the close
814
+ entryPrice: Number(p.entryPrice),
815
+ markPrice: Number(p.markPrice),
816
+ unrealizedPnl: Number(p.unrealizedPnl),
817
+ });
772
818
  }
773
- // Find all possible arb pairs (long on one exchange, short on another)
819
+ // Find all possible arb pairs (long on one exchange, short on another), each with its provenance:
820
+ // only pairs opened by arb (state / entry record) are closed without --pair.
774
821
  const possiblePairs = [];
775
822
  const longs = allPositions.filter(p => p.side === "long");
776
823
  const shorts = allPositions.filter(p => p.side === "short");
824
+ const persistedForClose = getArbStatePositions(networkOf(program));
825
+ const closeRecords = [...readExecutionLog({ type: "arb_entry", symbol: sym }), ...readExecutionLog({ type: "arb_close", symbol: sym })];
777
826
  for (const l of longs) {
778
827
  for (const s of shorts) {
779
828
  if (l.exchange !== s.exchange)
780
- possiblePairs.push({ long: l, short: s });
829
+ possiblePairs.push({ long: l, short: s, provenance: arbPairProvenance(sym, l.exchange, s.exchange, persistedForClose, closeRecords) });
781
830
  }
782
831
  }
832
+ const confirmedPairs = possiblePairs.filter(p => p.provenance !== "inferred");
783
833
  // If --pair specified, filter to that specific pair
784
834
  let longPos;
785
835
  let shortPos;
786
836
  if (opts.pair) {
787
- const [longEx, shortEx] = opts.pair.split(":");
837
+ const parts = opts.pair.split(":");
838
+ if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
839
+ throw new PerpError("INVALID_PARAMS", `--pair must be longExchange:shortExchange, got "${opts.pair}"`, { remediation: `Example: perp arb close ${sym} --pair aster:arcus` });
840
+ }
841
+ const longEx = resolveVenueId(parts[0]);
842
+ const shortEx = resolveVenueId(parts[1]);
788
843
  const match = possiblePairs.find(p => p.long.exchange === longEx && p.short.exchange === shortEx);
789
844
  if (match) {
790
845
  longPos = match.long;
791
846
  shortPos = match.short;
792
847
  }
793
848
  }
794
- else if (possiblePairs.length === 1) {
795
- longPos = possiblePairs[0].long;
796
- shortPos = possiblePairs[0].short;
797
- }
798
- else if (possiblePairs.length > 1) {
799
- // Multiple pairs found — require explicit --pair selection
800
- const msg = `Multiple arb pairs found for ${sym}. Use --pair to specify which one to close.`;
801
- if (isJson()) {
802
- return printJson(jsonOk({
803
- error: msg,
804
- pairs: possiblePairs.map(p => ({
805
- arbPairId: `${sym}:${p.long.exchange}:${p.short.exchange}`,
806
- longExchange: p.long.exchange,
807
- shortExchange: p.short.exchange,
808
- longSize: p.long.size,
809
- shortSize: p.short.size,
810
- })),
811
- }));
812
- }
813
- console.log(chalk.red(` ${msg}\n`));
814
- console.log(chalk.white(" Available pairs:"));
815
- for (const p of possiblePairs) {
816
- console.log(chalk.gray(` --pair ${p.long.exchange}:${p.short.exchange} (long ${p.long.size} / short ${p.short.size})`));
817
- }
818
- console.log();
819
- return;
849
+ else if (confirmedPairs.length === 1) {
850
+ longPos = confirmedPairs[0].long;
851
+ shortPos = confirmedPairs[0].short;
852
+ }
853
+ else if (confirmedPairs.length > 1 || possiblePairs.length > 0) {
854
+ // Several arb pairs, or only pairs inferred from unrelated positions: never guessed — --pair required.
855
+ const onlyInferred = confirmedPairs.length === 0;
856
+ throw new PerpError("INVALID_PARAMS", onlyInferred
857
+ ? `No ${sym} pair opened by arb (no arb state / entry record for these legs); ${possiblePairs.length} unconfirmed long/short combination(s) found. Use --pair to close one knowingly.`
858
+ : `Multiple arb pairs found for ${sym}. Use --pair to specify which one to close.`, {
859
+ pairs: possiblePairs.map(p => ({
860
+ arbPairId: `${sym}:${p.long.exchange}:${p.short.exchange}`,
861
+ longExchange: p.long.exchange,
862
+ shortExchange: p.short.exchange,
863
+ longSize: p.long.size,
864
+ shortSize: p.short.size,
865
+ provenance: p.provenance,
866
+ })),
867
+ remediation: `Pick one: ${(onlyInferred ? possiblePairs : confirmedPairs).map(p => `--pair ${p.long.exchange}:${p.short.exchange}`).join(" | ")}`,
868
+ });
820
869
  }
821
870
  if (!longPos || !shortPos) {
822
- const msg = `No arb pair found for ${sym}. Need long and short on different exchanges.`;
823
- if (isJson())
824
- return printJson(jsonOk({ error: msg, positions: allPositions }));
825
- console.log(chalk.red(` ${msg}`));
826
- if (allPositions.length > 0) {
827
- console.log(chalk.gray(` Found ${allPositions.length} position(s) but no matching arb pair:`));
828
- for (const p of allPositions) {
829
- console.log(chalk.gray(` ${p.side.toUpperCase()} ${p.exchange} size=${p.size}`));
830
- }
831
- }
832
- console.log();
833
- return;
871
+ const found = allPositions.map(p => `${p.side} ${p.sizeRaw} on ${p.exchange}`).join(", ");
872
+ throw new PerpError("POSITION_NOT_FOUND", `No arb pair found for ${sym}${opts.pair ? ` on ${opts.pair}` : ""}. Need long and short on different exchanges.`, {
873
+ symbol: sym,
874
+ venuesChecked,
875
+ positions: allPositions.map(p => ({ exchange: p.exchange, side: p.side, size: p.sizeRaw })),
876
+ remediation: allPositions.length > 0
877
+ ? `${sym} positions found: ${found}. Close a single leg with: perp trade close ${sym} -e <exchange>`
878
+ : `No ${sym} position on ${venuesChecked.join(", ")}. See the live pairs: perp arb status`,
879
+ });
834
880
  }
835
881
  // Calculate estimated PnL
836
882
  const totalUpnl = longPos.unrealizedPnl + shortPos.unrealizedPnl;
@@ -851,6 +897,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
851
897
  if (dryRun) {
852
898
  const result = {
853
899
  dryRun: true,
900
+ sent: false,
854
901
  symbol: sym,
855
902
  longExchange: longPos.exchange,
856
903
  shortExchange: shortPos.exchange,
@@ -871,77 +918,36 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
871
918
  console.log(chalk.yellow(` BUY ${shortPos.size} ${shortPos.rawSymbol} on ${shortPos.exchange} (close short)\n`));
872
919
  return;
873
920
  }
874
- // Execute close on both exchanges with retry
875
- const MAX_RETRIES = 2;
921
+ // Close the legs sequentially (long, then short), reduce-only and verified, each
922
+ // sized from the venue's own position size. No retries: a definite failure of the
923
+ // long close changes nothing; an unknown outcome or a leg left open after the
924
+ // other closed quarantines the symbol (design §12.3).
876
925
  const results = [];
877
- async function closeWithRetry(exchange, rawSymbol, side, size, label) {
878
- let lastError = "";
879
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
880
- try {
881
- const adapter = await getAdapterForExchange(exchange);
882
- if (opts.smart) {
883
- await smartOrder(adapter, rawSymbol, side, String(size), { reduceOnly: true });
884
- }
885
- else {
886
- await adapter.marketOrder(rawSymbol, side, String(size));
887
- }
888
- results.push({ exchange, action: `${side} (close ${label})`, status: "success", retries: attempt });
889
- if (!isJson())
890
- console.log(chalk.green(` Closed ${label} on ${exchange}: ${side.toUpperCase()} ${size} ${sym}${opts.smart ? " (smart)" : ""}${attempt > 0 ? ` (retry ${attempt})` : ""}`));
891
- return;
892
- }
893
- catch (err) {
894
- lastError = err instanceof Error ? err.message : String(err);
895
- if (attempt < MAX_RETRIES) {
896
- if (!isJson())
897
- console.log(chalk.yellow(` Retry ${attempt + 1}/${MAX_RETRIES} for ${label} on ${exchange}...`));
898
- await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
899
- }
900
- }
926
+ const closeRes = await closePerpPairVerified({
927
+ source: "arb-close", sym,
928
+ longAdapter: await getAdapterForExchange(longPos.exchange), longExch: longPos.exchange, longSym: longPos.rawSymbol, longSize: longPos.sizeRaw,
929
+ shortAdapter: await getAdapterForExchange(shortPos.exchange), shortExch: shortPos.exchange, shortSym: shortPos.rawSymbol, shortSize: shortPos.sizeRaw,
930
+ smart: !!opts.smart,
931
+ log: (m) => console.error(chalk.red(m)),
932
+ });
933
+ if (closeRes.status === "closed") {
934
+ results.push({ exchange: longPos.exchange, action: "sell (close long)", status: "success" });
935
+ results.push({ exchange: shortPos.exchange, action: "buy (close short)", status: "success" });
936
+ if (!isJson()) {
937
+ console.log(chalk.green(` Closed long on ${longPos.exchange}: SELL ${longPos.sizeRaw} ${sym} (reduce-only, verified${opts.smart ? ", smart" : ""})`));
938
+ console.log(chalk.green(` Closed short on ${shortPos.exchange}: BUY ${shortPos.sizeRaw} ${sym} (reduce-only, verified${opts.smart ? ", smart" : ""})`));
901
939
  }
902
- results.push({ exchange, action: `${side} (close ${label})`, status: "failed", error: lastError, retries: MAX_RETRIES });
940
+ }
941
+ else {
942
+ results.push({ exchange: `${longPos.exchange}+${shortPos.exchange}`, action: "close", status: closeRes.status, error: closeRes.reason });
903
943
  if (!isJson())
904
- console.error(chalk.red(` Failed to close ${label} on ${exchange}: ${lastError}`));
905
- }
906
- // Close both legs concurrently
907
- await Promise.all([
908
- closeWithRetry(longPos.exchange, longPos.rawSymbol, "sell", longPos.size, "long"),
909
- closeWithRetry(shortPos.exchange, shortPos.rawSymbol, "buy", shortPos.size, "short"),
910
- ]);
911
- const allSuccess = results.every(r => r.status === "success");
912
- const anySuccess = results.some(r => r.status === "success");
913
- // Verify positions are actually closed
914
- let verification;
915
- if (anySuccess) {
916
- verification = [];
917
- for (const r of results) {
918
- if (r.status !== "success")
919
- continue;
920
- try {
921
- const adapter = await getAdapterForExchange(r.exchange);
922
- const positions = await adapter.getPositions();
923
- const remaining = positions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym);
924
- verification.push({
925
- exchange: r.exchange,
926
- remainingSize: remaining ? Math.abs(Number(remaining.size)) : 0,
927
- closed: !remaining || Math.abs(Number(remaining.size)) < 0.0001,
928
- });
929
- }
930
- catch {
931
- verification.push({ exchange: r.exchange, remainingSize: -1, closed: false });
932
- }
933
- }
934
- const notClosed = verification.filter(v => !v.closed);
935
- if (notClosed.length > 0 && !isJson()) {
936
- console.log(chalk.yellow(`\n Warning: Position may not be fully closed on: ${notClosed.map(v => v.exchange).join(", ")}`));
937
- }
944
+ console.error(chalk.red(` ${closeRes.status === "quarantined" ? "QUARANTINED" : "Close failed (nothing changed, both legs open)"}: ${closeRes.reason}`));
938
945
  }
946
+ const allSuccess = closeRes.status === "closed";
939
947
  // Remove from persisted daemon state (so daemon doesn't try to re-close)
940
948
  if (allSuccess) {
941
- try {
942
- persistRemovePosition(sym);
943
- }
944
- catch { /* state may not exist */ }
949
+ // No catch (Rule #2): an absent state file is a no-op; a wrong-network/unreadable one must surface.
950
+ persistRemovePosition(networkOf(program), sym);
945
951
  }
946
952
  // Log to execution log
947
953
  const arbPairId = `${sym}:${longPos.exchange}:${shortPos.exchange}`;
@@ -970,22 +976,21 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
970
976
  symbol: sym,
971
977
  longExchange: longPos.exchange,
972
978
  shortExchange: shortPos.exchange,
973
- status: allSuccess ? "success" : anySuccess ? "partial" : "failed",
979
+ status: allSuccess ? "success" : closeRes.status,
974
980
  unrealizedPnl: totalUpnl,
975
981
  estimatedFees: totalFees,
976
982
  netPnl,
977
983
  results,
978
- verification,
979
984
  }));
980
985
  }
981
- if (!allSuccess && anySuccess) {
982
- console.log(chalk.yellow(`\n Warning: Partial close — one leg failed. Manual intervention may be needed.\n`));
986
+ if (closeRes.status === "quarantined") {
987
+ console.log(chalk.red.bold(`\n ${sym} quarantined — a leg may still be open. Check both venues by hand.\n`));
983
988
  }
984
989
  else if (allSuccess) {
985
990
  console.log(chalk.green(`\n Arb position ${sym} closed successfully.\n`));
986
991
  }
987
992
  else {
988
- console.log(chalk.red(`\n Failed to close arb position ${sym}. Both legs failed.\n`));
993
+ console.log(chalk.red(`\n Failed to close arb position ${sym}. Both legs are still open (hedged).\n`));
989
994
  }
990
995
  });
991
996
  // ── arb history ──
@@ -1280,15 +1285,15 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1280
1285
  console.log(chalk.gray(`\n Override with 'perp arb auto --min-spread 20 --size 50 ...'\n`));
1281
1286
  console.log(chalk.gray(` Run 'perp arb auto --help' for all options.\n`));
1282
1287
  });
1283
- arb
1284
- .command("rebalance")
1285
- .description("Cross-exchange balance rebalancing for arb")
1288
+ // Plan-only (never moves funds) — --dry-run only labels the plan.
1289
+ supportsDryRun(arb.command("rebalance"))
1290
+ .description("Cross-exchange balance rebalancing plan for arb (read-only; execute with perp funds rebalance execute)")
1286
1291
  .option("--check", "Show current balance distribution")
1287
1292
  .option("--target <ratio>", "Target distribution ratio (e.g., '50:50' for 2 exchanges, '33:33:33' for 3)")
1288
1293
  .option("--amount <usd>", "Total amount to rebalance")
1289
- .option("--dry-run", "Show plan without executing")
1290
1294
  .option("--exchanges <list>", "Comma-separated exchanges", "lighter,pacifica,hyperliquid")
1291
- .action(async (opts) => {
1295
+ .action(async (opts, cmd) => {
1296
+ const dryRun = isDryRun(cmd);
1292
1297
  const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
1293
1298
  const adapters = new Map();
1294
1299
  for (const name of exchangeNames) {
@@ -1378,7 +1383,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1378
1383
  // If --amount specified, scale moves proportionally
1379
1384
  let moves = plan.moves;
1380
1385
  if (opts.amount) {
1381
- const requestedAmount = parseFloat(opts.amount);
1386
+ const requestedAmount = Number(parsePositiveDecimal(opts.amount, "--amount"));
1382
1387
  const totalMoveAmount = moves.reduce((s, m) => s + m.amount, 0);
1383
1388
  if (totalMoveAmount > 0) {
1384
1389
  const scale = Math.min(1, requestedAmount / totalMoveAmount);
@@ -1416,7 +1421,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1416
1421
  }));
1417
1422
  if (isJson()) {
1418
1423
  return printJson(jsonOk({
1419
- status: opts.dryRun ? "dry_run" : "planned",
1424
+ status: dryRun ? "dry_run" : "planned",
1420
1425
  target: weights,
1421
1426
  moves: moveDetails,
1422
1427
  snapshots,
@@ -1454,7 +1459,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1454
1459
  }
1455
1460
  const totalFees = moveDetails.reduce((s, m) => s + m.bridgeFee, 0);
1456
1461
  console.log(chalk.gray(` Total bridge fees: ~$${totalFees.toFixed(2)}`));
1457
- if (opts.dryRun) {
1462
+ if (dryRun) {
1458
1463
  console.log(chalk.yellow("\n [DRY RUN] No transfers executed.\n"));
1459
1464
  }
1460
1465
  else {
@@ -1469,24 +1474,15 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1469
1474
  console.log(chalk.cyan(`\n Fetching funding payments (last ${periodDays} days)...\n`));
1470
1475
  // Fetch positions to identify arb pairs
1471
1476
  const fundingPositions = [];
1472
- for (const exName of EXCHANGES) {
1473
- try {
1474
- const adapter = await getAdapter(exName);
1475
- const positions = await adapter.getPositions();
1476
- for (const p of positions) {
1477
- const normalized = p.symbol.replace("-PERP", "").toUpperCase();
1478
- fundingPositions.push({
1479
- exchange: exName,
1480
- symbol: normalized,
1481
- side: p.side,
1482
- size: Math.abs(Number(p.size)),
1483
- markPrice: Number(p.markPrice),
1484
- });
1485
- }
1486
- }
1487
- catch {
1488
- // exchange not configured
1489
- }
1477
+ const fundingVenues = await listArbVenues();
1478
+ for (const { exchange, position: p } of await readArbVenuePositions(fundingVenues, getAdapter)) {
1479
+ fundingPositions.push({
1480
+ exchange,
1481
+ symbol: p.symbol.replace("-PERP", "").toUpperCase(),
1482
+ side: p.side,
1483
+ size: Math.abs(Number(p.size)),
1484
+ markPrice: Number(p.markPrice),
1485
+ });
1490
1486
  }
1491
1487
  // Identify arb pairs (long on one exchange, short on another)
1492
1488
  const arbPairKeys = new Set();
@@ -1508,7 +1504,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1508
1504
  }
1509
1505
  const allPayments = [];
1510
1506
  const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
1511
- for (const exName of EXCHANGES) {
1507
+ for (const exName of fundingVenues) {
1512
1508
  try {
1513
1509
  const adapter = await getAdapter(exName);
1514
1510
  const payments = await adapter.getFundingPayments(200);
@@ -1526,8 +1522,9 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1526
1522
  });
1527
1523
  }
1528
1524
  }
1529
- catch {
1530
- // exchange not configured or no payments
1525
+ catch (e) {
1526
+ // Reported, not hidden: this venue's payments are missing from the totals below.
1527
+ process.stderr.write(`[warn] ${exName} funding payments unavailable (left out of the totals): ${e instanceof Error ? e.message : String(e)}\n`);
1531
1528
  }
1532
1529
  }
1533
1530
  // Group by symbol, then by exchange