perp-cli 0.12.17 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +247 -27
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +286 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +70 -92
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +316 -337
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1383 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +156 -684
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +7 -2
  287. package/dist/landing.js +19 -3
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +142 -55
  348. package/skills/perp-cli/references/agent-operations.md +110 -56
  349. package/skills/perp-cli/references/commands.md +266 -71
  350. package/skills/perp-cli/references/strategies.md +34 -31
  351. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  352. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -0,0 +1,1383 @@
1
+ /**
2
+ * QFEX adapter — native TradingV2 + legacy ExchangeAdapter shim
3
+ * (design §14.3–§14.6 as amended by §14.12; §10.7, §10.10, §13).
4
+ *
5
+ * Signer: imported HMAC API key only (QfexAgentMeta → ~/.perp/qfex-agents/<publicKey>.json).
6
+ * Orders go over the Trade WebSocket (ws-transport.ts); account reads, leverage and transfers
7
+ * over REST; deposit address / withdraw over the banker host.
8
+ *
9
+ * Order entry:
10
+ * - market orders: IOC LIMIT at alignProtectionPrice(slippagePrice(mark, side, bps), tick,
11
+ * side) — the venue's native MARKET has no user bound. The reference is the MDS
12
+ * market_stats mark of THAT symbol, refused (PRICE_STALE) when its own `time` is older than
13
+ * QFEX_MARK_MAX_AGE_MS; nothing else is substituted.
14
+ * - limit: GTC | IOC | FOK as LIMIT; ALO as order_type ALO + GTC.
15
+ * - precision, bands, status, allowed order types / TIFs: /refdata only. Off-tick prices and
16
+ * out-of-band prices are refused, never rounded or clamped; sizes are floored to the lot.
17
+ * - client order ids are generated (UUID); a GTC / ALO order is returned as acknowledged
18
+ * (pending) after its ACK — "open" comes only from getOrderStatus (a get_user_orders row).
19
+ * An ALO order the venue cancels right after the ACK (it would cross) is ORDER_REJECTED
20
+ * { reason: "postOnlyWouldCross" }.
21
+ * - IOC / FOK / market: final only on a venue terminal frame; a terminal frame with zero
22
+ * fill is ORDER_REJECTED { reason: "iocNoFill" }; no terminal frame in time → pending.
23
+ *
24
+ * - reduce-only (close / reduce / flatten, reduce-only IOC limits): `reduce_only: true`, and —
25
+ * because the venue accepts a reduce-only order larger than the position (Q2, signing.ts) —
26
+ * the adapter's own rule before every send (requireReducible) and an echo check after it.
27
+ *
28
+ * Refused for now (uniform CAPABILITY_UNSUPPORTED, see signing.ts constants):
29
+ * - a RESTING reduce-only order (GTC / ALO; capability reduceOnlyTif, and so `trade scale-tp`)
30
+ * until QFEX_RESTING_REDUCE_ONLY_VERIFIED;
31
+ * - unattended automation — bots, plans, TWAP, DCA, grid, funding-arb, trailing stop,
32
+ * background jobs (automationGate) — until QFEX_AUTOMATION_VERIFIED;
33
+ * - stop-limit and stops that open (the venue offers neither); reduce-only stop-MARKET orders
34
+ * (TAKE_PROFIT / STOP_LOSS, Q21 passed — placeStop) are placed;
35
+ * - amend of a reduce-only order (a plain LIMIT / ALO is amended — Q5 passed);
36
+ * - attached TP/SL (Q7);
37
+ * - markets not quoted in USD (sizing / risk / notional code assumes USD);
38
+ * - user-supplied client order ids (not unique on the venue).
39
+ *
40
+ * Funding: unverified (Q4) → getVenueMarkets()/getMarkets() report fundingRate null.
41
+ */
42
+ import { randomUUID } from "node:crypto";
43
+ import { PerpError, orderNotSent } from "../../errors.js";
44
+ import { assertWithdrawAllowed, forgetWithdrawAttempt, recordWithdrawAttempt } from "../../withdraw-gate.js";
45
+ import { builderGateOf, NO_BUILDER } from "../builder-fee.js";
46
+ import { alignProtectionPrice, floorSize, requireOnTick, slippagePrice, subDecimal } from "../precision.js";
47
+ import { unsupported, validateLegacyTif, validateOrderRequest } from "../trading-v2.js";
48
+ import { requireCapabilities } from "../venue.js";
49
+ import { parseOrderRef, toLegacyMarketInfo } from "../venue-types.js";
50
+ import { QFEX_BANKER_NETWORK, QFEX_ENDPOINTS } from "../venues/qfex.js";
51
+ import { loadQfexCredentials, QFEX_IMPORT_REMEDIATION } from "./agent.js";
52
+ import { QfexClient } from "./client.js";
53
+ import { qfexStatusClass } from "./errors.js";
54
+ import { fetchQfexInstruments, qfexPlainSymbol, qfexPublicData, qfexTradable, qfexVenueSymbol, } from "./public.js";
55
+ import { QFEX_AUTOMATION_VERIFIED, QFEX_MODIFY_KEEPS_CLIENT_ID, QFEX_REDUCE_ONLY_WIRE, QFEX_STOP_CLIENT_ID_ECHO, decimalsEqual, fromWireNumber, isZeroDecimal, qfexAutomationGate, qfexReduceOnlyGate, reduceOnlyWireValue, subDecimals, sumDecimals, toWireNumber, weightedAveragePrice, } from "./signing.js";
56
+ import { QFEX_IDLE_CLOSE_MS, QFEX_MDS_TIMEOUT_MS, QFEX_TIMEOUTS, fetchQfexMarketStats, qfexPendingRemediation, qfexTradeSession, } from "./ws-transport.js";
57
+ /** A mark price entry older than this (its own venue `time`) is not used as an order reference. */
58
+ export const QFEX_MARK_MAX_AGE_MS = 10_000;
59
+ /** /refdata cache: bands move with the session, so it is short. */
60
+ const INSTRUMENTS_TTL_MS = 30_000;
61
+ /** settledByHistory: historic-orders reads (and the wait between them) for an order that left the open list. */
62
+ const HISTORY_SETTLE_TRIES = 4;
63
+ const HISTORY_SETTLE_DELAY_MS = 1_000;
64
+ /**
65
+ * A stop order's history verdict. Observed 2026-10-13: stop orders never appear in
66
+ * /user/historic-orders and /user/trade rows carry no order id, so a vanished stop cannot be
67
+ * proven cancelled — "not indexed" becomes a reason that says so (the cancel stays pending).
68
+ */
69
+ /** settledByHistory verdict prefix for a row that partially filled and then was cancelled (never reported as "filled"). */
70
+ const QFEX_PARTIAL_PREFIX = "partially filled";
71
+ function stopHistoryVerdict(settled) {
72
+ return settled === "not indexed"
73
+ ? "no longer listed open, but QFEX keeps no record of stop orders (not in /user/historic-orders), so the cancel cannot be proven — if perp account orders/positions -e qfex both show nothing for the symbol, it is gone"
74
+ : settled;
75
+ }
76
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
77
+ const STOP_TYPES = new Set(["TAKE_PROFIT", "STOP_LOSS"]);
78
+ const KLINE_RESOLUTION = {
79
+ "1m": "1MIN", "5m": "5MINS", "15m": "15MINS", "30m": "30MINS", "1h": "1HOUR", "4h": "4HOURS", "1d": "1DAY",
80
+ };
81
+ /** Client order id sent to the venue: a fresh UUID (the format the venue itself generates). */
82
+ export function qfexClientOrderId() {
83
+ return randomUUID();
84
+ }
85
+ /**
86
+ * JSON number → plain decimal string (never exponent notation). The venue sends decimals as
87
+ * JSON numbers, i.e. binary doubles, and some carry the venue's own float noise (live
88
+ * 2026-10-05 `/user/positions` average_price 4142.099999999999 for a 4142.1 entry). A double
89
+ * represents every decimal of up to 15 significant digits exactly-once (IEEE 754 binary64
90
+ * DBL_DIG = 15), so the value is read at 15 significant digits: noise in the 16th/17th digit
91
+ * is dropped and no real venue decimal is altered. Fields with a known grid (order price /
92
+ * quantity) are quantized to the instrument tick / lot instead (fromWireNumber).
93
+ */
94
+ export function plainNumber(n, what) {
95
+ if (typeof n !== "number" || !Number.isFinite(n))
96
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: ${what} is not a number (${JSON.stringify(n)})`);
97
+ if (Number.isInteger(n))
98
+ return String(n);
99
+ return Number(n.toPrecision(15)).toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
100
+ }
101
+ function listOf(v, what) {
102
+ if (v === null || v === undefined)
103
+ return []; // the venue encodes an empty list as JSON null
104
+ if (!Array.isArray(v))
105
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: ${what} is not a list`);
106
+ return v;
107
+ }
108
+ function execution(method, partial) {
109
+ return {
110
+ method,
111
+ slippageBps: partial.slippageBps ?? null,
112
+ slippageSource: partial.slippageSource ?? null,
113
+ protectionPrice: partial.protectionPrice ?? null,
114
+ referencePrice: partial.referencePrice ?? null,
115
+ tifRequested: partial.tifRequested ?? null,
116
+ tifSent: partial.tifSent ?? null,
117
+ expiresAt: partial.expiresAt ?? null,
118
+ builderFee: partial.builderFee ?? null,
119
+ notes: partial.notes ?? [],
120
+ };
121
+ }
122
+ /** a < b for plain decimals. */
123
+ function lessThan(a, b) {
124
+ return subDecimal(a, b).startsWith("-");
125
+ }
126
+ /**
127
+ * The frame of a reduce-only order that fails the echo check, or null when the order is confirmed
128
+ * as reduce-only. The rule, from production frames (2026-10-05, signing.ts Q2):
129
+ * - the ACK and every other NON-FILL status frame (CANCELLED, IOC_CANCELLED, MODIFIED, a
130
+ * post-ACK rejection, …) describes the ORDER and must carry `reduce_only: true`;
131
+ * - a fill-carrying frame (status FILLED) describes the TRADE and is NOT judged: the venue sends
132
+ * `reduce_only: false` on it for an order whose ACK and CANCELLED frames both carry `true`;
133
+ * - an order with no non-fill frame at all has nothing that confirms the flag, so its frames are
134
+ * judged as they are (a missing confirmation is never read as confirmed).
135
+ */
136
+ export function reduceOnlyUnconfirmedFrame(responses) {
137
+ const orderFrames = responses.filter(r => qfexStatusClass(r.status) !== "fill");
138
+ const judged = orderFrames.length > 0 ? orderFrames : responses;
139
+ return judged.find(r => r.reduce_only !== true) ?? null;
140
+ }
141
+ /**
142
+ * add_order params. `take_profit` / `stop_loss` are never sent (no attached TP/SL). `reduce_only`
143
+ * is sent only for a reduce-only order, in the verified wire format — with the format unset a
144
+ * reduce-only order is refused here.
145
+ */
146
+ export function buildAddOrderParams(o, reduceOnlyWire) {
147
+ const params = {
148
+ symbol: o.venueSymbol,
149
+ side: o.side === "buy" ? "BUY" : "SELL",
150
+ order_type: o.orderType,
151
+ order_time_in_force: o.tif,
152
+ quantity: toWireNumber(o.quantity, "quantity"),
153
+ price: toWireNumber(o.price, "price"),
154
+ client_order_id: o.clientOrderId,
155
+ };
156
+ if (o.reduceOnly) {
157
+ const gate = qfexReduceOnlyGate(reduceOnlyWire);
158
+ if (gate !== null)
159
+ throw gate;
160
+ params.reduce_only = reduceOnlyWireValue(reduceOnlyWire, true);
161
+ }
162
+ return params;
163
+ }
164
+ export class QfexAdapter {
165
+ name = "qfex";
166
+ aliases = ["qf"];
167
+ venue;
168
+ network;
169
+ caps;
170
+ client;
171
+ meta;
172
+ credentials;
173
+ instruments = null;
174
+ /**
175
+ * Builder-code gate (settings builder.qfex; no approval step); null = not configured. Set by
176
+ * the venue definition's create — the adapter never reads settings.
177
+ */
178
+ builderFee = null;
179
+ /**
180
+ * settings.withdraw.qfex, set by the venue definition's create; null = not configured →
181
+ * withdraw() refuses. Enforced inside withdraw() so no caller can skip it.
182
+ */
183
+ withdrawGate = null;
184
+ constructor(def, ctx) {
185
+ this.venue = def;
186
+ this.network = ctx.network;
187
+ this.caps = requireCapabilities(def);
188
+ let meta = null;
189
+ let credentials = null;
190
+ if (!ctx.readOnly) {
191
+ if (ctx.noAgent) {
192
+ throw new PerpError("NO_SIGNER_AVAILABLE", "QFEX signs only with an imported API key; --no-agent leaves no signer", {
193
+ remediation: `Drop --no-agent, or import a key: ${QFEX_IMPORT_REMEDIATION}`,
194
+ });
195
+ }
196
+ const agent = ctx.credentials.agent;
197
+ if (agent === null || !("kind" in agent) || agent.kind !== "qfex") {
198
+ throw new PerpError("NO_SIGNER_AVAILABLE", "No QFEX API key imported", {
199
+ remediation: `Create a key in QFEX Developer Settings, then: ${QFEX_IMPORT_REMEDIATION}`,
200
+ });
201
+ }
202
+ if (agent.network !== ctx.network) {
203
+ throw new PerpError("AGENT_NOT_REGISTERED", `QFEX API key ${agent.publicKey} was imported for ${String(agent.network)}, not ${ctx.network}`, {
204
+ remediation: `Import a ${ctx.network} key: perp${ctx.network === "testnet" ? " -n testnet" : ""} wallet agent import qfex`,
205
+ });
206
+ }
207
+ if (typeof agent.accountId !== "string" || !UUID_RE.test(agent.accountId)) {
208
+ throw new PerpError("AGENT_NOT_REGISTERED", `QFEX API key ${agent.publicKey} has no bound account id`, {
209
+ remediation: `Re-import it so the account is resolved and confirmed: ${QFEX_IMPORT_REMEDIATION} --replace`,
210
+ });
211
+ }
212
+ meta = agent;
213
+ credentials = loadQfexCredentials(agent.publicKey);
214
+ }
215
+ this.meta = meta;
216
+ this.credentials = credentials;
217
+ const ep = QFEX_ENDPOINTS[ctx.network];
218
+ this.client = new QfexClient({
219
+ restUrl: ep.rest, bankerUrl: ep.banker, network: ctx.network, credentials,
220
+ accountId: meta === null ? null : meta.accountId,
221
+ });
222
+ }
223
+ /** The account UUID every request of this adapter acts as. */
224
+ get accountId() {
225
+ return this.requireMeta().accountId;
226
+ }
227
+ requireMeta() {
228
+ if (this.meta === null) {
229
+ throw new PerpError("NO_SIGNER_AVAILABLE", "QFEX: this action needs an imported API key (read-only adapter)", { remediation: QFEX_IMPORT_REMEDIATION });
230
+ }
231
+ return this.meta;
232
+ }
233
+ // ── Helpers ──
234
+ /** The builder decision for the connection (the code rides on the session's auth, never per order). */
235
+ async builderDecision() {
236
+ const gate = builderGateOf(this);
237
+ return gate === null ? NO_BUILDER : gate.decide({ hip3: false });
238
+ }
239
+ async session() {
240
+ const meta = this.requireMeta();
241
+ const builder = await this.builderDecision();
242
+ const session = qfexTradeSession({
243
+ url: QFEX_ENDPOINTS[this.network].tradeWs,
244
+ network: this.network,
245
+ credentials: this.credentials,
246
+ accountId: meta.accountId,
247
+ builderCode: builder.applied === null ? null : builder.applied.identity,
248
+ rateLimit: this.venue.rateLimit,
249
+ timeouts: QFEX_TIMEOUTS,
250
+ idleCloseMs: QFEX_IDLE_CLOSE_MS,
251
+ });
252
+ return { session, builder };
253
+ }
254
+ async instrumentMap() {
255
+ if (this.instruments === null || Date.now() - this.instruments.at > INSTRUMENTS_TTL_MS) {
256
+ const list = await fetchQfexInstruments(this.client);
257
+ this.instruments = { at: Date.now(), bySymbol: new Map(list.map(i => [i.venueSymbol, i])) };
258
+ }
259
+ return this.instruments.bySymbol;
260
+ }
261
+ async instrumentOf(symbol) {
262
+ const venueSymbol = qfexVenueSymbol(symbol);
263
+ const i = (await this.instrumentMap()).get(venueSymbol);
264
+ if (i === undefined) {
265
+ throw new PerpError("SYMBOL_NOT_FOUND", `QFEX has no market ${venueSymbol}`, { exchange: "qfex", remediation: "List markets with: perp market list -e qfex" });
266
+ }
267
+ return i;
268
+ }
269
+ /** Refuse a market perp-cli must not send orders to, and an order type / TIF the symbol does not list. */
270
+ requireOrderable(i, orderType, tif) {
271
+ if (i.status !== "ACTIVE") {
272
+ throw new PerpError("SYMBOL_NOT_FOUND", `QFEX ${i.venueSymbol} is ${i.status}, not ACTIVE`, { exchange: "qfex", status: i.status, remediation: "List tradable markets with: perp market list -e qfex" });
273
+ }
274
+ if (i.quoteAsset !== "USD") {
275
+ throw unsupported("qfex", "nonUsdQuote", `${i.venueSymbol} is quoted in ${i.quoteAsset}; perp-cli sizes orders, risk limits and notionals in USD and would mis-size it`, `Trade ${i.venueSymbol} on the QFEX web app, or use a USD-quoted market (perp market list -e qfex)`);
276
+ }
277
+ if (!i.orderTypes.includes(orderType)) {
278
+ throw unsupported("qfex", "orderType", `${i.venueSymbol} does not list order type ${orderType} (allowed: ${i.orderTypes.join(", ")})`, `Use one of: ${i.orderTypes.join(", ")}`);
279
+ }
280
+ if (!i.timeInForce.includes(tif)) {
281
+ throw unsupported("qfex", "timeInForce", `${i.venueSymbol} does not list time-in-force ${tif} (allowed: ${i.timeInForce.join(", ")})`, `Use one of: ${i.timeInForce.join(", ")}`);
282
+ }
283
+ if (!qfexTradable(i))
284
+ throw new PerpError("SYMBOL_NOT_FOUND", `QFEX ${i.venueSymbol} is not tradable`, { exchange: "qfex" });
285
+ }
286
+ /** Mark of ONE symbol from a single-symbol MDS subscription; PRICE_STALE when absent or older than QFEX_MARK_MAX_AGE_MS. */
287
+ async referenceMark(venueSymbol) {
288
+ const snap = await fetchQfexMarketStats(QFEX_ENDPOINTS[this.network].mdsWs, venueSymbol, QFEX_MDS_TIMEOUT_MS);
289
+ const mark = snap.marks.get(venueSymbol);
290
+ const remediation = "Retry shortly, or place a limit order (perp trade limit …) — no other price is substituted for the mark";
291
+ if (mark === undefined)
292
+ throw new PerpError("PRICE_STALE", `QFEX: the market data snapshot has no mark price for ${venueSymbol}`, { exchange: "qfex", remediation });
293
+ const age = Date.now() - mark.time;
294
+ if (age > QFEX_MARK_MAX_AGE_MS) {
295
+ throw new PerpError("PRICE_STALE", `QFEX: mark price of ${venueSymbol} is ${Math.round(age / 1000)}s old (limit ${QFEX_MARK_MAX_AGE_MS / 1000}s)`, { exchange: "qfex", markTime: new Date(mark.time).toISOString(), remediation });
296
+ }
297
+ return mark.price;
298
+ }
299
+ checkBand(i, side, price, protection) {
300
+ const outside = side === "buy" ? lessThan(i.band.upper, price) : lessThan(price, i.band.lower);
301
+ if (!outside)
302
+ return;
303
+ throw new PerpError("INVALID_PARAMS", `QFEX ${i.venueSymbol}: ${protection ? "protection price" : `${side} price`} ${price} is outside the price band ${i.band.lower}–${i.band.upper}`, {
304
+ exchange: "qfex", band: i.band, price,
305
+ remediation: protection
306
+ ? "Lower --slippage-bps so the protection price stays inside the band, or place a limit order inside it"
307
+ : `Use a price inside the band (${side === "buy" ? `≤ ${i.band.upper}` : `≥ ${i.band.lower}`}); it is never clamped`,
308
+ });
309
+ }
310
+ /**
311
+ * `minimum: "deferred"` — a reduce-only order: its minimum is checked in requireReducible, once
312
+ * the position is known (a full close of a sub-minimum residual is not refused here).
313
+ */
314
+ checkSize(i, size, minimum) {
315
+ if (minimum === "enforced" && lessThan(size, i.minQuantity)) {
316
+ throw new PerpError("SIZE_TOO_SMALL", `QFEX ${i.venueSymbol}: size ${size} is below the minimum ${i.minQuantity}`, { exchange: "qfex", remediation: `Use a size of at least ${i.minQuantity}` });
317
+ }
318
+ if (lessThan(i.maxQuantity, size)) {
319
+ throw new PerpError("SIZE_TOO_LARGE", `QFEX ${i.venueSymbol}: size ${size} is above the maximum ${i.maxQuantity}`, { exchange: "qfex", remediation: `Use a size of at most ${i.maxQuantity}` });
320
+ }
321
+ }
322
+ /** The uniform refusal of every reduce-only order while the wire format is unset; null now that Q2 set it. */
323
+ reduceOnlyGate() {
324
+ return qfexReduceOnlyGate(QFEX_REDUCE_ONLY_WIRE);
325
+ }
326
+ /**
327
+ * The uniform refusal of every UNATTENDED flow (bots, plans, TWAP, DCA, grid, funding-arb,
328
+ * trailing stop, background jobs) while stops are refused: an automated strategy would have no
329
+ * protective exit on the venue. Manual commands never ask. Null once QFEX_AUTOMATION_VERIFIED.
330
+ */
331
+ automationGate() {
332
+ return qfexAutomationGate(QFEX_AUTOMATION_VERIFIED);
333
+ }
334
+ /**
335
+ * The uniform refusal of every stop order while Q21 is unset — and so of `--tp/--sl` and
336
+ * `trade tpsl`, whose legs are stops. Callers ask BEFORE an entry is sent (stopOrderGateOf).
337
+ * Null now that Q21 passed (QFEX_STOP_CLIENT_ID_ECHO).
338
+ */
339
+ stopOrderGate() {
340
+ if (QFEX_STOP_CLIENT_ID_ECHO === true)
341
+ return null;
342
+ return unsupported("qfex", "stopOrder", "the stop order acknowledgement is not documented to echo a client order id, so a stop cannot be correlated safely (Q21)", "Set stops / TP / SL on the QFEX web app until docs/TEST-PLAN.md QFEX item Q21 passes");
343
+ }
344
+ /**
345
+ * One reduce-only stop-market order (add_order with order_type TAKE_PROFIT / STOP_LOSS).
346
+ * Docs stop_add_order: "The direction given must be the eventual direction of the triggered
347
+ * order", `price` = "Price for when the order is triggered", quantity "Must respect lot size
348
+ * but not min/max limits", order_time_in_force "Required for legacy reasons, the value will
349
+ * not be read", and "TAKE_PROFIT and STOP_LOSS orders cannot exceed your current position,
350
+ * attempting to do this could cause your order to be rejected or other stop orders to be
351
+ * cancelled". So, before sending (every refusal definite, nothing sent):
352
+ * - a non-zero position, the order side opposite to it;
353
+ * - size + Σ quantity of the other open stops of the SAME type on the symbol ≤ |position|
354
+ * (never makes the venue cancel another stop);
355
+ * - kind given (TP/SL legs) or, for a plain stop, from the trigger vs the MDS mark price
356
+ * (closing a long: below mark = STOP_LOSS, above = TAKE_PROFIT; a short: the mirror).
357
+ * After the send: the ACK must carry our client id and reduce_only true (production 2026-10-12);
358
+ * the row is then read back from get_user_orders (final "open" only with that row). The venue's
359
+ * repeating `stop_order_response` frames are a heartbeat and never read as state.
360
+ */
361
+ async placeStop(req, kind) {
362
+ const gate = this.stopOrderGate();
363
+ if (gate !== null)
364
+ throw gate;
365
+ if (req.clientOrderId === null || !UUID_RE.test(req.clientOrderId)) {
366
+ throw new PerpError("INVALID_PARAMS", "QFEX stop orders carry a perp-cli generated client order id (UUID)");
367
+ }
368
+ if (req.triggerPrice === null)
369
+ throw new PerpError("INVALID_PARAMS", "stop order requires a trigger price");
370
+ if (req.triggerSource !== null) {
371
+ throw unsupported("qfex", "triggerSource", "QFEX stops trigger on the venue's own price source", "Omit --trigger-type");
372
+ }
373
+ const clientOrderId = req.clientOrderId;
374
+ this.requireMeta();
375
+ const i = await this.instrumentOf(req.symbol);
376
+ const size = floorSize(req.size, i.lotSize);
377
+ const trigger = requireOnTick(req.triggerPrice, i.tickSize);
378
+ const what = `stop add_order ${i.venueSymbol}`;
379
+ let session;
380
+ let exec;
381
+ let orderType;
382
+ try {
383
+ const s = await this.session();
384
+ session = s.session;
385
+ exec = execution("trigger", { referencePrice: trigger, tifSent: "GTC (not read by the venue for stops)", builderFee: s.builder.applied, notes: ["reduce-only stop-market: executes as a market order when triggered (no slippage bound)"] });
386
+ if (s.builder.note !== null)
387
+ exec.notes.push(s.builder.note);
388
+ const rows = (await this.userPositions()).positions.filter(p => p.symbol === i.venueSymbol);
389
+ if (rows.length > 1)
390
+ throw new PerpError("EXCHANGE_ERROR", `/user/positions lists ${rows.length} rows for ${i.venueSymbol}`);
391
+ const position = rows.length === 0 ? 0 : rows[0].position;
392
+ if (typeof position !== "number" || !Number.isFinite(position))
393
+ throw new PerpError("EXCHANGE_ERROR", `/user/positions reports position ${JSON.stringify(position)} for ${i.venueSymbol}`);
394
+ if (position === 0) {
395
+ throw new PerpError("POSITION_NOT_FOUND", `QFEX ${i.venueSymbol}: no open position — a stop order can only reduce one (docs: stops "cannot exceed your current position"); not sent`, {
396
+ exchange: "qfex", sent: false, remediation: "Check positions: perp account positions -e qfex",
397
+ });
398
+ }
399
+ const closeSide = position > 0 ? "sell" : "buy";
400
+ if (req.side !== closeSide) {
401
+ throw new PerpError("INVALID_PARAMS", `QFEX ${i.venueSymbol}: a ${req.side} stop cannot reduce the ${position > 0 ? "long" : "short"} position — not sent`, { exchange: "qfex", remediation: `Use side ${closeSide}` });
402
+ }
403
+ if (kind !== null)
404
+ orderType = kind === "tp" ? "TAKE_PROFIT" : "STOP_LOSS";
405
+ else {
406
+ const mark = await this.referenceMark(i.venueSymbol);
407
+ if (decimalsEqual(trigger, mark))
408
+ throw new PerpError("INVALID_PARAMS", `QFEX ${i.venueSymbol}: trigger ${trigger} equals the mark ${mark} — cannot tell stop-loss from take-profit; not sent`);
409
+ const below = lessThan(trigger, mark);
410
+ orderType = (closeSide === "sell") === below ? "STOP_LOSS" : "TAKE_PROFIT";
411
+ exec.notes.push(`sent as ${orderType}: trigger ${trigger} is ${below ? "below" : "above"} the mark ${mark}`);
412
+ }
413
+ // Same gate as every other order (ACTIVE, USD-quoted, tradable). The refdata `order_types`
414
+ // list only LIMIT / MARKET / ALO (no stop types — fixtures/qfex/refdata.json), and a stop
415
+ // executes as a MARKET order when triggered, so MARKET + GTC (the TIF sent) is checked.
416
+ this.requireOrderable(i, "MARKET", "GTC");
417
+ const held = floorSize(plainNumber(Math.abs(position), "position"), i.lotSize);
418
+ const sameType = (await session.getUserOrders(i.venueSymbol)).filter(o => o.symbol === i.venueSymbol && o.type === orderType);
419
+ const reserved = sumDecimals(sameType.map(o => fromWireNumber(o.quantity, i.lotSize, "quantity")));
420
+ const total = sumDecimals([reserved, size]);
421
+ if (lessThan(held, total)) {
422
+ throw new PerpError("SIZE_TOO_LARGE", `QFEX ${i.venueSymbol}: ${orderType} ${size} + ${reserved} in other open ${orderType} orders exceeds the ${held} position — not sent (the venue may reject it or cancel other stops)`, {
423
+ exchange: "qfex", position: held, reservedByStops: reserved, stopOrderIds: sameType.map(o => o.order_id),
424
+ remediation: sameType.length > 0 ? `Cancel a ${orderType} first: perp trade cancel ${i.symbol} <orderId> -e qfex (${sameType.map(o => o.order_id).join(", ")})` : `Use a size of at most ${held}`,
425
+ });
426
+ }
427
+ }
428
+ catch (e) {
429
+ // The rule's own refusals keep their code (nothing was sent); read failures become "not sent".
430
+ if (e instanceof PerpError && ["POSITION_NOT_FOUND", "INVALID_PARAMS", "SIZE_TOO_LARGE"].includes(e.structured.code))
431
+ throw e;
432
+ throw orderNotSent("qfex", what, e);
433
+ }
434
+ const params = {
435
+ symbol: i.venueSymbol, side: req.side === "buy" ? "BUY" : "SELL", order_type: orderType, order_time_in_force: "GTC",
436
+ quantity: toWireNumber(size, "quantity"), price: toWireNumber(trigger, "price"), client_order_id: clientOrderId,
437
+ reduce_only: reduceOnlyWireValue(QFEX_REDUCE_ONLY_WIRE, true),
438
+ };
439
+ if (subDecimal(req.size, size) !== "0")
440
+ exec.notes.push(`size ${req.size} floored to the lot ${i.lotSize} → ${size}`);
441
+ const add = await session.addOrder({ params, immediate: false, postOnly: false, lotSize: i.lotSize, band: null });
442
+ const unconfirmed = reduceOnlyUnconfirmedFrame(add.responses);
443
+ if (unconfirmed !== null)
444
+ throw this.reduceOnlyEchoError(i, add.orderId, clientOrderId, unconfirmed.reduce_only, { orderId: add.orderId, responses: add.responses });
445
+ // Read back: "open" only with the get_user_orders row of THIS stop (by client id).
446
+ let row = null;
447
+ try {
448
+ row = this.pick((await session.getUserOrders(i.venueSymbol)).filter(o => STOP_TYPES.has(o.type)), { kind: "clientOrderId", value: clientOrderId });
449
+ }
450
+ catch (e) {
451
+ exec.notes.push(`read-back failed: ${e instanceof Error ? e.message : String(e)}`);
452
+ }
453
+ const venueRaw = { orderId: add.orderId, responses: add.responses, row };
454
+ if (row === null) {
455
+ exec.notes.push(`acknowledged (ACK); not listed by get_user_orders yet — verify: perp trade status c:${clientOrderId} --symbol ${i.symbol} -e qfex`);
456
+ return { venue: "qfex", symbol: i.symbol, status: "accepted", finality: "pending", orderId: add.orderId, clientOrderId, filledSize: null, avgPrice: null, execution: exec, venueRaw };
457
+ }
458
+ exec.notes.push(`listed open as ${row.type} (untriggered)`);
459
+ return { venue: "qfex", symbol: i.symbol, status: "open", finality: "final", orderId: row.order_id, clientOrderId, filledSize: "0", avgPrice: null, execution: exec, venueRaw };
460
+ }
461
+ /** A TP / SL leg (separateOrders: legacy-trading-v2 placeTpSlLeg dispatches here). Stop-market only. */
462
+ async placeTpSlLeg(leg) {
463
+ if (leg.limitPrice !== null) {
464
+ throw unsupported("qfex", "stopLimit", "the venue's stop orders convert to market orders when triggered", "Omit --tp-limit on qfex");
465
+ }
466
+ return this.placeStop({
467
+ symbol: leg.symbol, side: leg.closeSide, type: "stopMarket", size: leg.size, price: null, triggerPrice: leg.trigger,
468
+ triggerSource: null, tif: null, reduceOnly: true, clientOrderId: qfexClientOrderId(), slippage: null, tpsl: null,
469
+ }, leg.kind);
470
+ }
471
+ /**
472
+ * Orders left on symbols where the account holds NO position: resting reduce-only LIMIT / ALO
473
+ * orders and TAKE_PROFIT / STOP_LOSS stops. Production 2026-10-12 (Q2): the venue does NOT
474
+ * cancel a resting reduce-only order when the position closes; whether one the market reaches
475
+ * while flat would open a position is unproven. `symbol` null = every symbol.
476
+ */
477
+ async orphanedReduceOnlyOrders(symbol) {
478
+ const venueSymbol = symbol === null ? null : (await this.instrumentOf(symbol)).venueSymbol;
479
+ const { session } = await this.session();
480
+ const [orders, { positions }] = await Promise.all([session.getUserOrders(venueSymbol), this.userPositions()]);
481
+ const held = new Set(positions.filter(p => typeof p.position === "number" && p.position !== 0).map(p => p.symbol));
482
+ return orders
483
+ .filter(o => !held.has(o.symbol) && (STOP_TYPES.has(o.type) || o.reduce_only === true))
484
+ .map(o => ({ symbol: qfexPlainSymbol(o.symbol), orderId: o.order_id, type: o.type, side: o.side, price: String(o.price), quantity: String(o.quantity_remaining) }));
485
+ }
486
+ /**
487
+ * The rule before EVERY reduce-only send. The venue accepts a reduce-only order larger than
488
+ * the position (Q2, signing.ts), so the size is enforced here, on live reads:
489
+ * (a) GET /user/positions has a non-zero position on the symbol — none → POSITION_NOT_FOUND;
490
+ * (b) the order side is opposite to it (long → sell, short → buy);
491
+ * (c) size ≤ |position| − Σ quantity_remaining of ALL this account's other open LIMIT / ALO
492
+ * orders on that symbol and side (get_user_orders rows) — a plain SELL consumes a long
493
+ * exactly like a reduce-only one, so both are counted;
494
+ * (d) size ≥ min_quantity, unless the order closes the whole (floored) position.
495
+ * A same-side open order whose row does not report `reduce_only` as a boolean is not in the
496
+ * verified row format (Q2) → refused, with the cancel command. Stop rows (TAKE_PROFIT / STOP_LOSS) are not counted: the
497
+ * venue's stops "act only to decrease position at the time of execution" (docs
498
+ * /qfex/order-types); they are reported as an execution note instead.
499
+ * Every refusal is definite — nothing was sent. The position is floored to the lot.
500
+ * Returns the execution notes of the check.
501
+ */
502
+ async requireReducible(i, session, side, size) {
503
+ const what = `reduce-only add_order ${i.venueSymbol}`;
504
+ const wireSide = side === "buy" ? "BUY" : "SELL";
505
+ const cancelHint = (ids) => `perp trade cancel ${i.symbol} <orderId> -e qfex (${ids.join(", ")})`;
506
+ let position;
507
+ let reserving;
508
+ let stops;
509
+ try {
510
+ const rows = (await this.userPositions()).positions.filter(p => p.symbol === i.venueSymbol);
511
+ if (rows.length > 1)
512
+ throw new PerpError("EXCHANGE_ERROR", `/user/positions lists ${rows.length} rows for ${i.venueSymbol}`);
513
+ if (rows.length === 1 && (typeof rows[0].position !== "number" || !Number.isFinite(rows[0].position))) {
514
+ throw new PerpError("EXCHANGE_ERROR", `/user/positions reports position ${JSON.stringify(rows[0].position)} for ${i.venueSymbol}`);
515
+ }
516
+ position = rows.length === 0 ? 0 : rows[0].position;
517
+ const open = (await session.getUserOrders(i.venueSymbol)).filter(o => o.symbol === i.venueSymbol);
518
+ stops = open.filter(o => STOP_TYPES.has(o.type));
519
+ reserving = open.filter(o => o.side === wireSide && !STOP_TYPES.has(o.type));
520
+ }
521
+ catch (e) {
522
+ throw orderNotSent("qfex", what, e);
523
+ }
524
+ const unknown = reserving.filter(o => typeof o.reduce_only !== "boolean").map(o => o.order_id);
525
+ if (unknown.length > 0) {
526
+ throw new PerpError("ORDER_REJECTED", `qfex ${what} not sent: open ${wireSide} order(s) ${unknown.join(", ")} do not report reduce_only (every verified open-order row carries it as a boolean), so the open-order read is not trusted for sizing`, {
527
+ exchange: "qfex", sent: false, unreadableOrderIds: unknown,
528
+ remediation: `Cancel the order(s), then retry: ${cancelHint(unknown)} — or look at them first: perp account orders -e qfex`,
529
+ });
530
+ }
531
+ if (position === 0) {
532
+ throw new PerpError("POSITION_NOT_FOUND", `QFEX ${i.venueSymbol}: no open position to reduce — the reduce-only order was not sent`, {
533
+ exchange: "qfex", sent: false, remediation: "Check positions: perp account positions -e qfex",
534
+ });
535
+ }
536
+ const positionSide = position > 0 ? "long" : "short";
537
+ if (side !== (position > 0 ? "sell" : "buy")) {
538
+ throw new PerpError("INVALID_PARAMS", `QFEX ${i.venueSymbol}: a reduce-only ${side} cannot reduce a ${positionSide} position — not sent`, {
539
+ exchange: "qfex", positionSide, remediation: `Reduce the ${positionSide} position with a ${position > 0 ? "sell" : "buy"} order`,
540
+ });
541
+ }
542
+ const held = floorSize(plainNumber(Math.abs(position), "position"), i.lotSize);
543
+ const reserved = sumDecimals(reserving.map(o => fromWireNumber(o.quantity_remaining, i.lotSize, "quantity_remaining")));
544
+ const available = lessThan(held, reserved) ? "0" : subDecimals(held, reserved, "reducible size");
545
+ if (lessThan(available, size)) {
546
+ const orderIds = reserving.map(o => o.order_id);
547
+ throw new PerpError("SIZE_TOO_LARGE", `QFEX ${i.venueSymbol}: reduce-only size ${size} exceeds what can still reduce the ${positionSide} position (${held} held − ${reserved} in other open ${wireSide} orders = ${available}) — not sent; the venue would accept the excess and it could open the opposite position`, {
548
+ exchange: "qfex", position: held, positionSide, reservedByOpenOrders: reserved, available, reservingOrderIds: orderIds,
549
+ remediation: orderIds.length === 0
550
+ ? `Use a size of at most ${available}`
551
+ : `Use a size of at most ${available}, or cancel the open ${wireSide} order(s) first: ${cancelHint(orderIds)}`,
552
+ });
553
+ }
554
+ // A residual below min_quantity can only be closed whole. Whether the venue accepts a
555
+ // sub-minimum reduce-only order is UNVERIFIED — it is sent and the venue answers.
556
+ const closesAll = decimalsEqual(size, held);
557
+ if (lessThan(size, i.minQuantity) && !closesAll) {
558
+ throw new PerpError("SIZE_TOO_SMALL", `QFEX ${i.venueSymbol}: reduce-only size ${size} is below the minimum ${i.minQuantity} and is not the whole position (${held}) — not sent`, {
559
+ exchange: "qfex", position: held, remediation: `Use a size of at least ${i.minQuantity}${lessThan(held, i.minQuantity) ? `, or close the whole position: ${held}` : ""}`,
560
+ });
561
+ }
562
+ const notes = [];
563
+ if (lessThan(size, i.minQuantity)) {
564
+ notes.push(`size ${size} is below the venue minimum ${i.minQuantity}; sent because it closes the whole position (venue behaviour for a sub-minimum reduce-only order is unverified)`);
565
+ }
566
+ if (stops.length > 0) {
567
+ notes.push(`${stops.length} open stop order(s) on ${i.venueSymbol} (${stops.map(o => o.order_id).join(", ")}) were not counted against the reducible size — a stop that triggers also reduces the position`);
568
+ }
569
+ return notes;
570
+ }
571
+ /**
572
+ * A reduce-only order the venue did not confirm as reduce-only (`reduce_only` false or absent
573
+ * on an order frame — reduceOnlyUnconfirmedFrame — or on a read-back row of THIS order). The order may be live as a normal order, so the outcome is
574
+ * unknown (ORDER_PENDING, design §14.12 #4e: never re-sent). It is NOT cancelled here: a cancel
575
+ * is a second mutating command with its own unknown outcome and cannot undo a fill.
576
+ */
577
+ reduceOnlyEchoError(i, orderId, clientOrderId, echo, venueRaw) {
578
+ const id = orderId === null ? `c:${clientOrderId}` : orderId;
579
+ return new PerpError("ORDER_PENDING", `QFEX ${i.venueSymbol}: the venue did not confirm reduce_only on order ${id} (reduce_only ${echo === undefined ? "not reported" : `echoed as ${JSON.stringify(echo)}`}) — it may be live as a NORMAL order that can open or flip a position. It was NOT cancelled automatically`, {
580
+ exchange: "qfex", alarm: "reduceOnlyNotEchoed", orderId, clientOrderId, reduceOnlyEcho: typeof echo === "boolean" ? echo : null, venueRaw,
581
+ remediation: `Do NOT resubmit. Check it NOW: perp trade status c:${clientOrderId} --symbol ${i.symbol} -e qfex — if it is still open, cancel it: perp trade cancel ${i.symbol} ${id} -e qfex — then check the position: perp account positions -e qfex`,
582
+ });
583
+ }
584
+ // ── TradingV2 ──
585
+ async getVenueMarkets() {
586
+ return qfexPublicData.fetchMarkets(this.network);
587
+ }
588
+ async placeOrder(input) {
589
+ // Wire-format gate first (null now that Q2 set the format).
590
+ if (input.reduceOnly) {
591
+ const gate = qfexReduceOnlyGate(QFEX_REDUCE_ONLY_WIRE);
592
+ if (gate !== null)
593
+ throw gate;
594
+ }
595
+ const tifRequested = input.tif;
596
+ const req = input.type === "limit" && input.tif === null ? { ...input, tif: "GTC" } : input;
597
+ validateOrderRequest(this.venue, this.caps, req);
598
+ if (req.type === "stopLimit") {
599
+ throw unsupported("qfex", "stopLimit", "the venue's stop orders convert to market orders when triggered", "Use a stop-market order");
600
+ }
601
+ if (req.type === "stopMarket") {
602
+ if (!req.reduceOnly) {
603
+ throw unsupported("qfex", "openingStop", "QFEX stop orders only reduce a position (they cannot exceed it)", "Stop orders on qfex must be reduce-only");
604
+ }
605
+ return this.placeStop(req, null);
606
+ }
607
+ if (req.clientOrderId === null || !UUID_RE.test(req.clientOrderId)) {
608
+ throw new PerpError("INVALID_PARAMS", "QFEX orders carry a perp-cli generated client order id (UUID); user-supplied ids are not accepted because ids are not unique on the venue", {
609
+ remediation: "Omit --client-id on qfex; the generated id is reported with the result",
610
+ });
611
+ }
612
+ const clientOrderId = req.clientOrderId;
613
+ this.requireMeta(); // no key → refused before any market-data read
614
+ const i = await this.instrumentOf(req.symbol);
615
+ const size = floorSize(req.size, i.lotSize);
616
+ this.checkSize(i, size, req.reduceOnly ? "deferred" : "enforced");
617
+ let orderType;
618
+ let tif;
619
+ let price;
620
+ let exec;
621
+ if (req.type === "limit") {
622
+ if (req.price === null || req.tif === null)
623
+ throw new PerpError("INVALID_PARAMS", "limit order requires price and tif");
624
+ if (req.tif !== "GTC" && req.tif !== "IOC" && req.tif !== "FOK" && req.tif !== "ALO") {
625
+ throw unsupported("qfex", "timeInForce", `time-in-force ${req.tif} is not offered`, "Use one of: GTC, IOC, FOK, ALO");
626
+ }
627
+ orderType = req.tif === "ALO" ? "ALO" : "LIMIT";
628
+ tif = req.tif === "ALO" ? "GTC" : req.tif;
629
+ price = requireOnTick(req.price, i.tickSize);
630
+ this.requireOrderable(i, orderType, tif);
631
+ this.checkBand(i, req.side, price, false);
632
+ exec = execution("limit", {
633
+ tifRequested, tifSent: req.tif === "ALO" ? "ALO (order_type ALO + GTC)" : tif,
634
+ notes: tifRequested === null ? ["no time-in-force given; GTC (uniform default)"] : [],
635
+ });
636
+ }
637
+ else {
638
+ const slippage = req.slippage; // validateOrderRequest guarantees it for market orders
639
+ orderType = "LIMIT";
640
+ tif = "IOC";
641
+ this.requireOrderable(i, orderType, tif);
642
+ const mark = await this.referenceMark(i.venueSymbol);
643
+ price = alignProtectionPrice(slippagePrice(mark, req.side, slippage.bps), i.tickSize, req.side);
644
+ this.checkBand(i, req.side, price, true);
645
+ const appliedBps = Math.abs(Number(price) - Number(mark)) / Number(mark) * 10_000;
646
+ exec = execution("protectedIoc", {
647
+ slippageBps: Number(appliedBps.toFixed(4)), slippageSource: slippage.source,
648
+ protectionPrice: price, referencePrice: mark, tifSent: "IOC",
649
+ notes: [
650
+ `resolved bound ${slippage.bps} bps (${slippage.source}); protection price aligned to tick ${i.tickSize} on the mark price`,
651
+ "sent as LIMIT IOC — subject to the venue's 500 ms speed bump, so it may fill less than a native market order would",
652
+ ],
653
+ });
654
+ }
655
+ if (subDecimal(req.size, size) !== "0")
656
+ exec.notes.push(`size ${req.size} floored to the lot ${i.lotSize} → ${size}`);
657
+ const params = buildAddOrderParams({ venueSymbol: i.venueSymbol, side: req.side, orderType, tif, quantity: size, price, clientOrderId, reduceOnly: req.reduceOnly }, QFEX_REDUCE_ONLY_WIRE);
658
+ const immediate = tif !== "GTC";
659
+ let session;
660
+ try {
661
+ const s = await this.session();
662
+ session = s.session;
663
+ exec.builderFee = s.builder.applied;
664
+ if (s.builder.note !== null)
665
+ exec.notes.push(s.builder.note);
666
+ if (s.builder.applied !== null)
667
+ exec.notes.push("builder code attached to the connection (fee share: you pay the normal fee); attribution is not confirmable from the trader side");
668
+ }
669
+ catch (e) {
670
+ throw orderNotSent("qfex", `add_order ${i.venueSymbol}`, e);
671
+ }
672
+ if (req.reduceOnly)
673
+ exec.notes.push(...await this.requireReducible(i, session, req.side, size));
674
+ let add;
675
+ try {
676
+ add = await session.addOrder({ params, immediate, postOnly: orderType === "ALO", lotSize: i.lotSize, band: i.band });
677
+ }
678
+ catch (e) {
679
+ if (!(e instanceof PerpError) || e.structured.code !== "ORDER_PENDING")
680
+ throw e;
681
+ // Unknown outcome: ONE read-only lookup on a fresh connection. Absence is not "not placed"
682
+ // (speed bump, indexing lag) — the pending error then stands.
683
+ let found = null;
684
+ let lookupNote = null;
685
+ try {
686
+ found = await this.findOrder(i, { kind: "clientOrderId", value: clientOrderId });
687
+ }
688
+ catch (lookupErr) {
689
+ lookupNote = `status lookup after the unknown outcome failed: ${lookupErr instanceof Error ? lookupErr.message : String(lookupErr)}`;
690
+ }
691
+ if (found === null) {
692
+ if (lookupNote === null)
693
+ throw e;
694
+ throw new PerpError("ORDER_PENDING", e.message, { ...(e.structured.details ?? {}), lookup: lookupNote, remediation: e.structured.remediation });
695
+ }
696
+ const result = found.result;
697
+ const notes = [...exec.notes, "placement outcome was unknown; this status was read back by the generated client order id"];
698
+ if (req.reduceOnly) {
699
+ const echo = result.venueRaw.reduce_only;
700
+ // /user/historic-orders rows carry no reduce_only field, so a missing echo on a TERMINAL
701
+ // historic row is not evidence of anything: the order is done and cannot open a position
702
+ // any more. An explicit false, or an order that is still live without `true`, is the alarm.
703
+ const doneWithoutEcho = echo === undefined && found.source === "historic" && result.finality === "final";
704
+ if (echo !== true && !doneWithoutEcho)
705
+ throw this.reduceOnlyEchoError(i, result.orderId, clientOrderId, echo, result.venueRaw);
706
+ if (doneWithoutEcho)
707
+ notes.push("reduce_only not reported on historic rows — the order is terminal; its reduce-only flag could not be confirmed from this row");
708
+ }
709
+ result.execution = { ...exec, notes };
710
+ return result;
711
+ }
712
+ if (req.reduceOnly) {
713
+ const unconfirmed = reduceOnlyUnconfirmedFrame(add.responses);
714
+ if (unconfirmed !== null) {
715
+ throw this.reduceOnlyEchoError(i, add.orderId, clientOrderId, unconfirmed.reduce_only, { orderId: add.orderId, responses: add.responses, fills: add.fills, terminal: add.terminal, ended: add.ended });
716
+ }
717
+ }
718
+ const result = this.orderResultOf(i, add, { size, immediate, postOnly: orderType === "ALO", exec });
719
+ if (req.reduceOnly && result.finality === "final" && result.status === "partiallyFilled") {
720
+ // Production 2026-10-05 (signing.ts, Q2): the venue caps a reduce-only order at the position
721
+ // size when it matches and cancels the rest. A remainder can also be plain missing liquidity.
722
+ result.execution.notes.push(`reduce-only: the venue filled ${result.filledSize} and cancelled the remainder — it caps a reduce-only order at the position size at match time (or no more liquidity within the limit price)`);
723
+ }
724
+ return result;
725
+ }
726
+ /** Map a correlated add_order outcome to an OrderResult — fills from venue frames only, decimal math. */
727
+ orderResultOf(i, add, ctx) {
728
+ const exec = ctx.exec;
729
+ const qty = (n, what) => fromWireNumber(n, i.lotSize, what);
730
+ if (add.userIdMismatches.length > 0) {
731
+ exec.notes.push(`order_response.user_id ${add.userIdMismatches.join(", ")} differs from the session account ${this.accountId} (unverified whether user_id is the account, Q22)`);
732
+ }
733
+ const venueRaw = { orderId: add.orderId, responses: add.responses, fills: add.fills, terminal: add.terminal, ended: add.ended };
734
+ const base = { venue: "qfex", symbol: i.symbol, orderId: add.orderId, clientOrderId: add.clientOrderId, execution: exec, venueRaw };
735
+ if (add.terminal !== null) {
736
+ const t = add.terminal;
737
+ const filled = subDecimals(qty(t.quantity, "quantity"), qty(t.quantity_remaining, "quantity_remaining"), "filled quantity");
738
+ const fills = add.fills.map(f => ({ price: fromWireNumber(f.price, i.tickSize, "fill price"), quantity: qty(f.quantity, "fill quantity") }));
739
+ const fillSum = sumDecimals(fills.map(f => f.quantity));
740
+ let avgPrice = null;
741
+ if (!isZeroDecimal(filled)) {
742
+ if (decimalsEqual(fillSum, filled))
743
+ avgPrice = weightedAveragePrice(fills);
744
+ else
745
+ exec.notes.push(`fill frames add up to ${fillSum}, the terminal order_response reports ${filled} filled — filledSize is the order_response value, average price unknown`);
746
+ }
747
+ if (isZeroDecimal(filled)) {
748
+ if (ctx.immediate) {
749
+ // A terminal frame with nothing filled — IOC_CANCELLED or CANCELLED alike (§13.10 #4).
750
+ throw new PerpError("ORDER_REJECTED", `QFEX ${i.venueSymbol}: the ${t.time_in_force} order filled nothing and was cancelled (${t.status})`, {
751
+ exchange: "qfex", reason: "iocNoFill", venueStatus: t.status, orderId: add.orderId, clientOrderId: add.clientOrderId, venueRaw,
752
+ remediation: "Nothing was filled. Widen --slippage-bps (within the price band), reduce the size, or place a resting limit order",
753
+ });
754
+ }
755
+ if (ctx.postOnly && t.status === "CANCELLED") {
756
+ // docs.qfex.com "Order Entry": "If an ALO order would immediately match with an existing
757
+ // order, it is **cancelled** instead." The venue ACKs the order, then sends CANCELLED
758
+ // (live 2026-10-05) — a definite "not placed", like a crossing post-only on other venues.
759
+ throw new PerpError("ORDER_REJECTED", `QFEX ${i.venueSymbol}: post-only order would cross — the venue cancelled the ALO order right after acknowledging it (${t.status}); nothing was placed`, {
760
+ exchange: "qfex", reason: "postOnlyWouldCross", venueStatus: t.status, orderId: add.orderId, clientOrderId: add.clientOrderId, venueRaw,
761
+ remediation: "Use a price that does not cross the book (buy below the best ask / sell above the best bid), or drop --tif ALO to take liquidity",
762
+ });
763
+ }
764
+ if (t.status === "CANCELLED_STP")
765
+ exec.notes.push("cancelled by self-trade prevention");
766
+ return { ...base, status: "cancelled", finality: "final", filledSize: "0", avgPrice: null };
767
+ }
768
+ const full = decimalsEqual(filled, qty(t.quantity, "quantity"));
769
+ if (!full)
770
+ exec.notes.push(`${filled} of ${qty(t.quantity, "quantity")} filled; the remainder was cancelled (${t.status})`);
771
+ return { ...base, status: full ? "filled" : "partiallyFilled", finality: "final", filledSize: filled, avgPrice };
772
+ }
773
+ // No terminal frame: acknowledged, never "open" (that needs a get_user_orders row).
774
+ const last = add.responses[add.responses.length - 1];
775
+ const partial = qfexStatusClass(last.status) === "fill";
776
+ const filledSoFar = partial ? subDecimals(qty(last.quantity, "quantity"), qty(last.quantity_remaining, "quantity_remaining"), "filled quantity") : null;
777
+ if (add.ended !== "resting") {
778
+ exec.notes.push(`no terminal venue frame within the wait (${add.ended}) — the order may still execute; status must be read back`);
779
+ }
780
+ return { ...base, status: partial ? "partiallyFilled" : "accepted", finality: "pending", filledSize: filledSoFar, avgPrice: null };
781
+ }
782
+ /**
783
+ * Final filled size of an order placed through the legacy shim — non-null ONLY when a venue
784
+ * terminal frame was received (design §14.12 #3). A pending result is never an outcome.
785
+ */
786
+ iocOutcomeOf(raw) {
787
+ const r = raw;
788
+ if (r === null || typeof r !== "object" || r.venue !== "qfex" || r.finality !== "final")
789
+ return null;
790
+ const v = r.venueRaw;
791
+ if (v === null || v === undefined || typeof v !== "object" || v.terminal === null || v.terminal === undefined)
792
+ return null;
793
+ if (typeof r.filledSize !== "string")
794
+ return null;
795
+ return { filledSize: r.filledSize, final: true };
796
+ }
797
+ async amendOrder(req) {
798
+ const cancelHint = `Cancel it, then place a new order: perp trade cancel ${req.symbol} ${req.ref.kind === "clientOrderId" ? `c:${req.ref.value}` : req.ref.value} -e qfex`;
799
+ if (QFEX_MODIFY_KEEPS_CLIENT_ID !== true) {
800
+ throw unsupported("qfex", "amend", "modify_order returns a NEW order id and is not documented to keep the client order id, so the reply cannot be tied to this order (Q5)", cancelHint);
801
+ }
802
+ const i = await this.instrumentOf(req.symbol);
803
+ const { session } = await this.session();
804
+ const row = this.pick(await session.getUserOrders(i.venueSymbol), req.ref);
805
+ if (row === null)
806
+ throw new PerpError("ORDER_NOT_FOUND", `QFEX: no open order ${req.ref.value} on ${i.venueSymbol}`, { exchange: "qfex" });
807
+ if (STOP_TYPES.has(row.type) || (row.type !== "LIMIT" && row.type !== "ALO")) {
808
+ throw unsupported("qfex", "amend", `only open LIMIT / ALO orders can be amended (order is ${row.type})`, cancelHint);
809
+ }
810
+ if (typeof row.client_order_id !== "string" || row.client_order_id === "") {
811
+ throw unsupported("qfex", "amend", "the order has no client order id to correlate the modify reply with", cancelHint);
812
+ }
813
+ if (row.reduce_only !== false) {
814
+ throw unsupported("qfex", "amend", "a reduce-only order is sized against the live position when it is placed; a modify would skip that check", cancelHint);
815
+ }
816
+ const price = requireOnTick(req.price, i.tickSize);
817
+ const size = floorSize(req.size, i.lotSize);
818
+ this.checkSize(i, size, "enforced");
819
+ this.checkBand(i, row.side === "BUY" ? "buy" : "sell", price, false);
820
+ const params = {
821
+ symbol: i.venueSymbol, order_id: row.order_id, side: row.side, order_type: row.type,
822
+ price: toWireNumber(price, "price"), quantity: toWireNumber(size, "quantity"),
823
+ // Only an order the venue reports as not reduce-only reaches here (checked above).
824
+ reduce_only: false,
825
+ // Re-send the order's current attached TP/SL: omitting them is documented as "0 = unused".
826
+ take_profit: typeof row.take_profit === "number" ? row.take_profit : 0,
827
+ stop_loss: typeof row.stop_loss === "number" ? row.stop_loss : 0,
828
+ };
829
+ // The MODIFIED frame carries a NEW order id and the SAME client id (Q5, production 2026-10-12):
830
+ // modifyOrder correlates on that client id plus the sent symbol / side / type / price / quantity.
831
+ const modified = await session.modifyOrder({ params, clientOrderId: row.client_order_id, lotSize: i.lotSize, tickSize: i.tickSize });
832
+ const notes = [`replacedOrderId ${row.order_id}: the venue assigns a new order id on modify (client id ${modified.client_order_id} kept)`];
833
+ let newRow = null;
834
+ try {
835
+ newRow = this.pick(await session.getUserOrders(i.venueSymbol), { kind: "orderId", value: modified.order_id });
836
+ }
837
+ catch (e) {
838
+ notes.push(`read-back failed: ${e instanceof Error ? e.message : String(e)}`);
839
+ }
840
+ if (newRow !== null) {
841
+ const r = this.openOrderResult(i, newRow);
842
+ return { ...r, execution: execution("nativeAmend", { tifSent: modified.time_in_force, notes: [...notes, "new order listed open (get_user_orders)"] }), venueRaw: { modified, row: newRow, replacedOrderId: row.order_id } };
843
+ }
844
+ notes.push(`the new order ${modified.order_id} is not listed open yet (it may have filled) — check: perp trade status ${modified.order_id} --symbol ${i.symbol} -e qfex`);
845
+ return {
846
+ venue: "qfex", symbol: i.symbol, status: "accepted", finality: "pending",
847
+ orderId: modified.order_id, clientOrderId: modified.client_order_id, filledSize: null, avgPrice: null,
848
+ execution: execution("nativeAmend", { tifSent: modified.time_in_force, notes }),
849
+ venueRaw: { modified, replacedOrderId: row.order_id },
850
+ };
851
+ }
852
+ /** The open-order row for a ref; a client id shared by several open orders is ambiguous and refused. */
853
+ pick(rows, ref) {
854
+ const hits = rows.filter(r => (ref.kind === "orderId" ? r.order_id : r.client_order_id) === ref.value);
855
+ if (hits.length > 1) {
856
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: ${hits.length} open orders share client order id ${ref.value}`, {
857
+ exchange: "qfex", orderIds: hits.map(h => h.order_id), remediation: "Use the order id instead: perp account orders -e qfex",
858
+ });
859
+ }
860
+ return hits.length === 1 ? hits[0] : null;
861
+ }
862
+ openOrderResult(i, row) {
863
+ const filled = subDecimals(fromWireNumber(row.quantity, i.lotSize, "quantity"), fromWireNumber(row.quantity_remaining, i.lotSize, "quantity_remaining"), "filled quantity");
864
+ return {
865
+ venue: "qfex", symbol: i.symbol, status: isZeroDecimal(filled) ? "open" : "partiallyFilled", finality: "final",
866
+ orderId: row.order_id, clientOrderId: row.client_order_id === "" ? null : row.client_order_id,
867
+ filledSize: filled, avgPrice: null,
868
+ execution: execution(STOP_TYPES.has(row.type) ? "trigger" : "limit", { tifSent: row.type === "ALO" ? "ALO (order_type ALO + GTC)" : row.time_in_force }),
869
+ venueRaw: row,
870
+ };
871
+ }
872
+ historicResult(i, h) {
873
+ const quantity = fromWireNumber(h.quantity, i.lotSize, "quantity");
874
+ const filled = fromWireNumber(h.filled_qty, i.lotSize, "filled_qty");
875
+ const cls = qfexStatusClass(h.terminal_status);
876
+ let status;
877
+ let finality = "final";
878
+ const notes = [];
879
+ if (cls === "unknown" || cls === "ack" || cls === "modified") {
880
+ // No (known) terminal status on the row: it does not prove the order is done.
881
+ status = isZeroDecimal(filled) ? "accepted" : "partiallyFilled";
882
+ finality = "pending";
883
+ notes.push(`historic-orders row has terminal_status ${JSON.stringify(h.terminal_status)} (execution_state ${JSON.stringify(h.execution_state)}) — not a known terminal status`);
884
+ }
885
+ else if (decimalsEqual(filled, quantity))
886
+ status = "filled";
887
+ else if (!isZeroDecimal(filled))
888
+ status = "partiallyFilled";
889
+ else
890
+ status = cls === "reject" ? "rejected" : "cancelled";
891
+ if (h.terminal_status === "CANCELLED_STP")
892
+ notes.push("cancelled by self-trade prevention");
893
+ return {
894
+ venue: "qfex", symbol: i.symbol, status, finality,
895
+ orderId: h.order_id, clientOrderId: h.client_order_id === "" ? null : h.client_order_id,
896
+ filledSize: filled, avgPrice: typeof h.avg_price === "number" && !isZeroDecimal(filled) ? plainNumber(h.avg_price, "avg_price") : null,
897
+ execution: execution(h.type === "LIMIT" || h.type === "ALO" ? "limit" : "trigger", { tifSent: h.time_in_force, notes }),
898
+ venueRaw: h,
899
+ };
900
+ }
901
+ /** Open orders first (get_user_orders), then the historic store; null when neither has it. `source` says which one answered. */
902
+ async findOrder(i, ref) {
903
+ const { session } = await this.session();
904
+ const row = this.pick(await session.getUserOrders(i.venueSymbol), ref);
905
+ if (row !== null)
906
+ return { result: this.openOrderResult(i, row), source: "open" };
907
+ const query = ref.kind === "clientOrderId"
908
+ ? { symbol: i.venueSymbol, client_order_id: ref.value, limit: 100 }
909
+ : { symbol: i.venueSymbol, limit: 1000 };
910
+ const body = await this.client.get("/user/historic-orders", query);
911
+ const rows = listOf(body.data, "/user/historic-orders data")
912
+ .filter(h => (ref.kind === "orderId" ? h.order_id : h.client_order_id) === ref.value);
913
+ if (rows.length > 1) {
914
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: ${rows.length} historic orders match ${ref.value}`, { exchange: "qfex", orderIds: rows.map(r => r.order_id) });
915
+ }
916
+ return rows.length === 1 ? { result: this.historicResult(i, rows[0]), source: "historic" } : null;
917
+ }
918
+ async getOrderStatus(symbol, ref) {
919
+ const i = await this.instrumentOf(symbol);
920
+ const found = await this.findOrder(i, ref);
921
+ if (found === null) {
922
+ throw new PerpError("ORDER_NOT_FOUND", `QFEX: order ${ref.kind === "clientOrderId" ? `c:${ref.value}` : ref.value} is neither open nor in the order history of ${i.venueSymbol}`, {
923
+ exchange: "qfex", remediation: "A just-sent order may not be visible yet (speed bump / indexing) — retry shortly; do not resubmit",
924
+ });
925
+ }
926
+ return found.result;
927
+ }
928
+ async cancelByRef(symbol, ref) {
929
+ const i = await this.instrumentOf(symbol);
930
+ const { session } = await this.session();
931
+ // Read first: decides stop vs normal order and turns a client id into THE order id (a
932
+ // client-id cancel on the venue would hit every order sharing that id).
933
+ const row = this.pick(await session.getUserOrders(i.venueSymbol), ref);
934
+ if (row === null) {
935
+ throw new PerpError("ORDER_NOT_FOUND", `QFEX: no open order ${ref.kind === "clientOrderId" ? `c:${ref.value}` : ref.value} on ${i.venueSymbol}`, {
936
+ exchange: "qfex", remediation: `Check its status: perp trade status ${ref.kind === "clientOrderId" ? `c:${ref.value}` : ref.value} --symbol ${i.symbol} -e qfex`,
937
+ });
938
+ }
939
+ const outcome = STOP_TYPES.has(row.type) ? await session.cancelStopOrder(i.venueSymbol, row.order_id) : await session.cancelOrder(i.venueSymbol, row.order_id);
940
+ const result = { venue: "qfex", cancelled: [], failed: [], pending: [], venueRaw: outcome };
941
+ if (outcome.kind === "cancelled")
942
+ result.cancelled.push(ref);
943
+ else if (outcome.kind === "refused")
944
+ result.failed.push({ ref, error: `venue answered ${outcome.status}` });
945
+ else {
946
+ const settled = STOP_TYPES.has(row.type) ? await this.settleUnknownStopCancel(i.venueSymbol, row.order_id) : null;
947
+ if (settled === "cancelled")
948
+ result.cancelled.push(ref);
949
+ else if (settled === "filled")
950
+ result.failed.push({ ref, error: `order ${row.order_id} triggered / filled before the cancel` });
951
+ else if (settled !== null && settled.startsWith(QFEX_PARTIAL_PREFIX))
952
+ result.failed.push({ ref, error: `order ${row.order_id} ${settled}` });
953
+ else
954
+ result.pending.push({ ref, reason: settled === null ? outcome.reason : `${outcome.reason}; re-check: ${settled}`, remediation: "Do NOT assume it was cancelled. Check: perp account orders -e qfex" });
955
+ }
956
+ return result;
957
+ }
958
+ async cancelAllV2(symbol) {
959
+ const venueSymbol = symbol === null ? null : (await this.instrumentOf(symbol)).venueSymbol;
960
+ const { session } = await this.session();
961
+ const before = await session.getUserOrders(venueSymbol);
962
+ const result = { venue: "qfex", cancelled: [], failed: [], pending: [], venueRaw: null };
963
+ const pending = result.pending;
964
+ if (before.length === 0) {
965
+ result.venueRaw = { before: 0 };
966
+ return result;
967
+ }
968
+ const normalIds = new Set(before.filter(o => !STOP_TYPES.has(o.type)).map(o => o.order_id));
969
+ const sent = await session.cancelAll(venueSymbol, normalIds, new Set(before.map(o => o.order_id)));
970
+ const proven = new Set(sent.cancelled);
971
+ // Verifying re-list: the venue documents no completion signal for cancel_all_orders, and
972
+ // whether it also removes stop orders is unverified (Q8).
973
+ const after = await session.getUserOrders(venueSymbol);
974
+ const stillOpen = new Set(after.map(o => o.order_id));
975
+ const refOf = (id) => ({ kind: "orderId", value: id });
976
+ for (const o of before) {
977
+ if (proven.has(o.order_id)) {
978
+ result.cancelled.push(refOf(o.order_id));
979
+ continue;
980
+ }
981
+ if (!stillOpen.has(o.order_id)) {
982
+ // Gone without a CANCELLED frame on this connection: settled from the order's own record
983
+ // (/user/historic-orders, bounded retries for its indexing lag) — cancelled, filled /
984
+ // triggered, or still unproven.
985
+ const settled = STOP_TYPES.has(o.type) ? stopHistoryVerdict(await this.settledByHistory(o.symbol, o.order_id)) : await this.settledByHistory(o.symbol, o.order_id);
986
+ if (settled === "cancelled")
987
+ result.cancelled.push(refOf(o.order_id));
988
+ else if (settled === "filled")
989
+ result.failed.push({ ref: refOf(o.order_id), error: `order ${o.order_id} ${STOP_TYPES.has(o.type) ? "triggered / " : ""}filled before the cancel` });
990
+ else if (settled.startsWith(QFEX_PARTIAL_PREFIX))
991
+ result.failed.push({ ref: refOf(o.order_id), error: `order ${o.order_id} ${settled} — check the position: perp account positions -e qfex` });
992
+ else
993
+ pending.push({ ref: refOf(o.order_id), reason: `no longer listed after cancel_all_orders, and its historic-orders row does not settle it yet (${settled})`, remediation: "Check: perp account orders -e qfex / perp account positions -e qfex" });
994
+ continue;
995
+ }
996
+ if (STOP_TYPES.has(o.type)) {
997
+ const one = await session.cancelStopOrder(o.symbol, o.order_id);
998
+ if (one.kind === "cancelled")
999
+ result.cancelled.push(refOf(o.order_id));
1000
+ else if (one.kind === "refused")
1001
+ result.failed.push({ ref: refOf(o.order_id), error: `venue answered ${one.status}` });
1002
+ else {
1003
+ // Observed on mainnet (2026-10-13): cancel_stop_order on a reduce-only stop whose position
1004
+ // was just closed answers "ServerError: unexpected error" while the venue removes the stop
1005
+ // itself. Unknown stays unknown unless a re-list AND the order's own record settle it.
1006
+ const settled = await this.settleUnknownStopCancel(o.symbol, o.order_id);
1007
+ if (settled === "cancelled")
1008
+ result.cancelled.push(refOf(o.order_id));
1009
+ else if (settled === "filled")
1010
+ result.failed.push({ ref: refOf(o.order_id), error: `order ${o.order_id} triggered / filled before the cancel` });
1011
+ else if (settled.startsWith(QFEX_PARTIAL_PREFIX))
1012
+ result.failed.push({ ref: refOf(o.order_id), error: `order ${o.order_id} ${settled}` });
1013
+ else
1014
+ pending.push({ ref: refOf(o.order_id), reason: `${one.reason}; re-check: ${settled}`, remediation: "Check: perp account orders -e qfex" });
1015
+ }
1016
+ continue;
1017
+ }
1018
+ pending.push({
1019
+ ref: refOf(o.order_id),
1020
+ reason: "still listed open after cancel_all_orders",
1021
+ remediation: "Check: perp account orders -e qfex / perp account positions -e qfex",
1022
+ });
1023
+ }
1024
+ result.venueRaw = { before: before.length, cancelAll: sent, afterOpen: after.length, excluded: "TWAP orders are not cancelled by perp-cli" };
1025
+ return result;
1026
+ }
1027
+ /**
1028
+ * How an order that disappeared from the open orders ended, from its /user/historic-orders row
1029
+ * (Q6: the row may lag): "cancelled" / "filled" when the row proves it, else "not indexed" /
1030
+ * the unproven status after HISTORY_SETTLE_TRIES reads HISTORY_SETTLE_DELAY_MS apart.
1031
+ */
1032
+ /** After an unknown stop-cancel outcome: "cancelled"/"filled" only when the stop is no longer listed open
1033
+ * AND its historic-orders row settles it; otherwise a reason string (the cancel stays pending). */
1034
+ async settleUnknownStopCancel(venueSymbol, orderId) {
1035
+ const { session } = await this.session();
1036
+ await new Promise(r => setTimeout(r, HISTORY_SETTLE_DELAY_MS));
1037
+ let open;
1038
+ try {
1039
+ open = await session.getUserOrders(venueSymbol);
1040
+ }
1041
+ catch (e) {
1042
+ // The cancel outcome stays unknown; the caller reports it pending (never thrown away).
1043
+ return `open-order re-list failed: ${e instanceof Error ? e.message : String(e)}`;
1044
+ }
1045
+ if (open.some(o => o.order_id === orderId))
1046
+ return "still listed open";
1047
+ return stopHistoryVerdict(await this.settledByHistory(venueSymbol, orderId));
1048
+ }
1049
+ async settledByHistory(venueSymbol, orderId) {
1050
+ let last = "not indexed";
1051
+ for (let i = 0; i < HISTORY_SETTLE_TRIES; i++) {
1052
+ if (i > 0)
1053
+ await new Promise(r => setTimeout(r, HISTORY_SETTLE_DELAY_MS));
1054
+ let rows;
1055
+ try {
1056
+ const body = await this.client.get("/user/historic-orders", { symbol: venueSymbol, limit: 1000 });
1057
+ rows = listOf(body.data, "/user/historic-orders data").filter(h => h.order_id === orderId);
1058
+ }
1059
+ catch (e) {
1060
+ last = `historic-orders read failed: ${e instanceof Error ? e.message : String(e)}`;
1061
+ continue;
1062
+ }
1063
+ if (rows.length !== 1) {
1064
+ last = rows.length === 0 ? "not indexed" : `${rows.length} rows`;
1065
+ continue;
1066
+ }
1067
+ const h = rows[0];
1068
+ const cancelledStatus = h.terminal_status === "CANCELLED" || h.terminal_status === "CANCELLED_STP" || h.terminal_status === "EXPIRED";
1069
+ // Filled qty on a CANCELLED row = partially filled, remainder cancelled — not "filled".
1070
+ if (Number(h.filled_qty) > 0)
1071
+ return cancelledStatus ? `${QFEX_PARTIAL_PREFIX} (${String(h.filled_qty)}) before the cancel; the remainder was cancelled` : "filled";
1072
+ const cls = qfexStatusClass(h.terminal_status);
1073
+ if (cancelledStatus)
1074
+ return "cancelled";
1075
+ last = `terminal_status ${JSON.stringify(h.terminal_status)} (${cls})`;
1076
+ }
1077
+ return last;
1078
+ }
1079
+ async applyLeverage(symbol, leverage, marginMode) {
1080
+ if (marginMode === "isolated") {
1081
+ throw unsupported("qfex", "isolatedMargin", "every QFEX market is cross-margin", "Omit --isolated");
1082
+ }
1083
+ if (!Number.isFinite(leverage) || leverage < 1)
1084
+ throw new PerpError("INVALID_PARAMS", `QFEX leverage must be a number ≥ 1 (got ${leverage})`);
1085
+ const i = await this.instrumentOf(symbol);
1086
+ if (leverage > i.maxLeverage) {
1087
+ throw new PerpError("INVALID_PARAMS", `QFEX ${i.venueSymbol}: leverage ${leverage} exceeds the market maximum ${i.maxLeverage}`, { exchange: "qfex" });
1088
+ }
1089
+ this.requireMeta();
1090
+ const raw = await this.client.post("/user/leverage", null, { symbol: i.venueSymbol, leverage });
1091
+ if (raw === null || typeof raw.leverage !== "number") {
1092
+ throw new PerpError("EXCHANGE_ERROR", "QFEX POST /user/leverage: response does not report the leverage", { exchange: "qfex", venueRaw: raw });
1093
+ }
1094
+ return { venue: "qfex", symbol: i.symbol, leverage: raw.leverage, marginMode: "cross", venueRaw: raw };
1095
+ }
1096
+ // ── Legacy ExchangeAdapter: market data ──
1097
+ async getMarkets() {
1098
+ return (await this.getVenueMarkets()).map(toLegacyMarketInfo);
1099
+ }
1100
+ async getOrderbook(symbol) {
1101
+ const venueSymbol = qfexVenueSymbol(symbol);
1102
+ const book = await this.client.publicGet(`/md/orderbook/${encodeURIComponent(venueSymbol)}`, null);
1103
+ // The venue lists empty price levels (quantity "0"); they are not part of the book.
1104
+ const side = (v, what) => listOf(v, what)
1105
+ .filter(l => Array.isArray(l) && typeof l[0] === "string" && typeof l[1] === "string" && Number(l[1]) > 0)
1106
+ .map(l => [l[0], l[1]]);
1107
+ return { bids: side(book.bids, "orderbook bids"), asks: side(book.asks, "orderbook asks") };
1108
+ }
1109
+ async getRecentTrades() {
1110
+ throw unsupported("qfex", "recentTrades", "the documented public trade endpoint (GET /public/trades) answers 404", "Use the order book: perp market book <symbol> -e qfex");
1111
+ }
1112
+ /** Hourly funding windows (GET /funding/{symbol}, intervalMinutes 60). `limit` = hours back (default 24). */
1113
+ async getFundingHistory(symbol, limit) {
1114
+ const venueSymbol = qfexVenueSymbol(symbol);
1115
+ const hours = limit === undefined ? 24 : limit;
1116
+ if (!Number.isInteger(hours) || hours < 1)
1117
+ throw new PerpError("INVALID_PARAMS", `funding history limit must be a positive integer of hours (got ${hours})`);
1118
+ const to = Date.now();
1119
+ const body = await this.client.publicGet(`/funding/${encodeURIComponent(venueSymbol)}`, {
1120
+ intervalMinutes: 60, fromISO: new Date(to - hours * 3_600_000).toISOString(), toISO: new Date(to).toISOString(),
1121
+ });
1122
+ const out = [];
1123
+ for (const r of listOf(body.data, "funding data")) {
1124
+ if (r.rate === null)
1125
+ continue; // the venue computed no rate for that window
1126
+ const time = typeof r.windowStart === "string" ? Date.parse(r.windowStart) : NaN;
1127
+ if (!Number.isFinite(time))
1128
+ throw new PerpError("EXCHANGE_ERROR", `QFEX funding history: invalid windowStart ${JSON.stringify(r.windowStart)}`);
1129
+ out.push({ time, rate: plainNumber(r.rate, "funding rate"), price: null });
1130
+ }
1131
+ return out.sort((a, b) => a.time - b.time);
1132
+ }
1133
+ async getKlines(symbol, interval, startTime, endTime) {
1134
+ const resolution = KLINE_RESOLUTION[interval.trim()];
1135
+ if (resolution === undefined) {
1136
+ throw new PerpError("INVALID_PARAMS", `Unsupported kline interval "${interval}" on QFEX`, { remediation: `Use one of: ${Object.keys(KLINE_RESOLUTION).join(", ")}` });
1137
+ }
1138
+ const body = await this.client.publicGet(`/candles/${encodeURIComponent(qfexVenueSymbol(symbol))}`, {
1139
+ resolution, fromISO: new Date(startTime).toISOString(), toISO: new Date(endTime).toISOString(),
1140
+ });
1141
+ return listOf(body.candles, "candles").map(c => {
1142
+ const time = Date.parse(c.startedAt);
1143
+ if (!Number.isFinite(time))
1144
+ throw new PerpError("EXCHANGE_ERROR", `QFEX candles: invalid startedAt "${c.startedAt}"`);
1145
+ return { time, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.baseTokenVolume, trades: null };
1146
+ }).sort((a, b) => a.time - b.time);
1147
+ }
1148
+ // ── Legacy ExchangeAdapter: account ──
1149
+ async userPositions() {
1150
+ this.requireMeta();
1151
+ const body = await this.client.get("/user/positions", null);
1152
+ if (body.balance === undefined || body.balance === null)
1153
+ throw new PerpError("EXCHANGE_ERROR", "QFEX /user/positions: response has no balance");
1154
+ return { balance: body.balance, positions: listOf(body.positions, "/user/positions positions") };
1155
+ }
1156
+ /** Equity is the venue's own figure (GET /user/subaccounts/equity) — never recomputed from balance fields. */
1157
+ async getBalance() {
1158
+ const meta = this.requireMeta();
1159
+ const { balance } = await this.userPositions();
1160
+ const eq = await this.client.getAs("/user/subaccounts/equity", null, null);
1161
+ const row = listOf(eq.accounts, "/user/subaccounts/equity accounts")
1162
+ .find(a => typeof a.account_id === "string" && a.account_id.toLowerCase() === meta.accountId);
1163
+ if (row === undefined) {
1164
+ throw new PerpError("EXCHANGE_ERROR", `QFEX /user/subaccounts/equity lists no account ${meta.accountId}`, {
1165
+ exchange: "qfex", remediation: "The key may no longer cover that account — re-import: perp wallet agent import qfex --replace",
1166
+ });
1167
+ }
1168
+ return {
1169
+ equity: plainNumber(row.equity, "equity"),
1170
+ available: plainNumber(balance.available_balance, "available_balance"),
1171
+ marginUsed: sumDecimals([plainNumber(balance.order_margin, "order_margin"), plainNumber(balance.position_margin, "position_margin")]),
1172
+ unrealizedPnl: plainNumber(balance.unrealised_pnl, "unrealised_pnl"),
1173
+ };
1174
+ }
1175
+ /**
1176
+ * `position` is a signed quantity: positive = long, negative = short (the response has no
1177
+ * side field; Q10). requireReducible reads the same sign for reduce-only orders. The mark
1178
+ * comes from the MDS snapshot; the venue reports no liquidation price.
1179
+ */
1180
+ async getPositions() {
1181
+ const { positions } = await this.userPositions();
1182
+ const open = positions.filter(p => typeof p.position === "number" && p.position !== 0);
1183
+ if (open.length === 0)
1184
+ return [];
1185
+ const instruments = await this.instrumentMap();
1186
+ const snap = await fetchQfexMarketStats(QFEX_ENDPOINTS[this.network].mdsWs, "*", QFEX_MDS_TIMEOUT_MS);
1187
+ return open.map(p => {
1188
+ const i = instruments.get(p.symbol);
1189
+ if (i === undefined)
1190
+ throw new PerpError("EXCHANGE_ERROR", `QFEX position on ${p.symbol}, which /refdata does not list`, { exchange: "qfex" });
1191
+ const mark = snap.marks.get(p.symbol);
1192
+ if (mark === undefined)
1193
+ throw new PerpError("PRICE_STALE", `QFEX: no mark price for ${p.symbol} (open position)`, { exchange: "qfex" });
1194
+ return {
1195
+ symbol: i.symbol,
1196
+ side: p.position > 0 ? "long" : "short",
1197
+ size: fromWireNumber(Math.abs(p.position), i.lotSize, "position"),
1198
+ entryPrice: plainNumber(p.average_price, "average_price"),
1199
+ markPrice: mark.price,
1200
+ liquidationPrice: null,
1201
+ unrealizedPnl: plainNumber(p.unrealised_pnl, "unrealised_pnl"),
1202
+ leverage: p.leverage,
1203
+ };
1204
+ });
1205
+ }
1206
+ async getOpenOrders() {
1207
+ const { session } = await this.session();
1208
+ const rows = await session.getUserOrders(null);
1209
+ const instruments = await this.instrumentMap();
1210
+ return rows.map(o => {
1211
+ const i = instruments.get(o.symbol);
1212
+ if (i === undefined)
1213
+ throw new PerpError("EXCHANGE_ERROR", `QFEX open order on ${o.symbol}, which /refdata does not list`, { exchange: "qfex" });
1214
+ const quantity = fromWireNumber(o.quantity, i.lotSize, "quantity");
1215
+ const price = fromWireNumber(o.price, i.tickSize, "price");
1216
+ const stop = STOP_TYPES.has(o.type);
1217
+ return {
1218
+ orderId: o.order_id,
1219
+ symbol: i.symbol,
1220
+ side: o.side === "BUY" ? "buy" : "sell",
1221
+ price,
1222
+ size: quantity,
1223
+ filled: subDecimals(quantity, fromWireNumber(o.quantity_remaining, i.lotSize, "quantity_remaining"), "filled quantity"),
1224
+ status: stop ? "untriggered" : "open",
1225
+ type: o.type,
1226
+ triggerPrice: stop ? price : null,
1227
+ };
1228
+ });
1229
+ }
1230
+ async getOrderHistory(limit) {
1231
+ this.requireMeta();
1232
+ const body = await this.client.get("/user/historic-orders", { limit: limit === undefined ? null : limit });
1233
+ return listOf(body.data, "/user/historic-orders data").map(h => ({
1234
+ orderId: h.order_id,
1235
+ symbol: qfexPlainSymbol(h.symbol),
1236
+ side: h.side === "BUY" ? "buy" : "sell",
1237
+ price: plainNumber(h.price, "price"),
1238
+ size: plainNumber(h.quantity, "quantity"),
1239
+ filled: plainNumber(h.filled_qty, "filled_qty"),
1240
+ status: h.terminal_status,
1241
+ type: h.type,
1242
+ triggerPrice: null,
1243
+ }));
1244
+ }
1245
+ /** `order_timestamp` is unix seconds with a fraction (the same unit as order_response.update_time). */
1246
+ async getTradeHistory(limit) {
1247
+ this.requireMeta();
1248
+ const body = await this.client.get("/user/trade", { limit: limit === undefined ? null : limit });
1249
+ return listOf(body.data, "/user/trade data").map(t => ({
1250
+ time: Math.round(Number(plainNumber(t.order_timestamp, "order_timestamp")) * 1000),
1251
+ symbol: qfexPlainSymbol(t.symbol),
1252
+ side: t.side === "BUY" ? "buy" : "sell",
1253
+ price: plainNumber(t.price, "price"),
1254
+ size: plainNumber(t.quantity, "quantity"),
1255
+ fee: plainNumber(t.fee, "fee"),
1256
+ }));
1257
+ }
1258
+ async getFundingPayments() {
1259
+ throw unsupported("qfex", "fundingPayments", "funding payments are GET /executions rows, but the execution_type that marks them is not documented (Q10)", "See funding in the QFEX web app; net funding per position is in: perp account positions -e qfex --json");
1260
+ }
1261
+ // ── Legacy ExchangeAdapter: trading (shim → TradingV2, design §10.7). Client ids are generated here. ──
1262
+ async marketOrder(symbol, side, size, opts) {
1263
+ return this.placeOrder({
1264
+ symbol, side, type: "market", size, price: null, triggerPrice: null, triggerSource: null, tif: null,
1265
+ reduceOnly: opts.reduceOnly, clientOrderId: qfexClientOrderId(), slippage: opts.slippage, tpsl: null,
1266
+ });
1267
+ }
1268
+ async limitOrder(symbol, side, price, size, opts) {
1269
+ const tifInput = opts?.tif;
1270
+ const tif = tifInput === undefined || tifInput === "" ? null : validateLegacyTif(this.venue, this.caps, tifInput);
1271
+ return this.placeOrder({
1272
+ symbol, side, type: "limit", size, price, triggerPrice: null, triggerSource: null, tif,
1273
+ reduceOnly: opts?.reduceOnly === true, clientOrderId: qfexClientOrderId(), slippage: null, tpsl: null,
1274
+ });
1275
+ }
1276
+ async editOrder(symbol, orderId, price, size) {
1277
+ return this.amendOrder({ symbol, ref: parseOrderRef(orderId), price, size, tif: null });
1278
+ }
1279
+ async cancelOrder(symbol, orderId) {
1280
+ return this.cancelByRef(symbol, parseOrderRef(orderId));
1281
+ }
1282
+ async cancelAllOrders(symbol) {
1283
+ return this.cancelAllV2(symbol === undefined ? null : symbol);
1284
+ }
1285
+ async setLeverage(symbol, leverage, marginMode) {
1286
+ return this.applyLeverage(symbol, leverage, marginMode === undefined ? null : marginMode);
1287
+ }
1288
+ async stopOrder(symbol, side, size, triggerPrice, opts) {
1289
+ return this.placeOrder({
1290
+ symbol, side, type: opts?.limitPrice !== undefined ? "stopLimit" : "stopMarket", size,
1291
+ price: opts?.limitPrice === undefined ? null : opts.limitPrice, triggerPrice,
1292
+ triggerSource: opts?.triggerType === undefined ? null : opts.triggerType,
1293
+ tif: opts?.limitPrice !== undefined ? "GTC" : null,
1294
+ reduceOnly: opts?.reduceOnly === true, clientOrderId: qfexClientOrderId(), slippage: null, tpsl: null,
1295
+ });
1296
+ }
1297
+ // ── Funds (design §14.6, §14.12 #8) ──
1298
+ /**
1299
+ * Deposit address for USDC on Arbitrum One (banker GET /address). The address is returned
1300
+ * only when the venue confirms exactly that network and asset — anything else is refused,
1301
+ * because funds sent on another network / asset are lost.
1302
+ */
1303
+ async depositAddress() {
1304
+ this.requireMeta();
1305
+ const r = await this.client.bankerGet("/address", { network: QFEX_BANKER_NETWORK });
1306
+ if (r.network !== QFEX_BANKER_NETWORK || r.asset !== "USDC" || typeof r.address !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(r.address)) {
1307
+ throw new PerpError("EXCHANGE_ERROR", `QFEX returned a deposit address for network ${JSON.stringify(r.network)} / asset ${JSON.stringify(r.asset)}, not ${QFEX_BANKER_NETWORK} / USDC — it is not shown`, {
1308
+ exchange: "qfex", remediation: "Use the QFEX web app funding screen; sending another asset or network to a deposit address loses the funds",
1309
+ });
1310
+ }
1311
+ return { address: r.address, network: r.network, asset: r.asset };
1312
+ }
1313
+ /**
1314
+ * Withdraw USDC to an Arbitrum One address (banker POST /withdraw — NOT idempotent).
1315
+ * Authority is the API key alone, so the local withdraw gate (settings.withdraw.qfex:
1316
+ * allowlist, maxPerTx, maxPerDay) is enforced HERE; absent → refused. The venue may still
1317
+ * refuse: QFEX documents that withdrawals need 2FA on the web app.
1318
+ * No balance pre-check: the venue's Withdrawable Balance is not exposed by the API and
1319
+ * `available_balance` is a different quantity — the venue's own 400 is the answer.
1320
+ */
1321
+ async withdraw(amount, destination, opts) {
1322
+ const meta = this.requireMeta();
1323
+ if (opts?.assetId !== undefined || opts?.routeType !== undefined) {
1324
+ throw new PerpError("INVALID_PARAMS", "QFEX withdraw takes no assetId/routeType (USDC on Arbitrum One only)");
1325
+ }
1326
+ if (!/^0x[0-9a-fA-F]{40}$/.test(destination)) {
1327
+ throw new PerpError("INVALID_PARAMS", `QFEX withdraw destination must be an EVM (Arbitrum One) address (got "${destination}")`);
1328
+ }
1329
+ if (!/^\d+(\.\d{1,6})?$/.test(amount) || !(Number(amount) > 0)) {
1330
+ throw new PerpError("INVALID_PARAMS", `Withdraw amount must be a positive decimal with at most 6 decimals (got "${amount}")`);
1331
+ }
1332
+ if (!meta.isPrimaryAccount) {
1333
+ throw new PerpError("INVALID_PARAMS", `QFEX withdrawals leave the primary account; key "${meta.agentName}" is bound to subaccount ${meta.accountId}`, {
1334
+ remediation: "Move funds to the primary account first (perp funds transfer <amount> primary -e qfex), then withdraw with a key bound to the primary account",
1335
+ });
1336
+ }
1337
+ const now = Date.now();
1338
+ assertWithdrawAllowed("qfex", this.network, amount, destination, this.withdrawGate, now);
1339
+ const body = { amount: toWireNumber(amount, "amount"), address: destination, network: QFEX_BANKER_NETWORK };
1340
+ const attempt = recordWithdrawAttempt("qfex", this.network, amount, destination, now);
1341
+ try {
1342
+ await this.client.bankerPost("/withdraw", body);
1343
+ }
1344
+ catch (e) {
1345
+ // Only an explicit venue answer (HTTP < 500) proves nothing left the account.
1346
+ const http = e instanceof PerpError ? e.structured.details?.httpStatus : undefined;
1347
+ if (e instanceof PerpError && e.structured.code !== "ORDER_PENDING" && typeof http === "number" && http < 500)
1348
+ forgetWithdrawAttempt(attempt);
1349
+ throw e;
1350
+ }
1351
+ return {
1352
+ accepted: true, amount, address: destination, network: QFEX_BANKER_NETWORK, asset: "USDC", fee: null,
1353
+ note: "Accepted and debited by QFEX. The network fee is taken from the amount (not published); on-chain completion may wait for a manual review.",
1354
+ };
1355
+ }
1356
+ /** The user's accounts (GET /user/subaccounts/balance): id, primary flag, available balance. */
1357
+ async listAccounts() {
1358
+ this.requireMeta();
1359
+ const body = await this.client.getAs("/user/subaccounts/balance", null, null);
1360
+ return listOf(body.accounts, "/user/subaccounts/balance accounts").map(a => {
1361
+ if (typeof a.account_id !== "string")
1362
+ throw new PerpError("EXCHANGE_ERROR", "QFEX /user/subaccounts/balance: account without account_id");
1363
+ return { accountId: a.account_id.toLowerCase(), isPrimary: a.is_master === true, available: plainNumber(a.available_balance, "available_balance") };
1364
+ });
1365
+ }
1366
+ /**
1367
+ * Move USDC between two of the user's accounts (POST /user/transfer → 204). Idempotency and
1368
+ * the needed permission are unverified (Q16): an unknown outcome is ORDER_PENDING, never re-sent.
1369
+ */
1370
+ async transferBetweenAccounts(amount, fromAccountId, toAccountId) {
1371
+ this.requireMeta();
1372
+ if (!/^\d+(\.\d{1,6})?$/.test(amount) || !(Number(amount) > 0)) {
1373
+ throw new PerpError("INVALID_PARAMS", `Transfer amount must be a positive decimal with at most 6 decimals (got "${amount}")`);
1374
+ }
1375
+ if (!UUID_RE.test(fromAccountId) || !UUID_RE.test(toAccountId))
1376
+ throw new PerpError("INVALID_PARAMS", "QFEX transfer accounts must be account UUIDs");
1377
+ if (fromAccountId.toLowerCase() === toAccountId.toLowerCase())
1378
+ throw new PerpError("INVALID_PARAMS", "QFEX transfer: source and destination are the same account");
1379
+ await this.client.post("/user/transfer", { src_account_id: fromAccountId, dst_account_id: toAccountId }, { amount: toWireNumber(amount, "amount") });
1380
+ return { amount, from: fromAccountId, to: toAccountId };
1381
+ }
1382
+ }
1383
+ export { qfexPendingRemediation };