perp-cli 0.12.15 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +278 -31
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +286 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +74 -84
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +286 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +939 -245
  103. package/dist/commands/history.js +72 -45
  104. package/dist/commands/init.d.ts +107 -15
  105. package/dist/commands/init.js +1116 -249
  106. package/dist/commands/jobs.d.ts +2 -1
  107. package/dist/commands/jobs.js +77 -15
  108. package/dist/commands/manage.js +136 -91
  109. package/dist/commands/market.d.ts +4 -1
  110. package/dist/commands/market.js +35 -32
  111. package/dist/commands/outcome.d.ts +10 -0
  112. package/dist/commands/outcome.js +396 -0
  113. package/dist/commands/plan.js +52 -12
  114. package/dist/commands/portfolio.d.ts +1 -0
  115. package/dist/commands/portfolio.js +60 -72
  116. package/dist/commands/rebalance.d.ts +2 -1
  117. package/dist/commands/rebalance.js +33 -43
  118. package/dist/commands/risk.js +46 -19
  119. package/dist/commands/settings.d.ts +1 -0
  120. package/dist/commands/settings.js +246 -51
  121. package/dist/commands/trade.d.ts +50 -0
  122. package/dist/commands/trade.js +1943 -695
  123. package/dist/commands/wallet.d.ts +30 -1
  124. package/dist/commands/wallet.js +316 -337
  125. package/dist/config.d.ts +60 -9
  126. package/dist/config.js +252 -82
  127. package/dist/constants.d.ts +29 -4
  128. package/dist/constants.js +62 -20
  129. package/dist/dashboard/server.d.ts +2 -0
  130. package/dist/dashboard/server.js +9 -6
  131. package/dist/dashboard/ui.js +6 -4
  132. package/dist/dashboard/ws-feeds.js +20 -8
  133. package/dist/dex-asset-map.js +5 -3
  134. package/dist/errors.d.ts +122 -0
  135. package/dist/errors.js +194 -0
  136. package/dist/event-stream.js +75 -19
  137. package/dist/evm-master-tx.d.ts +59 -0
  138. package/dist/evm-master-tx.js +154 -0
  139. package/dist/exchanges/adapter-network.d.ts +10 -0
  140. package/dist/exchanges/adapter-network.js +10 -0
  141. package/dist/exchanges/arcus/adapter.d.ts +196 -0
  142. package/dist/exchanges/arcus/adapter.js +1092 -0
  143. package/dist/exchanges/arcus/agent.d.ts +55 -0
  144. package/dist/exchanges/arcus/agent.js +188 -0
  145. package/dist/exchanges/arcus/client.d.ts +63 -0
  146. package/dist/exchanges/arcus/client.js +285 -0
  147. package/dist/exchanges/arcus/public.d.ts +65 -0
  148. package/dist/exchanges/arcus/public.js +138 -0
  149. package/dist/exchanges/arcus/signing.d.ts +176 -0
  150. package/dist/exchanges/arcus/signing.js +288 -0
  151. package/dist/exchanges/arcus/spot-chain.d.ts +60 -0
  152. package/dist/exchanges/arcus/spot-chain.js +241 -0
  153. package/dist/exchanges/arcus/spot-router.d.ts +177 -0
  154. package/dist/exchanges/arcus/spot-router.js +183 -0
  155. package/dist/exchanges/arcus/spot.d.ts +109 -0
  156. package/dist/exchanges/arcus/spot.js +645 -0
  157. package/dist/exchanges/aster-funds.d.ts +127 -0
  158. package/dist/exchanges/aster-funds.js +252 -0
  159. package/dist/exchanges/aster-nonce.d.ts +2 -0
  160. package/dist/exchanges/aster-nonce.js +107 -0
  161. package/dist/exchanges/aster-spot.d.ts +117 -0
  162. package/dist/exchanges/aster-spot.js +686 -0
  163. package/dist/exchanges/aster-typed-data.d.ts +133 -7
  164. package/dist/exchanges/aster-typed-data.js +121 -7
  165. package/dist/exchanges/aster.d.ts +219 -6
  166. package/dist/exchanges/aster.js +1023 -153
  167. package/dist/exchanges/builder-fee.d.ts +254 -0
  168. package/dist/exchanges/builder-fee.js +421 -0
  169. package/dist/exchanges/canonical-symbols.d.ts +32 -0
  170. package/dist/exchanges/canonical-symbols.js +96 -0
  171. package/dist/exchanges/capabilities.d.ts +7 -1
  172. package/dist/exchanges/capabilities.js +7 -0
  173. package/dist/exchanges/hl-signing.d.ts +165 -0
  174. package/dist/exchanges/hl-signing.js +158 -0
  175. package/dist/exchanges/hyperliquid-outcome.d.ts +211 -0
  176. package/dist/exchanges/hyperliquid-outcome.js +646 -0
  177. package/dist/exchanges/hyperliquid-spot.d.ts +44 -5
  178. package/dist/exchanges/hyperliquid-spot.js +308 -117
  179. package/dist/exchanges/hyperliquid.d.ts +462 -57
  180. package/dist/exchanges/hyperliquid.js +1575 -449
  181. package/dist/exchanges/index.d.ts +6 -0
  182. package/dist/exchanges/index.js +4 -0
  183. package/dist/exchanges/interface.d.ts +63 -10
  184. package/dist/exchanges/interface.js +18 -3
  185. package/dist/exchanges/ioc-outcome.d.ts +44 -0
  186. package/dist/exchanges/ioc-outcome.js +57 -0
  187. package/dist/exchanges/legacy-trading-v2.d.ts +39 -0
  188. package/dist/exchanges/legacy-trading-v2.js +940 -0
  189. package/dist/exchanges/lighter-public.d.ts +43 -0
  190. package/dist/exchanges/lighter-public.js +276 -0
  191. package/dist/exchanges/lighter-rh-deposit.d.ts +62 -0
  192. package/dist/exchanges/lighter-rh-deposit.js +185 -0
  193. package/dist/exchanges/lighter-signer.d.ts +123 -0
  194. package/dist/exchanges/lighter-signer.js +215 -0
  195. package/dist/exchanges/lighter-spot.d.ts +91 -13
  196. package/dist/exchanges/lighter-spot.js +595 -233
  197. package/dist/exchanges/lighter.d.ts +611 -39
  198. package/dist/exchanges/lighter.js +1890 -649
  199. package/dist/exchanges/ondo/adapter.d.ts +166 -0
  200. package/dist/exchanges/ondo/adapter.js +835 -0
  201. package/dist/exchanges/ondo/agent.d.ts +31 -0
  202. package/dist/exchanges/ondo/agent.js +101 -0
  203. package/dist/exchanges/ondo/client.d.ts +60 -0
  204. package/dist/exchanges/ondo/client.js +297 -0
  205. package/dist/exchanges/ondo/funds.d.ts +122 -0
  206. package/dist/exchanges/ondo/funds.js +290 -0
  207. package/dist/exchanges/ondo/public.d.ts +95 -0
  208. package/dist/exchanges/ondo/public.js +214 -0
  209. package/dist/exchanges/ondo/spot.d.ts +127 -0
  210. package/dist/exchanges/ondo/spot.js +392 -0
  211. package/dist/exchanges/outcome-interface.d.ts +140 -0
  212. package/dist/exchanges/outcome-interface.js +16 -0
  213. package/dist/exchanges/pacifica-spot.d.ts +83 -0
  214. package/dist/exchanges/pacifica-spot.js +324 -0
  215. package/dist/exchanges/pacifica-typed-data.d.ts +30 -13
  216. package/dist/exchanges/pacifica-typed-data.js +57 -9
  217. package/dist/exchanges/pacifica.d.ts +288 -4
  218. package/dist/exchanges/pacifica.js +1013 -76
  219. package/dist/exchanges/precision.d.ts +47 -0
  220. package/dist/exchanges/precision.js +209 -0
  221. package/dist/exchanges/qfex/adapter.d.ts +341 -0
  222. package/dist/exchanges/qfex/adapter.js +1383 -0
  223. package/dist/exchanges/qfex/agent.d.ts +58 -0
  224. package/dist/exchanges/qfex/agent.js +188 -0
  225. package/dist/exchanges/qfex/client.d.ts +54 -0
  226. package/dist/exchanges/qfex/client.js +138 -0
  227. package/dist/exchanges/qfex/errors.d.ts +70 -0
  228. package/dist/exchanges/qfex/errors.js +236 -0
  229. package/dist/exchanges/qfex/public.d.ts +122 -0
  230. package/dist/exchanges/qfex/public.js +268 -0
  231. package/dist/exchanges/qfex/signing.d.ts +160 -0
  232. package/dist/exchanges/qfex/signing.js +283 -0
  233. package/dist/exchanges/qfex/ws-transport.d.ts +294 -0
  234. package/dist/exchanges/qfex/ws-transport.js +798 -0
  235. package/dist/exchanges/rate-limiter.d.ts +12 -0
  236. package/dist/exchanges/rate-limiter.js +64 -0
  237. package/dist/exchanges/registry.d.ts +14 -2
  238. package/dist/exchanges/registry.js +20 -32
  239. package/dist/exchanges/slippage.d.ts +37 -0
  240. package/dist/exchanges/slippage.js +70 -0
  241. package/dist/exchanges/spot-factory.d.ts +166 -0
  242. package/dist/exchanges/spot-factory.js +431 -0
  243. package/dist/exchanges/spot-interface.d.ts +98 -4
  244. package/dist/exchanges/spot-interface.js +27 -1
  245. package/dist/exchanges/spot-result.d.ts +79 -0
  246. package/dist/exchanges/spot-result.js +273 -0
  247. package/dist/exchanges/trading-v2.d.ts +117 -0
  248. package/dist/exchanges/trading-v2.js +301 -0
  249. package/dist/exchanges/venue-types.d.ts +231 -0
  250. package/dist/exchanges/venue-types.js +87 -0
  251. package/dist/exchanges/venue.d.ts +211 -0
  252. package/dist/exchanges/venue.js +132 -0
  253. package/dist/exchanges/venues/arcus.d.ts +8 -0
  254. package/dist/exchanges/venues/arcus.js +109 -0
  255. package/dist/exchanges/venues/aster.d.ts +49 -0
  256. package/dist/exchanges/venues/aster.js +176 -0
  257. package/dist/exchanges/venues/create-helpers.d.ts +18 -0
  258. package/dist/exchanges/venues/create-helpers.js +199 -0
  259. package/dist/exchanges/venues/hyperliquid.d.ts +23 -0
  260. package/dist/exchanges/venues/hyperliquid.js +126 -0
  261. package/dist/exchanges/venues/index.d.ts +16 -0
  262. package/dist/exchanges/venues/index.js +26 -0
  263. package/dist/exchanges/venues/lighter-family.d.ts +92 -0
  264. package/dist/exchanges/venues/lighter-family.js +166 -0
  265. package/dist/exchanges/venues/lighter-rh.d.ts +7 -0
  266. package/dist/exchanges/venues/lighter-rh.js +58 -0
  267. package/dist/exchanges/venues/lighter.d.ts +6 -0
  268. package/dist/exchanges/venues/lighter.js +55 -0
  269. package/dist/exchanges/venues/ondo.d.ts +34 -0
  270. package/dist/exchanges/venues/ondo.js +140 -0
  271. package/dist/exchanges/venues/pacifica.d.ts +8 -0
  272. package/dist/exchanges/venues/pacifica.js +95 -0
  273. package/dist/exchanges/venues/qfex.d.ts +28 -0
  274. package/dist/exchanges/venues/qfex.js +132 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +157 -682
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +14 -0
  287. package/dist/landing.js +36 -0
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +820 -402
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/pacifica/client.d.ts +41 -10
  293. package/dist/pacifica/client.js +167 -63
  294. package/dist/pacifica/constants.d.ts +0 -1
  295. package/dist/pacifica/constants.js +0 -5
  296. package/dist/pacifica/deposit.d.ts +30 -3
  297. package/dist/pacifica/deposit.js +119 -2
  298. package/dist/pacifica/faucet.d.ts +29 -0
  299. package/dist/pacifica/faucet.js +100 -0
  300. package/dist/pacifica/sign-tx.d.ts +15 -0
  301. package/dist/pacifica/sign-tx.js +41 -0
  302. package/dist/pacifica/types/account.d.ts +28 -4
  303. package/dist/pacifica/types/lake.d.ts +20 -5
  304. package/dist/pacifica/types/lake.js +4 -0
  305. package/dist/pacifica/types/market.d.ts +5 -0
  306. package/dist/pacifica/types/order.d.ts +4 -1
  307. package/dist/pacifica/types/position.d.ts +9 -1
  308. package/dist/plan-executor.d.ts +10 -5
  309. package/dist/plan-executor.js +165 -54
  310. package/dist/position-history.js +13 -0
  311. package/dist/rebalance.d.ts +26 -2
  312. package/dist/rebalance.js +86 -7
  313. package/dist/relay-swap.d.ts +232 -0
  314. package/dist/relay-swap.js +630 -0
  315. package/dist/risk.js +5 -1
  316. package/dist/secret-env.d.ts +15 -0
  317. package/dist/secret-env.js +36 -0
  318. package/dist/settings.d.ts +169 -6
  319. package/dist/settings.js +219 -38
  320. package/dist/signer/ows-evm.d.ts +13 -0
  321. package/dist/signer/ows-evm.js +22 -0
  322. package/dist/smart-order.d.ts +9 -0
  323. package/dist/smart-order.js +53 -8
  324. package/dist/strategies/dca.d.ts +6 -2
  325. package/dist/strategies/dca.js +66 -26
  326. package/dist/strategies/funding-arb.d.ts +11 -1
  327. package/dist/strategies/funding-arb.js +202 -89
  328. package/dist/strategies/grid.d.ts +31 -0
  329. package/dist/strategies/grid.js +89 -11
  330. package/dist/strategies/pending-stop.d.ts +8 -0
  331. package/dist/strategies/pending-stop.js +12 -0
  332. package/dist/strategies/split-order.d.ts +45 -17
  333. package/dist/strategies/split-order.js +157 -91
  334. package/dist/strategies/trailing-stop.d.ts +2 -1
  335. package/dist/strategies/trailing-stop.js +52 -6
  336. package/dist/strategies/twap.d.ts +6 -1
  337. package/dist/strategies/twap.js +39 -11
  338. package/dist/trade-validator.d.ts +22 -0
  339. package/dist/trade-validator.js +119 -15
  340. package/dist/utils/numeric.d.ts +46 -0
  341. package/dist/utils/numeric.js +79 -0
  342. package/dist/utils.d.ts +19 -0
  343. package/dist/utils.js +96 -19
  344. package/dist/withdraw-gate.d.ts +49 -0
  345. package/dist/withdraw-gate.js +131 -0
  346. package/package.json +15 -6
  347. package/skills/perp-cli/SKILL.md +338 -134
  348. package/skills/perp-cli/references/agent-operations.md +181 -92
  349. package/skills/perp-cli/references/commands.md +475 -132
  350. package/skills/perp-cli/references/strategies.md +44 -38
  351. package/skills/perp-cli/scripts/arb-monitor.sh +22 -17
  352. package/skills/perp-cli/scripts/funding-analysis.sh +20 -10
  353. package/skills/perp-cli/scripts/preflight.sh +21 -14
  354. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  355. package/skills/perp-cli/scripts/validate-arb.sh +33 -16
  356. package/vendor/lighter-signer/SHA256SUMS +2 -0
  357. package/vendor/lighter-signer/VERSION +8 -0
  358. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  359. package/vendor/lighter-signer/wasm_exec.js +575 -0
  360. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  361. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -0,0 +1,160 @@
1
+ /**
2
+ * QFEX request signing + wire-number conversion (design §14.2, §14.3).
3
+ *
4
+ * Auth (docs /websocket/channels/trade/authenticate, OpenAPI securitySchemes):
5
+ * signature = hex(HMAC-SHA256(secret, `${nonce}:${unix_ts}`))
6
+ * nonce = hex, ≤ 100 chars, unique within 15 minutes → 16 random bytes per request
7
+ * unix_ts = seconds
8
+ * The same string signs the Trade WebSocket `auth` message and every REST request
9
+ * (method, path and body are NOT part of the signature).
10
+ *
11
+ * Wire numbers: QFEX sends and expects prices / quantities as JSON numbers. toWireNumber /
12
+ * fromWireNumber are the only places a decimal string becomes a float or the reverse.
13
+ *
14
+ * Wire facts that needed a live check are single constants here; while one is unset every code
15
+ * path that needs it refuses with the uniform CAPABILITY_UNSUPPORTED response (design §14.12).
16
+ */
17
+ import { PerpError } from "../../errors.js";
18
+ export interface QfexCredentials {
19
+ publicKey: string;
20
+ secret: string;
21
+ }
22
+ /** How `add_order.params.reduce_only` is encoded. */
23
+ export type QfexReduceOnlyWire = "boolean" | "integer";
24
+ /**
25
+ * Wire format of `add_order.reduce_only`: a JSON boolean. Q2, verified on PRODUCTION
26
+ * 2026-10-05 (primary account, GOLD-USD, minimum size, long 0.003):
27
+ * - PROVEN — the flag is read: SELL LIMIT GTC with `"reduce_only": true` was acknowledged with
28
+ * `"reduce_only": true` echoed on the order_response; every order_response (plain orders
29
+ * too) carries `reduce_only` as a JSON boolean.
30
+ * - PROVEN — enforced with no position: the same frame sent flat (quantity 0.003) was rejected,
31
+ * order_response status INVALID_QUANTITY.
32
+ * - PROVEN — NOT enforced against the position size at placement: quantity 0.006 against a
33
+ * position of 0.003 was ACKed and rested with the full 0.006 (quantity_remaining 0.006).
34
+ * - PROVEN (IMMEDIATE order only) — capped at match time: SELL LIMIT IOC 0.006 @ 4125 with
35
+ * `"reduce_only": true` against long 0.003 produced three order_response frames:
36
+ * 1. ACK quantity 0.006, quantity_remaining 0.006, reduce_only TRUE
37
+ * 2. FILLED quantity 0.003 @ 4145, quantity_remaining 0.003, trade_id set, reduce_only FALSE
38
+ * 3. CANCELLED quantity 0.006, quantity_remaining 0.003, reduce_only TRUE
39
+ * and the position was flat afterwards: the venue filled up to the position size and
40
+ * cancelled the rest (no flip). Note frame 2: a fill-carrying frame echoes
41
+ * `reduce_only: false` (and its `quantity` is the fill, not the order) — normal venue
42
+ * behaviour, so the echo check judges the ACK and the other non-fill frames only
43
+ * (adapter.ts reduceOnlyUnconfirmedFrame).
44
+ * - NOT PROVEN — what a RESTING reduce-only order does once the position later shrinks or
45
+ * closes (is it capped when it eventually matches? cancelled?). Capping was shown for an
46
+ * order that matched immediately, one observation.
47
+ * Also observed the same day: a GTC order survived a forced `kill -9` of the CLI process (Q1 kill
48
+ * variant, one observation); a short position reads back as side short (Q10).
49
+ * Docs (docs.qfex.com, re-read 2026-10-05): the add_order parameter table does not list
50
+ * `reduce_only`; the samples send `"reduce_only": true // Optional` (Node, Go) and
51
+ * `"resuce_only": True # Optional` (Python, misspelled); /qfex/order-types and /qfex/faq do not
52
+ * mention reduce-only orders. So nothing about enforcement is documented.
53
+ * Because the venue accepts an oversize order, the adapter enforces the size itself before
54
+ * every reduce-only send and checks the echo after it (QfexAdapter.placeOrder). That check is
55
+ * read-then-send: a position that shrinks between the read and the match (500 ms speed bump,
56
+ * another process, a stop trigger) relies on the venue capping at match time — observed once for
57
+ * an IOC order (above), not for a resting one.
58
+ */
59
+ export declare const QFEX_REDUCE_ONLY_WIRE: QfexReduceOnlyWire | null;
60
+ /**
61
+ * Q21 — stop orders can be correlated by client id. PRODUCTION 2026-10-12 ~16:03 UTC, GOLD-USD
62
+ * (scratchpad qfex-probe.mts): `add_order {order_type:"STOP_LOSS", side:"SELL", quantity:0.003,
63
+ * price:3900 (trigger), order_time_in_force:"GTC", client_order_id, reduce_only:true}` →
64
+ * order_response ACK echoing the client_order_id and reduce_only true (order_origin
65
+ * "STOP_ORDER"); then a `stop_order_response` frame about every second (stop_order_id = the same
66
+ * id, client_order_id echoed, stop_order_type "STOPLOSS") — a heartbeat, NOT new state;
67
+ * get_user_orders lists the row with type STOP_LOSS (status untriggered). `cancel_stop_order
68
+ * {stop_order_id}` → an `ack` frame, then order_response CANCELLED with the client id.
69
+ * Docs stop_add_order: "TAKE_PROFIT and STOP_LOSS orders cannot exceed your current position".
70
+ * true → reduce-only stop-market orders (trade stop, trade tpsl, --tp/--sl legs) are placed.
71
+ */
72
+ export declare const QFEX_STOP_CLIENT_ID_ECHO: true | null;
73
+ /**
74
+ * Whether UNATTENDED automation may trade on QFEX (bots, plans, TWAP, DCA, grid, funding-arb,
75
+ * trailing stop, background jobs). UNSET. Q21 passed (stops can be placed and correlated,
76
+ * QFEX_STOP_CLIENT_ID_ECHO), but funding is unverified (Q4) and whether a RESTING reduce-only
77
+ * order that the market reaches while flat opens a position is unproven (Q2: production
78
+ * 2026-10-12 — a resting reduce-only SELL stayed OPEN after the position was closed by a plain
79
+ * sell; the venue does not cancel it), and no automated flow places its own protective stop yet.
80
+ * Every automated entry point refuses to start (QfexAdapter.automationGate →
81
+ * assertCanClosePositions, startJob). Manual commands are not affected.
82
+ * To open: set to `true` once Q2 (resting reduce-only while flat) is settled.
83
+ */
84
+ export declare const QFEX_AUTOMATION_VERIFIED: true | null;
85
+ /**
86
+ * Whether a RESTING (GTC / ALO) reduce-only order is safe on QFEX. UNSET: Q2 proved the venue
87
+ * ACCEPTS a resting reduce-only order larger than the position. Match-time capping is proven
88
+ * for an IMMEDIATE (IOC) order only (2026-10-05, see QFEX_REDUCE_ONLY_WIRE); it is NOT proven
89
+ * that an order that RESTS is capped when it matches later, nor what it does once the position
90
+ * shrinks or closes. The adapter's
91
+ * size rule runs only when the order is placed, so a resting reduce-only order could later open
92
+ * or flip a position. Until proven, reduce-only orders on QFEX are immediate only — capability
93
+ * `reduceOnlyTif: ["IOC"]` (venues/qfex.ts) — which refuses `trade limit --reduce-only` with
94
+ * GTC / ALO / FOK and `trade scale-tp` before anything is sent.
95
+ * To open (one bounded experiment, or for good once match-time capping is proven): set to
96
+ * `true`; `reduceOnlyTif` then becomes null (every TIF) and nothing else changes.
97
+ */
98
+ export declare const QFEX_RESTING_REDUCE_ONLY_VERIFIED: true | null;
99
+ /**
100
+ * Whether a MODIFIED order_response keeps the original client_order_id (Q5). TRUE — PRODUCTION
101
+ * 2026-10-12 ~16:03 UTC, GOLD-USD: a resting BUY LIMIT 4050 modified with `modify_order
102
+ * {order_id, symbol, side, order_type, quantity, price:4060, reduce_only:false}` → order_response
103
+ * MODIFIED with a NEW order_id (8e4a…, old c563…) and the SAME client_order_id. amend correlates
104
+ * by that client id and reports the new order id. A modify of a resting reduce-only order to a
105
+ * crossing price while flat → INVALID_QUANTITY (rejected, order unchanged) — reduce-only orders
106
+ * are still not amended (QfexAdapter.amendOrder).
107
+ */
108
+ export declare const QFEX_MODIFY_KEEPS_CLIENT_ID: boolean | null;
109
+ /**
110
+ * Whether order_response.user_id is the ACCOUNT id the session authenticated as (Q22).
111
+ * UNSET: a mismatch is recorded on the result and warned once, not treated as a failure
112
+ * (it may be the user id). true → a mismatch is ORDER_PENDING.
113
+ */
114
+ export declare const QFEX_USER_ID_IS_ACCOUNT: boolean | null;
115
+ /** The uniform refusal of every reduce-only path when the wire format is unset; null when it is set. */
116
+ export declare function qfexReduceOnlyGate(wire: QfexReduceOnlyWire | null): PerpError | null;
117
+ /** The uniform refusal of every unattended flow while QFEX automation is unverified; null once it is. */
118
+ export declare function qfexAutomationGate(verified: true | null): PerpError | null;
119
+ /** `reduce_only` value for the wire in the verified format. */
120
+ export declare function reduceOnlyWireValue(wire: QfexReduceOnlyWire, reduceOnly: boolean): boolean | number;
121
+ export declare function qfexNonce(): string;
122
+ export declare function qfexSignature(secret: string, nonce: string, unixTs: number): string;
123
+ /** REST auth headers; `accountId` selects a subaccount (x-qfex-requested-account-id), null = the key's primary account. */
124
+ export declare function qfexRestHeaders(creds: QfexCredentials, accountId: string | null, nowMs: number, nonce: string): Record<string, string>;
125
+ export interface QfexAuthMessage {
126
+ type: "auth";
127
+ params: {
128
+ hmac: {
129
+ public_key: string;
130
+ nonce: string;
131
+ unix_ts: number;
132
+ signature: string;
133
+ };
134
+ account_id: string;
135
+ builder_code?: string;
136
+ };
137
+ }
138
+ /** Trade WebSocket `auth` message. The account is always named explicitly (never the venue's default). */
139
+ export declare function qfexWsAuthMessage(creds: QfexCredentials, accountId: string, builderCode: string | null, nowMs: number, nonce: string): QfexAuthMessage;
140
+ /**
141
+ * Decimal string → JSON number for the wire. Refuses anything a double cannot carry exactly
142
+ * (more than 15 significant digits) and anything that does not survive the round trip.
143
+ */
144
+ export declare function toWireNumber(value: string, what: string): number;
145
+ /**
146
+ * JSON number from the venue → decimal string quantized to the decimals of `step`
147
+ * (lot_size for quantities, tick_size for prices). Non-finite / negative → EXCHANGE_ERROR.
148
+ */
149
+ export declare function fromWireNumber(n: unknown, step: string, what: string): string;
150
+ /** Exact sum of plain decimal strings. */
151
+ export declare function sumDecimals(values: readonly string[]): string;
152
+ /** a − b exactly; throws when the result would be negative. */
153
+ export declare function subDecimals(a: string, b: string, what: string): string;
154
+ export declare function decimalsEqual(a: string, b: string): boolean;
155
+ export declare function isZeroDecimal(a: string): boolean;
156
+ /** Quantity-weighted average price Σ(price×qty)/Σqty, computed on BigInt; null when Σqty is zero. */
157
+ export declare function weightedAveragePrice(fills: ReadonlyArray<{
158
+ price: string;
159
+ quantity: string;
160
+ }>): string | null;
@@ -0,0 +1,283 @@
1
+ /**
2
+ * QFEX request signing + wire-number conversion (design §14.2, §14.3).
3
+ *
4
+ * Auth (docs /websocket/channels/trade/authenticate, OpenAPI securitySchemes):
5
+ * signature = hex(HMAC-SHA256(secret, `${nonce}:${unix_ts}`))
6
+ * nonce = hex, ≤ 100 chars, unique within 15 minutes → 16 random bytes per request
7
+ * unix_ts = seconds
8
+ * The same string signs the Trade WebSocket `auth` message and every REST request
9
+ * (method, path and body are NOT part of the signature).
10
+ *
11
+ * Wire numbers: QFEX sends and expects prices / quantities as JSON numbers. toWireNumber /
12
+ * fromWireNumber are the only places a decimal string becomes a float or the reverse.
13
+ *
14
+ * Wire facts that needed a live check are single constants here; while one is unset every code
15
+ * path that needs it refuses with the uniform CAPABILITY_UNSUPPORTED response (design §14.12).
16
+ */
17
+ import { createHmac, randomBytes } from "node:crypto";
18
+ import { PerpError } from "../../errors.js";
19
+ import { unsupported } from "../trading-v2.js";
20
+ /**
21
+ * Wire format of `add_order.reduce_only`: a JSON boolean. Q2, verified on PRODUCTION
22
+ * 2026-10-05 (primary account, GOLD-USD, minimum size, long 0.003):
23
+ * - PROVEN — the flag is read: SELL LIMIT GTC with `"reduce_only": true` was acknowledged with
24
+ * `"reduce_only": true` echoed on the order_response; every order_response (plain orders
25
+ * too) carries `reduce_only` as a JSON boolean.
26
+ * - PROVEN — enforced with no position: the same frame sent flat (quantity 0.003) was rejected,
27
+ * order_response status INVALID_QUANTITY.
28
+ * - PROVEN — NOT enforced against the position size at placement: quantity 0.006 against a
29
+ * position of 0.003 was ACKed and rested with the full 0.006 (quantity_remaining 0.006).
30
+ * - PROVEN (IMMEDIATE order only) — capped at match time: SELL LIMIT IOC 0.006 @ 4125 with
31
+ * `"reduce_only": true` against long 0.003 produced three order_response frames:
32
+ * 1. ACK quantity 0.006, quantity_remaining 0.006, reduce_only TRUE
33
+ * 2. FILLED quantity 0.003 @ 4145, quantity_remaining 0.003, trade_id set, reduce_only FALSE
34
+ * 3. CANCELLED quantity 0.006, quantity_remaining 0.003, reduce_only TRUE
35
+ * and the position was flat afterwards: the venue filled up to the position size and
36
+ * cancelled the rest (no flip). Note frame 2: a fill-carrying frame echoes
37
+ * `reduce_only: false` (and its `quantity` is the fill, not the order) — normal venue
38
+ * behaviour, so the echo check judges the ACK and the other non-fill frames only
39
+ * (adapter.ts reduceOnlyUnconfirmedFrame).
40
+ * - NOT PROVEN — what a RESTING reduce-only order does once the position later shrinks or
41
+ * closes (is it capped when it eventually matches? cancelled?). Capping was shown for an
42
+ * order that matched immediately, one observation.
43
+ * Also observed the same day: a GTC order survived a forced `kill -9` of the CLI process (Q1 kill
44
+ * variant, one observation); a short position reads back as side short (Q10).
45
+ * Docs (docs.qfex.com, re-read 2026-10-05): the add_order parameter table does not list
46
+ * `reduce_only`; the samples send `"reduce_only": true // Optional` (Node, Go) and
47
+ * `"resuce_only": True # Optional` (Python, misspelled); /qfex/order-types and /qfex/faq do not
48
+ * mention reduce-only orders. So nothing about enforcement is documented.
49
+ * Because the venue accepts an oversize order, the adapter enforces the size itself before
50
+ * every reduce-only send and checks the echo after it (QfexAdapter.placeOrder). That check is
51
+ * read-then-send: a position that shrinks between the read and the match (500 ms speed bump,
52
+ * another process, a stop trigger) relies on the venue capping at match time — observed once for
53
+ * an IOC order (above), not for a resting one.
54
+ */
55
+ export const QFEX_REDUCE_ONLY_WIRE = "boolean";
56
+ /**
57
+ * Q21 — stop orders can be correlated by client id. PRODUCTION 2026-10-12 ~16:03 UTC, GOLD-USD
58
+ * (scratchpad qfex-probe.mts): `add_order {order_type:"STOP_LOSS", side:"SELL", quantity:0.003,
59
+ * price:3900 (trigger), order_time_in_force:"GTC", client_order_id, reduce_only:true}` →
60
+ * order_response ACK echoing the client_order_id and reduce_only true (order_origin
61
+ * "STOP_ORDER"); then a `stop_order_response` frame about every second (stop_order_id = the same
62
+ * id, client_order_id echoed, stop_order_type "STOPLOSS") — a heartbeat, NOT new state;
63
+ * get_user_orders lists the row with type STOP_LOSS (status untriggered). `cancel_stop_order
64
+ * {stop_order_id}` → an `ack` frame, then order_response CANCELLED with the client id.
65
+ * Docs stop_add_order: "TAKE_PROFIT and STOP_LOSS orders cannot exceed your current position".
66
+ * true → reduce-only stop-market orders (trade stop, trade tpsl, --tp/--sl legs) are placed.
67
+ */
68
+ export const QFEX_STOP_CLIENT_ID_ECHO = true;
69
+ /**
70
+ * Whether UNATTENDED automation may trade on QFEX (bots, plans, TWAP, DCA, grid, funding-arb,
71
+ * trailing stop, background jobs). UNSET. Q21 passed (stops can be placed and correlated,
72
+ * QFEX_STOP_CLIENT_ID_ECHO), but funding is unverified (Q4) and whether a RESTING reduce-only
73
+ * order that the market reaches while flat opens a position is unproven (Q2: production
74
+ * 2026-10-12 — a resting reduce-only SELL stayed OPEN after the position was closed by a plain
75
+ * sell; the venue does not cancel it), and no automated flow places its own protective stop yet.
76
+ * Every automated entry point refuses to start (QfexAdapter.automationGate →
77
+ * assertCanClosePositions, startJob). Manual commands are not affected.
78
+ * To open: set to `true` once Q2 (resting reduce-only while flat) is settled.
79
+ */
80
+ export const QFEX_AUTOMATION_VERIFIED = null;
81
+ /**
82
+ * Whether a RESTING (GTC / ALO) reduce-only order is safe on QFEX. UNSET: Q2 proved the venue
83
+ * ACCEPTS a resting reduce-only order larger than the position. Match-time capping is proven
84
+ * for an IMMEDIATE (IOC) order only (2026-10-05, see QFEX_REDUCE_ONLY_WIRE); it is NOT proven
85
+ * that an order that RESTS is capped when it matches later, nor what it does once the position
86
+ * shrinks or closes. The adapter's
87
+ * size rule runs only when the order is placed, so a resting reduce-only order could later open
88
+ * or flip a position. Until proven, reduce-only orders on QFEX are immediate only — capability
89
+ * `reduceOnlyTif: ["IOC"]` (venues/qfex.ts) — which refuses `trade limit --reduce-only` with
90
+ * GTC / ALO / FOK and `trade scale-tp` before anything is sent.
91
+ * To open (one bounded experiment, or for good once match-time capping is proven): set to
92
+ * `true`; `reduceOnlyTif` then becomes null (every TIF) and nothing else changes.
93
+ */
94
+ export const QFEX_RESTING_REDUCE_ONLY_VERIFIED = null;
95
+ /**
96
+ * Whether a MODIFIED order_response keeps the original client_order_id (Q5). TRUE — PRODUCTION
97
+ * 2026-10-12 ~16:03 UTC, GOLD-USD: a resting BUY LIMIT 4050 modified with `modify_order
98
+ * {order_id, symbol, side, order_type, quantity, price:4060, reduce_only:false}` → order_response
99
+ * MODIFIED with a NEW order_id (8e4a…, old c563…) and the SAME client_order_id. amend correlates
100
+ * by that client id and reports the new order id. A modify of a resting reduce-only order to a
101
+ * crossing price while flat → INVALID_QUANTITY (rejected, order unchanged) — reduce-only orders
102
+ * are still not amended (QfexAdapter.amendOrder).
103
+ */
104
+ export const QFEX_MODIFY_KEEPS_CLIENT_ID = true;
105
+ /**
106
+ * Whether order_response.user_id is the ACCOUNT id the session authenticated as (Q22).
107
+ * UNSET: a mismatch is recorded on the result and warned once, not treated as a failure
108
+ * (it may be the user id). true → a mismatch is ORDER_PENDING.
109
+ */
110
+ export const QFEX_USER_ID_IS_ACCOUNT = null;
111
+ const Q2_REMEDIATION = "Reduce / close the position on the QFEX web app. perp-cli sends reduce-only orders on QFEX only with the wire format of item Q2 "
112
+ + "(docs/TEST-PLAN.md) set";
113
+ /** The uniform refusal of every reduce-only path when the wire format is unset; null when it is set. */
114
+ export function qfexReduceOnlyGate(wire) {
115
+ if (wire !== null)
116
+ return null;
117
+ return unsupported("qfex", "reduceOnly", "the wire format of add_order.reduce_only is unverified and the venue may ignore the flag, which would open or flip a position", Q2_REMEDIATION);
118
+ }
119
+ /** The uniform refusal of every unattended flow while QFEX automation is unverified; null once it is. */
120
+ export function qfexAutomationGate(verified) {
121
+ if (verified === true)
122
+ return null;
123
+ return unsupported("qfex", "automation", "funding is unverified (Q4) and a resting reduce-only order the market reaches while flat is not proven harmless (Q2), and no automated flow places its own protective stop yet", "Trade QFEX with the manual commands (perp trade market / limit / stop / tpsl / close / reduce / close-all / flatten -e qfex) and watch the position yourself; "
124
+ + "automation opens once docs/TEST-PLAN.md QFEX items Q2 / Q4 are settled");
125
+ }
126
+ /** `reduce_only` value for the wire in the verified format. */
127
+ export function reduceOnlyWireValue(wire, reduceOnly) {
128
+ return wire === "boolean" ? reduceOnly : reduceOnly ? 1 : 0;
129
+ }
130
+ // ── HMAC ──
131
+ export function qfexNonce() {
132
+ return randomBytes(16).toString("hex");
133
+ }
134
+ export function qfexSignature(secret, nonce, unixTs) {
135
+ if (!/^[0-9a-f]{1,100}$/.test(nonce))
136
+ throw new PerpError("INVALID_PARAMS", "QFEX nonce must be 1-100 lowercase hex characters");
137
+ if (!Number.isInteger(unixTs) || unixTs <= 0)
138
+ throw new PerpError("INVALID_PARAMS", `QFEX unix_ts must be a positive integer of seconds (got ${unixTs})`);
139
+ return createHmac("sha256", secret).update(`${nonce}:${unixTs}`).digest("hex");
140
+ }
141
+ /** REST auth headers; `accountId` selects a subaccount (x-qfex-requested-account-id), null = the key's primary account. */
142
+ export function qfexRestHeaders(creds, accountId, nowMs, nonce) {
143
+ const unixTs = Math.floor(nowMs / 1000);
144
+ const headers = {
145
+ "x-qfex-public-key": creds.publicKey,
146
+ "x-qfex-hmac-signature": qfexSignature(creds.secret, nonce, unixTs),
147
+ "x-qfex-nonce": nonce,
148
+ "x-qfex-timestamp": String(unixTs),
149
+ };
150
+ if (accountId !== null)
151
+ headers["x-qfex-requested-account-id"] = accountId;
152
+ return headers;
153
+ }
154
+ /** Trade WebSocket `auth` message. The account is always named explicitly (never the venue's default). */
155
+ export function qfexWsAuthMessage(creds, accountId, builderCode, nowMs, nonce) {
156
+ const unixTs = Math.floor(nowMs / 1000);
157
+ const msg = {
158
+ type: "auth",
159
+ params: {
160
+ hmac: { public_key: creds.publicKey, nonce, unix_ts: unixTs, signature: qfexSignature(creds.secret, nonce, unixTs) },
161
+ account_id: accountId,
162
+ },
163
+ };
164
+ if (builderCode !== null)
165
+ msg.params.builder_code = builderCode;
166
+ return msg;
167
+ }
168
+ // ── Wire numbers ──
169
+ const PLAIN_DECIMAL = /^\d+(\.\d+)?$/;
170
+ /** A JSON double carries 15 significant decimal digits exactly. */
171
+ const MAX_SIGNIFICANT_DIGITS = 15;
172
+ function decimalsOfStep(step) {
173
+ if (!PLAIN_DECIMAL.test(step))
174
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: step "${step}" is not a plain decimal`);
175
+ const dot = step.indexOf(".");
176
+ return dot === -1 ? 0 : step.length - dot - 1;
177
+ }
178
+ /**
179
+ * Decimal string → JSON number for the wire. Refuses anything a double cannot carry exactly
180
+ * (more than 15 significant digits) and anything that does not survive the round trip.
181
+ */
182
+ export function toWireNumber(value, what) {
183
+ if (!PLAIN_DECIMAL.test(value)) {
184
+ throw new PerpError("INVALID_PARAMS", `${what} "${value}" is not a plain non-negative decimal`);
185
+ }
186
+ const digits = value.replace(".", "").replace(/^0+/, "");
187
+ if (digits.length > MAX_SIGNIFICANT_DIGITS) {
188
+ throw new PerpError("INVALID_PARAMS", `${what} ${value} has more than ${MAX_SIGNIFICANT_DIGITS} significant digits; QFEX takes prices and quantities as JSON numbers`, {
189
+ remediation: `Use a ${what} with at most ${MAX_SIGNIFICANT_DIGITS} significant digits`,
190
+ });
191
+ }
192
+ const n = Number(value);
193
+ const dot = value.indexOf(".");
194
+ const back = fromWireDecimals(n, dot === -1 ? 0 : value.length - dot - 1);
195
+ if (back !== normalize(value)) {
196
+ throw new PerpError("INVALID_PARAMS", `${what} ${value} cannot be represented exactly as a JSON number`);
197
+ }
198
+ return n;
199
+ }
200
+ function normalize(s) {
201
+ if (!s.includes("."))
202
+ return s.replace(/^0+(?=\d)/, "");
203
+ return s.replace(/^0+(?=\d)/, "").replace(/0+$/, "").replace(/\.$/, "");
204
+ }
205
+ function fromWireDecimals(n, decimals) {
206
+ return normalize(n.toFixed(decimals));
207
+ }
208
+ /**
209
+ * JSON number from the venue → decimal string quantized to the decimals of `step`
210
+ * (lot_size for quantities, tick_size for prices). Non-finite / negative → EXCHANGE_ERROR.
211
+ */
212
+ export function fromWireNumber(n, step, what) {
213
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 0) {
214
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: ${what} is not a non-negative number (${JSON.stringify(n)})`);
215
+ }
216
+ return fromWireDecimals(n, decimalsOfStep(step));
217
+ }
218
+ // ── Exact decimal sums (fills) ──
219
+ function scaled(s) {
220
+ if (!PLAIN_DECIMAL.test(s))
221
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: "${s}" is not a plain decimal`);
222
+ const [ip, fp = ""] = s.split(".");
223
+ return { int: BigInt(ip + fp), scale: fp.length };
224
+ }
225
+ function unscale(int, scale) {
226
+ const s = int.toString().padStart(scale + 1, "0");
227
+ return scale === 0 ? s : normalize(`${s.slice(0, s.length - scale)}.${s.slice(s.length - scale)}`);
228
+ }
229
+ /** Exact sum of plain decimal strings. */
230
+ export function sumDecimals(values) {
231
+ let scale = 0;
232
+ const parts = values.map(scaled);
233
+ for (const p of parts)
234
+ scale = Math.max(scale, p.scale);
235
+ let total = 0n;
236
+ for (const p of parts)
237
+ total += p.int * 10n ** BigInt(scale - p.scale);
238
+ return unscale(total, scale);
239
+ }
240
+ /** a − b exactly; throws when the result would be negative. */
241
+ export function subDecimals(a, b, what) {
242
+ const x = scaled(a);
243
+ const y = scaled(b);
244
+ const scale = Math.max(x.scale, y.scale);
245
+ const d = x.int * 10n ** BigInt(scale - x.scale) - y.int * 10n ** BigInt(scale - y.scale);
246
+ if (d < 0n)
247
+ throw new PerpError("EXCHANGE_ERROR", `QFEX: ${what}: ${a} − ${b} is negative`);
248
+ return unscale(d, scale);
249
+ }
250
+ export function decimalsEqual(a, b) {
251
+ const x = scaled(a);
252
+ const y = scaled(b);
253
+ const scale = Math.max(x.scale, y.scale);
254
+ return x.int * 10n ** BigInt(scale - x.scale) === y.int * 10n ** BigInt(scale - y.scale);
255
+ }
256
+ export function isZeroDecimal(a) {
257
+ return scaled(a).int === 0n;
258
+ }
259
+ /** Extra decimals kept on an average price beyond the price grid. */
260
+ const AVG_PRICE_EXTRA_DECIMALS = 8;
261
+ /** Quantity-weighted average price Σ(price×qty)/Σqty, computed on BigInt; null when Σqty is zero. */
262
+ export function weightedAveragePrice(fills) {
263
+ let pScale = 0;
264
+ let qScale = 0;
265
+ const rows = fills.map(f => ({ p: scaled(f.price), q: scaled(f.quantity) }));
266
+ for (const r of rows) {
267
+ pScale = Math.max(pScale, r.p.scale);
268
+ qScale = Math.max(qScale, r.q.scale);
269
+ }
270
+ let notional = 0n;
271
+ let qty = 0n;
272
+ for (const r of rows) {
273
+ const p = r.p.int * 10n ** BigInt(pScale - r.p.scale);
274
+ const q = r.q.int * 10n ** BigInt(qScale - r.q.scale);
275
+ notional += p * q;
276
+ qty += q;
277
+ }
278
+ if (qty === 0n)
279
+ return null;
280
+ // notional has scale pScale + qScale, qty has qScale → the quotient has scale pScale (+ extra).
281
+ const outScale = pScale + AVG_PRICE_EXTRA_DECIMALS;
282
+ return unscale(notional * 10n ** BigInt(AVG_PRICE_EXTRA_DECIMALS) / qty, outScale);
283
+ }