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,10 +1,20 @@
1
+ import { assertCanClosePositions } from "../exchanges/trading-v2.js";
2
+ import { MAX_PRICE_DEVIATION_PCT } from "../exchanges/index.js";
1
3
  import { symbolMatch, logSettledRejections } from "../utils.js";
4
+ import { logExecution } from "../execution-log.js";
2
5
  import { updateJobState } from "../jobs.js";
3
- import { fetchAllBalances, computeRebalancePlan, hasEnoughBalance } from "../rebalance.js";
6
+ import { invalidateCache } from "../cache.js";
7
+ import { fetchAllBalances, computeRebalancePlan, hasEnoughBalance, marketLeverageOf, requiredArbMargin } from "../rebalance.js";
4
8
  import { checkArbLiquidity } from "../liquidity.js";
5
9
  import { computeAnnualSpread } from "../funding.js";
6
10
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../shared-api.js";
7
- import { computeMatchedSize, reconcileArbFills } from "../arb-sizing.js";
11
+ import { computeMatchedSize } from "../arb-sizing.js";
12
+ import { closePerpPairVerified, openPerpPairVerified } from "../commands/arb-auto.js";
13
+ import { closePerpVerified, isOutcomeUnknown } from "../bot/perp-order.js";
14
+ import { isQuarantined, quarantineSymbol } from "../bot/quarantine.js";
15
+ import { isPositionGone, matchSymbol } from "../bot/strategies/funding-arb-utils.js";
16
+ /** Quarantine source for `perp strategy funding-arb` (src/bot/quarantine.ts). */
17
+ const SOURCE = "funding-arb-cli";
8
18
  async function fetchAllRates() {
9
19
  // SSOT rule #2: each DEX is independent. allSettled keeps the comparison
10
20
  // alive when one DEX errors out, while rejection reasons get logged to
@@ -56,7 +66,19 @@ async function fetchAllRates() {
56
66
  ];
57
67
  }
58
68
  // annualize: use computeAnnualSpread from funding.ts for cross-exchange comparison
69
+ /**
70
+ * `perp strategy funding-arb` loop. With autoExecute (design §12.3):
71
+ * - entry = openPerpPairVerified (long, then the short sized to the long's VERIFIED fill;
72
+ * definite short failure → reduce-only rollback; unknown outcome → quarantine). A lot-size
73
+ * rejection is never resubmitted blindly: the long is re-sized only when it was definitely
74
+ * not placed; a rejected short is placed once at the lot-floored verified long fill;
75
+ * - close / max-drawdown close = closePerpPairVerified with the venue position size strings
76
+ * (reduce-only, verified, one residual close, quarantine on unknown / unhedged);
77
+ * - quarantined symbols (src/bot/quarantine.ts) are never entered or closed.
78
+ */
59
79
  export async function runFundingArb(adapters, params, jobId, log = console.log) {
80
+ for (const a of adapters.values())
81
+ assertCanClosePositions(a, `Funding arb on ${a.name}`);
60
82
  const maxPos = params.maxPositions ?? 3;
61
83
  const closeSpread = params.closeSpread ?? 5;
62
84
  const positions = [];
@@ -105,28 +127,19 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
105
127
  if (currentSpread <= closeSpread) {
106
128
  log(`[ARB] CLOSE signal: ${pos.symbol} spread ${currentSpread.toFixed(1)}% <= ${closeSpread}%`);
107
129
  if (params.autoExecute) {
108
- const longAdapter = adapters.get(pos.longExchange);
109
- const shortAdapter = adapters.get(pos.shortExchange);
110
- if (longAdapter && shortAdapter) {
111
- try {
112
- await Promise.all([
113
- longAdapter.marketOrder(pos.symbol, "sell", pos.size),
114
- shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
115
- ]);
116
- log(`[ARB] CLOSED ${pos.symbol} — both legs unwound`);
117
- // Estimate P&L from funding collected
118
- const hoursOpen = (Date.now() - pos.openedAt) / (1000 * 60 * 60);
119
- // entrySpread is annualized %; convert to per-hour rate
120
- const hourlySpreadRate = pos.entrySpread / 100 / (24 * 365);
121
- const estimatedFunding = hourlySpreadRate * hoursOpen * Number(pos.size) * pos.entryPrices.long;
122
- log(`[ARB] Est. funding P&L: ~$${estimatedFunding.toFixed(2)} (${hoursOpen.toFixed(1)}h open)`);
123
- }
124
- catch (err) {
125
- const msg = err instanceof Error ? err.message : String(err);
126
- log(`[ARB] Close error: ${msg}`);
127
- continue; // don't remove position if close failed
128
- }
130
+ if (isQuarantined(pos.symbol, log)) {
131
+ log(`[ARB] ${pos.symbol} is quarantined — not closing (perp strategy quarantine)`);
132
+ continue;
129
133
  }
134
+ const closed = await closeArbPosition(pos, adapters, log);
135
+ if (!closed)
136
+ continue; // still open (failed) or quarantined — keep tracking
137
+ // Estimate P&L from funding collected
138
+ const hoursOpen = (Date.now() - pos.openedAt) / (1000 * 60 * 60);
139
+ // entrySpread is annualized %; convert to per-hour rate
140
+ const hourlySpreadRate = pos.entrySpread / 100 / (24 * 365);
141
+ const estimatedFunding = hourlySpreadRate * hoursOpen * Number(pos.size) * pos.entryPrices.long;
142
+ log(`[ARB] Est. funding P&L: ~$${estimatedFunding.toFixed(2)} (${hoursOpen.toFixed(1)}h open)`);
130
143
  }
131
144
  positions.splice(i, 1);
132
145
  }
@@ -139,11 +152,14 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
139
152
  if (totalPnl < -params.maxDrawdown) {
140
153
  log(`[ARB] MAX DRAWDOWN hit: uPnL $${totalPnl.toFixed(2)} < -$${params.maxDrawdown}`);
141
154
  log(`[ARB] Closing all ${positions.length} positions...`);
142
- await closeAllPositions(positions, adapters, log);
155
+ const stillOpen = await closeAllPositions(positions, adapters, log);
143
156
  positions.length = 0;
157
+ positions.push(...stillOpen);
144
158
  }
145
159
  }
146
- catch { /* non-critical */ }
160
+ catch (err) {
161
+ log(`[ARB] Drawdown check failed: ${err instanceof Error ? err.message : String(err)}`);
162
+ }
147
163
  }
148
164
  // ── Phase 3: Balance check + rebalance trigger ──
149
165
  let balanceSnapshots = null;
@@ -178,6 +194,8 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
178
194
  continue;
179
195
  if (positions.length >= maxPos)
180
196
  break;
197
+ if (params.autoExecute && isQuarantined(symbol, log))
198
+ continue;
181
199
  // Only consider exchanges we have adapters for
182
200
  const available = symbolRates.filter((r) => adapters.has(r.exchange));
183
201
  if (available.length < 2)
@@ -187,6 +205,13 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
187
205
  const highest = available[available.length - 1];
188
206
  if (lowest.exchange === highest.exchange)
189
207
  continue;
208
+ // Same ticker on two venues is not proof of the same asset: the chosen pair must agree on price.
209
+ const pairGapPct = Math.abs(lowest.markPrice - highest.markPrice) / ((lowest.markPrice + highest.markPrice) / 2) * 100;
210
+ if (!(pairGapPct <= MAX_PRICE_DEVIATION_PCT)) {
211
+ if (cycleCount === 1)
212
+ log(`[ARB] Skip ${symbol} ${lowest.exchange}↔${highest.exchange}: mark gap ${pairGapPct.toFixed(1)}% > ${MAX_PRICE_DEVIATION_PCT}% (likely a different asset)`);
213
+ continue;
214
+ }
190
215
  const annualSpread = computeAnnualSpread(highest.fundingRate, highest.exchange, lowest.fundingRate, lowest.exchange);
191
216
  if (annualSpread >= params.minSpread) {
192
217
  const longEx = lowest.exchange;
@@ -212,64 +237,69 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
212
237
  }
213
238
  // Check balances before trading
214
239
  if (balanceSnapshots) {
215
- if (!hasEnoughBalance(balanceSnapshots, longEx, matched.notional) ||
216
- !hasEnoughBalance(balanceSnapshots, shortEx, matched.notional)) {
217
- log(`[ARB] Skip ${symbol}: insufficient balance on ${longEx} or ${shortEx} (need ~$${matched.notional.toFixed(0)} per leg)`);
218
- continue;
219
- }
220
- }
221
- try {
222
- log(`[ARB] Opening: ${matched.size} ${symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)...`);
240
+ // Margin at the leverage each venue applies to the market (unknown → 1x, see requiredArbMargin).
241
+ let longLev;
242
+ let shortLev;
223
243
  try {
224
- await Promise.all([
225
- longAdapter.marketOrder(symbol, "buy", matched.size),
226
- shortAdapter.marketOrder(symbol, "sell", matched.size),
227
- ]);
244
+ [longLev, shortLev] = await Promise.all([marketLeverageOf(longAdapter, symbol), marketLeverageOf(shortAdapter, symbol)]);
228
245
  }
229
- catch (orderErr) {
230
- const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
231
- const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
232
- if (lotMatch) {
233
- const lotSize = parseFloat(lotMatch[1]);
234
- log(`[ARB] Lot size ${lotSize} detected, recalculating...`);
235
- matched = computeMatchedSize(liq.adjustedSizeUsd, highest.markPrice, longEx, shortEx, { lotSize });
236
- if (!matched) {
237
- log(`[ARB] Skip ${symbol}: can't meet lot size ${lotSize}`);
238
- continue;
239
- }
240
- log(`[ARB] Retry: ${matched.size} ${symbol} ($${matched.notional.toFixed(0)}/leg)`);
241
- await Promise.all([
242
- longAdapter.marketOrder(symbol, "buy", matched.size),
243
- shortAdapter.marketOrder(symbol, "sell", matched.size),
244
- ]);
245
- }
246
- else {
247
- throw orderErr;
248
- }
246
+ catch (e) {
247
+ log(`[ARB] Skip ${symbol}: could not read the market leverage (${e instanceof Error ? e.message : String(e)})`);
248
+ continue;
249
249
  }
250
- // Verify fills match, correct if needed
251
- try {
252
- const recon = await reconcileArbFills(longAdapter, shortAdapter, symbol, log);
253
- if (!recon.matched) {
254
- log(`[ARB] WARNING: fills not matched after correction attempt`);
255
- }
250
+ if (!hasEnoughBalance(balanceSnapshots, longEx, matched.notional, longLev) ||
251
+ !hasEnoughBalance(balanceSnapshots, shortEx, matched.notional, shortLev)) {
252
+ const need = (ex, lev) => `${ex} ~$${requiredArbMargin(matched.notional, lev).toFixed(2)} (${lev !== null ? `${lev}x` : "leverage unknown → 1x"})`;
253
+ log(`[ARB] Skip ${symbol}: insufficient available balance for $${matched.notional.toFixed(2)}/leg — need ${need(longEx, longLev)}, ${need(shortEx, shortLev)}`);
254
+ continue;
256
255
  }
257
- catch { /* non-critical */ }
258
- positions.push({
259
- symbol,
260
- longExchange: longEx,
261
- shortExchange: shortEx,
262
- size: matched.size,
263
- entrySpread: annualSpread,
264
- openedAt: Date.now(),
265
- entryPrices: { long: lowest.markPrice, short: highest.markPrice },
266
- });
267
- log(`[ARB] OPENED ${symbol} delta-neutral (${positions.length}/${maxPos})`);
268
256
  }
269
- catch (err) {
270
- const msg = err instanceof Error ? err.message : String(err);
271
- log(`[ARB] Entry error: ${msg}`);
257
+ log(`[ARB] Opening: ${matched.size} ${symbol} — long ${longEx} then short ${shortEx}, each verified ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)...`);
258
+ const openPair = (size) => openPerpPairVerified({
259
+ source: SOURCE, sym: symbol,
260
+ longAdapter, longExch: longEx, longSym: symbol,
261
+ shortAdapter, shortExch: shortEx, shortSym: symbol,
262
+ size, smart: false, log, shortLotRetry: true,
263
+ });
264
+ let res = await openPair(matched.size);
265
+ if (res.status === "not_opened" && res.rolledBack === null && res.lotSize !== null) {
266
+ // The LONG was definitely rejected for lot size (nothing placed): recompute once.
267
+ const lotSize = res.lotSize;
268
+ log(`[ARB] Lot size ${lotSize} on ${longEx} — recalculating (nothing was placed)`);
269
+ matched = computeMatchedSize(liq.adjustedSizeUsd, highest.markPrice, longEx, shortEx, { lotSize });
270
+ if (!matched) {
271
+ log(`[ARB] Skip ${symbol}: can't meet lot size ${lotSize}`);
272
+ continue;
273
+ }
274
+ log(`[ARB] Retry: ${matched.size} ${symbol} ($${matched.notional.toFixed(0)}/leg)`);
275
+ res = await openPair(matched.size);
276
+ }
277
+ if (res.status === "quarantined") {
278
+ log(`[ARB] Entry ${symbol} QUARANTINED: ${res.reason} — exposure: ${res.exposure}`);
279
+ continue;
280
+ }
281
+ if (res.status === "not_opened") {
282
+ log(`[ARB] Entry error ${symbol}: ${res.reason}${res.rolledBack !== null ? ` (long ${res.rolledBack} rolled back)` : ""}`);
283
+ continue;
272
284
  }
285
+ if (res.trimmed !== "0")
286
+ log(`[ARB] Short filled ${res.hedged} — trimmed the long by ${res.trimmed} (reduce-only)`);
287
+ positions.push({
288
+ symbol,
289
+ longExchange: longEx,
290
+ shortExchange: shortEx,
291
+ size: res.hedged,
292
+ entrySpread: annualSpread,
293
+ openedAt: Date.now(),
294
+ entryPrices: { long: lowest.markPrice, short: highest.markPrice },
295
+ });
296
+ // Entry record: `arb status` / `arb close` confirm this pair from it (arbPairProvenance).
297
+ logExecution({
298
+ type: "arb_entry", exchange: `${longEx}+${shortEx}`, symbol, side: "entry", size: res.hedged,
299
+ status: "success", dryRun: false,
300
+ meta: { arbPairId: `${symbol.toUpperCase()}:${longEx}:${shortEx}`, longExchange: longEx, shortExchange: shortEx, spread: annualSpread, source: SOURCE },
301
+ });
302
+ log(`[ARB] OPENED ${symbol} delta-neutral ${res.hedged}/leg (${positions.length}/${maxPos})`);
273
303
  }
274
304
  }
275
305
  }
@@ -314,23 +344,106 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
314
344
  }
315
345
  log(`[ARB] Funding rate arbitrage stopped. Positions: ${positions.length}`);
316
346
  }
347
+ /** Venue position size string for one leg (abs, as the venue reports it), or null when flat. Read errors propagate. */
348
+ async function venueLegSize(adapter, symbol, side) {
349
+ invalidateCache("acct"); // the reduce-only close is sized from this read: never a cached position
350
+ const p = (await adapter.getPositions()).find(x => matchSymbol(x.symbol, symbol) && x.side === side);
351
+ if (p === undefined)
352
+ return null;
353
+ const abs = p.size.replace(/^-/, "");
354
+ return Number(abs) > 0 ? abs : null;
355
+ }
356
+ /**
357
+ * Close one tracked pair: reduce-only, verified, legs sequential (closePerpPairVerified), each
358
+ * sized from the venue's own position size string, capped at the tracked size (never rounded
359
+ * up, never another position's size). A leg already gone → the other leg alone (reduce-only,
360
+ * verified, one residual close). Unknown outcome / unhedged residual → quarantined. Returns
361
+ * true only when this pair is flat.
362
+ */
363
+ async function closeArbPosition(pos, adapters, log) {
364
+ const longAdapter = adapters.get(pos.longExchange);
365
+ const shortAdapter = adapters.get(pos.shortExchange);
366
+ if (!longAdapter || !shortAdapter) {
367
+ log(`[ARB] Close ${pos.symbol}: adapter missing (${pos.longExchange}/${pos.shortExchange}) — left open`);
368
+ return false;
369
+ }
370
+ const cap = (venue) => (venue === null ? null : Number(venue) > Number(pos.size) ? pos.size : venue);
371
+ let longSize;
372
+ let shortSize;
373
+ try {
374
+ [longSize, shortSize] = await Promise.all([
375
+ venueLegSize(longAdapter, pos.symbol, "long").then(cap),
376
+ venueLegSize(shortAdapter, pos.symbol, "short").then(cap),
377
+ ]);
378
+ }
379
+ catch (err) {
380
+ log(`[ARB] Close ${pos.symbol}: position read failed (${err instanceof Error ? err.message : String(err)}) — nothing sent, retry next cycle`);
381
+ return false;
382
+ }
383
+ if (longSize === null && shortSize === null) {
384
+ log(`[ARB] ${pos.symbol}: both legs already flat — removing from tracking`);
385
+ return true;
386
+ }
387
+ if (longSize !== null && shortSize !== null) {
388
+ const res = await closePerpPairVerified({
389
+ source: SOURCE, sym: pos.symbol,
390
+ longAdapter, longExch: pos.longExchange, longSym: pos.symbol, longSize,
391
+ shortAdapter, shortExch: pos.shortExchange, shortSym: pos.symbol, shortSize,
392
+ smart: false, log,
393
+ });
394
+ if (res.status === "closed") {
395
+ logExecution({
396
+ type: "arb_close", exchange: `${pos.longExchange}+${pos.shortExchange}`, symbol: pos.symbol, side: "close", size: pos.size,
397
+ status: "success", dryRun: false,
398
+ meta: { arbPairId: `${pos.symbol.toUpperCase()}:${pos.longExchange}:${pos.shortExchange}`, longExchange: pos.longExchange, shortExchange: pos.shortExchange, source: SOURCE },
399
+ });
400
+ log(`[ARB] CLOSED ${pos.symbol} — both legs unwound (long ${longSize}, short ${shortSize})`);
401
+ return true;
402
+ }
403
+ if (res.status === "failed") {
404
+ log(`[ARB] Close error ${pos.symbol}: ${res.reason} — hedge intact`);
405
+ return false;
406
+ }
407
+ log(`[ARB] Close ${pos.symbol} QUARANTINED: ${res.reason}`);
408
+ return false;
409
+ }
410
+ // One leg is gone (liquidated / closed elsewhere): close the orphan leg alone.
411
+ const [adapter, venue, side, size, leg] = longSize !== null
412
+ ? [longAdapter, pos.longExchange, "sell", longSize, "long"]
413
+ : [shortAdapter, pos.shortExchange, "buy", shortSize, "short"];
414
+ log(`[ARB] ${pos.symbol}: the other leg is gone — closing the orphaned ${leg} ${size} on ${venue} (reduce-only)`);
415
+ const q = (reason) => {
416
+ quarantineSymbol(SOURCE, pos.symbol, reason, `Check ${pos.symbol} on ${pos.longExchange} and ${pos.shortExchange}; close by hand, then: perp strategy quarantine-clear ${pos.symbol}`, log);
417
+ return false;
418
+ };
419
+ try {
420
+ const c = await closePerpVerified(adapter, venue, pos.symbol, side, size);
421
+ if (c.residual > 0)
422
+ return q(`orphaned ${leg} close on ${venue} closed ${c.closed} of ${size}, ${c.residual} still open${c.note !== null ? ` (${c.note})` : ""}`);
423
+ }
424
+ catch (err) {
425
+ if (!isOutcomeUnknown(err) && isPositionGone(err))
426
+ return true;
427
+ return q(`orphaned ${leg} ${size} on ${venue}: close ${isOutcomeUnknown(err) ? "outcome unknown" : "failed"} (${err instanceof Error ? err.message : String(err)}) — unhedged`);
428
+ }
429
+ log(`[ARB] Closed orphaned ${leg} ${size} ${pos.symbol} on ${venue}`);
430
+ return true;
431
+ }
432
+ /** Emergency close of every tracked pair (verified, reduce-only). Returns the pairs still open. */
317
433
  async function closeAllPositions(positions, adapters, log) {
434
+ const stillOpen = [];
318
435
  for (const pos of positions) {
319
- const longAdapter = adapters.get(pos.longExchange);
320
- const shortAdapter = adapters.get(pos.shortExchange);
321
- if (!longAdapter || !shortAdapter)
436
+ if (isQuarantined(pos.symbol, log)) {
437
+ log(`[ARB] ${pos.symbol} is quarantined — left open (perp strategy quarantine)`);
438
+ stillOpen.push(pos);
322
439
  continue;
323
- try {
324
- await Promise.all([
325
- longAdapter.marketOrder(pos.symbol, "sell", pos.size),
326
- shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
327
- ]);
328
- log(`[ARB] Emergency closed ${pos.symbol}`);
329
- }
330
- catch (err) {
331
- log(`[ARB] Failed to close ${pos.symbol}: ${err instanceof Error ? err.message : err}`);
332
440
  }
441
+ if (await closeArbPosition(pos, adapters, log))
442
+ log(`[ARB] Emergency closed ${pos.symbol}`);
443
+ else
444
+ stillOpen.push(pos);
333
445
  }
446
+ return stillOpen;
334
447
  }
335
448
  function sleep(ms) {
336
449
  return new Promise((r) => setTimeout(r, ms));
@@ -10,11 +10,32 @@ export interface GridParams {
10
10
  intervalSec?: number;
11
11
  maxRuntime?: number;
12
12
  trailingStop?: number;
13
+ /**
14
+ * At stop (max runtime, trailing stop, SIGINT): close the grid's own inventory — the position
15
+ * change since the grid started — reduce-only, verified (closePerpVerified). Default false: the
16
+ * inventory is left open and reported (warning + result.inventory).
17
+ */
18
+ closeOnStop?: boolean;
19
+ }
20
+ /** The grid's position change on `symbol` between start and stop (signed base: + long, − short). */
21
+ export interface GridInventory {
22
+ /** Signed position when the grid started. */
23
+ startSize: string;
24
+ /** Signed position after the remaining grid orders were cancelled; null = could not be read. */
25
+ endSize: string | null;
26
+ /** endSize − startSize (the grid's own net fills); null when endSize is unknown. */
27
+ gridDelta: string | null;
28
+ /** Base closed by --close-on-stop (0 when not requested / nothing to close). */
29
+ closed: string;
30
+ /** Inventory still open after stop (|gridDelta| − closed); null when unknown. */
31
+ leftOpen: string | null;
32
+ note: string | null;
13
33
  }
14
34
  export interface GridState {
15
35
  gridLines: GridLine[];
16
36
  activeOrders: Map<string, string>;
17
37
  fills: number;
38
+ /** Estimated PnL of COMPLETED round trips only (grid step × size per take-profit fill). */
18
39
  totalPnl: number;
19
40
  peakEquity: number;
20
41
  startedAt: number;
@@ -25,10 +46,20 @@ interface GridLine {
25
46
  side: "buy" | "sell";
26
47
  size: number;
27
48
  filled: boolean;
49
+ /** The line's resting order is the take-profit leg of an earlier fill (its fill completes a round trip). */
50
+ flipped: boolean;
28
51
  }
29
52
  export declare function runGrid(adapter: ExchangeAdapter, params: GridParams, jobId?: string, log?: (msg: string) => void): Promise<{
30
53
  fills: number;
31
54
  totalPnl: number;
32
55
  runtime: number;
56
+ inventory: GridInventory;
33
57
  }>;
58
+ /**
59
+ * The grid's inventory at stop: position now − position at start. Without closeOnStop it is
60
+ * left open and reported (WARNING). With closeOnStop the part still held in the same direction is
61
+ * closed reduce-only (closePerpVerified); a delta the current position no longer holds (the
62
+ * position was reduced / flipped by something else) is not closed — closing it would open a position.
63
+ */
64
+ export declare function settleGridInventory(adapter: ExchangeAdapter, symbol: string, startSize: string, closeOnStop: boolean, log: (msg: string) => void): Promise<GridInventory>;
34
65
  export {};
@@ -1,5 +1,20 @@
1
+ import { assertCanClosePositions } from "../exchanges/trading-v2.js";
1
2
  import { updateJobState } from "../jobs.js";
3
+ import { closePerpVerified } from "../bot/perp-order.js";
4
+ import { formatComputedPrice } from "../exchanges/precision.js";
5
+ import { alignComputedLimitPrice } from "../bot/quote-rules.js";
6
+ import { symbolMatch } from "../utils.js";
7
+ import { venueRateString } from "../utils/numeric.js";
8
+ /** Signed position of `symbol` (+ long, − short, 0 none) as a decimal string. */
9
+ async function signedPosition(adapter, symbol) {
10
+ const p = (await adapter.getPositions()).find(x => symbolMatch(x.symbol, symbol));
11
+ if (p === undefined)
12
+ return "0";
13
+ const abs = p.size.replace(/^-/, "");
14
+ return p.side === "short" ? `-${abs}` : abs;
15
+ }
2
16
  export async function runGrid(adapter, params, jobId, log = console.log) {
17
+ assertCanClosePositions(adapter, `Grid on ${adapter.name}`);
3
18
  const { symbol, upperPrice, lowerPrice, grids, totalSize } = params;
4
19
  const intervalMs = (params.intervalSec ?? 10) * 1000;
5
20
  const maxRuntime = (params.maxRuntime ?? 0) * 1000;
@@ -27,6 +42,7 @@ export async function runGrid(adapter, params, jobId, log = console.log) {
27
42
  side: "buy", // will be set based on current price
28
43
  size: sizePerGrid,
29
44
  filled: false,
45
+ flipped: false,
30
46
  });
31
47
  }
32
48
  log(`[GRID] ${symbol} ${params.side} | ${grids} grids | $${lowerPrice} - $${upperPrice} | step $${step.toFixed(2)}`);
@@ -40,14 +56,17 @@ export async function runGrid(adapter, params, jobId, log = console.log) {
40
56
  startedAt: Date.now(),
41
57
  running: true,
42
58
  };
59
+ // Baseline position: the grid's inventory at stop is measured against it (a failed read stops here, nothing placed).
60
+ const startSize = await signedPosition(adapter, symbol);
43
61
  // Graceful shutdown
44
62
  const shutdown = () => { state.running = false; };
45
63
  process.on("SIGINT", shutdown);
46
64
  process.on("SIGTERM", shutdown);
65
+ let roundTrips = 0;
47
66
  try {
48
67
  // Get current price to determine buy/sell sides
49
68
  const markets = await adapter.getMarkets();
50
- const market = markets.find(m => m.symbol.toUpperCase() === symbol.toUpperCase());
69
+ const market = markets.find(m => symbolMatch(m.symbol, symbol));
51
70
  const currentPrice = market ? parseFloat(market.markPrice) : (upperPrice + lowerPrice) / 2;
52
71
  // Assign sides: buy below current, sell above current
53
72
  for (const line of gridLines) {
@@ -84,6 +103,8 @@ export async function runGrid(adapter, params, jobId, log = console.log) {
84
103
  const line = gridLines[i];
85
104
  state.fills++;
86
105
  newFills++;
106
+ if (line.flipped)
107
+ roundTrips++;
87
108
  line.filled = true;
88
109
  // Flip side: buy → sell, sell → buy (take profit at next grid)
89
110
  const newSide = line.side === "buy" ? "sell" : "buy";
@@ -92,12 +113,13 @@ export async function runGrid(adapter, params, jobId, log = console.log) {
92
113
  : line.price - step; // buy one grid below
93
114
  if (newPrice >= lowerPrice && newPrice <= upperPrice) {
94
115
  try {
95
- const result = await adapter.limitOrder(symbol, newSide, String(newPrice.toFixed(2)), String(line.size));
116
+ const result = await adapter.limitOrder(symbol, newSide, await alignComputedLimitPrice(adapter, symbol, newSide, formatComputedPrice(newPrice)), String(line.size));
96
117
  const newOrderId = String(result?.orderId ?? result?.oid ?? result?.id ?? "");
97
118
  state.activeOrders.set(String(i), newOrderId);
98
119
  line.side = newSide;
99
120
  line.price = newPrice;
100
121
  line.filled = false;
122
+ line.flipped = !line.flipped;
101
123
  }
102
124
  catch (err) {
103
125
  const msg = err instanceof Error ? err.message : String(err);
@@ -110,9 +132,9 @@ export async function runGrid(adapter, params, jobId, log = console.log) {
110
132
  }
111
133
  }
112
134
  if (newFills > 0) {
113
- // Estimate PnL from grid spacing
114
- state.totalPnl += newFills * step * sizePerGrid;
115
- log(`[GRID] ${newFills} fill(s) | Total fills: ${state.fills} | Est. PnL: $${state.totalPnl.toFixed(2)}`);
135
+ // Estimate PnL from grid spacing — completed round trips only (an opening fill earns nothing yet).
136
+ state.totalPnl = roundTrips > 0 ? Number(formatComputedPrice(roundTrips * step * sizePerGrid)) : 0;
137
+ log(`[GRID] ${newFills} fill(s) | Total fills: ${state.fills} | Round trips: ${roundTrips} | Est. PnL: $${state.totalPnl}`);
116
138
  }
117
139
  // Update job state
118
140
  if (jobId) {
@@ -152,23 +174,79 @@ export async function runGrid(adapter, params, jobId, log = console.log) {
152
174
  try {
153
175
  await adapter.cancelAllOrders(symbol);
154
176
  }
155
- catch {
156
- // best-effort
177
+ catch (e) {
178
+ log(`[GRID] WARNING: cancelling the remaining ${symbol} orders failed (${e instanceof Error ? e.message : String(e)}) — check: perp account orders -e ${adapter.name}`);
157
179
  }
158
180
  }
181
+ const inventory = await settleGridInventory(adapter, symbol, startSize, params.closeOnStop === true, log);
159
182
  const runtime = Math.floor((Date.now() - state.startedAt) / 1000);
160
- log(`[GRID] Done. ${state.fills} fills, est. PnL $${state.totalPnl.toFixed(2)}, runtime ${runtime}s`);
183
+ log(`[GRID] Done. ${state.fills} fills (${roundTrips} round trips), est. PnL $${state.totalPnl}, runtime ${runtime}s`);
161
184
  if (jobId) {
162
- updateJobState(jobId, { status: "done", result: { fills: state.fills, totalPnl: state.totalPnl, runtime } });
185
+ updateJobState(jobId, { status: "done", result: { fills: state.fills, totalPnl: state.totalPnl, runtime, inventory } });
186
+ }
187
+ return { fills: state.fills, totalPnl: state.totalPnl, runtime, inventory };
188
+ }
189
+ /**
190
+ * The grid's inventory at stop: position now − position at start. Without closeOnStop it is
191
+ * left open and reported (WARNING). With closeOnStop the part still held in the same direction is
192
+ * closed reduce-only (closePerpVerified); a delta the current position no longer holds (the
193
+ * position was reduced / flipped by something else) is not closed — closing it would open a position.
194
+ */
195
+ export async function settleGridInventory(adapter, symbol, startSize, closeOnStop, log) {
196
+ let endSize;
197
+ try {
198
+ endSize = await signedPosition(adapter, symbol);
199
+ }
200
+ catch (e) {
201
+ const note = `position could not be read after stop (${e instanceof Error ? e.message : String(e)})`;
202
+ log(`[GRID] WARNING: ${note} — the grid may have left a ${symbol} position open. Check: perp account positions -e ${adapter.name}`);
203
+ return { startSize, endSize: null, gridDelta: null, closed: "0", leftOpen: null, note };
204
+ }
205
+ const dec = Math.max((startSize.split(".")[1] ?? "").length, (endSize.split(".")[1] ?? "").length);
206
+ const delta = Number(venueRateString(Number(endSize) - Number(startSize), "grid inventory"));
207
+ const gridDelta = delta.toFixed(dec);
208
+ const base = { startSize, endSize, gridDelta };
209
+ if (delta === 0)
210
+ return { ...base, closed: "0", leftOpen: "0", note: null };
211
+ const deltaAbs = Math.abs(delta).toFixed(dec);
212
+ const dir = delta > 0 ? "long" : "short";
213
+ if (!closeOnStop) {
214
+ log(`[GRID] WARNING: the grid left ${deltaAbs} ${symbol} ${dir} open (position ${startSize} → ${endSize}). Close it: perp trade close ${symbol} -e ${adapter.name} (or rerun with --close-on-stop)`);
215
+ return { ...base, closed: "0", leftOpen: deltaAbs, note: "left open (no --close-on-stop)" };
216
+ }
217
+ const end = Number(endSize);
218
+ const held = Math.sign(end) === Math.sign(delta) ? Math.min(Math.abs(delta), Math.abs(end)) : 0;
219
+ if (held === 0) {
220
+ const note = `the position (${endSize}) no longer holds the grid's ${dir} inventory — not closed (it would open a position)`;
221
+ log(`[GRID] WARNING: ${note}. Check: perp account positions -e ${adapter.name}`);
222
+ return { ...base, closed: "0", leftOpen: deltaAbs, note };
223
+ }
224
+ const size = held.toFixed(dec);
225
+ log(`[GRID] --close-on-stop: closing ${size} ${symbol} ${dir} (reduce-only, verified)...`);
226
+ try {
227
+ const c = await closePerpVerified(adapter, adapter.name, symbol, delta > 0 ? "sell" : "buy", size, { decimals: dec });
228
+ const closed = c.closed.toFixed(dec);
229
+ const leftOpen = Math.max(0, Math.abs(delta) - c.closed).toFixed(dec);
230
+ if (c.residual > 0 || Number(leftOpen) > 0) {
231
+ log(`[GRID] WARNING: closed ${closed}; ${leftOpen} ${symbol} still open${c.note !== null ? ` (${c.note})` : ""}. Check: perp account positions -e ${adapter.name}`);
232
+ }
233
+ else {
234
+ log(`[GRID] Closed the grid inventory (${closed} ${symbol}).`);
235
+ }
236
+ return { ...base, closed, leftOpen, note: c.note };
237
+ }
238
+ catch (e) {
239
+ const note = `close failed: ${e instanceof Error ? e.message : String(e)}`;
240
+ log(`[GRID] WARNING: ${note} — ${deltaAbs} ${symbol} ${dir} may still be open. Check: perp account positions -e ${adapter.name}`);
241
+ return { ...base, closed: "0", leftOpen: null, note };
163
242
  }
164
- return { fills: state.fills, totalPnl: state.totalPnl, runtime };
165
243
  }
166
244
  async function placeGridOrders(adapter, symbol, gridLines, state, log) {
167
245
  let placed = 0;
168
246
  for (let i = 0; i < gridLines.length; i++) {
169
247
  const line = gridLines[i];
170
248
  try {
171
- const result = await adapter.limitOrder(symbol, line.side, String(line.price.toFixed(2)), String(line.size));
249
+ const result = await adapter.limitOrder(symbol, line.side, await alignComputedLimitPrice(adapter, symbol, line.side, formatComputedPrice(line.price)), String(line.size));
172
250
  const orderId = String(result?.orderId ?? result?.oid ?? result?.id ?? "");
173
251
  state.activeOrders.set(String(i), orderId);
174
252
  placed++;
@@ -0,0 +1,8 @@
1
+ import type { ExchangeAdapter } from "../exchanges/index.js";
2
+ import { PerpError } from "../errors.js";
3
+ /**
4
+ * An order of a DCA / TWAP run whose outcome or fill size is unknown: it is NEVER resubmitted.
5
+ * The symbol is quarantined (every automated path skips it until a human clears it) and the
6
+ * returned ORDER_PENDING error ends the run.
7
+ */
8
+ export declare function pendingStop(source: "dca" | "twap", adapter: ExchangeAdapter, symbol: string, reason: string, log: (msg: string) => void): PerpError;
@@ -0,0 +1,12 @@
1
+ import { PerpError } from "../errors.js";
2
+ import { quarantineSymbol } from "../bot/quarantine.js";
3
+ /**
4
+ * An order of a DCA / TWAP run whose outcome or fill size is unknown: it is NEVER resubmitted.
5
+ * The symbol is quarantined (every automated path skips it until a human clears it) and the
6
+ * returned ORDER_PENDING error ends the run.
7
+ */
8
+ export function pendingStop(source, adapter, symbol, reason, log) {
9
+ const remediation = `Check ${symbol} on ${adapter.name} (perp -e ${adapter.name} account positions; perp -e ${adapter.name} account orders) — do NOT resubmit; fix by hand, then: perp strategy quarantine-clear ${symbol}`;
10
+ quarantineSymbol(source, symbol, `${reason} — not resubmitted`, remediation, log);
11
+ return new PerpError("ORDER_PENDING", `${reason} — run stopped, ${symbol} quarantined`, { exchange: adapter.name, remediation });
12
+ }