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
@@ -37,3 +37,25 @@ export interface TradeCheckParams {
37
37
  * Runs multiple checks in parallel where possible.
38
38
  */
39
39
  export declare function validateTrade(adapter: ExchangeAdapter, params: TradeCheckParams): Promise<TradeValidation>;
40
+ /**
41
+ * Hard pre-trade risk gate for manual order commands.
42
+ *
43
+ * Unlike validateTrade (which is advisory — used by `trade check` and the MCP
44
+ * preview), this is meant to run immediately before submitting a real order.
45
+ * It throws RISK_VIOLATION when the order would breach the locally configured
46
+ * limits (maxPositionUsd / maxTotalExposureUsd / maxPositions, plus any active
47
+ * critical violation that suspends trading). reduce-only and --force orders are
48
+ * exempt, so position-reducing and intentional-override flows are never blocked.
49
+ *
50
+ * Leverage is only checked when the caller passes an explicit value; the bare
51
+ * buy/sell/market/limit commands carry no leverage argument, so the gate there
52
+ * enforces notional/exposure rather than per-order leverage.
53
+ */
54
+ export declare function enforceOrderRisk(adapter: ExchangeAdapter, p: {
55
+ symbol: string;
56
+ size: number;
57
+ price?: number;
58
+ leverage?: number;
59
+ reduceOnly?: boolean;
60
+ force?: boolean;
61
+ }): Promise<void>;
@@ -1,7 +1,9 @@
1
+ import { maxLeverageForNotional } from "./exchanges/interface.js";
1
2
  import { symbolMatch } from "./utils.js";
2
3
  import { DEFAULT_TAKER_FEE } from "./constants.js";
4
+ import { PerpError } from "./errors.js";
5
+ import { resolveAdapterSlippage } from "./exchanges/slippage.js";
3
6
  // Use centralized DEFAULT_TAKER_FEE from constants.ts (0.035%)
4
- const DEFAULT_MAX_SLIPPAGE = 0.005; // 0.5%
5
7
  /**
6
8
  * Validate a trade before execution.
7
9
  * Runs multiple checks in parallel where possible.
@@ -10,6 +12,8 @@ export async function validateTrade(adapter, params) {
10
12
  const checks = [];
11
13
  const warnings = [];
12
14
  const sym = params.symbol.toUpperCase();
15
+ // Liquidity threshold = the bound a market order on this venue would carry (design §13.10 #14).
16
+ const maxSlippage = resolveAdapterSlippage(adapter, null).bps / 10_000;
13
17
  // Fetch market list, balance, and positions in parallel.
14
18
  // Defer the orderbook call until symbol membership is confirmed so invalid
15
19
  // symbols return a structured symbol_valid failure instead of a venue error.
@@ -29,11 +33,8 @@ export async function validateTrade(adapter, params) {
29
33
  checks.push({ check: "symbol_valid", passed: false, message: `Failed to load markets from ${adapter.name}: ${marketsError}` });
30
34
  return { valid: false, checks, warnings, timestamp: new Date().toISOString() };
31
35
  }
32
- // 1. Symbol validity (handle BTC vs BTC-PERP suffix variants)
33
- const market = markets.find(m => {
34
- const ms = m.symbol.toUpperCase();
35
- return ms === sym || ms === `${sym}-PERP` || ms.replace(/-PERP$/, "") === sym;
36
- });
36
+ // 1. Symbol validity (BTC vs BTC-PERP; a HIP-3 dex adapter lists "xyz:NVDA" for "NVDA")
37
+ const market = markets.find(m => symbolMatch(m.symbol, sym));
37
38
  if (market) {
38
39
  checks.push({ check: "symbol_valid", passed: true, message: `${sym} found on ${adapter.name}`, details: { maxLeverage: market.maxLeverage } });
39
40
  }
@@ -45,6 +46,13 @@ export async function validateTrade(adapter, params) {
45
46
  ? await adapter.getOrderbook(sym)
46
47
  : { bids: [], asks: [] };
47
48
  const markPrice = Number(market.markPrice);
49
+ // Rule #2: NaN slipped past the previous `markPrice <= 0` check because
50
+ // NaN comparisons are always false. Reject explicit non-finite values
51
+ // upfront so downstream NaN propagation (notional, marginRequired,
52
+ // slippage, deviation) is impossible.
53
+ if (Number.isNaN(markPrice) || markPrice === Infinity || markPrice === -Infinity) {
54
+ throw new PerpError("EXCHANGE_ERROR", `${adapter.name} returned non-finite markPrice for ${sym}: ${market.markPrice}`, { exchange: adapter.name });
55
+ }
48
56
  if (params.type === "limit" && (params.price === undefined || !Number.isFinite(params.price))) {
49
57
  throw new Error(`Limit order requires explicit price (received ${params.price}); refusing silent mark-price substitution.`);
50
58
  }
@@ -56,13 +64,25 @@ export async function validateTrade(adapter, params) {
56
64
  if (params.leverage !== undefined && (!Number.isFinite(params.leverage) || params.leverage <= 0)) {
57
65
  throw new Error(`Leverage must be a positive number (received ${params.leverage}).`);
58
66
  }
59
- const leverage = params.leverage ?? market.maxLeverage;
67
+ // `market.maxLeverage` is the TOP tier. Venues that publish notional-tiered
68
+ // caps (Hyperliquid: 36 of 233 symbols, e.g. BTC 40x but 20x above $150M)
69
+ // reject an order sized past a threshold even though the flat value allows it,
70
+ // so resolve the cap AT THIS ORDER'S NOTIONAL.
71
+ const effectiveMax = maxLeverageForNotional(market, notional);
72
+ const leverage = params.leverage ?? effectiveMax;
60
73
  if (!Number.isFinite(leverage) || leverage <= 0) {
61
74
  throw new Error(`No usable leverage for ${sym} (resolved=${leverage}); supply params.leverage explicitly.`);
62
75
  }
63
76
  const marginRequired = notional / leverage;
64
77
  // 2. Balance check
65
78
  const available = Number(balance.available);
79
+ // Rule #2: a NaN `available` would silently take the "insufficient
80
+ // balance" branch on the negative comparison and *also* render
81
+ // misleading "$NaN available" in the user-visible message. Throw
82
+ // instead so the trade flow surfaces a real venue/parsing failure.
83
+ if (!Number.isFinite(available)) {
84
+ throw new PerpError("EXCHANGE_ERROR", `${adapter.name} returned non-finite balance.available: ${balance.available}`, { exchange: adapter.name });
85
+ }
66
86
  if (params.reduceOnly) {
67
87
  // reduce-only doesn't need margin
68
88
  checks.push({ check: "balance_sufficient", passed: true, message: "Reduce-only order, no margin needed" });
@@ -96,15 +116,25 @@ export async function validateTrade(adapter, params) {
96
116
  let availableLiquidity = 0;
97
117
  let worstPrice = 0;
98
118
  for (const [px, sz] of book) {
99
- availableLiquidity += Number(px) * Number(sz);
100
- worstPrice = Number(px);
119
+ const pxN = Number(px);
120
+ const szN = Number(sz);
121
+ // Rule #2: a NaN level would silently inflate `availableLiquidity`
122
+ // to NaN, the `>= notional` comparison would be false, and the
123
+ // outer branch would emit a "Insufficient liquidity: $NaN" message.
124
+ // Reject the level explicitly so a malformed orderbook can't
125
+ // fabricate a passing or failing liquidity check.
126
+ if (!Number.isFinite(pxN) || !Number.isFinite(szN) || pxN <= 0 || szN < 0) {
127
+ throw new PerpError("EXCHANGE_ERROR", `${adapter.name} orderbook level has non-finite or non-positive value for ${sym}: px=${px} sz=${sz}`, { exchange: adapter.name });
128
+ }
129
+ availableLiquidity += pxN * szN;
130
+ worstPrice = pxN;
101
131
  if (availableLiquidity >= notional)
102
132
  break;
103
133
  }
104
134
  if (availableLiquidity >= notional) {
105
135
  const slippage = markPrice > 0 ? Math.abs(worstPrice - markPrice) / markPrice : 0;
106
- if (slippage > DEFAULT_MAX_SLIPPAGE) {
107
- checks.push({ check: "liquidity_ok", passed: false, message: `Slippage ${(slippage * 100).toFixed(2)}% exceeds ${(DEFAULT_MAX_SLIPPAGE * 100).toFixed(1)}% threshold`, details: { slippagePct: slippage * 100, worstPrice, liquidityUsd: availableLiquidity } });
136
+ if (slippage > maxSlippage) {
137
+ checks.push({ check: "liquidity_ok", passed: false, message: `Slippage ${(slippage * 100).toFixed(2)}% exceeds the ${(maxSlippage * 100).toFixed(2)}% market-order slippage bound`, details: { slippagePct: slippage * 100, worstPrice, liquidityUsd: availableLiquidity } });
108
138
  }
109
139
  else {
110
140
  checks.push({ check: "liquidity_ok", passed: true, message: `Sufficient liquidity ($${availableLiquidity.toFixed(0)}), slippage ~${(slippage * 100).toFixed(3)}%`, details: { slippagePct: slippage * 100, liquidityUsd: availableLiquidity } });
@@ -121,7 +151,11 @@ export async function validateTrade(adapter, params) {
121
151
  try {
122
152
  const { assessRisk, preTradeCheck } = await import("./risk.js");
123
153
  const assessment = assessRisk([{ exchange: adapter.name, balance }], positions.map(p => ({ exchange: adapter.name, position: p })));
124
- const riskResult = preTradeCheck(assessment, notional, leverage);
154
+ // The leverage limit is checked only for a leverage the caller STATED (--leverage), exactly
155
+ // like enforceOrderRisk. Without one, `leverage` above is the market MAXIMUM (used for the
156
+ // margin estimate only) — not what the account trades at: checking it failed every order on
157
+ // a 50x market against a 20x limit while the account was set to 5x.
158
+ const riskResult = preTradeCheck(assessment, notional, params.leverage ?? 0);
125
159
  if (riskResult.allowed) {
126
160
  checks.push({ check: "risk_limits", passed: true, message: "Within risk limits" });
127
161
  }
@@ -143,6 +177,11 @@ export async function validateTrade(adapter, params) {
143
177
  const pos = positions.find(p => symbolMatch(p.symbol, sym));
144
178
  if (pos) {
145
179
  const posSize = parseFloat(pos.size);
180
+ // Rule #2: NaN posSize would make `params.size > posSize` always
181
+ // false, silently passing the reduce-only size check.
182
+ if (!Number.isFinite(posSize)) {
183
+ throw new PerpError("EXCHANGE_ERROR", `${adapter.name} returned non-finite position size for ${sym}: ${pos.size}`, { exchange: adapter.name });
184
+ }
146
185
  if (params.size > posSize) {
147
186
  checks.push({ check: "position_exists", passed: false, message: `Reduce size ${params.size} exceeds position ${posSize}`, details: { positionSize: posSize, reduceSize: params.size } });
148
187
  }
@@ -158,8 +197,13 @@ export async function validateTrade(adapter, params) {
158
197
  const fee = notional * DEFAULT_TAKER_FEE;
159
198
  const estimatedSlippage = params.type === "limit" ? 0 : notional * 0.001;
160
199
  const valid = checks.every(c => c.passed);
161
- if (leverage > market.maxLeverage) {
162
- warnings.push(`Requested leverage ${leverage}x exceeds max ${market.maxLeverage}x`);
200
+ if (leverage > effectiveMax) {
201
+ // Compare against the tier that applies at this notional, not the headline
202
+ // cap — otherwise the check is tautological whenever leverage defaulted FROM
203
+ // maxLeverage, and it never fires for the tiered case it exists to catch.
204
+ warnings.push(effectiveMax < market.maxLeverage
205
+ ? `Requested leverage ${leverage}x exceeds ${effectiveMax}x, the cap for this $${notional.toFixed(0)} notional (headline max ${market.maxLeverage}x applies below the tier threshold)`
206
+ : `Requested leverage ${leverage}x exceeds max ${market.maxLeverage}x`);
163
207
  }
164
208
  return {
165
209
  valid,
@@ -174,9 +218,69 @@ export async function validateTrade(adapter, params) {
174
218
  marketInfo: {
175
219
  symbol: sym,
176
220
  markPrice,
177
- fundingRate: Number(market.fundingRate),
221
+ // Rule #2: NaN funding rate must not propagate to envelope output;
222
+ // surface the missing-data state via warnings rather than emitting
223
+ // `fundingRate: NaN` to agents that JSON.parse the response.
224
+ fundingRate: (() => {
225
+ const fr = Number(market.fundingRate);
226
+ if (!Number.isFinite(fr)) {
227
+ warnings.push(`Funding rate unavailable for ${sym} on ${adapter.name} (received ${market.fundingRate})`);
228
+ return 0;
229
+ }
230
+ return fr;
231
+ })(),
178
232
  maxLeverage: market.maxLeverage,
179
233
  },
180
234
  timestamp: new Date().toISOString(),
181
235
  };
182
236
  }
237
+ /**
238
+ * Hard pre-trade risk gate for manual order commands.
239
+ *
240
+ * Unlike validateTrade (which is advisory — used by `trade check` and the MCP
241
+ * preview), this is meant to run immediately before submitting a real order.
242
+ * It throws RISK_VIOLATION when the order would breach the locally configured
243
+ * limits (maxPositionUsd / maxTotalExposureUsd / maxPositions, plus any active
244
+ * critical violation that suspends trading). reduce-only and --force orders are
245
+ * exempt, so position-reducing and intentional-override flows are never blocked.
246
+ *
247
+ * Leverage is only checked when the caller passes an explicit value; the bare
248
+ * buy/sell/market/limit commands carry no leverage argument, so the gate there
249
+ * enforces notional/exposure rather than per-order leverage.
250
+ */
251
+ export async function enforceOrderRisk(adapter, p) {
252
+ if (p.reduceOnly || p.force)
253
+ return;
254
+ const { loadRiskLimits, assessRisk, preTradeCheck } = await import("./risk.js");
255
+ const limits = loadRiskLimits();
256
+ // Resolve notional: limit orders carry a price; market orders need markPrice.
257
+ let price = p.price;
258
+ if (price == null || !Number.isFinite(price)) {
259
+ const markets = await adapter.getMarkets();
260
+ const m = markets.find((mk) => symbolMatch(mk.symbol, p.symbol));
261
+ if (!m) {
262
+ throw new PerpError("SYMBOL_NOT_FOUND", `${p.symbol} not found on ${adapter.name}`, { exchange: adapter.name });
263
+ }
264
+ price = Number(m.markPrice);
265
+ }
266
+ // SSOT rule #2: if the notional cannot be computed we cannot enforce the
267
+ // limit — fail closed (the caller may pass --force) rather than wave it
268
+ // through with a silent zero notional that bypasses the position cap.
269
+ if (!Number.isFinite(price) || price <= 0) {
270
+ throw new PerpError("PRICE_STALE", `Cannot resolve a mark price for ${p.symbol} on ${adapter.name} to check risk limits`, {
271
+ exchange: adapter.name,
272
+ remediation: "Retry, or pass --force to skip the local risk check.",
273
+ });
274
+ }
275
+ const notional = Math.abs(p.size * price);
276
+ const [balance, positions] = await Promise.all([adapter.getBalance(), adapter.getPositions()]);
277
+ const assessment = assessRisk([{ exchange: adapter.name, balance }], positions.map((pos) => ({ exchange: adapter.name, position: pos })), limits);
278
+ const check = preTradeCheck(assessment, notional, p.leverage ?? 0);
279
+ if (!check.allowed) {
280
+ throw new PerpError("RISK_VIOLATION", check.reason ?? "Order blocked by local risk limits", {
281
+ exchange: adapter.name,
282
+ remediation: "Adjust limits with `perp risk set ...`, reduce the order size, or pass --force to override.",
283
+ details: { symbol: p.symbol, notional },
284
+ });
285
+ }
286
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Coerce a venue payload value to a finite number — or throw.
3
+ *
4
+ * Shared across all 4 exchange adapters (Lighter / Hyperliquid / Aster /
5
+ * Pacifica) to enforce Rule #2 ("no fallback") on venue-returned strings
6
+ * and numbers. Previously each adapter inlined `Number(x ?? 0)` which
7
+ * silently coerced NaN / Infinity / non-numeric strings to 0, masking
8
+ * stale-cache / partial-response corruption as legitimate "$0 balance".
9
+ *
10
+ * Rules:
11
+ * - `undefined` / `null` → returns `defaultValue` (default 0). Venues
12
+ * legitimately omit a field for an empty account or zero position;
13
+ * that is "no data, treat as zero", not a parsing failure.
14
+ * - empty string `""` → throws. Strict policy (qa/2026-05-16): an
15
+ * empty string is indistinguishable from a stale-cache partial
16
+ * response, so `Number("") === 0` would land back in the same
17
+ * silent-substitution hole. A truly absent field must surface as
18
+ * `undefined`/`null` at the adapter layer, not `""`.
19
+ * - finite number / parseable numeric string → returned as-is.
20
+ * - NaN / ±Infinity / non-numeric string → throws `EXCHANGE_ERROR`
21
+ * tagged with `exchange` so triage can attribute the failure to the
22
+ * exact venue endpoint.
23
+ *
24
+ * @param value Raw payload value (untyped — venues lie about SDK types).
25
+ * @param fieldName Field path for the error message (e.g.
26
+ * `"available_balance"`, `"position.unrealized_pnl"`).
27
+ * @param exchange Exchange tag for the PerpError details payload
28
+ * (`hyperliquid` / `pacifica` / `aster` / `lighter`).
29
+ * @param opts.defaultValue Value returned for null/undefined. Default `0`.
30
+ */
31
+ export declare function parseFiniteVenueNumber(value: unknown, fieldName: string, exchange: string, opts?: {
32
+ defaultValue?: number;
33
+ }): number;
34
+ /**
35
+ * A rate the venue sends as a JSON NUMBER, as a plain decimal string without float noise:
36
+ * rounded to 15 significant digits (a double's exact precision) and written without exponent.
37
+ * Lighter /funding-rates sends e.g. `"rate":0.00009599999999999999` → "0.000096";
38
+ * -0.000047999999999999994 → "-0.000048"; 1.2e-7 → "0.00000012". A rate the venue sends as a
39
+ * STRING is passed through verbatim by callers, never through this. Non-finite → EXCHANGE_ERROR.
40
+ */
41
+ export declare function venueRateString(value: number, what?: string): string;
42
+ /**
43
+ * A required venue rate field as a decimal string: a JSON number → venueRateString (no float
44
+ * noise); a numeric string → verbatim. Missing / non-numeric → EXCHANGE_ERROR (never "0").
45
+ */
46
+ export declare function venueRateField(value: unknown, fieldName: string, exchange: string): string;
@@ -0,0 +1,79 @@
1
+ import { PerpError } from "../errors.js";
2
+ /**
3
+ * Coerce a venue payload value to a finite number — or throw.
4
+ *
5
+ * Shared across all 4 exchange adapters (Lighter / Hyperliquid / Aster /
6
+ * Pacifica) to enforce Rule #2 ("no fallback") on venue-returned strings
7
+ * and numbers. Previously each adapter inlined `Number(x ?? 0)` which
8
+ * silently coerced NaN / Infinity / non-numeric strings to 0, masking
9
+ * stale-cache / partial-response corruption as legitimate "$0 balance".
10
+ *
11
+ * Rules:
12
+ * - `undefined` / `null` → returns `defaultValue` (default 0). Venues
13
+ * legitimately omit a field for an empty account or zero position;
14
+ * that is "no data, treat as zero", not a parsing failure.
15
+ * - empty string `""` → throws. Strict policy (qa/2026-05-16): an
16
+ * empty string is indistinguishable from a stale-cache partial
17
+ * response, so `Number("") === 0` would land back in the same
18
+ * silent-substitution hole. A truly absent field must surface as
19
+ * `undefined`/`null` at the adapter layer, not `""`.
20
+ * - finite number / parseable numeric string → returned as-is.
21
+ * - NaN / ±Infinity / non-numeric string → throws `EXCHANGE_ERROR`
22
+ * tagged with `exchange` so triage can attribute the failure to the
23
+ * exact venue endpoint.
24
+ *
25
+ * @param value Raw payload value (untyped — venues lie about SDK types).
26
+ * @param fieldName Field path for the error message (e.g.
27
+ * `"available_balance"`, `"position.unrealized_pnl"`).
28
+ * @param exchange Exchange tag for the PerpError details payload
29
+ * (`hyperliquid` / `pacifica` / `aster` / `lighter`).
30
+ * @param opts.defaultValue Value returned for null/undefined. Default `0`.
31
+ */
32
+ export function parseFiniteVenueNumber(value, fieldName, exchange, opts = {}) {
33
+ const defaultValue = opts.defaultValue ?? 0;
34
+ if (value === undefined || value === null)
35
+ return defaultValue;
36
+ if (value === "") {
37
+ throw new PerpError("EXCHANGE_ERROR", `${exchange} response field \`${fieldName}\` is an empty string (use null for missing data)`, { exchange });
38
+ }
39
+ const n = typeof value === "number" ? value : Number(value);
40
+ if (!Number.isFinite(n)) {
41
+ throw new PerpError("EXCHANGE_ERROR", `${exchange} response field \`${fieldName}\` is not a finite number: ${JSON.stringify(value)}`, { exchange });
42
+ }
43
+ return n;
44
+ }
45
+ /** Significant digits kept when a venue rate arrives as a JSON number (IEEE-754 double: 15 are exact). */
46
+ const RATE_SIG_DIGITS = 15;
47
+ /**
48
+ * A rate the venue sends as a JSON NUMBER, as a plain decimal string without float noise:
49
+ * rounded to 15 significant digits (a double's exact precision) and written without exponent.
50
+ * Lighter /funding-rates sends e.g. `"rate":0.00009599999999999999` → "0.000096";
51
+ * -0.000047999999999999994 → "-0.000048"; 1.2e-7 → "0.00000012". A rate the venue sends as a
52
+ * STRING is passed through verbatim by callers, never through this. Non-finite → EXCHANGE_ERROR.
53
+ */
54
+ export function venueRateString(value, what = "rate") {
55
+ if (!Number.isFinite(value))
56
+ throw new PerpError("EXCHANGE_ERROR", `${what} is not a finite number (${value})`);
57
+ if (value === 0)
58
+ return "0";
59
+ const [mant, expS] = value.toExponential(RATE_SIG_DIGITS - 1).split("e");
60
+ const digits = mant.replace("-", "").replace(".", "").replace(/0+$/, "");
61
+ const point = Number(expS) + 1; // value = 0.<digits> × 10^point
62
+ const sign = value < 0 ? "-" : "";
63
+ if (point <= 0)
64
+ return `${sign}0.${"0".repeat(-point)}${digits}`;
65
+ if (point >= digits.length)
66
+ return `${sign}${digits}${"0".repeat(point - digits.length)}`;
67
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
68
+ }
69
+ /**
70
+ * A required venue rate field as a decimal string: a JSON number → venueRateString (no float
71
+ * noise); a numeric string → verbatim. Missing / non-numeric → EXCHANGE_ERROR (never "0").
72
+ */
73
+ export function venueRateField(value, fieldName, exchange) {
74
+ if (typeof value === "number" && Number.isFinite(value))
75
+ return venueRateString(value, `${exchange} ${fieldName}`);
76
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)))
77
+ return value;
78
+ throw new PerpError("EXCHANGE_ERROR", `${exchange} ${fieldName}: not a numeric rate (${JSON.stringify(value)})`, { exchange });
79
+ }
package/dist/utils.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { OrderResult } from "./exchanges/venue-types.js";
1
2
  export declare function symbolMatch(candidate: string, target: string): boolean;
2
3
  export declare function formatUsd(value: string | number): string;
3
4
  export declare function formatPnl(value: string | number): string;
@@ -46,6 +47,24 @@ export declare function jsonError(code: string, message: string, meta?: Partial<
46
47
  * receive actionable hints. Codex v0.12.12 final QA #2.
47
48
  */
48
49
  export declare function withJsonErrors<T>(isJson: boolean, fn: () => Promise<T>, exchange?: string): Promise<T | undefined>;
50
+ /**
51
+ * Central CLI error printer. JSON: the structured envelope incl. details and
52
+ * remediation. Human: the uniform "not supported" line for CAPABILITY_UNSUPPORTED
53
+ * (design §13.6) — `✗ <Venue>: <feature> not supported — <reason>. Alternative: <remediation>` —
54
+ * otherwise `Error: <message>` plus the remediation.
55
+ */
56
+ export declare function printCliError(err: unknown, isJson: boolean, exchange?: string): void;
57
+ /**
58
+ * JSON payload of an order result (design §13.7): the uniform OrderResult (+ execution)
59
+ * with the venue's raw response duplicated under `legacy` for one release.
60
+ */
61
+ export declare function orderResultJson(r: OrderResult & {
62
+ remediation?: string | null;
63
+ }, extra?: Record<string, unknown>): Record<string, unknown>;
64
+ /** Human lines for an order result — one format for every venue (design §13.7). */
65
+ export declare function formatOrderResult(r: OrderResult & {
66
+ remediation?: string | null;
67
+ }): string[];
49
68
  /**
50
69
  * Log each rejected outcome from Promise.allSettled to stderr with a
51
70
  * per-source label. SSOT rule #2 helper: turn a quiet rejection inside a
package/dist/utils.js CHANGED
@@ -2,9 +2,19 @@ import Table from "cli-table3";
2
2
  import chalk from "chalk";
3
3
  import { classifyError } from "./errors.js";
4
4
  export function symbolMatch(candidate, target) {
5
- const c = candidate.toUpperCase();
6
- const t = target.toUpperCase();
7
- return c === t || c === `${t}-PERP` || c.replace(/-PERP$/, "") === t;
5
+ // Symmetric match: strip a trailing -PERP from BOTH sides before comparing.
6
+ // Callers pass (venueSymbol, userInput); the venue may report "BTC" or
7
+ // "BTC-PERP" and the user may type either form, so matching must not depend
8
+ // on which side carries the suffix.
9
+ const strip = (s) => s.toUpperCase().replace(/-PERP$/, "");
10
+ const c = strip(candidate);
11
+ const t = strip(target);
12
+ if (c === t)
13
+ return true;
14
+ // A HIP-3 dex-scoped adapter reports "xyz:NVDA" while the user (with --dex xyz) types "NVDA".
15
+ // One-way only: a dex-prefixed user input never matches a bare venue symbol.
16
+ const colon = c.indexOf(":");
17
+ return colon > 0 && !t.includes(":") && c.slice(colon + 1) === t;
8
18
  }
9
19
  export function formatUsd(value) {
10
20
  const num = Number(value);
@@ -136,6 +146,10 @@ function downsample(values, targetLen) {
136
146
  // ── JSON Output ──
137
147
  const _isNdjson = () => process.argv.includes("--ndjson");
138
148
  export function printJson(data) {
149
+ // An error envelope always means a non-zero exit (scripts / agents key on the exit code too):
150
+ // commands that print `jsonError(...)` and return would otherwise exit 0 with ok:false.
151
+ if (data !== null && typeof data === "object" && data.ok === false)
152
+ process.exitCode = 1;
139
153
  // Sanitize all string values in output
140
154
  let output = sanitizeValue(data);
141
155
  // Apply --fields filter
@@ -213,25 +227,88 @@ export async function withJsonErrors(isJson, fn, exchange) {
213
227
  return await fn();
214
228
  }
215
229
  catch (err) {
216
- const classified = classifyError(err, exchange);
217
- if (isJson) {
218
- console.log(JSON.stringify(jsonError(classified.code, classified.message, {
219
- status: classified.status,
220
- retryable: classified.retryable,
221
- retryAfterMs: classified.retryAfterMs,
222
- remediation: classified.remediation,
223
- details: classified.details,
224
- })));
225
- }
226
- else {
227
- console.error(chalk.red(`Error: ${classified.message}`));
228
- if (classified.remediation) {
229
- console.error(chalk.yellow(`Remediation: ${classified.remediation}`));
230
- }
231
- }
230
+ printCliError(err, isJson, exchange);
231
+ process.exitCode = 1; // printed, not swallowed: the command still exits non-zero
232
232
  return undefined;
233
233
  }
234
234
  }
235
+ /**
236
+ * Central CLI error printer. JSON: the structured envelope incl. details and
237
+ * remediation. Human: the uniform "not supported" line for CAPABILITY_UNSUPPORTED
238
+ * (design §13.6) — `✗ <Venue>: <feature> not supported — <reason>. Alternative: <remediation>` —
239
+ * otherwise `Error: <message>` plus the remediation.
240
+ */
241
+ export function printCliError(err, isJson, exchange) {
242
+ const classified = classifyError(err, exchange);
243
+ if (isJson) {
244
+ console.log(JSON.stringify(jsonError(classified.code, classified.message, {
245
+ status: classified.status,
246
+ retryable: classified.retryable,
247
+ retryAfterMs: classified.retryAfterMs,
248
+ remediation: classified.remediation,
249
+ details: classified.details,
250
+ })));
251
+ return;
252
+ }
253
+ const d = classified.details;
254
+ if (classified.code === "CAPABILITY_UNSUPPORTED" && d && typeof d.venue === "string" && typeof d.feature === "string" && typeof d.reason === "string") {
255
+ const alt = classified.remediation !== undefined ? ` Alternative: ${classified.remediation}` : "";
256
+ console.error(chalk.red(`✗ ${d.venue}: ${d.feature} not supported — ${d.reason}.`) + chalk.yellow(alt));
257
+ return;
258
+ }
259
+ console.error(chalk.red(`Error: ${classified.message}`));
260
+ if (classified.remediation) {
261
+ console.error(chalk.yellow(`Remediation: ${classified.remediation}`));
262
+ }
263
+ }
264
+ /**
265
+ * JSON payload of an order result (design §13.7): the uniform OrderResult (+ execution)
266
+ * with the venue's raw response duplicated under `legacy` for one release.
267
+ */
268
+ export function orderResultJson(r, extra) {
269
+ return { ...r, ...extra, legacy: r.venueRaw };
270
+ }
271
+ /** Human lines for an order result — one format for every venue (design §13.7). */
272
+ export function formatOrderResult(r) {
273
+ const lines = [];
274
+ const statusColor = r.status === "filled" ? chalk.green
275
+ : r.status === "rejected" || r.status === "cancelled" ? chalk.red
276
+ : r.status === "partiallyFilled" ? chalk.yellow : chalk.white;
277
+ const fill = `filled ${r.filledSize ?? "unknown"}${r.avgPrice !== null ? ` @ ${r.avgPrice}` : ""}`;
278
+ lines.push(` Status: ${statusColor(r.status)} (${r.finality === "final" ? "final" : chalk.yellow("pending")}) — ${fill}`);
279
+ const ids = [];
280
+ if (r.orderId !== null)
281
+ ids.push(`order ${r.orderId}`);
282
+ if (r.clientOrderId !== null)
283
+ ids.push(`client id ${r.clientOrderId}`);
284
+ if (ids.length > 0)
285
+ lines.push(` Order: ${ids.join(" · ")}`);
286
+ const e = r.execution;
287
+ const exec = [e.method];
288
+ if (e.slippageBps !== null)
289
+ exec.push(`slippage ${Math.round(e.slippageBps * 100) / 100} bps${e.slippageSource !== null ? ` (${e.slippageSource})` : ""}`);
290
+ if (e.protectionPrice !== null)
291
+ exec.push(`protection ${e.protectionPrice}${e.referencePrice !== null ? ` (ref ${e.referencePrice})` : ""}`);
292
+ else if (e.referencePrice !== null)
293
+ exec.push(`ref ${e.referencePrice}`);
294
+ lines.push(` Execution: ${exec.join(" · ")}`);
295
+ if (e.tifRequested !== null || e.tifSent !== null) {
296
+ const tif = e.tifRequested !== null && e.tifSent !== null && e.tifRequested !== e.tifSent
297
+ ? `${e.tifRequested} → ${e.tifSent}` : (e.tifSent ?? e.tifRequested);
298
+ lines.push(` TIF: ${tif}${e.expiresAt !== null ? ` (expires ${new Date(e.expiresAt).toISOString()})` : ""}`);
299
+ }
300
+ if (e.builderFee !== null) {
301
+ const approved = e.builderFee.approvedMaxBps === null ? "" : `, approved max ${e.builderFee.approvedMaxBps} bps`;
302
+ const fee = e.builderFee.feeKind === "share" ? "fee share (you pay the normal fee, nothing extra)"
303
+ : e.builderFee.feeKind === "max" ? `up to ${e.builderFee.feeBps} bps (builder's own rate applies${approved})` : `${e.builderFee.feeBps} bps`;
304
+ lines.push(` Builder: ${fee} → ${e.builderFee.identity}`);
305
+ }
306
+ for (const n of e.notes)
307
+ lines.push(chalk.gray(` Note: ${n}`));
308
+ if (r.remediation !== undefined && r.remediation !== null)
309
+ lines.push(chalk.yellow(` ${r.remediation}`));
310
+ return lines;
311
+ }
235
312
  /**
236
313
  * Log each rejected outcome from Promise.allSettled to stderr with a
237
314
  * per-source label. SSOT rule #2 helper: turn a quiet rejection inside a
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Withdraw gate for venues whose withdrawals are authorized by an API key alone — no owner
3
+ * signature (settings.withdraw.<venue>; today: qfex). Design §14.12 #8.
4
+ *
5
+ * Before such a withdrawal is sent it must pass, locally:
6
+ * 1. a gate is configured at all (absent → refused);
7
+ * 2. the destination is on the allowlist;
8
+ * 3. amount ≤ maxPerTx;
9
+ * 4. today's total (UTC day, from this machine's ledger) + amount ≤ maxPerDay.
10
+ * The adapter enforces it inside withdraw(), so no caller can skip it.
11
+ *
12
+ * What it is NOT: a defence against key theft. Someone holding the key file does not use this
13
+ * CLI. That defence is a trading key WITHOUT the withdraw permission (a separate key for
14
+ * funding). The gate limits mistakes and misdirected automation: its allowlist can only be
15
+ * changed with an interactive terminal confirmation (confirmWithdrawGateChange), never under
16
+ * --json, a pipe, the MCP server or a job.
17
+ *
18
+ * Ledger: ~/.perp/withdraw-ledger.json — amounts and addresses only (no secrets, Rule #3).
19
+ * An attempt is recorded BEFORE it is sent and removed only on a definite refusal, so an
20
+ * unknown outcome counts against the day.
21
+ */
22
+ import type { WithdrawGate, WithdrawGateVenue } from "./settings.js";
23
+ export interface WithdrawLedgerEntry {
24
+ id: string;
25
+ venue: string;
26
+ network: string;
27
+ /** USDC, as sent. */
28
+ amount: string;
29
+ to: string;
30
+ /** ISO timestamp of the attempt. */
31
+ at: string;
32
+ }
33
+ /** Sum of the amounts attempted for venue + network on the UTC day of `nowMs`. */
34
+ export declare function withdrawnOnDay(venue: string, network: string, nowMs: number): number;
35
+ /**
36
+ * Throws POLICY_DENIED unless the withdrawal passes `gate`. Nothing is sent before this returns.
37
+ * `gate` null = settings.withdraw.<venue> is not configured.
38
+ */
39
+ export declare function assertWithdrawAllowed(venue: WithdrawGateVenue, network: string, amount: string, to: string, gate: WithdrawGate | null, nowMs: number): void;
40
+ /** Record an attempt (call BEFORE sending). Returns its id for forgetWithdrawAttempt. */
41
+ export declare function recordWithdrawAttempt(venue: string, network: string, amount: string, to: string, nowMs: number): string;
42
+ /** Remove an attempt the venue DEFINITELY refused (nothing left the account). */
43
+ export declare function forgetWithdrawAttempt(id: string): void;
44
+ /**
45
+ * Confirmation for creating or changing settings.withdraw.<venue>. Only an interactive
46
+ * terminal can confirm: --json, a non-TTY stdin/stdout (pipes, the MCP server, jobs) are
47
+ * refused, and there is no --yes.
48
+ */
49
+ export declare function confirmWithdrawGateChange(venue: string, gate: WithdrawGate, json: boolean): Promise<void>;