perp-cli 0.12.17 → 0.14.1

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 (363) 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 +303 -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 +248 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +925 -245
  103. package/dist/commands/history.js +102 -54
  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 +357 -459
  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 +240 -6
  166. package/dist/exchanges/aster.js +1139 -195
  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 +152 -0
  200. package/dist/exchanges/ondo/adapter.js +811 -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 +95 -0
  206. package/dist/exchanges/ondo/funds.js +198 -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 +293 -4
  218. package/dist/exchanges/pacifica.js +1044 -90
  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 +1386 -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 +29 -0
  270. package/dist/exchanges/venues/ondo.js +136 -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 +39 -0
  274. package/dist/exchanges/venues/qfex.js +140 -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 +821 -403
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/onchain-balances.d.ts +40 -0
  293. package/dist/onchain-balances.js +173 -0
  294. package/dist/pacifica/client.d.ts +41 -10
  295. package/dist/pacifica/client.js +167 -63
  296. package/dist/pacifica/constants.d.ts +0 -1
  297. package/dist/pacifica/constants.js +0 -5
  298. package/dist/pacifica/deposit.d.ts +30 -3
  299. package/dist/pacifica/deposit.js +119 -2
  300. package/dist/pacifica/faucet.d.ts +29 -0
  301. package/dist/pacifica/faucet.js +100 -0
  302. package/dist/pacifica/sign-tx.d.ts +15 -0
  303. package/dist/pacifica/sign-tx.js +41 -0
  304. package/dist/pacifica/types/account.d.ts +28 -4
  305. package/dist/pacifica/types/lake.d.ts +20 -5
  306. package/dist/pacifica/types/lake.js +4 -0
  307. package/dist/pacifica/types/market.d.ts +5 -0
  308. package/dist/pacifica/types/order.d.ts +4 -1
  309. package/dist/pacifica/types/position.d.ts +9 -1
  310. package/dist/plan-executor.d.ts +10 -5
  311. package/dist/plan-executor.js +165 -54
  312. package/dist/position-history.js +13 -0
  313. package/dist/rebalance.d.ts +26 -2
  314. package/dist/rebalance.js +86 -7
  315. package/dist/relay-swap.d.ts +232 -0
  316. package/dist/relay-swap.js +630 -0
  317. package/dist/risk.js +5 -1
  318. package/dist/secret-env.d.ts +15 -0
  319. package/dist/secret-env.js +36 -0
  320. package/dist/settings.d.ts +169 -6
  321. package/dist/settings.js +219 -38
  322. package/dist/signer/ows-evm.d.ts +13 -0
  323. package/dist/signer/ows-evm.js +22 -0
  324. package/dist/smart-order.d.ts +9 -0
  325. package/dist/smart-order.js +53 -8
  326. package/dist/strategies/dca.d.ts +6 -2
  327. package/dist/strategies/dca.js +66 -26
  328. package/dist/strategies/funding-arb.d.ts +11 -1
  329. package/dist/strategies/funding-arb.js +202 -89
  330. package/dist/strategies/grid.d.ts +31 -0
  331. package/dist/strategies/grid.js +89 -11
  332. package/dist/strategies/pending-stop.d.ts +8 -0
  333. package/dist/strategies/pending-stop.js +12 -0
  334. package/dist/strategies/split-order.d.ts +45 -17
  335. package/dist/strategies/split-order.js +157 -91
  336. package/dist/strategies/trailing-stop.d.ts +2 -1
  337. package/dist/strategies/trailing-stop.js +52 -6
  338. package/dist/strategies/twap.d.ts +6 -1
  339. package/dist/strategies/twap.js +39 -11
  340. package/dist/trade-validator.d.ts +22 -0
  341. package/dist/trade-validator.js +119 -15
  342. package/dist/utils/numeric.d.ts +46 -0
  343. package/dist/utils/numeric.js +79 -0
  344. package/dist/utils.d.ts +19 -0
  345. package/dist/utils.js +96 -19
  346. package/dist/withdraw-gate.d.ts +49 -0
  347. package/dist/withdraw-gate.js +131 -0
  348. package/package.json +15 -6
  349. package/skills/perp-cli/SKILL.md +159 -55
  350. package/skills/perp-cli/references/agent-operations.md +112 -58
  351. package/skills/perp-cli/references/commands.md +269 -73
  352. package/skills/perp-cli/references/strategies.md +34 -31
  353. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  354. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  355. package/skills/perp-cli/scripts/preflight.sh +21 -14
  356. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  357. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  358. package/vendor/lighter-signer/SHA256SUMS +2 -0
  359. package/vendor/lighter-signer/VERSION +8 -0
  360. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  361. package/vendor/lighter-signer/wasm_exec.js +575 -0
  362. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  363. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,24 +1,438 @@
1
- import { printJson, errorAndExit, withJsonErrors, jsonOk, jsonError, symbolMatch, formatUsd } from "../utils.js";
2
- import { extractErrorMessage } from "../errors.js";
1
+ import { networkOf, supportsDryRun } from "../cli-flags.js";
2
+ import { printJson, errorAndExit, withJsonErrors, jsonOk, jsonError, symbolMatch, formatUsd, formatOrderResult, orderResultJson } from "../utils.js";
3
+ import { ERROR_CODES, extractErrorMessage, isDefiniteOrderFailure, parsePositiveDecimal, PerpError } from "../errors.js";
3
4
  import { logExecution } from "../execution-log.js";
4
- import { validateTrade } from "../trade-validator.js";
5
+ import { validateTrade, enforceOrderRisk } from "../trade-validator.js";
5
6
  import { generateClientId, logClientId, isOrderDuplicate } from "../client-id-tracker.js";
6
7
  import { smartOrder } from "../smart-order.js";
8
+ import { invalidateCache } from "../cache.js";
9
+ import { closePerpVerified, isOutcomeUnknown, placePerpVerified } from "../bot/perp-order.js";
10
+ import { iocLotTolerance, isIocOutcomeCapable } from "../exchanges/ioc-outcome.js";
11
+ import { quarantineEntryFor, quarantineSymbol } from "../bot/quarantine.js";
12
+ import { fillSizeString, sizeDecimalsOf } from "../exchanges/spot-result.js";
7
13
  import chalk from "chalk";
8
- import { hasPacificaSdk, isTwapCapable, isTriggerOrderCapable, isQueryOrderCapable } from "../exchanges/capabilities.js";
14
+ import { hasPacificaSdk, isTwapCapable, isQueryOrderCapable } from "../exchanges/capabilities.js";
15
+ import { getVenue, listVenuesWithRole, requireCapabilities } from "../exchanges/venue.js";
16
+ import "../exchanges/venues/index.js";
17
+ import { isTradingV2, newVenueClientOrderId, placeAndConfirm, reduceOnlyGateOf, stopOrderGateOf, unsupported, validateLegacyTif, validateOrderRequest } from "../exchanges/trading-v2.js";
18
+ import { placeTpSlLeg, tradingV2Of } from "../exchanges/legacy-trading-v2.js";
19
+ import { PACIFICA_UUID_RE } from "../exchanges/venues/pacifica.js";
20
+ import { HL_CLOID_RE } from "../exchanges/venues/hyperliquid.js";
21
+ import { ASTER_CLIENT_ID_RE } from "../exchanges/venues/aster.js";
22
+ import { parseOrderRef } from "../exchanges/venue-types.js";
23
+ import { resolveAdapterSlippage, resolveSlippage } from "../exchanges/slippage.js";
24
+ import { exactUnits, floorSize, floorToStep, lotStepOf, unitsToString } from "../exchanges/precision.js";
25
+ import { isSpotOpenOrdersCapable } from "../exchanges/spot-interface.js";
26
+ import { builderPlanFor } from "../exchanges/builder-fee.js";
27
+ import { HL_MIN_ORDER_VALUE_USD } from "../constants.js";
28
+ import { defaultSpotQuote, readSpotQuoteAvailableFresh, requireSpotAdapter, requireSpotDefinition, transferQuoteToPerp, transferQuoteToSpot, transferShortfallError } from "../exchanges/spot-factory.js";
29
+ // ── Uniform order surface (design §13) ──
30
+ //
31
+ // Every venue's trade market/buy/sell/limit/stop/edit/cancel goes through TradingV2:
32
+ // native on ondo/arcus, the legacy wrapper (exchanges/legacy-trading-v2.ts) on
33
+ // pacifica/hyperliquid/lighter/lighter-rh/aster. One default TIF (GTC), one result
34
+ // shape (OrderResult + execution), one printer.
35
+ /** Families with the legacy smart-order path (--smart: IOC at best book ± 1 tick). */
36
+ const LEGACY_ORDER_FAMILIES = new Set(["pacifica", "hyperliquid", "lighter", "aster"]);
37
+ /** Common default time-in-force for every venue (design §13.3; venues map GTC internally, e.g. GTT). */
38
+ const DEFAULT_TIF = "GTC";
39
+ function tradingVenueOf(adapter) {
40
+ const def = getVenue(adapter.name);
41
+ return { def, caps: requireCapabilities(def) };
42
+ }
43
+ function isLegacyOrderVenue(def) {
44
+ return LEGACY_ORDER_FAMILIES.has(def.family);
45
+ }
46
+ function parseSlippageBpsFlag(raw) {
47
+ if (raw === undefined)
48
+ return null;
49
+ const n = Number(raw);
50
+ if (!Number.isFinite(n) || n <= 0)
51
+ throw new PerpError("INVALID_PARAMS", `--slippage-bps must be a positive number (got "${raw}")`);
52
+ return n;
53
+ }
54
+ /**
55
+ * Client-order-id format accepted by each venue with client order ids. Arcus: 1–36 chars of
56
+ * [A-Za-z0-9_-] minus 3 so the "-tp"/"-sl" TP/SL leg suffix always fits;
57
+ * Ondo: 1–64 chars of [A-Za-z0-9_-]; Lighter(-RH): numeric clientOrderIndex (range
58
+ * checked by the adapter); Pacifica: a full UUID (docs.pacifica.fi create-limit-order:
59
+ * `client_order_id` "Full UUID string"); Hyperliquid: a 128-bit cloid, "0x" + 32 hex (HL_CLOID_RE);
60
+ * Aster: `newClientOrderId` ^[.A-Z:/a-z0-9_-]{1,36}$ (ASTER_CLIENT_ID_RE). Venues without client ids refuse --client-id / --auto-id
61
+ * (v2ClientOrderId) — an id is never accepted and then dropped.
62
+ */
63
+ const V2_CLIENT_ID_RULES = {
64
+ arcus: { re: /^[A-Za-z0-9_-]{1,33}$/, desc: "1-33 chars of A-Z a-z 0-9 _ -" },
65
+ ondo: { re: /^[A-Za-z0-9_-]{1,64}$/, desc: "1-64 chars of A-Z a-z 0-9 _ -" },
66
+ lighter: { re: /^[1-9][0-9]{0,14}$/, desc: "a decimal integer from 1 to 2^48 - 1" },
67
+ "lighter-rh": { re: /^[1-9][0-9]{0,14}$/, desc: "a decimal integer from 1 to 2^48 - 1" },
68
+ pacifica: { re: PACIFICA_UUID_RE, desc: "a full UUID, e.g. f47ac10b-58cc-4372-a567-0e02b2c3d479" },
69
+ hyperliquid: { re: HL_CLOID_RE, desc: "a 128-bit hex cloid: 0x + 32 lowercase hex chars, e.g. 0x1234567890abcdef1234567890abcdef (or --auto-id)" },
70
+ aster: { re: ASTER_CLIENT_ID_RE, desc: "1-36 chars of A-Z a-z 0-9 . : / _ -" },
71
+ };
72
+ /**
73
+ * Venues whose client order ids perp-cli GENERATES and never takes from the user: QFEX ids are
74
+ * not unique on the venue, so a reused id would attribute another order's fills / cancels to
75
+ * this one (design §14.12 #4c). --auto-id still works (the generated id is reported).
76
+ */
77
+ const GENERATED_CLIENT_ID_ONLY = new Set(["qfex"]);
78
+ function validateV2ClientId(def, id) {
79
+ if (GENERATED_CLIENT_ID_ONLY.has(def.id)) {
80
+ throw unsupported(def.displayName, "clientOrderId", "client order ids are not unique on the venue, so perp-cli only correlates orders by ids it generates", `Omit --client-id on ${def.id} (use --auto-id, or nothing — the generated id is reported and works with: perp trade status c:<id> --symbol <S> -e ${def.id})`);
81
+ }
82
+ const rule = V2_CLIENT_ID_RULES[def.id];
83
+ if (rule === undefined) {
84
+ throw unsupported(def.displayName, "clientOrderId", "no client-order-id format rule for this venue", "Omit --client-id / --auto-id");
85
+ }
86
+ if (!rule.re.test(id)) {
87
+ throw new PerpError("INVALID_PARAMS", `Client order id "${id}" is not valid on ${def.displayName} (${rule.desc})`);
88
+ }
89
+ }
90
+ /**
91
+ * Venue client order id: the caller's --client-id/--auto-id (validated); else a generated id
92
+ * for async-ack venues; else null. A venue whose orders carry no client id through this CLI
93
+ * refuses a caller id up front (CAPABILITY_UNSUPPORTED, nothing sent):
94
+ * the order would be placed without it and `trade status c:<id>` / `trade cancel c:<id>` could
95
+ * never find it.
96
+ */
97
+ function v2ClientOrderId(def, caps, userId) {
98
+ if (!caps.clientOrderId) {
99
+ if (userId !== undefined) {
100
+ throw unsupported(def.displayName, "clientOrderId", "orders on this venue carry no client order id through perp-cli, so the id would be dropped", `Omit --client-id / --auto-id on ${def.id} and use the venue order id the result reports (perp trade status <orderId> -e ${def.id})`);
101
+ }
102
+ return null;
103
+ }
104
+ if (userId !== undefined) {
105
+ validateV2ClientId(def, userId);
106
+ return userId;
107
+ }
108
+ return caps.orderAck === "async" ? newVenueClientOrderId(def) : null;
109
+ }
110
+ /**
111
+ * --auto-id: venue-format id where the venue has client ids; a venue without them refuses it
112
+ * (v2ClientOrderId — the locally generated id is never sent).
113
+ * Ondo / Pacifica limit orders: undefined — the adapter generates its own id, which encodes
114
+ * post-only (Ondo) / the TIF (Pacifica) for a later edit (design §13.10 #10, #11); the result
115
+ * reports that id. A Pacifica market order has no generated id, so --auto-id sends a UUID.
116
+ * Generated-only venues (qfex): undefined — v2ClientOrderId generates the venue id itself.
117
+ */
118
+ function autoClientId(def, caps, orderType) {
119
+ if (def.id === "ondo" || GENERATED_CLIENT_ID_ONLY.has(def.id))
120
+ return undefined;
121
+ if (def.id === "pacifica" && orderType === "limit")
122
+ return undefined;
123
+ return caps.clientOrderId ? newVenueClientOrderId(def) : generateClientId();
124
+ }
125
+ /** Validated market-order request (`slippage` = resolveSlippage() output). */
126
+ function v2MarketRequest(def, caps, symbol, side, size, reduceOnly, slippage, clientId) {
127
+ const req = {
128
+ symbol, side, type: "market", size, price: null, triggerPrice: null, triggerSource: null, tif: null,
129
+ reduceOnly,
130
+ clientOrderId: v2ClientOrderId(def, caps, clientId),
131
+ slippage,
132
+ tpsl: null,
133
+ };
134
+ validateOrderRequest(def, caps, req);
135
+ return req;
136
+ }
137
+ /**
138
+ * placeAndConfirm() plus the CLI notice for adapter-side size flooring
139
+ * (venueRaw.requestedSize/placedSize, Arcus) on stderr.
140
+ */
141
+ async function placeV2(v2, caps, req) {
142
+ const result = await placeAndConfirm(v2, caps, req);
143
+ const raw = result.venueRaw;
144
+ if (raw !== null && typeof raw === "object" && typeof raw.requestedSize === "string" && typeof raw.placedSize === "string") {
145
+ console.error(chalk.yellow(` Notice: size ${raw.requestedSize} was floored to the venue step → ${raw.placedSize} placed`));
146
+ }
147
+ return result;
148
+ }
149
+ /** Unified human output for an order result (every venue). */
150
+ function printOrder(header, r) {
151
+ console.log(chalk.green(`\n ${header}`));
152
+ for (const line of formatOrderResult(r))
153
+ console.log(line);
154
+ console.log();
155
+ }
156
+ /** Unified human output for a CancelResult. */
157
+ function printCancel(r, symbol) {
158
+ const refText = (ref) => (ref.kind === "clientOrderId" ? `c:${ref.value}` : ref.value);
159
+ const pending = r.pending ?? [];
160
+ if (r.cancelled.length > 0) {
161
+ console.log(chalk.green(`\n Cancelled on ${r.venue} (${symbol}): ${r.cancelled.map(refText).join(", ")}`));
162
+ }
163
+ else if (r.failed.length === 0 && pending.length === 0) {
164
+ console.log(chalk.yellow(`\n No order cancelled on ${r.venue} (${symbol}).`));
165
+ }
166
+ else {
167
+ console.log();
168
+ }
169
+ for (const f of r.failed) {
170
+ console.log(chalk.red(` ✗ ${f.ref !== null ? refText(f.ref) : symbol}: ${f.error}`));
171
+ }
172
+ for (const p of pending) {
173
+ console.log(chalk.yellow(` ? ${p.ref !== null ? refText(p.ref) : symbol}: cancel PENDING — ${p.reason}. ${p.remediation}`));
174
+ }
175
+ console.log();
176
+ }
177
+ /**
178
+ * A cancel with anything not proven cancelled (failed, or pending / still listed open after the
179
+ * verifying re-list) → ORDER_PARTIAL carrying the whole result (non-zero exit, also in --json);
180
+ * all failed (none cancelled, none pending) → ORDER_REJECTED (definitely nothing cancelled).
181
+ */
182
+ export function cancelIncompleteError(r, scope) {
183
+ const pending = r.pending ?? [];
184
+ if (r.failed.length === 0 && pending.length === 0)
185
+ return null;
186
+ if (r.cancelled.length === 0 && pending.length === 0) {
187
+ // Every order definitely NOT cancelled (venue refusal confirmed by the order's own state, e.g.
188
+ // already filled): a definite rejection — nothing changed on the venue.
189
+ return new PerpError("ORDER_REJECTED", `${r.venue} cancel (${scope}) refused: ${r.failed.map(f => f.error).join("; ")}`, {
190
+ exchange: r.venue, cancelled: r.cancelled, failed: r.failed, pending, legacy: r.venueRaw,
191
+ remediation: `Nothing was cancelled — check open orders: perp account orders -e ${r.venue}`,
192
+ });
193
+ }
194
+ return new PerpError("ORDER_PARTIAL", `${r.venue} cancel (${scope}): ${r.cancelled.length} confirmed cancelled, ${r.failed.length} failed, ${pending.length} pending / still open`, {
195
+ exchange: r.venue, cancelled: r.cancelled, failed: r.failed, pending, legacy: r.venueRaw,
196
+ remediation: `Check open orders before acting again: perp account orders -e ${r.venue}`,
197
+ });
198
+ }
199
+ /** JSON extra: the locally tracked CLI client id when the venue order does not carry it. */
200
+ function trackedId(r, clientId) {
201
+ return clientId !== undefined && r.clientOrderId !== clientId ? { trackedClientId: clientId } : undefined;
202
+ }
203
+ /**
204
+ * Close / flatten / reduce / stop and the `--tp/--sl` / `trade tpsl` legs are reduce-only orders.
205
+ * A venue whose reduce-only orders are currently refused says so UP FRONT — before an entry is sent ("entry filled, protection failed" never
206
+ * happens) and before flatten cancels anything.
207
+ */
208
+ /**
209
+ * QFEX (orphanedReduceOnlyOrders): resting reduce-only / stop orders left on a symbol whose
210
+ * position is gone — the venue leaves them open (production 2026-10-12, Q2), and whether one the
211
+ * market reaches while flat opens a position is unproven. `cancel` false → a WARNING listing them
212
+ * with the cancel command; true (flatten) → each is cancelled (per-order proof) and the result
213
+ * returned. Venues without the method → null. A failed read is a WARNING (the close itself stands).
214
+ */
215
+ async function orphanedReduceOnly(adapter, symbol, cancel) {
216
+ const fn = adapter.orphanedReduceOnlyOrders;
217
+ if (typeof fn !== "function")
218
+ return null;
219
+ let left;
220
+ try {
221
+ left = await fn.call(adapter, symbol);
222
+ }
223
+ catch (e) {
224
+ process.stderr.write(chalk.yellow(` WARNING: could not check ${adapter.name} for reduce-only / stop orders left after the close (${extractErrorMessage(e)}) — check: perp account orders -e ${adapter.name}\n`));
225
+ return null;
226
+ }
227
+ if (left.length === 0)
228
+ return null;
229
+ if (!cancel) {
230
+ process.stderr.write(chalk.yellow(` WARNING: ${left.length} reduce-only / stop order(s) remain on ${adapter.name} with no position (the venue does not cancel them): `
231
+ + `${left.map(o => `${o.symbol} ${o.type} ${o.side} ${o.quantity} @ ${o.price} (${o.orderId})`).join("; ")}. Cancel: ${left.map(o => `perp trade cancel ${o.symbol} ${o.orderId} -e ${adapter.name}`).join(" ; ")}\n`));
232
+ return null;
233
+ }
234
+ if (!isTradingV2(adapter))
235
+ throw new PerpError("NOT_IMPLEMENTED", `${adapter.name}: orphaned reduce-only orders can only be cancelled on a TradingV2 adapter`);
236
+ const v2 = adapter;
237
+ const out = { venue: adapter.name, cancelled: [], failed: [], pending: [], venueRaw: [] };
238
+ for (const o of left) {
239
+ const r = await v2.cancelByRef(o.symbol, { kind: "orderId", value: o.orderId });
240
+ out.cancelled.push(...r.cancelled);
241
+ out.failed.push(...r.failed);
242
+ out.pending.push(...(r.pending ?? []));
243
+ }
244
+ return out;
245
+ }
246
+ function refuseGatedReduceOnly(adapter) {
247
+ const gate = reduceOnlyGateOf(adapter);
248
+ if (gate !== null)
249
+ throw gate;
250
+ }
251
+ /** `--tp/--sl` and `trade tpsl` legs are stop orders: a venue that refuses stops (QFEX, Q21) says so before the entry is sent. */
252
+ function refuseGatedStops(adapter) {
253
+ const gate = stopOrderGateOf(adapter);
254
+ if (gate !== null)
255
+ throw gate;
256
+ }
257
+ /** Pre-validate an edit (amend) against the venue capabilities. */
258
+ function prevalidateAmend(adapter) {
259
+ const { def, caps } = tradingVenueOf(adapter);
260
+ if (caps.amend === null) {
261
+ throw unsupported(def.displayName, "amend", "the venue cannot edit orders", `Cancel and re-place the order: perp trade cancel -e ${def.id} ...`);
262
+ }
263
+ }
264
+ function positivePriceOpt(raw, flag) {
265
+ if (raw === undefined)
266
+ return null;
267
+ const n = Number(raw);
268
+ if (!Number.isFinite(n) || n <= 0)
269
+ throw new PerpError("INVALID_PARAMS", `${flag} must be a positive price (got "${raw}")`);
270
+ return raw;
271
+ }
272
+ /** --tp/--sl of an entry command; null when neither is given. */
273
+ function entryTpSl(opts) {
274
+ const tp = positivePriceOpt(opts.tp, "--tp");
275
+ const sl = positivePriceOpt(opts.sl, "--sl");
276
+ return tp === null && sl === null ? null : { tp, sl };
277
+ }
278
+ /**
279
+ * Direction check before anything is sent: a long needs tp > ref > sl, a short sl > ref > tp
280
+ * (ref = the entry's reference: mark for market entries, the limit price for limit entries,
281
+ * the live mark for an open position). Violations → INVALID_PARAMS.
282
+ */
283
+ function checkTpSlDirection(positionSide, ref, refLabel, p) {
284
+ const bad = [];
285
+ if (p.tp !== null) {
286
+ const tp = Number(p.tp);
287
+ if (positionSide === "long" ? !(tp > ref) : !(tp < ref))
288
+ bad.push(`take-profit ${p.tp} must be ${positionSide === "long" ? "above" : "below"} the ${refLabel} ${ref}`);
289
+ }
290
+ if (p.sl !== null) {
291
+ const sl = Number(p.sl);
292
+ if (positionSide === "long" ? !(sl < ref) : !(sl > ref))
293
+ bad.push(`stop-loss ${p.sl} must be ${positionSide === "long" ? "below" : "above"} the ${refLabel} ${ref}`);
294
+ }
295
+ if (bad.length > 0) {
296
+ throw new PerpError("INVALID_PARAMS", `TP/SL direction for a ${positionSide} position: ${bad.join("; ")}`, {
297
+ remediation: positionSide === "long" ? "For a long: --tp above and --sl below the reference price" : "For a short: --tp below and --sl above the reference price",
298
+ });
299
+ }
300
+ }
301
+ /** The venue's current mark price for `symbol` (PRICE_STALE when it is not published / not positive). */
302
+ async function markPriceOf(adapter, symbol) {
303
+ const m = (await adapter.getMarkets()).find(x => symbolMatch(x.symbol, symbol));
304
+ const px = m === undefined ? NaN : Number(m.markPrice);
305
+ if (!(px > 0) || !Number.isFinite(px)) {
306
+ throw new PerpError("PRICE_STALE", `${adapter.name} ${symbol}: no mark price to check the TP/SL direction against`, { exchange: adapter.name });
307
+ }
308
+ return px;
309
+ }
310
+ function positionTpSlAdapter(adapter) {
311
+ const a = adapter;
312
+ if (typeof a.positionTpSlOrders !== "function" || typeof a.setPositionTpSl !== "function") {
313
+ throw new PerpError("NOT_IMPLEMENTED", `${adapter.name} adapter has no position-level TP/SL methods`);
314
+ }
315
+ return adapter;
316
+ }
317
+ /** Refuse when position-level TP/SL orders already exist (never overwrite, §13.10 #12). */
318
+ async function refuseExistingPositionTpSl(adapter, def, symbol) {
319
+ const existing = await adapter.positionTpSlOrders(symbol);
320
+ if (existing.length > 0) {
321
+ throw new PerpError("INVALID_PARAMS", `${def.displayName} ${symbol} already has position TP/SL (${existing.map(o => `${o.type} #${o.orderId}${o.stopPrice !== null ? ` @ ${o.stopPrice}` : ""}`).join(", ")}); refusing to overwrite it`, {
322
+ exchange: def.id,
323
+ remediation: `Cancel the existing TP/SL first (perp trade cancel-stop ${symbol} <orderId> -e ${def.id}), then set the new one`,
324
+ });
325
+ }
326
+ }
327
+ function legLabel(l) {
328
+ return l.kind === "tp" ? `TP @ ${l.trigger}` : l.kind === "sl" ? `SL @ ${l.trigger}` : `position TP/SL ${l.trigger}`;
329
+ }
330
+ // ── Scaled orders (scale-tp / scale-in) ──
331
+ /**
332
+ * Sizes of scaled levels on the venue lot grid: the total is floored to `step`, each level is
333
+ * floor(total × pct / 100) lots and the LAST level takes the remainder — so every level is a
334
+ * multiple of the lot and the levels sum to the floored total. An off-lot level is refused by the
335
+ * venue (Pacifica 422 "Limit order amount 0.00205 is not a multiple of lot size"; Lighter 21706
336
+ * "invalid order base or quote amount" for 0.01475 TSLA at 4 size decimals). A level of zero lots
337
+ * → SIZE_TOO_SMALL.
338
+ */
339
+ export function scaleLevelSizes(total, pcts, step) {
340
+ const units = exactUnits(floorSize(total, step), step);
341
+ if (units === null)
342
+ throw new PerpError("INVALID_PARAMS", `size ${total} cannot be placed on the lot ${step}`);
343
+ const out = [];
344
+ let used = 0n;
345
+ pcts.forEach((pct, i) => {
346
+ const u = i === pcts.length - 1 ? units - used : BigInt(Math.floor(Number(units) * pct / 100 + 1e-9));
347
+ used += u;
348
+ out.push(u);
349
+ });
350
+ const zero = out.findIndex(u => u <= 0n);
351
+ if (zero !== -1) {
352
+ throw new PerpError("SIZE_TOO_SMALL", `Level ${zero + 1} (${pcts[zero]}% of ${total}) is below the lot ${step}; nothing was sent`, {
353
+ remediation: `Use fewer levels or a larger total: every level must be at least one lot (${step})`,
354
+ });
355
+ }
356
+ return out.map(u => unitsToString(u, step));
357
+ }
358
+ /**
359
+ * Refuse (SIZE_TOO_SMALL, nothing sent) a scaled order with a level under the venue's published
360
+ * minimum order size / notional (VenueMarket.minSize / minNotional; null = none published on this
361
+ * data path → not checked). Reduce-only levels are held to it too: no venue's docs state an
362
+ * exemption (Pacifica market-info: `min_order_size` "Minimum order size (denominated in USD)",
363
+ * no exception listed).
364
+ */
365
+ export function requireScaleLevelMinimums(venue, symbol, levels, min) {
366
+ levels.forEach((l, i) => {
367
+ const notional = Number(l.size) * Number(l.price);
368
+ const underSize = min.minSize !== null && Number(l.size) < Number(min.minSize);
369
+ const underNotional = min.minNotional !== null && notional < Number(min.minNotional);
370
+ if (!underSize && !underNotional)
371
+ return;
372
+ throw new PerpError("SIZE_TOO_SMALL", `${venue} ${symbol}: level ${i + 1} (${l.size} @ ${l.price} = $${notional.toFixed(2)}) is below the venue minimum ${underSize ? `size ${min.minSize}` : `order value $${min.minNotional}`}; nothing was sent`, {
373
+ exchange: venue, level: i + 1, size: l.size, price: l.price, minSize: min.minSize, minNotional: min.minNotional,
374
+ remediation: "Use fewer levels (or a larger total) so that every level meets the venue minimum",
375
+ });
376
+ });
377
+ }
378
+ /**
379
+ * The failure a scaled order reports unless every level was placed: any unknown outcome →
380
+ * ORDER_PENDING; every level definitely refused → ORDER_REJECTED; otherwise ORDER_PARTIAL.
381
+ * details list the placed / failed / unknown / not-sent levels.
382
+ */
383
+ export function scaleIncompleteError(venue, what, levels) {
384
+ const by = (o) => levels.filter(l => l.outcome === o);
385
+ const placed = by("placed"), rejected = by("rejected"), unknown = by("unknown"), notSent = by("notSent");
386
+ if (rejected.length === 0 && unknown.length === 0)
387
+ return null;
388
+ const details = { exchange: venue, placed, failed: rejected, unknown, notSent };
389
+ const hint = `perp account orders -e ${venue}`;
390
+ if (unknown.length > 0) {
391
+ return new PerpError("ORDER_PENDING", `${venue} ${what}: the outcome of level ${levels.indexOf(unknown[0]) + 1} is unknown; ${placed.length} placed, ${rejected.length} refused, ${notSent.length} not sent`, {
392
+ ...details, remediation: `Do NOT resubmit — a level may have been placed. Check open orders: ${hint}`,
393
+ });
394
+ }
395
+ if (placed.length === 0) {
396
+ return new PerpError("ORDER_REJECTED", `${venue} ${what}: every level was refused, nothing was placed — ${rejected.map(l => l.result.error).join("; ")}`, details);
397
+ }
398
+ return new PerpError("ORDER_PARTIAL", `${venue} ${what}: ${placed.length} level(s) placed, ${rejected.length} refused`, {
399
+ ...details, remediation: `Some levels are resting. Inspect with: ${hint}, then place the missing levels or cancel the placed ones.`,
400
+ });
401
+ }
402
+ /**
403
+ * Pacifica native TWAP (create / cancel) is refused when signed by an agent wallet: live mainnet
404
+ * 2026-10-05, POST /orders/twap/create with `agent_wallet` → 400 signature_verification_failed
405
+ * ("signature does not match signer and canonical payload"), while the same request signed by
406
+ * the master was accepted (TWAP 121433, then cancelled). The REST docs have no TWAP page and the
407
+ * official python-sdk example signs with the account key only.
408
+ */
409
+ function requirePacificaTwapSigner(adapter, what) {
410
+ const tier = adapter.activeSignerTier;
411
+ if (tier === "agent") {
412
+ throw unsupported("Pacifica", "agentSignedTwap", `the venue rejects an agent-signed ${what} (signature_verification_failed); only the account's own key is accepted`, "Sign this command with the master wallet: add --no-agent (the master wallet must be on this computer)");
413
+ }
414
+ }
415
+ /** Venues whose cancel-by-reference needs no symbol (Ondo: DELETE /orders/<id> or client id). */
416
+ const SYMBOLLESS_CANCEL_VENUES = new Set(["ondo"]);
417
+ /** UUID (Pacifica / QFEX / Ondo order ids) — 8-4-4-4-12 hex. */
418
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
419
+ /** One-arg `trade cancel <x>`: true when x is an order id (numeric, UUID or 0x-hex), never a symbol. */
420
+ export function looksLikeOrderId(x) {
421
+ return /^\d+$/.test(x) || UUID_RE.test(x) || /^0x[0-9a-f]+$/i.test(x);
422
+ }
9
423
  export function registerTradeCommands(program, getAdapter, isJson, isDryRun = () => false, getAdapterForExchange) {
10
424
  /** Guard: if --dry-run is active, log the intended action and return without executing. */
11
425
  function dryRunGuard(action, details) {
12
426
  if (!isDryRun())
13
427
  return false;
14
- const info = { dryRun: true, action, ...details, timestamp: new Date().toISOString() };
428
+ const info = { dryRun: true, sent: false, action, ...details, timestamp: new Date().toISOString() };
15
429
  if (isJson()) {
16
430
  printJson(jsonOk(info));
17
431
  }
18
432
  else {
19
433
  console.log(chalk.yellow(`\n [DRY RUN] Would ${action}:`));
20
434
  for (const [k, v] of Object.entries(details)) {
21
- console.log(chalk.gray(` ${k}: ${v}`));
435
+ console.log(chalk.gray(` ${k}: ${v !== null && typeof v === "object" ? JSON.stringify(v) : v}`));
22
436
  }
23
437
  console.log();
24
438
  }
@@ -34,182 +448,469 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
34
448
  });
35
449
  return true;
36
450
  }
37
- const trade = program.command("trade").description("Trading commands");
38
- // === Generic commands (all exchanges) ===
39
- trade
40
- .command("market <symbol> <side> <size>")
41
- .description("Place a market order (side: buy/sell)")
42
- .option("-s, --slippage <pct>", "Slippage percent", "1")
43
- .option("--reduce-only", "Reduce only order")
44
- .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
45
- .option("--split", "Use orderbook-aware split execution for large orders")
46
- .option("--max-slippage <pct>", "Max slippage per split slice (%)", "0.3")
47
- .option("--client-id <id>", "Client order ID for idempotent tracking")
48
- .option("--auto-id", "Auto-generate a client order ID")
49
- .action(async (symbol, side, size, opts) => {
50
- const s = side.toLowerCase();
51
- if (s !== "buy" && s !== "sell")
52
- errorAndExit("Side must be buy or sell");
53
- const sym = symbol.toUpperCase();
54
- // ── Split execution branch ──
55
- if (opts.split) {
56
- const adapter = await getAdapter();
57
- if (dryRunGuard("split-order", { exchange: adapter.name, symbol: sym, side: s, size }))
58
- return;
59
- const { runSplitOrder } = await import("../strategies/split-order.js");
60
- const markets = await adapter.getMarkets();
61
- const market = markets.find(m => symbolMatch(m.symbol, sym));
62
- const markPrice = market ? parseFloat(market.markPrice) : 0;
63
- const notionalUsd = markPrice > 0 ? parseFloat(size) * markPrice : parseFloat(size);
64
- const result = await runSplitOrder(adapter, {
65
- symbol: sym,
66
- side: s,
67
- totalSizeUsd: notionalUsd,
68
- maxSlippagePct: parseFloat(opts.maxSlippage ?? "0.3"),
69
- }, isJson() ? () => { } : (msg) => console.log(chalk.gray(` ${msg}`)));
70
- if (isJson())
71
- return printJson(jsonOk(result));
72
- const statusColor = result.status === "complete" ? chalk.green : result.status === "partial" ? chalk.yellow : chalk.red;
73
- console.log(`\n ${statusColor(result.status.toUpperCase())} | ${result.slices.length} slices | $${formatUsd(result.filledUsd)} filled | slippage: ${result.totalSlippagePct.toFixed(3)}%\n`);
451
+ /**
452
+ * dryRunGuard for a perp order: adds the builder fee the live order would carry
453
+ * (`builderFee`: { venue, identity, feeBps, feeKind } | null, plus `builderNote` when a
454
+ * configured fee would not be attached).
455
+ */
456
+ async function orderDryRunGuard(action, adapter, symbol, details) {
457
+ if (!isDryRun())
458
+ return false;
459
+ const d = await builderPlanFor(adapter, symbol);
460
+ return dryRunGuard(action, { ...details, builderFee: d.applied, ...(d.note !== null ? { builderNote: d.note } : {}) });
461
+ }
462
+ /**
463
+ * Pre-send gate of every order path that can open or flip a position, run BEFORE the dry-run
464
+ * return (a dry-run reports the refusal the live order would hit):
465
+ * - quarantine (non-reduce-only): an earlier unknown outcome on the symbol blocks new orders
466
+ * until a human clears it — the same gate as MCP (mcpPreTradeGate) and `arb exec`; --force
467
+ * does NOT bypass it (--force is the local risk-limit override only);
468
+ * - local risk limits (enforceOrderRisk; --force bypasses, reduce-only exempt).
469
+ */
470
+ /** Opening orders on a quarantined symbol are refused (reduce-only passes); --force never bypasses it. */
471
+ function assertNotQuarantined(venue, symbol, reduceOnly) {
472
+ if (reduceOnly === true)
473
+ return;
474
+ const q = quarantineEntryFor(symbol, (m) => { if (!isJson())
475
+ console.error(m); });
476
+ if (q !== null) {
477
+ throw new PerpError("RISK_VIOLATION", `${symbol} is quarantined by ${q.strategy}: ${q.reason}`, {
478
+ exchange: venue,
479
+ remediation: `${q.remediation} — only reduce-only orders are accepted until a human clears it (perp strategy quarantine-clear ${symbol})`,
480
+ });
481
+ }
482
+ }
483
+ async function openingOrderGate(adapter, p) {
484
+ assertNotQuarantined(adapter.name, p.symbol, p.reduceOnly);
485
+ await enforceOrderRisk(adapter, p);
486
+ }
487
+ /** Mark price of `sym` (split sizing / risk); no usable mark → PRICE_STALE, nothing sent. */
488
+ async function splitMarkPrice(adapter, sym) {
489
+ const market = (await adapter.getMarkets()).find(m => symbolMatch(m.symbol, sym));
490
+ const markPrice = market !== undefined ? Number(market.markPrice) : NaN;
491
+ if (!(markPrice > 0)) {
492
+ throw new PerpError("PRICE_STALE", `${adapter.name}: no usable mark price for ${sym} — cannot size the split; nothing was sent`, { exchange: adapter.name });
493
+ }
494
+ return markPrice;
495
+ }
496
+ const network = () => networkOf(program);
497
+ const v2Of = (adapter) => tradingV2Of(adapter, network());
498
+ /**
499
+ * Validate every level of a scaled order BEFORE anything is sent (venue TIF / reduce-only rules,
500
+ * e.g. Ondo refuses a resting reduce-only order; venue minimum per level) and return the requests.
501
+ */
502
+ async function scaleRequests(adapter, sym, side, reduceOnly, levels) {
503
+ const { def, caps } = tradingVenueOf(adapter);
504
+ const tif = validateLegacyTif(def, caps, DEFAULT_TIF);
505
+ const reqs = levels.map((l) => ({
506
+ symbol: sym, side, type: "limit", size: l.size, price: l.price, triggerPrice: null, triggerSource: null, tif,
507
+ reduceOnly, clientOrderId: v2ClientOrderId(def, caps, undefined), slippage: null, tpsl: null,
508
+ }));
509
+ for (const req of reqs)
510
+ validateOrderRequest(def, caps, req);
511
+ const { min } = await venueOrderRules(adapter, sym);
512
+ if (min !== null)
513
+ requireScaleLevelMinimums(def.id, sym, levels, min);
514
+ return reqs;
515
+ }
516
+ /**
517
+ * The venue's published tick and order minimums for `sym` (VenueMarket). A HIP-3 dex market
518
+ * ("xyz:NVDA") is not in the venue's public market list (native dex only): no tick there, but
519
+ * Hyperliquid's minimum order value is venue-wide, so it still applies. `min` null = the
520
+ * market has no VenueMarket row and the venue no venue-wide minimum → not checked.
521
+ */
522
+ async function venueOrderRules(adapter, sym) {
523
+ const { def } = tradingVenueOf(adapter);
524
+ const vm = (await v2Of(adapter).getVenueMarkets()).find(m => symbolMatch(m.symbol, sym));
525
+ if (vm !== undefined)
526
+ return { tickSize: vm.tickSize, min: { minSize: vm.minSize, minNotional: vm.minNotional } };
527
+ return { tickSize: null, min: def.id === "hyperliquid" ? { minSize: null, minNotional: HL_MIN_ORDER_VALUE_USD } : null };
528
+ }
529
+ /**
530
+ * Run a split order with the venue's lot / tick / minimums and report it. A run that did not
531
+ * fill completely is an error envelope (ORDER_REJECTED / ORDER_PENDING / ORDER_PARTIAL), never ok:true.
532
+ */
533
+ async function runSplit(adapter, sym, side, totalSizeUsd, o) {
534
+ const { runSplitOrder, splitIncompleteError } = await import("../strategies/split-order.js");
535
+ const rules = await venueOrderRules(adapter, sym);
536
+ const result = await runSplitOrder(adapter, {
537
+ symbol: sym, side, totalSizeUsd, ...o,
538
+ stepSize: await lotStepOf(adapter, sym), tickSize: rules.tickSize,
539
+ minSize: rules.min !== null ? rules.min.minSize : null, minNotional: rules.min !== null ? rules.min.minNotional : null,
540
+ }, isJson() ? () => { } : (msg) => console.log(chalk.gray(` ${msg}`)));
541
+ const incomplete = splitIncompleteError(adapter.name, result);
542
+ if (incomplete !== null)
543
+ throw incomplete;
544
+ if (isJson())
545
+ return printJson(jsonOk(result));
546
+ console.log(`\n ${chalk.green(result.status.toUpperCase())} | ${result.slices.length} slices`);
547
+ console.log(` Filled: ${result.filledSize} / ${result.requestedSize} ${sym}${result.filledUsd !== null ? ` ($${formatUsd(result.filledUsd)} of $${formatUsd(result.requestedUsd)})` : ""}`);
548
+ console.log(` Avg Price: ${result.avgPrice !== null ? `$${result.avgPrice}` : "not reported by the venue"}`);
549
+ if (result.totalSlippagePct !== null)
550
+ console.log(` Slippage: ${result.totalSlippagePct.toFixed(3)}%`);
551
+ console.log(` Runtime: ${result.runtime.toFixed(1)}s\n`);
552
+ }
553
+ /**
554
+ * Send the levels of a scaled order one by one through the uniform order path. A level whose
555
+ * outcome is unknown (transport failure after sending, or a non-final ack) stops the run — the
556
+ * remaining levels are not sent. Throws scaleIncompleteError unless every level was placed.
557
+ */
558
+ async function placeScaleLevels(adapter, action, sym, side, plan, reqs) {
559
+ const { caps } = tradingVenueOf(adapter);
560
+ const out = [];
561
+ let stopped = false;
562
+ for (const [i, level] of plan.entries()) {
563
+ if (stopped) {
564
+ out.push({ ...level, outcome: "notSent", result: { error: "not sent: an earlier level's outcome is unknown" } });
565
+ continue;
566
+ }
567
+ const meta = { action, pct: level.pct, reduceOnly: reqs[i].reduceOnly };
568
+ const log = (status, error) => logExecution({
569
+ type: "limit_order", exchange: adapter.name, symbol: sym, side, size: level.size, price: level.price,
570
+ status, dryRun: false, ...(error !== undefined ? { error } : {}), meta,
571
+ });
572
+ try {
573
+ const r = await placeV2(v2Of(adapter), caps, reqs[i]);
574
+ if (r.finality !== "final") {
575
+ stopped = true;
576
+ log("unverified", "the venue acknowledged the order without a final status");
577
+ out.push({ ...level, outcome: "unknown", result: orderResultJson(r) });
578
+ }
579
+ else if (r.status === "rejected" || r.status === "cancelled") {
580
+ log("failed", `the venue ${r.status} the order`);
581
+ out.push({ ...level, outcome: "rejected", result: { error: `the venue ${r.status} the order${r.orderId !== null ? ` ${r.orderId}` : ""}` } });
582
+ }
583
+ else {
584
+ log("success");
585
+ out.push({ ...level, outcome: "placed", result: orderResultJson(r) });
586
+ }
587
+ }
588
+ catch (err) {
589
+ const msg = extractErrorMessage(err);
590
+ const definite = isDefiniteOrderFailure(err);
591
+ if (!definite)
592
+ stopped = true;
593
+ log(definite ? "failed" : "unverified", msg);
594
+ out.push({ ...level, outcome: definite ? "rejected" : "unknown", result: { error: msg } });
595
+ }
596
+ }
597
+ return out;
598
+ }
599
+ /** Print the per-level table of a scaled order (human mode). */
600
+ function printScaleLevels(title, sym, levels) {
601
+ console.log(chalk.green(`\n ${title}\n`));
602
+ for (const l of levels) {
603
+ const status = l.outcome === "placed" ? chalk.green("OK") : l.outcome === "rejected" ? chalk.red("FAILED") : l.outcome === "unknown" ? chalk.yellow("UNKNOWN") : chalk.gray("NOT SENT");
604
+ console.log(` ${status} $${l.price} — ${l.pct}% (${l.size} ${sym})${"error" in l.result ? chalk.gray(` — ${String(l.result.error)}`) : ""}`);
605
+ }
606
+ console.log();
607
+ }
608
+ /**
609
+ * Validate an entry's --tp/--sl before anything is sent (design §13.10 #12): the venue has
610
+ * TP/SL, the entry opens (not reduce-only), separateOrders / positionLevel venues need an
611
+ * immediate entry (market or IOC — a resting entry has no verified fill to size the legs to),
612
+ * and the prices sit on the right sides of `ref`.
613
+ */
614
+ function prevalidateEntryTpSl(def, caps, side, reduceOnly, immediate, ref, refLabel, prices) {
615
+ if (caps.tpsl === null) {
616
+ throw unsupported(def.displayName, "tpsl", "the venue has no TP/SL orders", `Use stop orders: perp trade stop -e ${def.id} ...`);
617
+ }
618
+ if (reduceOnly) {
619
+ throw new PerpError("INVALID_PARAMS", "--tp/--sl protect the position an entry opens; they cannot be combined with --reduce-only");
620
+ }
621
+ if (!immediate && (caps.tpsl === "separateOrders" || caps.tpsl === "positionLevel")) {
622
+ throw unsupported(def.displayName, "tpslRestingEntry", "TP/SL legs are sized to the entry's verified fill, and a resting entry has no fill yet", `Use a market (or --tif IOC) entry with --tp/--sl, or place the entry and later run: perp trade tpsl <symbol> <long|short> --tp <p> --sl <p> -e ${def.id}`);
623
+ }
624
+ checkTpSlDirection(side === "buy" ? "long" : "short", ref, refLabel, prices);
625
+ }
626
+ /**
627
+ * Place an entry carrying --tp/--sl — one meaning on every venue: protect THIS entry's
628
+ * verified fill (design §13.5, §13.10 #12).
629
+ * - attached (ondo) / batchGroup (arcus): the venue attaches the legs to the entry.
630
+ * - separateOrders (hl, lighter, aster): entry fill VERIFIED (placePerpVerified: venue-final
631
+ * report cross-checked with the position delta), then reduce-only TP/SL triggers sized to it.
632
+ * - positionLevel (pacifica): entry fill verified, then position TP/SL (refused when TP/SL
633
+ * or a position already exists — the position-level TP/SL would cover more than this fill).
634
+ * Entry outcome unknown → ORDER_PENDING, no legs ("position may be unprotected"); entry ok and
635
+ * a leg fails → ORDER_PARTIAL (not retryable) with the entry result and the legs placed.
636
+ */
637
+ async function placeProtectedEntry(adapter, def, caps, req, prices, slippage) {
638
+ const v2 = v2Of(adapter);
639
+ const sym = req.symbol;
640
+ const positionSide = req.side === "buy" ? "long" : "short";
641
+ // An entry against an existing opposite position reduces (or flips) it instead of opening:
642
+ // TP/SL sized to its fill would protect a position that does not exist. Refused on every venue.
643
+ invalidateCache("acct");
644
+ const existing = (await adapter.getPositions()).filter(p => symbolMatch(p.symbol, sym) && Number(p.size) !== 0);
645
+ const opposite = existing.find(p => p.side !== positionSide);
646
+ if (opposite !== undefined) {
647
+ throw new PerpError("INVALID_PARAMS", `--tp/--sl protect the position an entry opens, but this ${req.side} would reduce the existing ${opposite.side} ${opposite.size.replace(/^-/, "")} ${sym} position`, {
648
+ remediation: `Close or reduce the ${opposite.side} first (perp trade close ${sym} -e ${def.id}), or place the entry without --tp/--sl`,
649
+ });
650
+ }
651
+ if (caps.tpsl === "attached" || caps.tpsl === "batchGroup") {
652
+ const entry = await placeV2(v2, caps, { ...req, tpsl: prices });
653
+ return { entry, filledSize: entry.filledSize ?? "unknown", method: caps.tpsl, legs: [] };
654
+ }
655
+ const closeSide = req.side === "buy" ? "sell" : "buy";
656
+ const tpslHint = `perp trade tpsl ${sym} ${positionSide}${prices.tp !== null ? ` --tp ${prices.tp}` : ""}${prices.sl !== null ? ` --sl ${prices.sl}` : ""} -e ${def.id}`;
657
+ let pacifica = null;
658
+ if (caps.tpsl === "positionLevel") {
659
+ pacifica = positionTpSlAdapter(adapter);
660
+ if (existing.length > 0) {
661
+ throw unsupported(def.displayName, "tpslPositionLevel", `TP/SL is position-level and would also cover the existing ${sym} position, not just this entry's fill`, `Place the entry without --tp/--sl, then run: perp trade tpsl ${sym} <long|short> --tp <p> --sl <p> -e ${def.id}`);
662
+ }
663
+ await refuseExistingPositionTpSl(pacifica, def, sym);
664
+ }
665
+ // Lot step from the venue market info: the entry is sent floored to it, its fill is judged
666
+ // against that sent size (half-lot tolerance) and the TP/SL legs are sized on the lot grid.
667
+ const step = await lotStepOf(adapter, sym);
668
+ const sent = { ...req, size: floorSize(req.size, step) };
669
+ const holder = { entry: null };
670
+ let fill;
671
+ try {
672
+ fill = await placePerpVerified(adapter, def.id, sym, sent.side, sent.size, {
673
+ reduceOnly: false,
674
+ stepSize: step,
675
+ place: async () => {
676
+ holder.entry = await placeV2(v2, caps, sent);
677
+ return holder.entry.venueRaw;
678
+ },
679
+ });
680
+ }
681
+ catch (e) {
682
+ if (isOutcomeUnknown(e)) {
683
+ throw new PerpError("ORDER_PENDING", `${extractErrorMessage(e)} — no TP/SL was placed: the position may be unprotected`, {
684
+ exchange: def.id,
685
+ entry: holder.entry !== null ? orderResultJson(holder.entry) : null,
686
+ remediation: `Do NOT resubmit. Check the position (perp account positions -e ${def.id}); once confirmed, protect it with: ${tpslHint}`,
687
+ });
688
+ }
689
+ throw e;
690
+ }
691
+ const entry = holder.entry;
692
+ const filled = floorToStep(fill.filledSize, step);
693
+ const legs = [];
694
+ const partial = (failed, e) => new PerpError("ORDER_PARTIAL", `Entry filled ${filled} ${sym} on ${def.displayName}, but ${failed} failed: ${extractErrorMessage(e)} — ${legs.length > 0 ? `placed: ${legs.map(legLabel).join(", ")}` : "no TP/SL placed"}`, {
695
+ exchange: def.id,
696
+ entry: entry !== null ? orderResultJson(entry) : null,
697
+ filledSize: filled,
698
+ legsPlaced: legs.map(l => ({ kind: l.kind, trigger: l.trigger, orderId: l.result !== null ? l.result.orderId : null })),
699
+ failedLeg: failed,
700
+ remediation: `Do NOT re-enter — the position is ${legs.length > 0 ? "only partly" : "NOT"} protected. Set the missing protection with: ${tpslHint}`,
701
+ });
702
+ if (!(Number(filled) > 0))
703
+ throw partial("TP/SL sizing", new Error(`verified fill ${fill.filledSize} is below one lot (${step})`));
704
+ if (pacifica !== null) {
705
+ const label = `tp ${prices.tp ?? "-"} / sl ${prices.sl ?? "-"}`;
706
+ try {
707
+ const raw = await pacifica.setPositionTpSl(sym, positionSide, { tp: prices.tp, tpLimit: null, sl: prices.sl });
708
+ legs.push({ kind: "tpsl", trigger: label, result: null, venueRaw: raw });
709
+ }
710
+ catch (e) {
711
+ throw partial(`position TP/SL (${label})`, e);
712
+ }
713
+ return { entry, filledSize: filled, method: "positionLevel", legs };
714
+ }
715
+ for (const [kind, trigger] of [["tp", prices.tp], ["sl", prices.sl]]) {
716
+ if (trigger === null)
717
+ continue;
718
+ try {
719
+ const r = await placeTpSlLeg(adapter, network(), { symbol: sym, closeSide, size: filled, trigger, kind, limitPrice: null, slippage });
720
+ legs.push({ kind, trigger, result: r, venueRaw: r.venueRaw });
721
+ }
722
+ catch (e) {
723
+ throw partial(legLabel({ kind, trigger }), e);
724
+ }
725
+ }
726
+ return { entry, filledSize: filled, method: "separateOrders", legs };
727
+ }
728
+ /** Human / JSON output of a protected entry. */
729
+ function printProtectedEntry(header, out, extra) {
730
+ const legsJson = out.legs.map(l => ({ kind: l.kind, trigger: l.trigger, ...(l.result !== null ? orderResultJson(l.result) : { venueRaw: l.venueRaw }) }));
731
+ if (isJson()) {
732
+ printJson(jsonOk({
733
+ ...(out.entry !== null ? orderResultJson(out.entry, extra) : { entry: null, ...extra }),
734
+ verifiedFilledSize: out.filledSize,
735
+ tpsl: { method: out.method, legs: legsJson },
736
+ }));
74
737
  return;
75
738
  }
76
- const clientId = opts.autoId ? generateClientId() : opts.clientId;
77
- if (clientId && isOrderDuplicate(clientId)) {
739
+ if (out.entry !== null)
740
+ printOrder(header, out.entry);
741
+ else
742
+ console.log(chalk.green(`\n ${header}\n`));
743
+ console.log(chalk.green(` Verified fill: ${out.filledSize} · TP/SL (${out.method})`));
744
+ if (out.legs.length === 0)
745
+ console.log(chalk.gray(" Legs attached to the entry by the venue (see execution notes)"));
746
+ for (const l of out.legs) {
747
+ console.log(chalk.green(` ${legLabel(l)}`));
748
+ if (l.result !== null)
749
+ for (const line of formatOrderResult(l.result))
750
+ console.log(line);
751
+ }
752
+ console.log();
753
+ }
754
+ /** One market-order path for trade market / buy / sell (every venue via TradingV2; --smart = legacy smart-order). */
755
+ async function runMarketOrder(sym, side, rawSize, opts) {
756
+ // One parsed size for the risk check and the order ("1,000" / "abc" refused before anything else).
757
+ const size = parsePositiveDecimal(rawSize, "size");
758
+ const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
759
+ if (opts.clientId && isOrderDuplicate(opts.clientId)) {
78
760
  if (isJson())
79
- return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
80
- console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
761
+ return printJson(jsonOk({ duplicate: true, clientOrderId: opts.clientId, message: "Order already submitted" }));
762
+ console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${opts.clientId}). Skipping.\n`));
81
763
  return;
82
764
  }
83
765
  const adapter = await getAdapter();
84
- if (dryRunGuard("market_order", { exchange: adapter.name, symbol: sym, side: s, size, smart: !!opts.smart }))
766
+ const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
767
+ const clientId = opts.autoId ? autoClientId(venueDef, venueCaps, "market") : opts.clientId;
768
+ if (opts.smart && clientId !== undefined) {
769
+ throw unsupported(venueDef.displayName, "smartClientOrderId", "--smart orders are sent without a client order id, so the id would be dropped", "Omit --client-id / --auto-id, or omit --smart");
770
+ }
771
+ const reduceOnly = opts.reduceOnly === true;
772
+ const slippage = resolveSlippage(venueDef.id, slippageFlag);
773
+ if (opts.smart && !isLegacyOrderVenue(venueDef)) {
774
+ throw unsupported(venueDef.displayName, "smartOrder", "--smart needs the legacy limit-order path", "Omit --smart");
775
+ }
776
+ const prices = entryTpSl(opts);
777
+ if (prices !== null) {
778
+ if (opts.smart)
779
+ throw unsupported(venueDef.displayName, "tpslSmart", "--smart entries do not carry --tp/--sl", "Omit --smart");
780
+ refuseGatedReduceOnly(adapter);
781
+ refuseGatedStops(adapter);
782
+ prevalidateEntryTpSl(venueDef, venueCaps, side, reduceOnly, true, await markPriceOf(adapter, sym), "mark price", prices);
783
+ }
784
+ const req = opts.smart ? null : v2MarketRequest(venueDef, venueCaps, sym, side, size, reduceOnly, slippage, clientId);
785
+ // Read-only quarantine + local risk check BEFORE the dry-run return (review L5): a dry-run reports
786
+ // the same RISK_VIOLATION the live order would hit. --force skips the risk limits in both modes.
787
+ await openingOrderGate(adapter, { symbol: sym, size: Number(size), reduceOnly: opts.reduceOnly, force: opts.force });
788
+ if (await orderDryRunGuard("market_order", adapter, sym, { exchange: adapter.name, symbol: sym, side, size, smart: !!opts.smart, ...(prices !== null ? { tp: prices.tp ?? "none", sl: prices.sl ?? "none" } : {}) }))
85
789
  return;
86
790
  if (clientId) {
87
791
  logClientId({
88
792
  clientOrderId: clientId, exchange: adapter.name,
89
- symbol: symbol.toUpperCase(), side: s, size, type: "market",
793
+ symbol: sym, side, size, type: "market",
90
794
  status: "pending", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
91
795
  });
92
796
  }
93
- let result;
797
+ const meta = { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) };
798
+ let placed = null;
799
+ let smartResult = null;
800
+ let protectedOut = null;
94
801
  try {
95
- if (opts.smart) {
96
- const sr = await smartOrder(adapter, symbol.toUpperCase(), s, size);
97
- result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
802
+ if (req === null) {
803
+ const sr = await smartOrder(adapter, sym, side, size, { reduceOnly, slippage });
804
+ smartResult = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
805
+ }
806
+ else if (prices !== null) {
807
+ protectedOut = await placeProtectedEntry(adapter, venueDef, venueCaps, req, prices, slippage);
808
+ placed = protectedOut.entry;
98
809
  }
99
810
  else {
100
- result = await adapter.marketOrder(symbol.toUpperCase(), s, size);
811
+ placed = await placeV2(v2Of(adapter), venueCaps, req);
101
812
  }
102
- logExecution({
103
- type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
104
- side: s, size, status: "success", dryRun: false,
105
- meta: { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) },
106
- });
813
+ logExecution({ type: "market_order", exchange: adapter.name, symbol: sym, side, size, status: "success", dryRun: false, meta });
107
814
  }
108
815
  catch (err) {
109
- logExecution({
110
- type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
111
- side: s, size, status: "failed", dryRun: false,
112
- error: extractErrorMessage(err),
113
- meta: { ...(clientId ? { clientOrderId: clientId } : {}), ...(opts.smart ? { smart: true } : {}) },
114
- });
816
+ logExecution({ type: "market_order", exchange: adapter.name, symbol: sym, side, size, status: isOutcomeUnknown(err) ? "unverified" : "failed", dryRun: false, error: extractErrorMessage(err), meta });
115
817
  throw err;
116
818
  }
117
819
  if (clientId) {
118
820
  logClientId({
119
821
  clientOrderId: clientId, exchange: adapter.name,
120
- symbol: symbol.toUpperCase(), side: s, size, type: "market",
822
+ symbol: sym, side, size, type: "market",
121
823
  status: "submitted", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
122
824
  });
123
825
  }
826
+ const header = `Market ${side.toUpperCase()} ${size} ${sym} on ${adapter.name}${opts.smart ? " (smart)" : ""}${clientId ? ` (id: ${clientId})` : ""}`;
827
+ if (protectedOut !== null)
828
+ return printProtectedEntry(header, protectedOut, placed !== null ? trackedId(placed, clientId) : undefined);
829
+ if (placed === null) {
830
+ // --smart keeps the raw smart-order result shape.
831
+ if (isJson())
832
+ return printJson(jsonOk(clientId ? { ...smartResult, clientOrderId: clientId } : smartResult));
833
+ console.log(chalk.green(`\n ${header} placed.\n`));
834
+ return;
835
+ }
124
836
  if (isJson())
125
- return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
126
- console.log(chalk.green(`\n Market ${s.toUpperCase()} ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}${clientId ? ` (id: ${clientId})` : ""}\n`));
837
+ return printJson(jsonOk(orderResultJson(placed, trackedId(placed, clientId))));
838
+ printOrder(header, placed);
839
+ }
840
+ const trade = program.command("trade").description("Trading commands");
841
+ // === Generic commands (all exchanges) ===
842
+ supportsDryRun(trade.command("market <symbol> <side> <size>"))
843
+ .description("Place a market order (side: buy/sell)")
844
+ .option("--reduce-only", "Reduce only order")
845
+ .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
846
+ .option("--split", "Use orderbook-aware split execution for large orders")
847
+ .option("--max-slippage <pct>", "--split only: max price impact per split slice (%) — not the market-order slippage bound", "0.3")
848
+ .option("--client-id <id>", "Client order ID for idempotent tracking")
849
+ .option("--auto-id", "Auto-generate a client order ID")
850
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
851
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
852
+ .option("--tp <price>", "Take-profit trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
853
+ .option("--sl <price>", "Stop-loss trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
854
+ .action(async (symbol, side, size, opts) => {
855
+ const s = side.toLowerCase();
856
+ if (s !== "buy" && s !== "sell")
857
+ errorAndExit("Side must be buy or sell");
858
+ const sym = symbol.toUpperCase();
859
+ // ── Split execution branch ──
860
+ if (opts.split) {
861
+ // runSplitOrder has no reduce-only path — refuse rather than silently place opening orders.
862
+ if (opts.reduceOnly)
863
+ errorAndExit("--reduce-only is not supported with --split");
864
+ if (opts.tp !== undefined || opts.sl !== undefined)
865
+ throw new PerpError("INVALID_PARAMS", "--tp/--sl are not supported with --split (no single verified entry fill to protect)");
866
+ const baseSize = Number(parsePositiveDecimal(size, "size"));
867
+ const adapter = await getAdapter();
868
+ // No mark price → the base size cannot be valued in USD; never treated as a USD amount.
869
+ const markPrice = await splitMarkPrice(adapter, sym);
870
+ // Split slices are opening orders: quarantine + risk limits before the dry-run return (--force: limits only).
871
+ await openingOrderGate(adapter, { symbol: sym, size: baseSize, price: markPrice, force: opts.force });
872
+ if (await orderDryRunGuard("split-order", adapter, sym, { exchange: adapter.name, symbol: sym, side: s, size }))
873
+ return;
874
+ await runSplit(adapter, sym, s, baseSize * markPrice, { maxSlippagePct: parseFloat(opts.maxSlippage ?? "0.3") });
875
+ return;
876
+ }
877
+ await runMarketOrder(sym, s, size, opts);
127
878
  });
128
879
  // Shortcuts: trade buy / trade sell
129
- trade
130
- .command("buy <symbol> <size>")
880
+ supportsDryRun(trade.command("buy <symbol> <size>"))
131
881
  .description("Market buy (shortcut for: trade market <symbol> buy <size>)")
132
- .option("-s, --slippage <pct>", "Slippage percent", "1")
133
882
  .option("--reduce-only", "Reduce only order")
134
883
  .option("--smart", "Smart execution: IOC limit at best ask + 1 tick")
135
884
  .option("--client-id <id>", "Client order ID")
136
885
  .option("--auto-id", "Auto-generate client order ID")
886
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
887
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
888
+ .option("--tp <price>", "Take-profit trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
889
+ .option("--sl <price>", "Stop-loss trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
137
890
  .action(async (symbol, size, opts) => {
138
- const clientId = opts.autoId ? generateClientId() : opts.clientId;
139
- if (clientId && isOrderDuplicate(clientId)) {
140
- if (isJson())
141
- return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
142
- console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
143
- return;
144
- }
145
- const adapter = await getAdapter();
146
- if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, smart: !!opts.smart }))
147
- return;
148
- let result;
149
- try {
150
- if (opts.smart) {
151
- const sr = await smartOrder(adapter, symbol.toUpperCase(), "buy", size);
152
- result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
153
- }
154
- else {
155
- result = await adapter.marketOrder(symbol.toUpperCase(), "buy", size);
156
- }
157
- logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, status: "success", dryRun: false, meta: opts.smart ? { smart: true } : undefined });
158
- }
159
- catch (err) {
160
- logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "buy", size, status: "failed", dryRun: false, error: extractErrorMessage(err) });
161
- throw err;
162
- }
163
- if (isJson())
164
- return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
165
- console.log(chalk.green(`\n Market BUY ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
891
+ await runMarketOrder(symbol.toUpperCase(), "buy", size, opts);
166
892
  });
167
- trade
168
- .command("sell <symbol> <size>")
893
+ supportsDryRun(trade.command("sell <symbol> <size>"))
169
894
  .description("Market sell (shortcut for: trade market <symbol> sell <size>)")
170
- .option("-s, --slippage <pct>", "Slippage percent", "1")
171
895
  .option("--reduce-only", "Reduce only order")
172
896
  .option("--smart", "Smart execution: IOC limit at best bid - 1 tick")
173
897
  .option("--client-id <id>", "Client order ID")
174
898
  .option("--auto-id", "Auto-generate client order ID")
899
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
900
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
901
+ .option("--tp <price>", "Take-profit trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
902
+ .option("--sl <price>", "Stop-loss trigger protecting this entry's verified fill (every venue; separate-order venues need a market / IOC entry)")
175
903
  .action(async (symbol, size, opts) => {
176
- const clientId = opts.autoId ? generateClientId() : opts.clientId;
177
- if (clientId && isOrderDuplicate(clientId)) {
178
- if (isJson())
179
- return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
180
- console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
181
- return;
182
- }
183
- const adapter = await getAdapter();
184
- if (dryRunGuard("market_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, smart: !!opts.smart }))
185
- return;
186
- let result;
187
- try {
188
- if (opts.smart) {
189
- const sr = await smartOrder(adapter, symbol.toUpperCase(), "sell", size);
190
- result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice, tickSize: sr.tickSize } };
191
- }
192
- else {
193
- result = await adapter.marketOrder(symbol.toUpperCase(), "sell", size);
194
- }
195
- logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, status: "success", dryRun: false, meta: opts.smart ? { smart: true } : undefined });
196
- }
197
- catch (err) {
198
- logExecution({ type: "market_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: "sell", size, status: "failed", dryRun: false, error: extractErrorMessage(err) });
199
- throw err;
200
- }
201
- if (isJson())
202
- return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
203
- console.log(chalk.green(`\n Market SELL ${size} ${symbol.toUpperCase()} placed on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
904
+ await runMarketOrder(symbol.toUpperCase(), "sell", size, opts);
204
905
  });
205
906
  // ── Split Order (orderbook-aware) ──
206
- trade
207
- .command("split <symbol> <side> <usd>")
907
+ supportsDryRun(trade.command("split <symbol> <side> <usd>"))
208
908
  .description("Orderbook-aware split execution — breaks large orders into slices based on depth")
209
909
  .option("--max-slippage <pct>", "Max slippage per slice (%)", "0.3")
210
910
  .option("--max-slices <n>", "Max number of slices", "10")
211
911
  .option("--delay <ms>", "Delay between slices in ms", "1000")
212
912
  .option("--min-slice <usd>", "Minimum slice size in USD", "100")
913
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
213
914
  .action(async (symbol, side, usd, opts) => {
214
915
  const sym = symbol.toUpperCase();
215
916
  const orderSide = side.toLowerCase();
@@ -219,70 +920,96 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
219
920
  if (isNaN(totalUsd) || totalUsd <= 0)
220
921
  errorAndExit("USD amount must be > 0");
221
922
  const adapter = await getAdapter();
222
- if (dryRunGuard("split-order", { exchange: adapter.name, symbol: sym, side: orderSide, totalUsd, ...opts }))
923
+ // Opening orders: quarantine + risk limits on size = usd / mark before the dry-run return (--force: limits only).
924
+ const markPrice = await splitMarkPrice(adapter, sym);
925
+ await openingOrderGate(adapter, { symbol: sym, size: totalUsd / markPrice, price: markPrice, force: opts.force });
926
+ if (await orderDryRunGuard("split-order", adapter, sym, { exchange: adapter.name, symbol: sym, side: orderSide, totalUsd, ...opts }))
223
927
  return;
224
- const { runSplitOrder } = await import("../strategies/split-order.js");
225
- const result = await runSplitOrder(adapter, {
226
- symbol: sym,
227
- side: orderSide,
228
- totalSizeUsd: totalUsd,
928
+ await runSplit(adapter, sym, orderSide, totalUsd, {
229
929
  maxSlippagePct: parseFloat(opts.maxSlippage),
230
930
  maxSlices: parseInt(opts.maxSlices),
231
931
  delayMs: parseInt(opts.delay),
232
932
  minSliceUsd: parseFloat(opts.minSlice),
233
- }, isJson() ? () => { } : (msg) => console.log(chalk.gray(` ${msg}`)));
234
- if (isJson())
235
- return printJson(jsonOk(result));
236
- const statusColor = result.status === "complete" ? chalk.green
237
- : result.status === "partial" ? chalk.yellow
238
- : chalk.red;
239
- console.log(`\n ${statusColor(result.status.toUpperCase())} | ${result.slices.length} slices`);
240
- console.log(` Filled: $${formatUsd(result.filledUsd)} / $${formatUsd(result.requestedUsd)}`);
241
- console.log(` Avg Price: $${formatUsd(result.avgPrice)}`);
242
- console.log(` Slippage: ${result.totalSlippagePct.toFixed(3)}%`);
243
- console.log(` Runtime: ${result.runtime.toFixed(1)}s\n`);
933
+ });
244
934
  });
245
- trade
246
- .command("limit <symbol> <side> <price> <size>")
935
+ supportsDryRun(trade.command("limit <symbol> <side> <price> <size>"))
247
936
  .description("Place a limit order")
248
- .option("--tif <tif>", "Time in force: GTC, IOC, ALO (HL only), TOB (HL only)", "GTC")
937
+ .option("--tif <tif>", "Time in force: GTC (default, every venue) | IOC | FOK | ALO (post-only); venue aliases GTT / GTX / TOB accepted")
249
938
  .option("--reduce-only", "Reduce only order")
250
939
  .option("--client-id <id>", "Client order ID for idempotent tracking")
251
940
  .option("--auto-id", "Auto-generate a client order ID")
252
- .action(async (symbol, side, price, size, opts) => {
941
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
942
+ .option("--tp <price>", "Take-profit protecting this entry's fill (ondo / arcus: attached to any entry; other venues: --tif IOC entries only)")
943
+ .option("--sl <price>", "Stop-loss protecting this entry's fill (ondo / arcus: attached to any entry; other venues: --tif IOC entries only)")
944
+ .option("--slippage-bps <n>", "Slippage bound in bps for protection-priced TP/SL legs (default: settings slippageBps[venue] → defaultSlippageBps)")
945
+ .action(async (symbol, side, price, rawSize, opts) => {
253
946
  const s = side.toLowerCase();
254
947
  if (s !== "buy" && s !== "sell")
255
948
  errorAndExit("Side must be buy or sell");
256
- const clientId = opts.autoId ? generateClientId() : opts.clientId;
257
- if (clientId && isOrderDuplicate(clientId)) {
949
+ const sym = symbol.toUpperCase();
950
+ // One parsed size for the risk check and the order ("1,000" / "abc" refused up front).
951
+ const size = parsePositiveDecimal(rawSize, "size");
952
+ if (opts.clientId && isOrderDuplicate(opts.clientId)) {
258
953
  if (isJson())
259
- return printJson(jsonOk({ duplicate: true, clientOrderId: clientId, message: "Order already submitted" }));
260
- console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${clientId}). Skipping.\n`));
954
+ return printJson(jsonOk({ duplicate: true, clientOrderId: opts.clientId, message: "Order already submitted" }));
955
+ console.log(chalk.yellow(`\n Duplicate order detected (clientId: ${opts.clientId}). Skipping.\n`));
261
956
  return;
262
957
  }
263
958
  const adapter = await getAdapter();
264
- if (dryRunGuard("limit_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, size, price }))
959
+ const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
960
+ const clientId = opts.autoId ? autoClientId(venueDef, venueCaps, "limit") : opts.clientId;
961
+ const tif = validateLegacyTif(venueDef, venueCaps, opts.tif ?? DEFAULT_TIF);
962
+ const req = {
963
+ symbol: sym, side: s, type: "limit", size, price,
964
+ triggerPrice: null, triggerSource: null, tif, reduceOnly: opts.reduceOnly === true,
965
+ clientOrderId: v2ClientOrderId(venueDef, venueCaps, clientId), slippage: null, tpsl: null,
966
+ };
967
+ validateOrderRequest(venueDef, venueCaps, req);
968
+ const prices = entryTpSl(opts);
969
+ if (prices !== null) {
970
+ if (!(Number(price) > 0))
971
+ throw new PerpError("INVALID_PARAMS", `limit price must be positive (got "${price}")`);
972
+ refuseGatedReduceOnly(adapter);
973
+ refuseGatedStops(adapter);
974
+ prevalidateEntryTpSl(venueDef, venueCaps, s, req.reduceOnly, tif === "IOC", Number(price), "limit price", prices);
975
+ }
976
+ // TP/SL legs of protection-priced venues (arcus, lighter) use the single resolved bound.
977
+ const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
978
+ if (slippageFlag !== null && prices === null) {
979
+ throw new PerpError("INVALID_PARAMS", "--slippage-bps applies to --tp/--sl legs of a limit order; a plain limit order has no slippage bound");
980
+ }
981
+ const tpslSlippage = prices !== null ? resolveSlippage(venueDef.id, slippageFlag) : null;
982
+ // Read-only quarantine + risk check before the dry-run return (L5) — same gate as the live order.
983
+ await openingOrderGate(adapter, { symbol: sym, size: Number(size), price: parseFloat(price), reduceOnly: opts.reduceOnly, force: opts.force });
984
+ if (await orderDryRunGuard("limit_order", adapter, sym, { exchange: adapter.name, symbol: sym, side: s, size, price, tif, ...(prices !== null ? { tp: prices.tp ?? "none", sl: prices.sl ?? "none" } : {}) }))
265
985
  return;
266
986
  if (clientId) {
267
987
  logClientId({
268
988
  clientOrderId: clientId, exchange: adapter.name,
269
- symbol: symbol.toUpperCase(), side: s, size, type: "limit",
989
+ symbol: sym, side: s, size, type: "limit",
270
990
  status: "pending", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
271
991
  });
272
992
  }
273
993
  let result;
994
+ let protectedOut = null;
274
995
  try {
275
- result = await adapter.limitOrder(symbol.toUpperCase(), s, price, size);
996
+ if (prices !== null && tpslSlippage !== null) {
997
+ protectedOut = await placeProtectedEntry(adapter, venueDef, venueCaps, { ...req, slippage: venueCaps.tpsl === "batchGroup" ? tpslSlippage : null }, prices, tpslSlippage);
998
+ result = protectedOut.entry;
999
+ }
1000
+ else {
1001
+ result = await placeV2(v2Of(adapter), venueCaps, req);
1002
+ }
276
1003
  logExecution({
277
- type: "limit_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
1004
+ type: "limit_order", exchange: adapter.name, symbol: sym,
278
1005
  side: s, size, price, status: "success", dryRun: false,
279
1006
  meta: clientId ? { clientOrderId: clientId } : undefined,
280
1007
  });
281
1008
  }
282
1009
  catch (err) {
283
1010
  logExecution({
284
- type: "limit_order", exchange: adapter.name, symbol: symbol.toUpperCase(),
285
- side: s, size, price, status: "failed", dryRun: false,
1011
+ type: "limit_order", exchange: adapter.name, symbol: sym,
1012
+ side: s, size, price, status: isOutcomeUnknown(err) ? "unverified" : "failed", dryRun: false,
286
1013
  error: extractErrorMessage(err),
287
1014
  meta: clientId ? { clientOrderId: clientId } : undefined,
288
1015
  });
@@ -291,132 +1018,167 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
291
1018
  if (clientId) {
292
1019
  logClientId({
293
1020
  clientOrderId: clientId, exchange: adapter.name,
294
- symbol: symbol.toUpperCase(), side: s, size, type: "limit",
1021
+ symbol: sym, side: s, size, type: "limit",
295
1022
  status: "submitted", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
296
1023
  });
297
1024
  }
1025
+ const header = `Limit ${s.toUpperCase()} ${size} ${sym} @ $${price} on ${adapter.name}${clientId ? ` (id: ${clientId})` : ""}`;
1026
+ if (protectedOut !== null)
1027
+ return printProtectedEntry(header, protectedOut, result !== null ? trackedId(result, clientId) : undefined);
1028
+ if (result === null)
1029
+ throw new PerpError("EXCHANGE_ERROR", "unreachable: limit order produced no result");
298
1030
  if (isJson())
299
- return printJson(jsonOk(clientId ? { ...result, clientOrderId: clientId } : result));
300
- console.log(chalk.green(`\n Limit ${s.toUpperCase()} ${size} ${symbol.toUpperCase()} @ $${price} placed on ${adapter.name}.${clientId ? ` (id: ${clientId})` : ""}\n`));
1031
+ return printJson(jsonOk(orderResultJson(result, trackedId(result, clientId))));
1032
+ printOrder(header, result);
301
1033
  });
302
- trade
303
- .command("cancel <symbolOrOrderId> [orderId]")
304
- .description("Cancel order(s) — by orderId, by symbol + orderId, or by symbol name (cancels all orders for that symbol)")
1034
+ supportsDryRun(trade.command("cancel <symbolOrOrderId> [orderId]"))
1035
+ .description("Cancel order(s) — by orderId (or c:<clientOrderId> on ondo/arcus/qfex/pacifica), by symbol + orderId / c:<clientOrderId> (every venue with client ids: also hyperliquid, aster, lighter, lighter-rh), or by symbol name (cancels all orders for that symbol)")
305
1036
  .action(async (symbolOrOrderId, orderId) => {
306
1037
  const adapter = await getAdapter();
307
- if (orderId) {
308
- // cancel <symbol> <orderId>
309
- const symbol = symbolOrOrderId.toUpperCase();
310
- const oid = orderId;
311
- if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: oid }))
1038
+ const v2 = v2Of(adapter);
1039
+ /** Cancel one order by reference through TradingV2 and print the uniform result. */
1040
+ const cancelOne = async (symbol, refText) => {
1041
+ const ref = parseOrderRef(refText);
1042
+ if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: refText }))
312
1043
  return;
1044
+ let result;
313
1045
  try {
314
- const result = await adapter.cancelOrder(symbol, oid);
315
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { orderId: oid } });
316
- if (isJson())
317
- return printJson(jsonOk(result));
318
- console.log(chalk.green(`\n Order ${oid} cancelled on ${adapter.name}.\n`));
1046
+ result = await v2.cancelByRef(symbol, ref);
1047
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: result.failed.length > 0 ? "failed" : (result.pending ?? []).length > 0 ? "unverified" : "success", dryRun: false, meta: { orderId: refText } });
319
1048
  }
320
1049
  catch (err) {
321
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { orderId: oid } });
1050
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { orderId: refText } });
322
1051
  throw err;
323
1052
  }
1053
+ const incomplete = cancelIncompleteError(result, symbol);
1054
+ if (incomplete !== null) {
1055
+ if (!isJson())
1056
+ printCancel(result, symbol);
1057
+ throw incomplete;
1058
+ }
1059
+ if (isJson())
1060
+ return printJson(jsonOk({ ...result, legacy: result.venueRaw }));
1061
+ printCancel(result, symbol);
1062
+ };
1063
+ if (orderId) {
1064
+ // cancel <symbol> <orderId>
1065
+ await cancelOne(symbolOrOrderId.toUpperCase(), orderId);
1066
+ return;
1067
+ }
1068
+ // Single argument — an order reference (c:<clientOrderId>, numeric / UUID / 0x-hex order id)
1069
+ // or a symbol name. An explicit order reference is never treated as a symbol.
1070
+ const ref = parseOrderRef(symbolOrOrderId);
1071
+ const { def: venueDef } = tradingVenueOf(adapter);
1072
+ const twoArgHint = `perp trade cancel <symbol> ${symbolOrOrderId} -e ${venueDef.id}`;
1073
+ if (ref.kind === "clientOrderId") {
1074
+ // Open-order listings carry no client id, so the symbol cannot be looked up from it.
1075
+ if (!SYMBOLLESS_CANCEL_VENUES.has(venueDef.id)) {
1076
+ throw new PerpError("INVALID_PARAMS", `${venueDef.displayName} cancels a client order id within a market — the symbol is required`, {
1077
+ remediation: `Pass the symbol: ${twoArgHint}`,
1078
+ });
1079
+ }
1080
+ await cancelOne("", symbolOrOrderId);
324
1081
  return;
325
1082
  }
326
- // Single argument — is it an orderId (numeric) or a symbol name?
327
- const isNumeric = /^\d+$/.test(symbolOrOrderId);
328
- if (isNumeric) {
1083
+ if (looksLikeOrderId(symbolOrOrderId)) {
329
1084
  // cancel <orderId> — look up symbol from open orders
330
1085
  const oid = symbolOrOrderId;
331
1086
  const orders = await adapter.getOpenOrders();
332
1087
  const match = orders.find((o) => String(o.orderId) === oid);
333
1088
  if (!match) {
334
- const err = `Order ${oid} not found in open orders`;
335
- if (isJson())
336
- return printJson(jsonOk({ cancelled: false, reason: err }));
337
- console.log(chalk.yellow(`\n ${err}\n`));
338
- return;
339
- }
340
- const symbol = match.symbol;
341
- if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: oid }))
342
- return;
343
- try {
344
- const result = await adapter.cancelOrder(symbol, oid);
345
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { orderId: oid } });
346
- if (isJson())
347
- return printJson(jsonOk(result));
348
- console.log(chalk.green(`\n Order ${oid} cancelled on ${adapter.name}.\n`));
349
- }
350
- catch (err) {
351
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { orderId: oid } });
352
- throw err;
1089
+ throw new PerpError("ORDER_NOT_FOUND", `Order ${oid} not found in the open orders on ${venueDef.displayName}; nothing was cancelled`, {
1090
+ exchange: adapter.name,
1091
+ remediation: `Check open orders (perp account orders -e ${venueDef.id}); if it is a stop / trigger not listed there, pass the symbol: ${twoArgHint}`,
1092
+ });
353
1093
  }
1094
+ await cancelOne(match.symbol, oid);
1095
+ return;
354
1096
  }
355
- else {
356
- // cancel <symbol> — cancel all open orders for this symbol
357
- const symbol = symbolOrOrderId.toUpperCase();
358
- const orders = await adapter.getOpenOrders();
359
- const matching = orders.filter((o) => symbolMatch(o.symbol, symbol));
360
- if (matching.length === 0) {
361
- const err = `No open orders found for ${symbol}`;
362
- if (isJson())
363
- return printJson(jsonOk({ cancelled: false, symbol, reason: err }));
364
- console.log(chalk.yellow(`\n ${err}\n`));
365
- return;
366
- }
367
- if (dryRunGuard("cancel_by_symbol", { exchange: adapter.name, symbol, count: matching.length }))
368
- return;
369
- try {
370
- const result = await adapter.cancelAllOrders(symbol);
371
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { bySymbol: true, count: matching.length } });
372
- if (isJson())
373
- return printJson(jsonOk({ cancelled: true, symbol, count: matching.length, result }));
374
- console.log(chalk.green(`\n ${matching.length} order(s) for ${symbol} cancelled on ${adapter.name}.\n`));
375
- }
376
- catch (err) {
377
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { bySymbol: true } });
378
- throw err;
379
- }
1097
+ // cancel <symbol> — cancel all open orders for this symbol
1098
+ const symbol = symbolOrOrderId.toUpperCase();
1099
+ const orders = await adapter.getOpenOrders();
1100
+ const matching = orders.filter((o) => symbolMatch(o.symbol, symbol));
1101
+ if (matching.length === 0) {
1102
+ const err = `No open orders found for ${symbol}`;
1103
+ if (isJson())
1104
+ return printJson(jsonOk({ cancelled: false, symbol, reason: err }));
1105
+ console.log(chalk.yellow(`\n ${err}\n`));
1106
+ return;
380
1107
  }
1108
+ if (dryRunGuard("cancel_by_symbol", { exchange: adapter.name, symbol, count: matching.length }))
1109
+ return;
1110
+ let result;
1111
+ try {
1112
+ result = await v2.cancelAllV2(symbol);
1113
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: result.failed.length > 0 ? "failed" : (result.pending ?? []).length > 0 ? "unverified" : "success", dryRun: false, meta: { bySymbol: true, count: matching.length } });
1114
+ }
1115
+ catch (err) {
1116
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: extractErrorMessage(err), meta: { bySymbol: true } });
1117
+ throw err;
1118
+ }
1119
+ const incomplete = cancelIncompleteError(result, symbol);
1120
+ if (incomplete !== null) {
1121
+ if (!isJson())
1122
+ printCancel(result, symbol);
1123
+ throw incomplete;
1124
+ }
1125
+ if (isJson())
1126
+ return printJson(jsonOk({ ...result, symbol, legacy: result.venueRaw }));
1127
+ printCancel(result, symbol);
381
1128
  });
382
- trade
383
- .command("cancel-all")
1129
+ supportsDryRun(trade.command("cancel-all"))
384
1130
  .description("Cancel all open orders")
385
1131
  .action(async () => {
386
1132
  const adapter = await getAdapter();
387
1133
  if (dryRunGuard("cancel_all", { exchange: adapter.name }))
388
1134
  return;
389
- const result = await adapter.cancelAllOrders();
1135
+ // Per-order proof (cancelAllV2): only orders the venue result proves are reported cancelled.
1136
+ const result = await v2Of(adapter).cancelAllV2(null);
1137
+ logExecution({ type: "cancel", exchange: adapter.name, symbol: "", side: "cancel", size: "0", dryRun: false,
1138
+ status: result.failed.length > 0 ? "failed" : (result.pending ?? []).length > 0 ? "unverified" : "success", meta: { all: true, cancelled: result.cancelled.length } });
1139
+ const incomplete = cancelIncompleteError(result, "all");
1140
+ if (incomplete !== null) {
1141
+ if (!isJson())
1142
+ printCancel(result, "all");
1143
+ throw incomplete;
1144
+ }
390
1145
  if (isJson())
391
- return printJson(jsonOk(result));
392
- console.log(chalk.green(`\n All orders cancelled on ${adapter.name}.\n`));
1146
+ return printJson(jsonOk({ ...result, legacy: result.venueRaw }));
1147
+ printCancel(result, "all");
393
1148
  });
394
1149
  // === TWAP — Pacifica (seconds) + Hyperliquid (minutes) ===
395
1150
  trade
396
1151
  .command("twap <symbol> <side> <size> <duration>")
397
1152
  .description("Place a TWAP order (Pacifica: seconds, HL: minutes, Lighter/any: client-side via --background)")
398
- .option("-s, --slippage <pct>", "Slippage percent", "1")
1153
+ .option("--slippage-bps <n>", "Slippage bound in bps for Pacifica native TWAP (default: settings slippageBps[venue] → defaultSlippageBps)")
399
1154
  .option("--reduce-only", "Reduce only order")
400
1155
  .option("--background", "Run client-side TWAP in background (tmux) — works on all exchanges")
401
1156
  .option("--slices <n>", "Number of slices for client-side TWAP")
402
- .action(async (symbol, side, size, duration, opts) => {
1157
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
1158
+ .action(async (symbol, side, rawSize, duration, opts) => {
403
1159
  const s = side.toLowerCase();
404
1160
  if (s !== "buy" && s !== "sell")
405
1161
  errorAndExit("Side must be buy or sell");
1162
+ // One parsed size for the risk check and the order ("1,000" / "abc" refused up front).
1163
+ const size = parsePositiveDecimal(rawSize, "size");
406
1164
  // --background → client-side TWAP via tmux job
407
1165
  if (opts.background) {
408
- const { startJob } = await import("../jobs.js");
409
- const exchange = (await getAdapter()).name;
1166
+ const { startJob, jobSelectorsOf } = await import("../jobs.js");
1167
+ const bgAdapter = await getAdapter();
1168
+ const exchange = bgAdapter.name;
1169
+ // Risk check (and its --force bypass) runs here: the child `strategy twap` has none.
1170
+ await openingOrderGate(bgAdapter, { symbol: symbol.toUpperCase(), size: Number(size), reduceOnly: opts.reduceOnly, force: opts.force });
410
1171
  const cliArgs = [
411
- symbol.toUpperCase(), s, size, duration,
1172
+ "strategy", "twap", symbol.toUpperCase(), s, size, duration,
412
1173
  ...(opts.slices ? ["--slices", opts.slices] : []),
1174
+ ...(opts.reduceOnly ? ["--reduce-only"] : []),
413
1175
  ];
414
- // Pass exchange flag through
415
1176
  const job = startJob({
416
1177
  strategy: "twap",
417
1178
  exchange,
418
- params: { symbol: symbol.toUpperCase(), side: s, size, duration, slices: opts.slices },
419
- cliArgs: [`-e`, exchange, ...cliArgs],
1179
+ params: { symbol: symbol.toUpperCase(), side: s, size, duration, slices: opts.slices, reduceOnly: opts.reduceOnly === true },
1180
+ venue: exchange, network: networkOf(program), dryRun: isDryRun(), selectors: jobSelectorsOf(program),
1181
+ cliArgs,
420
1182
  });
421
1183
  if (isJson())
422
1184
  return printJson(jsonOk(job));
@@ -428,24 +1190,29 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
428
1190
  return;
429
1191
  }
430
1192
  const adapter = await getAdapter();
1193
+ await openingOrderGate(adapter, { symbol: symbol.toUpperCase(), size: Number(size), reduceOnly: opts.reduceOnly, force: opts.force });
431
1194
  // Native TWAP: Pacifica (SDK) or Hyperliquid (twapOrder)
432
1195
  // Fallback: client-side TWAP for any exchange without native support
433
1196
  let result;
434
1197
  if (hasPacificaSdk(adapter)) {
1198
+ // One slippage resolver (design §13.2): bps → Pacifica's slippage_percent.
1199
+ const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
1200
+ requirePacificaTwapSigner(adapter, "TWAP order");
435
1201
  const sdk = adapter.sdk;
436
1202
  result = await sdk.createTWAP({
437
1203
  symbol: symbol.toUpperCase(),
438
1204
  amount: size,
439
1205
  side: s === "buy" ? "bid" : "ask",
440
- slippage_percent: opts.slippage,
1206
+ slippage_percent: String(slippage.bps / 100),
441
1207
  reduce_only: opts.reduceOnly ?? false,
442
1208
  duration_in_seconds: parseInt(duration),
443
1209
  }, adapter.publicKey, adapter.signer);
444
1210
  }
445
1211
  else if (isTwapCapable(adapter)) {
446
- const minutes = parseInt(duration);
447
- if (minutes < 5 || minutes > 1440)
448
- errorAndExit("HL TWAP duration must be 5-1440 minutes");
1212
+ // Bounds (5 minutes – 7 days, $100 minimum total size) are pre-checked in twapOrder (hlAssertTwapBounds).
1213
+ if (!/^\d+$/.test(duration.trim()))
1214
+ errorAndExit("HL TWAP duration must be a whole number of minutes");
1215
+ const minutes = Number(duration.trim());
449
1216
  result = await adapter.twapOrder(symbol.toUpperCase(), s, size, minutes, { reduceOnly: opts.reduceOnly });
450
1217
  }
451
1218
  else {
@@ -457,7 +1224,9 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
457
1224
  totalSize: parseFloat(size),
458
1225
  durationSec: parseInt(duration),
459
1226
  slices: opts.slices ? parseInt(opts.slices) : undefined,
460
- });
1227
+ reduceOnly: opts.reduceOnly === true,
1228
+ }, undefined, isJson() ? (m) => console.error(m) : undefined);
1229
+ // JSON mode: progress goes to stderr so stdout stays one JSON document.
461
1230
  if (isJson())
462
1231
  return printJson(jsonOk(twapResult));
463
1232
  return;
@@ -466,104 +1235,199 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
466
1235
  return printJson(jsonOk(result));
467
1236
  console.log(chalk.green(`\n TWAP ${s.toUpperCase()} ${size} ${symbol.toUpperCase()} over ${duration} placed on ${adapter.name}.\n`));
468
1237
  });
469
- // === Stop / Trigger orders — Pacifica + Hyperliquid ===
470
- trade
471
- .command("stop <symbol> <side> <size> <stopPrice>")
472
- .description("Place a stop order")
1238
+ // === Stop / Trigger orders (every trading venue) ===
1239
+ supportsDryRun(trade.command("stop <symbol> <side> <size> <stopPrice>"))
1240
+ .description("Place a stop order (market trigger; --limit-price makes it stop-limit). Reduce-only by default on every venue (BEHAVIOR CHANGE: pass --no-reduce-only to let it open / flip a position). Limit-priced market triggers (lighter, arcus) fill only within trigger ± the slippage bound — a fast move can leave them unfilled")
473
1241
  .option("--limit-price <price>", "Limit price (makes it stop-limit)")
474
- .option("--reduce-only", "Reduce only order")
475
- .action(async (symbol, side, size, stopPrice, opts) => {
1242
+ .option("--no-reduce-only", "Allow the triggered order to open or flip a position (default: reduce-only)")
1243
+ .option("--trigger-type <type>", "Trigger price source: mark | last | mid (pacifica; aster mark | last; other venues: unsupported)")
1244
+ .option("--slippage-bps <n>", "Bound for protection-priced market triggers (lighter, arcus) in bps, around the trigger price (default: settings)")
1245
+ .option("--force", "With --no-reduce-only: bypass local risk-limit checks (maxPosition / total exposure)")
1246
+ .action(async (symbol, side, rawSize, stopPrice, opts) => {
476
1247
  const s = side.toLowerCase();
477
1248
  if (s !== "buy" && s !== "sell")
478
1249
  errorAndExit("Side must be buy or sell");
1250
+ const size = parsePositiveDecimal(rawSize, "size");
1251
+ if (opts.triggerType && !["mark", "last", "mid"].includes(opts.triggerType)) {
1252
+ errorAndExit("--trigger-type must be one of: mark, last, mid");
1253
+ }
1254
+ const sym = symbol.toUpperCase();
479
1255
  const adapter = await getAdapter();
480
- if (dryRunGuard("stop_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, size, price: stopPrice }))
1256
+ const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
1257
+ const isLimit = opts.limitPrice !== undefined;
1258
+ const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
1259
+ if (slippageFlag !== null) {
1260
+ if (isLimit)
1261
+ throw new PerpError("INVALID_PARAMS", "--slippage-bps applies to market triggers only; a stop-limit executes at --limit-price");
1262
+ if (venueDef.family !== "lighter" && venueDef.id !== "arcus") {
1263
+ throw unsupported(venueDef.displayName, "stopSlippage", "market triggers on this venue are venue-native market orders (no slippage bound)", "Omit --slippage-bps, or use --limit-price");
1264
+ }
1265
+ }
1266
+ const req = {
1267
+ symbol: sym, side: s, type: isLimit ? "stopLimit" : "stopMarket", size,
1268
+ price: isLimit ? opts.limitPrice : null,
1269
+ triggerPrice: stopPrice,
1270
+ triggerSource: opts.triggerType !== undefined ? opts.triggerType : null,
1271
+ tif: isLimit ? validateLegacyTif(venueDef, venueCaps, DEFAULT_TIF) : null,
1272
+ reduceOnly: opts.reduceOnly !== false,
1273
+ clientOrderId: v2ClientOrderId(venueDef, venueCaps, undefined),
1274
+ // Market triggers carry the resolved bound (used by protection-priced venues; native triggers ignore it).
1275
+ slippage: isLimit ? null : resolveSlippage(venueDef.id, slippageFlag),
1276
+ tpsl: null,
1277
+ };
1278
+ validateOrderRequest(venueDef, venueCaps, req);
1279
+ // --no-reduce-only can open / flip: quarantine + risk limits at the price it would execute at
1280
+ // (limit price, else the trigger), before the dry-run return. Reduce-only stops are exempt.
1281
+ if (!req.reduceOnly) {
1282
+ await openingOrderGate(adapter, { symbol: sym, size: Number(size), price: parseFloat(opts.limitPrice ?? stopPrice), force: opts.force });
1283
+ }
1284
+ if (await orderDryRunGuard("stop_order", adapter, sym, { exchange: adapter.name, symbol: sym, side: s, size, price: stopPrice, reduceOnly: req.reduceOnly }))
481
1285
  return;
482
1286
  let result;
483
1287
  try {
484
- result = await adapter.stopOrder(symbol.toUpperCase(), s, size, stopPrice, { limitPrice: opts.limitPrice, reduceOnly: opts.reduceOnly });
485
- logExecution({ type: "stop_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, size, price: stopPrice, status: "success", dryRun: false });
1288
+ result = await placeV2(v2Of(adapter), venueCaps, req);
1289
+ logExecution({ type: "stop_order", exchange: adapter.name, symbol: sym, side: s, size, price: stopPrice, status: "success", dryRun: false, meta: { reduceOnly: req.reduceOnly } });
486
1290
  }
487
1291
  catch (err) {
488
- logExecution({ type: "stop_order", exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, size, price: stopPrice, status: "failed", dryRun: false, error: extractErrorMessage(err) });
1292
+ logExecution({ type: "stop_order", exchange: adapter.name, symbol: sym, side: s, size, price: stopPrice, status: isOutcomeUnknown(err) ? "unverified" : "failed", dryRun: false, error: extractErrorMessage(err), meta: { reduceOnly: req.reduceOnly } });
489
1293
  throw err;
490
1294
  }
491
1295
  if (isJson())
492
- return printJson(jsonOk(result));
493
- console.log(chalk.green(`\n Stop order placed on ${adapter.name}.\n`));
494
- });
495
- // === TP/SL — Pacifica + Hyperliquid ===
496
- trade
497
- .command("tpsl <symbol> <side>")
498
- .description("Set take-profit / stop-loss on a position (side: the side to CLOSE — buy to close short, sell to close long)")
499
- .option("--tp <price>", "Take profit trigger price")
500
- .option("--tp-limit <price>", "Take profit limit price")
501
- .option("--sl <price>", "Stop loss trigger price")
502
- .option("--size <size>", "Size (HL only, omit for full position)")
503
- .action(async (symbol, side, opts) => {
504
- const s = side.toLowerCase();
505
- if (s !== "buy" && s !== "sell")
506
- errorAndExit("Side must be buy or sell");
507
- if (!opts.tp && !opts.sl)
508
- errorAndExit("Must specify --tp and/or --sl");
1296
+ return printJson(jsonOk(orderResultJson(result, { reduceOnly: req.reduceOnly })));
1297
+ printOrder(`Stop ${s.toUpperCase()} ${size} ${sym} @ trigger $${stopPrice}${isLimit ? ` (limit $${opts.limitPrice})` : ""}${req.reduceOnly ? " (reduce-only)" : " (may open / flip)"} on ${adapter.name}`, result);
1298
+ });
1299
+ // === TP/SL on an open position (every venue with TP/SL) ===
1300
+ supportsDryRun(trade.command("tpsl <symbol> <positionSide>"))
1301
+ .description("Set take-profit / stop-loss on an OPEN position. positionSide = the position's side: long | short (BEHAVIOR CHANGE: buy/sell no longer accepted). Checked against the live position; legs are reduce-only and sized to the live position (or --size)")
1302
+ .option("--tp <price>", "Take-profit trigger price (long: above mark; short: below)")
1303
+ .option("--tp-limit <price>", "Take-profit limit price once triggered (default: market trigger)")
1304
+ .option("--sl <price>", "Stop-loss trigger price (long: below mark; short: above)")
1305
+ .option("--size <size>", "Protect only this much of the position (≤ position size; pacifica: whole position only)")
1306
+ .option("--slippage-bps <n>", "Bound for protection-priced market triggers (lighter, arcus) in bps (default: settings)")
1307
+ .action(async (symbol, positionSide, opts) => {
1308
+ const ps = positionSide.toLowerCase();
1309
+ if (ps !== "long" && ps !== "short") {
1310
+ throw new PerpError("INVALID_PARAMS", `positionSide must be long or short (got "${positionSide}")`, {
1311
+ remediation: "Pass the side of the position to protect: long or short (buy/sell were ambiguous and are no longer accepted)",
1312
+ });
1313
+ }
1314
+ const prices = { tp: positivePriceOpt(opts.tp, "--tp"), sl: positivePriceOpt(opts.sl, "--sl") };
1315
+ if (prices.tp === null && prices.sl === null)
1316
+ throw new PerpError("INVALID_PARAMS", "Must specify --tp and/or --sl");
1317
+ const tpLimit = positivePriceOpt(opts.tpLimit, "--tp-limit");
1318
+ if (tpLimit !== null && prices.tp === null)
1319
+ throw new PerpError("INVALID_PARAMS", "--tp-limit needs --tp");
1320
+ const sym = symbol.toUpperCase();
509
1321
  const adapter = await getAdapter();
510
- if (dryRunGuard("tpsl", { exchange: adapter.name, symbol: symbol.toUpperCase(), side: s, tp: opts.tp ?? "none", sl: opts.sl ?? "none" }))
511
- return;
512
- if (hasPacificaSdk(adapter)) {
513
- // TP/SL side is opposite of position: LONG position → "ask" to close
514
- const params = {
515
- symbol: symbol.toUpperCase(),
516
- side: s === "buy" ? "ask" : "bid",
517
- };
518
- if (opts.tp)
519
- params.take_profit = { stop_price: opts.tp, limit_price: opts.tpLimit };
520
- if (opts.sl)
521
- params.stop_loss = { stop_price: opts.sl };
522
- const sdk = adapter.sdk;
523
- const result = await sdk.setTPSL(params, adapter.publicKey, adapter.signer);
524
- if (isJson())
525
- return printJson(jsonOk(result));
526
- console.log(chalk.green(`\n TP/SL set for ${symbol.toUpperCase()} on Pacifica.\n`));
1322
+ const { def, caps } = tradingVenueOf(adapter);
1323
+ if (caps.tpsl === null) {
1324
+ throw unsupported(def.displayName, "tpsl", "the venue has no TP/SL orders", `Use stop orders: perp trade stop -e ${def.id} ...`);
527
1325
  }
528
- else if (isTriggerOrderCapable(adapter)) {
529
- // HL uses trigger orders for TP/SL
530
- const results = [];
531
- const posSize = opts.size || "0"; // 0 = full position via positionTpsl grouping
532
- if (opts.tp) {
533
- results.push(await adapter.triggerOrder(symbol.toUpperCase(), s === "buy" ? "sell" : "buy", // Close opposite side
534
- posSize, opts.tp, "tp", { isMarket: !opts.tpLimit, reduceOnly: true, grouping: "positionTpsl" }));
535
- }
536
- if (opts.sl) {
537
- results.push(await adapter.triggerOrder(symbol.toUpperCase(), s === "buy" ? "sell" : "buy", posSize, opts.sl, "sl", { isMarket: true, reduceOnly: true, grouping: "positionTpsl" }));
538
- }
539
- if (isJson())
540
- return printJson(jsonOk(results));
541
- console.log(chalk.green(`\n TP/SL set for ${symbol.toUpperCase()} on Hyperliquid.\n`));
1326
+ if (opts.size !== undefined && caps.tpsl === "positionLevel") {
1327
+ throw unsupported(def.displayName, "tpslSize", "TP/SL is position-level and always covers the whole position", "Omit --size");
1328
+ }
1329
+ refuseGatedReduceOnly(adapter);
1330
+ refuseGatedStops(adapter);
1331
+ const slippage = resolveSlippage(def.id, parseSlippageBpsFlag(opts.slippageBps));
1332
+ // Validate against the live position (side, size, mark).
1333
+ invalidateCache("acct");
1334
+ const pos = (await adapter.getPositions()).find(p => symbolMatch(p.symbol, sym));
1335
+ if (pos === undefined) {
1336
+ throw new PerpError("INVALID_PARAMS", `No open ${sym} position on ${def.displayName} to protect`, { exchange: def.id, remediation: `Check: perp account positions -e ${def.id}` });
1337
+ }
1338
+ if (pos.side !== ps) {
1339
+ throw new PerpError("INVALID_PARAMS", `The ${sym} position on ${def.displayName} is ${pos.side}, not ${ps}`, { exchange: def.id, remediation: `Run: perp trade tpsl ${sym} ${pos.side} ... -e ${def.id}` });
1340
+ }
1341
+ const posSize = pos.size.replace(/^-/, "");
1342
+ if (!(Number(posSize) > 0))
1343
+ throw new PerpError("EXCHANGE_ERROR", `${def.displayName} reports ${sym} position size "${pos.size}"`, { exchange: def.id });
1344
+ let size = posSize;
1345
+ if (opts.size !== undefined) {
1346
+ if (!(Number(opts.size) > 0))
1347
+ throw new PerpError("INVALID_PARAMS", `--size must be positive (got "${opts.size}")`);
1348
+ if (Number(opts.size) > Number(posSize))
1349
+ throw new PerpError("INVALID_PARAMS", `--size ${opts.size} exceeds the ${sym} position size ${posSize}`);
1350
+ size = opts.size;
1351
+ }
1352
+ const mark = Number(pos.markPrice) > 0 ? Number(pos.markPrice) : await markPriceOf(adapter, sym);
1353
+ checkTpSlDirection(ps, mark, "mark price", prices);
1354
+ const closeSide = ps === "long" ? "sell" : "buy";
1355
+ if (await orderDryRunGuard("tpsl", adapter, sym, { exchange: adapter.name, symbol: sym, positionSide: ps, size, tp: prices.tp ?? "none", sl: prices.sl ?? "none" }))
1356
+ return;
1357
+ const legs = [];
1358
+ const hint = `perp account orders -e ${def.id}`;
1359
+ const failAfter = (failed, e) => legs.length === 0 ? e : new PerpError("ORDER_PARTIAL", `TP/SL only partially set on ${sym}: ${legs.map(legLabel).join(", ")} placed, then ${failed} failed — ${extractErrorMessage(e)}`, {
1360
+ exchange: def.id,
1361
+ legsPlaced: legs.map(l => ({ kind: l.kind, trigger: l.trigger, orderId: l.result !== null ? l.result.orderId : null })),
1362
+ failedLeg: failed,
1363
+ remediation: `The position is protected on one side only. Inspect with: ${hint}, then place the missing leg or cancel the placed one.`,
1364
+ });
1365
+ if (caps.tpsl === "positionLevel") {
1366
+ const pac = positionTpSlAdapter(adapter);
1367
+ await refuseExistingPositionTpSl(pac, def, sym);
1368
+ const raw = await pac.setPositionTpSl(sym, ps, { tp: prices.tp, tpLimit, sl: prices.sl });
1369
+ legs.push({ kind: "tpsl", trigger: `tp ${prices.tp ?? "-"} / sl ${prices.sl ?? "-"}`, result: null, venueRaw: raw });
542
1370
  }
543
1371
  else {
544
- // Fallback: use stopOrder for TP/SL (Lighter and others)
545
- const closeSide = s === "buy" ? "sell" : "buy"; // Close opposite side
546
- const results = [];
547
- if (opts.tp) {
548
- results.push(await adapter.stopOrder(symbol.toUpperCase(), closeSide, opts.size || "0", opts.tp, { limitPrice: opts.tpLimit, reduceOnly: true }));
549
- }
550
- if (opts.sl) {
551
- results.push(await adapter.stopOrder(symbol.toUpperCase(), closeSide, opts.size || "0", opts.sl, { reduceOnly: true }));
1372
+ for (const [kind, trigger] of [["tp", prices.tp], ["sl", prices.sl]]) {
1373
+ if (trigger === null)
1374
+ continue;
1375
+ const limitPrice = kind === "tp" ? tpLimit : null;
1376
+ try {
1377
+ let r;
1378
+ if (caps.tpsl === "separateOrders") {
1379
+ r = await placeTpSlLeg(adapter, network(), { symbol: sym, closeSide, size, trigger, kind, limitPrice, slippage });
1380
+ }
1381
+ else {
1382
+ // attached (ondo) / batchGroup (arcus): a reduce-only position stop / TPSL leg (type chosen by the venue adapter).
1383
+ const req = {
1384
+ symbol: sym, side: closeSide, type: limitPrice !== null ? "stopLimit" : "stopMarket", size,
1385
+ price: limitPrice, triggerPrice: trigger, triggerSource: null,
1386
+ tif: limitPrice !== null ? validateLegacyTif(def, caps, DEFAULT_TIF) : null,
1387
+ reduceOnly: true, clientOrderId: v2ClientOrderId(def, caps, undefined),
1388
+ slippage: limitPrice !== null ? null : slippage, tpsl: null,
1389
+ };
1390
+ validateOrderRequest(def, caps, req);
1391
+ r = await placeV2(v2Of(adapter), caps, req);
1392
+ }
1393
+ legs.push({ kind, trigger, result: r, venueRaw: r.venueRaw });
1394
+ }
1395
+ catch (e) {
1396
+ throw failAfter(legLabel({ kind, trigger }), e);
1397
+ }
552
1398
  }
553
- if (isJson())
554
- return printJson(jsonOk(results));
555
- console.log(chalk.green(`\n TP/SL set for ${symbol.toUpperCase()} on ${adapter.name}.\n`));
556
1399
  }
1400
+ logExecution({ type: "stop_order", exchange: adapter.name, symbol: sym, side: closeSide, size, price: prices.sl ?? prices.tp ?? "", status: "success", dryRun: false, meta: { action: "tpsl", positionSide: ps, tp: prices.tp, sl: prices.sl } });
1401
+ if (isJson()) {
1402
+ return printJson(jsonOk({
1403
+ symbol: sym, positionSide: ps, size, method: caps.tpsl,
1404
+ legs: legs.map(l => ({ kind: l.kind, trigger: l.trigger, ...(l.result !== null ? orderResultJson(l.result) : { venueRaw: l.venueRaw }) })),
1405
+ }));
1406
+ }
1407
+ console.log(chalk.green(`\n TP/SL set for ${ps} ${size} ${sym} on ${def.displayName} (${caps.tpsl}, reduce-only):`));
1408
+ for (const l of legs) {
1409
+ console.log(chalk.green(` ${legLabel(l)}`));
1410
+ if (l.result !== null)
1411
+ for (const line of formatOrderResult(l.result))
1412
+ console.log(line);
1413
+ }
1414
+ console.log();
557
1415
  });
558
1416
  // === Scaled Take-Profit (분할익절) ===
559
- trade
560
- .command("scale-tp <symbol>")
1417
+ supportsDryRun(trade.command("scale-tp <symbol>"))
561
1418
  .description("Place multiple take-profit limit orders at different price levels (분할익절)")
562
1419
  .requiredOption("--levels <levels>", "[required] Comma-separated price:percent pairs (e.g., 72000:25,75000:25,80000:50)")
563
1420
  .option("--size <size>", "Override total position size (default: uses current position)")
564
1421
  .action(async (symbol, opts) => {
565
1422
  const sym = symbol.toUpperCase();
566
1423
  const adapter = await getAdapter();
1424
+ if (adapter.name === "ondo") {
1425
+ // docs.ondoperps.xyz take-profit-and-stop-loss: position TP/SL "Via Risk Management Module
1426
+ // [POST /v1/perps/stop_order] ... applies to the full position, with a limit of one TP and one
1427
+ // SL per market"; partial legs only "At Order Placement" (attached to each entry order). A
1428
+ // resting reduce-only limit is refused too (reduce_only_invalid_tif) — no multi-level TP on a held position.
1429
+ throw unsupported("Ondo", "scaleTp", "position-level stops allow one TP and one SL per market (docs: take-profit-and-stop-loss), and resting reduce-only limits are refused", `Set one (partial) take-profit: perp trade tpsl ${sym} <long|short> --tp <price> [--size <qty>] -e ondo, or attach --tp to each entry order`);
1430
+ }
567
1431
  // Parse levels: "72000:25,75000:25,80000:50"
568
1432
  const levels = opts.levels.split(",").map(l => {
569
1433
  const [price, pct] = l.trim().split(":");
@@ -577,90 +1441,96 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
577
1441
  errorAndExit(`Percentages must sum to 100%. Got: ${totalPct}%`);
578
1442
  }
579
1443
  // Get current position to determine size and side
580
- let totalSize;
1444
+ let total;
581
1445
  let closeSide;
582
- if (opts.size) {
583
- totalSize = parseFloat(opts.size);
584
- // Need to know position side — fetch it
585
- const positions = await adapter.getPositions();
586
- const pos = positions.find(p => p.symbol.toUpperCase() === sym);
587
- closeSide = pos?.side === "short" ? "buy" : "sell";
1446
+ // The position decides the close side (and, without --size, the total). symbolMatch: venues
1447
+ // report "ETH-PERP" (Hyperliquid) or "xyz:NVDA" (HIP-3) for the user's "ETH" / "NVDA".
1448
+ const pos = (await adapter.getPositions()).find(p => symbolMatch(p.symbol, sym));
1449
+ if (pos === undefined) {
1450
+ // Never a guessed side: a take-profit on the wrong side would not reduce the position.
1451
+ throw new PerpError("INVALID_PARAMS", `No open ${sym} position on ${adapter.name} — scale-tp places reduce-only take-profits on an open position`, {
1452
+ exchange: adapter.name, remediation: `Check: perp account positions -e ${adapter.name}`,
1453
+ });
588
1454
  }
589
- else {
590
- const positions = await adapter.getPositions();
591
- const pos = positions.find(p => p.symbol.toUpperCase() === sym);
592
- if (!pos)
593
- errorAndExit(`No open position for ${sym}. Use --size to specify manually.`);
594
- totalSize = parseFloat(pos.size);
595
- closeSide = pos.side === "long" ? "sell" : "buy";
1455
+ const held = pos.size.replace(/^-/, "");
1456
+ // Reduce-only take-profits can never add up to more than the position.
1457
+ if (opts.size !== undefined && Number(opts.size) > Number(held)) {
1458
+ throw new PerpError("SIZE_TOO_LARGE", `scale-tp --size ${opts.size} exceeds the open ${sym} ${pos.side} position (${held}) on ${adapter.name} — nothing was sent`, {
1459
+ exchange: adapter.name, position: held, positionSide: pos.side,
1460
+ remediation: `Use --size of at most ${held}, or omit --size to scale the whole position`,
1461
+ });
596
1462
  }
1463
+ total = opts.size ? opts.size : held;
1464
+ closeSide = pos.side === "long" ? "sell" : "buy";
1465
+ // Level sizes on the venue lot (remainder to the last level), validated before anything is sent.
1466
+ const sizes = scaleLevelSizes(total, levels.map(l => l.pct), await lotStepOf(adapter, sym));
1467
+ const plan = levels.map((l, i) => ({ price: l.price, size: sizes[i], pct: l.pct }));
1468
+ const reqs = await scaleRequests(adapter, sym, closeSide, true, plan);
597
1469
  if (dryRunGuard("scale_tp", {
598
1470
  exchange: adapter.name, symbol: sym, side: closeSide,
599
- totalSize: totalSize.toString(),
600
- levels: levels.map(l => `${l.price}@${l.pct}%`).join(", "),
1471
+ totalSize: total,
1472
+ levels: plan.map(l => `${l.price}@${l.pct}% (${l.size})`).join(", "),
601
1473
  }))
602
1474
  return;
603
1475
  // Place reduce-only limit orders at each level
604
- const results = [];
605
- for (const level of levels) {
606
- const levelSize = (totalSize * level.pct / 100).toString();
607
- try {
608
- const result = await adapter.limitOrder(sym, closeSide, level.price, levelSize, { reduceOnly: true });
609
- results.push({ price: level.price, size: levelSize, pct: level.pct, result });
610
- logExecution({
611
- type: "limit_order", exchange: adapter.name, symbol: sym,
612
- side: closeSide, size: levelSize, price: level.price,
613
- status: "success", dryRun: false,
614
- meta: { action: "scale-tp", pct: level.pct, reduceOnly: true },
615
- });
616
- }
617
- catch (err) {
618
- const msg = extractErrorMessage(err);
619
- logExecution({
620
- type: "limit_order", exchange: adapter.name, symbol: sym,
621
- side: closeSide, size: levelSize, price: level.price,
622
- status: "failed", dryRun: false, error: msg,
623
- meta: { action: "scale-tp", pct: level.pct },
624
- });
625
- if (isJson()) {
626
- results.push({ price: level.price, size: levelSize, pct: level.pct, result: { error: msg } });
627
- }
628
- else {
629
- console.log(chalk.red(` Failed: ${level.price} x ${levelSize} — ${msg}`));
630
- }
631
- }
1476
+ const results = await placeScaleLevels(adapter, "scale-tp", sym, closeSide, plan, reqs);
1477
+ const incomplete = scaleIncompleteError(adapter.name, `scale-tp ${sym}`, results);
1478
+ if (incomplete !== null) {
1479
+ if (!isJson())
1480
+ printScaleLevels(`Scaled TP for ${sym} on ${adapter.name} is INCOMPLETE:`, sym, results);
1481
+ throw incomplete;
632
1482
  }
633
1483
  if (isJson())
634
- return printJson(jsonOk({ symbol: sym, side: closeSide, totalSize, levels: results }));
635
- console.log(chalk.green(`\n Scaled TP set for ${sym} on ${adapter.name}:\n`));
636
- for (const r of results) {
637
- const status = r.result?.error ? chalk.red("FAILED") : chalk.green("OK");
638
- console.log(` ${status} $${r.price} — ${r.pct}% (${r.size} ${sym})`);
639
- }
640
- console.log();
1484
+ return printJson(jsonOk({ symbol: sym, side: closeSide, totalSize: Number(total), levels: results }));
1485
+ printScaleLevels(`Scaled TP set for ${sym} on ${adapter.name}:`, sym, results);
641
1486
  });
642
1487
  // === Pacifica-only commands ===
643
- trade
644
- .command("edit <symbol> <orderId> <price> <size>")
645
- .description("Edit an existing order")
646
- .action(async (symbol, orderId, price, size) => {
1488
+ supportsDryRun(trade.command("edit <symbol> <orderId> <price> <size>"))
1489
+ .description("Edit an existing order: new price and new open (unfilled) size; the order keeps its TIF / post-only (pacifica: re-created as ALO) — orderId, or c:<clientOrderId> on ondo/arcus/qfex (qfex: plain LIMIT / ALO only; the venue assigns a new order id, the client id is kept)")
1490
+ .option("--tif <tif>", "Time-in-force of the replacement (cancel+replace venues only, e.g. ondo; ALO keeps post-only)")
1491
+ .option("--force-tif", "Allow --tif to flip the order's post-only flag (ondo / pacifica); otherwise such a conflict is refused")
1492
+ .action(async (symbol, orderId, price, size, opts) => {
647
1493
  const adapter = await getAdapter();
1494
+ prevalidateAmend(adapter);
1495
+ const { def: venueDef, caps: venueCaps } = tradingVenueOf(adapter);
1496
+ const amendTif = opts.tif !== undefined ? validateLegacyTif(venueDef, venueCaps, opts.tif) : null;
1497
+ if (opts.forceTif === true && amendTif === null)
1498
+ throw new PerpError("INVALID_PARAMS", "--force-tif needs --tif");
648
1499
  if (dryRunGuard("edit_order", { exchange: adapter.name, symbol: symbol.toUpperCase(), orderId, price, size }))
649
1500
  return;
650
- const result = await adapter.editOrder(symbol.toUpperCase(), orderId, price, size);
1501
+ const result = await v2Of(adapter).amendOrder({ symbol: symbol.toUpperCase(), ref: parseOrderRef(orderId), price, size, tif: amendTif, forceTif: opts.forceTif === true });
651
1502
  if (isJson())
652
- return printJson(jsonOk(result));
653
- console.log(chalk.green(`\n Order ${orderId} updated to $${price} x ${size} on ${adapter.name}.\n`));
1503
+ return printJson(jsonOk(orderResultJson(result)));
1504
+ printOrder(`Edit of ${orderId} → $${price} x ${size} on ${adapter.name}`, result);
654
1505
  });
655
1506
  trade
656
1507
  .command("cancel-stop <symbol> <stopOrderId>")
657
- .description("Cancel a stop order (Pacifica)")
1508
+ .description("Cancel a stop / TP / SL order (pacifica; qfex: stop order id or c:<clientOrderId>; other venues: perp trade cancel)")
658
1509
  .action(async (symbol, stopOrderId) => {
659
1510
  const adapter = await getAdapter();
660
- if (!hasPacificaSdk(adapter))
661
- throw new Error("This command requires --exchange pacifica");
662
- const sdk = adapter.sdk;
663
- const result = await sdk.cancelStopOrder({ symbol: symbol.toUpperCase(), order_id: Number(stopOrderId) }, adapter.publicKey, adapter.signer);
1511
+ if (adapter.name === "qfex" && isTradingV2(adapter)) {
1512
+ // QFEX: cancelByRef reads the row first and routes a TAKE_PROFIT / STOP_LOSS row to cancel_stop_order.
1513
+ const sym = symbol.toUpperCase();
1514
+ if (dryRunGuard("cancel_stop", { exchange: adapter.name, symbol: sym, stopOrderId }))
1515
+ return;
1516
+ const r = await adapter.cancelByRef(sym, parseOrderRef(stopOrderId));
1517
+ const incomplete = cancelIncompleteError(r, sym);
1518
+ if (incomplete !== null) {
1519
+ if (!isJson())
1520
+ printCancel(r, sym);
1521
+ throw incomplete;
1522
+ }
1523
+ if (isJson())
1524
+ return printJson(jsonOk({ ...r, legacy: r.venueRaw }));
1525
+ printCancel(r, sym);
1526
+ return;
1527
+ }
1528
+ const pac = adapter;
1529
+ if (typeof pac.cancelStopOrder !== "function") {
1530
+ const { def } = tradingVenueOf(adapter);
1531
+ throw unsupported(def.displayName, "cancelStop", "stop orders are cancelled like any other order on this venue", `Use: perp trade cancel ${symbol.toUpperCase()} <orderId> -e ${def.id}`);
1532
+ }
1533
+ const result = await pac.cancelStopOrder(symbol, stopOrderId);
664
1534
  if (isJson())
665
1535
  return printJson(jsonOk(result));
666
1536
  console.log(chalk.green(`\n Stop order ${stopOrderId} cancelled.\n`));
@@ -669,11 +1539,17 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
669
1539
  .command("cancel-twap <symbol> <twapOrderId>")
670
1540
  .description("Cancel a TWAP order")
671
1541
  .action(async (symbol, twapOrderId) => {
1542
+ if (!/^\d+$/.test(twapOrderId)) {
1543
+ throw new PerpError("INVALID_PARAMS", `TWAP order id must be a whole number (got "${twapOrderId}")`, {
1544
+ remediation: "Pass the numeric TWAP id the venue reported when the TWAP was placed",
1545
+ });
1546
+ }
672
1547
  const adapter = await getAdapter();
673
1548
  let result;
674
1549
  if (hasPacificaSdk(adapter)) {
1550
+ requirePacificaTwapSigner(adapter, "TWAP cancel");
675
1551
  const sdk = adapter.sdk;
676
- result = await sdk.cancelTWAP({ symbol: symbol.toUpperCase(), twap_order_id: Number(twapOrderId) }, adapter.publicKey, adapter.signer);
1552
+ result = await sdk.cancelTWAP({ symbol: symbol.toUpperCase(), order_id: Number(twapOrderId) }, adapter.publicKey, adapter.signer);
677
1553
  }
678
1554
  else if (isTwapCapable(adapter)) {
679
1555
  result = await adapter.twapCancel(symbol.toUpperCase(), Number(twapOrderId));
@@ -686,21 +1562,26 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
686
1562
  console.log(chalk.green(`\n TWAP order ${twapOrderId} cancelled on ${adapter.name}.\n`));
687
1563
  });
688
1564
  // === Hyperliquid-only commands ===
689
- trade
690
- .command("leverage <symbol> <leverage>")
1565
+ supportsDryRun(trade.command("leverage <symbol> <leverage>"))
691
1566
  .description("Set leverage for a symbol")
692
1567
  .option("--isolated", "Use isolated margin mode (default: cross)")
693
1568
  .action(async (symbol, leverage, opts) => {
1569
+ if (!/^[1-9]\d*$/.test(leverage.trim())) {
1570
+ throw new PerpError("INVALID_PARAMS", `leverage must be a positive whole number (got "${leverage}")`, {
1571
+ remediation: "Pass an integer leverage, e.g. perp trade leverage BTC 5",
1572
+ });
1573
+ }
1574
+ const lev = Number(leverage.trim());
694
1575
  const adapter = await getAdapter();
695
1576
  const mode = opts.isolated ? "isolated" : "cross";
696
- if (dryRunGuard("set_leverage", { exchange: adapter.name, symbol: symbol.toUpperCase(), leverage, mode }))
1577
+ if (dryRunGuard("set_leverage", { exchange: adapter.name, symbol: symbol.toUpperCase(), leverage: lev, mode }))
697
1578
  return;
698
1579
  try {
699
- const result = await adapter.setLeverage(symbol.toUpperCase(), parseInt(leverage), mode);
1580
+ const result = await adapter.setLeverage(symbol.toUpperCase(), lev, mode);
700
1581
  logExecution({
701
1582
  type: "rebalance", exchange: adapter.name, symbol: symbol.toUpperCase(), side: mode,
702
1583
  size: leverage, status: "success", dryRun: false,
703
- meta: { action: "set_leverage", leverage: parseInt(leverage), mode },
1584
+ meta: { action: "set_leverage", leverage: lev, mode },
704
1585
  });
705
1586
  if (isJson())
706
1587
  return printJson(jsonOk(result));
@@ -711,22 +1592,112 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
711
1592
  type: "rebalance", exchange: adapter.name, symbol: symbol.toUpperCase(), side: mode,
712
1593
  size: leverage, status: "failed", dryRun: false,
713
1594
  error: extractErrorMessage(err),
714
- meta: { action: "set_leverage", leverage: parseInt(leverage), mode },
1595
+ meta: { action: "set_leverage", leverage: lev, mode },
715
1596
  });
716
1597
  throw err;
717
1598
  }
718
1599
  });
719
- // grid/dca moved to 'perp strategy grid' / 'perp strategy dca'
720
- // ── Position Management Shortcuts ──
721
- trade
722
- .command("close-all")
723
- .description("Close all open positions (market orders on opposite side)")
1600
+ /**
1601
+ * One verified reduce-only close (design §12.3, §13.10 #4): closePerpVerified (placePerpVerified
1602
+ * + one residual market close), so the reported `closed` is a measured / venue-final fill, never
1603
+ * the requested size. Logged "success" only when fully verified. A residual or an unknown outcome
1604
+ * quarantines the symbol (CLI path "trade-close"); a definite rejection propagates unchanged.
1605
+ */
1606
+ async function closeVerified(adapter, symbol, originalSide, size, opts) {
1607
+ const side = originalSide === "long" ? "sell" : "buy";
1608
+ const dec = sizeDecimalsOf(size);
1609
+ let smart = null;
1610
+ const place = opts.smart
1611
+ ? async () => {
1612
+ const sr = await smartOrder(adapter, symbol, side, size, { reduceOnly: true, slippage: opts.slippage });
1613
+ smart = { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice };
1614
+ return sr.result;
1615
+ }
1616
+ : undefined;
1617
+ const log = (m) => { if (!isJson())
1618
+ console.log(chalk.red(m)); };
1619
+ const meta = { action: opts.action, originalSide, smart: opts.smart };
1620
+ let c;
1621
+ try {
1622
+ c = await closePerpVerified(adapter, adapter.name, symbol, side, size, { decimals: dec, slippage: opts.slippage, ...(place !== undefined ? { place } : {}) });
1623
+ }
1624
+ catch (e) {
1625
+ logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size, status: isOutcomeUnknown(e) ? "unverified" : "failed", dryRun: false, error: extractErrorMessage(e), meta });
1626
+ if (isOutcomeUnknown(e)) {
1627
+ quarantineSymbol("trade-close", symbol, `${opts.action} ${side} ${size} ${symbol} on ${adapter.name}: outcome unknown (${extractErrorMessage(e)})`, `Check ${symbol} on ${adapter.name} (perp -e ${adapter.name} account positions); fix by hand, then: perp strategy quarantine-clear ${symbol}`, log);
1628
+ }
1629
+ throw e;
1630
+ }
1631
+ const closed = fillSizeString(c.closed, dec);
1632
+ const residual = fillSizeString(c.residual, dec);
1633
+ if (c.residual > 0) {
1634
+ logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size: closed, status: "failed", dryRun: false,
1635
+ error: `residual ${residual} of ${size} still open${c.note !== null ? ` (${c.note})` : ""}`, meta: { ...meta, requested: size, residual } });
1636
+ quarantineSymbol("trade-close", symbol, `${opts.action} ${symbol} on ${adapter.name}: closed ${closed} of ${size}, ${residual} still open${c.note !== null ? ` (${c.note})` : ""}`, `Check ${symbol} on ${adapter.name} (perp -e ${adapter.name} account positions); close the residual by hand, then: perp strategy quarantine-clear ${symbol}`, log);
1637
+ }
1638
+ else {
1639
+ logExecution({ type: "market_order", exchange: adapter.name, symbol, side, size: closed, status: "success", dryRun: false, meta: { ...meta, requested: size } });
1640
+ }
1641
+ return { symbol, side, originalSide, requested: size, closed, residual, note: c.note, smart };
1642
+ }
1643
+ /** Human line for one verified close. */
1644
+ function printCloseOutcome(o) {
1645
+ if (Number(o.residual) > 0) {
1646
+ console.log(chalk.red(` ✗ ${o.symbol}: closed ${o.closed} of ${o.requested} (${o.originalSide}) — ${o.residual} STILL OPEN${o.note !== null ? ` (${o.note})` : ""}; quarantined`));
1647
+ }
1648
+ else {
1649
+ console.log(chalk.green(` ✓ ${o.symbol}: closed ${o.closed} (${o.originalSide}, verified)${o.smart !== null ? ` [${o.smart.method}]` : ""}`));
1650
+ }
1651
+ }
1652
+ /** Residuals or failed closes → ORDER_PARTIAL (non-zero exit) carrying every close outcome and failure. */
1653
+ function residualError(venue, outcomes, failures = []) {
1654
+ const open = outcomes.filter(o => Number(o.residual) > 0);
1655
+ if (open.length === 0 && failures.length === 0)
1656
+ return null;
1657
+ const parts = [
1658
+ ...open.map(o => `${o.symbol} ${o.residual} still open after a verified close`),
1659
+ ...failures.map(f => `${f.symbol} close failed: ${f.error}`),
1660
+ ];
1661
+ return new PerpError("ORDER_PARTIAL", `${venue}: ${parts.join("; ")}`, {
1662
+ exchange: venue, closes: outcomes, failures,
1663
+ remediation: `Check positions (perp -e ${venue} account positions), close the residual by hand, then clear the quarantine: perp strategy quarantine-clear <symbol>`,
1664
+ });
1665
+ }
1666
+ /** Close every position, continuing past a close that throws; returns all outcomes and failures. */
1667
+ async function closeEach(adapter, positions, opts) {
1668
+ const outcomes = [];
1669
+ const failures = [];
1670
+ for (const pos of positions) {
1671
+ const size = pos.size.replace(/^-/, "");
1672
+ if (!isJson())
1673
+ console.log(chalk.gray(` ${pos.side === "long" ? "SELL" : "BUY"} ${size} ${pos.symbol} (closing ${pos.side})...`));
1674
+ try {
1675
+ const o = await closeVerified(adapter, pos.symbol, pos.side, size, opts);
1676
+ outcomes.push(o);
1677
+ if (!isJson())
1678
+ printCloseOutcome(o);
1679
+ }
1680
+ catch (e) {
1681
+ const f = { symbol: pos.symbol, side: pos.side, size, error: extractErrorMessage(e), code: e instanceof PerpError ? e.structured.code : null };
1682
+ failures.push(f);
1683
+ if (!isJson())
1684
+ console.log(chalk.red(` ✗ ${pos.symbol}: close FAILED — ${f.error}`));
1685
+ }
1686
+ }
1687
+ return { outcomes, failures };
1688
+ }
1689
+ supportsDryRun(trade.command("close-all"))
1690
+ .description("Close all open positions (verified reduce-only closes on the opposite side)")
724
1691
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
1692
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
725
1693
  .action(async (opts) => {
726
1694
  await withJsonErrors(isJson(), async () => {
727
1695
  const adapter = await getAdapter();
1696
+ refuseGatedReduceOnly(adapter);
1697
+ const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
728
1698
  if (dryRunGuard("close_all", { exchange: adapter.name, smart: !!opts.smart }))
729
1699
  return;
1700
+ invalidateCache("acct");
730
1701
  const positions = await adapter.getPositions();
731
1702
  if (positions.length === 0) {
732
1703
  if (isJson())
@@ -736,115 +1707,102 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
736
1707
  }
737
1708
  if (!isJson())
738
1709
  console.log(chalk.cyan(`\n Closing ${positions.length} position(s) on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
739
- const results = [];
740
- for (const pos of positions) {
741
- const closeSide = pos.side === "long" ? "sell" : "buy";
742
- if (!isJson())
743
- console.log(chalk.gray(` ${closeSide.toUpperCase()} ${pos.size} ${pos.symbol} (closing ${pos.side})...`));
744
- let result;
745
- if (opts.smart) {
746
- const sr = await smartOrder(adapter, pos.symbol, closeSide, pos.size, { reduceOnly: true });
747
- result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice } };
748
- }
749
- else {
750
- result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
751
- }
752
- results.push(result);
753
- logExecution({
754
- type: "market_order", exchange: adapter.name, symbol: pos.symbol,
755
- side: closeSide, size: pos.size, status: "success", dryRun: false,
756
- meta: { action: "close-all", originalSide: pos.side, smart: !!opts.smart },
757
- });
758
- }
1710
+ const { outcomes, failures } = await closeEach(adapter, positions, { smart: !!opts.smart, slippage, action: "close-all" });
1711
+ const partial = residualError(adapter.name, outcomes, failures);
1712
+ if (partial !== null)
1713
+ throw partial;
1714
+ await orphanedReduceOnly(adapter, null, false);
759
1715
  if (isJson())
760
- return printJson(jsonOk({ closed: results.length, results }));
761
- console.log(chalk.green(`\n Closed ${results.length} position(s) on ${adapter.name}.\n`));
1716
+ return printJson(jsonOk({ closed: outcomes.length, results: outcomes }));
1717
+ console.log(chalk.green(`\n Closed ${outcomes.length} position(s) on ${adapter.name} (verified).\n`));
762
1718
  });
763
1719
  });
764
- trade
765
- .command("close <symbol>")
766
- .description("Close a specific symbol's position")
1720
+ supportsDryRun(trade.command("close <symbol>"))
1721
+ .description("Close a specific symbol's position (verified reduce-only close)")
767
1722
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
1723
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
768
1724
  .action(async (symbol, opts) => {
769
1725
  await withJsonErrors(isJson(), async () => {
770
1726
  const sym = symbol.toUpperCase();
771
1727
  const adapter = await getAdapter();
1728
+ refuseGatedReduceOnly(adapter);
1729
+ const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
1730
+ invalidateCache("acct");
772
1731
  const positions = await adapter.getPositions();
773
1732
  const pos = positions.find(p => symbolMatch(p.symbol, sym));
774
1733
  if (!pos) {
775
- if (isJson())
776
- return printJson(jsonOk({ closed: false, reason: "no_position" }));
777
- errorAndExit(`No open position for ${sym}`);
1734
+ throw new PerpError("POSITION_NOT_FOUND", `No open position for ${sym} on ${adapter.name}; nothing was closed`, {
1735
+ exchange: adapter.name, remediation: `Check positions: perp account positions -e ${adapter.name}`,
1736
+ });
778
1737
  }
1738
+ const size = pos.size.replace(/^-/, "");
779
1739
  const closeSide = pos.side === "long" ? "sell" : "buy";
780
- if (dryRunGuard("close", { exchange: adapter.name, symbol: sym, side: closeSide, size: pos.size, originalSide: pos.side, smart: !!opts.smart }))
1740
+ if (await orderDryRunGuard("close", adapter, sym, { exchange: adapter.name, symbol: sym, side: closeSide, size, originalSide: pos.side, smart: !!opts.smart }))
781
1741
  return;
782
1742
  if (!isJson())
783
- console.log(chalk.cyan(`\n Closing ${pos.side} ${pos.size} ${sym} on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
784
- let result;
785
- if (opts.smart) {
786
- const sr = await smartOrder(adapter, sym, closeSide, pos.size, { reduceOnly: true });
787
- result = { ...sr.result, smartOrder: { method: sr.method, price: sr.price, bestBookPrice: sr.bestBookPrice } };
788
- }
789
- else {
790
- result = await adapter.marketOrder(sym, closeSide, pos.size);
791
- }
792
- logExecution({
793
- type: "market_order", exchange: adapter.name, symbol: sym,
794
- side: closeSide, size: pos.size, status: "success", dryRun: false,
795
- meta: { action: "close", originalSide: pos.side, smart: !!opts.smart },
796
- });
1743
+ console.log(chalk.cyan(`\n Closing ${pos.side} ${size} ${sym} on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
1744
+ const o = await closeVerified(adapter, pos.symbol, pos.side, size, { smart: !!opts.smart, slippage, action: "close" });
1745
+ const partial = residualError(adapter.name, [o]);
1746
+ if (!isJson())
1747
+ printCloseOutcome(o);
1748
+ if (partial !== null)
1749
+ throw partial;
1750
+ await orphanedReduceOnly(adapter, sym, false);
797
1751
  if (isJson())
798
- return printJson(jsonOk(result));
799
- console.log(chalk.green(`\n Closed ${pos.side} ${pos.size} ${sym} on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
1752
+ return printJson(jsonOk({ ...o, verified: true }));
1753
+ console.log();
800
1754
  });
801
1755
  });
802
- trade
803
- .command("flatten")
804
- .description("Cancel all orders AND close all positions (full cleanup)")
1756
+ supportsDryRun(trade.command("flatten"))
1757
+ .description("Cancel all orders AND close all positions (full cleanup, verified)")
805
1758
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
1759
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
806
1760
  .action(async (opts) => {
807
1761
  await withJsonErrors(isJson(), async () => {
808
1762
  const adapter = await getAdapter();
1763
+ refuseGatedReduceOnly(adapter);
1764
+ const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
809
1765
  if (dryRunGuard("flatten", { exchange: adapter.name, smart: !!opts.smart }))
810
1766
  return;
811
1767
  if (!isJson())
812
1768
  console.log(chalk.cyan(`\n Flattening account on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
813
- // Step 1: Cancel all orders
1769
+ // Step 1: Cancel all orders (per-order proof)
814
1770
  if (!isJson())
815
1771
  console.log(chalk.gray(" Cancelling all open orders..."));
816
- const cancelResult = await adapter.cancelAllOrders();
817
- // Step 2: Close all positions
1772
+ const cancelResult = await v2Of(adapter).cancelAllV2(null);
1773
+ if (!isJson())
1774
+ printCancel(cancelResult, "all");
1775
+ // Step 2: Close all positions (verified)
1776
+ invalidateCache("acct");
818
1777
  const positions = await adapter.getPositions();
819
- const closeResults = [];
820
- for (const pos of positions) {
821
- const closeSide = pos.side === "long" ? "sell" : "buy";
822
- if (!isJson())
823
- console.log(chalk.gray(` ${closeSide.toUpperCase()} ${pos.size} ${pos.symbol} (closing ${pos.side})...`));
824
- let result;
825
- if (opts.smart) {
826
- const sr = await smartOrder(adapter, pos.symbol, closeSide, pos.size, { reduceOnly: true });
827
- result = sr.result;
828
- }
829
- else {
830
- result = await adapter.marketOrder(pos.symbol, closeSide, pos.size);
831
- }
832
- closeResults.push(result);
833
- logExecution({
834
- type: "market_order", exchange: adapter.name, symbol: pos.symbol,
835
- side: closeSide, size: pos.size, status: "success", dryRun: false,
836
- meta: { action: "flatten", originalSide: pos.side, smart: !!opts.smart },
1778
+ const { outcomes, failures } = await closeEach(adapter, positions, { smart: !!opts.smart, slippage, action: "flatten" });
1779
+ // QFEX: reduce-only / stop orders still listed on a now-flat symbol (placed meanwhile, or not
1780
+ // covered by cancel-all) are cancelled one by one — the venue leaves them open (Q2).
1781
+ const orphanCancels = await orphanedReduceOnly(adapter, null, true);
1782
+ if (orphanCancels !== null) {
1783
+ cancelResult.cancelled.push(...orphanCancels.cancelled);
1784
+ cancelResult.failed.push(...orphanCancels.failed);
1785
+ (cancelResult.pending ??= []).push(...(orphanCancels.pending ?? []));
1786
+ }
1787
+ const partial = residualError(adapter.name, outcomes, failures);
1788
+ const cancelIncomplete = cancelIncompleteError(cancelResult, "all");
1789
+ if (partial !== null || cancelIncomplete !== null) {
1790
+ // Not fully flat: every cancel and close outcome so far rides on one ORDER_PARTIAL (non-zero exit).
1791
+ const messages = [cancelIncomplete?.message, partial?.message].filter((m) => typeof m === "string");
1792
+ throw new PerpError("ORDER_PARTIAL", `${adapter.name} flatten incomplete: ${messages.join(" | ")}`, {
1793
+ exchange: adapter.name, ordersCancelled: cancelResult, closes: outcomes, failures,
1794
+ remediation: `Check open orders and positions: perp -e ${adapter.name} account orders / account positions; clear a quarantine with perp strategy quarantine-clear <symbol>`,
837
1795
  });
838
1796
  }
839
1797
  if (isJson())
840
- return printJson(jsonOk({ ordersCancelled: cancelResult, positionsClosed: closeResults.length, closeResults }));
841
- console.log(chalk.green(`\n Flattened: cancelled orders + closed ${closeResults.length} position(s) on ${adapter.name}.\n`));
1798
+ return printJson(jsonOk({ ordersCancelled: cancelResult, positionsClosed: outcomes.length, closeResults: outcomes }));
1799
+ console.log(chalk.green(`\n Flattened: ${cancelResult.cancelled.length} order(s) cancelled + ${outcomes.length} position(s) closed (verified) on ${adapter.name}.\n`));
842
1800
  });
843
1801
  });
844
- trade
845
- .command("reduce <symbol> <percent>")
1802
+ supportsDryRun(trade.command("reduce <symbol> <percent>"))
846
1803
  .description("Reduce a position by a percentage (e.g., perp trade reduce BTC 50)")
847
1804
  .option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick")
1805
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
848
1806
  .action(async (symbol, percent, opts) => {
849
1807
  await withJsonErrors(isJson(), async () => {
850
1808
  const sym = symbol.toUpperCase();
@@ -852,45 +1810,87 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
852
1810
  if (isNaN(pct) || pct <= 0 || pct > 100)
853
1811
  errorAndExit("Percent must be between 0 and 100");
854
1812
  const adapter = await getAdapter();
1813
+ refuseGatedReduceOnly(adapter);
1814
+ const slippage = resolveAdapterSlippage(adapter, parseSlippageBpsFlag(opts.slippageBps));
1815
+ invalidateCache("acct");
855
1816
  const positions = await adapter.getPositions();
856
1817
  const pos = positions.find(p => symbolMatch(p.symbol, sym));
857
1818
  if (!pos) {
858
- if (isJson())
859
- return printJson(jsonOk({ reduced: false, reason: "no_position" }));
860
- errorAndExit(`No open position for ${sym}`);
1819
+ throw new PerpError("POSITION_NOT_FOUND", `No open position for ${sym} on ${adapter.name}; nothing was reduced`, {
1820
+ exchange: adapter.name, remediation: `Check positions: perp account positions -e ${adapter.name}`,
1821
+ });
1822
+ }
1823
+ // Reduce-only close size: position size × percent floored to the market's LOT step from the
1824
+ // venue market info (lotStepOf) — never to the decimals the position size happens to print
1825
+ // (QFEX GOLD-USD /refdata lot_size "0.0001": 50% of 0.003 is 0.0015, not 0.001). 100% →
1826
+ // the exact position size. Floored, so it can never exceed the position.
1827
+ const posSize = pos.size.replace(/^-/, "");
1828
+ let reduceSize = posSize;
1829
+ if (pct !== 100) {
1830
+ const sizeStep = await lotStepOf(adapter, pos.symbol);
1831
+ reduceSize = floorSize((parseFloat(posSize) * pct / 100).toFixed((sizeStep.split(".")[1] ?? "").length + 6), sizeStep);
1832
+ }
1833
+ if (parseFloat(reduceSize) > parseFloat(posSize)) {
1834
+ throw new PerpError("INVALID_PARAMS", `Computed reduce size ${reduceSize} exceeds position size ${posSize}`);
861
1835
  }
862
- const fullSize = parseFloat(pos.size);
863
- const reduceSize = (fullSize * pct / 100).toString();
864
1836
  const closeSide = pos.side === "long" ? "sell" : "buy";
865
- if (dryRunGuard("reduce", { exchange: adapter.name, symbol: sym, side: closeSide, size: reduceSize, percent: pct, originalSize: pos.size, smart: !!opts.smart }))
1837
+ if (await orderDryRunGuard("reduce", adapter, sym, { exchange: adapter.name, symbol: sym, side: closeSide, size: reduceSize, percent: pct, originalSize: pos.size, smart: !!opts.smart }))
866
1838
  return;
867
1839
  if (!isJson())
868
1840
  console.log(chalk.cyan(`\n Reducing ${sym} ${pos.side} by ${pct}% (${reduceSize} of ${pos.size}) on ${adapter.name}...${opts.smart ? " (smart)" : ""}\n`));
869
- let result;
870
- if (opts.smart) {
871
- const sr = await smartOrder(adapter, sym, closeSide, reduceSize, { reduceOnly: true });
872
- result = sr.result;
873
- }
874
- else {
875
- result = await adapter.marketOrder(sym, closeSide, reduceSize);
876
- }
877
- logExecution({
878
- type: "market_order", exchange: adapter.name, symbol: sym,
879
- side: closeSide, size: reduceSize, status: "success", dryRun: false,
880
- meta: { action: "reduce", percent: pct, originalSize: pos.size, originalSide: pos.side, smart: !!opts.smart },
881
- });
1841
+ const o = await closeVerified(adapter, pos.symbol, pos.side, reduceSize, { smart: !!opts.smart, slippage, action: "reduce" });
1842
+ const partial = residualError(adapter.name, [o]);
1843
+ if (!isJson())
1844
+ printCloseOutcome(o);
1845
+ if (partial !== null)
1846
+ throw partial;
1847
+ await orphanedReduceOnly(adapter, sym, false);
882
1848
  if (isJson())
883
- return printJson(jsonOk({ reduced: true, percent: pct, sizeReduced: reduceSize, originalSize: pos.size, result }));
884
- console.log(chalk.green(`\n Reduced ${sym} by ${pct}% (${closeSide} ${reduceSize}) on ${adapter.name}.${opts.smart ? " (smart)" : ""}\n`));
1849
+ return printJson(jsonOk({ reduced: true, percent: pct, sizeReduced: o.closed, requested: reduceSize, originalSize: pos.size, residual: o.residual, result: o }));
1850
+ console.log(chalk.green(`\n Reduced ${sym} by ${o.closed} (${closeSide}, verified) on ${adapter.name}.\n`));
885
1851
  });
886
1852
  });
887
1853
  // ── Order Status Query ──
888
1854
  trade
889
1855
  .command("status <orderId>")
890
- .description("Query order status by ID")
891
- .action(async (orderId) => {
1856
+ .description("Query order status by ID (ondo/arcus/qfex/lighter/lighter-rh/aster: orderId or c:<clientOrderId>, with --symbol; pacifica/hyperliquid: orderId or c:<clientOrderId>)")
1857
+ .option("--symbol <symbol>", "Market of the order (required on ondo/arcus/qfex, and for c:<clientOrderId> on lighter / lighter-rh / aster)")
1858
+ .action(async (orderId, opts) => {
892
1859
  await withJsonErrors(isJson(), async () => {
893
1860
  const adapter = await getAdapter();
1861
+ if (isTradingV2(adapter)) {
1862
+ if (opts.symbol === undefined) {
1863
+ throw new PerpError("INVALID_PARAMS", `${adapter.venue.displayName} order status needs the market`, {
1864
+ remediation: `Pass --symbol <SYM>: perp trade status ${orderId} --symbol <SYM> -e ${adapter.venue.id}`,
1865
+ });
1866
+ }
1867
+ const r = await adapter.getOrderStatus(opts.symbol.toUpperCase(), parseOrderRef(orderId));
1868
+ if (isJson())
1869
+ return printJson(jsonOk(orderResultJson(r)));
1870
+ printOrder(`Order ${orderId} (${r.symbol}) on ${adapter.name}`, r);
1871
+ return;
1872
+ }
1873
+ // c:<clientOrderId> on a legacy venue: the venue's own status query (pacifica, hyperliquid
1874
+ // orderStatus by cloid, aster GET /fapi/v3/order origClientOrderId, lighter / lighter-rh) —
1875
+ // never a local search that cannot match and answers ORDER_NOT_FOUND.
1876
+ const ref = parseOrderRef(orderId);
1877
+ if (ref.kind === "clientOrderId") {
1878
+ const def = getVenue(adapter.name);
1879
+ if (!requireCapabilities(def).clientOrderId) {
1880
+ throw unsupported(def.displayName, "statusByClientOrderId", "orders on this venue carry no client order id through perp-cli", `Query by venue order id: perp trade status <orderId> -e ${def.id}`);
1881
+ }
1882
+ // Pacifica / Hyperliquid query per account (the row names the market); the others per market.
1883
+ if (opts.symbol === undefined && def.family !== "pacifica" && def.family !== "hyperliquid") {
1884
+ throw new PerpError("INVALID_PARAMS", `${def.displayName} order status by client order id needs the market`, {
1885
+ remediation: `Pass --symbol <SYM>: perp trade status ${orderId} --symbol <SYM> -e ${def.id}`,
1886
+ });
1887
+ }
1888
+ const r = await v2Of(adapter).getOrderStatus(opts.symbol !== undefined ? opts.symbol.toUpperCase() : "", ref);
1889
+ if (isJson())
1890
+ return printJson(jsonOk(orderResultJson(r)));
1891
+ printOrder(`Order ${orderId} (${r.symbol}) on ${adapter.name}`, r);
1892
+ return;
1893
+ }
894
1894
  if (isQueryOrderCapable(adapter)) {
895
1895
  const result = await adapter.queryOrder(Number(orderId));
896
1896
  if (isJson())
@@ -1016,12 +2016,12 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1016
2016
  });
1017
2017
  });
1018
2018
  // ── Scale-In (분할매수) ──
1019
- trade
1020
- .command("scale-in <symbol> <side>")
2019
+ supportsDryRun(trade.command("scale-in <symbol> <side>"))
1021
2020
  .description("Place multiple limit orders at different price levels to build a position gradually (분할매수)")
1022
2021
  .requiredOption("--levels <levels>", "Comma-separated price:percent pairs (e.g., 65000:30,63000:30,60000:40)")
1023
2022
  .option("--size-usd <usd>", "Total USD amount to deploy across all levels")
1024
2023
  .option("--size <base>", "Total base amount (e.g., 0.01 BTC)")
2024
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure)")
1025
2025
  .action(async (symbol, side, opts) => {
1026
2026
  const sym = symbol.toUpperCase();
1027
2027
  const s = side.toLowerCase();
@@ -1042,24 +2042,34 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1042
2042
  errorAndExit(`Percentages must sum to 100%. Got: ${totalPct}%`);
1043
2043
  }
1044
2044
  const adapter = await getAdapter();
1045
- // Compute sizes for each level
2045
+ // Quarantine first: it refuses the whole ladder before any market / lot read.
2046
+ assertNotQuarantined(adapter.name, sym, false);
2047
+ // Compute sizes for each level on the venue lot (validated before anything is sent)
2048
+ const step = await lotStepOf(adapter, sym);
1046
2049
  let levelSizes;
1047
2050
  if (opts.sizeUsd) {
1048
2051
  const totalUsd = parseFloat(opts.sizeUsd);
1049
- levelSizes = levels.map(l => ({
1050
- price: l.price,
1051
- pct: l.pct,
1052
- size: (totalUsd * l.pct / 100 / parseFloat(l.price)).toString(),
1053
- }));
2052
+ levelSizes = levels.map((l, i) => {
2053
+ const size = floorToStep(totalUsd * l.pct / 100 / parseFloat(l.price), step);
2054
+ if (!(Number(size) > 0)) {
2055
+ throw new PerpError("SIZE_TOO_SMALL", `Level ${i + 1} ($${(totalUsd * l.pct / 100).toFixed(2)} @ ${l.price}) is below the lot ${step}; nothing was sent`, {
2056
+ remediation: `Use fewer levels or a larger --size-usd: every level must be at least one lot (${step})`,
2057
+ });
2058
+ }
2059
+ return { price: l.price, pct: l.pct, size };
2060
+ });
1054
2061
  }
1055
2062
  else {
1056
- const totalBase = parseFloat(opts.size);
1057
- levelSizes = levels.map(l => ({
1058
- price: l.price,
1059
- pct: l.pct,
1060
- size: (totalBase * l.pct / 100).toString(),
1061
- }));
2063
+ const sizes = scaleLevelSizes(opts.size, levels.map(l => l.pct), step);
2064
+ levelSizes = levels.map((l, i) => ({ price: l.price, pct: l.pct, size: sizes[i] }));
1062
2065
  }
2066
+ const reqs = await scaleRequests(adapter, sym, s, false, levelSizes);
2067
+ // Risk gate on the aggregate position being built (before the dry-run return — L5). scale-in is always an
2068
+ // opening flow (no reduce-only exemption); only --force bypasses. Notional
2069
+ // is the sum across levels, expressed as total base * volume-weighted price.
2070
+ const totalBase = levelSizes.reduce((acc, l) => acc + parseFloat(l.size), 0);
2071
+ const totalNotional = levelSizes.reduce((acc, l) => acc + parseFloat(l.size) * parseFloat(l.price), 0);
2072
+ await openingOrderGate(adapter, { symbol: sym, size: totalBase, price: totalBase > 0 ? totalNotional / totalBase : NaN, force: opts.force });
1063
2073
  if (dryRunGuard("scale_in", {
1064
2074
  exchange: adapter.name, symbol: sym, side: s,
1065
2075
  totalSizeUsd: opts.sizeUsd ?? "N/A",
@@ -1068,42 +2078,16 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1068
2078
  }))
1069
2079
  return;
1070
2080
  // Place limit orders at each level (NOT reduce-only — opening positions)
1071
- const results = [];
1072
- for (const level of levelSizes) {
1073
- try {
1074
- const result = await adapter.limitOrder(sym, s, level.price, level.size);
1075
- results.push({ price: level.price, size: level.size, pct: level.pct, result });
1076
- logExecution({
1077
- type: "limit_order", exchange: adapter.name, symbol: sym,
1078
- side: s, size: level.size, price: level.price,
1079
- status: "success", dryRun: false,
1080
- meta: { action: "scale-in", pct: level.pct },
1081
- });
1082
- }
1083
- catch (err) {
1084
- const msg = extractErrorMessage(err);
1085
- logExecution({
1086
- type: "limit_order", exchange: adapter.name, symbol: sym,
1087
- side: s, size: level.size, price: level.price,
1088
- status: "failed", dryRun: false, error: msg,
1089
- meta: { action: "scale-in", pct: level.pct },
1090
- });
1091
- if (isJson()) {
1092
- results.push({ price: level.price, size: level.size, pct: level.pct, result: { error: msg } });
1093
- }
1094
- else {
1095
- console.log(chalk.red(` Failed: ${level.price} x ${level.size} — ${msg}`));
1096
- }
1097
- }
2081
+ const results = await placeScaleLevels(adapter, "scale-in", sym, s, levelSizes, reqs);
2082
+ const incomplete = scaleIncompleteError(adapter.name, `scale-in ${sym}`, results);
2083
+ if (incomplete !== null) {
2084
+ if (!isJson())
2085
+ printScaleLevels(`Scale-in for ${sym} on ${adapter.name} is INCOMPLETE:`, sym, results);
2086
+ throw incomplete;
1098
2087
  }
1099
2088
  if (isJson())
1100
2089
  return printJson(jsonOk({ symbol: sym, side: s, levels: results }));
1101
- console.log(chalk.green(`\n Scale-in orders placed for ${sym} on ${adapter.name}:\n`));
1102
- for (const r of results) {
1103
- const status = r.result?.error ? chalk.red("FAILED") : chalk.green("OK");
1104
- console.log(` ${status} $${r.price} — ${r.pct}% (${r.size} ${sym})`);
1105
- }
1106
- console.log();
2090
+ printScaleLevels(`Scale-in orders placed for ${sym} on ${adapter.name}:`, sym, results);
1107
2091
  });
1108
2092
  // grid/dca/trailing-stop moved to 'perp strategy'
1109
2093
  // ── PnL Tracker ──
@@ -1186,131 +2170,316 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1186
2170
  }
1187
2171
  console.log(chalk.yellow(`\n PnL tracker stopped.\n`));
1188
2172
  });
1189
- // === Spot Trading ===
1190
- trade
1191
- .command("spot-buy <symbol> <size>")
1192
- .description("Buy spot token (HL or Lighter spot market)")
1193
- .action(async (symbol, size) => {
1194
- await withJsonErrors(isJson(), async () => {
1195
- const adapter = await getAdapter();
1196
- const exchangeName = adapter.name.toLowerCase();
1197
- let spotAdapter;
1198
- if (exchangeName === "hyperliquid") {
1199
- const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1200
- const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
1201
- if (adapter instanceof HyperliquidAdapter) {
1202
- spotAdapter = new HyperliquidSpotAdapter(adapter);
2173
+ // === Spot Trading (venue definitions, design §12) ===
2174
+ /** Spot adapter for the current venue via the factory; CAPABILITY_UNSUPPORTED when def.spot is null. */
2175
+ async function spotContext(confirmOnchainApprove) {
2176
+ const adapter = await getAdapter();
2177
+ const def = getVenue(adapter.name);
2178
+ const spotDef = requireSpotDefinition(def);
2179
+ if (confirmOnchainApprove && spotDef.accountModel !== "wallet") {
2180
+ throw unsupported(def.displayName, "onchainApprove", "--yes (on-chain approve) does not apply to this venue's spot", "Omit --yes (only on-chain wallet spot venues such as arcus send approve txs)");
2181
+ }
2182
+ const spot = await requireSpotAdapter(def.id, adapter, { confirmOnchainApprove });
2183
+ return { def, spotDef, spot };
2184
+ }
2185
+ /** Uniform feature name per spot capability (§13.6). */
2186
+ const SPOT_FEATURE = {
2187
+ limitOrder: "spotLimitOrder", orderbook: "spotOrderbook", cancel: "spotCancel", transfer: "spotTransfer",
2188
+ };
2189
+ function requireSpotCapability(def, spotDef, cap, what) {
2190
+ if (!spotDef.capabilities[cap]) {
2191
+ throw unsupported(def.displayName, SPOT_FEATURE[cap], `spot account model "${spotDef.accountModel}" has no ${what}`, spotDef.accountModel === "wallet"
2192
+ ? `${def.displayName} spot is a market swap: use perp -e ${def.id} trade spot-buy|spot-sell`
2193
+ : spotDef.accountModel === "unified"
2194
+ ? `${def.displayName} spot shares the perp account (one balance) — no transfer is needed`
2195
+ : `Use a venue whose spot supports ${what}`);
2196
+ }
2197
+ }
2198
+ function isSpotOrderResult(r) {
2199
+ if (r === null || typeof r !== "object")
2200
+ return false;
2201
+ const o = r;
2202
+ return typeof o.status === "string" && (o.finality === "final" || o.finality === "pending") && "receivedToken" in o;
2203
+ }
2204
+ /**
2205
+ * Uniform SpotOrderResult for a legacy raw spot result (HL statuses / Lighter sendTx ack).
2206
+ * A status / fill is reported only with proof: a venue-final IOC outcome (iocOutcomeOf),
2207
+ * HL `resting` (open) / `filled`; anything else is "pending" with a remediation — never "✓".
2208
+ */
2209
+ function spotOrderResultOf(def, spot, symbol, size, raw, kind) {
2210
+ if (isSpotOrderResult(raw))
2211
+ return raw;
2212
+ const r = raw !== null && typeof raw === "object" ? raw : null;
2213
+ const sentSize = typeof r?.sentSize === "string" ? r.sentSize : size;
2214
+ const statuses = r?.response?.data?.statuses;
2215
+ const st0 = Array.isArray(statuses) && statuses[0] !== null && typeof statuses[0] === "object" ? statuses[0] : null;
2216
+ const filled = st0?.filled;
2217
+ const resting = st0?.resting;
2218
+ const idOf = (v) => (typeof v === "number" || (typeof v === "string" && v !== "") ? String(v) : null);
2219
+ const orderId = idOf(filled?.oid) ?? idOf(resting?.oid) ?? idOf(r?.clientOrderIndex) ?? idOf(r?.tx_hash);
2220
+ const avgPrice = typeof filled?.avgPx === "string" && Number(filled.avgPx) > 0 ? filled.avgPx : null;
2221
+ const base = { venue: def.id, symbol, orderId, avgPrice, receivedToken: null, venueRaw: raw };
2222
+ const outcome = isIocOutcomeCapable(spot) ? spot.iocOutcomeOf(raw) : null;
2223
+ if (outcome !== null) {
2224
+ const full = Number(sentSize) - Number(outcome.filledSize) <= iocLotTolerance(sentSize, outcome.filledSize);
2225
+ return { ...base, status: Number(outcome.filledSize) === 0 ? "cancelled" : full ? "filled" : "partial", filledSize: outcome.filledSize, finality: "final", remediation: null };
2226
+ }
2227
+ if (kind === "limit" && resting !== undefined) {
2228
+ return { ...base, status: "open", filledSize: "0", finality: "final", remediation: null };
2229
+ }
2230
+ const check = kind === "limit" ? `perp trade spot-orders ${symbol} -e ${def.id}` : `perp -e ${def.id} account spot-balance`;
2231
+ return {
2232
+ ...base, status: "pending", filledSize: null, finality: "pending",
2233
+ remediation: `Sent; the venue response does not prove the outcome — do NOT resubmit. Check: ${check}`,
2234
+ };
2235
+ }
2236
+ /** Human output: SpotOrderResult status / finality / remediation (legacy raw results are wrapped first). */
2237
+ function printSpotOrder(verb, def, symbol, size, result) {
2238
+ const ok = result.status === "filled" || result.status === "partial" || result.status === "open";
2239
+ const color = result.status === "rejected" ? chalk.red : ok && result.finality === "final" ? chalk.green : chalk.yellow;
2240
+ console.log(color(`\n Spot ${verb} ${size} ${result.symbol} on ${def.id}: ${result.status} (${result.finality})`));
2241
+ const parts = [
2242
+ result.orderId !== null ? `order ${result.orderId}` : null,
2243
+ result.filledSize !== null ? `filled ${result.filledSize}` : null,
2244
+ result.avgPrice !== null ? `avg ${result.avgPrice}` : null,
2245
+ result.receivedToken !== null ? `received ${result.receivedToken}` : null,
2246
+ ].filter((x) => x !== null);
2247
+ if (parts.length > 0)
2248
+ console.log(chalk.gray(` ${parts.join(", ")}`));
2249
+ if (result.remediation !== null)
2250
+ console.log(chalk.yellow(` ${result.remediation}`));
2251
+ console.log();
2252
+ }
2253
+ /**
2254
+ * Execution record of a spot market order, same fields for every venue (design §13.8):
2255
+ * the resolved bound (bps + source) and, where the adapter reports it, the protection
2256
+ * price actually sent and its reference (`protection` on the raw result / venueRaw).
2257
+ */
2258
+ function spotExecutionOf(spotDef, def, result, slippage) {
2259
+ const rec = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : null);
2260
+ const r = rec(result);
2261
+ const pr = rec(r?.protection) ?? rec(rec(r?.venueRaw)?.protection);
2262
+ const str = (v) => (typeof v === "string" && v !== "" ? v : null);
2263
+ const method = spotDef.accountModel === "wallet" ? "routerSwap" : def.family === "pacifica" ? "venueSlippage" : "protectedIoc";
2264
+ const notes = [];
2265
+ if (method === "routerSwap")
2266
+ notes.push(`swap with minimum received = quote × (1 − ${slippage.bps} bps); no protection price`);
2267
+ else if (method === "venueSlippage")
2268
+ notes.push(`sent slippage_percent ${slippage.bps / 100}; ${def.displayName} derives the protection price`);
2269
+ else if (pr === null)
2270
+ notes.push("protection price not reported by the adapter");
2271
+ else
2272
+ notes.push(`requested ${slippage.bps} bps; applied after price-grid alignment`);
2273
+ return {
2274
+ method,
2275
+ slippageBps: pr !== null && typeof pr.appliedBps === "number" ? pr.appliedBps : slippage.bps,
2276
+ slippageSource: slippage.source,
2277
+ protectionPrice: pr !== null ? str(pr.protectionPrice) : null,
2278
+ referencePrice: pr !== null ? str(pr.referencePrice) : null,
2279
+ tifRequested: null,
2280
+ tifSent: method === "protectedIoc" ? "IOC" : null,
2281
+ expiresAt: null,
2282
+ builderFee: null, // spot orders never carry a builder fee
2283
+ notes,
2284
+ };
2285
+ }
2286
+ function printSpotExecution(e) {
2287
+ const parts = [e.method, `slippage ${e.slippageBps} bps (${e.slippageSource})`];
2288
+ if (e.protectionPrice !== null)
2289
+ parts.push(`protection ${e.protectionPrice}${e.referencePrice !== null ? ` (ref ${e.referencePrice})` : ""}`);
2290
+ console.log(chalk.gray(` Execution: ${parts.join(" · ")}`));
2291
+ for (const n of e.notes)
2292
+ console.log(chalk.gray(` Note: ${n}`));
2293
+ console.log();
2294
+ }
2295
+ /**
2296
+ * One cancel result shape for every spot venue (SpotOrderResult). Legacy raw acks are
2297
+ * wrapped: HL statuses[0] "success" → cancelled (final), an `error` → ORDER_REJECTED;
2298
+ * any other ack (Lighter tx) → pending until the open orders confirm it.
2299
+ */
2300
+ function spotCancelResult(def, symbol, orderId, raw) {
2301
+ if (isSpotOrderResult(raw))
2302
+ return raw;
2303
+ const statuses = raw?.response?.data?.statuses;
2304
+ const st0 = Array.isArray(statuses) ? statuses[0] : undefined;
2305
+ if (st0 !== null && typeof st0 === "object" && typeof st0.error === "string") {
2306
+ throw new PerpError("ORDER_REJECTED", `${def.displayName} spot cancel of ${orderId} refused: ${st0.error}`, { exchange: def.id });
2307
+ }
2308
+ const confirmed = st0 === "success";
2309
+ return {
2310
+ venue: def.id, symbol, orderId, status: confirmed ? "cancelled" : "pending", filledSize: null, avgPrice: null,
2311
+ finality: confirmed ? "final" : "pending", receivedToken: null,
2312
+ remediation: confirmed ? null : `Cancel sent; confirm with: perp trade spot-orders ${symbol} -e ${def.id}`,
2313
+ venueRaw: raw,
2314
+ };
2315
+ }
2316
+ function spotExecStatus(result) {
2317
+ if (result.status === "rejected")
2318
+ return "failed";
2319
+ return result.finality === "pending" ? "unverified" : "success";
2320
+ }
2321
+ for (const [verb, side] of [["buy", "buy"], ["sell", "sell"]]) {
2322
+ supportsDryRun(trade.command(`spot-${verb} <symbol> <size>`))
2323
+ .description(`${verb === "buy" ? "Buy" : "Sell"} a spot token at market on any venue with spot (bare base uses the venue's default quote)`)
2324
+ .option("--slippage-bps <n>", "Slippage bound in bps (default: settings defaultSlippageBps)")
2325
+ .option("--yes", "Allow the one-time on-chain approve(Permit2) tx (arcus; costs Robinhood Chain ETH gas)")
2326
+ .action(async (symbol, size, opts) => {
2327
+ await withJsonErrors(isJson(), async () => {
2328
+ const slippageFlag = parseSlippageBpsFlag(opts.slippageBps);
2329
+ const { def, spotDef, spot } = await spotContext(opts.yes === true);
2330
+ const slippage = resolveSlippage(def.id, slippageFlag);
2331
+ const sym = symbol.toUpperCase();
2332
+ if (dryRunGuard(`spot ${verb}`, { exchange: def.id, symbol: sym, side, size }))
2333
+ return;
2334
+ const raw = await spot.spotMarketOrder(sym, side, size, { slippage });
2335
+ const result = spotOrderResultOf(def, spot, sym, size, raw, "market");
2336
+ logExecution({ type: "market_order", exchange: def.id + "-spot", symbol: sym, side, size, status: spotExecStatus(result), dryRun: false, meta: { spot: true, result } });
2337
+ const execution = spotExecutionOf(spotDef, def, result, slippage);
2338
+ if (isJson()) {
2339
+ printJson(jsonOk({ order: result, symbol: sym, side, size, execution }));
1203
2340
  }
1204
- }
1205
- else if (exchangeName === "lighter") {
1206
- const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1207
- const { LighterAdapter } = await import("../exchanges/lighter.js");
1208
- if (adapter instanceof LighterAdapter) {
1209
- spotAdapter = new LighterSpotAdapter(adapter);
2341
+ else {
2342
+ printSpotOrder(verb === "buy" ? "bought" : "sold", def, sym, size, result);
2343
+ printSpotExecution(execution);
1210
2344
  }
1211
- }
1212
- if (!spotAdapter) {
1213
- if (isJson())
1214
- return printJson(jsonError("UNSUPPORTED", `${exchangeName} does not support spot trading`));
1215
- errorAndExit(`${exchangeName} does not support spot trading`);
2345
+ });
2346
+ });
2347
+ }
2348
+ supportsDryRun(trade.command("spot-limit <symbol> <side> <price> <size>"))
2349
+ .description("Place a spot limit order (venues with spot limitOrder capability)")
2350
+ .option("--tif <tif>", "Time in force (venue-specific, e.g. GTC/IOC/ALO); omitted = the adapter's default")
2351
+ .action(async (symbol, side, price, size, opts) => {
2352
+ await withJsonErrors(isJson(), async () => {
2353
+ const s = side.toLowerCase();
2354
+ if (s !== "buy" && s !== "sell")
2355
+ throw new PerpError("INVALID_PARAMS", `Side must be buy or sell (got "${side}")`);
2356
+ const { def, spotDef, spot } = await spotContext(false);
2357
+ requireSpotCapability(def, spotDef, "limitOrder", "limit orders");
2358
+ const sym = symbol.toUpperCase();
2359
+ if (dryRunGuard("spot limit", { exchange: def.id, symbol: sym, side: s, price, size, tif: opts.tif ?? null }))
1216
2360
  return;
2361
+ const raw = await spot.spotLimitOrder(sym, s, price, size, opts.tif !== undefined ? { tif: opts.tif } : undefined);
2362
+ const result = spotOrderResultOf(def, spot, sym, size, raw, "limit");
2363
+ logExecution({ type: "limit_order", exchange: def.id + "-spot", symbol: sym, side: s, size, price, status: spotExecStatus(result), dryRun: false, meta: { spot: true, tif: opts.tif ?? null, result } });
2364
+ if (isJson()) {
2365
+ printJson(jsonOk({ order: result, symbol: sym, side: s, price, size }));
2366
+ }
2367
+ else {
2368
+ printSpotOrder(`limit ${s} @ ${price}`, def, sym, size, result);
1217
2369
  }
1218
- await spotAdapter.init();
1219
- if (dryRunGuard("spot buy", { exchange: exchangeName, symbol: symbol.toUpperCase(), side: "buy", size }))
2370
+ });
2371
+ });
2372
+ supportsDryRun(trade.command("spot-cancel <symbol> <orderId>"))
2373
+ .description("Cancel a spot order (venues with spot cancel capability)")
2374
+ .action(async (symbol, orderId) => {
2375
+ await withJsonErrors(isJson(), async () => {
2376
+ const { def, spotDef, spot } = await spotContext(false);
2377
+ requireSpotCapability(def, spotDef, "cancel", "order cancel");
2378
+ const sym = symbol.toUpperCase();
2379
+ if (dryRunGuard("spot cancel", { exchange: def.id, symbol: sym, orderId }))
1220
2380
  return;
1221
- const result = await spotAdapter.spotMarketOrder(symbol.toUpperCase(), "buy", size);
1222
- logExecution({ type: "market_order", exchange: exchangeName + "-spot", symbol: symbol.toUpperCase(), side: "buy", size, status: "success", dryRun: false, meta: { spot: true, result } });
2381
+ const result = spotCancelResult(def, sym, orderId, await spot.spotCancelOrder(sym, orderId));
2382
+ logExecution({ type: "cancel", exchange: def.id + "-spot", symbol: sym, side: "cancel", size: "0", status: spotExecStatus(result), dryRun: false, meta: { spot: true, orderId, result } });
1223
2383
  if (isJson()) {
1224
- printJson(jsonOk({ order: result, symbol: symbol.toUpperCase(), side: "buy", size }));
2384
+ printJson(jsonOk({ cancelled: result, symbol: sym, orderId }));
1225
2385
  }
1226
2386
  else {
1227
- console.log(chalk.green(`\n ✓ Spot bought ${size} ${symbol.toUpperCase()} on ${exchangeName}\n`));
2387
+ printSpotOrder("cancel", def, sym, orderId, result);
1228
2388
  }
1229
2389
  });
1230
2390
  });
1231
2391
  trade
1232
- .command("spot-sell <symbol> <size>")
1233
- .description("Sell spot token (HL or Lighter spot market)")
1234
- .action(async (symbol, size) => {
2392
+ .command("spot-orders [symbol]")
2393
+ .description("List open spot orders (optionally for one symbol)")
2394
+ .action(async (symbol) => {
1235
2395
  await withJsonErrors(isJson(), async () => {
1236
- const adapter = await getAdapter();
1237
- const exchangeName = adapter.name.toLowerCase();
1238
- let spotAdapter;
1239
- if (exchangeName === "hyperliquid") {
1240
- const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1241
- const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
1242
- if (adapter instanceof HyperliquidAdapter) {
1243
- spotAdapter = new HyperliquidSpotAdapter(adapter);
1244
- }
1245
- }
1246
- else if (exchangeName === "lighter") {
1247
- const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1248
- const { LighterAdapter } = await import("../exchanges/lighter.js");
1249
- if (adapter instanceof LighterAdapter) {
1250
- spotAdapter = new LighterSpotAdapter(adapter);
1251
- }
2396
+ const { def, spotDef, spot } = await spotContext(false);
2397
+ if (spotDef.accountModel === "wallet" || !isSpotOpenOrdersCapable(spot)) {
2398
+ throw unsupported(def.displayName, "spotOpenOrders", spotDef.accountModel === "wallet" ? "spot is an on-chain swap that settles immediately — there are no resting orders" : "the spot adapter cannot list open orders", spotDef.accountModel === "wallet" ? `Check the wallet balance: perp -e ${def.id} trade spot-balance` : "Use the venue UI to inspect open spot orders");
1252
2399
  }
1253
- if (!spotAdapter) {
1254
- if (isJson())
1255
- return printJson(jsonError("UNSUPPORTED", `${exchangeName} does not support spot trading`));
1256
- errorAndExit(`${exchangeName} does not support spot trading`);
2400
+ const orders = await spot.getSpotOpenOrders(symbol === undefined ? null : symbol.toUpperCase());
2401
+ if (isJson())
2402
+ return printJson(jsonOk({ exchange: def.id, orders }));
2403
+ console.log(chalk.cyan(`\n Open spot orders (${def.id}):\n`));
2404
+ if (orders.length === 0) {
2405
+ console.log(chalk.gray(" No open spot orders\n"));
1257
2406
  return;
1258
2407
  }
1259
- await spotAdapter.init();
1260
- if (dryRunGuard("spot sell", { exchange: exchangeName, symbol: symbol.toUpperCase(), side: "sell", size }))
2408
+ const { makeTable } = await import("../utils.js");
2409
+ console.log(makeTable(["Order", "Symbol", "Side", "Price", "Size", "Filled"], orders.map(o => [o.orderId, o.symbol, o.side, o.price, o.size, o.filled])));
2410
+ console.log();
2411
+ });
2412
+ });
2413
+ supportsDryRun(trade.command("spot-transfer <direction> <amount>"))
2414
+ .description("Move the venue's spot quote asset between perp and spot (to-spot | to-perp). Shared-balance accounts (pacifica, HL unified, Lighter unified trading account): success no-op (execution.method sharedAccount); wallet spot (arcus): unsupported")
2415
+ .action(async (direction, amount) => {
2416
+ await withJsonErrors(isJson(), async () => {
2417
+ const dir = direction.toLowerCase();
2418
+ if (dir !== "to-spot" && dir !== "to-perp")
2419
+ throw new PerpError("INVALID_PARAMS", `Direction must be to-spot or to-perp (got "${direction}")`);
2420
+ const n = Number(amount);
2421
+ if (!Number.isFinite(n) || n <= 0)
2422
+ throw new PerpError("INVALID_PARAMS", `Amount must be a positive number (got "${amount}")`);
2423
+ const { def, spotDef, spot } = await spotContext(false);
2424
+ // Wallet model (arcus): spot is the on-chain wallet, not an exchange account → uniform unsupported.
2425
+ // Unified accounts (pacifica, HL unified, Lighter account_trading_mode 1) → success no-op below (execution.method "sharedAccount").
2426
+ if (spotDef.accountModel === "wallet") {
2427
+ throw unsupported(def.displayName, "spotTransfer", "spot is the on-chain wallet (no exchange spot account to transfer into)", `Fund the wallet directly; check it with: perp -e ${def.id} trade spot-balance`);
2428
+ }
2429
+ const asset = defaultSpotQuote(def, network());
2430
+ if (dryRunGuard("spot transfer", { exchange: def.id, direction: dir, amount, asset }))
1261
2431
  return;
1262
- const result = await spotAdapter.spotMarketOrder(symbol.toUpperCase(), "sell", size);
1263
- logExecution({ type: "market_order", exchange: exchangeName + "-spot", symbol: symbol.toUpperCase(), side: "sell", size, status: "success", dryRun: false, meta: { spot: true, result } });
1264
- if (isJson()) {
1265
- printJson(jsonOk({ order: result, symbol: symbol.toUpperCase(), side: "sell", size }));
2432
+ let moved;
2433
+ try {
2434
+ moved = dir === "to-spot" ? await transferQuoteToSpot(spot, n) : await transferQuoteToPerp(spot, n);
2435
+ }
2436
+ catch (e) {
2437
+ // Refused for lack of funds → say how much the source side can move (read live).
2438
+ throw await transferShortfallError(e, {
2439
+ venue: def.id, direction: dir, amount, asset,
2440
+ readAvailable: async () => {
2441
+ if (dir !== "to-spot")
2442
+ return readSpotQuoteAvailableFresh(spot);
2443
+ const perp = await getAdapter();
2444
+ // Aster margin is multi-asset: the account-wide available balance is not an amount of `asset`.
2445
+ return typeof perp.getPerpAssetTransferable === "function" ? perp.getPerpAssetTransferable(asset) : Number((await perp.getBalance()).available);
2446
+ },
2447
+ });
2448
+ }
2449
+ const execution = moved
2450
+ ? { method: "transfer", notes: [`${asset} ${dir === "to-spot" ? "perp → spot" : "spot → perp"}`] }
2451
+ : { method: "sharedAccount", notes: [`${def.displayName} spot and perp share one balance for this account — no transfer needed`] };
2452
+ if (isJson())
2453
+ return printJson(jsonOk({ exchange: def.id, direction: dir, amount, asset, transferred: moved, execution }));
2454
+ if (moved) {
2455
+ console.log(chalk.green(`\n ✓ Transferred ${amount} ${asset} ${dir === "to-spot" ? "perp → spot" : "spot → perp"} on ${def.id}`));
1266
2456
  }
1267
2457
  else {
1268
- console.log(chalk.green(`\n ✓ Spot sold ${size} ${symbol.toUpperCase()} on ${exchangeName}\n`));
2458
+ console.log(chalk.green(`\n ✓ Nothing to move on ${def.id}: spot and perp share one balance`));
1269
2459
  }
2460
+ console.log(chalk.gray(` Execution: ${execution.method}\n`));
1270
2461
  });
1271
2462
  });
1272
2463
  trade
1273
2464
  .command("spot-balance")
1274
- .description("Show spot token balances (HL or Lighter)")
2465
+ .description("Show spot token balances (any venue with spot)")
1275
2466
  .action(async () => {
1276
2467
  await withJsonErrors(isJson(), async () => {
1277
- const adapter = await getAdapter();
1278
- const exchangeName = adapter.name.toLowerCase();
1279
- let spotAdapter;
1280
- if (exchangeName === "hyperliquid") {
1281
- const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1282
- const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
1283
- if (adapter instanceof HyperliquidAdapter) {
1284
- spotAdapter = new HyperliquidSpotAdapter(adapter);
1285
- }
1286
- }
1287
- else if (exchangeName === "lighter") {
1288
- const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1289
- const { LighterAdapter } = await import("../exchanges/lighter.js");
1290
- if (adapter instanceof LighterAdapter) {
1291
- spotAdapter = new LighterSpotAdapter(adapter);
1292
- }
1293
- }
1294
- if (!spotAdapter) {
1295
- if (isJson())
1296
- return printJson(jsonError("UNSUPPORTED", `${exchangeName} does not support spot trading`));
1297
- errorAndExit(`${exchangeName} does not support spot trading`);
1298
- return;
1299
- }
1300
- await spotAdapter.init();
1301
- const balances = await spotAdapter.getSpotBalances();
2468
+ const { def, spot } = await spotContext(false);
2469
+ const balances = await spot.getSpotBalances();
1302
2470
  const nonZero = balances.filter(b => Number(b.total) > 0);
1303
2471
  if (isJson()) {
1304
- printJson(jsonOk({ exchange: exchangeName, balances: nonZero }));
2472
+ printJson(jsonOk({ exchange: def.id, balances: nonZero }));
1305
2473
  }
1306
2474
  else {
1307
- console.log(chalk.cyan(`\n Spot Balances (${exchangeName}):\n`));
2475
+ console.log(chalk.cyan(`\n Spot Balances (${def.id}):\n`));
1308
2476
  if (nonZero.length === 0) {
1309
2477
  console.log(chalk.gray(" No spot tokens\n"));
1310
2478
  }
1311
2479
  else {
1312
2480
  for (const b of nonZero) {
1313
- console.log(` ${b.token.padEnd(10)} ${b.total.padEnd(15)} available: ${b.available}`);
2481
+ const sharedNote = b.shared ? chalk.gray(" (shared with perp margin)") : "";
2482
+ console.log(` ${b.token.padEnd(10)} ${b.total.padEnd(15)} available: ${b.available}${sharedNote}`);
1314
2483
  }
1315
2484
  console.log();
1316
2485
  }
@@ -1333,15 +2502,14 @@ function parseMultiLeg(spec) {
1333
2502
  throw new Error(`Invalid leg format "${spec}" — expected exchange:symbol:side:size`);
1334
2503
  }
1335
2504
  const [exchange, symbol, side, size] = parts;
1336
- // Normalize exchange aliases
1337
- const exMap = {
1338
- hl: "hyperliquid", pac: "pacifica", lit: "lighter",
1339
- hyperliquid: "hyperliquid", pacifica: "pacifica", lighter: "lighter",
1340
- };
1341
- const normalizedExchange = exMap[exchange.toLowerCase()];
1342
- if (!normalizedExchange) {
1343
- throw new Error(`Unknown exchange "${exchange}" in leg spec`);
2505
+ // Normalize exchange aliases via the venue registry ("lit" kept as a legacy alias of lighter)
2506
+ const exRef = exchange.toLowerCase() === "lit" ? "lighter" : exchange;
2507
+ const trading = listVenuesWithRole("trading");
2508
+ const def = trading.find(v => v.id === exRef.toLowerCase() || v.aliases.includes(exRef.toLowerCase()));
2509
+ if (!def) {
2510
+ throw new Error(`Unknown exchange "${exchange}" in leg spec — valid: ${trading.map(v => v.id).join(", ")}`);
1344
2511
  }
2512
+ const normalizedExchange = def.id;
1345
2513
  if (side !== "buy" && side !== "sell") {
1346
2514
  throw new Error(`Invalid side "${side}" — must be "buy" or "sell"`);
1347
2515
  }
@@ -1357,12 +2525,18 @@ function registerMultiAction(parent, getAdapterForExchange, isJson, deprecated =
1357
2525
  if (deprecated)
1358
2526
  cmd._hidden = true;
1359
2527
  cmd
1360
- .option("--smart", "Use smart order (IOC limit + fallback) for each leg")
2528
+ .option("--smart", "Send each opening leg as an IOC limit at best bid/ask + 1 tick (no market fallback: a leg that does not match fails)")
1361
2529
  .option("--rollback", "Auto-rollback filled legs if any leg fails", true)
1362
2530
  .option("--no-rollback", "Disable auto-rollback")
1363
- .option("--timeout <ms>", "Per-leg timeout in milliseconds", "30000")
2531
+ .option("--timeout <ms>", "Removed: legs are verified by their position, never timed out (passing it is an error)")
2532
+ .option("--force", "Bypass local risk-limit checks (maxPosition / total exposure) for every leg")
1364
2533
  .action(async (legSpecs, opts) => {
1365
2534
  await withJsonErrors(isJson(), async () => {
2535
+ if (opts.timeout !== undefined) {
2536
+ throw new PerpError("INVALID_PARAMS", "--timeout was removed from 'trade multi': each leg is verified by its position delta; an unconfirmed leg is reported (and quarantined), never treated as failed after a timeout", {
2537
+ remediation: "Drop --timeout",
2538
+ });
2539
+ }
1366
2540
  // Parse legs
1367
2541
  const legs = legSpecs.map(parseMultiLeg);
1368
2542
  if (legs.length < 2) {
@@ -1388,101 +2562,175 @@ function registerMultiAction(parent, getAdapterForExchange, isJson, deprecated =
1388
2562
  adapters.set(l.exchange, await getAdapterForExchange(l.exchange));
1389
2563
  }
1390
2564
  }
1391
- // Execute all legs simultaneously
1392
- const timeoutMs = parseInt(opts.timeout);
1393
- const results = await Promise.allSettled(legs.map(async (leg) => {
2565
+ // Quarantined symbols (an earlier unknown outcome) are never traded automatically.
2566
+ for (const l of legs) {
2567
+ const q = quarantineEntryFor(l.symbol, (m) => { if (!isJson())
2568
+ console.error(m); });
2569
+ if (q !== null) {
2570
+ throw new PerpError("RISK_VIOLATION", `${l.symbol} is quarantined by ${q.strategy}: ${q.reason}`, { remediation: q.remediation });
2571
+ }
2572
+ }
2573
+ // Risk gate every leg before any order goes out, so a breach aborts the
2574
+ // whole multi-leg up front. --force bypasses; the rollback orders below
2575
+ // are exempt (they reduce the just-opened legs).
2576
+ await Promise.all(legs.map((leg) => enforceOrderRisk(adapters.get(leg.exchange), {
2577
+ symbol: leg.symbol,
2578
+ size: parseFloat(leg.size),
2579
+ force: opts.force,
2580
+ })));
2581
+ // Lot step per leg from the venue market info (unknown → throws before any order goes out):
2582
+ // each leg is sent floored to it and its fill judged against that sent size.
2583
+ const legSteps = new Map();
2584
+ for (const l of legs)
2585
+ legSteps.set(l, await lotStepOf(adapters.get(l.exchange), l.symbol));
2586
+ // Execute legs ONE AT A TIME, each verified by its position delta (placePerpVerified):
2587
+ // parallel legs on one venue/symbol would corrupt each other's measurement. With
2588
+ // rollback on, the first failure stops further legs. An unknown outcome stops
2589
+ // everything: that leg is quarantined and NOTHING is rolled back (the unknown leg may
2590
+ // have executed — undoing the others could leave it naked).
2591
+ const log = (m) => { if (!isJson())
2592
+ console.log(chalk.red(m)); };
2593
+ const quarantineLeg = (leg, reason) => quarantineSymbol("trade-multi", leg.symbol, reason, `Check ${leg.symbol} on ${leg.exchange} (perp -e ${leg.exchange} account positions) and the other legs; fix by hand, then: perp strategy quarantine-clear ${leg.symbol}`, log);
2594
+ const legResults = legs.map(leg => ({ leg, status: "skipped", filledSize: null }));
2595
+ let stop = false;
2596
+ for (const r of legResults) {
2597
+ if (stop)
2598
+ break;
2599
+ const leg = r.leg;
1394
2600
  const adapter = adapters.get(leg.exchange);
1395
- const orderPromise = opts.smart
1396
- ? smartOrder(adapter, leg.symbol, leg.side, leg.size).then(r => r.result)
1397
- : adapter.marketOrder(leg.symbol, leg.side, leg.size);
1398
- // Apply timeout
1399
- const result = await Promise.race([
1400
- orderPromise,
1401
- new Promise((_, reject) => setTimeout(() => reject(new Error("Leg timeout")), timeoutMs)),
1402
- ]);
1403
- // Log execution
1404
- logExecution({
1405
- type: "multi_leg",
1406
- exchange: leg.exchange,
1407
- symbol: leg.symbol,
1408
- side: leg.side,
1409
- size: leg.size,
1410
- status: "success",
1411
- dryRun: false,
1412
- meta: { result, smart: opts.smart },
1413
- });
1414
- return { leg, status: "filled", result };
1415
- }));
1416
- // Process results
1417
- const legResults = results.map((r, i) => {
1418
- if (r.status === "fulfilled")
1419
- return r.value;
1420
- return {
1421
- leg: legs[i],
1422
- status: "failed",
1423
- error: r.reason instanceof Error ? r.reason.message : String(r.reason),
1424
- };
1425
- });
2601
+ const step = legSteps.get(leg);
2602
+ try {
2603
+ const sentSize = floorSize(leg.size, step);
2604
+ const f = await placePerpVerified(adapter, leg.exchange, leg.symbol, leg.side, sentSize, {
2605
+ stepSize: step,
2606
+ ...(opts.smart ? { place: () => smartOrder(adapter, leg.symbol, leg.side, sentSize).then(x => x.result) } : {}),
2607
+ });
2608
+ r.status = "filled";
2609
+ r.filledSize = floorToStep(f.filledSize, step);
2610
+ r.result = f.raw;
2611
+ // Accepted partial fill (venue-final IOC partial, or ≥ 80% measured): the legs no longer
2612
+ // match — reported as ORDER_PARTIAL; with rollback on it stops and is rolled back.
2613
+ if (Number(r.filledSize) < Number(sentSize)) {
2614
+ r.error = `partial fill: ${r.filledSize} of ${sentSize}${sentSize !== leg.size ? ` (requested ${leg.size}, floored to lot ${step})` : ""}`;
2615
+ if (opts.rollback)
2616
+ stop = true;
2617
+ }
2618
+ logExecution({ type: "multi_leg", exchange: leg.exchange, symbol: leg.symbol, side: leg.side, size: leg.size, status: "success", dryRun: false, meta: { result: f.raw, smart: opts.smart, measuredFill: f.filledSize } });
2619
+ }
2620
+ catch (e) {
2621
+ r.error = extractErrorMessage(e);
2622
+ logExecution({ type: "multi_leg", exchange: leg.exchange, symbol: leg.symbol, side: leg.side, size: leg.size, status: isOutcomeUnknown(e) ? "unverified" : "failed", error: r.error, dryRun: false, meta: { smart: opts.smart, pending: isOutcomeUnknown(e) } });
2623
+ if (isOutcomeUnknown(e)) {
2624
+ r.status = "unknown";
2625
+ quarantineLeg(leg, `trade multi: ${leg.side} ${leg.size} ${leg.symbol} on ${leg.exchange} outcome unknown (${r.error})`);
2626
+ stop = true;
2627
+ }
2628
+ else {
2629
+ r.status = "failed";
2630
+ if (opts.rollback)
2631
+ stop = true;
2632
+ }
2633
+ }
2634
+ }
1426
2635
  const filled = legResults.filter(r => r.status === "filled");
1427
2636
  const failed = legResults.filter(r => r.status === "failed");
1428
- // Rollback if partial fill and rollback enabled
1429
- if (failed.length > 0 && filled.length > 0 && opts.rollback) {
2637
+ const unknown = legResults.filter(r => r.status === "unknown");
2638
+ // Rollback (definite failures or partial fills only): each verified-filled leg, reduce-only, sized to its
2639
+ // MEASURED fill, verified with one residual close. Rejected / unconfirmed / residual → quarantine.
2640
+ const partial = filled.filter(r => r.error !== undefined);
2641
+ if ((failed.length > 0 || partial.length > 0) && unknown.length === 0 && filled.length > 0 && opts.rollback) {
1430
2642
  if (!isJson()) {
1431
- console.log(chalk.yellow(`\n ${failed.length} leg(s) failed — rolling back ${filled.length} filled leg(s)...\n`));
2643
+ console.log(chalk.yellow(`\n ${failed.length} leg(s) failed${partial.length > 0 ? `, ${partial.length} partially filled` : ""} — rolling back ${filled.length} filled leg(s) (reduce-only)...\n`));
1432
2644
  }
1433
- for (const fr of filled) {
2645
+ for (const fr of [...filled].reverse()) {
1434
2646
  const adapter = adapters.get(fr.leg.exchange);
1435
2647
  const reverseSide = fr.leg.side === "buy" ? "sell" : "buy";
2648
+ const rbSize = fr.filledSize;
2649
+ let why = null;
1436
2650
  try {
1437
- await adapter.marketOrder(fr.leg.symbol, reverseSide, fr.leg.size);
1438
- fr.status = "rolled_back";
1439
- logExecution({
1440
- type: "multi_leg_rollback",
1441
- exchange: fr.leg.exchange,
1442
- symbol: fr.leg.symbol,
1443
- side: reverseSide,
1444
- size: fr.leg.size,
1445
- status: "success",
1446
- dryRun: false,
1447
- });
1448
- if (!isJson()) {
1449
- console.log(chalk.gray(` Rolled back: ${fr.leg.exchange} ${reverseSide} ${fr.leg.symbol} x ${fr.leg.size}`));
1450
- }
2651
+ const c = await closePerpVerified(adapter, fr.leg.exchange, fr.leg.symbol, reverseSide, rbSize, { stepSize: legSteps.get(fr.leg) });
2652
+ if (c.residual > 0)
2653
+ why = `rollback closed ${c.closed} of ${rbSize}, ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""}`;
1451
2654
  }
1452
2655
  catch (err) {
1453
- if (!isJson()) {
1454
- console.log(chalk.red(` Rollback failed: ${fr.leg.exchange} ${fr.leg.symbol} — ${err instanceof Error ? err.message : err}`));
1455
- }
2656
+ why = `rollback ${isOutcomeUnknown(err) ? "outcome unknown" : "failed"}: ${extractErrorMessage(err)}`;
2657
+ }
2658
+ logExecution({ type: "multi_leg_rollback", exchange: fr.leg.exchange, symbol: fr.leg.symbol, side: reverseSide, size: rbSize, status: why === null ? "success" : "failed", ...(why !== null ? { error: why } : {}), dryRun: false });
2659
+ if (why === null) {
2660
+ fr.status = "rolled_back";
2661
+ if (!isJson())
2662
+ console.log(chalk.gray(` Rolled back: ${fr.leg.exchange} ${reverseSide} ${fr.leg.symbol} x ${rbSize} (reduce-only)`));
2663
+ }
2664
+ else {
2665
+ fr.status = "rollback_failed";
2666
+ fr.error = why;
2667
+ quarantineLeg(fr.leg, `trade multi: ${fr.leg.side} ${rbSize} ${fr.leg.symbol} on ${fr.leg.exchange} filled; ${why}`);
1456
2668
  }
1457
2669
  }
1458
2670
  }
2671
+ // Not a success unless every leg filled: unknown → ORDER_PENDING; a failed rollback or legs
2672
+ // left filled next to a failed one → ORDER_PARTIAL; failed with nothing left open →
2673
+ // ORDER_REJECTED. Scripts get ok:false + exit code 1 with the full per-leg details.
2674
+ const rollbackFailed = legResults.filter(r => r.status === "rollback_failed");
2675
+ const stillFilled = legResults.filter(r => r.status === "filled");
2676
+ const outcome = unknown.length > 0 ? "ORDER_PENDING"
2677
+ : rollbackFailed.length > 0 ? "ORDER_PARTIAL"
2678
+ : failed.length > 0 || partial.length > 0 ? (stillFilled.length > 0 ? "ORDER_PARTIAL" : "ORDER_REJECTED")
2679
+ : null;
2680
+ if (outcome !== null)
2681
+ process.exitCode = 1;
1459
2682
  if (isJson()) {
1460
- printJson(jsonOk({
2683
+ const payload = {
1461
2684
  legs: legResults.map(r => ({
1462
2685
  exchange: r.leg.exchange,
1463
2686
  symbol: r.leg.symbol,
1464
2687
  side: r.leg.side,
1465
2688
  size: r.leg.size,
2689
+ filledSize: r.filledSize,
1466
2690
  status: r.status,
1467
2691
  error: r.error,
1468
2692
  })),
1469
2693
  summary: {
1470
2694
  total: legs.length,
1471
- filled: legResults.filter(r => r.status === "filled").length,
2695
+ filled: stillFilled.length,
1472
2696
  failed: failed.length,
2697
+ unknown: unknown.length,
2698
+ skipped: legResults.filter(r => r.status === "skipped").length,
1473
2699
  rolledBack: legResults.filter(r => r.status === "rolled_back").length,
2700
+ rollbackFailed: rollbackFailed.length,
2701
+ quarantined: [...new Set(legResults.filter(r => r.status === "unknown" || r.status === "rollback_failed").map(r => r.leg.symbol))],
1474
2702
  },
1475
- }));
2703
+ };
2704
+ if (outcome === null) {
2705
+ printJson(jsonOk(payload));
2706
+ }
2707
+ else {
2708
+ const message = outcome === "ORDER_PENDING" ? `trade multi: ${unknown.length} leg(s) have an unknown outcome (quarantined)`
2709
+ : outcome === "ORDER_PARTIAL" ? `trade multi: ${failed.length} leg(s) failed${partial.length > 0 ? `, ${partial.length} partially filled` : ""}; ${rollbackFailed.length > 0 ? `${rollbackFailed.length} rollback(s) failed (quarantined)` : `${stillFilled.length} leg(s) remain filled`}`
2710
+ : `trade multi: ${failed.length} leg(s) failed${partial.length > 0 ? `, ${partial.length} partially filled` : ""}; nothing left open`;
2711
+ printJson(jsonError(outcome, message, {
2712
+ status: ERROR_CODES[outcome].status,
2713
+ retryable: false,
2714
+ details: payload,
2715
+ ...(outcome !== "ORDER_REJECTED" ? { remediation: "Do NOT resubmit — check every leg's position (perp strategy quarantine; account positions per venue)" } : {}),
2716
+ }));
2717
+ }
1476
2718
  }
1477
2719
  else {
1478
2720
  console.log(chalk.cyan.bold("\nResults:\n"));
1479
2721
  for (const r of legResults) {
1480
2722
  const icon = r.status === "filled" ? chalk.green("OK") :
1481
2723
  r.status === "rolled_back" ? chalk.yellow("ROLLBACK") :
1482
- chalk.red("FAIL");
1483
- console.log(` ${icon} ${r.leg.exchange.padEnd(14)} ${r.leg.side} ${r.leg.symbol} x ${r.leg.size}${r.error ? ` — ${r.error}` : ""}`);
2724
+ r.status === "skipped" ? chalk.gray("SKIP") :
2725
+ r.status === "unknown" ? chalk.red.bold("UNKNOWN (quarantined)") :
2726
+ r.status === "rollback_failed" ? chalk.red.bold("ROLLBACK FAILED (quarantined)") :
2727
+ chalk.red("FAIL");
2728
+ const fill = r.filledSize !== null && r.filledSize !== r.leg.size ? ` (filled ${r.filledSize})` : "";
2729
+ console.log(` ${icon} ${r.leg.exchange.padEnd(14)} ${r.leg.side} ${r.leg.symbol} x ${r.leg.size}${fill}${r.error ? ` — ${r.error}` : ""}`);
1484
2730
  }
1485
- console.log(`\n Total: ${legs.length} Filled: ${filled.length} Failed: ${failed.length} Rolled back: ${legResults.filter(r => r.status === "rolled_back").length}`);
2731
+ console.log(`\n Total: ${legs.length} Filled: ${legResults.filter(r => r.status === "filled").length} Failed: ${failed.length} Unknown: ${unknown.length} Rolled back: ${legResults.filter(r => r.status === "rolled_back").length}`);
2732
+ if (unknown.length > 0)
2733
+ console.log(chalk.red(" An unconfirmed leg was quarantined — do NOT resubmit; check the positions (perp strategy quarantine)."));
1486
2734
  }
1487
2735
  });
1488
2736
  });