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,6 +1,13 @@
1
+ import { assertCanClosePositions } from "./exchanges/trading-v2.js";
1
2
  import { symbolMatch } from "./utils.js";
2
3
  import { logExecution } from "./execution-log.js";
3
4
  import { randomUUID } from "crypto";
5
+ import { invalidateCache } from "./cache.js";
6
+ import { PerpError, extractErrorMessage, isDefiniteOrderFailure, orderOutcomeUnknown } from "./errors.js";
7
+ import { closePerpVerified, isOutcomeUnknown, placePerpVerified } from "./bot/perp-order.js";
8
+ import { fillSizeString, sizeDecimalsOf } from "./exchanges/spot-result.js";
9
+ import { quarantineSymbol } from "./bot/quarantine.js";
10
+ import { resolveAdapterSlippage } from "./exchanges/slippage.js";
4
11
  /**
5
12
  * Validate a plan structure without executing it.
6
13
  */
@@ -67,6 +74,8 @@ export function validatePlan(plan) {
67
74
  /**
68
75
  * Execute a plan step by step.
69
76
  */
77
+ /** Plan actions that place orders (open or close positions). */
78
+ const PLAN_ORDER_ACTIONS = new Set(["market_order", "limit_order", "stop_order", "close_position"]);
70
79
  export async function executePlan(adapter, plan, opts) {
71
80
  const planId = randomUUID().slice(0, 8);
72
81
  const startTime = Date.now();
@@ -74,18 +83,40 @@ export async function executePlan(adapter, plan, opts) {
74
83
  const completedSteps = new Map();
75
84
  const dryRun = opts?.dryRun ?? false;
76
85
  const log = opts?.log ?? (() => { });
86
+ // A plan that places orders must be able to close them (rollback / close_position are
87
+ // reduce-only): refuse before the first step on a venue that cannot.
88
+ if (!dryRun && plan.steps.some(s => PLAN_ORDER_ACTIONS.has(s.action))) {
89
+ assertCanClosePositions(adapter, `Plan on ${adapter.name}`);
90
+ }
91
+ /** Order steps that completed, with their measured fill (market orders) for rollback. */
92
+ const placed = new Map();
93
+ const quarantined = new Set();
94
+ const finish = (status) => ({
95
+ planId,
96
+ status: quarantined.size > 0 && status !== "pending" ? "quarantined" : status,
97
+ steps: results,
98
+ quarantined: [...quarantined],
99
+ totalDurationMs: Date.now() - startTime,
100
+ timestamp: new Date().toISOString(),
101
+ });
102
+ const quarantine = (symbol, reason) => {
103
+ quarantineSymbol("plan-execute", symbol, `plan ${planId}: ${reason}`, `Check the ${symbol} position and open orders on ${adapter.name} (perp -e ${adapter.name} account positions / trade orders); fix by hand, then: perp strategy quarantine-clear ${symbol}`, log);
104
+ quarantined.add(symbol.toUpperCase());
105
+ };
77
106
  log(`[plan:${planId}] Starting ${plan.steps.length} steps (${dryRun ? "DRY RUN" : "LIVE"})`);
78
107
  for (const step of plan.steps) {
79
108
  const stepStart = Date.now();
80
109
  // Check dependency
81
110
  if (step.dependsOn) {
82
111
  const dep = completedSteps.get(step.dependsOn);
83
- if (!dep || dep.status === "failed") {
112
+ // Only a dependency that actually succeeded (or was dry-run) lets the step run:
113
+ // failed / skipped / pending / rolled-back / missing all block it.
114
+ if (!dep || (dep.status !== "success" && dep.status !== "dry_run")) {
84
115
  const result = {
85
116
  stepId: step.id,
86
117
  action: step.action,
87
118
  status: "skipped",
88
- error: { code: "DEPENDENCY_FAILED", message: `Depends on ${step.dependsOn} which failed/missing` },
119
+ error: { code: "DEPENDENCY_FAILED", message: `Depends on ${step.dependsOn} which is ${dep ? dep.status : "missing"}` },
89
120
  durationMs: 0,
90
121
  };
91
122
  results.push(result);
@@ -107,7 +138,7 @@ export async function executePlan(adapter, plan, opts) {
107
138
  continue;
108
139
  }
109
140
  try {
110
- const result = await executeStep(adapter, step, log);
141
+ const result = await executeStep(adapter, step, log, placed);
111
142
  const stepResult = {
112
143
  stepId: step.id,
113
144
  action: step.action,
@@ -120,8 +151,28 @@ export async function executePlan(adapter, plan, opts) {
120
151
  log(`[plan:${planId}] Step ${step.id} (${step.action}): success (${stepResult.durationMs}ms)`);
121
152
  }
122
153
  catch (err) {
123
- const message = err instanceof Error ? err.message : String(err);
154
+ const message = extractErrorMessage(err);
124
155
  const onFailure = step.onFailure ?? "abort";
156
+ if (ORDER_ACTIONS.has(step.action) && isOutcomeUnknown(err)) {
157
+ // The order may have been placed: never treat it as failed, never resubmit or roll it
158
+ // back. Stop the plan and quarantine the symbol (design §12.3).
159
+ const sym = String(step.params.symbol ?? "").toUpperCase();
160
+ log(`[plan:${planId}] Step ${step.id} (${step.action}): OUTCOME UNKNOWN — ${message}`);
161
+ quarantine(sym, `step ${step.id} (${step.action}) outcome unknown: ${message}`);
162
+ results.push({
163
+ stepId: step.id,
164
+ action: step.action,
165
+ status: "pending",
166
+ error: { code: "ORDER_PENDING", message },
167
+ durationMs: Date.now() - stepStart,
168
+ });
169
+ if (onFailure === "rollback") {
170
+ // Reverse completed steps on OTHER symbols only — the pending order's symbol is
171
+ // quarantined and left untouched (a reversal there could not be measured).
172
+ await rollbackSteps(adapter, results.filter(r => r.status === "success"), placed, sym, quarantine, log);
173
+ }
174
+ return finish("pending");
175
+ }
125
176
  log(`[plan:${planId}] Step ${step.id} (${step.action}): FAILED — ${message}`);
126
177
  if (onFailure === "skip") {
127
178
  results.push({
@@ -136,7 +187,7 @@ export async function executePlan(adapter, plan, opts) {
136
187
  }
137
188
  if (onFailure === "rollback") {
138
189
  log(`[plan:${planId}] Rolling back completed steps...`);
139
- await rollbackSteps(adapter, results.filter(r => r.status === "success"), log);
190
+ await rollbackSteps(adapter, results.filter(r => r.status === "success"), placed, null, quarantine, log);
140
191
  results.push({
141
192
  stepId: step.id,
142
193
  action: step.action,
@@ -144,13 +195,7 @@ export async function executePlan(adapter, plan, opts) {
144
195
  error: { code: "STEP_FAILED", message },
145
196
  durationMs: Date.now() - stepStart,
146
197
  });
147
- return {
148
- planId,
149
- status: "failed",
150
- steps: results,
151
- totalDurationMs: Date.now() - startTime,
152
- timestamp: new Date().toISOString(),
153
- };
198
+ return finish("failed");
154
199
  }
155
200
  // abort
156
201
  results.push({
@@ -160,44 +205,80 @@ export async function executePlan(adapter, plan, opts) {
160
205
  error: { code: "STEP_FAILED", message },
161
206
  durationMs: Date.now() - stepStart,
162
207
  });
163
- return {
164
- planId,
165
- status: results.some(r => r.status === "success") ? "partial" : "failed",
166
- steps: results,
167
- totalDurationMs: Date.now() - startTime,
168
- timestamp: new Date().toISOString(),
169
- };
208
+ return finish(results.some(r => r.status === "success") ? "partial" : "failed");
170
209
  }
171
210
  }
172
- return {
173
- planId,
174
- status: dryRun ? "dry_run" : "completed",
175
- steps: results,
176
- totalDurationMs: Date.now() - startTime,
177
- timestamp: new Date().toISOString(),
178
- };
211
+ return finish(dryRun ? "dry_run" : "completed");
212
+ }
213
+ /** Steps that send an order (a thrown non-definite error = outcome unknown). */
214
+ const ORDER_ACTIONS = new Set(["market_order", "limit_order", "stop_order", "close_position"]);
215
+ /**
216
+ * Order id from a venue placement response: TradingV2 OrderResult.orderId, Aster/Binance
217
+ * `orderId`, Pacifica `order_id`, HL `response.data.statuses[0].resting.oid`, generic `id`.
218
+ * null when none is present (the rollback then refuses to guess).
219
+ */
220
+ function extractOrderId(result) {
221
+ if (result === null || typeof result !== "object")
222
+ return null;
223
+ const r = result;
224
+ for (const k of ["orderId", "order_id", "oid", "id"]) {
225
+ const v = r[k];
226
+ if (typeof v === "string" && v.length > 0)
227
+ return v;
228
+ if (typeof v === "number" && Number.isFinite(v))
229
+ return String(v);
230
+ }
231
+ const data = (r.data ?? r.response?.data);
232
+ const statuses = data?.statuses;
233
+ for (const k of ["resting", "filled"]) { // HL: resting order, or a limit that filled immediately
234
+ const st = statuses?.[0]?.[k];
235
+ if (st && (typeof st.oid === "number" || typeof st.oid === "string"))
236
+ return String(st.oid);
237
+ }
238
+ if (data)
239
+ return extractOrderId(data);
240
+ return null;
241
+ }
242
+ /** Limit / stop placement: a typed definite failure stays; anything else may have placed the order → ORDER_PENDING. */
243
+ async function sendRestingOrder(adapter, what, send) {
244
+ try {
245
+ return await send();
246
+ }
247
+ catch (e) {
248
+ throw isDefiniteOrderFailure(e) ? e : orderOutcomeUnknown(adapter.name, what, e);
249
+ }
179
250
  }
180
- async function executeStep(adapter, step, log) {
251
+ async function executeStep(adapter, step, log, placed) {
181
252
  const p = step.params;
182
253
  const symbol = String(p.symbol ?? "").toUpperCase();
183
254
  const side = String(p.side ?? "");
184
255
  const size = String(p.size ?? "");
185
256
  switch (step.action) {
186
257
  case "market_order": {
187
- const result = await adapter.marketOrder(symbol, side, size);
188
- logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size, status: "success", dryRun: false, meta: { planStep: step.id, clientId: step.clientId } });
189
- return result;
258
+ // Fill MEASURED as the position delta (ORDER_PENDING when unconfirmed) — the rollback
259
+ // reverses exactly this size.
260
+ const fill = await placePerpVerified(adapter, adapter.name, symbol, side, size);
261
+ placed.set(step.id, { symbol, side, filledSize: fill.filledSize, decimals: sizeDecimalsOf(size), orderId: null });
262
+ logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size, status: "success", dryRun: false, meta: { planStep: step.id, clientId: step.clientId, filledSize: fill.filledSize } });
263
+ return fill.raw;
190
264
  }
191
265
  case "limit_order": {
192
266
  const price = String(p.price ?? "");
193
- const result = await adapter.limitOrder(symbol, side, price, size);
267
+ const result = await sendRestingOrder(adapter, `limit ${side} ${size} ${symbol} @ ${price}`, () => adapter.limitOrder(symbol, side, price, size));
268
+ placed.set(step.id, { symbol, side, filledSize: null, decimals: sizeDecimalsOf(size), orderId: extractOrderId(result) });
194
269
  logExecution({ type: "limit_order", exchange: adapter.name, symbol, side, size, price, status: "success", dryRun: false, meta: { planStep: step.id, clientId: step.clientId } });
195
270
  return result;
196
271
  }
197
272
  case "stop_order": {
198
273
  const triggerPrice = String(p.triggerPrice ?? "");
199
274
  const limitPrice = p.limitPrice ? String(p.limitPrice) : undefined;
200
- return adapter.stopOrder(symbol, side, size, triggerPrice, { limitPrice, reduceOnly: !!p.reduceOnly });
275
+ const result = await sendRestingOrder(adapter, `stop ${side} ${size} ${symbol} @ ${triggerPrice}`, () => adapter.stopOrder(symbol, side, size, triggerPrice, {
276
+ limitPrice, reduceOnly: !!p.reduceOnly,
277
+ // Market triggers carry the resolved bound (protection-priced on Lighter / Arcus; native triggers ignore it).
278
+ ...(limitPrice === undefined ? { slippage: resolveAdapterSlippage(adapter, null) } : {}),
279
+ }));
280
+ placed.set(step.id, { symbol, side, filledSize: null, decimals: sizeDecimalsOf(size), orderId: extractOrderId(result) });
281
+ return result;
201
282
  }
202
283
  case "cancel_order": {
203
284
  return adapter.cancelOrder(symbol, String(p.orderId ?? ""));
@@ -211,14 +292,23 @@ async function executeStep(adapter, step, log) {
211
292
  return adapter.setLeverage(symbol, lev, mode);
212
293
  }
213
294
  case "close_position": {
295
+ invalidateCache("acct"); // size the reduce-only close from a fresh read, never a cached position
214
296
  const positions = await adapter.getPositions();
215
297
  const pos = positions.find(pp => symbolMatch(pp.symbol, symbol));
216
298
  if (!pos)
217
299
  throw new Error(`No position found for ${symbol}`);
218
300
  const closeSide = pos.side === "long" ? "sell" : "buy";
219
- const result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
220
- logExecution({ type: "market_order", exchange: adapter.name, symbol, side: closeSide, size: pos.size, status: "success", dryRun: false, meta: { planStep: step.id, action: "close_position" } });
221
- return result;
301
+ // Size from the live position (abs), reduce-only: a stale / repeated close can never open
302
+ // or flip a position — the venue rejects it instead. Fill measured (ORDER_PENDING when unconfirmed).
303
+ // A partial close (venue-final IOC partial fills are accepted, §13.10 #4) gets ONE residual
304
+ // close (closePerpVerified); a residual still open fails the step with ORDER_PARTIAL.
305
+ const liveSize = pos.size.replace(/^-/, "");
306
+ const c = await closePerpVerified(adapter, adapter.name, pos.symbol, closeSide, liveSize);
307
+ logExecution({ type: "market_order", exchange: adapter.name, symbol, side: closeSide, size: liveSize, status: c.residual > 0 ? "failed" : "success", dryRun: false, meta: { planStep: step.id, action: "close_position", reduceOnly: true, filledSize: c.closed, residual: c.residual } });
308
+ if (c.residual > 0) {
309
+ throw new PerpError("ORDER_PARTIAL", `close_position ${pos.symbol}: closed ${c.closed} of ${liveSize}, ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""}`, { exchange: adapter.name });
310
+ }
311
+ return { symbol: pos.symbol, side: closeSide, size: liveSize, closed: c.closed };
222
312
  }
223
313
  case "wait": {
224
314
  const ms = Number(p.ms ?? 1000);
@@ -248,33 +338,54 @@ async function executeStep(adapter, step, log) {
248
338
  }
249
339
  }
250
340
  /**
251
- * Attempt to rollback completed steps by performing inverse operations.
341
+ * Reverse completed steps (reverse order). A filled market order is closed reduce-only for
342
+ * its MEASURED fill (closePerpVerified, residual handled); a limit / stop order's resting
343
+ * orders are cancelled on that step's symbol only. A reversal that is rejected, unconfirmed
344
+ * or partial marks the step rollback_failed and quarantines the symbol. Steps on
345
+ * `skipSymbol` (an order whose outcome is unknown) are left untouched.
252
346
  */
253
- async function rollbackSteps(adapter, completedSteps, log) {
254
- // Rollback in reverse order
347
+ async function rollbackSteps(adapter, completedSteps, placed, skipSymbol, quarantine, log) {
255
348
  for (let i = completedSteps.length - 1; i >= 0; i--) {
256
349
  const step = completedSteps[i];
350
+ const order = placed.get(step.stepId);
351
+ if (order === undefined)
352
+ continue; // not an order step (cancel / leverage / checks / close_position): nothing to reverse
353
+ if (skipSymbol !== null && order.symbol === skipSymbol) {
354
+ log(`[rollback] Leaving ${step.stepId} (${order.symbol}) — the symbol has an order with unknown outcome (quarantined)`);
355
+ continue;
356
+ }
257
357
  try {
258
- switch (step.action) {
259
- case "market_order":
260
- case "limit_order": {
261
- // Close the position that was opened
262
- // This is best-effort — market conditions may have changed
263
- log(`[rollback] Attempting to reverse ${step.action} ${step.stepId}`);
264
- // We'd need position info; just cancel any pending orders as safety measure
265
- await adapter.cancelAllOrders().catch(() => { });
266
- break;
358
+ if (order.filledSize !== null) {
359
+ const size = fillSizeString(order.filledSize, order.decimals);
360
+ if (!(Number(size) > 0))
361
+ continue;
362
+ const closeSide = order.side === "buy" ? "sell" : "buy";
363
+ log(`[rollback] Reversing ${step.stepId}: reduce-only ${closeSide} ${size} ${order.symbol}`);
364
+ const c = await closePerpVerified(adapter, adapter.name, order.symbol, closeSide, size, { decimals: order.decimals });
365
+ if (c.residual > 0)
366
+ throw new Error(`closed ${c.closed}; ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""}`);
367
+ }
368
+ else {
369
+ // Resting limit / stop: cancel ONLY this step's order (never the user's other orders
370
+ // on the symbol). Any fill before the cancel is not reversed — the step is marked
371
+ // "cancelled" (not "rolled_back") and the log says to check the position.
372
+ if (order.orderId === null) {
373
+ throw new Error(`no order id was returned for ${step.stepId}; cancel it manually (perp trade cancel ${order.symbol} <orderId>)`);
267
374
  }
268
- case "cancel_order":
269
- case "cancel_all":
270
- // Can't un-cancel, skip
271
- break;
272
- default:
273
- break;
375
+ log(`[rollback] Cancelling ${step.stepId} order ${order.orderId} on ${order.symbol}`);
376
+ await adapter.cancelOrder(order.symbol, order.orderId);
377
+ log(`[rollback] ${step.stepId}: any partial fill before the cancel is NOT reversed — check ${order.symbol} position`);
378
+ step.status = "cancelled";
379
+ continue;
274
380
  }
381
+ step.status = "rolled_back";
275
382
  }
276
383
  catch (err) {
277
- log(`[rollback] Failed to rollback step ${step.stepId}: ${err instanceof Error ? err.message : err}`);
384
+ const message = extractErrorMessage(err);
385
+ log(`[rollback] Failed to rollback step ${step.stepId}: ${message}`);
386
+ step.status = "rollback_failed";
387
+ step.error = { code: isOutcomeUnknown(err) ? "ORDER_PENDING" : "ROLLBACK_FAILED", message };
388
+ quarantine(order.symbol, `rollback of step ${step.stepId} (${step.action} ${order.side} ${order.symbol}) ${isOutcomeUnknown(err) ? "unconfirmed" : "failed"}: ${message}`);
278
389
  }
279
390
  }
280
391
  }
@@ -110,6 +110,15 @@ export function getPositionStats(opts) {
110
110
  for (const r of records) {
111
111
  stats.totalTrades++;
112
112
  const pnl = Number(r.realizedPnl ?? 0);
113
+ // Rule #2 (qa/2026-05-16 extension): a non-finite realizedPnl from a
114
+ // corrupt history row would propagate NaN into totalPnl / averages and
115
+ // break the entire stats report. Surface the bad row and skip its PnL
116
+ // contribution (the trade is still counted but excluded from PnL math
117
+ // so the operator sees the row count vs. expected and can investigate).
118
+ if (!Number.isFinite(pnl)) {
119
+ process.stderr.write(`[position-history] skipping non-finite realizedPnl for ${r.symbol ?? "<unknown>"} on ${r.exchange ?? "<unknown>"}: ${JSON.stringify(r.realizedPnl)}\n`);
120
+ continue;
121
+ }
113
122
  if (pnl > 0)
114
123
  stats.wins++;
115
124
  else if (pnl < 0)
@@ -150,6 +159,10 @@ export function getPositionStats(opts) {
150
159
  const symbolWins = {};
151
160
  for (const r of records) {
152
161
  const pnl = Number(r.realizedPnl ?? 0);
162
+ // Same guard as above — silently skipping NaN here keeps win-rate
163
+ // attribution consistent with the totals computed in the main loop.
164
+ if (!Number.isFinite(pnl))
165
+ continue;
153
166
  if (pnl > 0) {
154
167
  symbolWins[r.symbol] = (symbolWins[r.symbol] ?? 0) + 1;
155
168
  }
@@ -1,4 +1,7 @@
1
1
  import type { ExchangeAdapter } from "./exchanges/index.js";
2
+ import "./exchanges/venues/index.js";
3
+ /** Throws NO_BRIDGE_ROUTE when the venue's funds live where no bridge route exists. */
4
+ export declare function assertBridgeRoute(exchange: string): void;
2
5
  /**
3
6
  * Cross-exchange rebalancing engine.
4
7
  *
@@ -45,9 +48,30 @@ export declare function computeRebalancePlan(snapshots: ExchangeBalanceSnapshot[
45
48
  reserve?: number;
46
49
  }): RebalancePlan;
47
50
  /**
48
- * Check if an exchange has enough available balance for a given trade size.
51
+ * Headroom over the initial margin an arb leg needs before it is entered: covers taker fees,
52
+ * the slippage bound on the market entry, venue margin rounding and a mark move before the
53
+ * second leg fills (the leg must not be at maintenance margin right after entry).
49
54
  */
50
- export declare function hasEnoughBalance(snapshots: ExchangeBalanceSnapshot[], exchange: string, requiredUsd: number, marginBuffer?: number): boolean;
55
+ export declare const ARB_MARGIN_BUFFER = 1.5;
56
+ /**
57
+ * Margin a leg of `notionalUsd` needs at `leverage` (the leverage the venue applies to the market
58
+ * — the arb does not change it), × ARB_MARGIN_BUFFER. Leverage unknown (null: the venue exposes no
59
+ * per-market setting) → the 1x requirement (the full notional): the most margin any leverage can
60
+ * need, so the check never under-states it.
61
+ */
62
+ export declare function requiredArbMargin(notionalUsd: number, leverage: number | null, buffer?: number): number;
63
+ /**
64
+ * Check if an exchange has enough available balance to open a leg of `notionalUsd` at
65
+ * `leverage` (requiredArbMargin). No balance snapshot for the exchange → false.
66
+ */
67
+ export declare function hasEnoughBalance(snapshots: ExchangeBalanceSnapshot[], exchange: string, notionalUsd: number, leverage: number | null, marginBuffer?: number): boolean;
68
+ /**
69
+ * The leverage `adapter` applies to `symbol`: adapter.marketLeverage(symbol) where the venue has a
70
+ * per-symbol read (Hyperliquid activeAssetData, Pacifica /account/settings), else the
71
+ * adapter.getMarketSettings() row (Aster, Lighter, Arcus). null when neither lists the symbol;
72
+ * a failed read throws.
73
+ */
74
+ export declare function marketLeverageOf(adapter: object, symbol: string): Promise<number | null>;
51
75
  /**
52
76
  * Describe the bridge route for a rebalance move.
53
77
  */
package/dist/rebalance.js CHANGED
@@ -1,3 +1,21 @@
1
+ import { PerpError } from "./errors.js";
2
+ import { getVenue, tryResolveVenueId } from "./exchanges/venue.js";
3
+ import "./exchanges/venues/index.js";
4
+ /** Settlement chains with no withdraw → bridge → deposit route in this CLI (design §10.10). */
5
+ const NO_ROUTE_CHAINS = new Set(["robinhood", "offchain"]);
6
+ /** Throws NO_BRIDGE_ROUTE when the venue's funds live where no bridge route exists. */
7
+ export function assertBridgeRoute(exchange) {
8
+ const id = tryResolveVenueId(exchange);
9
+ if (id === null)
10
+ return; // not a venue name — the existing "unknown route" handling applies
11
+ const def = getVenue(id);
12
+ const chain = def.networks.mainnet.settlementChain;
13
+ if (NO_ROUTE_CHAINS.has(chain)) {
14
+ throw new PerpError("NO_BRIDGE_ROUTE", `${def.displayName} settles on "${chain}"; cross-venue rebalancing has no route there`, {
15
+ remediation: `Exclude ${def.id} from --exchanges and move funds for it manually`,
16
+ });
17
+ }
18
+ }
1
19
  import { EXCHANGE_TO_CHAIN } from "./bridge-engine.js";
2
20
  // Re-export for backward compat
3
21
  const EXCHANGE_CHAINS = Object.fromEntries(Object.entries(EXCHANGE_TO_CHAIN).map(([ex, chain]) => [ex, { chain }]));
@@ -8,12 +26,35 @@ export async function fetchAllBalances(adapters) {
8
26
  const entries = [...adapters.entries()];
9
27
  const results = await Promise.allSettled(entries.map(async ([name, adapter]) => {
10
28
  const bal = await adapter.getBalance();
29
+ // Strict policy (qa/2026-05-16): empty string is corruption, not
30
+ // "venue stringified zero". A truly absent field must surface as
31
+ // undefined/null at the adapter layer, not "". An empty string here
32
+ // would coerce to 0 via Number("") and silently propagate a $0
33
+ // balance into the plan — exactly the Rule #2 silent-substitution
34
+ // hole this audit cycle is closing.
35
+ if (bal.equity === "" || bal.available === "" || bal.marginUsed === "" || bal.unrealizedPnl === "") {
36
+ throw new PerpError("EXCHANGE_ERROR", `${name} returned empty-string balance field: equity="${bal.equity}" available="${bal.available}" marginUsed="${bal.marginUsed}" unrealizedPnl="${bal.unrealizedPnl}"`, { exchange: name });
37
+ }
38
+ const equity = Number(bal.equity);
39
+ const available = Number(bal.available);
40
+ const marginUsed = Number(bal.marginUsed);
41
+ const unrealizedPnl = Number(bal.unrealizedPnl);
42
+ // Rule #2: rebalance plan computes sums and per-exchange targets.
43
+ // A NaN value would silently propagate into the plan output and the
44
+ // execute step would attempt nonsense moves. Reject upfront so the
45
+ // affected exchange falls out of `Promise.allSettled` (filtered to
46
+ // fulfilled below) and the user sees a partial result instead of a
47
+ // corrupt plan.
48
+ if (!Number.isFinite(equity) || !Number.isFinite(available) ||
49
+ !Number.isFinite(marginUsed) || !Number.isFinite(unrealizedPnl)) {
50
+ throw new PerpError("EXCHANGE_ERROR", `${name} returned non-finite balance: equity=${bal.equity} available=${bal.available} marginUsed=${bal.marginUsed} unrealizedPnl=${bal.unrealizedPnl}`, { exchange: name });
51
+ }
11
52
  return {
12
53
  exchange: name,
13
- equity: Number(bal.equity),
14
- available: Number(bal.available),
15
- marginUsed: Number(bal.marginUsed),
16
- unrealizedPnl: Number(bal.unrealizedPnl),
54
+ equity,
55
+ available,
56
+ marginUsed,
57
+ unrealizedPnl,
17
58
  };
18
59
  }));
19
60
  return results
@@ -62,6 +103,8 @@ export function computeRebalancePlan(snapshots, opts = {}) {
62
103
  const moveAmt = Math.min(remaining, Math.abs(deficitAmt));
63
104
  if (moveAmt < minMove)
64
105
  continue;
106
+ assertBridgeRoute(fromEx);
107
+ assertBridgeRoute(toEx);
65
108
  moves.push({
66
109
  from: fromEx,
67
110
  to: toEx,
@@ -84,18 +127,54 @@ export function computeRebalancePlan(snapshots, opts = {}) {
84
127
  };
85
128
  }
86
129
  /**
87
- * Check if an exchange has enough available balance for a given trade size.
130
+ * Headroom over the initial margin an arb leg needs before it is entered: covers taker fees,
131
+ * the slippage bound on the market entry, venue margin rounding and a mark move before the
132
+ * second leg fills (the leg must not be at maintenance margin right after entry).
133
+ */
134
+ export const ARB_MARGIN_BUFFER = 1.5;
135
+ /**
136
+ * Margin a leg of `notionalUsd` needs at `leverage` (the leverage the venue applies to the market
137
+ * — the arb does not change it), × ARB_MARGIN_BUFFER. Leverage unknown (null: the venue exposes no
138
+ * per-market setting) → the 1x requirement (the full notional): the most margin any leverage can
139
+ * need, so the check never under-states it.
140
+ */
141
+ export function requiredArbMargin(notionalUsd, leverage, buffer = ARB_MARGIN_BUFFER) {
142
+ const lev = leverage !== null && Number.isFinite(leverage) && leverage >= 1 ? leverage : 1;
143
+ return (notionalUsd / lev) * buffer;
144
+ }
145
+ /**
146
+ * Check if an exchange has enough available balance to open a leg of `notionalUsd` at
147
+ * `leverage` (requiredArbMargin). No balance snapshot for the exchange → false.
88
148
  */
89
- export function hasEnoughBalance(snapshots, exchange, requiredUsd, marginBuffer = 1.5) {
149
+ export function hasEnoughBalance(snapshots, exchange, notionalUsd, leverage, marginBuffer = ARB_MARGIN_BUFFER) {
90
150
  const snap = snapshots.find((s) => s.exchange === exchange);
91
151
  if (!snap)
92
152
  return false;
93
- return snap.available >= requiredUsd * marginBuffer;
153
+ return snap.available >= requiredArbMargin(notionalUsd, leverage, marginBuffer);
154
+ }
155
+ /**
156
+ * The leverage `adapter` applies to `symbol`: adapter.marketLeverage(symbol) where the venue has a
157
+ * per-symbol read (Hyperliquid activeAssetData, Pacifica /account/settings), else the
158
+ * adapter.getMarketSettings() row (Aster, Lighter, Arcus). null when neither lists the symbol;
159
+ * a failed read throws.
160
+ */
161
+ export async function marketLeverageOf(adapter, symbol) {
162
+ const one = adapter.marketLeverage;
163
+ if (typeof one === "function")
164
+ return one.call(adapter, symbol);
165
+ const read = adapter.getMarketSettings;
166
+ if (typeof read !== "function")
167
+ return null;
168
+ const norm = (x) => x.toUpperCase().replace(/-PERP$/, "").replace(/^[^:]+:/, "");
169
+ const row = (await read.call(adapter)).find(r => norm(r.symbol) === norm(symbol));
170
+ return row !== undefined && row.leverage >= 1 ? row.leverage : null;
94
171
  }
95
172
  /**
96
173
  * Describe the bridge route for a rebalance move.
97
174
  */
98
175
  export function describeBridgeRoute(move) {
176
+ assertBridgeRoute(move.from);
177
+ assertBridgeRoute(move.to);
99
178
  const from = EXCHANGE_CHAINS[move.from];
100
179
  const to = EXCHANGE_CHAINS[move.to];
101
180
  if (!from || !to)