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
@@ -2,149 +2,219 @@
2
2
  /**
3
3
  * MCP (Model Context Protocol) server for perp-cli.
4
4
  *
5
- * Read-only advisor mode: provides market data, account balance, and CLI command suggestions.
6
- * Does NOT execute trades directly — instead suggests CLI commands for the user to run.
7
- * Adapters are created lazily from environment variables.
5
+ * Market data, account reads, CLI command suggestions, and two WRITE tools (`trade_execute`,
6
+ * `trade_close`) that place real orders through the same safety path as `perp trade`
7
+ * (local risk gate — no force override over MCP —, quarantine reject, execution log).
8
+ * Network: PERP_NETWORK (mainnet | testnet, validated like `-n`); unset = mainnet, the
9
+ * documented default. Every result's meta carries `network`.
10
+ * Adapters are created lazily from the registered agent / OWS wallet / settings (no CLI flags).
8
11
  */
9
- import "dotenv/config";
12
+ // SSOT Rule #3: load only ~/.perp/.env, like the CLI (src/index.ts) — never a .env in the CWD
13
+ // (a project tree's leftover dev keys would silently become this server's credentials).
14
+ import { setQuarantineNetwork } from "./bot/quarantine.js";
15
+ import { config as loadDotenv } from "dotenv";
16
+ import { resolve as resolvePath } from "node:path";
17
+ loadDotenv({ path: resolvePath(process.env.HOME || "~", ".perp", ".env") });
18
+ import { registerAdapterNetwork } from "./exchanges/adapter-network.js";
19
+ import { builderPlanFor } from "./exchanges/builder-fee.js";
20
+ import { resolveAdapterSlippage } from "./exchanges/slippage.js";
10
21
  import { createRequire } from "node:module";
11
22
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
23
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
24
  import { z } from "zod";
14
- import { tryLoadPrivateKey, parseSolanaKeypair } from "./config.js";
15
- import { PacificaAdapter } from "./exchanges/pacifica.js";
16
- import { HyperliquidAdapter } from "./exchanges/hyperliquid.js";
17
- import { LighterAdapter } from "./exchanges/lighter.js";
18
- import { AsterAdapter } from "./exchanges/aster.js";
19
- import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, pingPacifica, pingHyperliquid, pingLighter, } from "./shared-api.js";
25
+ import { hasCredentials, listConfiguredAccountVenues, resolveCredentials, NO_CLI_CREDENTIAL_OPTS } from "./config.js";
26
+ import { getVenue, listVenues, listVenuesWithRole, networkConfig, READ_ONLY_CREDENTIALS, requireCapabilities } from "./exchanges/venue.js";
27
+ import "./exchanges/venues/index.js";
28
+ import { newVenueClientOrderId, placeAndConfirm, validateLegacyTif, validateOrderRequest } from "./exchanges/trading-v2.js";
29
+ import { closePerpVerified, isOutcomeUnknown } from "./bot/perp-order.js";
30
+ import { quarantineSymbol } from "./bot/quarantine.js";
31
+ import { fillSizeString, sizeDecimalsOf } from "./exchanges/spot-result.js";
32
+ import { tradingV2Of } from "./exchanges/legacy-trading-v2.js";
33
+ import { PerpError, classifyError, extractErrorMessage, isDefiniteOrderFailure, orderOutcomeUnknown, parsePositiveDecimal } from "./errors.js";
34
+ import { invalidateCache } from "./cache.js";
35
+ import { pingPacifica, pingHyperliquid, pingLighter, } from "./shared-api.js";
20
36
  import { fetchAllFundingRates } from "./funding-rates.js";
21
37
  import { loadArbState } from "./arb/state.js";
22
38
  import { validateTrade } from "./trade-validator.js";
39
+ import { orderResultJson, symbolMatch } from "./utils.js";
40
+ import { logMcpExecution, mcpPreTradeGate, mcpReduceOnlyGate, resolveMcpNetwork } from "./mcp-trade-guard.js";
41
+ import { setSharedApiNetwork } from "./api/public/urls.js";
42
+ import { logClientId } from "./client-id-tracker.js";
43
+ import { loadSettings } from "./settings.js";
44
+ /**
45
+ * The one network this server serves: PERP_NETWORK, validated like `-n` (an invalid value
46
+ * makes the server refuse to start). Unset = mainnet, the documented default. Reported in
47
+ * every result's meta.network.
48
+ */
49
+ const MCP_NETWORK = resolveMcpNetwork(process.env["PERP_NETWORK"]);
50
+ // Quarantine entries are network-scoped; the MCP server trades only MCP_NETWORK.
51
+ setQuarantineNetwork(MCP_NETWORK);
52
+ setSharedApiNetwork(MCP_NETWORK);
23
53
  // ── Adapter cache & factory ──
54
+ //
55
+ // Same creation path and signer tiers as the CLI (agent → OWS master → PK;
56
+ // design §9.1 #1, §10.2). The MCP server has no CLI flags, so credentials come
57
+ // from the registered agent, settings.owsActiveWallet, and (legacy venues) env.
24
58
  const adapters = new Map();
59
+ /** The network each adapter was created on (TradingV2 wrappers must use the same one). */
60
+ const adapterNetworks = new WeakMap();
61
+ function requireCreate(def) {
62
+ if (def.create === null) {
63
+ throw new PerpError("NOT_IMPLEMENTED", `${def.displayName} has no account/trading API`, {
64
+ remediation: `${def.id} is market-data only — use get_markets / funding tools`,
65
+ });
66
+ }
67
+ return def.create;
68
+ }
25
69
  async function getOrCreateAdapter(exchange) {
26
- const key = exchange.toLowerCase();
27
- if (adapters.has(key))
28
- return adapters.get(key);
29
- const pk = await tryLoadPrivateKey(key);
30
- if (!pk)
31
- throw new Error(`No private key configured for ${exchange}. Run: perp setup`);
32
- let adapter;
33
- switch (key) {
34
- case "pacifica": {
35
- const keypair = parseSolanaKeypair(pk);
36
- adapter = new PacificaAdapter(keypair);
37
- break;
38
- }
39
- case "hyperliquid": {
40
- const hl = new HyperliquidAdapter(pk);
41
- await hl.init();
42
- adapter = hl;
43
- break;
44
- }
45
- case "lighter": {
46
- const lt = new LighterAdapter(pk);
47
- await lt.init();
48
- adapter = lt;
49
- break;
50
- }
51
- case "aster": {
52
- const ast = new AsterAdapter(pk);
53
- await ast.init();
54
- adapter = ast;
55
- break;
56
- }
57
- default:
58
- throw new Error(`Unknown exchange: ${exchange}. Supported: pacifica, hyperliquid, lighter, aster`);
70
+ const def = getVenue(exchange);
71
+ const cached = adapters.get(def.id);
72
+ if (cached)
73
+ return cached;
74
+ const create = requireCreate(def);
75
+ if (!(await hasCredentials(def.id, NO_CLI_CREDENTIAL_OPTS))) {
76
+ throw new PerpError("NO_SIGNER_AVAILABLE", `No credentials configured for ${def.id}`, {
77
+ remediation: def.masterKeyKind === "none"
78
+ ? `Import an API key created on the ${def.displayName} website: perp wallet agent import ${def.id}`
79
+ : `Run: perp setup, or perp wallet agent approve ${def.id}`,
80
+ });
59
81
  }
60
- adapters.set(key, adapter);
82
+ const network = MCP_NETWORK;
83
+ const adapter = await create({
84
+ network,
85
+ credentials: await resolveCredentials(def.id, NO_CLI_CREDENTIAL_OPTS, network),
86
+ noAgent: false,
87
+ hlDex: null,
88
+ readOnly: false,
89
+ // Builder fees follow settings.builder (user-configured, approval-confirmed); MCP has no per-call opt-out.
90
+ noBuilder: false,
91
+ });
92
+ adapters.set(def.id, adapter);
93
+ adapterNetworks.set(adapter, network);
94
+ registerAdapterNetwork(adapter, network);
61
95
  return adapter;
62
96
  }
63
- /** Try to get adapter, returns null if no key configured (for read-only fallback) */
97
+ /** Signer-availability failures that route a read tool to the venue's public data path. */
98
+ const PUBLIC_FALLBACK_CODES = new Set(["NO_SIGNER_AVAILABLE", "AGENT_NOT_REGISTERED", "KEY_NOT_FOUND"]);
99
+ /**
100
+ * Adapter when credentials are configured and usable; null when there are none
101
+ * or adapter creation fails specifically for lack of a usable signer
102
+ * (NO_SIGNER_AVAILABLE / AGENT_NOT_REGISTERED / KEY_NOT_FOUND) — the caller then
103
+ * reads the venue's public data path. Every other creation error propagates.
104
+ */
64
105
  async function tryGetAdapter(exchange) {
65
- const key = exchange.toLowerCase();
66
- if (adapters.has(key))
67
- return adapters.get(key);
106
+ const def = getVenue(exchange);
107
+ const cached = adapters.get(def.id);
108
+ if (cached)
109
+ return cached;
110
+ if (def.create === null || !(await hasCredentials(def.id, NO_CLI_CREDENTIAL_OPTS)))
111
+ return null;
68
112
  try {
69
- return await getOrCreateAdapter(exchange);
113
+ return await getOrCreateAdapter(def.id);
70
114
  }
71
- catch {
72
- return null;
115
+ catch (e) {
116
+ if (e instanceof PerpError && PUBLIC_FALLBACK_CODES.has(e.structured.code))
117
+ return null;
118
+ throw e;
73
119
  }
74
120
  }
75
- /** Fetch markets using public API (no key needed) */
121
+ /** Fetch markets using the venue's public data path (no key needed). */
76
122
  async function fetchMarketsPublic(exchange) {
77
- const ex = exchange.toLowerCase();
78
- if (ex === "pacifica" || ex === "pac") {
79
- const data = await fetchPacificaPrices();
80
- return data.map(d => ({ symbol: d.symbol, markPrice: String(d.mark), indexPrice: String(d.mark), fundingRate: String(d.funding), volume24h: "0", openInterest: "0", maxLeverage: 10 }));
81
- }
82
- else if (ex === "hyperliquid" || ex === "hl") {
83
- const data = await fetchHyperliquidMeta();
84
- return data.map(d => ({ symbol: d.symbol, markPrice: String(d.markPx), indexPrice: String(d.markPx), fundingRate: String(d.funding), volume24h: "0", openInterest: "0", maxLeverage: 50 }));
85
- }
86
- else if (ex === "lighter" || ex === "lt") {
87
- const data = await fetchLighterOrderBookDetails();
88
- return data.map(d => ({ symbol: d.symbol, markPrice: String(d.lastTradePrice), indexPrice: String(d.lastTradePrice), fundingRate: "0", volume24h: "0", openInterest: "0", maxLeverage: 20 }));
89
- }
90
- else if (ex === "aster" || ex === "ast") {
91
- // Aster has no shared-api helper; AsterAdapter.getMarkets() uses
92
- // public-only endpoints (exchangeInfo + ticker/24hr + premiumIndex).
93
- const ast = new AsterAdapter();
94
- await ast.init();
95
- return ast.getMarkets();
123
+ return getVenue(exchange).publicData.fetchMarkets(MCP_NETWORK);
124
+ }
125
+ /** One book level from a venue payload; a level without a string price / size is a payload error (never "0"). */
126
+ function bookLevel(venue, symbol, price, size) {
127
+ if (typeof price !== "string" || typeof size !== "string") {
128
+ throw new PerpError("EXCHANGE_ERROR", `${venue} orderbook ${symbol}: level without price / size`, { exchange: venue });
96
129
  }
97
- throw new Error(`Unknown exchange: ${exchange}. Supported: pacifica, hyperliquid, lighter, aster`);
130
+ return [price, size];
98
131
  }
99
- /** Fetch orderbook using public API (no key needed) */
132
+ /**
133
+ * Fetch an orderbook without credentials. Hyperliquid (native perps) and the Lighter family are
134
+ * read straight from their public REST endpoints and validated (HTTP status, payload shape, unknown
135
+ * symbol); every other venue goes through its read-only adapter (no key material), the same path
136
+ * `perp market book` takes without credentials. A venue without an account/trading API throws
137
+ * NOT_IMPLEMENTED (requireCreate).
138
+ */
100
139
  async function fetchOrderbookPublic(exchange, symbol) {
101
- const ex = exchange.toLowerCase();
102
- if (ex === "hyperliquid" || ex === "hl") {
140
+ const def = getVenue(exchange);
141
+ const sym = symbol.toUpperCase();
142
+ if (def.id === "hyperliquid") {
103
143
  const { HYPERLIQUID_API_URL } = await import("./api/public/urls.js");
104
144
  const res = await fetch(HYPERLIQUID_API_URL, {
105
145
  method: "POST", headers: { "Content-Type": "application/json" },
106
- body: JSON.stringify({ type: "l2Book", coin: symbol.toUpperCase() }),
146
+ body: JSON.stringify({ type: "l2Book", coin: sym }),
107
147
  });
148
+ if (!res.ok)
149
+ throw new PerpError("EXCHANGE_ERROR", `${def.displayName} l2Book ${sym}: HTTP ${res.status}`, { exchange: def.id });
108
150
  const data = await res.json();
109
- return {
110
- bids: (data.levels?.[0] ?? []).map(l => [l.px, l.sz]),
111
- asks: (data.levels?.[1] ?? []).map(l => [l.px, l.sz]),
112
- };
113
- }
114
- else if (ex === "lighter" || ex === "lt") {
115
- const { LIGHTER_API_URL } = await import("./api/public/urls.js");
116
- const res = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookOrders?market_id=0&limit=20`);
117
- const data = await res.json();
118
- return {
119
- bids: (data.bids ?? []).map(l => [l.price, l.remaining_base_amount ?? l.size ?? "0"]),
120
- asks: (data.asks ?? []).map(l => [l.price, l.remaining_base_amount ?? l.size ?? "0"]),
121
- };
151
+ if (data === null) {
152
+ throw new PerpError("SYMBOL_NOT_FOUND", `${def.displayName} has no native perp ${sym}`, {
153
+ exchange: def.id,
154
+ remediation: `List markets with get_markets (exchange: ${def.id}); HIP-3 dex markets are read with the CLI: perp -e hyperliquid --dex <name> market book ${sym}`,
155
+ });
156
+ }
157
+ const levels = data.levels;
158
+ if (!Array.isArray(levels) || levels.length !== 2 || !Array.isArray(levels[0]) || !Array.isArray(levels[1])) {
159
+ throw new PerpError("EXCHANGE_ERROR", `${def.displayName} l2Book ${sym}: unexpected payload (no [bids, asks] levels)`, { exchange: def.id });
160
+ }
161
+ const side = (ls) => ls.map(l => bookLevel(def.id, sym, l.px, l.sz));
162
+ return { bids: side(levels[0]), asks: side(levels[1]) };
122
163
  }
123
- else if (ex === "pacifica" || ex === "pac") {
124
- const { PACIFICA_API_URL } = await import("./api/public/urls.js");
125
- const baseUrl = PACIFICA_API_URL.replace("/info/prices", "");
126
- const res = await fetch(`${baseUrl}/info/l2Book?symbol=${symbol.toUpperCase()}`);
164
+ if (def.family === "lighter") {
165
+ const market = (await def.publicData.fetchMarkets(MCP_NETWORK)).find(m => m.symbol.toUpperCase() === sym);
166
+ if (!market) {
167
+ throw new PerpError("SYMBOL_NOT_FOUND", `${def.displayName} has no market ${sym}`, { exchange: def.id, remediation: `List markets with get_markets (exchange: ${def.id})` });
168
+ }
169
+ const res = await fetch(`${networkConfig(def, MCP_NETWORK).restUrl}/api/v1/orderBookOrders?market_id=${market.venueSymbol}&limit=20`);
170
+ if (!res.ok)
171
+ throw new PerpError("EXCHANGE_ERROR", `${def.displayName} orderBookOrders ${sym}: HTTP ${res.status}`, { exchange: def.id });
127
172
  const data = await res.json();
128
- return {
129
- bids: (data.l?.[0] ?? []).map(e => [e.p, e.a]),
130
- asks: (data.l?.[1] ?? []).map(e => [e.p, e.a]),
131
- };
132
- }
133
- else if (ex === "aster" || ex === "ast") {
134
- // AsterAdapter.getOrderbook() uses /fapi/v1/depth (public).
135
- const ast = new AsterAdapter();
136
- await ast.init();
137
- return ast.getOrderbook(symbol);
173
+ if (!Array.isArray(data.bids) || !Array.isArray(data.asks)) {
174
+ throw new PerpError("EXCHANGE_ERROR", `${def.displayName} orderBookOrders ${sym}: unexpected payload (no bids / asks arrays)`, { exchange: def.id });
175
+ }
176
+ const side = (ls) => ls.map(l => {
177
+ const lv = l;
178
+ return bookLevel(def.id, sym, lv.price, lv.remaining_base_amount !== undefined ? lv.remaining_base_amount : lv.size);
179
+ });
180
+ return { bids: side(data.bids), asks: side(data.asks) };
138
181
  }
139
- throw new Error(`Unknown exchange: ${exchange}`);
182
+ // Read-only adapter: no key material, public endpoints only.
183
+ const ro = await requireCreate(def)({ network: MCP_NETWORK, credentials: READ_ONLY_CREDENTIALS, noAgent: true, hlDex: null, readOnly: true, noBuilder: true });
184
+ return ro.getOrderbook(sym);
140
185
  }
141
186
  // ── JSON envelope helpers ──
142
187
  function ok(data, meta) {
143
- return JSON.stringify({ ok: true, data, meta }, null, 2);
188
+ return JSON.stringify({ ok: true, data, meta: { ...meta, network: MCP_NETWORK } }, null, 2);
144
189
  }
145
- function err(error, meta) {
146
- return JSON.stringify({ ok: false, error, meta }, null, 2);
190
+ /**
191
+ * Error envelope from a thrown value: the STRUCTURED error (classifyError — a PerpError keeps its
192
+ * code / remediation / details verbatim), never just the message. An ORDER_PENDING order must
193
+ * reach the agent as code + "Do NOT resubmit" remediation, or it would retry and double-trade.
194
+ */
195
+ function errOf(e, meta, top) {
196
+ const s = classifyError(e, typeof meta?.exchange === "string" ? meta.exchange : undefined);
197
+ return JSON.stringify({
198
+ ok: false,
199
+ error: s.message,
200
+ code: s.code,
201
+ retryable: s.retryable,
202
+ ...(s.remediation !== undefined ? { remediation: s.remediation } : {}),
203
+ ...(s.details !== undefined ? { details: s.details } : {}),
204
+ // Tool-specific top-level facts an agent must not miss (e.g. trade_execute executed:false / pending:true).
205
+ ...top,
206
+ meta: { ...meta, network: MCP_NETWORK },
207
+ }, null, 2);
147
208
  }
209
+ /** Hyperliquid scope of every MCP tool: the server builds adapters with no HIP-3 dex (hlDex null). */
210
+ const HL_SCOPE_NOTE = "Hyperliquid: native perps only (HIP-3 dexes via the CLI --dex).";
211
+ /**
212
+ * Venues with a `perp funds deposit <venue>` command. lighter takes a route (ethereum | cctp <chain>);
213
+ * qfex takes no amount (it shows the deposit address). Tested against the real command tree.
214
+ */
215
+ export const MCP_DEPOSIT_VENUES = ["pacifica", "hyperliquid", "lighter", "lighter-rh", "aster", "ondo", "qfex"];
216
+ /** Venues with a `perp funds withdraw <venue>` command. Tested against the real command tree. */
217
+ export const MCP_WITHDRAW_VENUES = ["pacifica", "hyperliquid", "lighter", "aster", "ondo", "arcus", "qfex"];
148
218
  // ── MCP Server ──
149
219
  const _require = createRequire(import.meta.url);
150
220
  const _pkg = _require("../package.json");
@@ -152,7 +222,7 @@ const server = new McpServer({ name: "perp-cli", version: _pkg.version }, { capa
152
222
  // ============================================================
153
223
  // Market Data tools (read-only, no private key needed)
154
224
  // ============================================================
155
- server.tool("get_markets", "Get all available perpetual futures markets on an exchange, including price, funding rate, volume, and max leverage. Works without API keys.", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
225
+ server.tool("get_markets", `Get all available perpetual futures markets on an exchange, including price, funding rate, volume, and max leverage. Works without API keys (public market data). ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
156
226
  try {
157
227
  // Try adapter first (richer data), fall back to public API (no key needed)
158
228
  const adapter = await tryGetAdapter(exchange);
@@ -164,11 +234,11 @@ server.tool("get_markets", "Get all available perpetual futures markets on an ex
164
234
  return { content: [{ type: "text", text: ok(markets, { exchange, count: markets.length, source: "public" }) }] };
165
235
  }
166
236
  catch (e) {
167
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange }) }], isError: true };
237
+ return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
168
238
  }
169
239
  });
170
- server.tool("get_orderbook", "Get the order book (bids and asks) for a symbol on an exchange. Works without API keys.", {
171
- exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster"),
240
+ server.tool("get_orderbook", `Get the order book (bids and asks) for a symbol on an exchange. Works without API keys on every venue with an account API (configured credentials → the account adapter; none → the public endpoint / read-only adapter); market-data-only venues have no orderbook path. ${HL_SCOPE_NOTE}`, {
241
+ exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`),
172
242
  symbol: z.string().describe("Trading pair symbol, e.g. BTC, ETH, SOL"),
173
243
  }, async ({ exchange, symbol }) => {
174
244
  try {
@@ -182,10 +252,10 @@ server.tool("get_orderbook", "Get the order book (bids and asks) for a symbol on
182
252
  return { content: [{ type: "text", text: ok(book, { exchange, symbol, source: "public" }) }] };
183
253
  }
184
254
  catch (e) {
185
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange, symbol }) }], isError: true };
255
+ return { content: [{ type: "text", text: errOf(e, { exchange, symbol }) }], isError: true };
186
256
  }
187
257
  });
188
- server.tool("get_funding_rates", "Compare funding rates across all 4 exchanges (Pacifica, Hyperliquid, Lighter, Aster). Returns rates per symbol with spread analysis", {
258
+ server.tool("get_funding_rates", "Compare funding rates across every market-data venue. Returns rates per symbol with spread analysis", {
189
259
  symbols: z
190
260
  .array(z.string())
191
261
  .optional()
@@ -202,105 +272,146 @@ server.tool("get_funding_rates", "Compare funding rates across all 4 exchanges (
202
272
  };
203
273
  }
204
274
  catch (e) {
205
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
275
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
206
276
  }
207
277
  });
208
- server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark prices from all 4 exchanges for comparison", {
278
+ server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark prices from every market-data venue for comparison", {
209
279
  symbols: z
210
280
  .array(z.string())
211
281
  .optional()
212
282
  .describe("Symbols to fetch prices for (e.g. ['BTC','ETH']). Omit for top assets"),
213
283
  }, async ({ symbols }) => {
214
284
  try {
215
- // Aster has no shared-api helper; use AsterAdapter.getMarkets() (public).
216
- const asterMarkets = (async () => {
217
- const ast = new AsterAdapter();
218
- await ast.init();
219
- return ast.getMarkets();
220
- })();
221
- const [pacifica, hl, lighter, aster] = await Promise.all([
222
- fetchPacificaPrices(),
223
- fetchHyperliquidMeta(),
224
- fetchLighterOrderBookDetails(),
225
- asterMarkets,
226
- ]);
285
+ const venues = listVenuesWithRole("marketData").map(v => v.id);
286
+ const results = await Promise.allSettled(venues.map(ex => fetchMarketsPublic(ex)));
227
287
  const filter = symbols ? new Set(symbols.map(s => s.toUpperCase())) : null;
228
- // Build symbol → exchange prices map
288
+ // Build symbol → exchange prices map; per-venue failures are reported in meta.errors
229
289
  const priceMap = new Map();
230
- for (const p of pacifica) {
231
- const sym = p.symbol.toUpperCase();
232
- if (filter && !filter.has(sym))
233
- continue;
234
- if (!priceMap.has(sym))
235
- priceMap.set(sym, {});
236
- priceMap.get(sym).pacifica = p.mark;
237
- }
238
- for (const a of hl) {
239
- const sym = a.symbol.toUpperCase();
240
- if (filter && !filter.has(sym))
241
- continue;
242
- if (!priceMap.has(sym))
243
- priceMap.set(sym, {});
244
- priceMap.get(sym).hyperliquid = a.markPx;
245
- }
246
- for (const m of lighter) {
247
- const sym = m.symbol.toUpperCase();
248
- if (filter && !filter.has(sym))
249
- continue;
250
- if (!priceMap.has(sym))
251
- priceMap.set(sym, {});
252
- priceMap.get(sym).lighter = m.lastTradePrice;
253
- }
254
- for (const a of aster) {
255
- const sym = a.symbol.toUpperCase();
256
- if (filter && !filter.has(sym))
257
- continue;
258
- if (!priceMap.has(sym))
259
- priceMap.set(sym, {});
260
- priceMap.get(sym).aster = Number(a.markPrice);
261
- }
290
+ const errors = {};
291
+ results.forEach((r, i) => {
292
+ const ex = venues[i];
293
+ if (r.status === "rejected") {
294
+ errors[ex] = r.reason instanceof Error ? r.reason.message : String(r.reason);
295
+ return;
296
+ }
297
+ for (const m of r.value) {
298
+ const sym = m.symbol.toUpperCase();
299
+ if (filter && !filter.has(sym))
300
+ continue;
301
+ if (!priceMap.has(sym))
302
+ priceMap.set(sym, {});
303
+ priceMap.get(sym)[ex] = Number(m.markPrice);
304
+ }
305
+ });
262
306
  const data = Array.from(priceMap.entries()).map(([symbol, prices]) => ({ symbol, prices }));
263
- return { content: [{ type: "text", text: ok(data, { count: data.length }) }] };
307
+ return { content: [{ type: "text", text: ok(data, { count: data.length, errors }) }] };
264
308
  }
265
309
  catch (e) {
266
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
310
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
311
+ }
312
+ });
313
+ // ============================================================
314
+ // Outcome markets (Hyperliquid HIP-4) — public reads
315
+ // ============================================================
316
+ server.tool("get_outcome_markets", "List active Hyperliquid Outcome markets (HIP-4). Each market has parsed metadata (underlying asset, target price, expiry, period) and per-side info (Yes/No name, encoding, asset id, current mid). Public read — no API key needed.", {}, async () => {
317
+ try {
318
+ const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
319
+ const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
320
+ const hl = new HyperliquidAdapter(undefined, MCP_NETWORK === "testnet");
321
+ await hl.init();
322
+ const out = new HyperliquidOutcomeAdapter(hl);
323
+ const markets = await out.getMarkets();
324
+ return { content: [{ type: "text", text: ok(markets, { count: markets.length, source: "outcomeMeta" }) }] };
325
+ }
326
+ catch (e) {
327
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
328
+ }
329
+ });
330
+ server.tool("get_outcome_view", "Get a combined view of one outcome market: all sides' books in parallel + underlying mark price gap (vs targetPrice) + time to expiry + per-side implied probability (mid sum). For binary markets, the symmetric structure means Yes bid + No ask ≈ 1.0 — the view exposes both sides in a single round-trip and surfaces directional context (e.g., is BTC currently above the target). Public read.", {
331
+ outcome: z.number().int().nonnegative().describe("Outcome id from outcomeMeta (e.g., 1 for the BTC binary daily)"),
332
+ depth: z.number().int().min(1).max(50).optional().default(10).describe("Number of book levels per side"),
333
+ }, async ({ outcome, depth }) => {
334
+ try {
335
+ const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
336
+ const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
337
+ const hl = new HyperliquidAdapter(undefined, MCP_NETWORK === "testnet");
338
+ await hl.init();
339
+ const out = new HyperliquidOutcomeAdapter(hl);
340
+ const view = await out.getView(outcome, depth);
341
+ return { content: [{ type: "text", text: ok(view, { outcome, depth }) }] };
342
+ }
343
+ catch (e) {
344
+ return { content: [{ type: "text", text: errOf(e, { outcome }) }], isError: true };
345
+ }
346
+ });
347
+ server.tool("get_outcome_book", "Get the orderbook for one outcome side. Public read — no API key needed.", {
348
+ outcome: z.number().int().nonnegative().describe("Outcome id from outcomeMeta (e.g., 1 for the BTC binary daily)"),
349
+ side: z.number().int().min(0).max(9).describe("Side index within the outcome (0 = first sideSpecs entry like 'Yes', 1 = second like 'No')"),
350
+ }, async ({ outcome, side }) => {
351
+ try {
352
+ const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
353
+ const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
354
+ const hl = new HyperliquidAdapter(undefined, MCP_NETWORK === "testnet");
355
+ await hl.init();
356
+ const out = new HyperliquidOutcomeAdapter(hl);
357
+ const book = await out.getOrderbook(outcome, side);
358
+ return { content: [{ type: "text", text: ok(book, { outcome, side, assetId: HyperliquidOutcomeAdapter.assetId(outcome, side) }) }] };
359
+ }
360
+ catch (e) {
361
+ return { content: [{ type: "text", text: errOf(e, { outcome, side }) }], isError: true };
267
362
  }
268
363
  });
269
364
  // ============================================================
270
365
  // Account tools (need private key)
271
366
  // ============================================================
272
- server.tool("get_balance", "Get account balance (equity, available margin, margin used, unrealized PnL) on an exchange", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
367
+ server.tool("get_outcome_positions", "Get the active account's open outcome positions (size, entry notional, mark price, unrealized PnL). Requires a configured Hyperliquid wallet or agent.", {}, async () => {
368
+ try {
369
+ const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
370
+ const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
371
+ const adapter = await getOrCreateAdapter("hyperliquid");
372
+ if (!(adapter instanceof HyperliquidAdapter)) {
373
+ throw new PerpError("CAPABILITY_UNSUPPORTED", "Outcome positions are Hyperliquid-only", { exchange: "hyperliquid" });
374
+ }
375
+ const out = new HyperliquidOutcomeAdapter(adapter);
376
+ const positions = await out.getPositions();
377
+ return { content: [{ type: "text", text: ok(positions, { count: positions.length }) }] };
378
+ }
379
+ catch (e) {
380
+ return { content: [{ type: "text", text: errOf(e, { exchange: "hyperliquid" }) }], isError: true };
381
+ }
382
+ });
383
+ server.tool("get_balance", `Get account balance (equity, available margin, margin used, unrealized PnL) on an exchange. ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
273
384
  try {
274
385
  const adapter = await getOrCreateAdapter(exchange);
275
386
  const balance = await adapter.getBalance();
276
387
  return { content: [{ type: "text", text: ok(balance, { exchange }) }] };
277
388
  }
278
389
  catch (e) {
279
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange }) }], isError: true };
390
+ return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
280
391
  }
281
392
  });
282
- server.tool("get_positions", "Get all open positions on an exchange, including size, entry price, PnL, leverage", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
393
+ server.tool("get_positions", `Get all open positions on an exchange, including size, entry price, PnL, leverage. ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
283
394
  try {
284
395
  const adapter = await getOrCreateAdapter(exchange);
285
396
  const positions = await adapter.getPositions();
286
397
  return { content: [{ type: "text", text: ok(positions, { exchange, count: positions.length }) }] };
287
398
  }
288
399
  catch (e) {
289
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange }) }], isError: true };
400
+ return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
290
401
  }
291
402
  });
292
- server.tool("get_open_orders", "Get all open/pending orders on an exchange", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
403
+ server.tool("get_open_orders", `Get all open/pending orders on an exchange. ${HL_SCOPE_NOTE}`, { exchange: z.string().describe(`Exchange name: ${listVenues().map(v => v.id).join(", ")} (aliases accepted)`) }, async ({ exchange }) => {
293
404
  try {
294
405
  const adapter = await getOrCreateAdapter(exchange);
295
406
  const orders = await adapter.getOpenOrders();
296
407
  return { content: [{ type: "text", text: ok(orders, { exchange, count: orders.length }) }] };
297
408
  }
298
409
  catch (e) {
299
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange }) }], isError: true };
410
+ return { content: [{ type: "text", text: errOf(e, { exchange }) }], isError: true };
300
411
  }
301
412
  });
302
- server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions, and risk metrics across all 4 exchanges", {}, async () => {
303
- const EXCHANGES = ["pacifica", "hyperliquid", "lighter", "aster"];
413
+ server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions, and risk metrics across all exchanges with configured credentials", {}, async () => {
414
+ const EXCHANGES = await listConfiguredAccountVenues();
304
415
  const snapshots = await Promise.all(EXCHANGES.map(async (name) => {
305
416
  try {
306
417
  const adapter = await getOrCreateAdapter(name);
@@ -375,21 +486,22 @@ server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions,
375
486
  // ============================================================
376
487
  server.tool("suggest_command", "Given a natural language trading goal, suggest the exact perp CLI commands to run. Does NOT execute anything — only returns commands for the user to review and run manually", {
377
488
  goal: z.string().describe("Natural language goal, e.g. 'buy 0.1 BTC on pacifica', 'close all positions', 'check funding arb opportunities'"),
378
- exchange: z.string().optional().describe("Preferred exchange (default: pacifica). Options: pacifica, hyperliquid, lighter, aster"),
489
+ exchange: z.string().optional().describe(`Preferred exchange (default: settings.defaultExchange, else pacifica — the CLI's -e default). Options: ${listVenuesWithRole("trading").map(v => v.id).join(", ")}`),
379
490
  }, async ({ goal, exchange }) => {
380
491
  try {
381
- const ex = exchange ?? "pacifica";
492
+ // Same default as the CLI's -e (src/index.ts): settings.defaultExchange, else pacifica. Aliases resolve; unknown names throw.
493
+ const ex = getVenue(exchange ?? (loadSettings().defaultExchange || "pacifica")).id;
382
494
  const g = goal.toLowerCase();
383
495
  const steps = [];
384
496
  if (g.includes("buy") || g.includes("long")) {
385
497
  const symbol = extractSymbol(g) || "BTC";
386
498
  const size = extractNumber(g) || "<size>";
387
- steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} buy ${size}`, description: "Pre-flight validation (dry run)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} buy ${size}`, description: `Buy ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
499
+ steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} buy ${size}`, description: "Pre-flight validation (read-only, sends nothing)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} buy ${size}`, description: `Buy ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
388
500
  }
389
501
  else if (g.includes("sell") || g.includes("short")) {
390
502
  const symbol = extractSymbol(g) || "BTC";
391
503
  const size = extractNumber(g) || "<size>";
392
- steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} sell ${size}`, description: "Pre-flight validation (dry run)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} sell ${size}`, description: `Sell ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
504
+ steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} sell ${size}`, description: "Pre-flight validation (read-only, sends nothing)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} sell ${size}`, description: `Sell ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
393
505
  }
394
506
  else if (g.includes("limit")) {
395
507
  const symbol = extractSymbol(g) || "BTC";
@@ -421,16 +533,16 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
421
533
  }
422
534
  else if (g.includes("tpsl") || g.includes("tp/sl") || g.includes("tp sl") || (g.includes("take profit") && g.includes("stop loss"))) {
423
535
  const symbol = extractSymbol(g) || "<symbol>";
424
- const side = g.includes("sell") || g.includes("short") ? "sell" : "buy";
425
- steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json trade tpsl ${symbol} ${side} --tp <price> --sl <price>`, description: "Set TP/SL bracket orders", dangerous: true }, { step: 3, command: `perp -e ${ex} --json account orders`, description: "Verify TP/SL orders placed" });
536
+ const side = g.includes("sell") || g.includes("short") ? "short" : "long";
537
+ steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position (side and size)" }, { step: 2, command: `perp -e ${ex} --json trade tpsl ${symbol} ${side} --tp <price> --sl <price>`, description: "Set reduce-only TP/SL on the position (positionSide = long|short)", dangerous: true }, { step: 3, command: `perp -e ${ex} --json account orders`, description: "Verify TP/SL orders placed" });
426
538
  }
427
539
  else if (g.includes("trailing") || g.includes("trail")) {
428
540
  const symbol = extractSymbol(g) || "<symbol>";
429
- steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json trade trailing-stop ${symbol}`, description: "Set trailing stop order", dangerous: true });
541
+ steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json strategy trailing-stop ${symbol} --trail <pct>`, description: "Run a client-side trailing stop (closes the position when the trail % is hit)", dangerous: true });
430
542
  }
431
543
  else if (g.includes("stop") || g.includes("sl") || g.includes("stop loss")) {
432
544
  const symbol = extractSymbol(g) || "<symbol>";
433
- steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json trade stop ${symbol} <side> <stopPrice> <size>`, description: "Place stop order", dangerous: true });
545
+ steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position" }, { step: 2, command: `perp -e ${ex} --json trade stop ${symbol} <side> <size> <stopPrice>`, description: "Place stop order", dangerous: true });
434
546
  }
435
547
  else if (g.includes("split") || g.includes("depth") || (g.includes("large") && (g.includes("order") || g.includes("buy") || g.includes("sell")))) {
436
548
  const symbol = extractSymbol(g) || "<symbol>";
@@ -450,7 +562,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
450
562
  steps.push({ step: 1, command: `perp -e ${ex} --json account positions`, description: "Check current position size" }, { step: 2, command: `perp -e ${ex} --json trade reduce ${symbol} ${pct}`, description: `Reduce ${symbol} position by ${pct}%`, dangerous: true });
451
563
  }
452
564
  else if (g.includes("edit") || g.includes("modify order")) {
453
- steps.push({ step: 1, command: `perp -e ${ex} --json account orders`, description: "List open orders to find order ID" }, { step: 2, command: `perp -e ${ex} --json trade edit <symbol> <orderId> <newPrice> <newSize>`, description: "Modify the order", dangerous: true });
565
+ steps.push({ step: 1, command: `perp -e ${ex} --json account orders`, description: "List open orders to find order ID" }, { step: 2, command: `perp -e ${ex} --json trade edit <symbol> <orderId> <price> <size>`, description: "Modify the order", dangerous: true });
454
566
  }
455
567
  else if (g.includes("arb") || g.includes("arbitrage")) {
456
568
  steps.push({ step: 1, command: "perp --json arb scan --min 5", description: "Scan funding rate arbitrage opportunities" }, { step: 2, command: "perp --json arb scan --gaps", description: "Check cross-exchange price gaps" }, { step: 3, command: "perp --json arb scan --hip3", description: "HIP-3 cross-dex arb opportunities (Hyperliquid)" });
@@ -466,7 +578,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
466
578
  }
467
579
  else if (g.includes("bridge") || g.includes("transfer") || g.includes("cross-chain")) {
468
580
  const amount = extractNumber(g) || "<amount>";
469
- steps.push({ step: 1, command: "perp --json bridge chains", description: "List supported chains" }, { step: 2, command: `perp --json bridge quote --from <chain> --to <chain> --amount ${amount}`, description: "Get bridge quote with fees" }, { step: 3, command: `perp --json bridge send --from <chain> --to <chain> --amount ${amount}`, description: "Execute bridge transfer", dangerous: true }, { step: 4, command: "perp --json bridge status <orderId>", description: "Track bridge completion" });
581
+ steps.push({ step: 1, command: "perp --json funds bridge chains", description: "List supported chains" }, { step: 2, command: `perp --json funds bridge quote --from <chain> --to <chain> --amount ${amount}`, description: "Get bridge quote with fees" }, { step: 3, command: `perp --json funds bridge send --from <chain> --to <chain> --amount ${amount}`, description: "Execute bridge transfer", dangerous: true }, { step: 4, command: "perp --json funds bridge status <orderId>", description: "Track bridge completion" });
470
582
  }
471
583
  else if (g.includes("grid") || g.includes("dca") || g.includes("bot")) {
472
584
  const symbol = extractSymbol(g) || "<symbol>";
@@ -491,7 +603,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
491
603
  }
492
604
  }
493
605
  else if (g.includes("setup") || g.includes("init") || g.includes("configure") || g.includes("connect") || g.includes("set key") || g.includes("set wallet") || g.includes("private key")) {
494
- steps.push({ step: 1, command: "perp --json wallet show", description: "Check if any wallets are already configured" }, { step: 2, command: "perp --json wallet set <exchange> <privateKey>", description: "Set private key for exchange (exchange: pacifica, hyperliquid, lighter, or aliases: hl, pac, lt)" }, { step: 3, command: "perp --json wallet set <exchange> <privateKey> --default", description: "Set key + make it the default exchange" }, { step: 4, command: "perp --json wallet show", description: "Verify wallet is configured (shows public address)" });
606
+ steps.push({ step: 1, command: "perp --json wallet show", description: "Check if any wallets are already configured" }, { step: 2, command: "printf '%s' \"$KEY\" | OWS_PASSPHRASE=<pp> perp --json wallet set <exchange>", description: "Import the master key (stdin — never argv) into the OWS vault and make it the active wallet. wallet set takes pacifica, hyperliquid, lighter, aster (aliases: hl, pac, lt, ast); Lighter RH / Ondo / Arcus register with perp wallet agent approve <lighter-rh|ondo|arcus>, QFEX with perp wallet agent import qfex" }, { step: 3, command: "printf '%s' \"$KEY\" | OWS_PASSPHRASE=<pp> perp --json wallet set <exchange> --default", description: "Import key + make it the default exchange" }, { step: 4, command: "perp --json wallet show", description: "Verify wallet is configured (shows public address)" });
495
607
  }
496
608
  else if (g.includes("wallet") || g.includes("balance") || g.includes("on-chain")) {
497
609
  steps.push({ step: 1, command: "perp --json wallet show", description: "Show configured wallets with public addresses" }, { step: 2, command: "perp --json wallet balance", description: "Check on-chain balances" });
@@ -518,7 +630,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
518
630
  steps.push({ step: 1, command: "perp --json arb scan --gaps", description: "Cross-exchange price gaps" }, { step: 2, command: "perp --json arb scan --gaps --live", description: "Live gap monitor" });
519
631
  }
520
632
  else if (g.includes("rebalance")) {
521
- steps.push({ step: 1, command: "perp --json rebalance check", description: "Check balance distribution" }, { step: 2, command: "perp --json rebalance plan", description: "Generate rebalance plan" }, { step: 3, command: "perp --json rebalance execute", description: "Execute rebalance", dangerous: true });
633
+ steps.push({ step: 1, command: "perp --json funds rebalance check", description: "Check balance distribution" }, { step: 2, command: "perp --json funds rebalance plan", description: "Generate rebalance plan" }, { step: 3, command: "perp --json funds rebalance execute", description: "Execute rebalance", dangerous: true });
522
634
  }
523
635
  else if (g.includes("job") || g.includes("running") || g.includes("background")) {
524
636
  steps.push({ step: 1, command: "perp --json background list", description: "List running background jobs" });
@@ -534,11 +646,31 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
534
646
  }
535
647
  else if (g.includes("deposit")) {
536
648
  const amount = extractNumber(g) || "<amount>";
537
- steps.push({ step: 1, command: "perp --json wallet balance", description: "Check wallet balance" }, { step: 2, command: `perp --json funds deposit ${ex} ${amount}`, description: `Deposit $${amount} to ${ex}`, dangerous: true }, { step: 3, command: `perp -e ${ex} --json portfolio`, description: "Verify deposit arrived" });
649
+ steps.push({ step: 1, command: "perp --json wallet balance", description: "Check wallet balance" });
650
+ if (ex === "lighter") {
651
+ // Lighter deposits pick a route: ethereum (L1 bridge) or cctp <chain> (USDC via CCTP).
652
+ steps.push({ step: 2, command: `perp --json funds deposit lighter ethereum ${amount}`, description: `Deposit $${amount} to lighter from Ethereum L1 (or: perp --json funds deposit lighter cctp <chain> ${amount})`, dangerous: true }, { step: 3, command: `perp -e ${ex} --json portfolio`, description: "Verify deposit arrived" });
653
+ }
654
+ else if (ex === "qfex") {
655
+ // QFEX: perp-cli shows the deposit address; the user sends USDC on Arbitrum One from their own wallet.
656
+ steps.push({ step: 2, command: "perp --json funds deposit qfex", description: "Show the QFEX USDC (Arbitrum One) deposit address — perp-cli sends nothing; transfer from your own wallet" }, { step: 3, command: `perp -e ${ex} --json portfolio`, description: "Verify deposit arrived" });
657
+ }
658
+ else if (MCP_DEPOSIT_VENUES.includes(ex)) {
659
+ steps.push({ step: 2, command: `perp --json funds deposit ${ex} ${amount}`, description: `Deposit $${amount} to ${ex}`, dangerous: true }, { step: 3, command: `perp -e ${ex} --json portfolio`, description: "Verify deposit arrived" });
660
+ }
661
+ else {
662
+ steps.push({ step: 2, command: "perp --json funds info", description: `${ex} has no CLI deposit route (deposit on the venue website); CLI deposit venues: ${MCP_DEPOSIT_VENUES.join(", ")}` });
663
+ }
538
664
  }
539
665
  else if (g.includes("withdraw")) {
540
666
  const amount = extractNumber(g) || "<amount>";
541
- steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }, { step: 2, command: `perp --json funds withdraw ${ex} ${amount}`, description: `Withdraw $${amount} from ${ex}`, dangerous: true }, { step: 3, command: "perp --json wallet balance", description: "Verify withdrawal received" });
667
+ steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" });
668
+ if (MCP_WITHDRAW_VENUES.includes(ex)) {
669
+ steps.push({ step: 2, command: `perp --json funds withdraw ${ex} ${amount}`, description: `Withdraw $${amount} from ${ex}`, dangerous: true }, { step: 3, command: "perp --json wallet balance", description: "Verify withdrawal received" });
670
+ }
671
+ else {
672
+ steps.push({ step: 2, command: "perp --json funds info", description: `${ex} has no CLI withdraw route (withdraw on the venue website); CLI withdraw venues: ${MCP_WITHDRAW_VENUES.join(", ")}` });
673
+ }
542
674
  }
543
675
  else if (g.includes("leverage")) {
544
676
  const symbol = extractSymbol(g) || "<symbol>";
@@ -564,7 +696,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
564
696
  }
565
697
  }
566
698
  else if (g.includes("setting") || g.includes("config") || g.includes("referral")) {
567
- steps.push({ step: 1, command: "perp settings show", description: "Show current settings" }, { step: 2, command: "perp settings referrals on", description: "Enable referral codes (no extra fees, just volume tracking)" });
699
+ steps.push({ step: 1, command: "perp settings show", description: "Show current settings" }, { step: 2, command: "perp -e hyperliquid wallet manage referral set", description: "Explicitly apply the perp-cli referral code (signs + sends one referral action; no extra fees). Also -e lighter. Supports --dry-run", dangerous: true });
568
700
  }
569
701
  else {
570
702
  steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check account overview" }, { step: 2, command: `perp --json portfolio --health --exchanges ${ex}`, description: "Check exchange connectivity" });
@@ -582,14 +714,14 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
582
714
  "Adjust exchange with -e <exchange> flag",
583
715
  "Use 'perp trade check' for pre-flight validation before executing trades",
584
716
  "Run commands in your terminal — this MCP server does NOT execute them",
585
- "If perp-cli is useful, run 'perp settings referrals on' to support development. No extra fees — just helps track volume.",
717
+ "If perp-cli is useful, run 'perp -e hyperliquid wallet manage referral set' (or -e lighter) to support development. It signs one referral action; no extra fees.",
586
718
  ],
587
719
  }),
588
720
  }],
589
721
  };
590
722
  }
591
723
  catch (e) {
592
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
724
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
593
725
  }
594
726
  });
595
727
  server.tool("explain_command", "Explain what a perp CLI command does, its parameters, and any risks. Helps the user understand a command before running it", {
@@ -671,10 +803,10 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
671
803
  parameters: [
672
804
  { name: "symbol", value: args[2], description: "Trading pair" },
673
805
  { name: "side", value: args[3], description: "buy or sell" },
674
- { name: "stopPrice", value: args[4], description: "Trigger price" },
675
- { name: "size", value: args[5], description: "Order size" },
806
+ { name: "size", value: args[4], description: "Order size" },
807
+ { name: "stopPrice", value: args[5], description: "Trigger price" },
676
808
  ],
677
- risks: ["Stop orders become market orders when triggered — slippage possible", "Stop may not fill in fast markets"],
809
+ risks: ["Stop orders become market orders when triggered — slippage possible", "Stop may not fill in fast markets", "Reduce-only by default (--no-reduce-only to open a position)"],
678
810
  category: "write",
679
811
  relatedCommands: ["perp account positions", "perp trade tpsl"],
680
812
  };
@@ -682,10 +814,10 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
682
814
  else if (category === "trade" && sub === "tpsl") {
683
815
  explanation = {
684
816
  command,
685
- description: "Sets take-profit and stop-loss bracket orders on a position. Both orders are reduce-only.",
817
+ description: "Sets take-profit and stop-loss on an open position (checked against the live position). Legs are reduce-only, sized to the live position.",
686
818
  parameters: [
687
819
  { name: "symbol", value: args[2], description: "Trading pair" },
688
- { name: "side", value: args[3], description: "Position side (buy=long, sell=short)" },
820
+ { name: "positionSide", value: args[3], description: "The position's side: long | short (buy/sell not accepted)" },
689
821
  { name: "--tp", description: "Take-profit price" },
690
822
  { name: "--sl", description: "Stop-loss price" },
691
823
  ],
@@ -728,16 +860,14 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
728
860
  relatedCommands: ["perp account balance", "perp trade cancel-twap"],
729
861
  };
730
862
  }
731
- else if (category === "trade" && (sub === "trailing-stop" || sub === "pnl-track")) {
863
+ else if (category === "trade" && sub === "pnl-track") {
732
864
  explanation = {
733
865
  command,
734
- description: sub === "trailing-stop"
735
- ? "Sets a trailing stop that follows the price by a callback percentage. Triggers a market close when price reverses."
736
- : "Starts real-time PnL tracking for open positions with visual updates",
737
- parameters: [{ name: "symbol", value: args[2], description: "Trading pair" }],
738
- risks: sub === "trailing-stop" ? ["Will close position when trailing stop is triggered"] : [],
739
- category: sub === "trailing-stop" ? "write" : "read",
740
- relatedCommands: ["perp account positions", "perp trade stop"],
866
+ description: "Starts real-time PnL tracking for open positions with visual updates",
867
+ parameters: [],
868
+ risks: [],
869
+ category: "read",
870
+ relatedCommands: ["perp account positions", "perp strategy trailing-stop <symbol> --trail <pct>"],
741
871
  };
742
872
  }
743
873
  else if (category === "trade" && (sub === "reduce" || sub === "flatten" || sub === "close-all")) {
@@ -853,7 +983,8 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
853
983
  relatedCommands: ["perp arb scan", "perp arb status", "perp arb scan --gaps"],
854
984
  };
855
985
  }
856
- else if (category === "bridge") {
986
+ else if (category === "funds" && sub === "bridge") {
987
+ const bsub = args[2] ?? "";
857
988
  explanation = {
858
989
  command,
859
990
  description: {
@@ -862,10 +993,10 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
862
993
  send: "Executes a cross-chain USDC bridge transfer",
863
994
  exchange: "Bridges USDC between exchange accounts",
864
995
  status: "Checks the status of a bridge transfer",
865
- }[sub] || `Bridge command: ${sub}`,
996
+ }[bsub] || `Bridge command: ${bsub}`,
866
997
  parameters: [],
867
- risks: sub === "send" || sub === "exchange" ? ["Transfers real funds cross-chain — verify addresses carefully"] : [],
868
- category: sub === "send" || sub === "exchange" ? "write" : "read",
998
+ risks: bsub === "send" || bsub === "exchange" ? ["Transfers real funds cross-chain — verify addresses carefully"] : [],
999
+ category: bsub === "send" || bsub === "exchange" ? "write" : "read",
869
1000
  relatedCommands: ["perp funds bridge chains", "perp funds bridge quote"],
870
1001
  };
871
1002
  }
@@ -895,7 +1026,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
895
1026
  command,
896
1027
  description: `Automated strategy: ${sub}. Runs as a background job.`,
897
1028
  parameters: args.slice(2).map((a, i) => ({ name: `arg${i}`, value: a, description: "See --help" })),
898
- risks: ["Runs automated trades — monitor with 'perp background list'", "Use --dry-run to simulate first"],
1029
+ risks: ["Runs automated trades — monitor with 'perp background list'", "--dry-run runs it against a read-only adapter (no orders sent); strategies that cannot be simulated reject --dry-run"],
899
1030
  category: "write",
900
1031
  relatedCommands: ["perp background list", "perp background stop"],
901
1032
  };
@@ -933,7 +1064,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
933
1064
  else if (category === "settings") {
934
1065
  explanation = {
935
1066
  command,
936
- description: { show: "Display current CLI settings", referrals: "Toggle referral codes on/off (no extra fees, helps track volume)", set: "Set a specific setting value" }[sub] || `Settings: ${sub}`,
1067
+ description: { show: "Display current CLI settings", set: "Set a specific setting value" }[sub] || `Settings: ${sub}`,
937
1068
  parameters: [],
938
1069
  risks: [],
939
1070
  category: "read",
@@ -954,7 +1085,7 @@ server.tool("explain_command", "Explain what a perp CLI command does, its parame
954
1085
  return { content: [{ type: "text", text: ok(explanation) }] };
955
1086
  }
956
1087
  catch (e) {
957
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1088
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
958
1089
  }
959
1090
  });
960
1091
  // ── Helper functions for suggest_command ──
@@ -1013,39 +1144,53 @@ server.tool("arb_scan", "Scan for funding rate arbitrage opportunities across ex
1013
1144
  };
1014
1145
  }
1015
1146
  catch (e) {
1016
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1147
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
1017
1148
  }
1018
1149
  });
1019
- server.tool("health_check", "Ping all 4 exchanges and return connectivity status and latency", {}, async () => {
1150
+ server.tool("health_check", "Ping every registered venue on the server's network and return connectivity status and latency. Pacifica / Hyperliquid / Lighter / Aster / QFEX: REST ping (status = HTTP status, 0 = no response); every other venue: its public market-data read (status null). A failure carries its error message.", {}, async () => {
1020
1151
  try {
1021
- // Aster has no shared-api ping helper; ping /fapi/v1/time directly.
1022
- const pingAster = async () => {
1152
+ const timed = async (probe, fn) => {
1023
1153
  const start = Date.now();
1024
1154
  try {
1025
- const res = await fetch("https://fapi.asterdex.com/fapi/v1/time");
1026
- return { ok: res.ok, latencyMs: Date.now() - start, status: res.status };
1155
+ const r = await fn();
1156
+ return { ok: r.ok, latencyMs: Date.now() - start, status: r.status, error: r.ok ? null : `HTTP ${r.status}`, probe };
1027
1157
  }
1028
- catch {
1029
- return { ok: false, latencyMs: Date.now() - start, status: 0 };
1158
+ catch (e) {
1159
+ return { ok: false, latencyMs: Date.now() - start, status: probe === "ping" ? 0 : null, error: extractErrorMessage(e), probe };
1030
1160
  }
1031
1161
  };
1032
- const [pacifica, hyperliquid, lighter, aster] = await Promise.all([
1033
- pingPacifica(),
1034
- pingHyperliquid(),
1035
- pingLighter(),
1036
- pingAster(),
1037
- ]);
1038
- const result = {
1039
- pacifica: { ...pacifica, statusText: pacifica.ok ? "healthy" : "unreachable" },
1040
- hyperliquid: { ...hyperliquid, statusText: hyperliquid.ok ? "healthy" : "unreachable" },
1041
- lighter: { ...lighter, statusText: lighter.ok ? "healthy" : "unreachable" },
1042
- aster: { ...aster, statusText: aster.ok ? "healthy" : "unreachable" },
1043
- allHealthy: pacifica.ok && hyperliquid.ok && lighter.ok && aster.ok,
1162
+ const restPing = (url) => timed("ping", async () => {
1163
+ const res = await fetch(url);
1164
+ return { ok: res.ok, status: res.status };
1165
+ });
1166
+ // Shared-api helpers report a transport failure as status 0 (no message).
1167
+ const helperPing = (fn) => async () => {
1168
+ const r = await fn();
1169
+ return { ...r, error: r.ok ? null : r.status === 0 ? "no response" : `HTTP ${r.status}`, probe: "ping" };
1170
+ };
1171
+ const pings = {
1172
+ pacifica: helperPing(pingPacifica),
1173
+ hyperliquid: helperPing(pingHyperliquid),
1174
+ lighter: helperPing(pingLighter),
1175
+ aster: () => restPing(`${networkConfig(getVenue("aster"), MCP_NETWORK).restUrl}/fapi/v1/time`),
1176
+ // QFEX: public GET /refdata for one instrument on the server's network.
1177
+ qfex: () => restPing(`${networkConfig(getVenue("qfex"), MCP_NETWORK).restUrl}/refdata?ticker=AAPL-USD`),
1044
1178
  };
1179
+ const venues = listVenues();
1180
+ const probes = await Promise.all(venues.map(v => {
1181
+ const ping = pings[v.id];
1182
+ return ping !== undefined
1183
+ ? ping()
1184
+ : timed("markets", async () => { await v.publicData.fetchMarkets(MCP_NETWORK); return { ok: true, status: null }; });
1185
+ }));
1186
+ const result = {};
1187
+ venues.forEach((v, i) => { result[v.id] = { ...probes[i], statusText: probes[i].ok ? "healthy" : "unreachable" }; });
1188
+ result.covered = venues.map(v => v.id);
1189
+ result.allHealthy = probes.every(p => p.ok);
1045
1190
  return { content: [{ type: "text", text: ok(result) }] };
1046
1191
  }
1047
1192
  catch (e) {
1048
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1193
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
1049
1194
  }
1050
1195
  });
1051
1196
  // ============================================================
@@ -1055,13 +1200,14 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1055
1200
  const schema = {
1056
1201
  schemaVersion: "2.0",
1057
1202
  name: "perp",
1058
- description: "Multi-DEX Perpetual Futures CLI (Pacifica, Hyperliquid, Lighter, Aster)",
1059
- exchanges: ["pacifica", "hyperliquid", "lighter", "aster"],
1203
+ description: `Multi-DEX Perpetual Futures CLI (${listVenues().map(v => v.displayName).join(", ")})`,
1204
+ exchanges: listVenues().map(v => ({ id: v.id, displayName: v.displayName, roles: v.roles })),
1060
1205
  globalFlags: [
1061
- { flag: "-e, --exchange <name>", description: "Exchange to use (pacifica, hyperliquid, lighter, aster)", default: "pacifica" },
1206
+ { flag: "-e, --exchange <name>", description: `Exchange to use (${listVenues().map(v => v.id).join(", ")})`, default: "settings.defaultExchange, else pacifica" },
1062
1207
  { flag: "--json", description: "Output as JSON for structured parsing" },
1063
- { flag: "-n, --network <net>", description: "Network: mainnet or testnet", default: "mainnet" },
1064
- { flag: "--dry-run", description: "Simulate without executing (for trade commands)" },
1208
+ { flag: "-n, --network <net>", description: "Network: mainnet or testnet (validated; a typo is a usage error)", default: "mainnet" },
1209
+ { flag: "--dry-run", description: "Print the order/plan without sending anything (result has sent:false). Default-deny: commands that cannot honor it reject --dry-run instead of running live" },
1210
+ { flag: "--dex <name>", description: "HIP-3 deployed dex name, Hyperliquid only (the MCP tools read/trade native Hyperliquid perps only). Example: perp -e hyperliquid --dex <name> market list" },
1065
1211
  ],
1066
1212
  commands: {
1067
1213
  market: {
@@ -1093,17 +1239,16 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1093
1239
  },
1094
1240
  },
1095
1241
  trade: {
1096
- description: "Trading commands (execute in terminal, use --dry-run to simulate)",
1242
+ description: "Trading commands (execute in terminal; --dry-run prints the order without sending it)",
1097
1243
  subcommands: {
1098
1244
  market: { usage: "perp trade market <symbol> <buy|sell> <size>", description: "Market order" },
1099
1245
  buy: { usage: "perp trade buy <symbol> <size>", description: "Shorthand market buy" },
1100
1246
  sell: { usage: "perp trade sell <symbol> <size>", description: "Shorthand market sell" },
1101
1247
  limit: { usage: "perp trade limit <symbol> <buy|sell> <price> <size>", description: "Limit order" },
1102
- stop: { usage: "perp trade stop <symbol> <side> <stopPrice> <size>", description: "Stop order" },
1103
- tpsl: { usage: "perp trade tpsl <symbol> <side> --tp <p> --sl <p>", description: "TP/SL bracket" },
1248
+ stop: { usage: "perp trade stop <symbol> <side> <size> <stopPrice>", description: "Stop order (reduce-only by default; --no-reduce-only to open)" },
1249
+ tpsl: { usage: "perp trade tpsl <symbol> <long|short> --tp <p> --sl <p>", description: "TP/SL on an open position (reduce-only)" },
1104
1250
  "scale-tp": { usage: "perp trade scale-tp <symbol> --levels '<p>:<pct>,...'", description: "Scaled take-profit" },
1105
1251
  "scale-in": { usage: "perp trade scale-in <symbol> <side> --levels '<p>:<size>,...'", description: "Scaled entry" },
1106
- "trailing-stop": { usage: "perp trade trailing-stop <symbol>", description: "Trailing stop" },
1107
1252
  twap: { usage: "perp trade twap <symbol> <side> <size> <duration>", description: "TWAP execution" },
1108
1253
  edit: { usage: "perp trade edit <symbol> <orderId> <price> <size>", description: "Modify existing order" },
1109
1254
  cancel: { usage: "perp trade cancel <symbol> <orderId>", description: "Cancel order" },
@@ -1128,7 +1273,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1128
1273
  subcommands: {
1129
1274
  scan: { usage: "perp arb scan --min <pct>", description: "Find arb opportunities (use --rates, --gaps, --hip3, --positions, --basis, --compare, --history, --live)" },
1130
1275
  auto: { usage: "perp arb auto --min-spread <pct>", description: "Auto-execute funding arb" },
1131
- exec: { usage: "perp arb exec <symbol> <longEx> <shortEx> <size>", description: "Execute arb trade on two exchanges" },
1276
+ exec: { usage: "perp arb exec <symbol> <longExch> <shortExch> <sizeUsd>", description: "Execute arb trade on two exchanges (sizeUsd = USD notional per leg)" },
1132
1277
  status: { usage: "perp arb status", description: "Current arb positions" },
1133
1278
  close: { usage: "perp arb close <symbol>", description: "Close arb position" },
1134
1279
  "history|log": { usage: "perp arb history", description: "Arb execution history" },
@@ -1149,10 +1294,10 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1149
1294
  subcommands: {
1150
1295
  start: { usage: "perp strategy start <config>", description: "Start bot from config file" },
1151
1296
  twap: { usage: "perp strategy twap <symbol> <side> <size> <duration>", description: "TWAP execution" },
1152
- grid: { usage: "perp strategy grid <symbol> --range <pct> --grids <n> --size <usd>", description: "Grid trading bot" },
1297
+ grid: { usage: "perp strategy grid <symbol> --upper <price> --lower <price> --grids <n> --size <base>", description: "Grid trading bot" },
1153
1298
  dca: { usage: "perp strategy dca <symbol> <side> <amount> <interval>", description: "DCA bot" },
1154
1299
  "funding-arb": { usage: "perp strategy funding-arb", description: "Funding arb bot" },
1155
- "trailing-stop": { usage: "perp strategy trailing-stop <symbol>", description: "Trailing stop bot" },
1300
+ "trailing-stop": { usage: "perp strategy trailing-stop <symbol> --trail <pct>", description: "Client-side trailing stop (closes the position when the trail % is hit)" },
1156
1301
  "quick-grid": { usage: "perp strategy quick-grid <symbol>", description: "Quick grid bot" },
1157
1302
  "quick-dca": { usage: "perp strategy quick-dca <symbol> <side> <amount> <interval>", description: "Quick DCA bot" },
1158
1303
  "quick-arb": { usage: "perp strategy quick-arb", description: "Quick arb bot" },
@@ -1171,15 +1316,16 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1171
1316
  list: { usage: "perp wallet list", description: "List all wallets (OWS vault + legacy)" },
1172
1317
  balance: { usage: "perp wallet balance [name]", description: "On-chain balances" },
1173
1318
  generate: { usage: "perp wallet generate [name]", description: "Generate new OWS wallet (encrypted, multi-chain)" },
1174
- import: { usage: "perp wallet import <privateKey>", description: "Import key into OWS vault" },
1319
+ import: { usage: "printf '%s' \"$KEY\" | OWS_PASSPHRASE=<pp> perp wallet import --name <name> [--chain evm|solana|dual]", description: "Import key into OWS vault (stdin / hidden prompt — never argv)" },
1175
1320
  migrate: { usage: "perp wallet migrate", description: "Migrate legacy wallets to OWS vault" },
1176
- set: { usage: "perp wallet set <exchange> <key>", description: "Set private key for exchange (legacy)" },
1321
+ set: { usage: "perp wallet set <exchange> [--name <n>]", description: "Import an exchange key (hidden prompt / stdin; argv key rejected) into the OWS vault → active wallet" },
1177
1322
  use: { usage: "perp wallet use <name>", description: "Set active wallet (OWS or legacy)" },
1178
1323
  remove: { usage: "perp wallet remove <name>", description: "Remove a wallet" },
1179
1324
  rename: { usage: "perp wallet rename <oldName> <newName>", description: "Rename a wallet" },
1180
1325
  policy: { usage: "perp wallet policy <create|list|show|delete>", description: "OWS signing policies (guardrails for agent signing)" },
1181
1326
  key: { usage: "perp wallet key <create|list|revoke>", description: "OWS API keys (scoped agent access tokens)" },
1182
1327
  agent: { usage: "perp wallet agent <approve|list|revoke|rotate|verify>", description: "DEX-side agent wallet delegation (per-exchange)" },
1328
+ builder: { usage: "perp wallet builder <status|approve|revoke>", description: "Builder fees (off by default): status / master-signed approve / revoke of the builder set in settings builder.<venue> (approve/revoke: --dry-run, --yes)" },
1183
1329
  deposit: { usage: "perp wallet deposit <walletName>", description: "MoonPay multi-chain deposit (auto-converts to USDC)" },
1184
1330
  setup: { usage: "perp wallet setup", description: "One-click: wallet + guardrail policy + agent API key" },
1185
1331
  backup: { usage: "perp wallet backup", description: "Backup encrypted OWS vault" },
@@ -1224,8 +1370,10 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1224
1370
  usage: "perp funds <deposit|withdraw|transfer|bridge|rebalance|info> ...",
1225
1371
  description: "Fund movement — deposit, withdraw, transfer, cross-chain bridge, inter-exchange rebalance",
1226
1372
  subcommands: {
1227
- "deposit <exchange> <amount>": "Deposit USDC to exchange (pacifica, hyperliquid, lighter)",
1228
- "withdraw <exchange> <amount>": "Withdraw USDC from exchange (pacifica, hyperliquid, lighter)",
1373
+ [`deposit <${MCP_DEPOSIT_VENUES.filter(v => v !== "lighter" && v !== "qfex").join("|")}> <amount>`]: "Deposit USDC to an exchange account",
1374
+ "deposit qfex": "Show the QFEX USDC (Arbitrum One) deposit address — perp-cli sends nothing",
1375
+ "deposit lighter <ethereum|cctp> ...": "Deposit to Lighter: perp funds deposit lighter ethereum <amount> (L1 bridge) | perp funds deposit lighter cctp <chain> <amount> (CCTP)",
1376
+ [`withdraw <${MCP_WITHDRAW_VENUES.join("|")}> <amount>`]: "Withdraw USDC from an exchange account",
1229
1377
  "transfer <amount> <address>": "HL internal transfer (instant)",
1230
1378
  "bridge chains": "List supported chains and USDC addresses",
1231
1379
  "bridge quote --from <chain> --to <chain> --amount <n>": "Get bridge quotes from all providers",
@@ -1238,6 +1386,19 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1238
1386
  info: "Show all deposit/withdraw/bridge/rebalance routes & limits",
1239
1387
  },
1240
1388
  },
1389
+ outcome: {
1390
+ description: "Hyperliquid Outcome markets (HIP-4) — binary/range contracts",
1391
+ subcommands: {
1392
+ list: { usage: "perp outcome list", description: "Active outcome markets" },
1393
+ book: { usage: "perp outcome book <outcome> <side>", description: "Orderbook for one outcome side" },
1394
+ view: { usage: "perp outcome view <outcome>", description: "Both sides' books + underlying mark gap + expiry" },
1395
+ positions: { usage: "perp outcome positions", description: "Open outcome positions" },
1396
+ orders: { usage: "perp outcome orders", description: "Open outcome orders" },
1397
+ buy: { usage: "perp outcome buy <outcome> <side> [usd]", description: "Buy a side (USD notional, or --size <shares>; --limit <px>; supports --dry-run)" },
1398
+ sell: { usage: "perp outcome sell <outcome> <side> [usd]", description: "Sell a side (USD notional, --size <shares> or --all; supports --dry-run)" },
1399
+ cancel: { usage: "perp outcome cancel <outcome> <side> <oid>", description: "Cancel an open outcome order" },
1400
+ },
1401
+ },
1241
1402
  portfolio: { usage: "perp portfolio", description: "Cross-exchange portfolio summary" },
1242
1403
  status: { usage: "perp portfolio", description: "Full account overview" },
1243
1404
  health: { usage: "perp portfolio --health", description: "Exchange connectivity check" },
@@ -1245,7 +1406,6 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1245
1406
  description: "CLI settings",
1246
1407
  subcommands: {
1247
1408
  show: { usage: "perp settings show", description: "Current settings" },
1248
- referrals: { usage: "perp settings referrals on|off", description: "Toggle referral codes (no extra fees)" },
1249
1409
  },
1250
1410
  },
1251
1411
  // `agent` is now `wallet agent ...` — see `wallet.subcommands.agent` above.
@@ -1253,9 +1413,9 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1253
1413
  tips: [
1254
1414
  "Always use --json for structured output when automating",
1255
1415
  "Use 'perp trade check' before executing trades for validation",
1256
- "Use --dry-run to simulate trade commands",
1416
+ "Use --dry-run to print a trade's order without sending it (sent:false); commands that cannot honor it reject the flag",
1257
1417
  "Numbers are strings to avoid float precision loss",
1258
- "If perp-cli is useful, run 'perp settings referrals on' to support development. No extra fees — just helps track volume.",
1418
+ "If perp-cli is useful, run 'perp -e hyperliquid wallet manage referral set' (or -e lighter) to support development. It signs one referral action; no extra fees.",
1259
1419
  ],
1260
1420
  };
1261
1421
  return { contents: [{ uri: "perp://schema", text: JSON.stringify(schema, null, 2), mimeType: "application/json" }] };
@@ -1263,67 +1423,132 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1263
1423
  // ============================================================
1264
1424
  // Trade execution tools (dry-run + confirm safety model)
1265
1425
  // ============================================================
1266
- server.tool("trade_preview", "Preview a trade WITHOUT executing. Returns estimated fill price, fees, margin impact, and risk checks. ALWAYS call this before trade_execute and show the result to the user for confirmation.", {
1267
- exchange: z.string().describe("Exchange: pacifica, hyperliquid, lighter, or aster"),
1426
+ /**
1427
+ * The OrderRequest trade_execute sends (and trade_preview checks): limit orders need a price and
1428
+ * a TIF the venue accepts; a market order carries the resolved slippage bound. Throws
1429
+ * INVALID_PARAMS / CAPABILITY_UNSUPPORTED before anything is read or sent.
1430
+ */
1431
+ function mcpOrderRequest(adapter, p) {
1432
+ const def = getVenue(adapter.name);
1433
+ const caps = requireCapabilities(def);
1434
+ const isLimit = p.orderType === "limit";
1435
+ if (isLimit && !p.price)
1436
+ throw new PerpError("INVALID_PARAMS", "Price is required for limit orders");
1437
+ return {
1438
+ symbol: p.symbol, side: p.side, type: p.orderType, size: p.size,
1439
+ price: isLimit ? p.price : null,
1440
+ triggerPrice: null, triggerSource: null,
1441
+ tif: isLimit ? validateLegacyTif(def, caps, p.tif ?? "GTC") : null,
1442
+ reduceOnly: p.reduceOnly,
1443
+ clientOrderId: caps.clientOrderId && caps.orderAck === "async" ? newVenueClientOrderId(def) : null,
1444
+ slippage: isLimit ? null : resolveAdapterSlippage(adapter, p.slippageBps ?? null),
1445
+ tpsl: null,
1446
+ };
1447
+ }
1448
+ server.tool("trade_preview", `Preview a trade WITHOUT executing. Runs trade_execute's pre-send checks read-only (size parse, order validation incl. TIF, quarantine, local risk limits — never forced) and reports any refusal as valid:false with its code + remediation; also returns the estimated fill price from the book, notional, margin impact, the builder fee the order would carry, and the existing position. Venue trading fees are NOT estimated. ALWAYS call this before trade_execute and show the result to the user for confirmation. ${HL_SCOPE_NOTE}`, {
1449
+ exchange: z.string().describe(`Exchange: ${listVenues().filter(v => v.create !== null).map(v => v.id).join(", ")} (aliases accepted)`),
1268
1450
  symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
1269
1451
  side: z.enum(["buy", "sell"]).describe("Order side"),
1270
1452
  size: z.string().describe("Order size (base currency units, e.g., '0.1' for 0.1 BTC)"),
1271
1453
  orderType: z.enum(["market", "limit"]).default("market").describe("Order type"),
1272
1454
  price: z.string().optional().describe("Limit price (required for limit orders)"),
1273
- }, async ({ exchange, symbol, side, size, orderType, price }) => {
1455
+ tif: z.string().optional().describe("Limit orders: GTC (default) | IOC | FOK | ALO (post-only); venue aliases GTT / GTX / TOB accepted — checked like trade_execute"),
1456
+ reduceOnly: z.boolean().default(false).describe("Reduce-only order (close position only) — checked like trade_execute (quarantine applies only to non-reduce-only orders)"),
1457
+ }, async ({ exchange, symbol, side, size, orderType, price, tif, reduceOnly }) => {
1274
1458
  try {
1275
1459
  const adapter = await getOrCreateAdapter(exchange);
1460
+ const sym = symbol.toUpperCase();
1461
+ const isLimit = orderType === "limit";
1462
+ // trade_execute's pre-send checks, read-only. Each refusal keeps its code + remediation.
1463
+ const refusals = [];
1464
+ const refuse = (check, e) => {
1465
+ const s = classifyError(e, adapter.name);
1466
+ refusals.push({ check, code: s.code, message: s.message, remediation: s.remediation ?? null });
1467
+ };
1468
+ let parsedSize = null;
1469
+ try {
1470
+ parsedSize = parsePositiveDecimal(size, "size");
1471
+ }
1472
+ catch (e) {
1473
+ refuse("size", e);
1474
+ }
1475
+ if (parsedSize !== null) {
1476
+ try {
1477
+ const req = mcpOrderRequest(adapter, { symbol: sym, side, size: parsedSize, orderType, price, tif, reduceOnly, slippageBps: undefined });
1478
+ const def = getVenue(adapter.name);
1479
+ validateOrderRequest(def, requireCapabilities(def), req);
1480
+ }
1481
+ catch (e) {
1482
+ refuse("order", e);
1483
+ }
1484
+ try {
1485
+ await mcpPreTradeGate(adapter, { symbol: sym, size: Number(parsedSize), price: isLimit && price ? Number(price) : null, reduceOnly }, (m) => process.stderr.write(`${m}\n`));
1486
+ }
1487
+ catch (e) {
1488
+ refuse("gate", e);
1489
+ }
1490
+ }
1276
1491
  const [balance, positions, orderbook] = await Promise.all([
1277
1492
  adapter.getBalance(),
1278
1493
  adapter.getPositions(),
1279
- adapter.getOrderbook(symbol),
1494
+ adapter.getOrderbook(sym),
1280
1495
  ]);
1281
1496
  // Estimate fill price from orderbook
1282
1497
  const bestBid = orderbook.bids[0]?.[0];
1283
1498
  const bestAsk = orderbook.asks[0]?.[0];
1284
- const estPrice = orderType === "limit" && price
1499
+ const estPrice = isLimit && price
1285
1500
  ? price
1286
1501
  : side === "buy" ? bestAsk : bestBid;
1287
- const notional = estPrice ? Number(estPrice) * Number(size) : 0;
1288
- const existingPos = positions.find(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()));
1289
- // Run validation
1502
+ // Unknown (unparseable size / empty book side) = null, never 0.
1503
+ const notional = estPrice && parsedSize !== null ? Number(estPrice) * Number(parsedSize) : null;
1504
+ const existingPos = positions.find(p => symbolMatch(p.symbol, symbol));
1290
1505
  // SSOT rule #2: validation failure must surface as a structured invalid
1291
- // result, NOT be swallowed into a fake `{ valid: true }`. The previous
1292
- // shape silently green-lit trades when the underlying balance/orderbook
1293
- // fetches errored out.
1506
+ // result, NOT be swallowed into a fake `{ valid: true }`.
1294
1507
  let validation;
1295
1508
  // For a preview the user has not yet committed leverage, so 1x is the
1296
1509
  // documented default exposed in the preview shape (no silent fallback —
1297
1510
  // the caller can override via existingPos.leverage when re-entering an
1298
1511
  // open position).
1299
1512
  const previewLeverage = existingPos?.leverage ?? 1;
1300
- try {
1301
- validation = await validateTrade(adapter, { symbol, side, size: Number(size), leverage: previewLeverage });
1302
- }
1303
- catch (err) {
1304
- const msg = err instanceof Error ? err.message : String(err);
1305
- validation = {
1306
- valid: false,
1307
- checks: [{
1308
- check: "symbol_valid",
1309
- passed: false,
1310
- message: `Validation could not run: ${msg}`,
1311
- }],
1312
- warnings: [],
1313
- };
1513
+ if (parsedSize === null) {
1514
+ validation = { valid: false, checks: [{ check: "size", passed: false, message: refusals[0].message }], warnings: [] };
1314
1515
  }
1516
+ else {
1517
+ try {
1518
+ validation = await validateTrade(adapter, {
1519
+ symbol: sym, side, size: Number(parsedSize), leverage: previewLeverage, type: orderType, reduceOnly,
1520
+ ...(isLimit && price ? { price: Number(price) } : {}),
1521
+ });
1522
+ }
1523
+ catch (err) {
1524
+ const msg = err instanceof Error ? err.message : String(err);
1525
+ validation = {
1526
+ valid: false,
1527
+ checks: [{
1528
+ check: "symbol_valid",
1529
+ passed: false,
1530
+ message: `Validation could not run: ${msg}`,
1531
+ }],
1532
+ warnings: [],
1533
+ };
1534
+ }
1535
+ }
1536
+ const builderPlan = await builderPlanFor(adapter, sym);
1315
1537
  const preview = {
1316
- order: { exchange, symbol, side, size, orderType, price: price ?? estPrice },
1538
+ // false when trade_execute would refuse this order (see refusals) or the trade checks fail.
1539
+ valid: refusals.length === 0 && validation.valid,
1540
+ refusals,
1541
+ order: { exchange, symbol, side, size, orderType, price: price ?? estPrice ?? null, tif: tif ?? null, reduceOnly },
1317
1542
  estimate: {
1318
- fillPrice: estPrice ?? "unknown",
1319
- notionalUsd: notional.toFixed(2),
1320
- spread: bestBid && bestAsk ? ((Number(bestAsk) - Number(bestBid)) / Number(bestBid) * 100).toFixed(4) + "%" : "unknown",
1543
+ fillPrice: estPrice ?? null,
1544
+ notionalUsd: notional !== null ? notional.toFixed(2) : null,
1545
+ spread: bestBid && bestAsk ? ((Number(bestAsk) - Number(bestBid)) / Number(bestBid) * 100).toFixed(4) + "%" : null,
1321
1546
  },
1322
1547
  account: {
1323
1548
  equity: balance.equity,
1324
1549
  available: balance.available,
1325
1550
  marginUsed: balance.marginUsed,
1326
- marginAfter: (Number(balance.marginUsed) + notional / (existingPos?.leverage ?? 1)).toFixed(2),
1551
+ marginAfter: notional !== null ? (Number(balance.marginUsed) + notional / previewLeverage).toFixed(2) : null,
1327
1552
  },
1328
1553
  existingPosition: existingPos ? {
1329
1554
  side: existingPos.side,
@@ -1332,66 +1557,238 @@ server.tool("trade_preview", "Preview a trade WITHOUT executing. Returns estimat
1332
1557
  unrealizedPnl: existingPos.unrealizedPnl,
1333
1558
  } : null,
1334
1559
  validation,
1560
+ // Builder fee the live order would carry (settings builder.<venue> + confirmed approval); null = none.
1561
+ builderFee: builderPlan.applied,
1562
+ builderNote: builderPlan.note,
1335
1563
  warning: "This is a PREVIEW only. Call trade_execute to place the order after user confirms.",
1336
1564
  };
1337
1565
  return { content: [{ type: "text", text: ok(preview, { exchange, type: "preview" }) }] };
1338
1566
  }
1339
1567
  catch (e) {
1340
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1568
+ return { content: [{ type: "text", text: errOf(e, { exchange, symbol }) }], isError: true };
1341
1569
  }
1342
1570
  });
1343
- server.tool("trade_execute", "Execute a trade. IMPORTANT: Always call trade_preview first and get explicit user confirmation before calling this tool. Supports market and limit orders.", {
1344
- exchange: z.string().describe("Exchange: pacifica, hyperliquid, lighter, or aster"),
1571
+ /**
1572
+ * Place one order through TradingV2 (native on ondo/arcus, legacy wrapper on the four
1573
+ * pre-existing venues) and return OrderResult + execution (design §13.7). Pre-send
1574
+ * validation failures stay definite; anything thrown after the request may have reached
1575
+ * the venue becomes ORDER_PENDING ("do NOT resubmit", design §12.3).
1576
+ */
1577
+ async function placeViaV2(adapter, req, what, remediation) {
1578
+ const def = getVenue(adapter.name);
1579
+ const caps = requireCapabilities(def);
1580
+ validateOrderRequest(def, caps, req);
1581
+ try {
1582
+ const network = adapterNetworks.get(adapter);
1583
+ if (network === undefined)
1584
+ throw new PerpError("INVALID_PARAMS", `${def.displayName}: adapter network unknown — refusing to place`);
1585
+ return await placeAndConfirm(tradingV2Of(adapter, network), caps, req);
1586
+ }
1587
+ catch (e) {
1588
+ throw isDefiniteOrderFailure(e) ? e : orderOutcomeUnknown(adapter.name, what, e, remediation);
1589
+ }
1590
+ }
1591
+ server.tool("trade_execute", `Execute a REAL trade on the server's network (meta.network; PERP_NETWORK, default mainnet). IMPORTANT: Always call trade_preview first and get explicit user confirmation before calling this tool. Refused with RISK_VIOLATION when the order breaches the local risk limits (perp risk set; there is no override over MCP) or when the symbol is quarantined (only reduce-only orders pass). Every order is written to the execution log. Supports market and limit orders. Returns the uniform OrderResult (status, finality, filledSize, execution: slippage / protection price / TIF sent) with the raw venue response under \`legacy\`, plus top-level filledSize / requestedSize / partial. Outcomes: an unknown outcome (transport failure after send, or a non-final venue ack) → ok:false, code ORDER_PENDING, executed:false, pending:true and a "do NOT resubmit" remediation — the symbol is quarantined for opening orders until a human clears it; a final rejection / cancel with nothing filled → ORDER_REJECTED, executed:false; a cancel after a partial fill → executed:true, partial:true with the real filledSize. ${HL_SCOPE_NOTE}`, {
1592
+ exchange: z.string().describe(`Exchange: ${listVenues().filter(v => v.create !== null).map(v => v.id).join(", ")} (aliases accepted)`),
1345
1593
  symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
1346
1594
  side: z.enum(["buy", "sell"]).describe("Order side"),
1347
1595
  size: z.string().describe("Order size (base currency units)"),
1348
1596
  orderType: z.enum(["market", "limit"]).default("market").describe("Order type"),
1349
1597
  price: z.string().optional().describe("Limit price (required for limit orders)"),
1598
+ tif: z.string().optional().describe("Limit orders: GTC (default) | IOC | FOK | ALO (post-only); venue aliases GTT / GTX / TOB accepted"),
1350
1599
  reduceOnly: z.boolean().default(false).describe("Reduce-only order (close position only)"),
1351
- }, async ({ exchange, symbol, side, size, orderType, price, reduceOnly }) => {
1600
+ slippageBps: z.number().optional().describe("Market orders: slippage bound in bps (default: settings defaultSlippageBps)"),
1601
+ }, async ({ exchange, symbol, side, size: rawSize, orderType, price, tif, reduceOnly, slippageBps }) => {
1352
1602
  try {
1603
+ // One parsed size for the risk gate and the order ("1,000" / "abc" refused before any read or send).
1604
+ const size = parsePositiveDecimal(rawSize, "size");
1353
1605
  const adapter = await getOrCreateAdapter(exchange);
1606
+ const def = getVenue(adapter.name);
1607
+ const caps = requireCapabilities(def);
1608
+ const sym = symbol.toUpperCase();
1609
+ const isLimit = orderType === "limit";
1610
+ const req = mcpOrderRequest(adapter, { symbol: sym, side, size, orderType, price, tif, reduceOnly, slippageBps });
1611
+ // Same pre-send path as `perp trade`: request validation, quarantine (non-reduce-only),
1612
+ // local risk limits (no force over MCP), then client-id + execution log around the send.
1613
+ validateOrderRequest(def, caps, req);
1614
+ await mcpPreTradeGate(adapter, { symbol: sym, size: Number(size), price: isLimit ? Number(price) : null, reduceOnly }, (m) => process.stderr.write(`${m}\n`));
1615
+ const orderType_ = isLimit ? "limit" : "market";
1616
+ const logType = isLimit ? "limit_order" : "market_order";
1617
+ const cid = req.clientOrderId;
1618
+ const nowIso = () => new Date().toISOString();
1619
+ if (cid !== null) {
1620
+ logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, ...(isLimit ? { price: price } : {}), type: orderType_, status: "pending", createdAt: nowIso(), updatedAt: nowIso() });
1621
+ }
1622
+ const logMeta = { reduceOnly, ...(cid !== null ? { clientOrderId: cid } : {}) };
1623
+ const priceLog = isLimit ? { price: price } : {};
1624
+ const errMeta = { exchange, symbol, type: "execution" };
1625
+ const qLog = (m) => process.stderr.write(`${m}\n`);
1626
+ const clearCmd = `${MCP_NETWORK === "testnet" ? "perp -n testnet" : "perp"} strategy quarantine-clear ${sym}`;
1627
+ // An unknown outcome on an opening order quarantines the symbol: the gate then refuses a
1628
+ // resubmit of the same order (only reduce-only orders pass) until a human clears it.
1629
+ const quarantineUnknown = (why) => {
1630
+ if (reduceOnly)
1631
+ return;
1632
+ quarantineSymbol("mcp-execute", sym, `trade_execute ${orderType} ${side} ${size} ${sym} on ${adapter.name}: ${why}`, `Check ${sym} on ${adapter.name} (get_open_orders / get_positions), resolve the order by hand, then: ${clearCmd}`, qLog);
1633
+ };
1354
1634
  let result;
1355
- if (orderType === "limit") {
1356
- if (!price)
1357
- throw new Error("Price is required for limit orders");
1358
- result = await adapter.limitOrder(symbol, side, price, size, { reduceOnly });
1635
+ try {
1636
+ result = await placeViaV2(adapter, req, `${orderType} ${side} ${size} ${sym}`, `Do NOT resubmit — the order may have been placed. Call get_positions / get_open_orders for ${symbol} on ${exchange} first`);
1637
+ }
1638
+ catch (placeErr) {
1639
+ const unknown = isOutcomeUnknown(placeErr);
1640
+ logMcpExecution(MCP_NETWORK, { type: logType, exchange: adapter.name, symbol: sym, side, size, ...priceLog, status: unknown ? "unverified" : "failed", error: extractErrorMessage(placeErr), meta: logMeta });
1641
+ // An unknown outcome stays "pending" in the client-id log (it may have been placed).
1642
+ if (cid !== null && !unknown) {
1643
+ logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, type: orderType_, status: "failed", createdAt: nowIso(), updatedAt: nowIso() });
1644
+ }
1645
+ if (unknown)
1646
+ quarantineUnknown(`outcome unknown (${extractErrorMessage(placeErr)})`);
1647
+ return { content: [{ type: "text", text: errOf(placeErr, errMeta, unknown ? { executed: false, pending: true } : { executed: false }) }], isError: true };
1648
+ }
1649
+ // Settle what the venue reported (mirrors the CLI scale path, src/commands/trade.ts placeScaleLevels):
1650
+ // - not final → outcome unknown: ORDER_PENDING, never "success";
1651
+ // - final rejected / cancelled with nothing filled → ORDER_REJECTED;
1652
+ // - final cancelled with an unreported fill → the fill is unknown: ORDER_PENDING;
1653
+ // - final rejected / cancelled with a fill → executed, reported as the partial fill it is.
1654
+ const filled = result.filledSize === null ? null : Number(result.filledSize);
1655
+ const terminal = result.status === "rejected" || result.status === "cancelled";
1656
+ const fillUnknown = result.finality === "final" && result.status === "cancelled" && filled === null;
1657
+ if (result.finality !== "final" || fillUnknown) {
1658
+ const why = fillUnknown
1659
+ ? `the venue cancelled the order without reporting its fill`
1660
+ : `the venue acknowledged the order without a final status (${result.status})`;
1661
+ logMcpExecution(MCP_NETWORK, { type: logType, exchange: adapter.name, symbol: sym, side, size, ...priceLog, status: "unverified", error: why, meta: logMeta });
1662
+ quarantineUnknown(why);
1663
+ const pe = new PerpError("ORDER_PENDING", `${adapter.name} ${orderType} ${side} ${size} ${sym}: outcome unknown — ${why}`, {
1664
+ exchange: adapter.name,
1665
+ remediation: result.remediation ?? `Do NOT resubmit — the order may have filled. Call get_open_orders / get_positions for ${symbol} on ${exchange} first`,
1666
+ });
1667
+ return { content: [{ type: "text", text: errOf(pe, errMeta, { executed: false, pending: true, result: orderResultJson(result) }) }], isError: true };
1668
+ }
1669
+ if (terminal && (filled === null || filled === 0)) {
1670
+ const why = `the venue ${result.status} the order${result.orderId !== null ? ` ${result.orderId}` : ""}`;
1671
+ logMcpExecution(MCP_NETWORK, { type: logType, exchange: adapter.name, symbol: sym, side, size, ...priceLog, status: "failed", error: why, meta: logMeta });
1672
+ if (cid !== null) {
1673
+ logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, type: orderType_, status: "failed", createdAt: nowIso(), updatedAt: nowIso() });
1674
+ }
1675
+ const pe = new PerpError("ORDER_REJECTED", `${adapter.name} ${orderType} ${side} ${size} ${sym}: ${why}; nothing filled`, { exchange: adapter.name });
1676
+ return { content: [{ type: "text", text: errOf(pe, errMeta, { executed: false, result: orderResultJson(result) }) }], isError: true };
1359
1677
  }
1360
- else {
1361
- result = await adapter.marketOrder(symbol, side, size);
1678
+ const partial = terminal; // a terminal status with a fill > 0 — the rest was cancelled / rejected
1679
+ logMcpExecution(MCP_NETWORK, {
1680
+ type: logType, exchange: adapter.name, symbol: sym, side, size: partial ? result.filledSize : size, ...priceLog, status: "success",
1681
+ meta: { ...logMeta, ...(partial ? { partial: true, requested: size, venueStatus: result.status } : {}) },
1682
+ });
1683
+ if (cid !== null) {
1684
+ logClientId({ clientOrderId: cid, exchange: adapter.name, symbol: sym, side, size, type: orderType_, status: "submitted", createdAt: nowIso(), updatedAt: nowIso() });
1685
+ }
1686
+ // The order is placed: a failed position read below must NOT turn it into an error
1687
+ // (an agent would retry and double-trade). Report currentPosition: null + warning instead.
1688
+ // Exact symbol match; "-PERP" suffix-insensitive; uncached read.
1689
+ let currentPosition;
1690
+ let warning = partial
1691
+ ? `Partially filled: ${result.filledSize} of ${size} ${sym}; the rest was ${result.status} by the venue. Do NOT resubmit the full size`
1692
+ : null;
1693
+ try {
1694
+ invalidateCache("acct");
1695
+ const positions = await adapter.getPositions();
1696
+ currentPosition = positions.find(p => symbolMatch(p.symbol, symbol)) ?? null;
1697
+ }
1698
+ catch (readErr) {
1699
+ currentPosition = null;
1700
+ const readWarning = `Order placed, but the post-order position read failed: ${extractErrorMessage(readErr)}. Do NOT resubmit — call get_positions for ${symbol} on ${exchange}`;
1701
+ warning = warning === null ? readWarning : `${warning}. ${readWarning}`;
1362
1702
  }
1363
- // Fetch updated position
1364
- const positions = await adapter.getPositions();
1365
- const pos = positions.find(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()));
1366
1703
  return {
1367
1704
  content: [{
1368
1705
  type: "text",
1369
1706
  text: ok({
1370
1707
  executed: true,
1371
- order: { exchange, symbol, side, size, orderType, price },
1372
- result,
1373
- currentPosition: pos ?? null,
1708
+ partial,
1709
+ // null = the venue did not report the fill (never "0" as a stand-in).
1710
+ filledSize: result.filledSize,
1711
+ requestedSize: size,
1712
+ order: { exchange, symbol, side, size, orderType, price, tif: req.tif },
1713
+ result: orderResultJson(result),
1714
+ currentPosition,
1715
+ ...(warning !== null ? { warning } : {}),
1374
1716
  }, { exchange, type: "execution" }),
1375
1717
  }],
1376
1718
  };
1377
1719
  }
1378
1720
  catch (e) {
1379
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1721
+ return { content: [{ type: "text", text: errOf(e, { exchange, symbol, type: "execution" }) }], isError: true };
1380
1722
  }
1381
1723
  });
1382
- server.tool("trade_close", "Close an existing position. IMPORTANT: Call trade_preview first with the opposite side to show impact, then get user confirmation.", {
1383
- exchange: z.string().describe("Exchange: pacifica, hyperliquid, lighter, or aster"),
1724
+ server.tool("trade_close", `Close an existing position. IMPORTANT: Call trade_preview first with the opposite side to show impact, then get user confirmation. Verified close: \`closedSize\` is the measured / venue-final fill (one residual market close is attempted); a residual still open → ORDER_PARTIAL error (symbol quarantined). Includes the uniform OrderResult of the reduce-only market order. ${HL_SCOPE_NOTE}`, {
1725
+ exchange: z.string().describe(`Exchange: ${listVenues().filter(v => v.create !== null).map(v => v.id).join(", ")} (aliases accepted)`),
1384
1726
  symbol: z.string().describe("Symbol to close (e.g., BTC, ETH)"),
1385
1727
  }, async ({ exchange, symbol }) => {
1386
1728
  try {
1387
1729
  const adapter = await getOrCreateAdapter(exchange);
1730
+ const def = getVenue(adapter.name);
1731
+ const caps = requireCapabilities(def);
1732
+ // A venue whose reduce-only orders are gated refuses the close before any read.
1733
+ mcpReduceOnlyGate(adapter);
1734
+ // Size the reduce-only close from a fresh read, never a cached position.
1735
+ invalidateCache("acct");
1388
1736
  const positions = await adapter.getPositions();
1389
- const pos = positions.find(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()));
1737
+ const pos = positions.find(p => symbolMatch(p.symbol, symbol));
1390
1738
  if (!pos || Number(pos.size) === 0) {
1391
- return { content: [{ type: "text", text: err(`No open position for ${symbol} on ${exchange}`) }], isError: true };
1739
+ throw new PerpError("POSITION_NOT_FOUND", `No open position for ${symbol} on ${exchange}`, { exchange: adapter.name, remediation: `List open positions with get_positions (exchange: ${exchange})` });
1392
1740
  }
1393
1741
  const closeSide = pos.side === "long" ? "sell" : "buy";
1394
- const result = await adapter.marketOrder(symbol, closeSide, pos.size);
1742
+ const closeSize = pos.size.replace(/^-/, "");
1743
+ const req = {
1744
+ symbol: pos.symbol, side: closeSide, type: "market", size: closeSize,
1745
+ price: null, triggerPrice: null, triggerSource: null, tif: null, reduceOnly: true,
1746
+ clientOrderId: caps.clientOrderId && caps.orderAck === "async" ? newVenueClientOrderId(def) : null,
1747
+ slippage: resolveAdapterSlippage(adapter, null),
1748
+ tpsl: null,
1749
+ };
1750
+ const holder = { placed: null };
1751
+ const nowIso = () => new Date().toISOString();
1752
+ const dec = sizeDecimalsOf(closeSize);
1753
+ const qLog = (m) => process.stderr.write(`${m}\n`);
1754
+ const remediation = `Check ${pos.symbol} on ${adapter.name} (get_positions), close any residual by hand, then: perp strategy quarantine-clear ${pos.symbol}`;
1755
+ let c;
1756
+ try {
1757
+ // Verified close: the first leg is the TradingV2 reduce-only market order; the fill is
1758
+ // measured / venue-final, and one residual market close is attempted (closePerpVerified).
1759
+ c = await closePerpVerified(adapter, adapter.name, pos.symbol, closeSide, closeSize, {
1760
+ decimals: dec,
1761
+ slippage: req.slippage ?? undefined,
1762
+ place: async () => {
1763
+ if (req.clientOrderId !== null)
1764
+ logClientId({ clientOrderId: req.clientOrderId, exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, type: "market", status: "pending", createdAt: nowIso(), updatedAt: nowIso() });
1765
+ holder.placed = await placeViaV2(adapter, req, `reduce-only close ${closeSide} ${closeSize} ${pos.symbol}`, `Do NOT resubmit — the close may have executed. Call get_positions for ${symbol} on ${exchange} first`);
1766
+ return holder.placed.venueRaw;
1767
+ },
1768
+ });
1769
+ }
1770
+ catch (e) {
1771
+ // An unknown outcome keeps the client id "pending"; a definite failure marks it failed.
1772
+ if (req.clientOrderId !== null && !isOutcomeUnknown(e))
1773
+ logClientId({ clientOrderId: req.clientOrderId, exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, type: "market", status: "failed", createdAt: nowIso(), updatedAt: nowIso() });
1774
+ if (isOutcomeUnknown(e))
1775
+ quarantineSymbol("mcp-close", pos.symbol, `trade_close ${pos.symbol} on ${adapter.name}: outcome unknown (${extractErrorMessage(e)})`, remediation, qLog);
1776
+ logMcpExecution(MCP_NETWORK, { type: "market_order", exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, status: isOutcomeUnknown(e) ? "unverified" : "failed", error: extractErrorMessage(e), meta: { reduceOnly: true, action: "close" } });
1777
+ throw e;
1778
+ }
1779
+ const closedSize = fillSizeString(c.closed, dec);
1780
+ const residual = fillSizeString(c.residual, dec);
1781
+ if (req.clientOrderId !== null)
1782
+ logClientId({ clientOrderId: req.clientOrderId, exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closeSize, type: "market", status: "submitted", createdAt: nowIso(), updatedAt: nowIso() });
1783
+ logMcpExecution(MCP_NETWORK, { type: "market_order", exchange: adapter.name, symbol: pos.symbol, side: closeSide, size: closedSize, status: c.residual > 0 ? "failed" : "success", ...(c.residual > 0 ? { error: `residual ${residual} still open` } : {}), meta: { reduceOnly: true, action: "close", requested: closeSize } });
1784
+ const closedPosition = { side: pos.side, size: pos.size, entryPrice: pos.entryPrice, unrealizedPnl: pos.unrealizedPnl };
1785
+ const entryResult = holder.placed !== null ? orderResultJson(holder.placed) : null;
1786
+ if (c.residual > 0) {
1787
+ quarantineSymbol("mcp-close", pos.symbol, `trade_close ${pos.symbol} on ${adapter.name}: closed ${closedSize} of ${closeSize}, ${residual} still open${c.note !== null ? ` (${c.note})` : ""}`, remediation, qLog);
1788
+ throw new PerpError("ORDER_PARTIAL", `${adapter.name} close ${pos.symbol}: closed ${closedSize} of ${closeSize}; ${residual} still open${c.note !== null ? ` (${c.note})` : ""}`, {
1789
+ exchange: adapter.name, closedSize, residual, closedPosition, result: entryResult, remediation,
1790
+ });
1791
+ }
1395
1792
  return {
1396
1793
  content: [{
1397
1794
  type: "text",
@@ -1399,14 +1796,16 @@ server.tool("trade_close", "Close an existing position. IMPORTANT: Call trade_pr
1399
1796
  closed: true,
1400
1797
  symbol,
1401
1798
  exchange,
1402
- closedPosition: { side: pos.side, size: pos.size, entryPrice: pos.entryPrice, unrealizedPnl: pos.unrealizedPnl },
1403
- result,
1799
+ closedSize,
1800
+ residual,
1801
+ closedPosition,
1802
+ result: entryResult,
1404
1803
  }, { exchange, type: "close" }),
1405
1804
  }],
1406
1805
  };
1407
1806
  }
1408
1807
  catch (e) {
1409
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1808
+ return { content: [{ type: "text", text: errOf(e, { exchange, symbol, type: "close" }) }], isError: true };
1410
1809
  }
1411
1810
  });
1412
1811
  // ============================================================
@@ -1418,34 +1817,35 @@ server.tool("get_funding_analysis", "Analyze funding income across exchanges —
1418
1817
  symbol: z.string().optional().describe("Filter by symbol"),
1419
1818
  }, async ({ period, exchange: exFilter, symbol: symFilter }) => {
1420
1819
  try {
1421
- const exchanges = exFilter ? [exFilter] : ["pacifica", "hyperliquid", "lighter", "aster"];
1820
+ const exchanges = exFilter ? [exFilter] : await listConfiguredAccountVenues();
1422
1821
  const periodMatch = period.match(/^(\d+)(d|w|m)$/);
1423
1822
  const sinceMs = periodMatch
1424
1823
  ? Date.now() - parseInt(periodMatch[1]) * ({ d: 86400000, w: 604800000, m: 2592000000 }[periodMatch[2]] ?? 86400000)
1425
1824
  : 0;
1426
1825
  const allFunding = [];
1427
1826
  const posNotionals = new Map();
1428
- await Promise.allSettled(exchanges.map(async (ex) => {
1429
- try {
1430
- const adapter = await getOrCreateAdapter(ex);
1431
- const [payments, positions] = await Promise.all([
1432
- adapter.getFundingPayments(200),
1433
- adapter.getPositions(),
1434
- ]);
1435
- for (const p of payments) {
1436
- if (sinceMs && p.time < sinceMs)
1437
- continue;
1438
- if (symFilter && !p.symbol.toUpperCase().includes(symFilter.toUpperCase()))
1439
- continue;
1440
- allFunding.push({ exchange: ex, symbol: p.symbol, payment: Number(p.payment), time: p.time });
1441
- }
1442
- for (const p of positions) {
1443
- if (Number(p.size) > 0)
1444
- posNotionals.set(`${ex}:${p.symbol}`, Math.abs(Number(p.size) * Number(p.markPrice)));
1445
- }
1827
+ // Per-venue failures are reported in meta.errors (never silently dropped from the totals).
1828
+ const errors = {};
1829
+ const settled = await Promise.allSettled(exchanges.map(async (ex) => {
1830
+ const adapter = await getOrCreateAdapter(ex);
1831
+ const [payments, positions] = await Promise.all([
1832
+ adapter.getFundingPayments(200),
1833
+ adapter.getPositions(),
1834
+ ]);
1835
+ for (const p of payments) {
1836
+ if (sinceMs && p.time < sinceMs)
1837
+ continue;
1838
+ if (symFilter && !p.symbol.toUpperCase().includes(symFilter.toUpperCase()))
1839
+ continue;
1840
+ allFunding.push({ exchange: ex, symbol: p.symbol, payment: Number(p.payment), time: p.time });
1841
+ }
1842
+ for (const p of positions) {
1843
+ if (Number(p.size) > 0)
1844
+ posNotionals.set(`${ex}:${p.symbol}`, Math.abs(Number(p.size) * Number(p.markPrice)));
1446
1845
  }
1447
- catch { /* skip */ }
1448
1846
  }));
1847
+ settled.forEach((r, i) => { if (r.status === "rejected")
1848
+ errors[exchanges[i]] = extractErrorMessage(r.reason); });
1449
1849
  allFunding.sort((a, b) => a.time - b.time);
1450
1850
  // Aggregate by exchange×symbol
1451
1851
  const byExSym = new Map();
@@ -1461,7 +1861,7 @@ server.tool("get_funding_analysis", "Analyze funding income across exchanges —
1461
1861
  const periodDays = sinceMs ? (Date.now() - sinceMs) / 86400000
1462
1862
  : allFunding.length > 1 ? (allFunding[allFunding.length - 1].time - allFunding[0].time) / 86400000 : 1;
1463
1863
  // Match to arb positions
1464
- const arbState = loadArbState();
1864
+ const arbState = loadArbState(MCP_NETWORK);
1465
1865
  const arbPositions = (arbState?.positions ?? []).map(pos => {
1466
1866
  const lf = allFunding.filter(f => f.symbol === pos.symbol && f.exchange === pos.longExchange).reduce((s, f) => s + f.payment, 0);
1467
1867
  const sf = allFunding.filter(f => f.symbol === pos.symbol && f.exchange === pos.shortExchange).reduce((s, f) => s + f.payment, 0);
@@ -1477,10 +1877,10 @@ server.tool("get_funding_analysis", "Analyze funding income across exchanges —
1477
1877
  })),
1478
1878
  arbPositions,
1479
1879
  };
1480
- return { content: [{ type: "text", text: ok(data) }] };
1880
+ return { content: [{ type: "text", text: ok(data, { errors }) }] };
1481
1881
  }
1482
1882
  catch (e) {
1483
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1883
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
1484
1884
  }
1485
1885
  });
1486
1886
  server.tool("get_pnl_analysis", "P&L analysis combining trading fees and funding income — shows net profit/loss by exchange with daily breakdown", {
@@ -1488,43 +1888,44 @@ server.tool("get_pnl_analysis", "P&L analysis combining trading fees and funding
1488
1888
  exchange: z.string().optional().describe("Filter by exchange"),
1489
1889
  }, async ({ period, exchange: exFilter }) => {
1490
1890
  try {
1491
- const exchanges = exFilter ? [exFilter] : ["pacifica", "hyperliquid", "lighter", "aster"];
1891
+ const exchanges = exFilter ? [exFilter] : await listConfiguredAccountVenues();
1492
1892
  const periodMatch = period.match(/^(\d+)(d|w|m)$/);
1493
1893
  const sinceMs = periodMatch
1494
1894
  ? Date.now() - parseInt(periodMatch[1]) * ({ d: 86400000, w: 604800000, m: 2592000000 }[periodMatch[2]] ?? 86400000)
1495
1895
  : 0;
1496
1896
  const byExchange = new Map();
1497
1897
  const dailyMap = new Map();
1498
- await Promise.allSettled(exchanges.map(async (ex) => {
1499
- try {
1500
- const adapter = await getOrCreateAdapter(ex);
1501
- const [trades, funding] = await Promise.all([adapter.getTradeHistory(200), adapter.getFundingPayments(200)]);
1502
- const exData = byExchange.get(ex) ?? { trades: 0, volume: 0, fees: 0, funding: 0 };
1503
- for (const t of trades) {
1504
- if (sinceMs && t.time < sinceMs)
1505
- continue;
1506
- const notional = Number(t.price) * Number(t.size);
1507
- exData.trades++;
1508
- exData.volume += notional;
1509
- exData.fees += Number(t.fee);
1510
- const day = new Date(t.time).toISOString().slice(0, 10);
1511
- const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
1512
- d.fees += Number(t.fee);
1513
- dailyMap.set(day, d);
1514
- }
1515
- for (const f of funding) {
1516
- if (sinceMs && f.time < sinceMs)
1517
- continue;
1518
- exData.funding += Number(f.payment);
1519
- const day = new Date(f.time).toISOString().slice(0, 10);
1520
- const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
1521
- d.funding += Number(f.payment);
1522
- dailyMap.set(day, d);
1523
- }
1524
- byExchange.set(ex, exData);
1898
+ // Per-venue failures are reported in meta.errors (never silently dropped from the totals).
1899
+ const errors = {};
1900
+ const settled = await Promise.allSettled(exchanges.map(async (ex) => {
1901
+ const adapter = await getOrCreateAdapter(ex);
1902
+ const [trades, funding] = await Promise.all([adapter.getTradeHistory(200), adapter.getFundingPayments(200)]);
1903
+ const exData = byExchange.get(ex) ?? { trades: 0, volume: 0, fees: 0, funding: 0 };
1904
+ for (const t of trades) {
1905
+ if (sinceMs && t.time < sinceMs)
1906
+ continue;
1907
+ const notional = Number(t.price) * Number(t.size);
1908
+ exData.trades++;
1909
+ exData.volume += notional;
1910
+ exData.fees += Number(t.fee);
1911
+ const day = new Date(t.time).toISOString().slice(0, 10);
1912
+ const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
1913
+ d.fees += Number(t.fee);
1914
+ dailyMap.set(day, d);
1525
1915
  }
1526
- catch { /* skip */ }
1916
+ for (const f of funding) {
1917
+ if (sinceMs && f.time < sinceMs)
1918
+ continue;
1919
+ exData.funding += Number(f.payment);
1920
+ const day = new Date(f.time).toISOString().slice(0, 10);
1921
+ const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
1922
+ d.funding += Number(f.payment);
1923
+ dailyMap.set(day, d);
1924
+ }
1925
+ byExchange.set(ex, exData);
1527
1926
  }));
1927
+ settled.forEach((r, i) => { if (r.status === "rejected")
1928
+ errors[exchanges[i]] = extractErrorMessage(r.reason); });
1528
1929
  let totalFees = 0, totalFunding = 0, totalVolume = 0, totalTrades = 0;
1529
1930
  for (const d of byExchange.values()) {
1530
1931
  totalFees += d.fees;
@@ -1537,17 +1938,17 @@ server.tool("get_pnl_analysis", "P&L analysis combining trading fees and funding
1537
1938
  byExchange: Object.fromEntries([...byExchange.entries()].map(([ex, d]) => [ex, { ...d, netPnl: d.funding - d.fees }])),
1538
1939
  daily: [...dailyMap.entries()].sort().map(([date, d]) => ({ date, fees: d.fees, funding: d.funding, net: d.funding - d.fees })),
1539
1940
  };
1540
- return { content: [{ type: "text", text: ok(data) }] };
1941
+ return { content: [{ type: "text", text: ok(data, { errors }) }] };
1541
1942
  }
1542
1943
  catch (e) {
1543
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1944
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
1544
1945
  }
1545
1946
  });
1546
1947
  server.tool("get_arb_compare", "Compare active arbitrage positions side by side — ROI, funding income, price PnL, and annualized returns for each position", {
1547
1948
  period: z.string().default("all").describe("Funding lookup period: 7d, 30d, 90d, or all"),
1548
1949
  }, async ({ period }) => {
1549
1950
  try {
1550
- const arbState = loadArbState();
1951
+ const arbState = loadArbState(MCP_NETWORK);
1551
1952
  const positions = arbState?.positions ?? [];
1552
1953
  if (positions.length === 0) {
1553
1954
  return { content: [{ type: "text", text: ok({ positions: [], message: "No active arb positions" }) }] };
@@ -1563,38 +1964,53 @@ server.tool("get_arb_compare", "Compare active arbitrage positions side by side
1563
1964
  }
1564
1965
  const fundingByExSym = new Map();
1565
1966
  const currentPrices = new Map();
1566
- await Promise.allSettled([...exchangeSet].map(async (ex) => {
1567
- try {
1568
- const adapter = await getOrCreateAdapter(ex);
1569
- const [funding, livePos] = await Promise.all([adapter.getFundingPayments(200), adapter.getPositions()]);
1570
- for (const f of funding) {
1571
- if (sinceMs && f.time < sinceMs)
1572
- continue;
1573
- const key = `${ex}:${f.symbol}`;
1574
- fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
1575
- }
1576
- for (const p of livePos)
1577
- currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
1967
+ // A venue whose reads failed has unknown funding / prices: its rows report null, the reason is in meta.errors.
1968
+ const errors = {};
1969
+ const venues = [...exchangeSet];
1970
+ const settled = await Promise.allSettled(venues.map(async (ex) => {
1971
+ const adapter = await getOrCreateAdapter(ex);
1972
+ const [funding, livePos] = await Promise.all([adapter.getFundingPayments(200), adapter.getPositions()]);
1973
+ for (const f of funding) {
1974
+ if (sinceMs && f.time < sinceMs)
1975
+ continue;
1976
+ const key = `${ex}:${f.symbol}`;
1977
+ fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
1578
1978
  }
1579
- catch { /* skip */ }
1979
+ for (const p of livePos)
1980
+ currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
1580
1981
  }));
1581
- const rows = positions.map(pos => {
1982
+ settled.forEach((r, i) => { if (r.status === "rejected")
1983
+ errors[venues[i]] = extractErrorMessage(r.reason); });
1984
+ // Entries without a verified entry price have no price PnL: listed apart, never priced at a guess.
1985
+ const priced = positions.filter((p) => p.entryLongPrice !== null && p.entryShortPrice !== null);
1986
+ const unpricedPositions = positions.filter(p => p.entryLongPrice === null || p.entryShortPrice === null).map(p => p.symbol);
1987
+ // Funding of a venue that answered: the sum of its payments (none = 0). A failed venue: unknown (null).
1988
+ const fundingOf = (ex, sym) => (ex in errors ? null : fundingByExSym.get(`${ex}:${sym}`) ?? 0);
1989
+ // Current mark price from the live position; none (failed read / no live leg) = unknown (null), never the entry price.
1990
+ const priceOf = (ex, sym) => currentPrices.get(`${ex}:${sym}`) ?? null;
1991
+ const rows = priced.map(pos => {
1582
1992
  const daysHeld = Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000));
1583
- const funding = (fundingByExSym.get(`${pos.longExchange}:${pos.symbol}`) ?? 0) + (fundingByExSym.get(`${pos.shortExchange}:${pos.symbol}`) ?? 0);
1584
- const longPrice = currentPrices.get(`${pos.longExchange}:${pos.symbol}`) ?? pos.entryLongPrice;
1585
- const shortPrice = currentPrices.get(`${pos.shortExchange}:${pos.symbol}`) ?? pos.entryShortPrice;
1586
- const pricePnl = (longPrice - pos.entryLongPrice) * pos.longSize + (pos.entryShortPrice - shortPrice) * pos.shortSize;
1587
- const totalPnl = pricePnl + funding;
1993
+ const lf = fundingOf(pos.longExchange, pos.symbol);
1994
+ const sf = fundingOf(pos.shortExchange, pos.symbol);
1995
+ const funding = lf !== null && sf !== null ? lf + sf : null;
1996
+ const longPrice = priceOf(pos.longExchange, pos.symbol);
1997
+ const shortPrice = priceOf(pos.shortExchange, pos.symbol);
1998
+ const pricePnl = longPrice !== null && shortPrice !== null
1999
+ ? (longPrice - pos.entryLongPrice) * pos.longSize + (pos.entryShortPrice - shortPrice) * pos.shortSize
2000
+ : null;
2001
+ const totalPnl = pricePnl !== null && funding !== null ? pricePnl + funding : null;
1588
2002
  const notional = pos.entryLongPrice * pos.longSize + pos.entryShortPrice * pos.shortSize;
1589
- const roi = notional > 0 ? (totalPnl / notional) * 100 : 0;
1590
- const fundingRoi = notional > 0 ? (funding / notional) * 100 : 0;
1591
- return { symbol: pos.symbol, mode: pos.mode ?? "perp-perp", longExchange: pos.longExchange, shortExchange: pos.shortExchange, daysHeld, funding, pricePnl, totalPnl, roi, annualizedRoi: daysHeld > 0 ? (fundingRoi / daysHeld) * 365 : 0 };
1592
- }).sort((a, b) => b.totalPnl - a.totalPnl);
1593
- const totals = { funding: rows.reduce((s, r) => s + r.funding, 0), pricePnl: rows.reduce((s, r) => s + r.pricePnl, 0), totalPnl: rows.reduce((s, r) => s + r.totalPnl, 0) };
1594
- return { content: [{ type: "text", text: ok({ period, positionCount: rows.length, positions: rows, totals }) }] };
2003
+ const roi = totalPnl !== null && notional > 0 ? (totalPnl / notional) * 100 : null;
2004
+ const fundingRoi = funding !== null && notional > 0 ? (funding / notional) * 100 : null;
2005
+ return { symbol: pos.symbol, mode: pos.mode ?? "perp-perp", longExchange: pos.longExchange, shortExchange: pos.shortExchange, daysHeld, funding, pricePnl, totalPnl, roi, annualizedRoi: fundingRoi !== null ? (fundingRoi / daysHeld) * 365 : null };
2006
+ }).sort((a, b) => (a.totalPnl === null ? 1 : b.totalPnl === null ? -1 : b.totalPnl - a.totalPnl));
2007
+ // A total over rows with an unknown value is itself unknown (null).
2008
+ const sumOrNull = (vals) => (vals.some(v => v === null) ? null : vals.reduce((s, v) => s + v, 0));
2009
+ const totals = { funding: sumOrNull(rows.map(r => r.funding)), pricePnl: sumOrNull(rows.map(r => r.pricePnl)), totalPnl: sumOrNull(rows.map(r => r.totalPnl)) };
2010
+ return { content: [{ type: "text", text: ok({ period, positionCount: rows.length, positions: rows, unpricedPositions, totals }, { errors }) }] };
1595
2011
  }
1596
2012
  catch (e) {
1597
- return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
2013
+ return { content: [{ type: "text", text: errOf(e) }], isError: true };
1598
2014
  }
1599
2015
  });
1600
2016
  // ============================================================
@@ -1602,27 +2018,28 @@ server.tool("get_arb_compare", "Compare active arbitrage positions side by side
1602
2018
  // ============================================================
1603
2019
  server.resource("market_prices", "market://prices", { mimeType: "application/json", description: "Live cross-exchange prices for all perpetual futures markets" }, async () => {
1604
2020
  const prices = {};
1605
- await Promise.allSettled(["pacifica", "hyperliquid", "lighter", "aster"].map(async (ex) => {
1606
- try {
1607
- const adapter = await getOrCreateAdapter(ex);
1608
- const markets = await adapter.getMarkets();
1609
- for (const m of markets) {
1610
- if (!prices[m.symbol])
1611
- prices[m.symbol] = {};
1612
- prices[m.symbol][ex] = m.markPrice;
1613
- }
2021
+ const venues = listVenuesWithRole("marketData").map(v => v.id);
2022
+ // Per-venue failures are reported in meta.errors (like get_prices), never silently skipped.
2023
+ const errors = {};
2024
+ const settled = await Promise.allSettled(venues.map(async (ex) => {
2025
+ const markets = await fetchMarketsPublic(ex);
2026
+ for (const m of markets) {
2027
+ if (!prices[m.symbol])
2028
+ prices[m.symbol] = {};
2029
+ prices[m.symbol][ex] = m.markPrice;
1614
2030
  }
1615
- catch { /* skip */ }
1616
2031
  }));
1617
- return { contents: [{ uri: "market://prices", text: JSON.stringify({ ok: true, data: prices, meta: { timestamp: new Date().toISOString() } }, null, 2), mimeType: "application/json" }] };
2032
+ settled.forEach((r, i) => { if (r.status === "rejected")
2033
+ errors[venues[i]] = extractErrorMessage(r.reason); });
2034
+ return { contents: [{ uri: "market://prices", text: JSON.stringify({ ok: true, data: prices, meta: { timestamp: new Date().toISOString(), network: MCP_NETWORK, errors } }, null, 2), mimeType: "application/json" }] };
1618
2035
  });
1619
2036
  server.resource("funding_rates", "market://funding-rates", { mimeType: "application/json", description: "Current funding rates across all exchanges with spread analysis" }, async () => {
1620
2037
  try {
1621
2038
  const rates = await fetchAllFundingRates();
1622
- return { contents: [{ uri: "market://funding-rates", text: JSON.stringify({ ok: true, data: rates, meta: { timestamp: new Date().toISOString() } }, null, 2), mimeType: "application/json" }] };
2039
+ return { contents: [{ uri: "market://funding-rates", text: JSON.stringify({ ok: true, data: rates, meta: { timestamp: new Date().toISOString(), network: MCP_NETWORK } }, null, 2), mimeType: "application/json" }] };
1623
2040
  }
1624
2041
  catch (e) {
1625
- return { contents: [{ uri: "market://funding-rates", text: JSON.stringify({ ok: false, error: String(e) }), mimeType: "application/json" }] };
2042
+ return { contents: [{ uri: "market://funding-rates", text: errOf(e), mimeType: "application/json" }] };
1626
2043
  }
1627
2044
  });
1628
2045
  // ============================================================
@@ -1649,12 +2066,14 @@ server.prompt("trading-guide", "Step-by-step guide for trading with perp-cli via
1649
2066
 
1650
2067
  3. **Preview Before Executing**
1651
2068
  - ALWAYS use \`trade_preview\` before any trade
1652
- - Show the user: estimated price, fees, margin impact
2069
+ - Show the user: estimated price, notional, margin impact, builder fee, and any refusal (valid:false + code / remediation)
2070
+ - Venue trading fees are not estimated by the preview
1653
2071
  - Wait for explicit user confirmation
1654
2072
 
1655
2073
  4. **Execute with Confirmation**
1656
2074
  - Only call \`trade_execute\` after user says "yes" or "confirm"
1657
2075
  - Never execute trades without showing the preview first
2076
+ - ORDER_PENDING (pending:true) means the outcome is unknown: do NOT resubmit — check \`get_open_orders\` / \`get_positions\` (the symbol is quarantined for opening orders until a human clears it)
1658
2077
 
1659
2078
  5. **Monitor After Execution**
1660
2079
  - Use \`get_positions\` to verify the trade went through
@@ -1666,11 +2085,10 @@ server.prompt("trading-guide", "Step-by-step guide for trading with perp-cli via
1666
2085
  - If the user asks to "buy" or "sell", preview first, then ask for confirmation
1667
2086
  - Warn about high-leverage trades or large position sizes
1668
2087
 
1669
- ## Supported Exchanges
1670
- - **pacifica** (Solana) — SOL-based perpetual futures
1671
- - **hyperliquid** (HyperEVM) — Low-fee perpetuals with HIP-3 DEXes
1672
- - **lighter** (Ethereum) — Ethereum-based perpetual futures
1673
- - **aster** (BNB Chain) — BNB Chain perpetual futures`,
2088
+ ## Supported Exchanges (server network: ${MCP_NETWORK})
2089
+ ${listVenues().map(v => `- **${v.id}** (${v.displayName}) — ${v.roles.join(", ")}`).join("\n")}
2090
+
2091
+ ${HL_SCOPE_NOTE}`,
1674
2092
  },
1675
2093
  }],
1676
2094
  }));