perp-cli 0.12.17 → 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 +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 +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 +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 +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 +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 +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 +142 -55
  348. package/skills/perp-cli/references/agent-operations.md +110 -56
  349. package/skills/perp-cli/references/commands.md +266 -71
  350. package/skills/perp-cli/references/strategies.md +34 -31
  351. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  352. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  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 +31 -15
  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
@@ -1,22 +1,36 @@
1
1
  /**
2
2
  * Orderbook-aware split order execution.
3
3
  *
4
- * Unlike TWAP (time-based splitting), this strategy reads the orderbook
5
- * before each slice and calculates optimal slice sizes based on available
6
- * depth. Each slice is placed as an IOC limit order at the worst acceptable
7
- * price within the slippage tolerance, ensuring no sweep beyond the target.
4
+ * The USD total is converted ONCE to a base size (at the opening best price, floored to the
5
+ * venue lot) and that size is split across the slices in whole lots. Every slice is an IOC
6
+ * limit at the worst acceptable price within the slippage tolerance (on the venue tick), and
7
+ * its fill is VERIFIED (placePerpVerified: venue-final IOC outcome or measured position delta)
8
+ * — a resolved order call is never counted as a fill.
9
+ *
10
+ * Before anything is sent the plan is checked against the venue minimums: a slice below the
11
+ * venue's minimum size / order value refuses the whole run (SIZE_TOO_SMALL, nothing sent).
8
12
  *
9
13
  * Flow per slice:
10
14
  * 1. Fetch fresh orderbook
11
- * 2. Walk levels via computeExecutableSize to find fillable amount
12
- * 3. Place IOC limit at best price + slippage tolerance
13
- * 4. Wait for book to refresh, then repeat
15
+ * 2. Slice = remaining size / slices left (whole lots; the last slice takes the rest)
16
+ * 3. Place IOC limit at best price ± slippage tolerance, verify the fill
17
+ * 4. Wait for the book to refresh, then repeat
18
+ * A slice with an unknown outcome stops the run (nothing is resent); a definite rejection
19
+ * (incl. an IOC that filled nothing) leaves its size to the following slices.
14
20
  */
15
21
  import type { ExchangeAdapter } from "../exchanges/index.js";
22
+ import { PerpError } from "../errors.js";
16
23
  export interface SplitOrderParams {
17
24
  symbol: string;
18
25
  side: "buy" | "sell";
19
26
  totalSizeUsd: number;
27
+ /** Venue lot step of the market (required: slices are sized in whole lots). */
28
+ stepSize: string;
29
+ /** Venue price tick; null = the venue publishes none (Hyperliquid) → the orderbook's price precision is used. */
30
+ tickSize: string | null;
31
+ /** Venue-published order minimums (null = none published). */
32
+ minSize: string | null;
33
+ minNotional: string | null;
20
34
  maxSlippagePct?: number;
21
35
  maxSlices?: number;
22
36
  delayMs?: number;
@@ -26,24 +40,38 @@ export interface SplitSlice {
26
40
  index: number;
27
41
  size: string;
28
42
  price: string;
29
- notionalUsd: number;
30
- slippagePct: number;
31
- method: "limit_ioc" | "market";
32
- status: "filled" | "partial" | "failed";
43
+ /** Verified filled base size; null = outcome unknown. */
44
+ filledSize: string | null;
45
+ /** Venue-reported average fill price; null = the venue did not report one. */
46
+ avgFillPrice: string | null;
47
+ method: "limit_ioc";
48
+ status: "filled" | "partial" | "rejected" | "unknown";
49
+ error: string | null;
33
50
  }
34
51
  export interface SplitOrderResult {
35
52
  symbol: string;
36
53
  side: "buy" | "sell";
37
54
  requestedUsd: number;
38
- filledUsd: number;
39
- filledSize: number;
40
- avgPrice: number;
41
- totalSlippagePct: number;
55
+ /** Base size the USD total was converted to (opening best price, floored to the lot). */
56
+ requestedSize: string;
57
+ filledSize: string;
58
+ /** Filled notional at the reported fill prices; null when a filled slice has no reported price. */
59
+ filledUsd: number | null;
60
+ /** Size-weighted average of the venue-reported fill prices; null when any filled slice has none. */
61
+ avgPrice: string | null;
62
+ totalSlippagePct: number | null;
42
63
  slices: SplitSlice[];
43
- status: "complete" | "partial" | "failed";
64
+ /** complete = the whole size filled; partial = some; failed = nothing filled (all definite); unknown = a slice's outcome is unknown. */
65
+ status: "complete" | "partial" | "failed" | "unknown";
44
66
  runtime: number;
45
67
  }
46
68
  /**
47
- * Execute a split order: orderbook-aware slicing with IOC limits.
69
+ * The error a split run that did not fill completely must surface (never ok:true): ORDER_PENDING
70
+ * when a slice's outcome is unknown, ORDER_REJECTED when nothing filled and every slice is
71
+ * definite, ORDER_PARTIAL when only part filled. null = complete.
72
+ */
73
+ export declare function splitIncompleteError(venue: string, r: SplitOrderResult): PerpError | null;
74
+ /**
75
+ * Execute a split order: lot-sized slices of the total, IOC limits, verified fills.
48
76
  */
49
77
  export declare function runSplitOrder(adapter: ExchangeAdapter, params: SplitOrderParams, log?: (msg: string) => void): Promise<SplitOrderResult>;
@@ -1,136 +1,202 @@
1
1
  /**
2
2
  * Orderbook-aware split order execution.
3
3
  *
4
- * Unlike TWAP (time-based splitting), this strategy reads the orderbook
5
- * before each slice and calculates optimal slice sizes based on available
6
- * depth. Each slice is placed as an IOC limit order at the worst acceptable
7
- * price within the slippage tolerance, ensuring no sweep beyond the target.
4
+ * The USD total is converted ONCE to a base size (at the opening best price, floored to the
5
+ * venue lot) and that size is split across the slices in whole lots. Every slice is an IOC
6
+ * limit at the worst acceptable price within the slippage tolerance (on the venue tick), and
7
+ * its fill is VERIFIED (placePerpVerified: venue-final IOC outcome or measured position delta)
8
+ * — a resolved order call is never counted as a fill.
9
+ *
10
+ * Before anything is sent the plan is checked against the venue minimums: a slice below the
11
+ * venue's minimum size / order value refuses the whole run (SIZE_TOO_SMALL, nothing sent).
8
12
  *
9
13
  * Flow per slice:
10
14
  * 1. Fetch fresh orderbook
11
- * 2. Walk levels via computeExecutableSize to find fillable amount
12
- * 3. Place IOC limit at best price + slippage tolerance
13
- * 4. Wait for book to refresh, then repeat
15
+ * 2. Slice = remaining size / slices left (whole lots; the last slice takes the rest)
16
+ * 3. Place IOC limit at best price ± slippage tolerance, verify the fill
17
+ * 4. Wait for the book to refresh, then repeat
18
+ * A slice with an unknown outcome stops the run (nothing is resent); a definite rejection
19
+ * (incl. an IOC that filled nothing) leaves its size to the following slices.
14
20
  */
15
21
  import { computeExecutableSize } from "../liquidity.js";
16
22
  import { logExecution } from "../execution-log.js";
17
- /**
18
- * Infer price precision (decimal places) from an orderbook price string.
19
- */
23
+ import { PerpError, extractErrorMessage, isDefiniteOrderFailure } from "../errors.js";
24
+ import { alignComputedLimitPrice } from "../bot/quote-rules.js";
25
+ import { alignProtectionPrice, floorToStep, formatComputedPrice, toUnitsExact, unitsToString } from "../exchanges/precision.js";
26
+ import { placePerpVerified } from "../bot/perp-order.js";
27
+ import { reportedAvgFillPrice } from "../exchanges/ioc-outcome.js";
28
+ /** Decimal places of an orderbook price string. */
20
29
  function pricePrecision(priceStr) {
21
30
  return priceStr.includes(".") ? priceStr.split(".")[1].length : 0;
22
31
  }
23
32
  /**
24
- * Execute a split order: orderbook-aware slicing with IOC limits.
33
+ * IOC limit for a slice: best ± slippage, aligned to the tick TOWARD the best price (never
34
+ * beyond the tolerance); without a tick, cut to the orderbook's own price precision.
35
+ */
36
+ function sliceLimitPrice(best, side, maxSlippagePct, tickSize) {
37
+ const raw = Number(best) * (side === "buy" ? 1 + maxSlippagePct / 100 : 1 - maxSlippagePct / 100);
38
+ if (tickSize === null)
39
+ return raw.toFixed(pricePrecision(best));
40
+ const aligned = alignProtectionPrice(raw.toFixed(12), tickSize, side === "buy" ? "sell" : "buy");
41
+ // A tolerance smaller than one tick aligns back onto / past the best price: the best price itself still crosses.
42
+ const crosses = side === "buy" ? Number(aligned) >= Number(best) : Number(aligned) <= Number(best);
43
+ return crosses ? aligned : best;
44
+ }
45
+ function belowMinimum(size, price, p) {
46
+ if (p.minSize !== null && Number(size) < Number(p.minSize))
47
+ return `size ${p.minSize}`;
48
+ if (p.minNotional !== null && Number(size) * price < Number(p.minNotional))
49
+ return `order value $${p.minNotional}`;
50
+ return null;
51
+ }
52
+ /**
53
+ * The error a split run that did not fill completely must surface (never ok:true): ORDER_PENDING
54
+ * when a slice's outcome is unknown, ORDER_REJECTED when nothing filled and every slice is
55
+ * definite, ORDER_PARTIAL when only part filled. null = complete.
56
+ */
57
+ export function splitIncompleteError(venue, r) {
58
+ if (r.status === "complete")
59
+ return null;
60
+ const what = `${venue} split ${r.side} $${r.requestedUsd} ${r.symbol}`;
61
+ const details = { exchange: venue, result: r };
62
+ const check = `perp account positions -e ${venue}`;
63
+ if (r.status === "unknown") {
64
+ return new PerpError("ORDER_PENDING", `${what}: the outcome of slice ${r.slices.findIndex(s => s.status === "unknown") + 1} is unknown; ${r.filledSize} of ${r.requestedSize} verified filled`, {
65
+ ...details, remediation: `Do NOT resubmit — the slice may have executed. Check the position: ${check}`,
66
+ });
67
+ }
68
+ if (r.status === "failed") {
69
+ const why = r.slices.map(s => s.error).filter(e => e !== null).join("; ");
70
+ return new PerpError("ORDER_REJECTED", `${what}: nothing filled${why !== "" ? ` — ${why}` : r.slices.length === 0 ? " — no slice was sent" : ""}`, details);
71
+ }
72
+ return new PerpError("ORDER_PARTIAL", `${what}: ${r.filledSize} of ${r.requestedSize} filled`, {
73
+ ...details, remediation: `The filled part is open. Check the position: ${check}, then send the remainder as a new order if still wanted.`,
74
+ });
75
+ }
76
+ /**
77
+ * Execute a split order: lot-sized slices of the total, IOC limits, verified fills.
25
78
  */
26
79
  export async function runSplitOrder(adapter, params, log = console.log) {
27
- const { symbol, side, totalSizeUsd, maxSlippagePct = 0.3, maxSlices = 10, delayMs = 1000, minSliceUsd = 100, } = params;
80
+ const { symbol, side, totalSizeUsd, stepSize, tickSize, maxSlippagePct = 0.3, maxSlices = 10, delayMs = 1000, minSliceUsd = 100, } = params;
28
81
  const startedAt = Date.now();
29
- const slices = [];
30
- let remainingUsd = totalSizeUsd;
31
- let totalFilledSize = 0;
32
- let totalFilledNotional = 0;
33
- // Capture initial best price for overall slippage calculation
34
- let initialBestPrice = 0;
35
- try {
36
- const initialBook = await adapter.getOrderbook(symbol);
37
- const initialLevels = side === "buy" ? initialBook.asks : initialBook.bids;
38
- if (initialLevels.length > 0) {
39
- initialBestPrice = Number(initialLevels[0][0]);
40
- }
82
+ if (!(totalSizeUsd > 0) || !(minSliceUsd > 0) || !Number.isInteger(maxSlices) || maxSlices < 1 || !(maxSlippagePct > 0)) {
83
+ throw new PerpError("INVALID_PARAMS", `split ${symbol}: usd, --min-slice and --max-slippage must be > 0 and --max-slices a whole number ≥ 1`);
84
+ }
85
+ // ── Plan (nothing is sent before every slice passes) ──
86
+ const openingBook = await adapter.getOrderbook(symbol);
87
+ const openingLevels = side === "buy" ? openingBook.asks : openingBook.bids;
88
+ if (openingLevels.length === 0) {
89
+ throw new PerpError("PRICE_STALE", `${adapter.name} ${symbol}: no ${side === "buy" ? "asks" : "bids"} in the orderbook — nothing was sent`, { exchange: adapter.name });
90
+ }
91
+ const initialBestPrice = Number(openingLevels[0][0]);
92
+ if (!(initialBestPrice > 0)) {
93
+ throw new PerpError("PRICE_STALE", `${adapter.name} ${symbol}: unusable best price ${openingLevels[0][0]} — nothing was sent`, { exchange: adapter.name });
94
+ }
95
+ const sliceCount = Math.min(maxSlices, Math.floor(totalSizeUsd / minSliceUsd));
96
+ if (sliceCount < 1) {
97
+ throw new PerpError("SIZE_TOO_SMALL", `split ${symbol}: $${totalSizeUsd} is below --min-slice $${minSliceUsd}; nothing was sent`, {
98
+ exchange: adapter.name, remediation: "Lower --min-slice or use a larger total",
99
+ });
41
100
  }
42
- catch {
43
- // Will be recaptured on first slice
101
+ const totalSize = floorToStep(totalSizeUsd / initialBestPrice, stepSize);
102
+ const totalUnits = toUnitsExact(totalSize, stepSize, "size");
103
+ const smallest = unitsToString(totalUnits / BigInt(sliceCount), stepSize);
104
+ const under = totalUnits / BigInt(sliceCount) <= 0n ? `lot ${stepSize}` : belowMinimum(smallest, initialBestPrice, params);
105
+ if (under !== null) {
106
+ throw new PerpError("SIZE_TOO_SMALL", `${adapter.name} ${symbol}: $${totalSizeUsd} in ${sliceCount} slice(s) gives a slice of ${smallest} (≈ $${(Number(smallest) * initialBestPrice).toFixed(2)} @ ${openingLevels[0][0]}), below the venue minimum ${under}; nothing was sent`, {
107
+ exchange: adapter.name, slices: sliceCount, sliceSize: smallest, stepSize, minSize: params.minSize, minNotional: params.minNotional,
108
+ remediation: "Use fewer slices (--max-slices) or a larger total so that every slice meets the venue minimum",
109
+ });
44
110
  }
45
- log(`[SPLIT] ${side.toUpperCase()} $${totalSizeUsd.toFixed(0)} of ${symbol} | max slippage: ${maxSlippagePct}% | max slices: ${maxSlices}`);
46
- for (let i = 0; i < maxSlices && remainingUsd >= minSliceUsd; i++) {
47
- // 1. Fetch fresh orderbook
48
- const book = await adapter.getOrderbook(symbol);
111
+ log(`[SPLIT] ${side.toUpperCase()} $${totalSizeUsd} of ${symbol} = ${totalSize} in ${sliceCount} slice(s) | max slippage: ${maxSlippagePct}%`);
112
+ const slices = [];
113
+ let remainingUnits = totalUnits;
114
+ let filledUnits = 0n;
115
+ let stopped = false;
116
+ for (let i = 0; i < sliceCount && remainingUnits > 0n && !stopped; i++) {
117
+ // 1. Fresh orderbook
118
+ const book = i === 0 ? openingBook : await adapter.getOrderbook(symbol);
49
119
  const levels = side === "buy" ? book.asks : book.bids;
50
120
  if (levels.length === 0) {
51
121
  log(`[SPLIT] No ${side === "buy" ? "asks" : "bids"} in orderbook, stopping`);
52
122
  break;
53
123
  }
54
- // Capture initial best if we missed it
55
- if (initialBestPrice === 0) {
56
- initialBestPrice = Number(levels[0][0]);
57
- }
58
- // 2. Calculate how much we can execute within slippage on this depth
59
- const check = computeExecutableSize(levels, remainingUsd, maxSlippagePct);
60
- if (check.maxSize <= 0) {
61
- log(`[SPLIT] Insufficient liquidity within ${maxSlippagePct}% slippage, stopping`);
124
+ const best = levels[0][0];
125
+ // 2. Slice: an even share of what remains, in whole lots (the last slice takes the rest)
126
+ const slicesLeft = BigInt(sliceCount - i);
127
+ const sliceUnits = slicesLeft === 1n ? remainingUnits : remainingUnits / slicesLeft;
128
+ const size = unitsToString(sliceUnits, stepSize);
129
+ const short = sliceUnits <= 0n ? `lot ${stepSize}` : belowMinimum(size, Number(best), params);
130
+ if (short !== null) {
131
+ log(`[SPLIT] Slice ${i + 1}: ${size} is below the venue minimum ${short}, stopping (not sent)`);
62
132
  break;
63
133
  }
64
- // Slice size: what we can fill within slippage, capped by remaining
65
- const sliceNotional = Math.min(check.maxSize * check.avgFillPrice, remainingUsd);
66
- const sliceSize = sliceNotional / check.avgFillPrice;
67
- // 3. Calculate IOC limit price (worst acceptable within slippage)
68
- const bestPrice = Number(levels[0][0]);
69
- const limitPrice = side === "buy"
70
- ? bestPrice * (1 + maxSlippagePct / 100)
71
- : bestPrice * (1 - maxSlippagePct / 100);
72
- const decimals = pricePrecision(levels[0][0]);
73
- const formattedPrice = limitPrice.toFixed(decimals);
74
- const formattedSize = sliceSize.toFixed(6);
75
- log(`[SPLIT] Slice ${i + 1}: ${formattedSize} @ limit $${formattedPrice} (~$${sliceNotional.toFixed(0)}) | depth: $${check.depthUsd.toFixed(0)}`);
76
- // 4. Execute IOC limit order
134
+ // 3. IOC limit at the worst acceptable price, fill verified
135
+ const raw = sliceLimitPrice(best, side, maxSlippagePct, tickSize);
136
+ // No published tick (HL): the venue grid, aligned toward the best price (never past the tolerance).
137
+ const price = tickSize === null ? await alignComputedLimitPrice(adapter, symbol, side === "buy" ? "buy" : "sell", raw) : raw;
138
+ const depth = computeExecutableSize(levels, Number(size) * Number(best), maxSlippagePct);
139
+ log(`[SPLIT] Slice ${i + 1}/${sliceCount}: ${size} @ limit $${price} | depth within ${maxSlippagePct}%: $${depth.depthUsd.toFixed(0)}`);
77
140
  let slice;
78
141
  try {
79
- await adapter.limitOrder(symbol, side, formattedPrice, formattedSize, { tif: "IOC" });
142
+ const fill = await placePerpVerified(adapter, adapter.name, symbol, side, size, {
143
+ stepSize,
144
+ place: () => adapter.limitOrder(symbol, side, price, size, { tif: "IOC" }),
145
+ });
146
+ const filled = floorToStep(fill.filledSize, stepSize);
147
+ const got = toUnitsExact(filled, stepSize, "filled size");
148
+ filledUnits += got;
149
+ remainingUnits -= got;
80
150
  slice = {
81
- index: i,
82
- size: formattedSize,
83
- price: formattedPrice,
84
- notionalUsd: sliceNotional,
85
- slippagePct: check.slippagePct,
86
- method: "limit_ioc",
87
- status: "filled",
151
+ index: i, size, price, filledSize: filled, avgFillPrice: reportedAvgFillPrice(fill.raw),
152
+ method: "limit_ioc", status: got >= sliceUnits ? "filled" : "partial", error: null,
88
153
  };
89
- totalFilledSize += sliceSize;
90
- totalFilledNotional += sliceNotional;
91
- remainingUsd -= sliceNotional;
92
- logExecution({
93
- type: "limit_order", exchange: adapter.name, symbol,
94
- side, size: formattedSize, price: formattedPrice,
95
- notional: sliceNotional, status: "success", dryRun: false,
96
- meta: { action: "split-order", slice: i + 1, totalSlices: maxSlices },
97
- });
98
154
  }
99
155
  catch (err) {
100
- log(`[SPLIT] Slice ${i + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
156
+ const definite = isDefiniteOrderFailure(err);
157
+ const msg = extractErrorMessage(err);
158
+ log(`[SPLIT] Slice ${i + 1} ${definite ? "refused" : "outcome UNKNOWN"}: ${msg}`);
101
159
  slice = {
102
- index: i, size: formattedSize, price: formattedPrice,
103
- notionalUsd: 0, slippagePct: 0, method: "limit_ioc", status: "failed",
160
+ index: i, size, price, filledSize: definite ? "0" : null, avgFillPrice: null,
161
+ method: "limit_ioc", status: definite ? "rejected" : "unknown", error: msg,
104
162
  };
105
- logExecution({
106
- type: "limit_order", exchange: adapter.name, symbol,
107
- side, size: formattedSize, price: formattedPrice,
108
- notional: 0, status: "failed", dryRun: false,
109
- error: err instanceof Error ? err.message : String(err),
110
- meta: { action: "split-order", slice: i + 1, totalSlices: maxSlices },
111
- });
163
+ // Unknown outcome: the slice may have executed — never send more on top of it.
164
+ if (!definite)
165
+ stopped = true;
112
166
  }
113
167
  slices.push(slice);
114
- // 5. Delay before next slice (let the book refresh)
115
- if (remainingUsd >= minSliceUsd && i < maxSlices - 1) {
168
+ logExecution({
169
+ type: "limit_order", exchange: adapter.name, symbol, side, size, price,
170
+ status: slice.status === "unknown" ? "unverified" : slice.status === "rejected" ? "failed" : "success",
171
+ dryRun: false, ...(slice.error !== null ? { error: slice.error } : {}),
172
+ meta: { action: "split-order", slice: i + 1, totalSlices: sliceCount, filledSize: slice.filledSize, avgFillPrice: slice.avgFillPrice },
173
+ });
174
+ // 4. Let the book refresh
175
+ if (!stopped && remainingUnits > 0n && i < sliceCount - 1) {
116
176
  await new Promise(r => setTimeout(r, delayMs));
117
177
  }
118
178
  }
119
- const avgPrice = totalFilledSize > 0 ? totalFilledNotional / totalFilledSize : 0;
120
- const totalSlippage = initialBestPrice > 0
121
- ? Math.abs((avgPrice - initialBestPrice) / initialBestPrice) * 100
122
- : 0;
123
- const status = totalFilledNotional >= totalSizeUsd * 0.95 ? "complete"
124
- : totalFilledNotional > 0 ? "partial"
125
- : "failed";
126
- log(`[SPLIT] Done: ${slices.length} slices | filled $${totalFilledNotional.toFixed(0)}/$${totalSizeUsd.toFixed(0)} | avg price: $${avgPrice.toFixed(2)} | slippage: ${totalSlippage.toFixed(3)}%`);
179
+ // Average = the venue-reported fill prices, size-weighted; unknown when any filled slice has none.
180
+ const filledSlices = slices.filter(s => s.filledSize !== null && Number(s.filledSize) > 0);
181
+ const priced = filledSlices.length > 0 && filledSlices.every(s => s.avgFillPrice !== null);
182
+ const filledSize = unitsToString(filledUnits, stepSize);
183
+ const filledUsd = priced ? filledSlices.reduce((sum, s) => sum + Number(s.filledSize) * Number(s.avgFillPrice), 0) : filledUnits === 0n ? 0 : null;
184
+ const avgNum = priced && filledUsd !== null ? filledUsd / Number(filledSize) : null;
185
+ const avgPrice = avgNum !== null ? formatComputedPrice(avgNum, tickSize) : null;
186
+ const totalSlippagePct = avgNum !== null ? Math.abs((avgNum - initialBestPrice) / initialBestPrice) * 100 : null;
187
+ const status = slices.some(s => s.status === "unknown") ? "unknown"
188
+ : filledUnits >= totalUnits ? "complete"
189
+ : filledUnits > 0n ? "partial"
190
+ : "failed";
191
+ log(`[SPLIT] Done: ${slices.length} slice(s) | filled ${filledSize}/${totalSize} | avg price: ${avgPrice !== null ? `$${avgPrice}` : "not reported"}`);
127
192
  return {
128
193
  symbol, side,
129
194
  requestedUsd: totalSizeUsd,
130
- filledUsd: totalFilledNotional,
131
- filledSize: totalFilledSize,
195
+ requestedSize: totalSize,
196
+ filledSize,
197
+ filledUsd,
132
198
  avgPrice,
133
- totalSlippagePct: totalSlippage,
199
+ totalSlippagePct,
134
200
  slices,
135
201
  status,
136
202
  runtime: (Date.now() - startedAt) / 1000,
@@ -7,7 +7,8 @@ export interface TrailingStopParams {
7
7
  }
8
8
  export interface TrailingStopResult {
9
9
  triggered: boolean;
10
- reason: "triggered" | "cancelled" | "no_position";
10
+ /** position_gone = the position was closed / flipped before the trigger fired (nothing sent). */
11
+ reason: "triggered" | "cancelled" | "no_position" | "position_gone";
11
12
  peakPrice?: number;
12
13
  triggerPrice?: number;
13
14
  changePct?: number;
@@ -1,10 +1,17 @@
1
+ import { assertCanClosePositions } from "../exchanges/trading-v2.js";
1
2
  import { updateJobState } from "../jobs.js";
3
+ import { invalidateCache } from "../cache.js";
2
4
  import { logExecution } from "../execution-log.js";
5
+ import { PerpError } from "../errors.js";
6
+ import { closePerpVerified } from "../bot/perp-order.js";
7
+ import { isPositionGone } from "../bot/strategies/funding-arb-utils.js";
3
8
  export async function runTrailingStop(adapter, params, jobId, log = console.log) {
4
9
  const { symbol, trailPct } = params;
5
10
  const intervalMs = (params.intervalSec ?? 5) * 1000;
6
11
  const activationPrice = params.activationPrice;
7
12
  const startedAt = Date.now();
13
+ // Unattended: the close fires later, with nobody watching — refused up front on a gated venue.
14
+ assertCanClosePositions(adapter, `Trailing stop on ${adapter.name}`);
8
15
  // Auto-detect position side
9
16
  const positions = await adapter.getPositions();
10
17
  const pos = positions.find(p => {
@@ -19,6 +26,39 @@ export async function runTrailingStop(adapter, params, jobId, log = console.log)
19
26
  const positionSide = pos.side;
20
27
  const closeSide = positionSide === "long" ? "sell" : "buy";
21
28
  const posSize = pos.size;
29
+ /**
30
+ * At trigger time: re-read the position (it may have shrunk, grown, flipped or closed since
31
+ * start) and close exactly the live size, reduce-only and verified (closePerpVerified: one
32
+ * residual close). Returns the size closed, or null when the position is gone (nothing sent /
33
+ * the venue rejected a reduce-only close of a flat position). An unknown outcome (ORDER_PENDING)
34
+ * or a residual propagates as an error — never resubmitted.
35
+ */
36
+ const closeLive = async () => {
37
+ invalidateCache("acct"); // size the reduce-only close from a fresh read, never a cached position
38
+ const live = (await adapter.getPositions()).find(p => {
39
+ const c = p.symbol.toUpperCase();
40
+ const t = symbol.toUpperCase();
41
+ return (c === t || c === `${t}-PERP` || c.replace(/-PERP$/, "") === t) && p.side === positionSide;
42
+ });
43
+ const liveSize = live === undefined ? null : live.size.replace(/^-/, "");
44
+ if (liveSize === null || !(Number(liveSize) > 0))
45
+ return null;
46
+ log(`[TRAIL] Closing ${positionSide} ${liveSize} ${symbol} (live size, reduce-only)...`);
47
+ try {
48
+ const c = await closePerpVerified(adapter, adapter.name, symbol, closeSide, liveSize);
49
+ if (c.residual > 0) {
50
+ throw new PerpError("ORDER_PARTIAL", `trailing stop closed ${c.closed} of ${liveSize} ${symbol}; ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""}`, {
51
+ exchange: adapter.name, remediation: `Do NOT rerun blindly — check the ${symbol} position on ${adapter.name} and close the rest by hand`,
52
+ });
53
+ }
54
+ }
55
+ catch (e) {
56
+ if (isPositionGone(e))
57
+ return null;
58
+ throw e;
59
+ }
60
+ return liveSize;
61
+ };
22
62
  log(`[TRAIL] ${symbol} ${positionSide} ${posSize} | Trail: ${trailPct}%${activationPrice ? ` | Activation: $${activationPrice}` : ""}`);
23
63
  let peakPrice = 0;
24
64
  let activated = !activationPrice;
@@ -69,11 +109,14 @@ export async function runTrailingStop(adapter, params, jobId, log = console.log)
69
109
  log(`[TRAIL] $${currentPrice.toFixed(2)} | Peak: $${peakPrice.toFixed(2)} | Drop: ${dropPct.toFixed(2)}%`);
70
110
  if (dropPct >= trailPct) {
71
111
  log(`[TRAIL] TRIGGERED! Price dropped ${dropPct.toFixed(2)}% from peak $${peakPrice.toFixed(2)}`);
72
- log(`[TRAIL] Closing ${positionSide} ${posSize} ${symbol}...`);
73
- await adapter.marketOrder(symbol, closeSide, posSize);
112
+ const closedSize = await closeLive();
113
+ if (closedSize === null) {
114
+ log(`[TRAIL] ${symbol} ${positionSide} position is gone — nothing to close. Exiting.`);
115
+ return { triggered: false, reason: "position_gone", peakPrice, positionSide, runtime: (Date.now() - startedAt) / 1000 };
116
+ }
74
117
  logExecution({
75
118
  type: "market_order", exchange: adapter.name, symbol,
76
- side: closeSide, size: posSize, status: "success", dryRun: false,
119
+ side: closeSide, size: closedSize, status: "success", dryRun: false,
77
120
  meta: { action: "trailing-stop", trailPct, peakPrice, triggerPrice: currentPrice },
78
121
  });
79
122
  return {
@@ -91,11 +134,14 @@ export async function runTrailingStop(adapter, params, jobId, log = console.log)
91
134
  log(`[TRAIL] $${currentPrice.toFixed(2)} | Trough: $${peakPrice.toFixed(2)} | Rise: ${risePct.toFixed(2)}%`);
92
135
  if (risePct >= trailPct) {
93
136
  log(`[TRAIL] TRIGGERED! Price rose ${risePct.toFixed(2)}% from trough $${peakPrice.toFixed(2)}`);
94
- log(`[TRAIL] Closing ${positionSide} ${posSize} ${symbol}...`);
95
- await adapter.marketOrder(symbol, closeSide, posSize);
137
+ const closedSize = await closeLive();
138
+ if (closedSize === null) {
139
+ log(`[TRAIL] ${symbol} ${positionSide} position is gone — nothing to close. Exiting.`);
140
+ return { triggered: false, reason: "position_gone", peakPrice, positionSide, runtime: (Date.now() - startedAt) / 1000 };
141
+ }
96
142
  logExecution({
97
143
  type: "market_order", exchange: adapter.name, symbol,
98
- side: closeSide, size: posSize, status: "success", dryRun: false,
144
+ side: closeSide, size: closedSize, status: "success", dryRun: false,
99
145
  meta: { action: "trailing-stop", trailPct, troughPrice: peakPrice, triggerPrice: currentPrice },
100
146
  });
101
147
  return {
@@ -6,13 +6,18 @@ export interface TWAPParams {
6
6
  durationSec: number;
7
7
  slices?: number;
8
8
  maxSlippage?: number;
9
+ reduceOnly?: boolean;
9
10
  }
10
11
  export interface TWAPState {
11
12
  filled: number;
12
13
  remaining: number;
13
14
  slicesDone: number;
14
15
  totalSlices: number;
15
- avgPrice: number;
16
+ /**
17
+ * Size-weighted average of the venue-reported FILL prices of the slices; null while any
18
+ * filled slice has no reported fill price (never the limit / slippage-protection price).
19
+ */
20
+ avgPrice: number | null;
16
21
  errors: number;
17
22
  startedAt: number;
18
23
  lastSliceAt: number;
@@ -1,5 +1,13 @@
1
+ import { assertCanClosePositions } from "../exchanges/trading-v2.js";
2
+ import { resolveAdapterSlippage } from "../exchanges/slippage.js";
1
3
  import { updateJobState } from "../jobs.js";
4
+ import { reportedAvgFillPrice } from "../exchanges/ioc-outcome.js";
5
+ import { filledOf } from "../bot/fill-size.js";
6
+ import { isOutcomeUnknown } from "../bot/perp-order.js";
7
+ import { pendingStop } from "./pending-stop.js";
8
+ import { formatComputedPrice } from "../exchanges/precision.js";
2
9
  export async function runTWAP(adapter, params, jobId, log = console.log) {
10
+ assertCanClosePositions(adapter, `TWAP on ${adapter.name}`);
3
11
  const totalSlices = params.slices || Math.max(Math.floor(params.durationSec / 30), 2);
4
12
  const sliceSize = params.totalSize / totalSlices;
5
13
  const intervalMs = (params.durationSec * 1000) / totalSlices;
@@ -8,7 +16,7 @@ export async function runTWAP(adapter, params, jobId, log = console.log) {
8
16
  remaining: params.totalSize,
9
17
  slicesDone: 0,
10
18
  totalSlices,
11
- avgPrice: 0,
19
+ avgPrice: null,
12
20
  errors: 0,
13
21
  startedAt: Date.now(),
14
22
  lastSliceAt: 0,
@@ -17,6 +25,9 @@ export async function runTWAP(adapter, params, jobId, log = console.log) {
17
25
  log(`[TWAP] ${totalSlices} slices, ${sliceSize.toFixed(6)} per slice, ${(intervalMs / 1000).toFixed(1)}s interval`);
18
26
  log(`[TWAP] Exchange: ${adapter.name}`);
19
27
  let totalCost = 0;
28
+ let everySlicePriced = true;
29
+ /** Set when a slice's outcome / fill is unknown: the run stops and surfaces ORDER_PENDING. */
30
+ let pending = null;
20
31
  for (let i = 0; i < totalSlices; i++) {
21
32
  if (i > 0) {
22
33
  await sleep(intervalMs);
@@ -28,19 +39,26 @@ export async function runTWAP(adapter, params, jobId, log = console.log) {
28
39
  break;
29
40
  try {
30
41
  log(`[TWAP] Slice ${i + 1}/${totalSlices}: ${params.side} ${thisSlice.toFixed(6)} ${params.symbol}...`);
31
- const result = await adapter.marketOrder(params.symbol, params.side, String(thisSlice));
42
+ const result = await adapter.marketOrder(params.symbol, params.side, String(thisSlice), { reduceOnly: params.reduceOnly === true, slippage: resolveAdapterSlippage(adapter, null) });
43
+ // The venue-reported fill (IOC outcome / OrderResult.filledSize) — never the slice requested.
44
+ const sliceFilled = filledOf(adapter, result);
45
+ if (sliceFilled === null) {
46
+ pending = pendingStop("twap", adapter, params.symbol, `TWAP slice ${i + 1}/${totalSlices} ${params.side} ${thisSlice.toFixed(6)} ${params.symbol} on ${adapter.name}: accepted but the venue reported no fill size`, log);
47
+ break;
48
+ }
32
49
  state.slicesDone++;
33
- state.filled += thisSlice;
50
+ state.filled += sliceFilled;
34
51
  state.remaining = params.totalSize - state.filled;
35
52
  state.lastSliceAt = Date.now();
36
- // Try to extract fill price from result
37
- const fillPrice = Number(result?.price ?? result?.avg_price ?? result?.fill_price ?? 0);
38
- if (fillPrice > 0) {
39
- totalCost += thisSlice * fillPrice;
40
- state.avgPrice = totalCost / state.filled;
41
- }
53
+ // The venue-reported fill price only: the result's `price` is the protection price that was sent.
54
+ const fillPrice = reportedAvgFillPrice(result);
55
+ if (fillPrice === null)
56
+ everySlicePriced = false;
57
+ else
58
+ totalCost += sliceFilled * Number(fillPrice);
59
+ state.avgPrice = everySlicePriced && state.filled > 0 ? Number(formatComputedPrice(totalCost / state.filled)) : null;
42
60
  const pct = ((state.filled / params.totalSize) * 100).toFixed(1);
43
- log(`[TWAP] Filled ${state.filled.toFixed(6)}/${params.totalSize} (${pct}%)${state.avgPrice > 0 ? ` avg $${state.avgPrice.toFixed(4)}` : ""}`);
61
+ log(`[TWAP] Filled ${state.filled.toFixed(6)}/${params.totalSize} (${pct}%)${state.avgPrice !== null ? ` avg $${state.avgPrice}` : ""}`);
44
62
  // Update job state file if running as background job
45
63
  if (jobId) {
46
64
  updateJobState(jobId, {
@@ -56,8 +74,13 @@ export async function runTWAP(adapter, params, jobId, log = console.log) {
56
74
  }
57
75
  }
58
76
  catch (err) {
59
- state.errors++;
60
77
  const msg = err instanceof Error ? err.message : String(err);
78
+ if (isOutcomeUnknown(err)) {
79
+ // Unknown outcome: never resubmitted — the run stops and the symbol is quarantined.
80
+ pending = pendingStop("twap", adapter, params.symbol, `TWAP slice ${i + 1}/${totalSlices} ${params.side} ${thisSlice.toFixed(6)} ${params.symbol} on ${adapter.name}: outcome unknown (${msg})`, log);
81
+ break;
82
+ }
83
+ state.errors++;
61
84
  log(`[TWAP] Slice ${i + 1} error: ${msg}`);
62
85
  // Continue — don't abort the whole TWAP for one failed slice
63
86
  if (state.errors > totalSlices * 0.5) {
@@ -68,6 +91,11 @@ export async function runTWAP(adapter, params, jobId, log = console.log) {
68
91
  }
69
92
  const elapsed = ((Date.now() - state.startedAt) / 1000).toFixed(1);
70
93
  log(`[TWAP] Complete. Filled ${state.filled.toFixed(6)} in ${elapsed}s, ${state.errors} errors.`);
94
+ if (pending !== null) {
95
+ if (jobId)
96
+ updateJobState(jobId, { status: "error", result: { ...state, error: pending.message } });
97
+ throw pending;
98
+ }
71
99
  if (jobId) {
72
100
  updateJobState(jobId, { status: "done", result: { ...state } });
73
101
  }