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,24 @@
1
1
  import { registerStrategy } from "../strategy-registry.js";
2
+ import { PerpError } from "../../errors.js";
2
3
  import { checkArbLiquidity, checkSpotPerpLiquidity } from "../../liquidity.js";
3
- import { computeMatchedSize, computeSpotPerpMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
4
+ import { computeMatchedSize, computeSpotPerpMatchedSize } from "../../arb-sizing.js";
4
5
  import { getFundingHours } from "../../funding.js";
5
6
  import { logExecution, pruneExecutionLog } from "../../execution-log.js";
6
7
  import { annualizeHourlyRate } from "../../funding/normalize.js";
7
- import { isPositionGone, transferUsdcToSpot, transferUsdcToPerp, getSpotAdapter, getPerpSymbol, findRate, getPriceEstimate, fetchRates, } from "./funding-arb-utils.js";
8
+ import { getBotSpotAdapter, nonQuoteSpotBalances, readSpotQuoteAvailableFresh, spotNetworkOf, spotTokenBase, sweepSpotQuoteToPerp } from "../../exchanges/spot-factory.js";
9
+ import { getVenue } from "../../exchanges/venue.js";
10
+ import { isPositionGone, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, getPriceEstimate, fetchRates, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols } from "./funding-arb-utils.js";
11
+ import { fillSizeString, sizeDecimalsOf, spotLotStepOf, spotMarketFill } from "../../exchanges/spot-result.js";
12
+ import { floorToStep, lotStepOf } from "../../exchanges/precision.js";
13
+ import { isQuarantined, quarantineKey, quarantineSymbol } from "../quarantine.js";
14
+ import { SpotPerpExecutor } from "./spot-perp-executor.js";
15
+ import { closePerpVerified, isOutcomeUnknown, placePerpVerified, trimFirstLegToHedge } from "../perp-order.js";
16
+ import { reportedAvgFillPrice } from "../../exchanges/ioc-outcome.js";
17
+ import { recordSpotPerpClose, recordSpotPerpEntry } from "./spot-perp-state.js";
18
+ let _posIdCounter = 0;
19
+ function genPosId() { return `fa-${Date.now()}-${++_posIdCounter}`; }
20
+ /** Consecutive failed closes (spot-perp and perp-perp) before the symbol is quarantined — no retry loop (same bound as funding-arb-v2 / funding-auto). */
21
+ const MAX_CLOSE_FAILURES = 3;
8
22
  export class FundingArbStrategy {
9
23
  name = "funding-arb";
10
24
  describe() {
@@ -27,7 +41,24 @@ export class FundingArbStrategy {
27
41
  }
28
42
  _config = {};
29
43
  _failCooldown = new Map(); // "symbol:action" → timestamp of next retry
44
+ /** Consecutive failed closes per symbol, spot-perp and perp-perp (quarantined at MAX_CLOSE_FAILURES). */
45
+ _closeFailures = new Map();
46
+ /** Venues whose positions could not be read during recovery: no entries there until re-read. */
47
+ _unrecovered = new Set();
48
+ executor = new SpotPerpExecutor();
49
+ /** Symbols this bot entered or tracked this run — the only symbols its stop cancels orders on. */
50
+ _traded = new Set();
51
+ quarantined(symbol, ctx) {
52
+ return isQuarantined(symbol, ctx.log);
53
+ }
54
+ /** Unknown / unhedged outcome: stop touching this symbol until a human clears it. */
55
+ quarantine(symbol, reason, venues, ctx) {
56
+ const list = venues.split("/");
57
+ const checks = list.map(v => `perp -e ${v} positions, perp -e ${v} trade spot-balance, perp -e ${v} trade spot-orders`).join("; ");
58
+ quarantineSymbol(this.name, symbol, reason, `Check ${symbol.toUpperCase()} on ${list.join(" and ")} (${checks}); resolve by hand, then: perp strategy quarantine-clear ${symbol.toUpperCase()}`, ctx.log);
59
+ }
30
60
  async init(ctx, _snapshot) {
61
+ assertFundingArbLegs(ctx);
31
62
  // Prune old execution logs (keep 30 days)
32
63
  try {
33
64
  pruneExecutionLog(30);
@@ -55,97 +86,8 @@ export class FundingArbStrategy {
55
86
  for (const [name, a] of extraAdapters)
56
87
  adapters.set(name, a);
57
88
  }
58
- // Fetch all positions across exchanges
59
- const positionsByExchange = new Map();
60
- for (const [name, a] of adapters) {
61
- try {
62
- const positions = await a.getPositions();
63
- positionsByExchange.set(name, positions.map(p => ({
64
- symbol: p.symbol.toUpperCase(),
65
- side: p.side,
66
- size: p.size,
67
- })));
68
- }
69
- catch { /* skip unavailable exchange */ }
70
- }
71
- // Match long/short pairs across exchanges → reconstruct arbOpenPositions
72
- const recovered = [];
73
- const used = new Set(); // "exchange:symbol" to avoid double-matching
74
- for (const [exA, posA] of positionsByExchange) {
75
- for (const pA of posA) {
76
- const keyA = `${exA}:${pA.symbol}`;
77
- if (used.has(keyA))
78
- continue;
79
- for (const [exB, posB] of positionsByExchange) {
80
- if (exA === exB)
81
- continue;
82
- for (const pB of posB) {
83
- const keyB = `${exB}:${pB.symbol}`;
84
- if (used.has(keyB))
85
- continue;
86
- if (pA.symbol !== pB.symbol)
87
- continue;
88
- if (pA.side === pB.side)
89
- continue;
90
- // Found a matching pair
91
- const longEx = pA.side === "long" ? exA : exB;
92
- const shortEx = pA.side === "short" ? exA : exB;
93
- const size = pA.side === "long" ? pA.size : pB.size;
94
- recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size, mode: "perp-perp", entryTime: 0 });
95
- used.add(keyA);
96
- used.add(keyB);
97
- break;
98
- }
99
- if (used.has(keyA))
100
- break;
101
- }
102
- }
103
- }
104
- // ── Same-exchange spot-perp hedges (e.g. PURR spot + PURR-PERP short on HL) ──
105
- for (const [name, a] of adapters) {
106
- try {
107
- let spotBalances = [];
108
- if (name === "hyperliquid") {
109
- const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
110
- const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
111
- if (a instanceof HyperliquidAdapter) {
112
- const hlSpot = new HyperliquidSpotAdapter(a);
113
- await hlSpot.init();
114
- spotBalances = await hlSpot.getSpotBalances();
115
- }
116
- }
117
- else if (name === "lighter") {
118
- const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
119
- const { LighterAdapter } = await import("../../exchanges/lighter.js");
120
- if (a instanceof LighterAdapter) {
121
- const ltSpot = new LighterSpotAdapter(a);
122
- await ltSpot.init();
123
- spotBalances = await ltSpot.getSpotBalances();
124
- }
125
- }
126
- const nonUsdc = spotBalances.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
127
- ctx.log(` [ARB] ${name} spot balances: ${nonUsdc.map(b => `${b.token}=${b.total}`).join(", ") || "none"}`);
128
- if (nonUsdc.length === 0)
129
- continue;
130
- const perpPositions = positionsByExchange.get(name) ?? [];
131
- for (const perp of perpPositions) {
132
- const perpKey = `${name}:${perp.symbol}`;
133
- if (used.has(perpKey)) {
134
- ctx.log(` [ARB] ${perpKey} already matched cross-exchange, skip spot-perp`);
135
- continue;
136
- }
137
- const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
138
- const spotBal = nonUsdc.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === base);
139
- ctx.log(` [ARB] ${name} perp ${perp.symbol}(${perp.side}) → base=${base}, spotMatch=${spotBal ? spotBal.token : "none"}`);
140
- if (spotBal && perp.side === "short") {
141
- recovered.push({ symbol: base, longExchange: `${name}-spot`, shortExchange: name, entrySpread: 0, size: perp.size, mode: "spot-perp", entryTime: 0 });
142
- used.add(perpKey);
143
- ctx.log(` [ARB] Detected spot-perp hedge: ${base} ${name}-spot↔${name}`);
144
- }
145
- }
146
- }
147
- catch { /* adapter doesn't support spot */ }
148
- }
89
+ const { recovered, unreadable } = await this.recoverPositions(adapters, ctx);
90
+ this._unrecovered = unreadable;
149
91
  if (recovered.length > 0) {
150
92
  ctx.state.set("arbOpenPositions", recovered);
151
93
  ctx.state.set("arbPositions", recovered.length);
@@ -177,6 +119,172 @@ export class FundingArbStrategy {
177
119
  ctx.log(` [ARB] Position recovery failed: ${msg}`);
178
120
  }
179
121
  }
122
+ /**
123
+ * Rebuild tracked positions from the venues (perp-perp pairs + same-venue spot-perp
124
+ * hedges). A venue whose positions or spot balances cannot be read is returned in
125
+ * `unreadable` — never treated as holding nothing.
126
+ */
127
+ async recoverPositions(adapters, ctx) {
128
+ const unreadable = new Set();
129
+ // Fetch all positions across exchanges
130
+ const positionsByExchange = new Map();
131
+ for (const [name, a] of adapters) {
132
+ try {
133
+ const positions = await a.getPositions();
134
+ positionsByExchange.set(name, positions.map(p => ({
135
+ symbol: p.symbol.toUpperCase(),
136
+ side: p.side,
137
+ size: p.size,
138
+ })));
139
+ }
140
+ catch (e) {
141
+ // Never "no positions": no entries on this venue until its positions are read.
142
+ unreadable.add(name);
143
+ ctx.log(` [ARB] ${name} positions unreadable during recovery (${e instanceof Error ? e.message : String(e)}) — no entries on ${name} until recovered`);
144
+ }
145
+ }
146
+ // Match long/short pairs across exchanges → reconstruct arbOpenPositions
147
+ const recovered = [];
148
+ const used = new Set(); // "exchange:symbol" to avoid double-matching
149
+ for (const [exA, posA] of positionsByExchange) {
150
+ for (const pA of posA) {
151
+ const keyA = `${exA}:${pA.symbol}`;
152
+ if (used.has(keyA))
153
+ continue;
154
+ for (const [exB, posB] of positionsByExchange) {
155
+ if (exA === exB)
156
+ continue;
157
+ for (const pB of posB) {
158
+ const keyB = `${exB}:${pB.symbol}`;
159
+ if (used.has(keyB))
160
+ continue;
161
+ if (pA.symbol !== pB.symbol)
162
+ continue;
163
+ if (pA.side === pB.side)
164
+ continue;
165
+ // Only an exactly size-matched long/short is a pair; anything else is quarantined below.
166
+ if (Math.abs(Number(pA.size)) !== Math.abs(Number(pB.size)))
167
+ continue;
168
+ // Found a matching pair
169
+ const longEx = pA.side === "long" ? exA : exB;
170
+ const shortEx = pA.side === "short" ? exA : exB;
171
+ const size = pA.side === "long" ? pA.size : pB.size;
172
+ recovered.push({ symbol: pA.symbol, longExchange: longEx, shortExchange: shortEx, entrySpread: 0, size, mode: "perp-perp", entryTime: 0 });
173
+ used.add(keyA);
174
+ used.add(keyB);
175
+ break;
176
+ }
177
+ if (used.has(keyA))
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ // ── Spot-perp hedges: a perp short on venue P + spot holdings of the same canonical
183
+ // base on a bot spot venue S — same venue (e.g. PURR spot + PURR-PERP short on HL)
184
+ // or cross-venue (spot on A, perp short on B, as `arb exec spot:A B` /
185
+ // spot-perp-arb open them). Matched by quarantineKey (canonical: HIP-3 prefix /
186
+ // "-PERP" stripped, denomination aliases). A same-venue holding wins; otherwise
187
+ // exactly one candidate venue is required — an ambiguous match (several venues
188
+ // hold the base, or the only holding is already matched to another short) is
189
+ // quarantined instead of being left as untracked exposure.
190
+ const holdings = new Map(); // venue → canonical key → spot token
191
+ for (const [name, a] of adapters) {
192
+ try {
193
+ const spotAdapter = await getBotSpotAdapter(name, a);
194
+ if (!spotAdapter)
195
+ continue;
196
+ const nonUsdc = nonQuoteSpotBalances(getVenue(name), spotNetworkOf(spotAdapter), await spotAdapter.getSpotBalances());
197
+ ctx.log(` [ARB] ${name} spot balances: ${nonUsdc.map(b => `${b.token}=${b.total}`).join(", ") || "none"}`);
198
+ const byKey = new Map();
199
+ for (const b of nonUsdc)
200
+ byKey.set(quarantineKey(spotTokenBase(b.token)), b.token);
201
+ holdings.set(name, byKey);
202
+ }
203
+ catch (e) {
204
+ unreadable.add(name);
205
+ ctx.log(` [ARB] ${name} spot recovery failed: ${e instanceof Error ? e.message : String(e)} — no entries on ${name} until recovered`);
206
+ }
207
+ }
208
+ const usedSpot = new Set(); // "venue:KEY"
209
+ for (const [perpVenue, perpPositions] of positionsByExchange) {
210
+ for (const perp of perpPositions) {
211
+ if (perp.side !== "short")
212
+ continue;
213
+ const perpKey = `${perpVenue}:${perp.symbol}`;
214
+ if (used.has(perpKey)) {
215
+ ctx.log(` [ARB] ${perpKey} already matched cross-exchange, skip spot-perp`);
216
+ continue;
217
+ }
218
+ const key = quarantineKey(perp.symbol);
219
+ const holders = [...holdings].filter(([, byKey]) => byKey.has(key)).map(([v]) => v);
220
+ if (holders.length === 0)
221
+ continue;
222
+ const free = holders.filter(v => !usedSpot.has(`${v}:${key}`));
223
+ const spotVenue = free.includes(perpVenue) ? perpVenue : free.length === 1 ? free[0] : null;
224
+ if (spotVenue === null) {
225
+ this.quarantine(key, `recovery: perp short ${perp.size} ${perp.symbol} on ${perpVenue} has an ambiguous spot hedge (spot ${key} held on ${holders.join(", ")}${free.length < holders.length ? "; some already matched to another short" : ""}) — not tracked`, [perpVenue, ...holders.filter(v => v !== perpVenue)].join("/"), ctx);
226
+ continue;
227
+ }
228
+ const base = perp.symbol.replace(/-PERP$/, "").toUpperCase(); // perp venue's own symbol (orders use it)
229
+ recovered.push({ symbol: base, longExchange: `${spotVenue}-spot`, shortExchange: perpVenue, entrySpread: 0, size: perp.size, mode: "spot-perp", entryTime: 0 });
230
+ used.add(perpKey);
231
+ usedSpot.add(`${spotVenue}:${key}`);
232
+ const q = this.quarantined(base, ctx) ? " — QUARANTINED: tracked but not touched until cleared" : "";
233
+ ctx.log(` [ARB] Detected spot-perp hedge: ${base} ${spotVenue}-spot (${holdings.get(spotVenue).get(key)})↔${perpVenue} perp short ${perp.size}${q}`);
234
+ }
235
+ }
236
+ // ── Unpaired legs: a single perp leg, or a long/short whose sizes differ, is exposure the
237
+ // bot cannot manage as a hedge — quarantined (both sizes recorded), never left untracked
238
+ // for a later entry to stack on.
239
+ const leftover = new Map();
240
+ for (const [venue, list] of positionsByExchange) {
241
+ for (const p of list) {
242
+ if (Number(p.size) === 0 || used.has(`${venue}:${p.symbol}`))
243
+ continue;
244
+ const key = quarantineKey(p.symbol);
245
+ leftover.set(key, [...(leftover.get(key) ?? []), { venue, ...p }]);
246
+ }
247
+ }
248
+ for (const [key, legs] of leftover) {
249
+ if (this.quarantined(key, ctx))
250
+ continue;
251
+ const desc = legs.map(l => `${l.side} ${l.size.replace(/^-/, "")} on ${l.venue}`).join(", ");
252
+ const mismatched = legs.some(l => l.side === "long") && legs.some(l => l.side === "short");
253
+ this.quarantine(key, mismatched
254
+ ? `recovery: ${key} long/short sizes do not match (${desc}) — not tracked as a pair`
255
+ : `recovery: unpaired ${key} perp leg (${desc}) — no hedge found, not tracked`, [...new Set(legs.map(l => l.venue))].join("/"), ctx);
256
+ }
257
+ return { recovered, unreadable };
258
+ }
259
+ /**
260
+ * true when `symbol` has a live perp position on any of `venues` that the bot does not track
261
+ * (a manual / foreign position): no entry is stacked on it. A failed read counts as held
262
+ * (unknown is never "flat").
263
+ */
264
+ async heldUntracked(symbol, venues, adapters, ctx, cache) {
265
+ const key = quarantineKey(symbol);
266
+ const tracked = ctx.state.get("arbOpenPositions").some(p => quarantineKey(p.symbol) === key);
267
+ for (const v of venues) {
268
+ const a = adapters.get(v);
269
+ if (a === undefined)
270
+ continue;
271
+ if (!cache.has(v))
272
+ cache.set(v, a.getPositions());
273
+ let list;
274
+ try {
275
+ list = await cache.get(v);
276
+ }
277
+ catch (e) {
278
+ ctx.log(` [ARB] Skip ${symbol}: ${v} positions unreadable (${e instanceof Error ? e.message : String(e)}) — no entry on unknown exposure`);
279
+ return true;
280
+ }
281
+ if (!tracked && list.some(p => quarantineKey(p.symbol) === key && Number(p.size) !== 0)) {
282
+ ctx.log(` [ARB] Skip ${symbol}: a ${symbol} position on ${v} is not one of the bot's pairs — no entry on top of it`);
283
+ return true;
284
+ }
285
+ }
286
+ return false;
287
+ }
180
288
  async onTick(ctx, _snapshot) {
181
289
  const params = this.params;
182
290
  const openPositions = ctx.state.get("arbOpenPositions");
@@ -193,6 +301,20 @@ export class FundingArbStrategy {
193
301
  ctx.log(` [ARB] Need 2+ exchanges, have ${adapters.size}. Skipping.`);
194
302
  return [];
195
303
  }
304
+ // ── Retry recovery on venues whose positions could not be read at init ──
305
+ if (this._unrecovered.size > 0) {
306
+ const { recovered, unreadable } = await this.recoverPositions(adapters, ctx);
307
+ const tracked = new Set(openPositions.map(p => p.symbol.toUpperCase()));
308
+ for (const r of recovered) {
309
+ if (tracked.has(r.symbol.toUpperCase()))
310
+ continue;
311
+ openPositions.push(r);
312
+ tracked.add(r.symbol.toUpperCase());
313
+ ctx.log(` [ARB] Recovered ${r.symbol} ${r.longExchange}↔${r.shortExchange} (retry)`);
314
+ }
315
+ ctx.state.set("arbPositions", openPositions.length);
316
+ this._unrecovered = unreadable;
317
+ }
196
318
  // ── Pre-filter exchanges by margin availability ──
197
319
  const exchangeBalances = new Map();
198
320
  const availableAdapters = new Map();
@@ -206,7 +328,10 @@ export class FundingArbStrategy {
206
328
  exchangeBalances.set(name, { equity, available, marginPct });
207
329
  // Skip if: margin > 90%, available < $5, or exchange on liquidation cooldown
208
330
  const exchCooldown = this._failCooldown.get(`${name}:exchange`) ?? 0;
209
- if (Date.now() < exchCooldown) {
331
+ if (this._unrecovered.has(name)) {
332
+ ctx.log(` [ARB] ${name} positions not recovered — no entries this tick`);
333
+ }
334
+ else if (Date.now() < exchCooldown) {
210
335
  ctx.log(` [ARB] ${name} on liquidation cooldown — skipping`);
211
336
  }
212
337
  else if (available < 5) {
@@ -242,7 +367,15 @@ export class FundingArbStrategy {
242
367
  // Fetch rates — all exchanges if room for new positions, only position exchanges if full
243
368
  const ratesByExchange = new Map();
244
369
  for (const [name, a] of exchangesToScan) {
245
- const rates = await fetchRates(a, name);
370
+ let rates;
371
+ try {
372
+ rates = await fetchRates(a, name);
373
+ }
374
+ catch (err) {
375
+ // Recorded per venue; this venue is left out of this cycle's rate map.
376
+ ctx.log(` [FARB] fetchRates failed for ${name}: ${err instanceof Error ? err.message : String(err)}`);
377
+ continue;
378
+ }
246
379
  const map = new Map();
247
380
  for (const r of rates)
248
381
  map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage, fundingHours: r.fundingHours });
@@ -328,6 +461,10 @@ export class FundingArbStrategy {
328
461
  const deeplyNegative = signedSpread < -20; // urgent: losing significant funding
329
462
  const shouldClose = deeplyNegative || (holdTime >= minHoldMs && signedSpread < params.close_spread);
330
463
  if (shouldClose) {
464
+ if (this.quarantined(pos.symbol, ctx)) {
465
+ ctx.log(` [ARB] ${pos.symbol} is quarantined — not closing (perp strategy quarantine)`);
466
+ continue;
467
+ }
331
468
  // Cooldown: skip if close recently failed for this symbol
332
469
  const closeKey = `${pos.symbol}:close`;
333
470
  const cooldownUntil = this._failCooldown.get(closeKey) ?? 0;
@@ -344,75 +481,9 @@ export class FundingArbStrategy {
344
481
  const longAdapter = adapters.get(pos.longExchange);
345
482
  const shortAdapter = adapters.get(pos.shortExchange);
346
483
  if (longAdapter && shortAdapter) {
347
- let closeLongOk = false;
348
- let longPositionGone = false;
349
- try {
350
- const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
351
- closeLongOk = true;
352
- logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "long", signedSpread: signedSpread, response: closeLongResult } });
353
- }
354
- catch (err) {
355
- const msg = err instanceof Error ? err.message : String(err);
356
- if (isPositionGone(msg)) {
357
- // Position already liquidated/closed on this exchange
358
- longPositionGone = true;
359
- ctx.log(` [ARB] Long position gone for ${pos.symbol} on ${pos.longExchange} (liquidated?) — force closing short`);
360
- // Cooldown the exchange for 30 minutes to prevent liquidation-entry cycle
361
- this._failCooldown.set(`${pos.longExchange}:exchange`, Date.now() + 30 * 60 * 1000);
362
- }
363
- else {
364
- ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} (cooldown 5m)`);
365
- logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "long" } });
366
- this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
367
- }
368
- }
369
- if (!closeLongOk && !longPositionGone)
370
- continue;
371
- try {
372
- const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
373
- logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "short", signedSpread: signedSpread, response: closeShortResult } });
374
- }
375
- catch (err) {
376
- const msg = err instanceof Error ? err.message : String(err);
377
- if (isPositionGone(msg)) {
378
- // Short position also gone — both liquidated, just clean up tracking
379
- ctx.log(` [ARB] Short position also gone for ${pos.symbol} on ${pos.shortExchange} — removing from tracking`);
380
- this._failCooldown.set(`${pos.shortExchange}:exchange`, Date.now() + 30 * 60 * 1000);
381
- }
382
- else if (closeLongOk) {
383
- // Long was closed but short failed (not liquidated) — rollback long
384
- ctx.log(` [ARB] Close short leg failed for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
385
- logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short" } });
386
- try {
387
- await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
388
- ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
389
- logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
390
- }
391
- catch (rbErr) {
392
- const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
393
- ctx.log(` [ARB] CRITICAL: Close rollback failed for ${pos.symbol}: ${rbMsg}`);
394
- logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
395
- }
396
- this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
397
- continue;
398
- }
399
- else {
400
- // Long was gone, short failed (not liquidated) — orphaned short, force close without reduceOnly
401
- ctx.log(` [ARB] Force closing orphaned short for ${pos.symbol}: ${msg}`);
402
- try {
403
- await shortAdapter.marketOrder(pos.symbol, "buy", pos.size);
404
- ctx.log(` [ARB] Force closed short ${pos.size} ${pos.symbol} on ${pos.shortExchange}`);
405
- }
406
- catch (forceErr) {
407
- const forceMsg = forceErr instanceof Error ? forceErr.message : String(forceErr);
408
- ctx.log(` [ARB] CRITICAL: Force close short failed for ${pos.symbol}: ${forceMsg}`);
409
- this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 10 * 60 * 1000);
410
- continue;
411
- }
412
- }
413
- }
414
- toClose.push(pos);
415
- ctx.log(` [ARB] Closed ${pos.symbol} position${longPositionGone ? ' (long was liquidated)' : ''}`);
484
+ const closed = await this.closePerpPerp(pos, longAdapter, shortAdapter, ctx, { signedSpread, stop: false });
485
+ if (closed)
486
+ toClose.push(pos);
416
487
  }
417
488
  }
418
489
  }
@@ -471,16 +542,23 @@ export class FundingArbStrategy {
471
542
  }
472
543
  // Spot-perp opportunities: for each exchange with positive perp rate, check if spot is available
473
544
  const spotPerpMinSpread = params.spot_perp_min_spread ?? params.min_spread;
545
+ const perpMarketsCache = new Map();
474
546
  for (const [name, a] of adapters) {
547
+ if (this._unrecovered.has(name))
548
+ continue; // positions not recovered: no entries on this venue
475
549
  try {
476
- const spotAdapter = await getSpotAdapter(name, a);
550
+ const spotAdapter = await getBotSpotAdapter(name, a);
477
551
  if (!spotAdapter)
478
552
  continue;
479
- const spotMarkets = await spotAdapter.getSpotMarkets();
480
- const spotSymbols = new Set(spotMarkets.map(m => m.baseToken.toUpperCase()));
481
553
  const perpRates = ratesByExchange.get(name);
482
554
  if (!perpRates)
483
555
  continue;
556
+ // Same-venue spot↔perp: canonical symbol + price-deviation guard (§12.4 #6).
557
+ const spotMarkets = await spotAdapter.getSpotMarkets();
558
+ const matched = await botSpotPerpMatches(name, spotMarkets, [name], perpMarketsCache, (msg) => ctx.log(` [ARB] ${msg}`));
559
+ const spotSymbols = matched.get(name);
560
+ if (spotSymbols === undefined)
561
+ throw new Error(`spot↔perp matching returned no entry for ${name}`);
484
562
  for (const [sym, rateEntry] of perpRates) {
485
563
  const { rate } = rateEntry;
486
564
  // Only consider positive funding (shorts receive, longs pay)
@@ -504,7 +582,10 @@ export class FundingArbStrategy {
504
582
  }
505
583
  }
506
584
  }
507
- catch { /* exchange doesn't support spot */ }
585
+ catch (e) {
586
+ // Logged, not swallowed; this venue is skipped for spot-perp this scan.
587
+ ctx.log(` [ARB] ${name} spot scan failed: ${e instanceof Error ? e.message : String(e)}`);
588
+ }
508
589
  }
509
590
  opportunities.sort((a, b) => b.spread - a.spread);
510
591
  if (opportunities.length > 0) {
@@ -525,9 +606,19 @@ export class FundingArbStrategy {
525
606
  // Skip symbols already in open positions
526
607
  const openSymbols = new Set(ctx.state.get("arbOpenPositions").map(p => p.symbol.toUpperCase()));
527
608
  let skipCooldown = 0;
528
- for (const best of opportunities) {
609
+ const livePositions = new Map();
610
+ // Engine: equity unknown → no new entries (closes above still run).
611
+ const entriesBlocked = ctx.state.get("equityUnknown") === true;
612
+ if (entriesBlocked)
613
+ ctx.log(` [ARB] Equity unknown — no new entries this tick`);
614
+ for (const best of entriesBlocked ? [] : opportunities) {
529
615
  if (openSymbols.has(best.symbol.toUpperCase()))
530
616
  continue;
617
+ if (this.quarantined(best.symbol, ctx))
618
+ continue;
619
+ if (await this.heldUntracked(best.symbol, best.mode === "spot-perp" ? [best.shortExchange] : [best.longExchange, best.shortExchange], adapters, ctx, livePositions))
620
+ continue;
621
+ this._traded.add(best.symbol.toUpperCase());
531
622
  // Cooldown: skip if entry recently failed for this symbol
532
623
  const entryKey = `${best.symbol}:entry`;
533
624
  const entryCooldown = this._failCooldown.get(entryKey) ?? 0;
@@ -603,96 +694,77 @@ export class FundingArbStrategy {
603
694
  continue;
604
695
  }
605
696
  ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
606
- // Step 1: Execute long leg first
607
- let longResult;
697
+ // Step 1: Execute long leg first (verified by the position delta)
698
+ const errText = (e) => e instanceof Error ? e.message : String(e);
699
+ const sizeDec = sizeDecimalsOf(matched.size);
700
+ let longFilled;
608
701
  try {
609
- longResult = await longAdapter.marketOrder(best.symbol, "buy", matched.size);
610
- logExecution({ type: "arb_entry", exchange: best.longExchange, symbol: best.symbol, side: "buy", size: matched.size, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "long", response: longResult } });
702
+ const lf = await placePerpVerified(longAdapter, best.longExchange, best.symbol, "buy", matched.size);
703
+ longFilled = lf.filledSize;
704
+ logExecution({ type: "arb_entry", exchange: best.longExchange, symbol: best.symbol, side: "buy", size: matched.size, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "long", response: lf.raw, measuredFill: lf.filledSize } });
611
705
  }
612
706
  catch (longErr) {
613
- const msg = longErr instanceof Error ? longErr.message : String(longErr);
707
+ const msg = errText(longErr);
708
+ logExecution({ type: "arb_entry", exchange: best.longExchange, symbol: best.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp", leg: "long", pending: isOutcomeUnknown(longErr) } });
709
+ if (isOutcomeUnknown(longErr)) {
710
+ this.quarantine(best.symbol, `long leg ${matched.size} on ${best.longExchange} outcome unknown (${msg}) — no short placed`, best.longExchange, ctx);
711
+ continue;
712
+ }
614
713
  ctx.log(` [ARB] Long leg failed for ${best.symbol}: ${msg} (cooldown 5m)`);
615
- logExecution({ type: "arb_entry", exchange: best.longExchange, symbol: best.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp", leg: "long" } });
616
714
  this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
617
715
  continue;
618
716
  }
619
- // Step 2: Execute short leg
717
+ // Step 2: Execute short leg sized to the VERIFIED long fill (not the requested size)
718
+ const longOpen = fillSizeString(longFilled, sizeDec);
719
+ let shortFilled;
620
720
  try {
621
- const shortResult = await shortAdapter.marketOrder(best.symbol, "sell", matched.size);
622
- logExecution({ type: "arb_entry", exchange: best.shortExchange, symbol: best.symbol, side: "sell", size: matched.size, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "short", response: shortResult } });
721
+ const sf = await placePerpVerified(shortAdapter, best.shortExchange, best.symbol, "sell", longOpen);
722
+ shortFilled = sf.filledSize;
723
+ logExecution({ type: "arb_entry", exchange: best.shortExchange, symbol: best.symbol, side: "sell", size: longOpen, notional: matched.notional / 2, status: "success", dryRun: false, meta: { mode: "perp-perp", leg: "short", response: sf.raw, measuredFill: sf.filledSize } });
623
724
  }
624
725
  catch (shortErr) {
625
- const msg = shortErr instanceof Error ? shortErr.message : String(shortErr);
726
+ const msg = errText(shortErr);
727
+ logExecution({ type: "arb_entry", exchange: best.shortExchange, symbol: best.symbol, side: "sell", size: longOpen, status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp", leg: "short", pending: isOutcomeUnknown(shortErr) } });
728
+ if (isOutcomeUnknown(shortErr)) {
729
+ // The short may exist: closing the long now could leave a naked short.
730
+ this.quarantine(best.symbol, `short leg ${longOpen} on ${best.shortExchange} outcome unknown (${msg}); long ${longOpen} open on ${best.longExchange} — not rolled back`, `${best.longExchange}/${best.shortExchange}`, ctx);
731
+ continue;
732
+ }
626
733
  ctx.log(` [ARB] Short leg failed for ${best.symbol}: ${msg} — ROLLING BACK long leg (cooldown 5m)`);
627
- logExecution({ type: "arb_entry", exchange: best.shortExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "perp-perp", leg: "short" } });
628
- // Rollback: close the long position
734
+ // Rollback: close what the long leg actually filled (reduce-only, verified)
629
735
  try {
630
- await longAdapter.marketOrder(best.symbol, "sell", matched.size, { reduceOnly: true });
631
- ctx.log(` [ARB] Rollback success: closed long ${matched.size} ${best.symbol} on ${best.longExchange}`);
632
- logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
736
+ const rb = await closePerpVerified(longAdapter, best.longExchange, best.symbol, "sell", longOpen, { decimals: sizeDec });
737
+ if (rb.residual > 0) {
738
+ const rbMsg = `rollback closed ${fillSizeString(rb.closed, sizeDec)} of ${longOpen}; ${fillSizeString(rb.residual, sizeDec)} still open${rb.note !== null ? ` (${rb.note})` : ""}`;
739
+ logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: longOpen, status: "failed", error: rbMsg, dryRun: false });
740
+ this.quarantine(best.symbol, `short leg failed (${msg}); ${rbMsg} — orphaned long on ${best.longExchange}`, best.longExchange, ctx);
741
+ continue;
742
+ }
743
+ ctx.log(` [ARB] Rollback success: closed long ${longOpen} ${best.symbol} on ${best.longExchange}`);
744
+ logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: longOpen, status: "success", dryRun: false });
633
745
  }
634
746
  catch (rbErr) {
635
- const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
636
- ctx.log(` [ARB] CRITICAL: Rollback failed for ${best.symbol}: ${rbMsg}`);
637
- logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
638
- ctx.log(` [ARB] CRITICAL: Orphaned long position on ${best.longExchange} for ${best.symbol} — operator must close manually`);
747
+ const rbMsg = errText(rbErr);
748
+ logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: longOpen, status: "failed", error: rbMsg, dryRun: false });
749
+ this.quarantine(best.symbol, `short leg failed (${msg}) and the long rollback failed or is unconfirmed (${rbMsg}) — orphaned long ${longOpen} on ${best.longExchange}`, best.longExchange, ctx);
750
+ continue;
639
751
  }
640
752
  this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 5 * 60 * 1000);
641
753
  continue;
642
754
  }
643
- // Verify fills match
755
+ // Step 3: residual — the short filled less than the long: trim the long by exactly
756
+ // this trade's excess (reduce-only, verified). Not trimmed → unhedged → quarantine.
757
+ const hedgedSize = fillSizeString(shortFilled, sizeDec);
644
758
  try {
645
- const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
646
- if (!recon.matched) {
647
- ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
648
- }
649
- }
650
- catch (reconErr) {
651
- const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
652
- ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
759
+ const trimmed = await trimFirstLegToHedge(longAdapter, best.longExchange, best.symbol, "buy", longFilled, shortFilled, sizeDec);
760
+ if (trimmed !== "0")
761
+ ctx.log(` [ARB] Short filled ${hedgedSize} of ${longOpen} — trimmed long by ${trimmed} (reduce-only)`);
653
762
  }
654
- // Verify both positions actually exist (catches instant liquidation)
655
- // Invalidate cache so getPositions returns fresh data
656
- const { invalidateCache } = await import("../../cache.js");
657
- invalidateCache("acct");
658
- await new Promise(resolve => setTimeout(resolve, 2000)); // brief wait for exchange settlement
659
- try {
660
- const [longPositions, shortPositions] = await Promise.all([
661
- longAdapter.getPositions(),
662
- shortAdapter.getPositions(),
663
- ]);
664
- const symUpper = best.symbol.toUpperCase();
665
- const matchSymbol = (s) => { const u = s.toUpperCase(); return u === symUpper || u === symUpper + "-PERP" || u.replace(/-PERP$/, "") === symUpper; };
666
- const longExists = longPositions.some(p => matchSymbol(p.symbol) && parseFloat(p.size) > 0);
667
- const shortExists = shortPositions.some(p => matchSymbol(p.symbol) && parseFloat(p.size) > 0);
668
- if (!longExists && !shortExists) {
669
- // Both missing likely means cache issue — log warning but don't rollback
670
- ctx.log(` [ARB] Position verification: both sides not visible for ${best.symbol} (may be cache delay)`);
671
- }
672
- else if (!longExists || !shortExists) {
673
- // One side missing = instant liquidation — rollback the other
674
- ctx.log(` [ARB] Position verification failed for ${best.symbol}: long=${longExists}, short=${shortExists}`);
675
- logExecution({ type: "arb_entry", exchange: `${best.longExchange}↔${best.shortExchange}`, symbol: best.symbol, side: "verify", size: matched.size, status: "failed", error: `Position missing after fill: long=${longExists} short=${shortExists}`, dryRun: false });
676
- if (longExists && !shortExists) {
677
- try {
678
- await longAdapter.marketOrder(best.symbol, "sell", matched.size, { reduceOnly: true });
679
- ctx.log(` [ARB] Rollback: closed phantom long ${best.symbol}`);
680
- }
681
- catch { /* best effort */ }
682
- }
683
- if (shortExists && !longExists) {
684
- try {
685
- await shortAdapter.marketOrder(best.symbol, "buy", matched.size, { reduceOnly: true });
686
- ctx.log(` [ARB] Rollback: closed phantom short ${best.symbol}`);
687
- }
688
- catch { /* best effort */ }
689
- }
690
- this._failCooldown.set(`${best.symbol}:entry`, Date.now() + 10 * 60 * 1000);
691
- continue;
692
- }
693
- }
694
- catch (verifyErr) {
695
- ctx.log(` [ARB] Position verification error for ${best.symbol}: ${verifyErr instanceof Error ? verifyErr.message : String(verifyErr)}`);
763
+ catch (trimErr) {
764
+ const trimMsg = errText(trimErr);
765
+ logExecution({ type: "multi_leg_rollback", exchange: best.longExchange, symbol: best.symbol, side: "sell", size: fillSizeString(longFilled - shortFilled, sizeDec), status: "failed", error: trimMsg, dryRun: false, meta: { mode: "perp-perp", reason: "residual trim" } });
766
+ this.quarantine(best.symbol, `short filled ${hedgedSize} of ${longOpen}; trimming the long's excess on ${best.longExchange} failed or is unconfirmed (${trimMsg}) — long ${longOpen} vs short ${hedgedSize}`, `${best.longExchange}/${best.shortExchange}`, ctx);
767
+ continue;
696
768
  }
697
769
  // Track position
698
770
  const currentPositions = ctx.state.get("arbOpenPositions");
@@ -701,7 +773,7 @@ export class FundingArbStrategy {
701
773
  longExchange: best.longExchange,
702
774
  shortExchange: best.shortExchange,
703
775
  entrySpread: best.spread,
704
- size: matched.size,
776
+ size: hedgedSize,
705
777
  mode: "perp-perp",
706
778
  entryTime: Date.now(),
707
779
  });
@@ -724,15 +796,22 @@ export class FundingArbStrategy {
724
796
  async onStop(ctx) {
725
797
  const openPositions = ctx.state.get("arbOpenPositions");
726
798
  const extraAdapters = ctx.state.get("extraAdapters");
799
+ const adapters = new Map();
800
+ adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
801
+ if (extraAdapters) {
802
+ for (const [name, a] of extraAdapters)
803
+ adapters.set(name, a);
804
+ }
805
+ for (const p of openPositions ?? [])
806
+ this._traded.add(p.symbol.toUpperCase());
727
807
  if (openPositions && openPositions.length > 0) {
728
- const adapters = new Map();
729
- adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
730
- if (extraAdapters) {
731
- for (const [name, a] of extraAdapters)
732
- adapters.set(name, a);
733
- }
734
808
  const remainingPositions = [];
735
809
  for (const pos of openPositions) {
810
+ if (this.quarantined(pos.symbol, ctx)) {
811
+ ctx.log(` [ARB] CRITICAL: ${pos.symbol} is quarantined — left open on stop; resolve manually (perp strategy quarantine)`);
812
+ remainingPositions.push(pos);
813
+ continue;
814
+ }
736
815
  if (pos.mode === "spot-perp") {
737
816
  const closed = await this.closeSpotPerp(pos, adapters, ctx);
738
817
  if (!closed) {
@@ -745,45 +824,9 @@ export class FundingArbStrategy {
745
824
  const shortAdapter = adapters.get(pos.shortExchange);
746
825
  if (longAdapter && shortAdapter) {
747
826
  ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
748
- let closeLongOk = false;
749
- try {
750
- const closeLongResult = await longAdapter.marketOrder(pos.symbol, "sell", pos.size, { reduceOnly: true });
751
- logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "long", stop: true, response: closeLongResult } });
752
- closeLongOk = true;
753
- }
754
- catch (err) {
755
- const msg = err instanceof Error ? err.message : String(err);
756
- ctx.log(` [ARB] Close on stop failed (long) for ${pos.symbol}: ${msg}`);
757
- logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "long", stop: true } });
758
- }
759
- if (!closeLongOk) {
760
- ctx.log(` [ARB] CRITICAL: ${pos.symbol} long close failed on stop — skipping short close to preserve hedge`);
761
- remainingPositions.push(pos);
762
- continue;
763
- }
764
- let closeShortOk = false;
765
- try {
766
- const closeShortResult = await shortAdapter.marketOrder(pos.symbol, "buy", pos.size, { reduceOnly: true });
767
- logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "short", stop: true, response: closeShortResult } });
768
- closeShortOk = true;
769
- }
770
- catch (err) {
771
- const msg = err instanceof Error ? err.message : String(err);
772
- ctx.log(` [ARB] Close on stop failed (short) for ${pos.symbol}: ${msg} — re-opening long to restore hedge`);
773
- logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short", stop: true } });
774
- // Rollback: re-open long to restore hedge
775
- try {
776
- await longAdapter.marketOrder(pos.symbol, "buy", pos.size);
777
- ctx.log(` [ARB] Rollback: re-opened long ${pos.size} ${pos.symbol} on ${pos.longExchange}`);
778
- logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false });
779
- }
780
- catch (rbErr) {
781
- const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
782
- ctx.log(` [ARB] CRITICAL: Stop rollback failed for ${pos.symbol}: ${rbMsg}`);
783
- logExecution({ type: "multi_leg_rollback", exchange: pos.longExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: rbMsg, dryRun: false });
784
- }
785
- }
786
- if (!closeShortOk) {
827
+ const closed = await this.closePerpPerp(pos, longAdapter, shortAdapter, ctx, { signedSpread: null, stop: true });
828
+ if (!closed) {
829
+ ctx.log(` [ARB] CRITICAL: ${pos.symbol} could not be fully closed on stop — requires manual cleanup`);
787
830
  remainingPositions.push(pos);
788
831
  }
789
832
  }
@@ -796,90 +839,171 @@ export class FundingArbStrategy {
796
839
  ctx.state.set("arbOpenPositions", remainingPositions);
797
840
  ctx.state.set("arbPositions", remainingPositions.length);
798
841
  }
799
- // Cancel on extra adapters directly
800
- if (extraAdapters) {
801
- for (const [, a] of extraAdapters) {
802
- try {
803
- await a.cancelAllOrders();
804
- }
805
- catch { /* best effort */ }
806
- }
807
- }
808
- return [{ type: "cancel_all" }];
842
+ // Cancel only the bot's own symbols on each venue it used — never an account-wide cancel
843
+ // (it would cancel the user's manual orders on every market).
844
+ await cancelBotSymbols(adapters, this._traded, "[ARB]", ctx.log);
845
+ return [];
809
846
  }
810
- /** Close a spot-perp position: sell spot, transfer USDC back, close perp short.
811
- * Returns true only if ALL steps succeed. If spot sell succeeds but perp close fails,
812
- * attempts to re-buy spot as rollback and returns false. */
813
- async closeSpotPerp(pos, adapters, ctx) {
814
- const exchangeName = pos.longExchange.replace("-spot", "");
815
- const perpAdapter = adapters.get(pos.shortExchange) ?? adapters.get(exchangeName);
816
- if (!perpAdapter) {
817
- ctx.log(` [ARB] Close spot-perp failed: no adapter for ${pos.shortExchange}`);
818
- return false;
819
- }
847
+ /**
848
+ * Close a perp-perp position: long then short, both reduce-only and verified by the
849
+ * position delta. Returns true when both legs are closed (or already gone).
850
+ * - an unknown leg outcome → quarantine (never retried / rolled back blindly);
851
+ * - long close definitely failed → hedge intact, cooldown; MAX_CLOSE_FAILURES in a row →
852
+ * quarantine (no endless retry);
853
+ * - short close definitely failed after THIS call closed the long → quarantine at once
854
+ * (naked short). The long is never re-opened (a non-reduce-only order that could loop
855
+ * close → fail → re-open every tick), same as closePerpPairVerified;
856
+ * - short close failed with the long already gone → one more reduce-only close of the
857
+ * orphaned short; if that fails → quarantine.
858
+ */
859
+ async closePerpPerp(pos, longAdapter, shortAdapter, ctx, meta) {
860
+ const errText = (e) => e instanceof Error ? e.message : String(e);
861
+ const venues = `${pos.longExchange}/${pos.shortExchange}`;
862
+ const cooldown = (ms) => { if (!meta.stop)
863
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + ms); };
864
+ let closeLongOk = false;
865
+ let longPositionGone = false;
820
866
  try {
821
- const spotAdapter = await getSpotAdapter(exchangeName, perpAdapter);
822
- if (!spotAdapter) {
823
- ctx.log(` [ARB] Close spot-perp failed: spot adapter not available for ${exchangeName}`);
867
+ const lc = await closePerpVerified(longAdapter, pos.longExchange, pos.symbol, "sell", pos.size);
868
+ if (lc.residual > 0) {
869
+ // Partial close survived one residual close: legs unbalanced → quarantine (short left open).
870
+ logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: `closed ${lc.closed}, residual ${lc.residual}`, dryRun: false, meta: { mode: pos.mode, leg: "long", stop: meta.stop } });
871
+ this.quarantine(pos.symbol, `close of long on ${pos.longExchange} closed ${lc.closed} of ${pos.size}, ${lc.residual} still open${lc.note !== null ? ` (${lc.note})` : ""}; short ${pos.size} on ${pos.shortExchange} left open`, venues, ctx);
824
872
  return false;
825
873
  }
826
- ctx.log(` [ARB] Closing spot-perp ${pos.symbol} (spot sell + perp buy)`);
827
- // Step 1: Sell spot
828
- let spotSold = false;
829
- try {
830
- await spotAdapter.spotMarketOrder(pos.symbol, "sell", pos.size);
831
- ctx.log(` [ARB] Sold spot ${pos.size} ${pos.symbol}`);
832
- spotSold = true;
833
- }
834
- catch (err) {
835
- const msg = err instanceof Error ? err.message : String(err);
836
- ctx.log(` [ARB] Spot sell failed for ${pos.symbol}: ${msg}`);
874
+ closeLongOk = true;
875
+ logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "long", stop: meta.stop, signedSpread: meta.signedSpread, measuredClose: lc.closed } });
876
+ }
877
+ catch (err) {
878
+ const msg = errText(err);
879
+ if (isOutcomeUnknown(err)) {
880
+ this.quarantine(pos.symbol, `close of long ${pos.size} on ${pos.longExchange} outcome unknown (${msg}) — short left open`, venues, ctx);
837
881
  return false;
838
882
  }
839
- // Step 2: Transfer USDC back to perp (use 95% of notional as estimate)
840
- try {
841
- const perpSymbol = getPerpSymbol(pos.symbol, pos.shortExchange);
842
- const priceEstimate = await getPriceEstimate(perpAdapter, perpSymbol, pos.symbol);
843
- const proceeds = priceEstimate * parseFloat(pos.size) * 0.95;
844
- if (proceeds > 1) {
845
- await transferUsdcToPerp(spotAdapter, exchangeName, Math.floor(proceeds));
846
- ctx.log(` [ARB] Transferred ~$${Math.floor(proceeds)} USDC back to perp`);
883
+ if (isPositionGone(err)) {
884
+ longPositionGone = true;
885
+ ctx.log(` [ARB] Long position gone for ${pos.symbol} on ${pos.longExchange} (liquidated?) — closing short`);
886
+ // Cooldown the exchange for 30 minutes to prevent liquidation-entry cycle
887
+ this._failCooldown.set(`${pos.longExchange}:exchange`, Date.now() + 30 * 60 * 1000);
888
+ }
889
+ else {
890
+ logExecution({ type: "arb_close", exchange: pos.longExchange, symbol: pos.symbol, side: "sell", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "long", stop: meta.stop } });
891
+ const n = (this._closeFailures.get(pos.symbol) ?? 0) + 1;
892
+ this._closeFailures.set(pos.symbol, n);
893
+ if (n >= MAX_CLOSE_FAILURES) {
894
+ this.quarantine(pos.symbol, `close of long on ${pos.longExchange} failed ${n} times in a row (last: ${msg}) — hedge intact, stopping automatic close attempts`, venues, ctx);
895
+ return false;
847
896
  }
897
+ ctx.log(` [ARB] Close long leg failed for ${pos.symbol}: ${msg} — hedge intact (${n}/${MAX_CLOSE_FAILURES})${meta.stop ? "" : " (cooldown 5m)"}`);
898
+ cooldown(5 * 60 * 1000);
899
+ return false;
848
900
  }
849
- catch (err) {
850
- const msg = err instanceof Error ? err.message : String(err);
851
- ctx.log(` [ARB] USDC transfer back failed: ${msg}`);
852
- // Non-fatal: continue to close perp short
901
+ }
902
+ try {
903
+ const sc = await closePerpVerified(shortAdapter, pos.shortExchange, pos.symbol, "buy", pos.size);
904
+ if (sc.residual > 0) {
905
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: `closed ${sc.closed}, residual ${sc.residual}`, dryRun: false, meta: { mode: pos.mode, leg: "short", stop: meta.stop } });
906
+ this.quarantine(pos.symbol, `close of short on ${pos.shortExchange} closed ${sc.closed} of ${pos.size}, ${sc.residual} still open${sc.note !== null ? ` (${sc.note})` : ""}${closeLongOk ? "; the long is closed — residual short unhedged" : ""}`, venues, ctx);
907
+ return false;
853
908
  }
854
- // Step 3: Close perp short
855
- const perpSymbol = getPerpSymbol(pos.symbol, pos.shortExchange);
856
- try {
857
- await perpAdapter.marketOrder(perpSymbol, "buy", pos.size, { reduceOnly: true });
858
- ctx.log(` [ARB] Closed perp short ${pos.size} ${perpSymbol}`);
859
- return true;
909
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "success", dryRun: false, meta: { mode: pos.mode, leg: "short", stop: meta.stop, signedSpread: meta.signedSpread, measuredClose: sc.closed } });
910
+ }
911
+ catch (err) {
912
+ const msg = errText(err);
913
+ if (isOutcomeUnknown(err)) {
914
+ this.quarantine(pos.symbol, `close of short ${pos.size} on ${pos.shortExchange} outcome unknown (${msg})${closeLongOk ? "; the long is closed" : ""}`, venues, ctx);
915
+ return false;
860
916
  }
861
- catch (err) {
862
- const msg = err instanceof Error ? err.message : String(err);
863
- ctx.log(` [ARB] Perp close failed for ${pos.symbol}: ${msg}`);
864
- // Rollback: re-buy spot to restore hedge
865
- if (spotSold) {
866
- try {
867
- await spotAdapter.spotMarketOrder(pos.symbol, "buy", pos.size);
868
- ctx.log(` [ARB] Rollback: re-bought spot ${pos.size} ${pos.symbol} on ${exchangeName}`);
917
+ if (isPositionGone(err)) {
918
+ ctx.log(` [ARB] Short position also gone for ${pos.symbol} on ${pos.shortExchange} — removing from tracking`);
919
+ this._failCooldown.set(`${pos.shortExchange}:exchange`, Date.now() + 30 * 60 * 1000);
920
+ }
921
+ else if (closeLongOk) {
922
+ // This call closed the long but the short close definitely failed: the short is naked.
923
+ // Quarantine now — never re-open the long (a non-reduce-only order; close → fail →
924
+ // re-open could repeat every tick). Same as closePerpPairVerified.
925
+ logExecution({ type: "arb_close", exchange: pos.shortExchange, symbol: pos.symbol, side: "buy", size: pos.size, status: "failed", error: msg, dryRun: false, meta: { mode: pos.mode, leg: "short", stop: meta.stop } });
926
+ this.quarantine(pos.symbol, `long closed on ${pos.longExchange}; close of short ${pos.size} on ${pos.shortExchange} failed (${msg}) — naked short`, venues, ctx);
927
+ return false;
928
+ }
929
+ else {
930
+ // Long already gone and the short close failed: one more reduce-only close of the
931
+ // orphaned short (never a plain order — it must not flip into a long).
932
+ ctx.log(` [ARB] Closing orphaned short for ${pos.symbol} (reduce-only): ${msg}`);
933
+ try {
934
+ const oc = await closePerpVerified(shortAdapter, pos.shortExchange, pos.symbol, "buy", pos.size);
935
+ if (oc.residual > 0) {
936
+ this.quarantine(pos.symbol, `long gone on ${pos.longExchange}; closing the orphaned short closed ${oc.closed} of ${pos.size}, ${oc.residual} still open${oc.note !== null ? ` (${oc.note})` : ""}`, venues, ctx);
937
+ return false;
869
938
  }
870
- catch (rbErr) {
871
- const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
872
- ctx.log(` [ARB] CRITICAL: Spot-perp close rollback failed for ${pos.symbol}: ${rbMsg}`);
939
+ ctx.log(` [ARB] Closed orphaned short ${pos.size} ${pos.symbol} on ${pos.shortExchange}`);
940
+ }
941
+ catch (forceErr) {
942
+ const forceMsg = errText(forceErr);
943
+ if (!isPositionGone(forceErr)) {
944
+ this.quarantine(pos.symbol, `long gone on ${pos.longExchange} and closing the orphaned short failed (${forceMsg}) — naked short ${pos.size} on ${pos.shortExchange}`, venues, ctx);
945
+ return false;
873
946
  }
874
947
  }
875
- return false;
876
948
  }
877
949
  }
878
- catch (err) {
879
- const msg = err instanceof Error ? err.message : String(err);
880
- ctx.log(` [ARB] Close spot-perp failed for ${pos.symbol}: ${msg}`);
950
+ this._closeFailures.delete(pos.symbol);
951
+ ctx.log(` [ARB] Closed ${pos.symbol} position${longPositionGone ? " (long was liquidated)" : ""}`);
952
+ return true;
953
+ }
954
+ /**
955
+ * Close a spot-perp position via SpotPerpExecutor.closePosition (perp short FIRST,
956
+ * reduce-only and verified; spot sold only after; a perp already gone is detected by
957
+ * the reduce-only rejection, never by selling spot again). Returns true only when
958
+ * fully closed. Unknown / unhedged outcomes quarantine the symbol; a plain failure
959
+ * cools down, and MAX_CLOSE_FAILURES consecutive failures quarantine it — each attempt
960
+ * can trade (perp close → spot sell fail → re-hedge), so it never loops.
961
+ */
962
+ async closeSpotPerp(pos, adapters, ctx) {
963
+ const exchangeName = pos.longExchange.replace("-spot", "");
964
+ const perpAdapter = adapters.get(pos.shortExchange) ?? adapters.get(exchangeName);
965
+ if (!perpAdapter) {
966
+ ctx.log(` [ARB] Close spot-perp failed: no adapter for ${pos.shortExchange}`);
881
967
  return false;
882
968
  }
969
+ const spotAdapter = await getBotSpotAdapter(exchangeName, adapters.get(exchangeName) ?? perpAdapter);
970
+ if (!spotAdapter) {
971
+ ctx.log(` [ARB] Close spot-perp failed: ${exchangeName} is not a bot spot venue`);
972
+ return false;
973
+ }
974
+ ctx.log(` [ARB] Closing spot-perp ${pos.symbol} (perp buy reduce-only, then spot sell)`);
975
+ const closed = await this.executor.closePosition({
976
+ symbol: pos.symbol, spotExchange: exchangeName, perpExchange: pos.shortExchange,
977
+ spotAdapter, perpAdapter, size: pos.size, log: ctx.log,
978
+ });
979
+ // The pair in the arb state (`arb status` / `arb close`): removed on a full close, legs set to the remainder on a partial one.
980
+ const pair = { symbol: pos.symbol, spotExchange: exchangeName, perpExchange: pos.shortExchange };
981
+ if (closed.closed) {
982
+ recordSpotPerpClose(spotAdapter, pair, null, "[ARB]", ctx.log);
983
+ this._closeFailures.delete(pos.symbol);
984
+ return true;
985
+ }
986
+ if (closed.manual) {
987
+ this.quarantine(pos.symbol, closed.reason, exchangeName, ctx);
988
+ return false;
989
+ }
990
+ if (closed.remainingSize !== null) {
991
+ // Partial spot sell: the remainder stays a hedged position (state updated in place).
992
+ ctx.log(` [ARB] Partial close of ${pos.symbol}: ${closed.remainingSize} remains open (spot + perp)`);
993
+ pos.size = closed.remainingSize;
994
+ recordSpotPerpClose(spotAdapter, pair, closed.remainingSize, "[ARB]", ctx.log);
995
+ this._closeFailures.delete(pos.symbol);
996
+ return false;
997
+ }
998
+ const n = (this._closeFailures.get(pos.symbol) ?? 0) + 1;
999
+ this._closeFailures.set(pos.symbol, n);
1000
+ if (n >= MAX_CLOSE_FAILURES) {
1001
+ this.quarantine(pos.symbol, `spot-perp close failed ${n} times in a row — stopping automatic close attempts`, exchangeName, ctx);
1002
+ }
1003
+ else {
1004
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
1005
+ }
1006
+ return false;
883
1007
  }
884
1008
  /** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
885
1009
  async openSpotPerp(opp, adapters, ctx, ratesByExchange) {
@@ -891,9 +1015,9 @@ export class FundingArbStrategy {
891
1015
  return false;
892
1016
  }
893
1017
  try {
894
- const spotAdapter = await getSpotAdapter(exchangeName, perpAdapter);
1018
+ const spotAdapter = await getBotSpotAdapter(exchangeName, perpAdapter);
895
1019
  if (!spotAdapter) {
896
- ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
1020
+ ctx.log(` [ARB] Open spot-perp failed: ${exchangeName} is not a bot spot venue`);
897
1021
  return false;
898
1022
  }
899
1023
  // Determine leverage for perp leg
@@ -962,68 +1086,135 @@ export class FundingArbStrategy {
962
1086
  ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't compute matched size`);
963
1087
  return false;
964
1088
  }
1089
+ // Lot steps from the venue market info: the hedge is floored to the perp lot and fills are
1090
+ // judged with a half-lot tolerance. Unknown → skip before any transfer / order (Rule #2).
1091
+ let perpStep;
1092
+ let spotStep;
1093
+ try {
1094
+ perpStep = await lotStepOf(perpAdapter, perpSymbol);
1095
+ spotStep = await spotLotStepOf(spotAdapter, opp.symbol);
1096
+ }
1097
+ catch (err) {
1098
+ ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: lot step unknown (${err instanceof Error ? err.message : String(err)})`);
1099
+ return false;
1100
+ }
965
1101
  ctx.log(` [ARB] Opening spot-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)}, slippage ~${liq.spotSlippage.toFixed(2)}%/${liq.perpSlippage.toFixed(2)}%)`);
966
1102
  // Step 1: Transfer USDC to spot
967
1103
  const transferAmt = Math.ceil(matched.notional * 1.02); // slight buffer for price movement
968
- await transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
969
- ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
970
- // Step 2: Buy spot
971
- let spotResult;
1104
+ if (await transferUsdcToSpot(spotAdapter, transferAmt))
1105
+ ctx.log(` [ARB] Transferred $${transferAmt} to spot`);
1106
+ // Step 2: Buy spot — the hedge is sized to what actually filled (§12.4 #5).
1107
+ let hedgeSize;
1108
+ let spotFill;
972
1109
  try {
973
- spotResult = await spotAdapter.spotMarketOrder(opp.symbol, "buy", matched.size);
974
- ctx.log(` [ARB] Bought spot ${matched.size} ${opp.symbol}`);
975
- logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "spot", response: spotResult } });
1110
+ const fill = await spotMarketFill(spotAdapter, opp.symbol, "buy", matched.size, spotStep);
1111
+ spotFill = fill;
1112
+ hedgeSize = floorToStep(fill.filledSize, perpStep);
1113
+ ctx.log(` [ARB] Bought spot ${fill.filledSize}${fill.status === "partial" ? ` of ${matched.size} (partial)` : ""} ${opp.symbol}`);
1114
+ logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: String(fill.filledSize), notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "spot", fillStatus: fill.status, requestedSize: matched.size, response: fill.raw } });
976
1115
  }
977
1116
  catch (spotErr) {
978
1117
  const msg = spotErr instanceof Error ? spotErr.message : String(spotErr);
1118
+ const pending = spotErr instanceof PerpError && spotErr.structured.code === "ORDER_PENDING";
979
1119
  ctx.log(` [ARB] Open spot-perp failed (spot buy) for ${opp.symbol}: ${msg}`);
980
- logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "spot" } });
1120
+ logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "spot", pending } });
1121
+ if (pending) {
1122
+ // Outcome unknown: the quote may already be spent — do not move funds or retry.
1123
+ this.quarantine(opp.symbol, `spot buy ${matched.size} on ${exchangeName} is PENDING (not confirmed) — no perp hedge placed, $${transferAmt} left in spot (${msg})`, exchangeName, ctx);
1124
+ return false;
1125
+ }
981
1126
  try {
982
- await transferUsdcToPerp(spotAdapter, exchangeName, transferAmt);
983
- ctx.log(` [ARB] Transferred $${transferAmt} USDC back to perp after spot buy failure`);
1127
+ if (await transferUsdcToPerp(spotAdapter, transferAmt))
1128
+ ctx.log(` [ARB] Transferred $${transferAmt} back to perp after spot buy failure`);
1129
+ }
1130
+ catch (xferErr) {
1131
+ ctx.log(` [ARB] WARNING: $${transferAmt} left in ${exchangeName} spot — transfer back to perp failed: ${xferErr instanceof Error ? xferErr.message : String(xferErr)}`);
984
1132
  }
985
- catch { /* best effort */ }
986
1133
  return false;
987
1134
  }
988
- // Step 3: Short perp
1135
+ if (!(Number(hedgeSize) > 0)) {
1136
+ this.quarantine(opp.symbol, `spot buy filled only dust (below one perp lot ${perpStep}) — unhedged spot remains on ${exchangeName}; no perp short placed`, exchangeName, ctx);
1137
+ return false;
1138
+ }
1139
+ // Step 3: Short perp (filled spot size only), verified by the position delta
1140
+ let shortHedged = hedgeSize;
1141
+ let perpEntryPrice;
989
1142
  try {
990
- const perpResult = await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
991
- ctx.log(` [ARB] Shorted perp ${matched.size} ${perpSymbol}`);
992
- logExecution({ type: "arb_entry", exchange: opp.shortExchange, symbol: opp.symbol, side: "sell", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "perp", response: perpResult } });
1143
+ const pf = await placePerpVerified(perpAdapter, opp.shortExchange, perpSymbol, "sell", hedgeSize, { stepSize: perpStep });
1144
+ shortHedged = floorToStep(pf.filledSize, perpStep);
1145
+ // The venue-reported average fill of the short (null = not reported, never the protection price).
1146
+ const avg = reportedAvgFillPrice(pf.raw);
1147
+ perpEntryPrice = avg === null ? null : Number(avg);
1148
+ if (Number(shortHedged) < Number(hedgeSize)) {
1149
+ // Accepted partial fill (≥ 80% measured, or a venue-final partial IOC fill): spot exceeds the short — never resubmit, quarantine the residual.
1150
+ this.quarantine(opp.symbol, `perp short filled ${shortHedged} of ${hedgeSize} on ${opp.shortExchange}; spot ${hedgeSize} held on ${exchangeName} — ${floorToStep(Number(hedgeSize) - Number(shortHedged), perpStep)} spot unhedged`, exchangeName, ctx);
1151
+ }
1152
+ ctx.log(` [ARB] Shorted perp ${shortHedged} ${perpSymbol}`);
1153
+ logExecution({ type: "arb_entry", exchange: opp.shortExchange, symbol: opp.symbol, side: "sell", size: hedgeSize, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "perp", response: pf.raw, measuredFill: pf.filledSize } });
993
1154
  }
994
1155
  catch (perpErr) {
995
1156
  const msg = perpErr instanceof Error ? perpErr.message : String(perpErr);
1157
+ logExecution({ type: "arb_entry", exchange: opp.shortExchange, symbol: opp.symbol, side: "sell", size: hedgeSize, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "perp", pending: isOutcomeUnknown(perpErr) } });
1158
+ if (isOutcomeUnknown(perpErr)) {
1159
+ // The short may exist: selling the spot back could leave a naked short.
1160
+ this.quarantine(opp.symbol, `perp short ${hedgeSize} on ${opp.shortExchange} outcome unknown (${msg}); ${hedgeSize} spot held on ${exchangeName} — not rolled back`, exchangeName, ctx);
1161
+ return false;
1162
+ }
996
1163
  ctx.log(` [ARB] Open spot-perp failed (perp short) for ${opp.symbol}: ${msg} — ROLLING BACK spot buy`);
997
- logExecution({ type: "arb_entry", exchange: opp.shortExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "perp" } });
998
- // Rollback: sell spot back and transfer USDC back to perp
1164
+ // Rollback: sell spot back, then sweep ONLY this sale's proceeds back to perp
1165
+ // (quote above the pre-sale balance, capped at what this entry transferred in).
1166
+ let preSaleQuote;
999
1167
  try {
1000
- await spotAdapter.spotMarketOrder(opp.symbol, "sell", matched.size);
1001
- ctx.log(` [ARB] Rollback success: sold spot ${matched.size} ${opp.symbol}`);
1002
- logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
1003
- // Transfer USDC back to perp account
1004
- try {
1005
- await transferUsdcToPerp(spotAdapter, opp.longExchange.replace("-spot", ""), Math.ceil(matched.notional * 1.02));
1168
+ preSaleQuote = await readSpotQuoteAvailableFresh(spotAdapter);
1169
+ }
1170
+ catch (qErr) {
1171
+ preSaleQuote = null;
1172
+ ctx.log(` [ARB] WARNING: ${exchangeName} spot quote unreadable before the rollback sale (${qErr instanceof Error ? qErr.message : String(qErr)}) — proceeds will stay in spot`);
1173
+ }
1174
+ try {
1175
+ const rb = await spotMarketFill(spotAdapter, opp.symbol, "sell", hedgeSize, spotStep);
1176
+ ctx.log(` [ARB] Rollback ${rb.status === "partial" ? "PARTIAL" : "success"}: sold spot ${rb.filledSize} of ${hedgeSize} ${opp.symbol}`);
1177
+ if (rb.status === "partial")
1178
+ this.quarantine(opp.symbol, `entry rollback sold only ${rb.filledSize} of ${hedgeSize} — unhedged spot remains on ${exchangeName}`, exchangeName, ctx);
1179
+ logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: String(rb.filledSize), status: rb.status === "partial" ? "failed" : "success", dryRun: false });
1180
+ if (preSaleQuote !== null) {
1181
+ try {
1182
+ const moved = await sweepSpotQuoteToPerp(spotAdapter, 1, { keep: preSaleQuote, max: transferAmt });
1183
+ if (moved > 0)
1184
+ ctx.log(` [ARB] Transferred rollback proceeds $${moved} back to perp (unspent transfer buffer stays in ${exchangeName} spot)`);
1185
+ }
1186
+ catch (xferErr) {
1187
+ ctx.log(` [ARB] WARNING: rollback proceeds left in ${exchangeName} spot — transfer back to perp failed: ${xferErr instanceof Error ? xferErr.message : String(xferErr)}`);
1188
+ }
1006
1189
  }
1007
- catch { /* best effort */ }
1008
1190
  }
1009
1191
  catch (rbErr) {
1010
1192
  const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
1011
1193
  ctx.log(` [ARB] CRITICAL: Spot rollback failed for ${opp.symbol}: ${rbMsg}`);
1194
+ this.quarantine(opp.symbol, `perp short failed and the spot rollback sell failed (${rbMsg}) — unhedged spot ${hedgeSize} remains on ${exchangeName}`, exchangeName, ctx);
1012
1195
  logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "failed", error: rbMsg, dryRun: false });
1013
1196
  }
1014
1197
  return false;
1015
1198
  }
1016
1199
  // Track position
1017
1200
  const currentPositions = ctx.state.get("arbOpenPositions");
1201
+ const entryTime = Date.now();
1018
1202
  currentPositions.push({
1019
1203
  symbol: opp.symbol,
1020
1204
  longExchange: opp.longExchange,
1021
1205
  shortExchange: opp.shortExchange,
1022
1206
  entrySpread: opp.spread,
1023
- size: matched.size,
1207
+ size: shortHedged,
1024
1208
  mode: "spot-perp",
1025
- entryTime: Date.now(),
1209
+ entryTime,
1026
1210
  });
1211
+ // Recorded in the arb state with the actual legs, so `arb status` confirms it and `arb close` finds it.
1212
+ recordSpotPerpEntry(spotAdapter, {
1213
+ id: genPosId(), symbol: opp.symbol, spotExchange: exchangeName, perpExchange: opp.shortExchange,
1214
+ entryTime, entrySpread: opp.spread,
1215
+ spotSize: spotFill.filledSize, perpSize: Number(shortHedged),
1216
+ spotAvgPrice: spotFill.avgPrice, perpEntryPrice,
1217
+ }, "[ARB]", ctx.log);
1027
1218
  ctx.state.set("arbOpenPositions", currentPositions);
1028
1219
  ctx.state.set("arbPositions", currentPositions.length);
1029
1220
  ctx.log(` [ARB] Spot-perp position opened! (${currentPositions.length}/${params.max_positions})`);