perp-cli 0.12.15 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +278 -31
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +286 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +74 -84
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +316 -337
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1383 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +157 -682
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +14 -0
  287. package/dist/landing.js +36 -0
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +338 -134
  348. package/skills/perp-cli/references/agent-operations.md +181 -92
  349. package/skills/perp-cli/references/commands.md +475 -132
  350. package/skills/perp-cli/references/strategies.md +44 -38
  351. package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
  352. package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +33 -16
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -0,0 +1,798 @@
1
+ /**
2
+ * QFEX Trade WebSocket transport (design §14.2 as amended by §14.12 #2, #3, #4, #11, #13).
3
+ *
4
+ * The venue has no request id. Correlation therefore rests on four rules:
5
+ * 1. ONE command at a time per connection (FIFO) — reads share the queue.
6
+ * 2. An order is recognized by its GENERATED client order id only; every frame is ignored
7
+ * until an order_response for that id arrives (ACK or rejection), then the order is pinned
8
+ * to the `order_id` of that frame. A user-supplied id is never used (ids are not unique on
9
+ * the venue).
10
+ * 3. The "sent" boundary is the call to `ws.send()`: a socket that is not OPEN before it =
11
+ * NOT SENT (definite). Anything that goes wrong after it — send callback error, close,
12
+ * timeout, ServerError, an error frame without a matching echo — is an UNKNOWN outcome
13
+ * (ORDER_PENDING). Nothing is ever re-sent or replayed.
14
+ * 4. After any timeout / unknown outcome the connection is destroyed, so a late frame can
15
+ * never meet the next command. The next command opens a fresh, re-authenticated,
16
+ * re-subscribed connection.
17
+ *
18
+ * Status is never inferred from silence: a GTC / ALO order is returned as acknowledged
19
+ * (pending) right after its ACK; "open" comes only from a get_user_orders row.
20
+ *
21
+ * cancel_on_disconnect is NOT in the message whitelist: this transport cannot send it.
22
+ * (A different connection of the same user that enabled it — e.g. the official CLI — still
23
+ * cancels all of the account's orders when THAT connection drops.)
24
+ *
25
+ * Idle: between commands the socket is unref()'d so it never keeps the process alive, and it
26
+ * is closed after `idleCloseMs`. The server keeps an authenticated connection alive with
27
+ * WebSocket ping frames (docs authenticate page); `ws` answers them automatically.
28
+ */
29
+ import { WebSocket } from "ws";
30
+ import { PerpError, extractErrorMessage, orderNotSent, orderOutcomeUnknown } from "../../errors.js";
31
+ import { acquireRate } from "../rate-limiter.js";
32
+ import { qfexErrFrameError, qfexErrIsDefiniteRefusal, qfexOrderStatusError, qfexStatusClass, } from "./errors.js";
33
+ import { QFEX_USER_ID_IS_ACCOUNT, decimalsEqual, fromWireNumber, isZeroDecimal, qfexNonce, qfexWsAuthMessage, subDecimals, sumDecimals, } from "./signing.js";
34
+ export const QFEX_TIMEOUTS = {
35
+ connectMs: 10_000, authMs: 10_000, subscribeMs: 10_000, ackMs: 5_000, immediateFinalMs: 10_000, postOnlyFinalMs: 3_000,
36
+ fillsMs: 1_000, cancelMs: 5_000, readMs: 10_000,
37
+ };
38
+ /** Idle connection lifetime for long-lived processes (MCP server, bots). */
39
+ export const QFEX_IDLE_CLOSE_MS = 30_000;
40
+ const CHANNELS = ["order_responses", "fills", "stop_orders"];
41
+ /**
42
+ * Every message type this transport may send. `cancel_on_disconnect` is deliberately absent
43
+ * (unit-test pinned): enabling it would cancel ALL of the account's orders when a one-shot
44
+ * CLI connection closes.
45
+ */
46
+ export const QFEX_SENDABLE_TYPES = new Set([
47
+ "auth", "subscribe", "add_order", "cancel_order", "modify_order", "cancel_all_orders", "cancel_stop_order", "get_user_orders",
48
+ ]);
49
+ const MUTATING_TYPES = new Set(["add_order", "cancel_order", "modify_order", "cancel_all_orders", "cancel_stop_order"]);
50
+ /** UNVERIFIED which messages draw on the venue's cancel bucket (Q14); cancels are kept on their own local bucket. */
51
+ const CANCEL_TYPES = new Set(["cancel_order", "cancel_all_orders", "cancel_stop_order"]);
52
+ function isRecord(v) {
53
+ return v !== null && typeof v === "object" && !Array.isArray(v);
54
+ }
55
+ /** `order_response` payload of a frame, or null. Only frames with the fields correlation needs qualify. */
56
+ export function orderResponseOf(frame) {
57
+ const r = frame.order_response;
58
+ if (!isRecord(r) || typeof r.order_id !== "string" || typeof r.status !== "string")
59
+ return null;
60
+ return r;
61
+ }
62
+ export function fillResponseOf(frame) {
63
+ const r = frame.fill_response;
64
+ if (!isRecord(r) || typeof r.order_id !== "string" || typeof r.trade_id !== "string")
65
+ return null;
66
+ return r;
67
+ }
68
+ /**
69
+ * Error frame in any documented shape (design §14.12 #11):
70
+ * { "err": { error_code, message, incoming_message } } /websocket/errors + AsyncAPI
71
+ * { "type": "Err", error_code, message, incoming_message } /websocket/rate
72
+ * { "type": "error", error_code, message } the docs' own client samples
73
+ * An absent or empty (`{}`) incoming_message is "no echo".
74
+ */
75
+ export function errFrameOf(frame) {
76
+ let body = null;
77
+ if (isRecord(frame.err))
78
+ body = frame.err;
79
+ else if ((frame.type === "Err" || frame.type === "error") && typeof frame.error_code === "string")
80
+ body = frame;
81
+ if (body === null || typeof body.error_code !== "string")
82
+ return null;
83
+ const inc = body.incoming_message;
84
+ const echo = isRecord(inc) && typeof inc.type === "string"
85
+ ? { type: inc.type, params: isRecord(inc.params) ? inc.params : {} }
86
+ : null;
87
+ return { errorCode: body.error_code, message: typeof body.message === "string" ? body.message : null, echo };
88
+ }
89
+ /** Auth outcome frame: both documented success shapes are accepted (§14.12 #11). */
90
+ function authVerdictOf(frame) {
91
+ if (typeof frame.authenticated === "boolean")
92
+ return frame.authenticated;
93
+ if (frame.type === "auth" && typeof frame.result === "string")
94
+ return frame.result === "success";
95
+ return null;
96
+ }
97
+ function subscribedTo(frame, channel) {
98
+ const s = frame.subscribed;
99
+ if (s === channel)
100
+ return true;
101
+ if (Array.isArray(s) && s.includes(channel))
102
+ return true;
103
+ // MDS-style confirmation: { type: "subscribed", channels: [...] }
104
+ return frame.type === "subscribed" && Array.isArray(frame.channels) && frame.channels.includes(channel);
105
+ }
106
+ /** Remediation of an order whose outcome is unknown: the status lookup by generated client id. */
107
+ export function qfexPendingRemediation(spec) {
108
+ const sym = spec.symbol === null ? "<symbol>" : spec.symbol.replace(/-USD$/, "");
109
+ const check = spec.clientOrderId !== null
110
+ ? `perp trade status c:${spec.clientOrderId} --symbol ${sym} -e qfex`
111
+ : "perp account orders -e qfex / perp account positions -e qfex";
112
+ return `Do NOT resubmit — the request may have been applied. Check: ${check}`;
113
+ }
114
+ export class QfexTradeSession {
115
+ opts;
116
+ conn = null;
117
+ queue = Promise.resolve();
118
+ idleTimer = null;
119
+ inFlight = null;
120
+ /** Number of connections opened by this session (diagnostics / tests). */
121
+ connectCount = 0;
122
+ constructor(opts) {
123
+ this.opts = opts;
124
+ }
125
+ // ── Connection ──
126
+ fail(conn, w) {
127
+ const waiter = conn.waiter;
128
+ conn.waiter = null;
129
+ if (waiter !== null)
130
+ waiter(w);
131
+ }
132
+ nextFrame(conn, deadlineAt) {
133
+ const buffered = conn.buffer.shift();
134
+ if (buffered !== undefined)
135
+ return Promise.resolve({ kind: "frame", frame: buffered });
136
+ if (conn.closed !== null)
137
+ return Promise.resolve({ kind: "closed", reason: conn.closed });
138
+ const ms = deadlineAt - Date.now();
139
+ if (ms <= 0)
140
+ return Promise.resolve({ kind: "timeout" });
141
+ return new Promise(resolve => {
142
+ const timer = setTimeout(() => { conn.waiter = null; resolve({ kind: "timeout" }); }, ms);
143
+ conn.waiter = w => { clearTimeout(timer); resolve(w); };
144
+ });
145
+ }
146
+ /** Drop the connection without a closing handshake; its late frames go nowhere. */
147
+ destroy() {
148
+ const conn = this.conn;
149
+ this.conn = null;
150
+ if (this.idleTimer !== null) {
151
+ clearTimeout(this.idleTimer);
152
+ this.idleTimer = null;
153
+ }
154
+ if (conn === null)
155
+ return;
156
+ conn.ws.removeAllListeners();
157
+ conn.ws.on("error", () => { });
158
+ conn.ws.terminate();
159
+ if (conn.closed === null)
160
+ conn.closed = "destroyed";
161
+ this.fail(conn, { kind: "closed", reason: "destroyed" });
162
+ }
163
+ /** Close the connection (normal closure). Safe to call at any time; queued after the running command. */
164
+ async close() {
165
+ // Through the FIFO queue: a command queued while an earlier one runs must never have its
166
+ // socket closed under it — the close runs strictly between commands.
167
+ await this.enqueue(async () => {
168
+ const conn = this.conn;
169
+ this.conn = null;
170
+ if (this.idleTimer !== null) {
171
+ clearTimeout(this.idleTimer);
172
+ this.idleTimer = null;
173
+ }
174
+ if (conn !== null && conn.closed === null)
175
+ conn.ws.close(1000);
176
+ });
177
+ }
178
+ setRef(conn, ref) {
179
+ // `ws` exposes the underlying net.Socket as `_socket`; (un)ref decides whether an idle
180
+ // connection keeps the process alive.
181
+ const sock = conn.ws._socket;
182
+ if (sock === undefined || sock === null)
183
+ return;
184
+ if (ref)
185
+ sock.ref();
186
+ else
187
+ sock.unref();
188
+ }
189
+ /** true when nothing owned by this session keeps the event loop alive. */
190
+ isIdleUnref() {
191
+ const conn = this.conn;
192
+ if (conn === null)
193
+ return true;
194
+ const sock = conn.ws._socket;
195
+ const socketRef = sock?._handle?.hasRef?.() === true;
196
+ const timerRef = this.idleTimer !== null && this.idleTimer.hasRef();
197
+ return this.inFlight === null && !socketRef && !timerRef;
198
+ }
199
+ sendRaw(conn, msg) {
200
+ if (!QFEX_SENDABLE_TYPES.has(msg.type)) {
201
+ throw new PerpError("INVALID_PARAMS", `QFEX transport refuses to send message type "${msg.type}"`);
202
+ }
203
+ return new Promise((resolve, reject) => {
204
+ conn.ws.send(JSON.stringify(msg), err => (err ? reject(err) : resolve()));
205
+ });
206
+ }
207
+ notReady(code, message, remediation) {
208
+ return new PerpError(code, `QFEX trade connection: ${message}`, { exchange: "qfex", sent: false, remediation });
209
+ }
210
+ /** connect → auth → subscribe. Every failure here is BEFORE any command was sent. */
211
+ async open() {
212
+ const t = this.opts.timeouts;
213
+ const url = `${this.opts.url.replace(/\/+$/, "")}/?api_key=${encodeURIComponent(this.opts.credentials.publicKey)}`;
214
+ const ws = new WebSocket(url, { handshakeTimeout: t.connectMs });
215
+ const conn = { ws, buffer: [], waiter: null, closed: null };
216
+ this.connectCount += 1;
217
+ await new Promise((resolve, reject) => {
218
+ const timer = setTimeout(() => { ws.terminate(); reject(this.notReady("TIMEOUT", `connect timed out after ${t.connectMs}ms`, `Check network connectivity to ${this.opts.url}`)); }, t.connectMs);
219
+ ws.once("open", () => { clearTimeout(timer); resolve(); });
220
+ ws.once("error", e => { clearTimeout(timer); reject(this.notReady("EXCHANGE_UNREACHABLE", `connect failed: ${extractErrorMessage(e)}`, `Check network connectivity to ${this.opts.url}`)); });
221
+ });
222
+ ws.removeAllListeners("error");
223
+ ws.on("error", e => {
224
+ if (conn.closed === null)
225
+ conn.closed = `socket error: ${extractErrorMessage(e)}`;
226
+ this.fail(conn, { kind: "closed", reason: conn.closed });
227
+ });
228
+ ws.on("close", (code, reason) => {
229
+ if (conn.closed === null)
230
+ conn.closed = `closed by the venue (code ${code}${reason.length > 0 ? `: ${reason.toString()}` : ""})`;
231
+ if (this.conn === conn)
232
+ this.conn = null;
233
+ this.fail(conn, { kind: "closed", reason: conn.closed });
234
+ });
235
+ ws.on("message", data => {
236
+ let frame;
237
+ try {
238
+ frame = JSON.parse(data.toString());
239
+ }
240
+ catch {
241
+ frame = { unparsed: data.toString().slice(0, 500) };
242
+ }
243
+ const f = isRecord(frame) ? frame : { nonObject: frame };
244
+ if (conn.waiter !== null) {
245
+ const w = conn.waiter;
246
+ conn.waiter = null;
247
+ w({ kind: "frame", frame: f });
248
+ }
249
+ else {
250
+ conn.buffer.push(f);
251
+ }
252
+ });
253
+ try {
254
+ // auth
255
+ await this.sendRaw(conn, qfexWsAuthMessage(this.opts.credentials, this.opts.accountId, this.opts.builderCode, Date.now(), qfexNonce()));
256
+ const authDeadline = Date.now() + t.authMs;
257
+ for (;;) {
258
+ const w = await this.nextFrame(conn, authDeadline);
259
+ if (w.kind === "timeout")
260
+ throw this.notReady("TIMEOUT", `no authentication result within ${t.authMs}ms`, "Retry; if it persists check the system clock and the key (perp wallet agent verify qfex)");
261
+ if (w.kind === "closed")
262
+ throw this.notReady("SIGNATURE_FAILED", `closed during authentication (${w.reason})`, "Check the system clock, the key and the account id: perp wallet agent verify qfex");
263
+ const err = errFrameOf(w.frame);
264
+ if (err !== null) {
265
+ const e = qfexErrFrameError(err, "authentication");
266
+ const code = e.structured.code === "POLICY_DENIED" ? "POLICY_DENIED" : "SIGNATURE_FAILED";
267
+ throw this.notReady(code, `authentication refused: ${err.errorCode}${err.message !== null ? ` — ${err.message}` : ""}`, e.structured.remediation ?? "Check the system clock, the key and the account id: perp wallet agent verify qfex");
268
+ }
269
+ const verdict = authVerdictOf(w.frame);
270
+ if (verdict === true)
271
+ break;
272
+ if (verdict === false)
273
+ throw this.notReady("SIGNATURE_FAILED", "authentication refused", "Check the system clock, the key and the account id: perp wallet agent verify qfex");
274
+ }
275
+ // subscribe — one message per channel, each confirmed.
276
+ for (const channel of CHANNELS) {
277
+ await this.sendRaw(conn, { type: "subscribe", params: { channels: [channel] } });
278
+ const deadline = Date.now() + t.subscribeMs;
279
+ for (;;) {
280
+ const w = await this.nextFrame(conn, deadline);
281
+ if (w.kind === "timeout")
282
+ throw this.notReady("TIMEOUT", `no confirmation of the ${channel} subscription within ${t.subscribeMs}ms`, "Retry");
283
+ if (w.kind === "closed")
284
+ throw this.notReady("EXCHANGE_UNREACHABLE", `closed while subscribing to ${channel} (${w.reason})`, "Retry");
285
+ const err = errFrameOf(w.frame);
286
+ if (err !== null)
287
+ throw this.notReady("EXCHANGE_ERROR", `subscription to ${channel} refused: ${err.errorCode}${err.message !== null ? ` — ${err.message}` : ""}`, "Check the key's View orders permission in QFEX Developer Settings");
288
+ if (subscribedTo(w.frame, channel))
289
+ break;
290
+ }
291
+ }
292
+ }
293
+ catch (e) {
294
+ ws.removeAllListeners();
295
+ ws.on("error", () => { });
296
+ ws.terminate();
297
+ if (e instanceof PerpError)
298
+ throw e;
299
+ throw this.notReady("EXCHANGE_UNREACHABLE", `setup failed: ${extractErrorMessage(e)}`, "Retry");
300
+ }
301
+ return conn;
302
+ }
303
+ /** Authenticate only (key import): connect + auth + subscribe, then close. Sends no command. */
304
+ async verifyAuth() {
305
+ await this.enqueue(async () => {
306
+ const conn = await this.open();
307
+ this.conn = conn;
308
+ await this.closeNow(conn);
309
+ });
310
+ }
311
+ closeNow(conn) {
312
+ if (this.conn === conn)
313
+ this.conn = null;
314
+ if (conn.closed !== null)
315
+ return Promise.resolve();
316
+ return new Promise(resolve => {
317
+ const timer = setTimeout(() => { conn.ws.terminate(); resolve(); }, 2_000);
318
+ conn.ws.once("close", () => { clearTimeout(timer); resolve(); });
319
+ conn.ws.close(1000);
320
+ });
321
+ }
322
+ enqueue(run) {
323
+ const p = this.queue.then(run, run);
324
+ this.queue = p.catch(() => undefined);
325
+ return p;
326
+ }
327
+ // ── Command execution ──
328
+ onSigint = () => {
329
+ const spec = this.inFlight;
330
+ if (spec !== null) {
331
+ process.stderr.write(`\n[qfex] ORDER_PENDING: interrupted while ${spec.what} was in flight — its outcome is unknown. `
332
+ + `${qfexPendingRemediation(spec)}${spec.clientOrderId !== null ? ` (c:${spec.clientOrderId})` : ""}\n`);
333
+ }
334
+ // No other SIGINT handler → keep the default behaviour (exit 130).
335
+ if (process.listenerCount("SIGINT") <= 1)
336
+ process.exit(130);
337
+ };
338
+ /**
339
+ * Run one command: wait for the queue, connect if needed, send, then let `body` read frames.
340
+ * Throws PerpError. Mutating commands: not-sent failures are definite (ORDER_REJECTED "not
341
+ * sent"), post-send failures are ORDER_PENDING. The session is destroyed after any timeout,
342
+ * close or thrown non-definite error inside `body`.
343
+ */
344
+ transact(spec, body) {
345
+ const mutating = MUTATING_TYPES.has(spec.type);
346
+ return this.enqueue(async () => {
347
+ if (this.idleTimer !== null) {
348
+ clearTimeout(this.idleTimer);
349
+ this.idleTimer = null;
350
+ }
351
+ // ── before send: every failure is "not sent" ──
352
+ let conn;
353
+ try {
354
+ if (this.conn === null || this.conn.closed !== null)
355
+ this.conn = await this.open();
356
+ conn = this.conn;
357
+ this.setRef(conn, true);
358
+ const bucket = `qfex:${this.opts.network}:${CANCEL_TYPES.has(spec.type) ? "cancel" : "ws"}`;
359
+ await acquireRate(bucket, this.opts.rateLimit, spec.type);
360
+ conn.buffer.length = 0; // frames that arrived between commands belong to no command
361
+ if (conn.closed !== null || conn.ws.readyState !== WebSocket.OPEN) {
362
+ throw this.notReady("EXCHANGE_UNREACHABLE", `not open before sending ${spec.what} (${conn.closed ?? `readyState ${conn.ws.readyState}`})`, "Retry — nothing was sent");
363
+ }
364
+ }
365
+ catch (e) {
366
+ this.destroy();
367
+ throw mutating ? orderNotSent("qfex", spec.what, e) : e;
368
+ }
369
+ // ── send: from here on a mutating command's outcome is unknown unless the venue answers ──
370
+ let unknown = false;
371
+ this.inFlight = spec;
372
+ if (mutating)
373
+ process.on("SIGINT", this.onSigint);
374
+ const sentAt = Date.now();
375
+ try {
376
+ try {
377
+ await this.sendRaw(conn, { type: spec.type, params: spec.params });
378
+ }
379
+ catch (e) {
380
+ unknown = true;
381
+ if (mutating)
382
+ throw orderOutcomeUnknown("qfex", spec.what, new Error(`socket error while sending: ${extractErrorMessage(e)}`), qfexPendingRemediation(spec));
383
+ throw new PerpError("EXCHANGE_UNREACHABLE", `QFEX ${spec.what}: send failed: ${extractErrorMessage(e)}`, { exchange: "qfex" });
384
+ }
385
+ const io = {
386
+ next: async (deadlineAt, soft) => {
387
+ const w = await this.nextFrame(conn, deadlineAt);
388
+ if (w.kind === "closed" || (w.kind === "timeout" && soft !== true))
389
+ unknown = true;
390
+ return w;
391
+ },
392
+ };
393
+ return await body(io, sentAt);
394
+ }
395
+ catch (e) {
396
+ if (!(e instanceof PerpError) || e.structured.code === "ORDER_PENDING" || e.structured.code === "TIMEOUT" || e.structured.code === "EXCHANGE_UNREACHABLE")
397
+ unknown = true;
398
+ throw e;
399
+ }
400
+ finally {
401
+ this.inFlight = null;
402
+ if (mutating)
403
+ process.removeListener("SIGINT", this.onSigint);
404
+ if (unknown) {
405
+ this.destroy();
406
+ }
407
+ else if (this.conn === conn && conn.closed === null) {
408
+ this.setRef(conn, false);
409
+ this.idleTimer = setTimeout(() => { void this.close(); }, this.opts.idleCloseMs);
410
+ this.idleTimer.unref();
411
+ }
412
+ }
413
+ });
414
+ }
415
+ /** Unknown-outcome error of a mutating command (ORDER_PENDING, with the c:<id> lookup). */
416
+ pending(spec, reason, extra) {
417
+ const e = orderOutcomeUnknown("qfex", spec.what, new Error(reason), qfexPendingRemediation(spec));
418
+ return new PerpError("ORDER_PENDING", e.message, {
419
+ ...(e.structured.details ?? {}), ...extra, exchange: "qfex",
420
+ ...(spec.clientOrderId !== null ? { clientOrderId: spec.clientOrderId } : {}),
421
+ remediation: e.structured.remediation,
422
+ });
423
+ }
424
+ /**
425
+ * An error frame during a command. Read: the mapped error. Mutating: a definite refusal only
426
+ * when the venue echoed THIS request and the code is a refusal; otherwise unknown.
427
+ */
428
+ errOutcome(spec, err, echoKey) {
429
+ if (!MUTATING_TYPES.has(spec.type))
430
+ return qfexErrFrameError(err, spec.what);
431
+ const echoMatches = err.echo !== null && err.echo.type === spec.type
432
+ && (echoKey === null || err.echo.params[echoKey] === spec.params[echoKey]);
433
+ if (qfexErrIsDefiniteRefusal(err, echoMatches))
434
+ return qfexErrFrameError(err, spec.what);
435
+ const why = err.echo === null
436
+ ? `error frame ${err.errorCode} without a request echo — it cannot be attributed to this request`
437
+ : !echoMatches ? `error frame ${err.errorCode} echoing a different request` : `${err.errorCode}${err.message !== null ? `: ${err.message}` : ""}`;
438
+ return this.pending(spec, why, { venueCode: err.errorCode });
439
+ }
440
+ assertUser(spec, r, mismatches) {
441
+ if (typeof r.user_id !== "string" || r.user_id === this.opts.accountId)
442
+ return;
443
+ if (QFEX_USER_ID_IS_ACCOUNT === true) {
444
+ throw this.pending(spec, `order_response.user_id ${r.user_id} is not the session account ${this.opts.accountId}`, { alarm: "userIdMismatch", venueRaw: r });
445
+ }
446
+ if (!mismatches.includes(r.user_id))
447
+ mismatches.push(r.user_id);
448
+ }
449
+ // ── Orders ──
450
+ /**
451
+ * add_order. Resolves once the venue has answered for THIS order as far as the order type
452
+ * allows: `immediate` (IOC / FOK) waits for a terminal frame; a resting order returns right
453
+ * after its first correlated frame — except `postOnly` (ALO), which waits postOnlyFinalMs for
454
+ * the venue's CANCELLED frame (it would have crossed) before it is reported resting. Throws the mapped rejection (definite), or ORDER_PENDING
455
+ * when nothing correlated arrived (session destroyed).
456
+ */
457
+ addOrder(req) {
458
+ const clientOrderId = req.params.client_order_id;
459
+ const spec = { type: "add_order", params: req.params, what: `add_order ${req.params.symbol}`, clientOrderId, symbol: req.params.symbol };
460
+ const t = this.opts.timeouts;
461
+ return this.transact(spec, async (io, sentAt) => {
462
+ const frames = [];
463
+ const responses = [];
464
+ const fillFrames = [];
465
+ const userIdMismatches = [];
466
+ let orderId = null;
467
+ const qty = (n, what) => fromWireNumber(n, req.lotSize, what);
468
+ const result = (ended, terminal) => {
469
+ const byTrade = new Map();
470
+ for (const f of fillFrames)
471
+ if (f.order_id === orderId && !byTrade.has(f.trade_id))
472
+ byTrade.set(f.trade_id, f);
473
+ const fills = [...byTrade.values()];
474
+ return { orderId: orderId, clientOrderId, responses, fills, terminal, ended, frames, userIdMismatches };
475
+ };
476
+ // 1. First correlated frame: an order_response carrying OUR generated client id.
477
+ const ackDeadline = sentAt + t.ackMs;
478
+ let first = null;
479
+ while (first === null) {
480
+ const w = await io.next(ackDeadline);
481
+ if (w.kind !== "frame") {
482
+ throw this.pending(spec, w.kind === "timeout" ? `no order_response for client id ${clientOrderId} within ${t.ackMs}ms` : `connection lost before any order_response (${w.reason})`, { frames });
483
+ }
484
+ frames.push(w.frame);
485
+ const err = errFrameOf(w.frame);
486
+ if (err !== null)
487
+ throw this.errOutcome(spec, err, "client_order_id");
488
+ const fill = fillResponseOf(w.frame);
489
+ if (fill !== null) {
490
+ fillFrames.push(fill);
491
+ continue;
492
+ }
493
+ const r = orderResponseOf(w.frame);
494
+ if (r !== null && r.client_order_id === clientOrderId)
495
+ first = r;
496
+ }
497
+ this.assertUser(spec, first, userIdMismatches);
498
+ orderId = first.order_id;
499
+ responses.push(first);
500
+ const firstClass = qfexStatusClass(first.status);
501
+ if (firstClass === "reject")
502
+ throw qfexOrderStatusError(first.status, { what: spec.what, band: req.band }, first);
503
+ // 2. Terminal?
504
+ const isTerminal = (r) => {
505
+ const c = qfexStatusClass(r.status);
506
+ return c === "terminal" || (c === "fill" && isZeroDecimal(qty(r.quantity_remaining, "quantity_remaining")));
507
+ };
508
+ let terminal = isTerminal(first) ? first : null;
509
+ const postOnly = !req.immediate && req.postOnly === true;
510
+ if (terminal === null && !req.immediate && !postOnly)
511
+ return result("resting", null);
512
+ const finalDeadline = sentAt + (postOnly ? t.postOnlyFinalMs : t.immediateFinalMs);
513
+ while (terminal === null) {
514
+ const w = await io.next(finalDeadline);
515
+ // ALO: no terminal frame in its window = it was not cancelled for crossing — it rests.
516
+ if (w.kind !== "frame")
517
+ return result(w.kind !== "timeout" ? "closed" : postOnly ? "resting" : "timeout", null);
518
+ frames.push(w.frame);
519
+ const fill = fillResponseOf(w.frame);
520
+ if (fill !== null) {
521
+ fillFrames.push(fill);
522
+ continue;
523
+ }
524
+ const r = orderResponseOf(w.frame);
525
+ if (r === null || r.order_id !== orderId)
526
+ continue; // another order's event, an error for nothing we sent, or an unknown frame
527
+ this.assertUser(spec, r, userIdMismatches);
528
+ responses.push(r);
529
+ if (qfexStatusClass(r.status) === "reject") {
530
+ // A rejection after the ACK is "never traded" only when NOTHING says it filled: no fill
531
+ // frame, no earlier order_response with a fill status (e.g. PARTIALLY FILLED seen before
532
+ // its fill frame arrived) and no response — this one included — with less remaining
533
+ // than ordered. Otherwise it is the terminal frame of a partly filled order.
534
+ const responded = responses.some(x => qfexStatusClass(x.status) === "fill"
535
+ || !isZeroDecimal(subDecimals(qty(x.quantity, "quantity"), qty(x.quantity_remaining, "quantity_remaining"), "filled quantity")));
536
+ if (!responded && !fillFrames.some(f => f.order_id === orderId))
537
+ throw qfexOrderStatusError(r.status, { what: spec.what, band: req.band }, r);
538
+ terminal = r;
539
+ }
540
+ else if (isTerminal(r)) {
541
+ terminal = r;
542
+ }
543
+ }
544
+ // 3. Fill frames until they add up to quantity − quantity_remaining (soft wait).
545
+ const expected = subDecimals(qty(terminal.quantity, "quantity"), qty(terminal.quantity_remaining, "quantity_remaining"), "filled quantity");
546
+ const fillsDeadline = Date.now() + t.fillsMs;
547
+ for (;;) {
548
+ const got = sumDecimals(result("terminal", terminal).fills.map(f => qty(f.quantity, "fill quantity")));
549
+ if (decimalsEqual(got, expected))
550
+ break;
551
+ const w = await io.next(fillsDeadline, true);
552
+ if (w.kind !== "frame")
553
+ break;
554
+ frames.push(w.frame);
555
+ const fill = fillResponseOf(w.frame);
556
+ if (fill !== null)
557
+ fillFrames.push(fill);
558
+ }
559
+ return result("terminal", terminal);
560
+ });
561
+ }
562
+ /**
563
+ * cancel_order by ORDER ID (the adapter resolves client ids first: a client-id cancel would
564
+ * cancel every order sharing that id). Never throws for an unproven cancel — "unknown".
565
+ */
566
+ cancelOrder(symbol, orderId) {
567
+ return this.cancelById({ type: "cancel_order", params: { symbol, order_id: orderId, cancel_order_id_type: "order_id" }, what: `cancel_order ${orderId}`, clientOrderId: null, symbol }, "order_id", orderId);
568
+ }
569
+ /** cancel_stop_order (TAKE_PROFIT / STOP_LOSS rows of get_user_orders). */
570
+ cancelStopOrder(symbol, stopOrderId) {
571
+ return this.cancelById({ type: "cancel_stop_order", params: { stop_order_id: stopOrderId }, what: `cancel_stop_order ${stopOrderId}`, clientOrderId: null, symbol }, "stop_order_id", stopOrderId);
572
+ }
573
+ async cancelById(spec, echoKey, id) {
574
+ const t = this.opts.timeouts;
575
+ try {
576
+ return await this.transact(spec, async (io, sentAt) => {
577
+ const deadline = sentAt + t.cancelMs;
578
+ for (;;) {
579
+ const w = await io.next(deadline);
580
+ if (w.kind !== "frame") {
581
+ return { kind: "unknown", reason: w.kind === "timeout" ? `no cancel confirmation within ${t.cancelMs}ms` : `connection lost (${w.reason})` };
582
+ }
583
+ const err = errFrameOf(w.frame);
584
+ if (err !== null)
585
+ throw this.errOutcome(spec, err, echoKey);
586
+ const r = orderResponseOf(w.frame);
587
+ if (r === null || r.order_id !== id)
588
+ continue;
589
+ const c = qfexStatusClass(r.status);
590
+ if (r.status === "CANCELLED" || r.status === "CANCELLED_STP" || r.status === "IOC_CANCELLED" || r.status === "EXPIRED")
591
+ return { kind: "cancelled", frame: r };
592
+ if (c === "reject")
593
+ return { kind: "refused", status: r.status, frame: r };
594
+ if (c === "fill" && isZeroDecimal(fromWireNumber(r.quantity_remaining, "0.000000000001", "quantity_remaining"))) {
595
+ return { kind: "refused", status: "FILLED", frame: r };
596
+ }
597
+ }
598
+ });
599
+ }
600
+ catch (e) {
601
+ // ORDER_PENDING (unknown outcome after send) is reported per order, not thrown.
602
+ if (e instanceof PerpError && e.structured.code === "ORDER_PENDING")
603
+ return { kind: "unknown", reason: e.message };
604
+ throw e;
605
+ }
606
+ }
607
+ /**
608
+ * cancel_all_orders. The venue documents no completion signal: CANCELLED frames for the
609
+ * `expected` order ids are collected until all arrived or the window ends. What is not
610
+ * proven here is settled by the caller's verifying get_user_orders.
611
+ */
612
+ async cancelAll(symbol, expected, watch = expected) {
613
+ const params = symbol === null ? {} : { symbol };
614
+ const spec = { type: "cancel_all_orders", params, what: `cancel_all_orders ${symbol ?? "(all symbols)"}`, clientOrderId: null, symbol };
615
+ const t = this.opts.timeouts;
616
+ try {
617
+ return await this.transact(spec, async (io, sentAt) => {
618
+ const cancelled = new Set();
619
+ const frames = [];
620
+ const deadline = sentAt + t.cancelMs;
621
+ const done = () => [...expected].every(id => cancelled.has(id));
622
+ while (!done()) {
623
+ const w = await io.next(deadline);
624
+ if (w.kind !== "frame") {
625
+ return { cancelled: [...cancelled], frames, complete: false, note: w.kind === "timeout" ? `window of ${t.cancelMs}ms ended` : `connection lost (${w.reason})` };
626
+ }
627
+ frames.push(w.frame);
628
+ const err = errFrameOf(w.frame);
629
+ if (err !== null)
630
+ throw this.errOutcome(spec, err, null);
631
+ const r = orderResponseOf(w.frame);
632
+ // `watch` ⊇ expected: CANCELLED frames of other listed orders (stops — whether cancel_all
633
+ // also cancels them is unverified, Q8) are recorded as proof too, without being waited for.
634
+ if (r !== null && (expected.has(r.order_id) || watch.has(r.order_id)) && (r.status === "CANCELLED" || r.status === "CANCELLED_STP"))
635
+ cancelled.add(r.order_id);
636
+ }
637
+ return { cancelled: [...cancelled], frames, complete: true, note: null };
638
+ });
639
+ }
640
+ catch (e) {
641
+ if (e instanceof PerpError && e.structured.code === "ORDER_PENDING")
642
+ return { cancelled: [], frames: [], complete: false, note: e.message };
643
+ throw e;
644
+ }
645
+ }
646
+ /**
647
+ * modify_order (wire function; the adapter keeps amend gated until Q5 is verified).
648
+ * The reply carries a NEW order_id. It is accepted only when it is MODIFIED, carries the
649
+ * client id of the order being modified AND its symbol / side / type / price / quantity equal
650
+ * the request. A CANNOT_MODIFY_* / rejection frame is matched on the OLD order id.
651
+ */
652
+ modifyOrder(req) {
653
+ const spec = { type: "modify_order", params: req.params, what: `modify_order ${req.params.order_id}`, clientOrderId: req.clientOrderId, symbol: req.params.symbol };
654
+ const t = this.opts.timeouts;
655
+ const sameNumber = (a, b, step, what) => decimalsEqual(fromWireNumber(a, step, what), fromWireNumber(b, step, what));
656
+ return this.transact(spec, async (io, sentAt) => {
657
+ const deadline = sentAt + t.ackMs;
658
+ for (;;) {
659
+ const w = await io.next(deadline);
660
+ if (w.kind !== "frame")
661
+ throw this.pending(spec, w.kind === "timeout" ? `no MODIFIED order_response within ${t.ackMs}ms` : `connection lost (${w.reason})`, {});
662
+ const err = errFrameOf(w.frame);
663
+ if (err !== null)
664
+ throw this.errOutcome(spec, err, "order_id");
665
+ const r = orderResponseOf(w.frame);
666
+ if (r === null)
667
+ continue;
668
+ if (r.order_id === req.params.order_id && qfexStatusClass(r.status) === "reject") {
669
+ throw qfexOrderStatusError(r.status, { what: spec.what, band: null }, r);
670
+ }
671
+ if (r.status === "MODIFIED" && r.client_order_id === req.clientOrderId && r.symbol === req.params.symbol
672
+ && r.side === req.params.side && r.type === req.params.order_type
673
+ && sameNumber(r.price, req.params.price, req.tickSize, "price") && sameNumber(r.quantity, req.params.quantity, req.lotSize, "quantity")) {
674
+ return r;
675
+ }
676
+ }
677
+ });
678
+ }
679
+ // ── Reads ──
680
+ /** get_user_orders: every open order (stops included as TAKE_PROFIT / STOP_LOSS rows), all pages. */
681
+ async getUserOrders(symbol) {
682
+ const limit = 100;
683
+ const out = [];
684
+ for (let offset = 0;; offset += limit) {
685
+ const params = { limit, offset };
686
+ if (symbol !== null)
687
+ params.symbol = symbol;
688
+ const spec = { type: "get_user_orders", params, what: `get_user_orders${symbol !== null ? ` ${symbol}` : ""}`, clientOrderId: null, symbol };
689
+ const t = this.opts.timeouts;
690
+ const page = await this.transact(spec, async (io, sentAt) => {
691
+ const deadline = sentAt + t.readMs;
692
+ for (;;) {
693
+ const w = await io.next(deadline);
694
+ if (w.kind === "timeout")
695
+ throw new PerpError("TIMEOUT", `QFEX ${spec.what}: no all_orders_response within ${t.readMs}ms`, { exchange: "qfex" });
696
+ if (w.kind === "closed")
697
+ throw new PerpError("EXCHANGE_UNREACHABLE", `QFEX ${spec.what}: connection lost (${w.reason})`, { exchange: "qfex" });
698
+ const err = errFrameOf(w.frame);
699
+ if (err !== null)
700
+ throw this.errOutcome(spec, err, null);
701
+ const body = w.frame.all_orders_response;
702
+ if (!isRecord(body))
703
+ continue;
704
+ if (body.orders !== null && body.orders !== undefined && !Array.isArray(body.orders)) {
705
+ throw new PerpError("EXCHANGE_ERROR", `QFEX ${spec.what}: all_orders_response.orders is not a list`, { exchange: "qfex" });
706
+ }
707
+ // A JSON null list is the venue's encoding of "no rows".
708
+ return (Array.isArray(body.orders) ? body.orders : []);
709
+ }
710
+ });
711
+ out.push(...page);
712
+ if (page.length < limit)
713
+ return out;
714
+ }
715
+ }
716
+ }
717
+ // ── Session registry (one per network + key + account + builder code) ──
718
+ const sessions = new Map();
719
+ /** The process-wide session for this identity. A different builder code (or --no-builder) is a different session. */
720
+ export function qfexTradeSession(opts) {
721
+ const key = `${opts.network}|${opts.credentials.publicKey}|${opts.accountId}|${opts.builderCode ?? ""}`;
722
+ let s = sessions.get(key);
723
+ if (s === undefined) {
724
+ s = new QfexTradeSession(opts);
725
+ sessions.set(key, s);
726
+ }
727
+ return s;
728
+ }
729
+ function entries(v) {
730
+ return Array.isArray(v) ? v.filter(isRecord) : [];
731
+ }
732
+ /** Parse one `market_stats` frame. */
733
+ export function parseMarketStats(frame) {
734
+ const marks = new Map();
735
+ for (const m of entries(frame.mark_prices)) {
736
+ if (typeof m.symbol !== "string" || typeof m.price !== "string" || typeof m.time !== "string")
737
+ continue;
738
+ const time = Date.parse(m.time);
739
+ if (!Number.isFinite(time))
740
+ continue;
741
+ marks.set(m.symbol, { price: m.price, time });
742
+ }
743
+ const openInterest = new Map();
744
+ for (const o of entries(frame.open_interests)) {
745
+ if (typeof o.symbol === "string" && o.symbol !== "" && typeof o.open_interest === "string")
746
+ openInterest.set(o.symbol, o.open_interest);
747
+ }
748
+ const volumeNotional = new Map();
749
+ for (const v of entries(frame.daily_volumes_notional)) {
750
+ if (typeof v.symbol === "string" && typeof v.volume === "string")
751
+ volumeNotional.set(v.symbol, v.volume);
752
+ }
753
+ return { marks, openInterest, volumeNotional, frame };
754
+ }
755
+ /** How long a one-shot MDS snapshot may take (connect + first market_stats frame). */
756
+ export const QFEX_MDS_TIMEOUT_MS = 3_000;
757
+ /**
758
+ * One `market_stats` snapshot from the public MDS WebSocket, then close. `symbol` = one venue
759
+ * symbol (order reference prices) or "*" (scans / listings). Throws PRICE_STALE when no
760
+ * snapshot arrives in time — never substitutes index / last / mid.
761
+ */
762
+ export function fetchQfexMarketStats(mdsUrl, symbol, timeoutMs) {
763
+ return new Promise((resolve, reject) => {
764
+ const ws = new WebSocket(mdsUrl, { handshakeTimeout: timeoutMs });
765
+ let done = false;
766
+ const finish = (err, snap) => {
767
+ if (done)
768
+ return;
769
+ done = true;
770
+ clearTimeout(timer);
771
+ ws.removeAllListeners();
772
+ ws.on("error", () => { });
773
+ ws.terminate();
774
+ if (snap !== null)
775
+ resolve(snap);
776
+ else
777
+ reject(err);
778
+ };
779
+ const stale = (why) => new PerpError("PRICE_STALE", `QFEX market data (${symbol}): ${why}`, {
780
+ exchange: "qfex", remediation: `Retry shortly; the mark price comes only from ${mdsUrl} (market_stats) — no other price is substituted`,
781
+ });
782
+ const timer = setTimeout(() => finish(stale(`no market_stats snapshot within ${timeoutMs}ms`), null), timeoutMs);
783
+ ws.on("open", () => ws.send(JSON.stringify({ type: "subscribe", channels: ["market_stats"], symbols: [symbol] })));
784
+ ws.on("error", e => finish(stale(`connection failed: ${extractErrorMessage(e)}`), null));
785
+ ws.on("close", () => finish(stale("connection closed before a snapshot arrived"), null));
786
+ ws.on("message", data => {
787
+ let frame;
788
+ try {
789
+ frame = JSON.parse(data.toString());
790
+ }
791
+ catch {
792
+ return; /* not a snapshot; keep waiting until the timeout */
793
+ }
794
+ if (isRecord(frame) && frame.type === "market_stats")
795
+ finish(null, parseMarketStats(frame));
796
+ });
797
+ });
798
+ }