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
@@ -8,27 +8,36 @@ import { logExecution } from "../../execution-log.js";
8
8
  import { invalidateCache } from "../../cache.js";
9
9
  import { checkSpotPerpLiquidity } from "../../liquidity.js";
10
10
  import { computeSpotPerpMatchedSize } from "../../arb-sizing.js";
11
- import { getSpotAdapter as getSpotAdapterUtil, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, matchSymbol, getPriceEstimate, isPositionGone, } from "./funding-arb-utils.js";
11
+ import { findSpotQuoteBalance, getBotSpotAdapter, spotNetworkOf, spotTokenBase } from "../../exchanges/spot-factory.js";
12
+ import { getVenue } from "../../exchanges/venue.js";
13
+ import { PerpError } from "../../errors.js";
14
+ import { fillSizeString, readSpotBalancesFresh, sizeDecimalsOf, spotLotStepOf, spotMarketFill } from "../../exchanges/spot-result.js";
15
+ import { floorToStep, lotStepOf } from "../../exchanges/precision.js";
16
+ import { closePerpVerified, isOutcomeUnknown, placePerpVerified } from "../perp-order.js";
17
+ import { transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, matchSymbol, getPriceEstimate, isPositionGone, } from "./funding-arb-utils.js";
12
18
  // ── Executor ──
13
19
  export class SpotPerpExecutor {
14
- _spotAdapterCache = new Map();
15
- /** Get or create cached spot adapter. */
20
+ /** Spot adapter for a bot spot venue (factory-cached); null outside the bot spot scope. */
16
21
  async getSpotAdapter(name, adapter) {
17
- const cached = this._spotAdapterCache.get(name);
18
- if (cached)
19
- return cached;
20
- const spot = await getSpotAdapterUtil(name, adapter);
21
- if (spot)
22
- this._spotAdapterCache.set(name, spot);
23
- return spot;
22
+ return getBotSpotAdapter(name, adapter);
24
23
  }
25
- /** Transfer USDC: perp -> spot account. */
26
- async transferToSpot(spotAdapter, exchange, amount) {
27
- await transferUsdcToSpot(spotAdapter, exchange, amount);
24
+ /** Transfer the spot quote asset perp -> spot. false = shared margin (nothing moved). */
25
+ async transferToSpot(spotAdapter, amount) {
26
+ return transferUsdcToSpot(spotAdapter, amount);
28
27
  }
29
- /** Transfer USDC: spot -> perp account. */
30
- async transferToPerp(spotAdapter, exchange, amount) {
31
- await transferUsdcToPerp(spotAdapter, exchange, amount);
28
+ /** Transfer the spot quote asset spot -> perp. false = shared margin (nothing moved). */
29
+ async transferToPerp(spotAdapter, amount) {
30
+ return transferUsdcToPerp(spotAdapter, amount);
31
+ }
32
+ /** Rollback transfer spot → perp: failure is logged loudly (funds remain in spot), never swallowed. */
33
+ async returnToPerp(spotAdapter, amount, spotExchange, log) {
34
+ try {
35
+ if (await transferUsdcToPerp(spotAdapter, amount))
36
+ log(` [SPA] Recovered $${amount} back to perp`);
37
+ }
38
+ catch (err) {
39
+ log(` [SPA] WARNING: $${amount} left in ${spotExchange} spot — transfer back to perp failed: ${err instanceof Error ? err.message : String(err)}`);
40
+ }
32
41
  }
33
42
  // isPositionGone() is now in funding-arb-utils.ts
34
43
  /**
@@ -66,31 +75,51 @@ export class SpotPerpExecutor {
66
75
  const spotMarkets = await spotAdapter.getSpotMarkets();
67
76
  const spotDec = spotMarkets.find(m => m.baseToken.toUpperCase() === symbol.toUpperCase())?.sizeDecimals;
68
77
  const perpMarkets = await perpAdapter.getMarkets();
69
- const perpDec = perpMarkets.find(m => matchSymbol(m.symbol, perpSymbol) || matchSymbol(m.symbol, symbol))?.sizeDecimals;
78
+ const perpMarket = perpMarkets.find(m => matchSymbol(m.symbol, perpSymbol) || matchSymbol(m.symbol, symbol));
79
+ let perpDec;
80
+ if (perpMarket !== undefined) {
81
+ // Venue listed the market but did not publish its size precision — sizing would be a guess.
82
+ if (perpMarket.sizeDecimals === null) {
83
+ log(` [SPA] Skip ${symbol}: ${perpExchange} does not publish sizeDecimals for ${perpMarket.symbol}`);
84
+ return { success: false, error: "perp sizeDecimals unknown" };
85
+ }
86
+ perpDec = perpMarket.sizeDecimals;
87
+ }
70
88
  const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, price, spotExchange, perpExchange, spotDec, perpDec);
71
89
  if (!matched) {
72
90
  log(` [SPA] Skip ${symbol}: matched size fail (spotDec=${spotDec}, perpDec=${perpDec}, price=${price.toFixed(2)}, sizeUsd=${liq.adjustedSizeUsd.toFixed(2)})`);
73
91
  return { success: false, error: "matched size fail" };
74
92
  }
75
93
  log(` [SPA] Opening: ${matched.size} ${symbol} ($${matched.notional.toFixed(0)}) ${spotExchange}-spot<>${perpExchange}`);
76
- // Transfer USDC to spot account — only transfer the shortfall, track actual amount
77
- const tAmt = Math.ceil(matched.notional * 1.02);
78
- let spotUsdcAvailable = 0;
94
+ // Lot steps from the venue market info: the hedge is floored to the perp lot and every fill is
95
+ // judged with a half-lot tolerance. Unknown → stop before any transfer / order (Rule #2).
96
+ let perpStep;
97
+ let spotStep;
79
98
  try {
80
- const spotBals = await spotAdapter.getSpotBalances();
81
- const usdcBal = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
82
- spotUsdcAvailable = usdcBal ? parseFloat(usdcBal.available) : 0;
99
+ perpStep = await lotStepOf(perpAdapter, perpSymbol);
100
+ spotStep = await spotLotStepOf(spotAdapter, symbol);
101
+ }
102
+ catch (err) {
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ log(` [SPA] Skip ${symbol}: lot step unknown (${msg})`);
105
+ return { success: false, error: `lot step: ${msg}` };
83
106
  }
84
- catch { /* ignore */ }
107
+ // Transfer USDC to spot account — only transfer the shortfall, track actual amount
108
+ const tAmt = Math.ceil(matched.notional * 1.02);
109
+ const spotDef = getVenue(spotExchange);
110
+ const quoteBal = findSpotQuoteBalance(spotDef, spotNetworkOf(spotAdapter), await spotAdapter.getSpotBalances());
111
+ const spotUsdcAvailable = quoteBal ? parseFloat(quoteBal.available) : 0;
85
112
  let actualTransferred = 0;
86
113
  if (spotUsdcAvailable >= tAmt) {
87
- log(` [SPA] Spot already has $${spotUsdcAvailable.toFixed(2)} USDC, skipping transfer`);
114
+ log(` [SPA] Spot already has $${spotUsdcAvailable.toFixed(2)} ${quoteBal?.token ?? ""}, skipping transfer`);
88
115
  }
89
116
  else {
90
- actualTransferred = Math.ceil(tAmt - spotUsdcAvailable);
117
+ const want = Math.ceil(tAmt - spotUsdcAvailable);
91
118
  try {
92
- await transferUsdcToSpot(spotAdapter, spotExchange, actualTransferred);
93
- log(` [SPA] Transferred $${actualTransferred} USDC to ${spotExchange} spot`);
119
+ if (await transferUsdcToSpot(spotAdapter, want)) {
120
+ actualTransferred = want;
121
+ log(` [SPA] Transferred $${actualTransferred} to ${spotExchange} spot`);
122
+ }
94
123
  }
95
124
  catch (err) {
96
125
  const msg = err instanceof Error ? err.message : String(err);
@@ -98,77 +127,113 @@ export class SpotPerpExecutor {
98
127
  return { success: false, error: `transfer: ${msg}` };
99
128
  }
100
129
  }
101
- // Step 1: Buy spot
130
+ // Pre-trade base balance: the post-buy check measures the DELTA, not the total
131
+ // (pre-existing holdings must not count as "bought"). A failed read aborts before buying.
132
+ let preBase;
133
+ try {
134
+ preBase = await this.baseBalance(spotAdapter, symbol);
135
+ }
136
+ catch (err) {
137
+ const msg = err instanceof Error ? err.message : String(err);
138
+ log(` [SPA] Pre-trade ${symbol} balance read failed: ${msg} — aborting before the spot buy`);
139
+ if (actualTransferred > 0)
140
+ await this.returnToPerp(spotAdapter, actualTransferred, spotExchange, log);
141
+ return { success: false, error: `pre-trade balance: ${msg}` };
142
+ }
143
+ // Step 1: Buy spot — the hedge is sized to what actually filled (§12.4 #5)
144
+ let fill;
102
145
  try {
103
- await spotAdapter.spotMarketOrder(symbol, "buy", matched.size);
104
- logExecution({ type: "arb_entry", exchange: `${spotExchange}-spot`, symbol, side: "buy", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "spot" } });
146
+ fill = await spotMarketFill(spotAdapter, symbol, "buy", matched.size, spotStep);
147
+ if (fill.status === "partial")
148
+ log(` [SPA] Spot buy partial: ${fill.filledSize} of ${matched.size} ${symbol} — hedging the filled size`);
149
+ logExecution({ type: "arb_entry", exchange: `${spotExchange}-spot`, 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 } });
105
150
  }
106
151
  catch (err) {
107
152
  const msg = err instanceof Error ? err.message : String(err);
108
153
  log(` [SPA] Spot buy failed: ${msg}`);
154
+ if (err instanceof PerpError && err.structured.code === "ORDER_PENDING") {
155
+ // Outcome unknown: the quote may be spent — do not move funds back or retry.
156
+ log(` [SPA] CRITICAL: spot buy ${symbol} on ${spotExchange} is PENDING — no perp hedge placed. Verify the spot balance manually; do NOT resubmit.`);
157
+ return { success: false, error: `spot buy ${matched.size} on ${spotExchange} pending (no perp hedge placed): ${msg}`, manual: true };
158
+ }
109
159
  if (actualTransferred > 0) {
110
- try {
111
- await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
112
- log(` [SPA] Recovered $${actualTransferred} USDC back to perp`);
113
- }
114
- catch { /* best effort */ }
160
+ await this.returnToPerp(spotAdapter, actualTransferred, spotExchange, log);
115
161
  }
116
162
  return { success: false, error: `spot buy: ${msg}` };
117
163
  }
118
- // Step 1b: Verify spot actually bought (safety net for "assuming executed" false positive)
164
+ // Step 1b: Verify the buy by balance delta vs the pre-trade balance. A failed
165
+ // verification STOPS (no perp short on an unverified spot leg).
166
+ const hedgeSize = floorToStep(fill.filledSize, perpStep);
119
167
  try {
120
- invalidateCache("acct");
121
- const postBals = await spotAdapter.getSpotBalances();
122
- const bought = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
123
- const boughtAmt = bought ? parseFloat(bought.total) : 0;
124
- if (boughtAmt < parseFloat(matched.size) * 0.8) {
125
- log(` [SPA] Spot buy returned OK but ${symbol} balance too low (${boughtAmt.toFixed(4)} < ${matched.size} × 80%) — aborting`);
126
- if (actualTransferred > 0) {
127
- try {
128
- await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
129
- }
130
- catch { /* best effort */ }
131
- }
132
- return { success: false, error: "spot buy not confirmed by balance" };
168
+ const delta = (await this.baseBalance(spotAdapter, symbol)) - preBase;
169
+ if (delta < fill.filledSize * 0.8) {
170
+ log(` [SPA] CRITICAL: spot buy reported ${fill.filledSize} ${symbol} but the balance rose only ${delta.toFixed(6)} — no perp hedge placed; verify ${spotExchange} spot manually`);
171
+ return { success: false, error: `spot buy reported ${fill.filledSize} but the balance rose only ${delta.toFixed(6)} (no perp hedge placed)`, manual: true };
133
172
  }
134
173
  }
135
- catch { /* balance check failed, proceed optimistically */ }
136
- // Step 2: Short perp
174
+ catch (err) {
175
+ log(` [SPA] CRITICAL: post-buy balance check failed (${err instanceof Error ? err.message : String(err)}) — stopping: ${fill.filledSize} ${symbol} spot is unhedged; verify manually`);
176
+ return { success: false, error: `post-buy balance check failed — ${fill.filledSize} spot unhedged`, manual: true };
177
+ }
178
+ if (!(Number(hedgeSize) > 0)) {
179
+ log(` [SPA] CRITICAL: spot buy ${symbol} filled only dust (${fill.filledSize}, below one ${perpExchange} lot ${perpStep}) — unhedged spot remains on ${spotExchange}`);
180
+ return { success: false, error: `spot fill ${fill.filledSize} below one perp lot (${perpStep}) — unhedged spot remains`, manual: true };
181
+ }
182
+ // Step 2: Short perp (filled size only), verified by the position delta.
137
183
  try {
138
- await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
139
- logExecution({ type: "arb_entry", exchange: perpExchange, symbol, side: "sell", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "perp" } });
184
+ const pf = await placePerpVerified(perpAdapter, perpExchange, perpSymbol, "sell", hedgeSize, { stepSize: perpStep });
185
+ logExecution({ type: "arb_entry", exchange: perpExchange, symbol, side: "sell", size: hedgeSize, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "perp", measuredFill: pf.filledSize } });
186
+ // An accepted partial short (≥ 80% measured, or a venue-final partial IOC fill) leaves
187
+ // part of the spot unhedged: never resubmit — stop for a human (caller quarantines).
188
+ const shorted = floorToStep(pf.filledSize, perpStep);
189
+ if (Number(shorted) < Number(hedgeSize)) {
190
+ log(` [SPA] CRITICAL: perp short filled ${shorted} of ${hedgeSize} ${symbol} on ${perpExchange} — ${floorToStep(Number(hedgeSize) - Number(shorted), perpStep)} spot unhedged`);
191
+ return { success: false, error: `perp short filled ${shorted} of ${hedgeSize} on ${perpExchange}; ${hedgeSize} ${symbol} spot held on ${spotExchange} — spot partly unhedged`, manual: true };
192
+ }
140
193
  }
141
194
  catch (err) {
142
195
  const msg = err instanceof Error ? err.message : String(err);
196
+ if (isOutcomeUnknown(err)) {
197
+ // The short may exist: selling the spot now could leave a naked short. Stop.
198
+ log(` [SPA] CRITICAL: perp short ${hedgeSize} ${symbol} on ${perpExchange} outcome unknown — spot NOT rolled back`);
199
+ return { success: false, error: `perp short outcome unknown (${msg}); ${hedgeSize} ${symbol} spot held on ${spotExchange} — verify the ${perpExchange} position, do NOT resubmit`, manual: true };
200
+ }
143
201
  log(` [SPA] Perp short failed: ${msg} -- ROLLING BACK spot`);
144
202
  try {
145
- await spotAdapter.spotMarketOrder(symbol, "sell", matched.size);
146
- logExecution({ type: "multi_leg_rollback", exchange: `${spotExchange}-spot`, symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
147
- if (actualTransferred > 0) {
148
- try {
149
- await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
150
- }
151
- catch { /* best effort */ }
203
+ const rb = await spotMarketFill(spotAdapter, symbol, "sell", hedgeSize, spotStep);
204
+ logExecution({ type: "multi_leg_rollback", exchange: `${spotExchange}-spot`, symbol, side: "sell", size: String(rb.filledSize), status: rb.status === "partial" ? "failed" : "success", dryRun: false });
205
+ if (rb.status === "partial") {
206
+ log(` [SPA] CRITICAL: spot rollback sold only ${rb.filledSize} of ${hedgeSize} ${symbol} — unhedged spot remains`);
207
+ return { success: false, error: `perp short failed (${msg}); rollback sold only ${rb.filledSize} of ${hedgeSize} — unhedged spot remains`, manual: true };
152
208
  }
209
+ if (actualTransferred > 0)
210
+ await this.returnToPerp(spotAdapter, actualTransferred, spotExchange, log);
153
211
  }
154
212
  catch (rbErr) {
155
- log(` [SPA] CRITICAL: Spot rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
213
+ const rbMsg = rbErr instanceof Error ? rbErr.message : String(rbErr);
214
+ log(` [SPA] CRITICAL: Spot rollback failed: ${rbMsg}`);
215
+ return { success: false, error: `perp short failed (${msg}); spot rollback failed (${rbMsg}) — unhedged spot ${hedgeSize} remains`, manual: true };
156
216
  }
157
217
  return { success: false, error: `perp short: ${msg}` };
158
218
  }
159
- // Position verification
219
+ // Position verification (+ the venue's entry price of the short, recorded in the arb state)
160
220
  invalidateCache("acct");
221
+ let perpEntryPrice = null;
161
222
  try {
162
223
  const perpPositions = await perpAdapter.getPositions();
163
- const hasPerp = perpPositions.some(x => matchSymbol(x.symbol, perpSymbol) && parseFloat(x.size) > 0);
164
- if (!hasPerp) {
224
+ const perpPos = perpPositions.find(x => matchSymbol(x.symbol, perpSymbol) && parseFloat(x.size) !== 0);
225
+ if (!perpPos) {
165
226
  log(` [SPA] WARNING: Perp position not visible (cache delay?)`);
166
227
  }
228
+ else {
229
+ const px = Number(perpPos.entryPrice);
230
+ perpEntryPrice = Number.isFinite(px) && px > 0 ? px : null;
231
+ }
167
232
  }
168
233
  catch (err) {
169
234
  log(` [SPA] Verify error: ${err instanceof Error ? err.message : String(err)}`);
170
235
  }
171
- return { success: true, size: matched.size, notional: matched.notional };
236
+ return { success: true, size: hedgeSize, notional: matched.notional, spotSize: fill.filledSize, spotAvgPrice: fill.avgPrice, perpEntryPrice };
172
237
  }
173
238
  catch (err) {
174
239
  const msg = err instanceof Error ? err.message : String(err);
@@ -181,126 +246,260 @@ export class SpotPerpExecutor {
181
246
  * 1. Close perp short FIRST (reduceOnly) — hedge stays intact if this fails
182
247
  * 2. Sell spot
183
248
  * 3. Transfer USDC back to perp (always, not just same-exchange)
249
+ *
250
+ * Returns `manual: true` (caller quarantines, never retries) when the spot outcome is
251
+ * unknown (pending sell, failed post-trade verification) or spot is left unhedged.
252
+ * A perp short is re-opened only when THIS call closed it; if the perp was already
253
+ * gone and spot cannot be sold, nothing new is opened — the position is quarantined.
184
254
  */
185
255
  async closePosition(params) {
186
256
  const { symbol, spotExchange, perpExchange, spotAdapter, perpAdapter, size, log } = params;
187
257
  const perpSym = getPerpSymbol(symbol, perpExchange);
258
+ const notClosed = { closed: false, remainingSize: null, manual: false };
259
+ const manual = (reason) => {
260
+ log(` [SPA] CRITICAL: ${symbol} ${spotExchange}-spot<>${perpExchange} needs manual action — ${reason}`);
261
+ return { closed: false, remainingSize: null, manual: true, reason };
262
+ };
188
263
  try {
189
- // Step 1: Close perp short FIRST (reduceOnly) — if this fails, hedge is still intact
264
+ // Pre-trade base + quote balances (post-sell delta check, proceeds sweep); read before anything moves.
265
+ let preBase;
266
+ let preQuote;
267
+ try {
268
+ const pre = await readSpotBalancesFresh(spotAdapter);
269
+ preBase = this.baseOf(spotAdapter, pre, symbol);
270
+ preQuote = this.quoteOf(spotExchange, spotAdapter, pre);
271
+ }
272
+ catch (err) {
273
+ log(` [SPA] Pre-close ${symbol} balance read failed: ${err instanceof Error ? err.message : String(err)} — close not started`);
274
+ return notClosed;
275
+ }
276
+ // Step 1: Close perp short FIRST (reduceOnly, verified, one residual close) — if this fails, hedge is still intact
277
+ const dec = sizeDecimalsOf(size);
278
+ let perpClosedHere = false;
279
+ /** Base units of the short this call closed (measured). */
280
+ let perpClosed = 0;
281
+ /** Set when the perp close left a residual short open (after one residual close). */
282
+ let perpResidual = null;
190
283
  try {
191
- await perpAdapter.marketOrder(perpSym, "buy", size, { reduceOnly: true });
192
- logExecution({ type: "arb_close", exchange: perpExchange, symbol, side: "buy", size, status: "success", dryRun: false, meta: { mode: "spot-perp" } });
284
+ const pc = await closePerpVerified(perpAdapter, perpExchange, perpSym, "buy", size);
285
+ perpClosedHere = true;
286
+ perpClosed = Math.min(pc.closed, Number(size));
287
+ if (pc.residual > 0)
288
+ perpResidual = pc;
289
+ logExecution({ type: "arb_close", exchange: perpExchange, symbol, side: "buy", size, status: pc.residual > 0 ? "failed" : "success", dryRun: false, meta: { mode: "spot-perp", measuredClose: pc.closed, residual: pc.residual } });
193
290
  }
194
291
  catch (err) {
195
292
  const msg = err instanceof Error ? err.message : String(err);
293
+ if (isOutcomeUnknown(err)) {
294
+ return manual(`perp close ${size} on ${perpExchange} outcome unknown (${msg}); spot not sold — verify the perp position, do NOT resubmit`);
295
+ }
196
296
  // ReduceOnly rejected = position already gone, proceed to sell spot
197
- if (isPositionGone(msg)) {
297
+ if (isPositionGone(err)) {
198
298
  log(` [SPA] Perp already closed (ReduceOnly rejected)`);
199
299
  }
200
300
  else {
201
301
  log(` [SPA] Perp close failed, hedge intact: ${msg}`);
202
- return false;
302
+ return notClosed;
203
303
  }
204
304
  }
205
- // Step 2: Sell spot
305
+ // Step 2: Sell spot — sized to the VERIFIED perp close when a perp residual remains
306
+ // (the unsold spot then still hedges the residual short; quarantined below).
307
+ const sellSize = perpResidual === null ? size : fillSizeString(perpClosed, dec);
308
+ const closedStr = fillSizeString(perpClosed, dec);
309
+ const residualNote = perpResidual === null ? "" : `perp close on ${perpExchange} closed ${closedStr} of ${size}, ${perpResidual.residual} short still open${perpResidual.note !== null ? ` (${perpResidual.note})` : ""}`;
310
+ if (perpResidual !== null) {
311
+ log(` [SPA] ${residualNote} — selling only ${sellSize} spot`);
312
+ if (!(Number(sellSize) > 0))
313
+ return manual(`${residualNote}; spot not sold`);
314
+ }
315
+ let fill;
206
316
  try {
207
- await spotAdapter.spotMarketOrder(symbol, "sell", size);
317
+ fill = await spotMarketFill(spotAdapter, symbol, "sell", sellSize);
208
318
  }
209
319
  catch (err) {
210
320
  const msg = err instanceof Error ? err.message : String(err);
211
- log(` [SPA] Spot sell failed: ${msg} -- re-opening perp hedge`);
321
+ if (err instanceof PerpError && err.structured.code === "ORDER_PENDING") {
322
+ // The sell may still execute: re-hedging now could leave a naked short.
323
+ return manual(`spot sell ${sellSize} is PENDING (${msg}); perp is closed — verify ${spotExchange} spot, do NOT resubmit`);
324
+ }
325
+ if (!perpClosedHere) {
326
+ // The perp was already gone before this call: opening a new short here is not a
327
+ // rollback, it is a new position. Leave it to a human.
328
+ return manual(`spot sell ${size} failed (${msg}) and the perp short was already gone — ${size} spot is unhedged`);
329
+ }
330
+ // Definite spot failure (nothing sold): restore the short this call closed (measured), verified.
331
+ log(` [SPA] Spot sell failed: ${msg} -- re-opening perp hedge ${closedStr}`);
212
332
  try {
213
- await perpAdapter.marketOrder(perpSym, "sell", size);
333
+ const rh = await placePerpVerified(perpAdapter, perpExchange, perpSym, "sell", closedStr);
334
+ if (rh.filledSize < Number(closedStr) * (1 - 1e-6)) {
335
+ return manual(`spot sell failed (${msg}); the perp re-hedge filled ${rh.filledSize} of ${closedStr} — spot partly unhedged`);
336
+ }
214
337
  }
215
338
  catch (rbErr) {
216
- log(` [SPA] CRITICAL: Perp re-hedge failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
339
+ return manual(`spot sell failed (${msg}) and the perp re-hedge failed or is unconfirmed (${rbErr instanceof Error ? rbErr.message : String(rbErr)}) — ${closedStr} spot may be unhedged`);
217
340
  }
218
- return false;
341
+ if (perpResidual !== null)
342
+ return manual(`${residualNote}; spot sell failed (${msg}) and the closed part was re-hedged`);
343
+ return notClosed;
219
344
  }
220
- // Step 2b: Verify spot actually sold (safety net for "assuming executed" false positive)
345
+ // Step 2b: Verify the sell by a fresh balance delta vs the pre-trade balance; a failed check stops.
221
346
  try {
222
- invalidateCache("acct");
223
- const postBals = await spotAdapter.getSpotBalances();
224
- const remaining = postBals.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === symbol.toUpperCase());
225
- const remainingAmt = remaining ? parseFloat(remaining.total) : 0;
226
- if (remainingAmt >= parseFloat(size) * 0.8) {
227
- log(` [SPA] CRITICAL: Spot sell returned OK but ${symbol} balance unchanged (${remainingAmt.toFixed(4)} >= ${size} × 80%) — re-hedging perp`);
347
+ const sold = preBase - this.baseOf(spotAdapter, await readSpotBalancesFresh(spotAdapter), symbol);
348
+ if (sold < fill.filledSize * 0.8) {
349
+ return manual(`spot sell reported ${fill.filledSize} but the balance fell only ${sold.toFixed(6)}; perp is closed — verify ${spotExchange} spot`);
350
+ }
351
+ }
352
+ catch (err) {
353
+ return manual(`post-sell balance check failed (${err instanceof Error ? err.message : String(err)}); perp is closed — verify ${spotExchange} spot`);
354
+ }
355
+ // Partial sell: re-hedge the unsold remainder so it stays a hedged position.
356
+ let remainingSize = null;
357
+ if (fill.status === "partial") {
358
+ const remaining = fillSizeString(Number(sellSize) - fill.filledSize, dec);
359
+ if (Number(remaining) > 0) {
360
+ log(` [SPA] Spot sell partial: ${fill.filledSize} of ${sellSize} ${symbol} — re-hedging the remaining ${remaining}`);
228
361
  try {
229
- await perpAdapter.marketOrder(perpSym, "sell", size);
362
+ const rh = await placePerpVerified(perpAdapter, perpExchange, perpSym, "sell", remaining);
363
+ if (rh.filledSize < Number(remaining) * (1 - 1e-6)) {
364
+ return manual(`spot sell partial (${fill.filledSize} of ${sellSize}); re-hedge of the unsold ${remaining} filled ${rh.filledSize} — spot partly unhedged`);
365
+ }
366
+ remainingSize = remaining;
230
367
  }
231
368
  catch (rbErr) {
232
- log(` [SPA] CRITICAL: Perp re-hedge failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
369
+ return manual(`re-hedge of the unsold ${remaining} failed or is unconfirmed (${rbErr instanceof Error ? rbErr.message : String(rbErr)}) — unhedged spot may remain`);
233
370
  }
234
- return false;
235
371
  }
236
372
  }
237
- catch { /* balance check failed, proceed optimistically */ }
238
- // Step 3: Transfer USDC back to perp (always — lighter/HL both need this)
373
+ // Step 3: Transfer THIS sale's proceeds back to perp (quote delta since the pre-close
374
+ // read — never quote that was already in spot, e.g. another position's funds).
239
375
  try {
240
- const spotBals = await spotAdapter.getSpotBalances();
241
- const usdcBal = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
242
- const usdcAmount = usdcBal ? parseFloat(usdcBal.total) : 0;
243
- if (usdcAmount > 1) {
244
- await transferUsdcToPerp(spotAdapter, spotExchange, Math.floor(usdcAmount));
245
- log(` [SPA] Transferred $${Math.floor(usdcAmount)} USDC back to perp`);
376
+ const postQuote = this.quoteOf(spotExchange, spotAdapter, await readSpotBalancesFresh(spotAdapter));
377
+ const proceeds = Math.floor(Math.min(postQuote, postQuote - preQuote));
378
+ if (proceeds > 1 && await transferUsdcToPerp(spotAdapter, proceeds)) {
379
+ log(` [SPA] Transferred $${proceeds} (sale proceeds) back to perp`);
246
380
  }
247
381
  }
248
- catch { /* non-fatal */ }
249
- return true;
382
+ catch (err) {
383
+ // Non-fatal for the close (both legs are done), but surfaced: proceeds remain in spot.
384
+ log(` [SPA] WARNING: spot→perp transfer after close failed on ${spotExchange}: ${err instanceof Error ? err.message : String(err)}`);
385
+ }
386
+ if (perpResidual !== null) {
387
+ return manual(`${residualNote}; spot sold ${fill.filledSize} — the rest (${fillSizeString(Number(size) - fill.filledSize, dec)} spot vs the residual short) needs a manual check`);
388
+ }
389
+ return remainingSize === null ? { closed: true, remainingSize: null, manual: false } : { closed: false, remainingSize, manual: false };
250
390
  }
251
391
  catch (err) {
252
392
  log(` [SPA] Close failed: ${err instanceof Error ? err.message : String(err)}`);
253
- return false;
393
+ return notClosed;
254
394
  }
255
395
  }
396
+ /** Base-token balance (total), read UNCACHED; a token with no balance row holds 0. Errors propagate. */
397
+ async baseBalance(spotAdapter, symbol) {
398
+ return this.baseOf(spotAdapter, await readSpotBalancesFresh(spotAdapter), symbol);
399
+ }
400
+ baseOf(spotAdapter, balances, symbol) {
401
+ const row = balances.find(b => spotTokenBase(b.token) === symbol.toUpperCase());
402
+ if (row === undefined)
403
+ return 0;
404
+ const n = parseFloat(row.total);
405
+ if (!Number.isFinite(n))
406
+ throw new Error(`${spotAdapter.name} ${symbol} balance is not a number: ${row.total}`);
407
+ return n;
408
+ }
409
+ /** Spot quote total (0 when shared with perp margin — nothing to move, or no quote row). */
410
+ quoteOf(spotExchange, spotAdapter, balances) {
411
+ const quoteBal = findSpotQuoteBalance(getVenue(spotExchange), spotNetworkOf(spotAdapter), balances);
412
+ if (!quoteBal || quoteBal.shared)
413
+ return 0;
414
+ const n = parseFloat(quoteBal.total);
415
+ if (!Number.isFinite(n))
416
+ throw new Error(`${spotExchange} spot ${quoteBal.token} balance is not a number: ${quoteBal.total}`);
417
+ return n;
418
+ }
256
419
  /**
257
- * Rotate perp short to better exchange:
420
+ * Rotate perp short to better exchange (spot stays unchanged):
258
421
  * 1. Set leverage on new exchange
259
- * 2. Close current perp short (reduceOnly)
260
- * 3. Open new perp short on new exchange
261
- * 4. Rollback if new short fails
262
- * Spot stays unchanged.
422
+ * 2. Close current perp short (reduceOnly, verified)
423
+ * 3. Open new perp short on new exchange (verified)
424
+ * 4. If the new short definitely failed: re-open the old short (verified)
425
+ * Any leg whose outcome is unknown, or a failed rollback, returns manual (quarantine):
426
+ * retrying could double-short or leave the spot unhedged.
263
427
  */
264
428
  async rotateShort(params) {
265
429
  const { symbol, currentExchange, newExchange, currentAdapter, newAdapter, size, leverage, log } = params;
266
430
  const oldSym = getPerpSymbol(symbol, currentExchange);
267
431
  const newSym = getPerpSymbol(symbol, newExchange);
432
+ const errText = (e) => e instanceof Error ? e.message : String(e);
268
433
  // Set leverage on new exchange
269
434
  try {
270
435
  await newAdapter.setLeverage(newSym, leverage, "cross");
271
436
  }
272
437
  catch (err) {
273
- log(` [SPA] Rotation setLeverage failed: ${err instanceof Error ? err.message : String(err)}`);
274
- return false;
438
+ log(` [SPA] Rotation setLeverage failed: ${errText(err)}`);
439
+ return { ok: false, manual: false };
275
440
  }
276
- // Close old short
441
+ // Close old short (reduce-only, verified, one residual close)
442
+ const dec = sizeDecimalsOf(size);
443
+ let closed;
277
444
  try {
278
- await currentAdapter.marketOrder(oldSym, "buy", size, { reduceOnly: true });
279
- logExecution({ type: "arb_close", exchange: currentExchange, symbol, side: "buy", size, status: "success", dryRun: false, meta: { rotation: true } });
445
+ const oc = await closePerpVerified(currentAdapter, currentExchange, oldSym, "buy", size);
446
+ closed = fillSizeString(Math.min(oc.closed, Number(size)), dec);
447
+ if (oc.residual > 0) {
448
+ logExecution({ type: "arb_close", exchange: currentExchange, symbol, side: "buy", size, status: "failed", error: `closed ${closed}, residual ${oc.residual}`, dryRun: false, meta: { rotation: true } });
449
+ return { ok: false, manual: true, reason: `rotation close of the ${currentExchange} short closed ${closed} of ${size}, ${oc.residual} still open${oc.note !== null ? ` (${oc.note})` : ""} — no new short placed; ${closed} spot unhedged` };
450
+ }
451
+ logExecution({ type: "arb_close", exchange: currentExchange, symbol, side: "buy", size, status: "success", dryRun: false, meta: { rotation: true, measuredClose: oc.closed } });
280
452
  }
281
453
  catch (err) {
282
- log(` [SPA] Rotation close failed: ${err instanceof Error ? err.message : String(err)}`);
283
- return false;
454
+ if (isOutcomeUnknown(err)) {
455
+ return { ok: false, manual: true, reason: `rotation close of the ${currentExchange} short ${size} outcome unknown (${errText(err)}) — verify the ${currentExchange} position` };
456
+ }
457
+ log(` [SPA] Rotation close failed: ${errText(err)}`);
458
+ return { ok: false, manual: false };
284
459
  }
285
- // Open new short
460
+ // Open new short sized to the VERIFIED close
286
461
  try {
287
- await newAdapter.marketOrder(newSym, "sell", size);
288
- logExecution({ type: "arb_entry", exchange: newExchange, symbol, side: "sell", size, status: "success", dryRun: false, meta: { rotation: true } });
462
+ const nf = await placePerpVerified(newAdapter, newExchange, newSym, "sell", closed);
463
+ logExecution({ type: "arb_entry", exchange: newExchange, symbol, side: "sell", size: closed, status: "success", dryRun: false, meta: { rotation: true, measuredFill: nf.filledSize } });
464
+ if (nf.filledSize < Number(closed) * (1 - 1e-6) || Number(closed) < Number(size)) {
465
+ // Accepted partial fill (or a smaller old short): the spot is no longer fully hedged — never resubmit.
466
+ return { ok: false, manual: true, reason: `rotation: new ${newExchange} short filled ${nf.filledSize} of ${closed} (old ${currentExchange} short closed ${closed} of ${size}) — spot ${size} ${symbol} partly unhedged` };
467
+ }
289
468
  }
290
469
  catch (err) {
291
- const msg = err instanceof Error ? err.message : String(err);
470
+ const msg = errText(err);
471
+ if (isOutcomeUnknown(err)) {
472
+ // The new short may exist: re-opening the old one could double-short.
473
+ return { ok: false, manual: true, reason: `rotation: new ${newExchange} short ${closed} outcome unknown (${msg}); the ${currentExchange} short is closed — spot may be unhedged or double-hedged` };
474
+ }
292
475
  log(` [SPA] Rotation new short failed: ${msg} -- rolling back`);
293
476
  try {
294
- await currentAdapter.marketOrder(oldSym, "sell", size);
477
+ const rb = await placePerpVerified(currentAdapter, currentExchange, oldSym, "sell", closed);
478
+ if (rb.filledSize < Number(closed) * (1 - 1e-6)) {
479
+ logExecution({ type: "multi_leg_rollback", exchange: currentExchange, symbol, side: "sell", size: closed, status: "failed", error: `re-opened ${rb.filledSize} of ${closed}`, dryRun: false });
480
+ return { ok: false, manual: true, reason: `rotation: new ${newExchange} short failed (${msg}); re-opening the ${currentExchange} short filled ${rb.filledSize} of ${closed} — spot partly unhedged` };
481
+ }
295
482
  log(` [SPA] Rotation rollback OK`);
296
- logExecution({ type: "multi_leg_rollback", exchange: currentExchange, symbol, side: "sell", size, status: "success", dryRun: false });
483
+ logExecution({ type: "multi_leg_rollback", exchange: currentExchange, symbol, side: "sell", size: closed, status: "success", dryRun: false });
297
484
  }
298
485
  catch (rbErr) {
299
- log(` [SPA] CRITICAL: Rotation rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
300
- logExecution({ type: "multi_leg_rollback", exchange: currentExchange, symbol, side: "sell", size, status: "failed", error: rbErr instanceof Error ? rbErr.message : String(rbErr), dryRun: false });
486
+ const rbMsg = errText(rbErr);
487
+ logExecution({ type: "multi_leg_rollback", exchange: currentExchange, symbol, side: "sell", size: closed, status: "failed", error: rbMsg, dryRun: false });
488
+ return { ok: false, manual: true, reason: `rotation: new ${newExchange} short failed (${msg}) and re-opening the ${currentExchange} short failed or is unconfirmed (${rbMsg}) — ${closed} ${symbol} spot may be unhedged` };
301
489
  }
302
- return false;
490
+ return { ok: false, manual: false };
491
+ }
492
+ // The new short's entry price (arb state); a failed read is reported as unknown.
493
+ let entryPrice = null;
494
+ try {
495
+ invalidateCache("acct");
496
+ const p = (await newAdapter.getPositions()).find(x => matchSymbol(x.symbol, newSym) && parseFloat(x.size) !== 0);
497
+ const px = Number(p?.entryPrice);
498
+ entryPrice = Number.isFinite(px) && px > 0 ? px : null;
499
+ }
500
+ catch (err) {
501
+ log(` [SPA] Rotation: ${newExchange} entry price unreadable (${errText(err)})`);
303
502
  }
304
- return true;
503
+ return { ok: true, entryPrice };
305
504
  }
306
505
  }