perp-cli 0.12.17 → 0.14.1

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 (363) hide show
  1. package/README.md +247 -27
  2. package/dist/_stderr-filter.js +0 -1
  3. package/dist/agent-wallet/lighter-keystore.d.ts +11 -0
  4. package/dist/agent-wallet/lighter-keystore.js +92 -10
  5. package/dist/agent-wallet/passphrase.d.ts +1 -1
  6. package/dist/agent-wallet/passphrase.js +16 -3
  7. package/dist/agent-wallet/store.d.ts +37 -4
  8. package/dist/agent-wallet/store.js +132 -0
  9. package/dist/agent-wallet/venue-keystore.d.ts +14 -0
  10. package/dist/agent-wallet/venue-keystore.js +121 -0
  11. package/dist/api/public/hyperliquid.js +4 -4
  12. package/dist/api/public/lighter.js +5 -5
  13. package/dist/api/public/pacifica.d.ts +0 -1
  14. package/dist/api/public/pacifica.js +4 -13
  15. package/dist/api/public/urls.d.ts +9 -1
  16. package/dist/api/public/urls.js +35 -13
  17. package/dist/arb/file-lock.d.ts +11 -0
  18. package/dist/arb/file-lock.js +74 -0
  19. package/dist/arb/index.d.ts +2 -2
  20. package/dist/arb/index.js +2 -2
  21. package/dist/arb/pending-entry.d.ts +32 -0
  22. package/dist/arb/pending-entry.js +77 -0
  23. package/dist/arb/sizing.d.ts +6 -13
  24. package/dist/arb/sizing.js +12 -51
  25. package/dist/arb/state.d.ts +45 -12
  26. package/dist/arb/state.js +214 -42
  27. package/dist/arb/utils.d.ts +20 -6
  28. package/dist/arb/utils.js +43 -39
  29. package/dist/bot/conditions.d.ts +4 -4
  30. package/dist/bot/conditions.js +11 -6
  31. package/dist/bot/config.d.ts +17 -1
  32. package/dist/bot/config.js +48 -12
  33. package/dist/bot/engine.d.ts +61 -2
  34. package/dist/bot/engine.js +610 -144
  35. package/dist/bot/fill-size.d.ts +7 -0
  36. package/dist/bot/fill-size.js +18 -0
  37. package/dist/bot/index.d.ts +1 -1
  38. package/dist/bot/index.js +1 -1
  39. package/dist/bot/perp-order.d.ts +99 -0
  40. package/dist/bot/perp-order.js +299 -0
  41. package/dist/bot/presets.d.ts +7 -0
  42. package/dist/bot/presets.js +17 -0
  43. package/dist/bot/quarantine.d.ts +75 -0
  44. package/dist/bot/quarantine.js +190 -0
  45. package/dist/bot/quote-rules.d.ts +23 -0
  46. package/dist/bot/quote-rules.js +69 -0
  47. package/dist/bot/strategies/avellaneda-mm.d.ts +32 -2
  48. package/dist/bot/strategies/avellaneda-mm.js +72 -70
  49. package/dist/bot/strategies/basis-arb.js +2 -0
  50. package/dist/bot/strategies/dca-strategy.d.ts +2 -0
  51. package/dist/bot/strategies/dca-strategy.js +5 -0
  52. package/dist/bot/strategies/funding-arb-strategy.d.ts +43 -3
  53. package/dist/bot/strategies/funding-arb-strategy.js +571 -380
  54. package/dist/bot/strategies/funding-arb-utils.d.ts +48 -23
  55. package/dist/bot/strategies/funding-arb-utils.js +159 -195
  56. package/dist/bot/strategies/funding-arb-v2.d.ts +32 -0
  57. package/dist/bot/strategies/funding-arb-v2.js +258 -47
  58. package/dist/bot/strategies/funding-auto.js +215 -194
  59. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +14 -0
  60. package/dist/bot/strategies/spot-perp-arb-strategy.js +217 -50
  61. package/dist/bot/strategies/spot-perp-executor.d.ts +73 -19
  62. package/dist/bot/strategies/spot-perp-executor.js +319 -120
  63. package/dist/bot/strategies/spot-perp-state.d.ts +43 -0
  64. package/dist/bot/strategies/spot-perp-state.js +70 -0
  65. package/dist/bot/strategy-types.d.ts +6 -0
  66. package/dist/bot/tui/BotDashboard.d.ts +7 -2
  67. package/dist/bot/tui/BotDashboard.js +6 -3
  68. package/dist/bridge-engine.d.ts +120 -19
  69. package/dist/bridge-engine.js +710 -422
  70. package/dist/bridge-signers.d.ts +54 -0
  71. package/dist/bridge-signers.js +161 -0
  72. package/dist/cache.d.ts +4 -1
  73. package/dist/cache.js +11 -0
  74. package/dist/cli-flags.d.ts +50 -0
  75. package/dist/cli-flags.js +104 -0
  76. package/dist/cli-program.d.ts +35 -0
  77. package/dist/cli-program.js +303 -0
  78. package/dist/cli-spec.js +1 -1
  79. package/dist/client-id-tracker.js +11 -2
  80. package/dist/commands/account.d.ts +7 -1
  81. package/dist/commands/account.js +70 -92
  82. package/dist/commands/agent-import.d.ts +114 -0
  83. package/dist/commands/agent-import.js +794 -0
  84. package/dist/commands/agent.d.ts +232 -2
  85. package/dist/commands/agent.js +1858 -955
  86. package/dist/commands/alerts.d.ts +1 -0
  87. package/dist/commands/alerts.js +31 -28
  88. package/dist/commands/arb/index.d.ts +60 -1
  89. package/dist/commands/arb/index.js +381 -384
  90. package/dist/commands/arb-auto.d.ts +132 -4
  91. package/dist/commands/arb-auto.js +1474 -967
  92. package/dist/commands/arb.d.ts +3 -1
  93. package/dist/commands/arb.js +62 -30
  94. package/dist/commands/bot.js +127 -47
  95. package/dist/commands/bridge.d.ts +2 -1
  96. package/dist/commands/bridge.js +339 -192
  97. package/dist/commands/builder.d.ts +55 -0
  98. package/dist/commands/builder.js +387 -0
  99. package/dist/commands/funds-onchain.d.ts +32 -0
  100. package/dist/commands/funds-onchain.js +248 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +925 -245
  103. package/dist/commands/history.js +102 -54
  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 +357 -459
  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 +240 -6
  166. package/dist/exchanges/aster.js +1139 -195
  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 +152 -0
  200. package/dist/exchanges/ondo/adapter.js +811 -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 +95 -0
  206. package/dist/exchanges/ondo/funds.js +198 -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 +293 -4
  218. package/dist/exchanges/pacifica.js +1044 -90
  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 +1386 -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 +29 -0
  270. package/dist/exchanges/venues/ondo.js +136 -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 +39 -0
  274. package/dist/exchanges/venues/qfex.js +140 -0
  275. package/dist/execution-log.js +10 -2
  276. package/dist/funding/legacy-public.d.ts +31 -0
  277. package/dist/funding/legacy-public.js +369 -0
  278. package/dist/funding/normalize.d.ts +28 -16
  279. package/dist/funding/normalize.js +61 -29
  280. package/dist/funding/rates.d.ts +46 -11
  281. package/dist/funding/rates.js +228 -277
  282. package/dist/guardrail/perp-guardrail.js +0 -0
  283. package/dist/index.js +156 -684
  284. package/dist/jobs.d.ts +84 -6
  285. package/dist/jobs.js +239 -41
  286. package/dist/landing.d.ts +7 -2
  287. package/dist/landing.js +19 -3
  288. package/dist/mcp-server.d.ts +14 -4
  289. package/dist/mcp-server.js +821 -403
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/onchain-balances.d.ts +40 -0
  293. package/dist/onchain-balances.js +173 -0
  294. package/dist/pacifica/client.d.ts +41 -10
  295. package/dist/pacifica/client.js +167 -63
  296. package/dist/pacifica/constants.d.ts +0 -1
  297. package/dist/pacifica/constants.js +0 -5
  298. package/dist/pacifica/deposit.d.ts +30 -3
  299. package/dist/pacifica/deposit.js +119 -2
  300. package/dist/pacifica/faucet.d.ts +29 -0
  301. package/dist/pacifica/faucet.js +100 -0
  302. package/dist/pacifica/sign-tx.d.ts +15 -0
  303. package/dist/pacifica/sign-tx.js +41 -0
  304. package/dist/pacifica/types/account.d.ts +28 -4
  305. package/dist/pacifica/types/lake.d.ts +20 -5
  306. package/dist/pacifica/types/lake.js +4 -0
  307. package/dist/pacifica/types/market.d.ts +5 -0
  308. package/dist/pacifica/types/order.d.ts +4 -1
  309. package/dist/pacifica/types/position.d.ts +9 -1
  310. package/dist/plan-executor.d.ts +10 -5
  311. package/dist/plan-executor.js +165 -54
  312. package/dist/position-history.js +13 -0
  313. package/dist/rebalance.d.ts +26 -2
  314. package/dist/rebalance.js +86 -7
  315. package/dist/relay-swap.d.ts +232 -0
  316. package/dist/relay-swap.js +630 -0
  317. package/dist/risk.js +5 -1
  318. package/dist/secret-env.d.ts +15 -0
  319. package/dist/secret-env.js +36 -0
  320. package/dist/settings.d.ts +169 -6
  321. package/dist/settings.js +219 -38
  322. package/dist/signer/ows-evm.d.ts +13 -0
  323. package/dist/signer/ows-evm.js +22 -0
  324. package/dist/smart-order.d.ts +9 -0
  325. package/dist/smart-order.js +53 -8
  326. package/dist/strategies/dca.d.ts +6 -2
  327. package/dist/strategies/dca.js +66 -26
  328. package/dist/strategies/funding-arb.d.ts +11 -1
  329. package/dist/strategies/funding-arb.js +202 -89
  330. package/dist/strategies/grid.d.ts +31 -0
  331. package/dist/strategies/grid.js +89 -11
  332. package/dist/strategies/pending-stop.d.ts +8 -0
  333. package/dist/strategies/pending-stop.js +12 -0
  334. package/dist/strategies/split-order.d.ts +45 -17
  335. package/dist/strategies/split-order.js +157 -91
  336. package/dist/strategies/trailing-stop.d.ts +2 -1
  337. package/dist/strategies/trailing-stop.js +52 -6
  338. package/dist/strategies/twap.d.ts +6 -1
  339. package/dist/strategies/twap.js +39 -11
  340. package/dist/trade-validator.d.ts +22 -0
  341. package/dist/trade-validator.js +119 -15
  342. package/dist/utils/numeric.d.ts +46 -0
  343. package/dist/utils/numeric.js +79 -0
  344. package/dist/utils.d.ts +19 -0
  345. package/dist/utils.js +96 -19
  346. package/dist/withdraw-gate.d.ts +49 -0
  347. package/dist/withdraw-gate.js +131 -0
  348. package/package.json +15 -6
  349. package/skills/perp-cli/SKILL.md +159 -55
  350. package/skills/perp-cli/references/agent-operations.md +112 -58
  351. package/skills/perp-cli/references/commands.md +269 -73
  352. package/skills/perp-cli/references/strategies.md +34 -31
  353. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  354. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  355. package/skills/perp-cli/scripts/preflight.sh +21 -14
  356. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  357. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  358. package/vendor/lighter-signer/SHA256SUMS +2 -0
  359. package/vendor/lighter-signer/VERSION +8 -0
  360. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  361. package/vendor/lighter-signer/wasm_exec.js +575 -0
  362. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  363. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,10 +1,15 @@
1
- import { getMarketSnapshot, evaluateAllConditions } from "./conditions.js";
1
+ import { assertCanClosePositions } from "../exchanges/trading-v2.js";
2
+ import { resolveAdapterSlippage } from "../exchanges/slippage.js";
3
+ import { filledOf } from "./fill-size.js";
4
+ import { PerpError } from "../errors.js";
5
+ import { getMarketSnapshot, evaluateAllConditions, evaluateCondition } from "./conditions.js";
2
6
  import { updateJobState } from "../jobs.js";
3
7
  import { getStrategy } from "./strategy-registry.js";
4
8
  import chalk from "chalk";
5
9
  import { appendFileSync, mkdirSync } from "node:fs";
6
10
  import { homedir } from "node:os";
7
11
  import { join } from "node:path";
12
+ import { prepareLimitQuote, prepareMarketSize, quoteRulesOf, alignComputedLimitPrice } from "./quote-rules.js";
8
13
  // Auto-register built-in strategies
9
14
  import "./strategies/grid-strategy.js";
10
15
  import "./strategies/dca-strategy.js";
@@ -48,23 +53,82 @@ class TuiEmitter {
48
53
  l(entry);
49
54
  }
50
55
  }
51
- export async function runBot(adapter, config, jobId, log = defaultLog, extraAdapters, // for funding-arb (multi-exchange)
52
- mode = "headless") {
56
+ /**
57
+ * Strategies that place/close orders themselves (multi-leg executors, spot legs) instead of
58
+ * returning StrategyActions — the engine cannot simulate them, so --dry-run is refused.
59
+ */
60
+ export const NON_SIMULATABLE_STRATEGIES = new Set([
61
+ "funding-arb", "funding-arb-v2", "funding-auto", "spot-perp-arb",
62
+ ]);
63
+ /**
64
+ * ExchangeAdapter methods a --dry-run bot may call: pure reads (plus resolveSymbol, a local
65
+ * lookup). Allowlist, not blocklist (review L4): any other method — every venue write, and any
66
+ * write added to an adapter later — throws, so a dry-run can never reach the venue's write path.
67
+ */
68
+ export const ADAPTER_READ_METHODS = new Set([
69
+ "getMarkets", "getOrderbook", "getRecentTrades", "getFundingHistory", "getKlines",
70
+ "getBalance", "getPositions", "getOpenOrders", "getOrderHistory", "getTradeHistory", "getFundingPayments",
71
+ "getFundingHours", "resolveSymbol",
72
+ ]);
73
+ /** Throw INVALID_PARAMS if `strategyType` cannot run under --dry-run (checked before any adapter call). */
74
+ export function assertStrategySimulatable(strategyType) {
75
+ if (!NON_SIMULATABLE_STRATEGIES.has(strategyType.toLowerCase()))
76
+ return;
77
+ throw new PerpError("INVALID_PARAMS", `Strategy "${strategyType}" executes orders directly and cannot simulate under --dry-run`, {
78
+ remediation: "Run it without --dry-run on a small size, or backtest it: perp backtest --help",
79
+ });
80
+ }
81
+ /** Wrap an adapter so only allowlisted reads run; every other method throws (defense in depth for --dry-run bots). */
82
+ export function dryRunAdapter(adapter) {
83
+ return new Proxy(adapter, {
84
+ get(target, prop, receiver) {
85
+ const v = Reflect.get(target, prop, receiver);
86
+ if (typeof v !== "function")
87
+ return v;
88
+ if (typeof prop === "string" && ADAPTER_READ_METHODS.has(prop)) {
89
+ return v.bind(target);
90
+ }
91
+ return () => {
92
+ throw new PerpError("INVALID_PARAMS", `--dry-run bot attempted ${String(prop)}() on ${target.name} — only read methods are allowed under --dry-run; blocked`, {
93
+ remediation: "This strategy cannot simulate; run it without --dry-run",
94
+ });
95
+ };
96
+ },
97
+ });
98
+ }
99
+ export async function runBot(adapter, config, jobId, log, extraAdapters, // for funding-arb (multi-exchange)
100
+ mode,
101
+ /** Global --dry-run: strategy actions are logged, never sent; non-simulatable strategies are refused. */
102
+ isDryRun) {
103
+ if (isDryRun) {
104
+ assertStrategySimulatable(config.strategy.type);
105
+ adapter = dryRunAdapter(adapter);
106
+ if (extraAdapters)
107
+ extraAdapters = new Map([...extraAdapters].map(([k, a]) => [k, dryRunAdapter(a)]));
108
+ }
109
+ else {
110
+ // A bot must be able to close what it opens: refuse before the first order.
111
+ assertCanClosePositions(adapter, `Bot "${config.strategy.type}" on ${adapter.name}`);
112
+ for (const a of extraAdapters?.values() ?? [])
113
+ assertCanClosePositions(a, `Bot "${config.strategy.type}" on ${a.name}`);
114
+ }
53
115
  const state = {
54
116
  phase: "monitoring",
55
117
  startTime: Date.now(),
56
- equity: 0,
57
- peakEquity: 0,
118
+ equity: null,
119
+ peakEquity: null,
58
120
  dailyPnl: 0,
59
121
  dailyStartEquity: 0,
60
122
  dailyStartDate: new Date().toISOString().slice(0, 10),
61
123
  fills: 0,
124
+ ordersPlaced: 0,
62
125
  totalPnl: 0,
63
126
  initialEquity: 0,
64
127
  rebalanceCount: 0,
65
128
  lastRebalance: 0,
66
129
  strategyActive: false,
67
130
  riskBreached: false,
131
+ runtimeEnded: false,
68
132
  };
69
133
  // Resolve strategy from registry
70
134
  const strategyFactory = getStrategy(config.strategy.type);
@@ -84,7 +148,18 @@ mode = "headless") {
84
148
  strategyCtx.state.set("extraAdapters", extraAdapters);
85
149
  }
86
150
  strategyCtx.state.set("riskConfig", config.risk);
87
- const isDryRun = false; // TODO: wire up from config when dry-run mode is added
151
+ // The market's tick / lot / minimums, read once per run (a failed read is retried on the next order).
152
+ let rules = null;
153
+ const rulesOf = async () => {
154
+ if (rules === null)
155
+ rules = await quoteRulesOf(adapter, config.symbol);
156
+ return rules;
157
+ };
158
+ // Failure surfacing: a run whose strategy never started / never completed a tick fails with its last error.
159
+ let lastError = null;
160
+ let strategyTicksOk = 0;
161
+ /** Consecutive failed strategy attempts (start / tick / loop), reset by a successful one. */
162
+ let failureStreak = 0;
88
163
  let running = true;
89
164
  // ── TUI / JSON mode setup ──
90
165
  const tuiEmitter = new TuiEmitter();
@@ -162,17 +237,91 @@ mode = "headless") {
162
237
  log(` Exit: ${config.exit_conditions.map(c => c.type).join(" | ") || "manual"}`);
163
238
  log("");
164
239
  }
165
- // Get initial equity
240
+ // Unknown equity is logged once per reason (and once when it is known again), not every tick;
241
+ // JSON mode also writes it to stderr (stdout carries only the JSON lines).
242
+ let equityError = null;
243
+ const equityUnknown = (e, what) => {
244
+ const reason = e instanceof Error ? e.message : String(e);
245
+ state.equity = null;
246
+ if (reason === equityError)
247
+ return;
248
+ equityError = reason;
249
+ const msg = chalk.yellow(` WARNING: ${what} (${reason}) — equity reported as unknown (null); drawdown / daily-loss limits are not evaluated until it is known`);
250
+ effectiveLog(msg);
251
+ if (mode === "json")
252
+ process.stderr.write(`${msg}\n`);
253
+ };
254
+ // Consecutive ticks whose end-of-tick equity read failed; at UNKNOWN_EQUITY_STOP_TICKS the bot stops.
255
+ let unknownEquityTicks = 0;
256
+ /** A condition that ends the run as a failure regardless of earlier successful ticks. */
257
+ let fatalError = null;
258
+ // Entries are blocked while equity is unknown (logged once per blocked stretch).
259
+ let entryBlockLogged = false;
260
+ const blockEntryLog = (what) => {
261
+ if (entryBlockLogged)
262
+ return;
263
+ entryBlockLogged = true;
264
+ effectiveLog(chalk.yellow(` Entries blocked: equity unknown (${equityError ?? "not read yet"}) — ${what}; reduce-only orders and cancels still go out`));
265
+ };
266
+ // Market data (orderbook / klines) a strategy needs but could not be read: the tick is skipped (logged once per cause).
267
+ let marketDataError = null;
268
+ const needsMarketData = MARKET_DATA_STRATEGIES.has(config.strategy.type.toLowerCase());
269
+ /** Last snapshot price (reference price for the stop actions' market orders). */
270
+ let lastPrice = 0;
271
+ // onStop runs once per started strategy, on every stop path (exit / risk / time_after / SIGINT / error).
272
+ let onStopPending = false;
273
+ /** `retry`: a failure is rethrown and onStop stays pending (the exit phase retries next tick); else logged. */
274
+ const runOnStop = async (why, retry) => {
275
+ if (!onStopPending)
276
+ return;
277
+ onStopPending = false;
278
+ try {
279
+ const stopActions = await strategy.onStop(strategyCtx);
280
+ await executeActions(adapter, config.symbol, stopActions, effectiveLog, isDryRun, rulesOf, lastPrice);
281
+ }
282
+ catch (e) {
283
+ if (retry) {
284
+ onStopPending = true;
285
+ throw e;
286
+ }
287
+ effectiveLog(chalk.red(` WARNING: strategy stop (${why}) FAILED: ${e instanceof Error ? e.message : String(e)} — check positions / orders by hand: perp -e ${adapter.name} account positions; perp -e ${adapter.name} account orders`));
288
+ }
289
+ };
290
+ /**
291
+ * Cancel the orders this bot placed. Single-symbol bot: that symbol's orders on its venue.
292
+ * Multi-symbol ("ALL") bot: the engine never sends a symbol-less cancel (it would cancel the
293
+ * user's manual orders on every market) — the strategy's onStop cancels its own symbols.
294
+ * Returns true when nothing of the bot's is left to cancel.
295
+ */
296
+ const cancelBotOrders = async () => {
297
+ if (isDryRun)
298
+ return true;
299
+ if (config.symbol.toUpperCase() === "ALL") {
300
+ effectiveLog(` Multi-symbol bot: no account-wide cancel sent — the strategy cancels the orders of the symbols it traded`);
301
+ return true;
302
+ }
303
+ effectiveLog(` Cleaning up orders...`);
304
+ try {
305
+ await adapter.cancelAllOrders(config.symbol);
306
+ effectiveLog(` Remaining ${config.symbol} orders cancelled.`);
307
+ return true;
308
+ }
309
+ catch (e) {
310
+ effectiveLog(` WARNING: cancelling the remaining ${config.symbol} orders FAILED (${e instanceof Error ? e.message : String(e)}) — check: perp -e ${adapter.name} account orders`);
311
+ return false;
312
+ }
313
+ };
314
+ // Get initial equity (every venue + spot holdings for a multi-venue bot — botEquity)
166
315
  try {
167
- const bal = await adapter.getBalance();
168
- state.equity = parseFloat(bal.equity);
169
- state.peakEquity = state.equity;
170
- state.dailyStartEquity = state.equity;
171
- state.initialEquity = state.equity;
172
- effectiveLog(` Starting equity: $${state.equity.toFixed(2)}`);
316
+ const eq = await botEquity(adapter, extraAdapters);
317
+ state.equity = eq;
318
+ state.peakEquity = eq;
319
+ state.dailyStartEquity = eq;
320
+ state.initialEquity = eq;
321
+ effectiveLog(` Starting equity: $${eq.toFixed(2)}`);
173
322
  }
174
- catch {
175
- effectiveLog(chalk.yellow(` Could not fetch initial balance`));
323
+ catch (e) {
324
+ equityUnknown(e, "could not read the starting equity — the baseline is set at the first successful read");
176
325
  }
177
326
  if (mode !== "tui") {
178
327
  log(chalk.gray(`\n Monitoring conditions... (Ctrl+C to stop)\n`));
@@ -187,17 +336,44 @@ mode = "headless") {
187
336
  const loopStart = Date.now();
188
337
  try {
189
338
  // Get market data (enriched for strategy use)
190
- const snapshot = await getEnrichedSnapshot(adapter, config.symbol);
339
+ const { snapshot, unknown: dataUnknown } = await getEnrichedSnapshot(adapter, config.symbol);
340
+ if (snapshot.price > 0)
341
+ lastPrice = snapshot.price;
342
+ // A strategy that reads the orderbook / klines never runs on fake (empty) data.
343
+ const skipForData = needsMarketData && dataUnknown !== null;
344
+ if (skipForData && dataUnknown !== marketDataError) {
345
+ marketDataError = dataUnknown;
346
+ effectiveLog(chalk.yellow(` WARNING: market data unknown (${dataUnknown}) — ${config.strategy.type} ticks are skipped until it is read`));
347
+ }
348
+ else if (!skipForData && needsMarketData && marketDataError !== null) {
349
+ effectiveLog(` Market data known again — ${config.strategy.type} resumes`);
350
+ marketDataError = null;
351
+ }
352
+ strategyCtx.state.set("equityUnknown", state.equity === null);
191
353
  const context = {
192
354
  equity: state.equity,
193
355
  startTime: state.startTime,
194
356
  peakEquity: state.peakEquity,
195
357
  dailyPnl: state.dailyPnl,
196
358
  };
359
+ // Runtime limit while not running (never entered, or a failed start retrying): stop too —
360
+ // the `time_after` check in the running phase alone would never see it.
361
+ if (state.phase === "monitoring" && config.exit_conditions.some(c => c.type === "time_after" && evaluateCondition(c, snapshot, context))) {
362
+ effectiveLog(` Bot stopped (runtime limit reached before the strategy ran).`);
363
+ break;
364
+ }
197
365
  // ── Phase: Monitoring (waiting for entry conditions) ──
198
366
  if (state.phase === "monitoring") {
199
367
  const shouldEnter = evaluateAllConditions(config.entry_conditions, snapshot, context, "all");
200
- if (shouldEnter) {
368
+ if (shouldEnter && state.equity === null) {
369
+ // Unknown equity: no new entry (the start places opening orders); retried next tick.
370
+ blockEntryLog("the strategy is not started");
371
+ logStatus(effectiveLog, state, snapshot, "waiting");
372
+ }
373
+ else if (shouldEnter && skipForData) {
374
+ logStatus(effectiveLog, state, snapshot, "waiting");
375
+ }
376
+ else if (shouldEnter) {
201
377
  effectiveLog(` Entry conditions met @ $${snapshot.price.toFixed(2)}`);
202
378
  state.phase = "entering";
203
379
  }
@@ -207,21 +383,39 @@ mode = "headless") {
207
383
  }
208
384
  // ── Phase: Entering (start strategy) ──
209
385
  if (state.phase === "entering") {
386
+ // Leftover orders of a failed start: cancel them before a retry (never stack a second batch).
387
+ if (state.strategyActive && !(await cancelBotOrders())) {
388
+ effectiveLog(` Strategy start postponed: the previous failed start's orders could not be cancelled`);
389
+ state.phase = "monitoring";
390
+ }
391
+ }
392
+ if (state.phase === "entering") {
393
+ // Active BEFORE the first order: a failed start's leftover orders are cancelled at stop.
394
+ state.strategyActive = true;
210
395
  try {
211
396
  await strategy.init(strategyCtx, snapshot);
212
397
  // Execute any initial actions (e.g., grid placement on first tick)
213
398
  strategyCtx.tick = 0;
214
399
  const initActions = await strategy.onTick(strategyCtx, snapshot);
215
- await executeActions(adapter, config.symbol, initActions, effectiveLog, isDryRun);
216
- state.fills += countFills(initActions);
400
+ onStopPending = true;
401
+ // Orders sent (an undersized side skipped for the tick is not counted) / venue-reported fills.
402
+ const sent = await executeActions(adapter, config.symbol, initActions, effectiveLog, isDryRun, rulesOf, snapshot.price);
403
+ state.ordersPlaced += sent.placed;
404
+ state.fills += sent.filled;
217
405
  strategyCtx.tick++;
406
+ strategyTicksOk++;
407
+ failureStreak = 0;
218
408
  state.phase = "running";
219
- state.strategyActive = true;
220
409
  effectiveLog(` Strategy started`);
221
410
  }
222
411
  catch (err) {
412
+ lastError = err;
413
+ failureStreak++;
414
+ onStopPending = false; // a failed start is not stopped (its leftovers are cancelled), it is retried
223
415
  const msg = err instanceof Error ? err.message : String(err);
224
416
  effectiveLog(` Strategy start failed: ${msg}`);
417
+ if (await cancelBotOrders())
418
+ state.strategyActive = false;
225
419
  state.phase = "monitoring"; // retry
226
420
  }
227
421
  }
@@ -230,38 +424,70 @@ mode = "headless") {
230
424
  // Check exit conditions (any = stop)
231
425
  const shouldExit = config.exit_conditions.length > 0
232
426
  && evaluateAllConditions(config.exit_conditions, snapshot, context, "any");
427
+ // A met `time_after` (runtime limit, e.g. quick-grid --max-runtime) ends the bot: returning
428
+ // to monitoring would re-enter at once (entry "always") and the runtime would never end.
429
+ const runtimeEnded = config.exit_conditions.some(c => c.type === "time_after" && evaluateCondition(c, snapshot, context));
233
430
  // Check risk limits
234
- const drawdown = state.peakEquity - state.equity;
235
- const dailyLossBreached = state.dailyPnl < -config.risk.max_daily_loss;
236
- const riskBreached = drawdown > config.risk.max_drawdown || dailyLossBreached;
237
- if (shouldExit || riskBreached) {
238
- const reason = dailyLossBreached
431
+ // Equity not known this tick (a balance / spot read failed): the drawdown / daily-loss
432
+ // limits are not judged on stale or partial numbers — logged once (equityUnknown), never a fake breach.
433
+ const drawdown = state.equity !== null && state.peakEquity !== null ? state.peakEquity - state.equity : null;
434
+ const dailyLossBreached = drawdown !== null && state.dailyPnl < -config.risk.max_daily_loss;
435
+ const riskBreached = drawdown !== null && (drawdown > config.risk.max_drawdown || dailyLossBreached);
436
+ const finished = strategy.isDone?.(strategyCtx) === true;
437
+ if (shouldExit || riskBreached || finished) {
438
+ const reason = finished ? "strategy finished (its order total is placed)" : dailyLossBreached
239
439
  ? `daily loss $${Math.abs(state.dailyPnl).toFixed(2)} > limit $${config.risk.max_daily_loss}`
240
- : drawdown > config.risk.max_drawdown
440
+ : drawdown !== null && drawdown > config.risk.max_drawdown
241
441
  ? `drawdown $${drawdown.toFixed(2)} > limit $${config.risk.max_drawdown}`
242
- : "exit condition met";
442
+ : runtimeEnded ? "runtime limit (time_after) reached" : "exit condition met";
243
443
  effectiveLog(` Exiting: ${reason}`);
244
444
  state.riskBreached = riskBreached;
445
+ state.runtimeEnded = runtimeEnded || finished;
245
446
  state.phase = "exiting";
246
447
  }
448
+ else if (skipForData) {
449
+ logStatus(effectiveLog, state, snapshot, "running");
450
+ }
247
451
  else {
248
452
  // Manage running strategy
249
- const actions = await strategy.onTick(strategyCtx, snapshot);
250
- await executeActions(adapter, config.symbol, actions, effectiveLog, isDryRun);
251
- state.fills += countFills(actions);
453
+ let actions = await strategy.onTick(strategyCtx, snapshot);
454
+ if (state.equity === null) {
455
+ // Unknown equity: opening orders are dropped; reduce-only orders / cancels still go out.
456
+ const kept = actions.filter(a => a.type !== "place_order" || a.reduceOnly === true);
457
+ if (kept.length < actions.length)
458
+ blockEntryLog(`${actions.length - kept.length} opening order(s) dropped this tick`);
459
+ actions = kept;
460
+ }
461
+ else {
462
+ entryBlockLogged = false;
463
+ }
464
+ // Orders sent (an undersized side skipped for the tick is not counted) / venue-reported fills.
465
+ const sent = await executeActions(adapter, config.symbol, actions, effectiveLog, isDryRun, rulesOf, snapshot.price);
466
+ state.ordersPlaced += sent.placed;
467
+ state.fills += sent.filled;
252
468
  strategyCtx.tick++;
469
+ strategyTicksOk++;
470
+ failureStreak = 0;
253
471
  logStatus(effectiveLog, state, snapshot, "running");
254
472
  }
255
473
  }
256
474
  // ── Phase: Exiting (close everything) ──
257
475
  if (state.phase === "exiting") {
258
- const stopActions = await strategy.onStop(strategyCtx);
259
- await executeActions(adapter, config.symbol, stopActions, effectiveLog, isDryRun);
476
+ await runOnStop("exit", true);
260
477
  state.strategyActive = false;
261
- if (config.risk.pause_after_loss_sec > 0 && state.dailyPnl < 0) {
478
+ if (state.runtimeEnded) {
479
+ effectiveLog(` Bot stopped (runtime limit reached or strategy finished).`);
480
+ break;
481
+ }
482
+ else if (config.risk.pause_after_loss_sec > 0 && state.dailyPnl < 0) {
262
483
  effectiveLog(` Pausing ${config.risk.pause_after_loss_sec}s after loss`);
263
484
  state.phase = "paused";
264
- await sleep(config.risk.pause_after_loss_sec * 1000);
485
+ // Interruptible: a SIGINT during the pause ends the bot within 250 ms.
486
+ const pauseUntil = Date.now() + config.risk.pause_after_loss_sec * 1000;
487
+ while (running && Date.now() < pauseUntil)
488
+ await sleep(Math.min(250, pauseUntil - Date.now()));
489
+ if (!running)
490
+ break;
265
491
  // Data will be refreshed on the next loop iteration (re-fetches snapshot + equity)
266
492
  state.phase = "monitoring";
267
493
  effectiveLog(` Resuming monitoring...`);
@@ -284,24 +510,46 @@ mode = "headless") {
284
510
  fetchOpenOrders(adapter, config.symbol),
285
511
  fetchExchangeBalances(adapter, extraAdapters),
286
512
  ]);
287
- // Update equity using the already-fetched balance (avoids a redundant getBalance() call)
513
+ // Equity: the primary venue's (single-venue bot) or every venue + spot holdings (multi-venue:
514
+ // a spot-perp bot moves USDC into spot tokens — counting the perp account alone read that
515
+ // as a loss; live 2026-10-12 "daily loss $10.12" right after a $10 spot buy).
288
516
  try {
289
- const primaryBal = exchangeBalances.find(b => b.exchange === adapter.name);
290
- if (primaryBal) {
291
- state.equity = parseFloat(primaryBal.equity);
292
- if (state.equity > state.peakEquity)
293
- state.peakEquity = state.equity;
294
- // Reset daily baseline at day boundary
295
- const today = new Date().toISOString().slice(0, 10);
296
- if (today !== state.dailyStartDate) {
297
- state.dailyStartEquity = state.equity;
298
- state.dailyStartDate = today;
299
- }
300
- state.dailyPnl = state.equity - state.dailyStartEquity;
301
- state.totalPnl = state.equity - state.initialEquity;
517
+ const eq = await botEquity(adapter, extraAdapters);
518
+ state.equity = eq;
519
+ if (state.peakEquity === null) {
520
+ // First successful read after a failed start read: this is the baseline.
521
+ state.peakEquity = eq;
522
+ state.dailyStartEquity = eq;
523
+ state.initialEquity = eq;
524
+ }
525
+ if (equityError !== null) {
526
+ effectiveLog(` Equity known again: $${eq.toFixed(2)} — risk limits evaluated`);
527
+ equityError = null;
528
+ }
529
+ if (eq > state.peakEquity)
530
+ state.peakEquity = eq;
531
+ // Reset daily baseline at day boundary
532
+ const today = new Date().toISOString().slice(0, 10);
533
+ if (today !== state.dailyStartDate) {
534
+ state.dailyStartEquity = eq;
535
+ state.dailyStartDate = today;
536
+ }
537
+ state.dailyPnl = eq - state.dailyStartEquity;
538
+ state.totalPnl = eq - state.initialEquity;
539
+ unknownEquityTicks = 0;
540
+ }
541
+ catch (e) {
542
+ equityUnknown(e, "equity read failed");
543
+ unknownEquityTicks++;
544
+ if (unknownEquityTicks >= UNKNOWN_EQUITY_STOP_TICKS) {
545
+ fatalError = new PerpError(e instanceof PerpError ? e.structured.code : "EXCHANGE_ERROR", `Bot "${config.name}": equity unknown for ${unknownEquityTicks} consecutive ticks (last: ${equityError}) — stopped (risk limits cannot be evaluated)`, {
546
+ exchange: adapter.name,
547
+ remediation: `Check the balances / spot holdings the bot reads (perp -e ${adapter.name} account balance${extraAdapters ? "; every venue of the bot" : ""}), fix the read or the unpriced holding, then restart the bot`,
548
+ });
549
+ effectiveLog(chalk.red(` ${fatalError.message}`));
550
+ running = false;
302
551
  }
303
552
  }
304
- catch { /* non-critical */ }
305
553
  if (mode === "tui" || mode === "json") {
306
554
  const tuiState = buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx, exchangeBalances);
307
555
  if (mode === "tui") {
@@ -312,7 +560,7 @@ mode = "headless") {
312
560
  }
313
561
  }
314
562
  else {
315
- effectiveLog(` tick: phase=${state.phase} equity=$${state.equity.toFixed(2)}`);
563
+ effectiveLog(` tick: phase=${state.phase} equity=${usdOrUnknown(state.equity)}`);
316
564
  }
317
565
  // Update job state
318
566
  if (jobId) {
@@ -322,6 +570,7 @@ mode = "headless") {
322
570
  equity: state.equity,
323
571
  peakEquity: state.peakEquity,
324
572
  fills: state.fills,
573
+ ordersPlaced: state.ordersPlaced,
325
574
  totalPnl: state.totalPnl,
326
575
  rebalances: state.rebalanceCount,
327
576
  runtime: Math.floor((Date.now() - state.startTime) / 1000),
@@ -330,14 +579,18 @@ mode = "headless") {
330
579
  }
331
580
  }
332
581
  catch (err) {
582
+ lastError = err;
583
+ failureStreak++;
333
584
  const msg = err instanceof Error ? err.message : String(err);
334
585
  effectiveLog(` Loop error: ${msg}`);
335
586
  }
336
587
  // Wait for next interval
337
588
  const elapsed = Date.now() - loopStart;
338
589
  const waitMs = Math.max(0, config.monitor_interval_sec * 1000 - elapsed);
339
- if (running && waitMs > 0)
340
- await sleep(waitMs);
590
+ // Interruptible wait: a SIGINT (background stop) ends it within 250 ms, not after a full interval.
591
+ const until = Date.now() + waitMs;
592
+ while (running && Date.now() < until)
593
+ await sleep(Math.min(250, until - Date.now()));
341
594
  }
342
595
  }
343
596
  finally {
@@ -348,57 +601,265 @@ mode = "headless") {
348
601
  process.stdin.setRawMode?.(false);
349
602
  process.stdin.pause();
350
603
  }
351
- // Cleanup: cancel any remaining orders
352
- if (state.strategyActive) {
353
- effectiveLog(` Cleaning up orders...`);
354
- try {
355
- await adapter.cancelAllOrders(config.symbol);
356
- }
357
- catch { /* best effort */ }
358
- }
604
+ // Every stop path (SIGINT / time_after / error / unknown equity): the strategy's onStop runs
605
+ // once (guarded), then the bot's remaining orders are cancelled.
606
+ await runOnStop("shutdown", false);
607
+ if (state.strategyActive)
608
+ await cancelBotOrders();
359
609
  // Unmount TUI if active
360
610
  if (tuiUnmount) {
361
611
  tuiUnmount();
362
612
  }
363
613
  }
364
614
  const runtime = Math.floor((Date.now() - state.startTime) / 1000);
615
+ // What the bot leaves open: positions on its venue(s) (its symbol, or every symbol for a
616
+ // multi-symbol bot — those may include positions this bot did not open), with close commands.
617
+ // The stop summary must reach the user in every mode: JSON mode keeps stdout for JSON lines
618
+ // (→ stderr), and the TUI is unmounted by now (→ the console log).
619
+ const stopLog = mode === "json"
620
+ ? (msg) => { writeLog(msg); process.stderr.write(`${msg}\n`); }
621
+ : mode === "tui" ? (msg) => { log(msg); writeLog(msg); } : effectiveLog;
622
+ const spotLegs = extraAdapters !== undefined && extraAdapters.size > 0 && SPOT_LEG_STRATEGIES.has(config.strategy.type);
623
+ const leftOpen = isDryRun ? [] : await openPositionsAtStop(adapter, config.symbol, extraAdapters, stopLog, spotLegs);
365
624
  if (mode === "json") {
366
- console.log(JSON.stringify({ event: "done", fills: state.fills, totalPnl: state.totalPnl, runtime, equity: state.equity, peakEquity: state.peakEquity }));
625
+ console.log(JSON.stringify({ event: "done", fills: state.fills, ordersPlaced: state.ordersPlaced, totalPnl: state.totalPnl, runtime, equity: state.equity, peakEquity: state.peakEquity, openPositions: leftOpen }));
367
626
  }
368
627
  else if (mode !== "tui") {
369
628
  log(chalk.cyan.bold(`\n Bot "${config.name}" finished`));
370
- log(` Runtime: ${formatDuration(runtime)} | Fills: ${state.fills} | PnL: $${state.totalPnl.toFixed(2)}`);
371
- log(` Equity: $${state.equity.toFixed(2)} (peak: $${state.peakEquity.toFixed(2)})\n`);
629
+ log(` Runtime: ${formatDuration(runtime)} | Orders: ${state.ordersPlaced} | Fills: ${state.fills} | PnL: $${state.totalPnl.toFixed(2)}`);
630
+ log(` Equity: ${usdOrUnknown(state.equity)} (peak: ${usdOrUnknown(state.peakEquity)})\n`);
631
+ }
632
+ if (fatalError !== null) {
633
+ if (jobId)
634
+ updateJobState(jobId, { status: "error", result: { error: fatalError.message, fills: state.fills, ordersPlaced: state.ordersPlaced, runtime } });
635
+ throw fatalError;
636
+ }
637
+ // A run whose strategy never completed a start / tick — or whose last FAILED_RUN_STREAK attempts
638
+ // all failed — is a failed run (non-zero exit / ok:false), never a clean "done".
639
+ if (lastError !== null && (strategyTicksOk === 0 || failureStreak >= FAILED_RUN_STREAK)) {
640
+ const msg = lastError instanceof Error ? lastError.message : String(lastError);
641
+ const why = strategyTicksOk === 0 ? "the strategy never started / completed a tick" : `the last ${failureStreak} attempts failed`;
642
+ if (jobId)
643
+ updateJobState(jobId, { status: "error", result: { error: msg, fills: state.fills, ordersPlaced: state.ordersPlaced, runtime } });
644
+ throw new PerpError(lastError instanceof PerpError ? lastError.structured.code : "EXCHANGE_ERROR", `Bot "${config.name}" (${config.strategy.type} on ${adapter.name}) failed: ${why}. Last error: ${msg}`, {
645
+ exchange: adapter.name, fills: state.fills, ordersPlaced: state.ordersPlaced, runtime,
646
+ remediation: `See the log: ${logFile}`,
647
+ });
372
648
  }
373
649
  if (jobId) {
374
650
  updateJobState(jobId, { status: "done" });
375
651
  }
376
- return { fills: state.fills, totalPnl: state.totalPnl, runtime };
652
+ return { fills: state.fills, ordersPlaced: state.ordersPlaced, totalPnl: state.totalPnl, runtime };
653
+ }
654
+ /** Consecutive failed strategy starts / ticks at the end of a run that make the run a failure. */
655
+ const FAILED_RUN_STREAK = 3;
656
+ /** Consecutive ticks with unknown equity after which the bot stops (an error, never a silent run). */
657
+ export const UNKNOWN_EQUITY_STOP_TICKS = 10;
658
+ /** Strategies that read the snapshot's orderbook / klines: a tick whose read failed is skipped. */
659
+ export const MARKET_DATA_STRATEGIES = new Set([
660
+ "engine-mm", "avellaneda-mm", "regime-mm", "claude-agent", "momentum-breakout", "aggressive-taker", "mean-reversion",
661
+ ]);
662
+ /** Strategies that hold spot legs (spot-perp): their stop summary lists the spot holdings too. */
663
+ export const SPOT_LEG_STRATEGIES = new Set(["spot-perp-arb", "funding-arb"]);
664
+ /**
665
+ * Open positions at stop (logged with the close command); a failed read is logged, never "none".
666
+ * `spotLegs` (a multi-venue spot-perp bot): the bot spot venues' holdings of the traded symbol(s)
667
+ * are listed too — a spot holding + a perp short of one symbol is closed as a pair
668
+ * (`arb close --spot-exch --perp-exch`), never the short alone (that leaves the spot unhedged).
669
+ */
670
+ export async function openPositionsAtStop(primary, symbol, extra, log, spotLegs) {
671
+ const all = new Map([[primary.name, primary], ...[...(extra ?? new Map())].filter(([n]) => n !== primary.name)]);
672
+ const multi = symbol.toUpperCase() === "ALL";
673
+ const out = [];
674
+ for (const [name, a] of all) {
675
+ try {
676
+ for (const p of await a.getPositions()) {
677
+ if (Number(p.size) === 0)
678
+ continue;
679
+ const sym = p.symbol.toUpperCase().replace(/-PERP$/, "");
680
+ if (!multi && sym !== symbol.toUpperCase())
681
+ continue;
682
+ out.push({ exchange: name, symbol: sym, side: p.side, size: p.size.replace(/^-/, ""), close: null });
683
+ }
684
+ }
685
+ catch (e) {
686
+ log(` WARNING: could not read ${name} positions at stop (${e instanceof Error ? e.message : String(e)}) — check: perp -e ${name} account positions`);
687
+ }
688
+ }
689
+ if (spotLegs) {
690
+ const { getBotSpotAdapter, nonQuoteSpotBalances, spotNetworkOf, spotTokenBase } = await import("../exchanges/spot-factory.js");
691
+ const { SPOT_PERP_TOKEN_MAP } = await import("../exchanges/spot-interface.js");
692
+ const { getVenue } = await import("../exchanges/venue.js");
693
+ for (const [name, a] of all) {
694
+ try {
695
+ const spot = await getBotSpotAdapter(name, a);
696
+ if (spot === null)
697
+ continue;
698
+ const [balances, markets] = await Promise.all([spot.getSpotBalances(), spot.getSpotMarkets()]);
699
+ // Only tokens with a spot market can be a spot leg (e.g. HIP-4 outcome tokens are not).
700
+ const traded = new Set(markets.map(m => m.baseToken.toUpperCase()));
701
+ for (const b of nonQuoteSpotBalances(getVenue(name), spotNetworkOf(spot), balances)) {
702
+ const base = spotTokenBase(b.token);
703
+ if (!traded.has(base))
704
+ continue;
705
+ // Listed under the perp name (UBTC → BTC) so it pairs with its hedge.
706
+ const sym = SPOT_PERP_TOKEN_MAP[base] ?? base;
707
+ if (!multi && sym !== symbol.toUpperCase() && base !== symbol.toUpperCase())
708
+ continue;
709
+ out.push({ exchange: name, symbol: sym, side: "spot", size: b.total, close: `perp -e ${name} trade spot-sell ${base} ${b.total}` });
710
+ }
711
+ }
712
+ catch (e) {
713
+ log(` WARNING: could not read ${name} spot holdings at stop (${e instanceof Error ? e.message : String(e)}) — check: perp -e ${name} trade spot-balance`);
714
+ }
715
+ }
716
+ }
717
+ if (out.length === 0)
718
+ return [];
719
+ log(` Open positions at stop${multi ? ` on ${[...all.keys()].join(", ")} (may include positions this bot did not open)` : ""}:`);
720
+ const paired = new Set();
721
+ const shortFor = (sym, notOn) => out.find(p => p.side === "short" && p.symbol === sym && p.exchange !== notOn && !paired.has(p));
722
+ for (const sp of out.filter(p => p.side === "spot")) {
723
+ const s = shortFor(sp.symbol, null);
724
+ if (s === undefined)
725
+ continue;
726
+ paired.add(sp);
727
+ paired.add(s);
728
+ sp.close = s.close = `perp arb close ${sp.symbol} --spot-exch ${sp.exchange} --perp-exch ${s.exchange}`;
729
+ log(` ${sp.symbol}: spot ${sp.size} on ${sp.exchange} / short ${s.size} on ${s.exchange} — close the pair: ${sp.close}`);
730
+ }
731
+ for (const l of out.filter(p => p.side === "long")) {
732
+ const s = shortFor(l.symbol, l.exchange);
733
+ if (s === undefined)
734
+ continue;
735
+ paired.add(l);
736
+ paired.add(s);
737
+ l.close = s.close = `perp arb close ${l.symbol} --pair ${l.exchange}:${s.exchange}`;
738
+ log(` ${l.symbol}: long ${l.size} on ${l.exchange} / short ${s.size} on ${s.exchange} — close the pair: ${l.close}`);
739
+ }
740
+ for (const p of out) {
741
+ if (paired.has(p))
742
+ continue;
743
+ if (p.close === null)
744
+ p.close = `perp -e ${p.exchange} trade close ${p.symbol}`;
745
+ log(` ${p.symbol}: ${p.side} ${p.size} on ${p.exchange} — close: ${p.close}`);
746
+ }
747
+ return out.map(p => ({ ...p, close: p.close }));
748
+ }
749
+ /**
750
+ * Bot equity in USD. Single-venue bot: the venue's account equity. Multi-venue bot (extra
751
+ * adapters): Σ every venue's account equity + its bot spot holdings — tokens at the shared
752
+ * valuation price (spotMarketsForValuation: spot market price, HIP-4 outcome tokens at their
753
+ * outcome mid), and the spot quote balance when the spot account is separate from the perp
754
+ * margin (spotSharesPerpMargin false). Any failed read or unpriced token → throws (equity unknown;
755
+ * never a partial sum that would read as a loss).
756
+ */
757
+ async function botEquity(primary, extra) {
758
+ const eq = (v, who) => {
759
+ const n = Number(v);
760
+ if (!Number.isFinite(n))
761
+ throw new PerpError("EXCHANGE_ERROR", `${who} equity is not a number (${v})`);
762
+ return n;
763
+ };
764
+ if (extra === undefined || extra.size === 0)
765
+ return eq((await primary.getBalance()).equity, primary.name);
766
+ const { getBotSpotAdapter, isSpotQuoteAsset, spotMarketsForValuation, spotNetworkOf, spotTokenBase, spotSharesPerpMargin } = await import("../exchanges/spot-factory.js");
767
+ const { getVenue } = await import("../exchanges/venue.js");
768
+ const all = new Map([[primary.name, primary], ...[...extra].filter(([n]) => n !== primary.name)]);
769
+ let total = 0;
770
+ for (const [name, a] of all) {
771
+ total += eq((await a.getBalance()).equity, name);
772
+ const spot = await getBotSpotAdapter(name, a);
773
+ if (spot === null)
774
+ continue;
775
+ const def = getVenue(name);
776
+ const net = spotNetworkOf(spot);
777
+ const shared = spotSharesPerpMargin(def, a);
778
+ // Shared valuation prices (spot markets + held HIP-4 outcome tokens at their outcome mid).
779
+ const balances = await spot.getSpotBalances();
780
+ const markets = await spotMarketsForValuation(def, spot, balances, net);
781
+ const price = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
782
+ for (const b of balances) {
783
+ const qty = Number(b.total);
784
+ if (!(qty > 0))
785
+ continue;
786
+ if (isSpotQuoteAsset(def, net, b.token)) {
787
+ if (!shared)
788
+ total += qty;
789
+ continue;
790
+ }
791
+ const px = Number(price.get(spotTokenBase(b.token)));
792
+ if (!(px > 0))
793
+ throw new PerpError("PRICE_STALE", `${name} spot ${b.token}: no market price — bot equity unknown`);
794
+ total += qty * px;
795
+ }
796
+ }
797
+ return total;
377
798
  }
378
799
  // ── Action executor ──
379
- async function executeActions(adapter, symbol, actions, log, dryRun = false) {
800
+ /**
801
+ * Send strategy actions. Orders go out on the venue grid (quote-rules.ts): limit prices aligned
802
+ * passively to the tick (bid ↓, ask ↑), sizes floored to the lot, venue minimums refused before
803
+ * sending. `refPrice` = the tick's snapshot price (minimum order value of market orders).
804
+ * One order under the venue minimum (e.g. the inventory-skewed side of a market maker) is
805
+ * skipped for this tick with a log line — the other orders still go out; when EVERY order of
806
+ * the batch is under the minimum the last SIZE_TOO_SMALL is thrown (the tick fails: the
807
+ * strategy's base size itself is too small).
808
+ * Returns the orders sent (`placed`) and those the venue reported as (partly) filled (`filled`,
809
+ * filledOf) — an accepted resting limit order is a placement, not a fill. Under --dry-run every
810
+ * place_order is a simulated placement and nothing fills.
811
+ */
812
+ export async function executeActions(adapter, symbol, actions, log, dryRun, rulesOf, refPrice) {
813
+ let orders = 0;
814
+ let filled = 0;
815
+ let undersized = null;
816
+ let skipped = 0;
380
817
  for (const action of actions) {
381
818
  if (dryRun) {
382
819
  log(` [DRY-RUN] ${action.type}: ${JSON.stringify(action)}`);
820
+ if (action.type === "place_order")
821
+ orders++;
383
822
  continue;
384
823
  }
385
824
  switch (action.type) {
386
- case "place_order":
387
- if (action.orderType === "market") {
388
- await adapter.marketOrder(symbol, action.side, action.size);
825
+ case "place_order": {
826
+ orders++;
827
+ const rules = await rulesOf();
828
+ let prepared;
829
+ try {
830
+ prepared = action.orderType === "market"
831
+ ? { price: null, size: prepareMarketSize(rules, adapter.name, symbol, action.size, refPrice) }
832
+ : prepareLimitQuote(rules, adapter.name, symbol, action.side, action.price, action.size);
389
833
  }
390
- else {
391
- await adapter.limitOrder(symbol, action.side, action.price, action.size, {
834
+ catch (e) {
835
+ if (!(e instanceof PerpError) || e.structured.code !== "SIZE_TOO_SMALL")
836
+ throw e;
837
+ undersized = e;
838
+ skipped++;
839
+ log(` SKIP ${action.side} ${action.size}${action.orderType === "limit" ? ` @ ${action.price}` : ""} this tick: ${e.message}`);
840
+ break;
841
+ }
842
+ const raw = prepared.price === null
843
+ ? await adapter.marketOrder(symbol, action.side, prepared.size, { reduceOnly: action.reduceOnly === true, slippage: resolveAdapterSlippage(adapter, null) })
844
+ : await adapter.limitOrder(symbol, action.side, rules.tickSize === null ? await alignComputedLimitPrice(adapter, symbol, action.side, prepared.price) : prepared.price, prepared.size, {
392
845
  reduceOnly: action.reduceOnly,
393
846
  tif: action.tif,
394
847
  });
395
- }
848
+ const f = filledOf(adapter, raw);
849
+ if (f !== null && f > 0)
850
+ filled++;
396
851
  break;
852
+ }
397
853
  case "cancel_order":
398
854
  await adapter.cancelOrder(symbol, action.orderId);
399
855
  break;
400
856
  case "cancel_all":
401
- await adapter.cancelAllOrders(symbol);
857
+ // A multi-symbol bot never sends an account-wide cancel (it would cancel the user's
858
+ // manual orders on every market): its strategy cancels the symbols it traded.
859
+ if (symbol.toUpperCase() === "ALL")
860
+ log(` cancel_all not sent for a multi-symbol bot (account-wide) — the strategy cancels its own symbols`);
861
+ else
862
+ await adapter.cancelAllOrders(symbol);
402
863
  break;
403
864
  case "edit_order":
404
865
  await adapter.editOrder(symbol, action.orderId, action.price, action.size);
@@ -410,37 +871,54 @@ async function executeActions(adapter, symbol, actions, log, dryRun = false) {
410
871
  break;
411
872
  }
412
873
  }
874
+ if (undersized !== null && skipped === orders)
875
+ throw undersized;
876
+ return { placed: orders - skipped, filled };
413
877
  }
878
+ export { filledOf };
414
879
  // ── Enriched snapshot ──
880
+ /**
881
+ * The tick's market snapshot. `unknown` names a failed orderbook / klines read (null = both
882
+ * read): the empty arrays in `snapshot` then are NOT data — the engine skips the tick of a
883
+ * strategy that reads them (MARKET_DATA_STRATEGIES).
884
+ */
415
885
  async function getEnrichedSnapshot(adapter, symbol) {
416
886
  // Multi-symbol strategies use "ALL" — skip market-specific data
417
887
  if (symbol.toUpperCase() === "ALL") {
418
888
  return {
419
- price: 0, high24h: 0, low24h: 0, volume24h: 0,
420
- fundingRate: 0, volatility24h: 0, rsi: NaN, spreadPct: 0,
421
- klines: [], orderbook: { bids: [], asks: [] }, openInterest: "0",
889
+ snapshot: {
890
+ price: 0, high24h: 0, low24h: 0, volume24h: 0,
891
+ fundingRate: 0, volatility24h: 0, rsi: NaN, spreadPct: 0,
892
+ klines: [], orderbook: { bids: [], asks: [] }, openInterest: "0",
893
+ },
894
+ unknown: null,
422
895
  };
423
896
  }
424
897
  const base = await getMarketSnapshot(adapter, symbol);
898
+ const failed = [];
899
+ const why = (what) => (e) => { failed.push(`${what} read failed: ${e instanceof Error ? e.message : String(e)}`); return null; };
425
900
  const [orderbook, klines] = await Promise.all([
426
- adapter.getOrderbook(symbol).catch(() => ({ bids: [], asks: [] })),
427
- adapter.getKlines(symbol, "1h", Date.now() - 24 * 60 * 60 * 1000, Date.now()).catch(() => []),
901
+ adapter.getOrderbook(symbol).catch(why("orderbook")),
902
+ adapter.getKlines(symbol, "1h", Date.now() - 24 * 60 * 60 * 1000, Date.now()).catch(why("klines")),
428
903
  ]);
429
- return { ...base, klines, orderbook, openInterest: "0" };
904
+ return {
905
+ snapshot: { ...base, klines: klines ?? [], orderbook: orderbook ?? { bids: [], asks: [] }, openInterest: "0" },
906
+ unknown: failed.length > 0 ? failed.join("; ") : null,
907
+ };
430
908
  }
431
909
  // ── Utils ──
432
- /** Count fill-producing actions (all order placements) */
433
- function countFills(actions) {
434
- return actions.filter(a => a.type === "place_order").length;
435
- }
436
910
  function logStatus(log, state, snapshot, phase) {
437
911
  const runtime = formatDuration(Math.floor((Date.now() - state.startTime) / 1000));
438
912
  const vol = snapshot.volatility24h.toFixed(1);
439
913
  const fr = (snapshot.fundingRate * 100).toFixed(4);
440
- const eq = state.equity.toFixed(2);
441
- const dd = (state.peakEquity - state.equity).toFixed(2);
914
+ const eq = usdOrUnknown(state.equity);
915
+ const dd = state.equity !== null && state.peakEquity !== null ? `$${(state.peakEquity - state.equity).toFixed(2)}` : "unknown";
442
916
  log(chalk.gray(` [${phase}] $${snapshot.price.toFixed(2)} | vol: ${vol}% | fr: ${fr}% | ` +
443
- `eq: $${eq} | dd: $${dd} | fills: ${state.fills} | ${runtime}`));
917
+ `eq: ${eq} | dd: ${dd} | fills: ${state.fills} | ${runtime}`));
918
+ }
919
+ /** "$12.34", or "unknown" for a null (unknown) amount. */
920
+ function usdOrUnknown(v) {
921
+ return v === null ? "unknown" : `$${v.toFixed(2)}`;
444
922
  }
445
923
  function defaultLog(msg) {
446
924
  const ts = new Date().toLocaleTimeString();
@@ -497,70 +975,57 @@ async function fetchExchangeBalances(primaryAdapter, extraAdapters) {
497
975
  }));
498
976
  return results;
499
977
  }
978
+ const _spotPriceWarned = new Set();
500
979
  async function fetchSpotPositions(adapter) {
501
- const name = adapter.name.toLowerCase();
502
- if (name !== "hyperliquid" && name !== "lighter")
503
- return [];
980
+ // Bot scope (§12.4 #3): only separate/runtime spot venues with transfer.
981
+ const { getBotSpotAdapter, isSpotQuoteAsset, spotMarketsForValuation, spotNetworkOf, spotTokenBase } = await import("../exchanges/spot-factory.js");
982
+ const { getVenue } = await import("../exchanges/venue.js");
983
+ let raw;
984
+ let markets;
985
+ let net;
504
986
  try {
505
- if (name === "hyperliquid") {
506
- const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
507
- const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
508
- if (!(adapter instanceof HyperliquidAdapter))
509
- return [];
510
- const hlSpot = new HyperliquidSpotAdapter(adapter);
511
- await hlSpot.init();
512
- const [raw, markets] = await Promise.all([hlSpot.getSpotBalances(), hlSpot.getSpotMarkets()]);
513
- const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
514
- const strip = (t) => t.replace(/-SPOT$/i, "").toUpperCase();
515
- const hlPositions = [];
516
- for (const b of raw) {
517
- if (Number(b.total) <= 0)
518
- continue;
519
- const base = strip(b.token);
520
- const markPrice = priceMap.get(base) ?? "0";
521
- if (base === "USDC" || markPrice === "0")
522
- continue;
523
- const total = Number(b.total);
524
- const entryNtl = b.entryNtl ? Number(b.entryNtl) : 0;
525
- const avgEntry = (entryNtl > 0 && total > 0) ? (entryNtl / total) : 0;
526
- const mark = Number(markPrice);
527
- const uPnL = avgEntry > 0 ? (mark - avgEntry) * total : 0;
528
- hlPositions.push({
529
- symbol: base, side: "spot", size: b.total,
530
- entryPrice: avgEntry > 0 ? avgEntry.toPrecision(5) : "—",
531
- markPrice,
532
- unrealizedPnl: avgEntry > 0 ? uPnL.toFixed(2) : "—",
533
- exchange: adapter.name,
534
- });
535
- }
536
- return hlPositions;
537
- }
538
- if (name === "lighter") {
539
- const { LighterAdapter } = await import("../exchanges/lighter.js");
540
- const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
541
- if (!(adapter instanceof LighterAdapter))
542
- return [];
543
- const ltSpot = new LighterSpotAdapter(adapter);
544
- await ltSpot.init();
545
- const [raw, markets] = await Promise.all([ltSpot.getSpotBalances(), ltSpot.getSpotMarkets()]);
546
- const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
547
- const ltPositions = [];
548
- for (const b of raw) {
549
- if (Number(b.total) <= 0)
550
- continue;
551
- const base = b.token.toUpperCase();
552
- const markPrice = priceMap.get(base) ?? "0";
553
- if (base === "USDC" || base === "USDC_SPOT" || markPrice === "0")
554
- continue;
555
- ltPositions.push({ symbol: base, side: "spot", size: b.total, entryPrice: "—", markPrice, unrealizedPnl: "—", exchange: adapter.name });
987
+ const spot = await getBotSpotAdapter(adapter.name, adapter);
988
+ if (spot === null)
989
+ return [];
990
+ net = spotNetworkOf(spot);
991
+ raw = await spot.getSpotBalances();
992
+ markets = await spotMarketsForValuation(getVenue(adapter.name), spot, raw, net);
993
+ }
994
+ catch (e) {
995
+ // Display-only path: surface the failure instead of hiding it, keep the TUI alive.
996
+ process.stderr.write(`[warn] ${adapter.name} spot positions unavailable: ${e instanceof Error ? e.message : String(e)}\n`);
997
+ return [];
998
+ }
999
+ const def = getVenue(adapter.name);
1000
+ const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
1001
+ const out = [];
1002
+ for (const b of raw) {
1003
+ if (Number(b.total) <= 0 || isSpotQuoteAsset(def, net, b.token))
1004
+ continue;
1005
+ const base = spotTokenBase(b.token);
1006
+ const markPrice = priceMap.get(base);
1007
+ if (markPrice === undefined || markPrice === null || !(Number(markPrice) > 0)) {
1008
+ // Unknown price: excluded (never valued at 0) — surfaced once per token.
1009
+ const k = `${def.id}:${base}`;
1010
+ if (!_spotPriceWarned.has(k)) {
1011
+ _spotPriceWarned.add(k);
1012
+ process.stderr.write(`[warn] ${def.displayName} spot ${base}: no market price — excluded from spot positions\n`);
556
1013
  }
557
- return ltPositions;
1014
+ continue;
558
1015
  }
1016
+ const total = Number(b.total);
1017
+ const entryNtl = b.entryNtl ? Number(b.entryNtl) : 0;
1018
+ const avgEntry = (entryNtl > 0 && total > 0) ? (entryNtl / total) : 0;
1019
+ const uPnL = avgEntry > 0 ? (Number(markPrice) - avgEntry) * total : 0;
1020
+ out.push({
1021
+ symbol: base, side: "spot", size: b.total,
1022
+ entryPrice: avgEntry > 0 ? avgEntry.toPrecision(5) : "—",
1023
+ markPrice,
1024
+ unrealizedPnl: avgEntry > 0 ? uPnL.toFixed(2) : "—",
1025
+ exchange: adapter.name,
1026
+ });
559
1027
  }
560
- catch {
561
- // Spot adapter unavailable — skip silently
562
- }
563
- return [];
1028
+ return out;
564
1029
  }
565
1030
  async function fetchAllPositions(primaryAdapter, symbol, extraAdapters) {
566
1031
  const adapters = [[primaryAdapter.name, primaryAdapter]];
@@ -618,6 +1083,7 @@ function buildTuiState(state, config, snapshot, positions, openOrders, strategyC
618
1083
  peakEquity: state.peakEquity,
619
1084
  dailyPnl: state.dailyPnl,
620
1085
  fills: state.fills,
1086
+ ordersPlaced: state.ordersPlaced,
621
1087
  totalPnl: state.totalPnl,
622
1088
  runtime: Math.floor((Date.now() - state.startTime) / 1000),
623
1089
  strategy: config.strategy.type,