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
package/dist/arb/state.js CHANGED
@@ -1,8 +1,28 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "fs";
1
+ import { copyFileSync, constants as fsConstants, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "fs";
2
2
  import { resolve } from "path";
3
+ import { PerpError } from "../errors.js";
4
+ import { withFileLock } from "./file-lock.js";
5
+ /**
6
+ * Arb daemon state is PER NETWORK (review N2): a testnet run must never read, close, or
7
+ * overwrite mainnet positions, and vice versa.
8
+ *
9
+ * mainnet → ~/.perp/arb-state.json (the pre-network path, unchanged)
10
+ * testnet → ~/.perp/arb-state.testnet.json
11
+ *
12
+ * Every saved file carries `network`; a load whose tag does not match the requested
13
+ * network throws. Files written before this change have no tag. They are MAINNET by
14
+ * definition (mainnet was — and is — the documented default network, and the mainnet
15
+ * path is unchanged), so a mainnet load adopts an untagged file and tags it in place
16
+ * (atomic tmp+rename, after a one-time backup copy `arb-state.json.pre-network.bak`).
17
+ * The history does NOT prove an untagged file could only come from mainnet (`-n testnet`
18
+ * routed `arb exec/auto` to testnet adapters since 2026-03 and they wrote the same file),
19
+ * so a TESTNET load refuses while an untagged legacy file exists — the user decides
20
+ * (adopt as mainnet, or move it to the testnet path). Nothing is ever dropped or copied
21
+ * into both networks.
22
+ */
3
23
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
4
24
  const STATE_FILE = resolve(PERP_DIR, "arb-state.json");
5
- // Allow overriding the state file path for testing
25
+ // Allow overriding the (mainnet) state file path for testing; testnet derives from it.
6
26
  let stateFilePath = STATE_FILE;
7
27
  export function setStateFilePath(path) {
8
28
  stateFilePath = path;
@@ -10,13 +30,73 @@ export function setStateFilePath(path) {
10
30
  export function resetStateFilePath() {
11
31
  stateFilePath = STATE_FILE;
12
32
  }
13
- function ensureDir() {
14
- const dir = resolve(stateFilePath, "..");
33
+ /** Absolute state-file path for `network` (mainnet = the pre-network path). */
34
+ export function arbStateFilePath(network) {
35
+ if (network === "mainnet")
36
+ return stateFilePath;
37
+ return stateFilePath.endsWith(".json") ? `${stateFilePath.slice(0, -5)}.testnet.json` : `${stateFilePath}.testnet`;
38
+ }
39
+ function ensureDir(path) {
40
+ const dir = resolve(path, "..");
15
41
  if (!existsSync(dir))
16
42
  mkdirSync(dir, { recursive: true, mode: 0o700 });
17
43
  }
18
- /** Load daemon state from disk. Falls back to .tmp if main file is corrupted. */
19
- export function loadArbState() {
44
+ /** Parse a state file's tag without the .tmp recovery (used for the legacy-file guard). */
45
+ function readTag(path) {
46
+ if (!existsSync(path))
47
+ return null;
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
51
+ }
52
+ catch (e) {
53
+ throw new PerpError("INVALID_PARAMS", `Arb state ${path} is not valid JSON (${e instanceof Error ? e.message : String(e)}) — refusing to guess its network`, {
54
+ remediation: `Inspect or restore ${path} (a .tmp / .pre-network.bak copy may exist next to it)`,
55
+ });
56
+ }
57
+ return { untagged: parsed.network === undefined };
58
+ }
59
+ /** Load the daemon state for `network`. Falls back to .tmp if main file is corrupted. */
60
+ export function loadArbState(network, opts = { readOnly: false }) {
61
+ if (network === "testnet") {
62
+ const legacy = readTag(arbStateFilePath("mainnet"));
63
+ if (legacy?.untagged) {
64
+ const main = arbStateFilePath("mainnet");
65
+ throw new PerpError("INVALID_PARAMS", `Untagged arb state ${main} (written before per-network state) exists — refusing to guess its network on testnet`, {
66
+ remediation: `If it holds MAINNET positions (the default network), adopt it once with any mainnet arb read: perp -n mainnet arb status. `
67
+ + `If it holds TESTNET positions, move it: mv ${main} ${arbStateFilePath("testnet")}`,
68
+ });
69
+ }
70
+ }
71
+ const state = loadArbStateFile(arbStateFilePath(network));
72
+ if (state === null)
73
+ return null;
74
+ if (state.network === undefined) {
75
+ // Only reachable for mainnet (testnet refused above / its path never held untagged data).
76
+ // A read-only (dry-run) load tags the copy in memory and leaves the file untouched.
77
+ if (opts.readOnly)
78
+ state.network = "mainnet";
79
+ else
80
+ adoptLegacyAsMainnet(state);
81
+ return state;
82
+ }
83
+ if (state.network !== network) {
84
+ throw new PerpError("INVALID_PARAMS", `Arb state ${arbStateFilePath(network)} is tagged ${state.network}, not ${network}`, {
85
+ remediation: `The file was moved or edited by hand. Restore it to ${arbStateFilePath(state.network)}`,
86
+ });
87
+ }
88
+ return state;
89
+ }
90
+ /** One-time adoption of a pre-network (untagged) state file as mainnet: backup copy, then atomic in-place tag. */
91
+ function adoptLegacyAsMainnet(state) {
92
+ const path = arbStateFilePath("mainnet");
93
+ const backup = `${path}.pre-network.bak`;
94
+ if (!existsSync(backup))
95
+ copyFileSync(path, backup, fsConstants.COPYFILE_EXCL);
96
+ state.network = "mainnet";
97
+ writeStateAtomic(path, state);
98
+ }
99
+ function loadArbStateFile(stateFilePath) {
20
100
  const tmpPath = stateFilePath + ".tmp";
21
101
  // Try main file first
22
102
  if (existsSync(stateFilePath)) {
@@ -55,47 +135,110 @@ export function loadArbState() {
55
135
  * Writes to .tmp first, then renames to main file.
56
136
  * If crash occurs mid-write, .tmp is partial but main file is intact.
57
137
  */
58
- export function saveArbState(state) {
59
- ensureDir();
60
- const tmpPath = stateFilePath + ".tmp";
138
+ export function saveArbState(network, state) {
139
+ if (state.network !== undefined && state.network !== network) {
140
+ throw new PerpError("INVALID_PARAMS", `Refusing to save ${state.network} arb state as ${network}`, {
141
+ remediation: "This is a bug — the state object was loaded for another network. Re-run the command.",
142
+ });
143
+ }
144
+ // Same network on the -n testnet path: never let a testnet save land while an untagged legacy file is unresolved.
145
+ if (network === "testnet")
146
+ loadArbState("testnet");
147
+ writeStateAtomic(arbStateFilePath(network), { ...state, network });
148
+ }
149
+ function writeStateAtomic(path, state) {
150
+ ensureDir(path);
151
+ const tmpPath = path + ".tmp";
61
152
  const json = JSON.stringify(state, null, 2);
62
153
  writeFileSync(tmpPath, json, { mode: 0o600 });
63
- renameSync(tmpPath, stateFilePath);
64
- }
65
- /** Add a position to the persisted state. Auto-initializes state if missing. */
66
- export function addPosition(pos) {
67
- let state = loadArbState();
68
- if (!state) {
69
- // State was lost — create minimal valid state to avoid losing position tracking
70
- state = createInitialState({ minSpread: 0, closeSpread: 0, size: 0, holdDays: 0, bridgeCost: 0, maxPositions: 10, settleStrategy: "off" });
154
+ renameSync(tmpPath, path);
155
+ }
156
+ /** Identity of a state entry: symbol + long venue + short venue + mode (absent mode = perp-perp). */
157
+ export function positionKey(p) {
158
+ return `${p.symbol.toUpperCase()}|${p.longExchange}|${p.shortExchange}|${p.mode ?? "perp-perp"}`;
159
+ }
160
+ /**
161
+ * Put `pos` into `positions`: an entry with the same key is replaced (a re-record of the same
162
+ * pair); an entry of the same symbol on OTHER venues / another mode is never overwritten —
163
+ * RISK_VIOLATION (the state tracks one pair per symbol; overwriting would drop a live pair).
164
+ */
165
+ export function upsertPosition(positions, pos) {
166
+ const key = positionKey(pos);
167
+ const other = positions.find(p => p.symbol.toUpperCase() === pos.symbol.toUpperCase() && positionKey(p) !== key);
168
+ if (other !== undefined) {
169
+ throw new PerpError("RISK_VIOLATION", `Arb state already tracks ${other.symbol} as ${other.mode ?? "perp-perp"} long ${other.longExchange} / short ${other.shortExchange} — refusing to overwrite it with ${pos.mode ?? "perp-perp"} long ${pos.longExchange} / short ${pos.shortExchange}`, {
170
+ remediation: `Both pairs may be open: check ${pos.symbol} on ${[...new Set([other.longExchange, other.shortExchange, pos.longExchange, pos.shortExchange])].join(", ")} (perp arb status), close one by hand, then record / drop it (perp arb close ${pos.symbol} --state-only)`,
171
+ });
71
172
  }
72
- // Avoid duplicates by symbol
73
- state.positions = state.positions.filter(p => p.symbol !== pos.symbol);
74
- state.positions.push(pos);
75
- saveArbState(state);
76
- }
77
- /** Remove a position by symbol from the persisted state. */
78
- export function removePosition(symbol) {
79
- const state = loadArbState();
80
- if (!state)
81
- return;
82
- state.positions = state.positions.filter(p => p.symbol !== symbol);
83
- saveArbState(state);
173
+ return [...positions.filter(p => positionKey(p) !== key), pos];
174
+ }
175
+ /** Load → modify → save of the state for `network` under the cross-process lock. */
176
+ function withStateLock(network, fn) {
177
+ return withFileLock(arbStateFilePath(network), `Arb state (${network})`, fn);
178
+ }
179
+ /** Add a position to the persisted state for `network`. Auto-initializes state if missing. Throws RISK_VIOLATION for a conflicting entry (upsertPosition). */
180
+ export function addPosition(network, pos) {
181
+ withStateLock(network, () => {
182
+ let state = loadArbState(network);
183
+ if (!state) {
184
+ // State was lost — create minimal valid state to avoid losing position tracking
185
+ state = createInitialState({ minSpread: 0, closeSpread: 0, size: 0, holdDays: 0, bridgeCost: 0, maxPositions: 10, settleStrategy: "off" });
186
+ }
187
+ state.positions = upsertPosition(state.positions, pos);
188
+ saveArbState(network, state);
189
+ });
190
+ }
191
+ /** Remove a position by symbol from the persisted state for `network` (one entry per symbol: upsertPosition). */
192
+ export function removePosition(network, symbol) {
193
+ withStateLock(network, () => {
194
+ const state = loadArbState(network);
195
+ if (!state)
196
+ return;
197
+ state.positions = state.positions.filter(p => p.symbol !== symbol);
198
+ saveArbState(network, state);
199
+ });
200
+ }
201
+ /** Remove exactly the entry with `entry`'s key (symbol + venues + mode); another pair of the symbol is left alone. */
202
+ export function removePositionEntry(network, entry) {
203
+ withStateLock(network, () => {
204
+ const state = loadArbState(network);
205
+ if (!state)
206
+ return;
207
+ const key = positionKey(entry);
208
+ state.positions = state.positions.filter(p => positionKey(p) !== key);
209
+ saveArbState(network, state);
210
+ });
84
211
  }
85
212
  /** Update a position by symbol with partial updates. */
86
- export function updatePosition(symbol, updates) {
87
- const state = loadArbState();
88
- if (!state)
89
- return;
90
- const idx = state.positions.findIndex(p => p.symbol === symbol);
91
- if (idx === -1)
92
- return;
93
- state.positions[idx] = { ...state.positions[idx], ...updates };
94
- saveArbState(state);
95
- }
96
- /** Get all persisted positions. */
97
- export function getPositions() {
98
- const state = loadArbState();
213
+ export function updatePosition(network, symbol, updates) {
214
+ withStateLock(network, () => {
215
+ const state = loadArbState(network);
216
+ if (!state)
217
+ return;
218
+ const idx = state.positions.findIndex(p => p.symbol === symbol);
219
+ if (idx === -1)
220
+ return;
221
+ state.positions[idx] = { ...state.positions[idx], ...updates };
222
+ saveArbState(network, state);
223
+ });
224
+ }
225
+ /** Update exactly the entry with `entry`'s key (symbol + venues + mode). */
226
+ export function updatePositionEntry(network, entry, updates) {
227
+ withStateLock(network, () => {
228
+ const state = loadArbState(network);
229
+ if (!state)
230
+ return;
231
+ const key = positionKey(entry);
232
+ const idx = state.positions.findIndex(p => positionKey(p) === key);
233
+ if (idx === -1)
234
+ return;
235
+ state.positions[idx] = { ...state.positions[idx], ...updates };
236
+ saveArbState(network, state);
237
+ });
238
+ }
239
+ /** Get all persisted positions for `network`. */
240
+ export function getPositions(network) {
241
+ const state = loadArbState(network);
99
242
  if (!state)
100
243
  return [];
101
244
  return state.positions;
@@ -111,3 +254,32 @@ export function createInitialState(config) {
111
254
  config,
112
255
  };
113
256
  }
257
+ /**
258
+ * Live: the per-network state file. Dry-run (review N3): an in-memory copy seeded READ-ONLY
259
+ * from the real state — nothing is ever written to disk, so a simulated entry is never picked
260
+ * up by a live run and a simulated close never drops a real position from tracking.
261
+ */
262
+ export function createDaemonStateStore(network, dryRun, config) {
263
+ if (!dryRun) {
264
+ return {
265
+ load: () => loadArbState(network),
266
+ save: (next) => saveArbState(network, next),
267
+ add: (pos) => addPosition(network, pos),
268
+ remove: (symbol) => removePosition(network, symbol),
269
+ };
270
+ }
271
+ let sim = structuredClone(loadArbState(network, { readOnly: true }));
272
+ return {
273
+ load: () => structuredClone(sim),
274
+ save: (next) => { sim = structuredClone(next); },
275
+ add: (pos) => {
276
+ const next = sim ?? createInitialState(config);
277
+ next.positions = upsertPosition(next.positions, structuredClone(pos));
278
+ sim = next;
279
+ },
280
+ remove: (symbol) => {
281
+ if (sim !== null)
282
+ sim.positions = sim.positions.filter(p => p.symbol !== symbol);
283
+ },
284
+ };
285
+ }
@@ -1,24 +1,38 @@
1
1
  /**
2
2
  * Arb utilities: settlement timing strategy, basis risk monitoring, and webhook notifications.
3
3
  */
4
- /** Settlement schedules per exchange (UTC hours when settlement occurs) */
4
+ /**
5
+ * Settlement schedules per venue (UTC hours when settlement occurs), generated
6
+ * from `VenueDefinition.settlementIntervalHours`, anchored at 00:00 UTC
7
+ * (design §10.1, §10.13 #1). Venues whose interval is per-market (Aster)
8
+ * have no entry — pass the market's interval to getLastSettlement().
9
+ */
5
10
  export declare const SETTLEMENT_SCHEDULES: Record<string, number[]>;
6
11
  export type SettleStrategy = "block" | "aggressive" | "off";
7
12
  /**
8
- * Get the most recent settlement time for an exchange.
9
- * @returns Date of the most recent past settlement
13
+ * Settlement interval in hours for a venue ref. `marketIntervalHours` (the
14
+ * market's `settlementIntervalHours`) takes precedence; it is required for
15
+ * per-market venues. Unknown venues throw UNKNOWN_VENUE.
16
+ */
17
+ export declare function settlementIntervalHours(exchange: string, marketIntervalHours?: number | null): number;
18
+ /**
19
+ * Get the most recent settlement time for an exchange (anchored at 00:00 UTC).
20
+ * @param marketIntervalHours market settlement interval; required for per-market venues
21
+ * @returns Date of the most recent past settlement (== now when exactly on a settlement)
10
22
  */
11
- export declare function getLastSettlement(exchange: string, now?: Date): Date;
23
+ export declare function getLastSettlement(exchange: string, now?: Date, marketIntervalHours?: number | null): Date;
12
24
  /**
13
25
  * Get minutes since the most recent settlement for an exchange.
14
26
  */
15
- export declare function getMinutesSinceSettlement(exchange: string, now?: Date): number;
27
+ export declare function getMinutesSinceSettlement(exchange: string, now?: Date, marketIntervalHours?: number | null): number;
16
28
  /**
17
29
  * Compute a score boost for aggressive settlement mode.
18
30
  * Returns a multiplier > 1.0 if within the post-settlement window, else 1.0.
19
31
  * The boost decays linearly from 1.5x (right after settlement) to 1.0x (at windowMinutes).
32
+ * `marketIntervalFor` returns the market's settlement interval per exchange;
33
+ * it must return a number for per-market venues (Aster).
20
34
  */
21
- export declare function aggressiveSettleBoost(longExchange: string, shortExchange: string, windowMinutes?: number, now?: Date): number;
35
+ export declare function aggressiveSettleBoost(longExchange: string, shortExchange: string, windowMinutes?: number, now?: Date, marketIntervalFor?: (exchange: string) => number | null): number;
22
36
  /**
23
37
  * Estimate cumulative funding between now and the next settlement.
24
38
  *
package/dist/arb/utils.js CHANGED
@@ -1,62 +1,66 @@
1
1
  /**
2
2
  * Arb utilities: settlement timing strategy, basis risk monitoring, and webhook notifications.
3
3
  */
4
+ import { PerpError } from "../errors.js";
5
+ import { listVenues, settlementIntervalOf } from "../exchanges/venue.js";
6
+ import { ensureVenuesRegistered } from "../exchanges/venues/index.js";
4
7
  // ── Settlement Timing Strategy ──
5
- /** Settlement schedules per exchange (UTC hours when settlement occurs) */
6
- export const SETTLEMENT_SCHEDULES = {
7
- hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
8
- pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
9
- lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
10
- aster: [0, 8, 16], // every 8 hours (Binance convention)
11
- };
8
+ ensureVenuesRegistered();
12
9
  /**
13
- * Get the most recent settlement time for an exchange.
14
- * @returns Date of the most recent past settlement
10
+ * Settlement schedules per venue (UTC hours when settlement occurs), generated
11
+ * from `VenueDefinition.settlementIntervalHours`, anchored at 00:00 UTC
12
+ * (design §10.1, §10.13 #1). Venues whose interval is per-market (Aster)
13
+ * have no entry — pass the market's interval to getLastSettlement().
15
14
  */
16
- export function getLastSettlement(exchange, now = new Date()) {
17
- const schedule = SETTLEMENT_SCHEDULES[exchange.toLowerCase()];
18
- if (!schedule || schedule.length === 0) {
19
- return getLastSettlement("pacifica", now);
15
+ export const SETTLEMENT_SCHEDULES = Object.fromEntries(listVenues()
16
+ .filter(v => v.settlementIntervalHours !== "perMarket")
17
+ .map(v => [v.id, scheduleFor(v.settlementIntervalHours)]));
18
+ function scheduleFor(intervalHours) {
19
+ if (!(intervalHours > 0) || 24 % intervalHours !== 0) {
20
+ throw new PerpError("INVALID_PARAMS", `Settlement interval ${intervalHours}h does not divide a UTC day`);
20
21
  }
21
- const currentHour = now.getUTCHours();
22
- const currentMinutes = now.getUTCMinutes();
23
- const currentSeconds = now.getUTCSeconds();
24
- // Find the most recent settlement hour at or before now
25
- for (let i = schedule.length - 1; i >= 0; i--) {
26
- const hour = schedule[i];
27
- if (hour < currentHour || (hour === currentHour && (currentMinutes > 0 || currentSeconds > 0))) {
28
- const last = new Date(now);
29
- last.setUTCHours(hour, 0, 0, 0);
30
- return last;
31
- }
32
- // Exactly at settlement time
33
- if (hour === currentHour && currentMinutes === 0 && currentSeconds === 0) {
34
- const last = new Date(now);
35
- last.setUTCHours(hour, 0, 0, 0);
36
- return last;
22
+ return Array.from({ length: 24 / intervalHours }, (_, i) => i * intervalHours);
23
+ }
24
+ /**
25
+ * Settlement interval in hours for a venue ref. `marketIntervalHours` (the
26
+ * market's `settlementIntervalHours`) takes precedence; it is required for
27
+ * per-market venues. Unknown venues throw UNKNOWN_VENUE.
28
+ */
29
+ export function settlementIntervalHours(exchange, marketIntervalHours = null) {
30
+ if (marketIntervalHours !== null) {
31
+ if (!(marketIntervalHours > 0)) {
32
+ throw new PerpError("INVALID_PARAMS", `Invalid settlement interval ${marketIntervalHours}h for ${exchange}`);
37
33
  }
34
+ return marketIntervalHours;
38
35
  }
39
- // Wrap to previous day's last settlement
40
- const last = new Date(now);
41
- last.setUTCDate(last.getUTCDate() - 1);
42
- last.setUTCHours(schedule[schedule.length - 1], 0, 0, 0);
43
- return last;
36
+ return settlementIntervalOf(exchange);
37
+ }
38
+ /**
39
+ * Get the most recent settlement time for an exchange (anchored at 00:00 UTC).
40
+ * @param marketIntervalHours market settlement interval; required for per-market venues
41
+ * @returns Date of the most recent past settlement (== now when exactly on a settlement)
42
+ */
43
+ export function getLastSettlement(exchange, now = new Date(), marketIntervalHours = null) {
44
+ const intervalMs = settlementIntervalHours(exchange, marketIntervalHours) * 3_600_000;
45
+ return new Date(Math.floor(now.getTime() / intervalMs) * intervalMs);
44
46
  }
45
47
  /**
46
48
  * Get minutes since the most recent settlement for an exchange.
47
49
  */
48
- export function getMinutesSinceSettlement(exchange, now = new Date()) {
49
- const lastSettle = getLastSettlement(exchange, now);
50
+ export function getMinutesSinceSettlement(exchange, now = new Date(), marketIntervalHours = null) {
51
+ const lastSettle = getLastSettlement(exchange, now, marketIntervalHours);
50
52
  return (now.getTime() - lastSettle.getTime()) / (1000 * 60);
51
53
  }
52
54
  /**
53
55
  * Compute a score boost for aggressive settlement mode.
54
56
  * Returns a multiplier > 1.0 if within the post-settlement window, else 1.0.
55
57
  * The boost decays linearly from 1.5x (right after settlement) to 1.0x (at windowMinutes).
58
+ * `marketIntervalFor` returns the market's settlement interval per exchange;
59
+ * it must return a number for per-market venues (Aster).
56
60
  */
57
- export function aggressiveSettleBoost(longExchange, shortExchange, windowMinutes = 10, now = new Date()) {
58
- const longMinutes = getMinutesSinceSettlement(longExchange, now);
59
- const shortMinutes = getMinutesSinceSettlement(shortExchange, now);
61
+ export function aggressiveSettleBoost(longExchange, shortExchange, windowMinutes = 10, now = new Date(), marketIntervalFor = () => null) {
62
+ const longMinutes = getMinutesSinceSettlement(longExchange, now, marketIntervalFor(longExchange));
63
+ const shortMinutes = getMinutesSinceSettlement(shortExchange, now, marketIntervalFor(shortExchange));
60
64
  // Use the minimum of the two — we want BOTH exchanges to have recently settled
61
65
  const minMinutes = Math.min(longMinutes, shortMinutes);
62
66
  if (minMinutes <= windowMinutes) {
@@ -19,14 +19,14 @@ export interface MarketSnapshot {
19
19
  export declare function calculateRSI(closes: number[], period?: number): number;
20
20
  export declare function getMarketSnapshot(adapter: ExchangeAdapter, symbol: string): Promise<MarketSnapshot>;
21
21
  export declare function evaluateCondition(condition: Condition, snapshot: MarketSnapshot, context: {
22
- equity: number;
22
+ equity: number | null;
23
23
  startTime: number;
24
- peakEquity: number;
24
+ peakEquity: number | null;
25
25
  dailyPnl: number;
26
26
  }): boolean;
27
27
  export declare function evaluateAllConditions(conditions: Condition[], snapshot: MarketSnapshot, context: {
28
- equity: number;
28
+ equity: number | null;
29
29
  startTime: number;
30
- peakEquity: number;
30
+ peakEquity: number | null;
31
31
  dailyPnl: number;
32
32
  }, mode?: "all" | "any"): boolean;
@@ -1,3 +1,4 @@
1
+ import { symbolMatch } from "../utils.js";
1
2
  // ── RSI Calculation (Wilder smoothing) ──
2
3
  /**
3
4
  * Calculate RSI from an array of closing prices using Wilder's smoothing method.
@@ -39,13 +40,16 @@ export function calculateRSI(closes, period = 14) {
39
40
  }
40
41
  export async function getMarketSnapshot(adapter, symbol) {
41
42
  const markets = await adapter.getMarkets();
42
- const m = markets.find(mk => mk.symbol.toUpperCase() === symbol.toUpperCase());
43
+ const m = markets.find(mk => symbolMatch(mk.symbol, symbol));
43
44
  if (!m) {
44
45
  throw new Error(`Market "${symbol}" not found on ${adapter.name}`);
45
46
  }
46
47
  const price = parseFloat(m.markPrice);
47
- const fundingRate = parseFloat(m.fundingRate ?? "0");
48
- const volume24h = parseFloat(m.volume24h);
48
+ // Unknown funding (venue publishes none, e.g. QFEX) → NaN, never 0: a 0 would satisfy
49
+ // "funding below X" conditions on a rate nobody reported. NaN makes every comparison false.
50
+ const fundingRate = m.fundingRate === null ? NaN : parseFloat(m.fundingRate);
51
+ // Unknown volume → NaN, never 0: a 0 would read as "no liquidity" to volume conditions.
52
+ const volume24h = m.volume24h === null ? NaN : parseFloat(m.volume24h);
49
53
  // Try to get kline data for 24h high/low + RSI
50
54
  let high24h = price;
51
55
  let low24h = price;
@@ -113,10 +117,11 @@ export function evaluateCondition(condition, snapshot, context) {
113
117
  return !isNaN(snapshot.rsi) && snapshot.rsi > val;
114
118
  case "rsi_below":
115
119
  return !isNaN(snapshot.rsi) && snapshot.rsi < val;
120
+ // Unknown equity (null) never meets an equity condition — neither a fake stop nor a fake entry.
116
121
  case "balance_above":
117
- return context.equity > val;
122
+ return context.equity !== null && context.equity > val;
118
123
  case "balance_below":
119
- return context.equity < val;
124
+ return context.equity !== null && context.equity < val;
120
125
  case "spread_above":
121
126
  // Bid-ask spread percentage, pre-computed from orderbook in snapshot.
122
127
  return snapshot.spreadPct > val;
@@ -125,7 +130,7 @@ export function evaluateCondition(condition, snapshot, context) {
125
130
  return elapsed > val;
126
131
  }
127
132
  case "max_drawdown":
128
- return (context.peakEquity - context.equity) > val;
133
+ return context.equity !== null && context.peakEquity !== null && (context.peakEquity - context.equity) > val;
129
134
  default:
130
135
  return false;
131
136
  }
@@ -1,3 +1,4 @@
1
+ import { type VenueId } from "../exchanges/venue.js";
1
2
  export type ConditionType = "price_above" | "price_below" | "volatility_above" | "volatility_below" | "funding_rate_above" | "funding_rate_below" | "spread_above" | "rsi_above" | "rsi_below" | "balance_above" | "balance_below" | "time_after" | "max_drawdown" | "always";
2
3
  export interface Condition {
3
4
  type: ConditionType;
@@ -59,7 +60,22 @@ export interface BotConfig {
59
60
  risk: RiskConfig;
60
61
  monitor_interval_sec: number;
61
62
  }
62
- export declare function loadBotConfig(pathOrInline: string): BotConfig;
63
+ /**
64
+ * The venue selected on the command line: the root `-e` value (resolved id) and whether it was
65
+ * given explicitly (commander option source "cli") or is the CLI default.
66
+ */
67
+ export interface CliVenueSelection {
68
+ id: VenueId;
69
+ explicit: boolean;
70
+ }
71
+ /**
72
+ * The venue a config runs on. `config.exchange` (alias resolved; unknown → UNKNOWN_VENUE) wins
73
+ * over the CLI default; an explicit root `-e` naming a different venue is a contradiction →
74
+ * INVALID_PARAMS (never one of them silently). No `exchange` in the config → the CLI selection.
75
+ */
76
+ export declare function resolveConfigVenue(configExchange: string | null, cli: CliVenueSelection): VenueId;
77
+ /** Load a bot config; its venue is resolved against the CLI selection (resolveConfigVenue). */
78
+ export declare function loadBotConfig(pathOrInline: string, cli: CliVenueSelection): BotConfig;
63
79
  export declare function parseStrategy(type: string, raw: Record<string, unknown>): StrategyParams;
64
80
  export declare function quickGridConfig(opts: {
65
81
  exchange: string;
@@ -1,5 +1,7 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { parse as parseYaml } from "yaml";
3
+ import { PerpError, extractErrorMessage } from "../errors.js";
4
+ import { resolveVenueId } from "../exchanges/venue.js";
3
5
  // ── Defaults ──
4
6
  const DEFAULT_RISK = {
5
7
  max_position_usd: 1000,
@@ -8,27 +10,61 @@ const DEFAULT_RISK = {
8
10
  pause_after_loss_sec: 300,
9
11
  max_open_bots: 5,
10
12
  };
11
- // ── Loader ──
12
- export function loadBotConfig(pathOrInline) {
13
+ /**
14
+ * The venue a config runs on. `config.exchange` (alias resolved; unknown → UNKNOWN_VENUE) wins
15
+ * over the CLI default; an explicit root `-e` naming a different venue is a contradiction →
16
+ * INVALID_PARAMS (never one of them silently). No `exchange` in the config → the CLI selection.
17
+ */
18
+ export function resolveConfigVenue(configExchange, cli) {
19
+ if (configExchange === null)
20
+ return cli.id;
21
+ const id = resolveVenueId(configExchange);
22
+ if (cli.explicit && cli.id !== id) {
23
+ throw new PerpError("INVALID_PARAMS", `The config runs on "${configExchange}" (${id}) but -e selects ${cli.id}`, {
24
+ remediation: `Drop -e, or pass -e ${id}, or change "exchange" in the config`,
25
+ });
26
+ }
27
+ return id;
28
+ }
29
+ /** Load a bot config; its venue is resolved against the CLI selection (resolveConfigVenue). */
30
+ export function loadBotConfig(pathOrInline, cli) {
13
31
  let raw;
32
+ const remediation = "Pass a readable YAML/JSON config file path (or inline JSON). See examples with: perp strategy example";
14
33
  if (existsSync(pathOrInline)) {
15
- const content = readFileSync(pathOrInline, "utf-8");
16
- if (pathOrInline.endsWith(".json")) {
17
- raw = JSON.parse(content);
34
+ let content;
35
+ try {
36
+ content = readFileSync(pathOrInline, "utf-8");
37
+ }
38
+ catch (err) {
39
+ throw new PerpError("INVALID_PARAMS", `Cannot read bot config file '${pathOrInline}': ${extractErrorMessage(err)}`, { remediation });
40
+ }
41
+ try {
42
+ raw = pathOrInline.endsWith(".json") ? JSON.parse(content) : parseYaml(content);
18
43
  }
19
- else {
20
- raw = parseYaml(content);
44
+ catch (err) {
45
+ throw new PerpError("INVALID_PARAMS", `Bot config file '${pathOrInline}' is not valid ${pathOrInline.endsWith(".json") ? "JSON" : "YAML"}: ${extractErrorMessage(err)}`, { remediation });
46
+ }
47
+ }
48
+ else if (pathOrInline.trimStart().startsWith("{")) {
49
+ // Inline JSON config
50
+ try {
51
+ raw = JSON.parse(pathOrInline);
52
+ }
53
+ catch (err) {
54
+ throw new PerpError("INVALID_PARAMS", `Inline bot config is not valid JSON: ${extractErrorMessage(err)}`, { remediation });
21
55
  }
22
56
  }
23
57
  else {
24
- // Try parsing as inline JSON
25
- raw = JSON.parse(pathOrInline);
58
+ throw new PerpError("INVALID_PARAMS", `Bot config file not found: '${pathOrInline}'`, { remediation });
26
59
  }
27
- return parseBotConfig(raw);
60
+ return parseBotConfig(raw, cli);
28
61
  }
29
- function parseBotConfig(raw) {
62
+ function parseBotConfig(raw, cli) {
30
63
  const name = String(raw.name ?? `bot-${Date.now()}`);
31
- const exchange = String(raw.exchange ?? "hyperliquid");
64
+ if (raw.exchange !== undefined && (typeof raw.exchange !== "string" || raw.exchange.trim() === "")) {
65
+ throw new PerpError("INVALID_PARAMS", `Bot config "exchange" must be a venue name (got ${JSON.stringify(raw.exchange)})`);
66
+ }
67
+ const exchange = resolveConfigVenue(typeof raw.exchange === "string" ? raw.exchange : null, cli);
32
68
  const symbol = String(raw.symbol ?? "ETH");
33
69
  const monitorInterval = Number(raw.monitor_interval_sec ?? raw.interval ?? 30);
34
70
  // Strategy