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
@@ -7,12 +7,22 @@
7
7
  * Strategy layer handles ONLY decisions (opportunity scanning, scoring, close/rotation decisions).
8
8
  * Execution layer (SpotPerpExecutor) handles ALL exchange interactions.
9
9
  */
10
+ import { requiredArbMargin } from "../../rebalance.js";
10
11
  import { registerStrategy } from "../strategy-registry.js";
11
12
  import { getFundingHours } from "../../funding.js";
12
13
  import { annualizeHourlyRate } from "../../funding/normalize.js";
13
14
  import { pruneExecutionLog } from "../../execution-log.js";
14
- import { buildAdapterMap, getSpotAdapter, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, } from "./funding-arb-utils.js";
15
+ import { buildAdapterMap, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols } from "./funding-arb-utils.js";
15
16
  import { SpotPerpExecutor } from "./spot-perp-executor.js";
17
+ import { findSpotQuoteBalance, getBotSpotAdapter, nonQuoteSpotBalances, spotNetworkOf, spotSharesPerpMargin, spotTokenBase } from "../../exchanges/spot-factory.js";
18
+ import { getVenue } from "../../exchanges/venue.js";
19
+ import { updatePositionEntry } from "../../arb/state.js";
20
+ import { recordSpotPerpClose, recordSpotPerpEntry, stateEntryOf, stateWriteFailed } from "./spot-perp-state.js";
21
+ import { isQuarantined, quarantineSymbol } from "../quarantine.js";
22
+ /** Consecutive failed closes before a position is quarantined (no sell→fail→re-hedge loop). */
23
+ const MAX_CLOSE_FAILURES = 3;
24
+ /** Spot leg buying power: notional plus 1% for the taker fee and the spot buy's slippage bound. */
25
+ const SPOT_FEE_BUFFER = 1.01;
16
26
  let _posIdCounter = 0;
17
27
  function genPosId() { return `spa-${Date.now()}-${++_posIdCounter}`; }
18
28
  // ── Strategy ──
@@ -38,6 +48,12 @@ export class SpotPerpArbStrategy {
38
48
  }
39
49
  _config = {};
40
50
  _failCooldown = new Map();
51
+ /** Consecutive failed close attempts per symbol (quarantined at MAX_CLOSE_FAILURES). */
52
+ _closeFailures = new Map();
53
+ /** Venues whose positions could not be read during recovery: no entries there until re-read. */
54
+ _unrecovered = new Set();
55
+ /** Perp symbols this bot entered or tracked this run — the only symbols its stop cancels orders on. */
56
+ _traded = new Set();
41
57
  _fundingScoreCache = new Map();
42
58
  get params() {
43
59
  const c = this._config;
@@ -57,6 +73,7 @@ export class SpotPerpArbStrategy {
57
73
  }
58
74
  // ── init ──
59
75
  async init(ctx, _snapshot) {
76
+ assertFundingArbLegs(ctx);
60
77
  try {
61
78
  pruneExecutionLog(30);
62
79
  }
@@ -73,7 +90,8 @@ export class SpotPerpArbStrategy {
73
90
  ctx.log(` [SPA] Exchanges: ${p.exchanges.join(", ")}`);
74
91
  try {
75
92
  const adapters = buildAdapterMap(ctx);
76
- const recovered = await this.recoverPositions(adapters, ctx);
93
+ const { recovered, unreadable } = await this.recoverPositions(adapters, ctx);
94
+ this._unrecovered = unreadable;
77
95
  if (recovered.length > 0) {
78
96
  ctx.state.set("spaPositions", recovered);
79
97
  ctx.state.set("arbPositions", recovered.length);
@@ -105,6 +123,19 @@ export class SpotPerpArbStrategy {
105
123
  const p = this.params;
106
124
  const positions = ctx.state.get("spaPositions");
107
125
  const adapters = buildAdapterMap(ctx);
126
+ // Retry recovery on venues whose positions could not be read at init.
127
+ if (this._unrecovered.size > 0) {
128
+ const { recovered, unreadable } = await this.recoverPositions(adapters, ctx);
129
+ const tracked = new Set(positions.map(pos => pos.symbol.toUpperCase()));
130
+ for (const r of recovered) {
131
+ if (tracked.has(r.symbol.toUpperCase()))
132
+ continue;
133
+ positions.push(r);
134
+ tracked.add(r.symbol.toUpperCase());
135
+ }
136
+ ctx.state.set("arbPositions", positions.length);
137
+ this._unrecovered = unreadable;
138
+ }
108
139
  const exchangeBalances = new Map();
109
140
  const availableAdapters = new Map();
110
141
  for (const [name, a] of adapters) {
@@ -115,7 +146,9 @@ export class SpotPerpArbStrategy {
115
146
  const available = parseFloat(bal.available);
116
147
  exchangeBalances.set(name, { equity, available, marginPct });
117
148
  const cd = this._failCooldown.get(`${name}:exchange`) ?? 0;
118
- if (Date.now() < cd)
149
+ if (this._unrecovered.has(name))
150
+ ctx.log(` [SPA] ${name} positions not recovered — no entries this tick`);
151
+ else if (Date.now() < cd)
119
152
  ctx.log(` [SPA] ${name} on cooldown`);
120
153
  else if (available < 5)
121
154
  ctx.log(` [SPA] ${name} available $${available.toFixed(2)} too low`);
@@ -131,7 +164,15 @@ export class SpotPerpArbStrategy {
131
164
  }
132
165
  const ratesByExchange = new Map();
133
166
  for (const [name, a] of adapters) {
134
- const rates = await fetchRates(a, name);
167
+ let rates;
168
+ try {
169
+ rates = await fetchRates(a, name);
170
+ }
171
+ catch (err) {
172
+ // Recorded per venue; this venue is left out of this cycle's rate map.
173
+ ctx.log(` [SPA] fetchRates failed for ${name}: ${err instanceof Error ? err.message : String(err)}`);
174
+ continue;
175
+ }
135
176
  const map = new Map();
136
177
  for (const r of rates)
137
178
  map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage, fundingHours: r.fundingHours });
@@ -189,6 +230,10 @@ export class SpotPerpArbStrategy {
189
230
  for (const pos of positions) {
190
231
  if (!this.shouldClose(pos, ratesByExchange, p))
191
232
  continue;
233
+ if (this.quarantined(pos.symbol, ctx)) {
234
+ ctx.log(` [SPA] ${pos.symbol} is quarantined — not closing (perp strategy quarantine)`);
235
+ continue;
236
+ }
192
237
  const cd = this._failCooldown.get(`${pos.symbol}:close`) ?? 0;
193
238
  if (Date.now() < cd)
194
239
  continue;
@@ -206,23 +251,67 @@ export class SpotPerpArbStrategy {
206
251
  symbol: pos.symbol, spotExchange: pos.spotExchange, perpExchange: pos.perpExchange,
207
252
  spotAdapter, perpAdapter, size: pos.size, log: ctx.log,
208
253
  });
209
- if (closed)
254
+ if (closed.closed) {
210
255
  toClose.push(pos);
211
- else
256
+ this._closeFailures.delete(pos.symbol);
257
+ this.recordClose(spotAdapter, pos, null, ctx);
258
+ }
259
+ else if (closed.manual) {
260
+ this.quarantine(pos.symbol, closed.reason, pos.spotExchange, pos.perpExchange, ctx);
261
+ }
262
+ else if (closed.remainingSize !== null) {
263
+ pos.size = closed.remainingSize; // partial close: remainder still hedged
264
+ this.recordClose(spotAdapter, pos, closed.remainingSize, ctx);
265
+ this._closeFailures.delete(pos.symbol);
212
266
  this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
267
+ }
268
+ else {
269
+ // Each failed attempt may have traded (perp close → spot sell fail → re-hedge):
270
+ // stop after MAX_CLOSE_FAILURES instead of looping.
271
+ const n = (this._closeFailures.get(pos.symbol) ?? 0) + 1;
272
+ this._closeFailures.set(pos.symbol, n);
273
+ if (n >= MAX_CLOSE_FAILURES) {
274
+ this.quarantine(pos.symbol, `close failed ${n} times in a row — stopping automatic close attempts`, pos.spotExchange, pos.perpExchange, ctx);
275
+ }
276
+ else {
277
+ this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
278
+ }
279
+ }
213
280
  }
214
281
  if (toClose.length > 0) {
215
282
  const remaining = positions.filter(pos => !toClose.some(c => c.id === pos.id));
216
283
  ctx.state.set("spaPositions", remaining);
217
284
  ctx.state.set("arbPositions", remaining.length);
218
285
  }
219
- // ── Rotation check ──
286
+ // ── Rotation check (only to perp venues that match the spot leg, §12.4 #6) ──
287
+ const perpMarketsCache = new Map();
288
+ const allowedBySpotVenue = new Map();
220
289
  const currentPositions = ctx.state.get("spaPositions");
221
290
  for (const pos of currentPositions) {
291
+ if (this.quarantined(pos.symbol, ctx))
292
+ continue;
222
293
  const rcd = this._failCooldown.get(`${pos.id}:rotate`) ?? 0;
223
294
  if (Date.now() < rcd)
224
295
  continue;
225
- const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange);
296
+ let allowedByBase = allowedBySpotVenue.get(pos.spotExchange);
297
+ if (allowedByBase === undefined) {
298
+ const spotExAdapter = adapters.get(pos.spotExchange);
299
+ const rotSpot = spotExAdapter ? await getBotSpotAdapter(pos.spotExchange, spotExAdapter) : null;
300
+ if (rotSpot === null)
301
+ continue;
302
+ try {
303
+ allowedByBase = await this.spotPerpVenues(pos.spotExchange, await rotSpot.getSpotMarkets(), adapters, perpMarketsCache, ctx);
304
+ }
305
+ catch (e) {
306
+ ctx.log(` [SPA] ${pos.spotExchange} spot↔perp matching failed (rotation skipped): ${e instanceof Error ? e.message : String(e)}`);
307
+ continue;
308
+ }
309
+ allowedBySpotVenue.set(pos.spotExchange, allowedByBase);
310
+ }
311
+ const allowed = allowedByBase.get(pos.symbol.toUpperCase());
312
+ if (allowed === undefined)
313
+ continue;
314
+ const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange, allowed);
226
315
  const best = candidates[0];
227
316
  if (!best || best.exchange === pos.perpExchange)
228
317
  continue;
@@ -247,11 +336,26 @@ export class SpotPerpArbStrategy {
247
336
  currentAdapter: oldAdapter, newAdapter,
248
337
  size: pos.size, leverage: Math.min(p.leverage, newMaxLev), log: ctx.log,
249
338
  });
250
- if (ok) {
339
+ if (ok.ok) {
340
+ // The spot leg is unchanged: the state entry follows the short to its new venue.
341
+ try {
342
+ const spotExAdapter = adapters.get(pos.spotExchange);
343
+ const rotSpot = spotExAdapter ? await this.executor.getSpotAdapter(pos.spotExchange, spotExAdapter) : null;
344
+ const entry = rotSpot !== null ? stateEntryOf(rotSpot, pos) : undefined;
345
+ if (rotSpot !== null && entry !== undefined)
346
+ updatePositionEntry(spotNetworkOf(rotSpot), entry, { shortExchange: getVenue(best.exchange).id, entryShortPrice: ok.entryPrice });
347
+ }
348
+ catch (e) {
349
+ stateWriteFailed("[SPA]", "update (rotation)", { ...pos, perpExchange: best.exchange }, e, ctx.log);
350
+ }
251
351
  pos.perpExchange = best.exchange;
252
352
  pos.fundingHistory = [];
253
353
  pos.lastFundingCheck = 0;
254
354
  }
355
+ else if (ok.manual) {
356
+ // A leg is unknown / unhedged: a retry could double-short — quarantine, never cool down.
357
+ this.quarantine(pos.symbol, ok.reason, pos.spotExchange, `${pos.perpExchange}/${best.exchange}`, ctx);
358
+ }
255
359
  else {
256
360
  this._failCooldown.set(`${pos.id}:rotate`, Date.now() + 10 * 60 * 1000);
257
361
  }
@@ -266,12 +370,12 @@ export class SpotPerpArbStrategy {
266
370
  const HL_SPOT_WHITELIST = new Set(["UBTC", "UETH", "USOL", "UFART", "UPUMP", "LINK0", "AVAX0", "AAVE0", "PURR", "HYPE"]);
267
371
  for (const [spotExName, spotExAdapter] of adapters) {
268
372
  try {
269
- const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
373
+ const spotAdapter = await getBotSpotAdapter(spotExName, spotExAdapter);
270
374
  if (!spotAdapter)
271
375
  continue;
272
- const spotMarkets = await spotAdapter.getSpotMarkets();
273
- for (const m of spotMarkets) {
274
- const base = m.baseToken.toUpperCase();
376
+ // Canonical symbol + price guard (§12.4 #6): base → perp venues it may hedge on.
377
+ const allowedByBase = await this.spotPerpVenues(spotExName, await spotAdapter.getSpotMarkets(), adapters, perpMarketsCache, ctx);
378
+ for (const [base, allowed] of allowedByBase) {
275
379
  // HL spot: skip tokens not in whitelist
276
380
  if (spotExName === "hyperliquid" && !HL_SPOT_WHITELIST.has(base))
277
381
  continue;
@@ -279,7 +383,9 @@ export class SpotPerpArbStrategy {
279
383
  continue;
280
384
  if (Date.now() < (this._failCooldown.get(`${base}:entry`) ?? 0))
281
385
  continue;
282
- const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange);
386
+ if (this.quarantined(base, ctx))
387
+ continue;
388
+ const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange, allowed);
283
389
  for (const cand of candidates) {
284
390
  if (cand.score.annualized < p.min_rate || cand.score.consistency < p.min_consistency)
285
391
  continue;
@@ -287,7 +393,9 @@ export class SpotPerpArbStrategy {
287
393
  }
288
394
  }
289
395
  }
290
- catch { /* no spot */ }
396
+ catch (e) {
397
+ ctx.log(` [SPA] ${spotExName} spot scan failed: ${e instanceof Error ? e.message : String(e)}`);
398
+ }
291
399
  }
292
400
  scoredOpps.sort((a, b) => b.annualized - a.annualized);
293
401
  if (scoredOpps.length > 0 && numPos < p.max_positions) {
@@ -298,6 +406,8 @@ export class SpotPerpArbStrategy {
298
406
  for (const opp of scoredOpps) {
299
407
  if (ctx.state.get("spaPositions").length >= p.max_positions)
300
408
  break;
409
+ if (this._unrecovered.has(opp.spotExchange) || this._unrecovered.has(opp.perpExchange))
410
+ continue;
301
411
  const opened = await this.tryOpenPosition(opp.spotExchange, opp.perpExchange, opp.symbol, opp.annualized, adapters, ctx, ratesByExchange, exchangeBalances);
302
412
  if (opened)
303
413
  return [{ type: "noop" }];
@@ -311,9 +421,16 @@ export class SpotPerpArbStrategy {
311
421
  async onStop(ctx) {
312
422
  const positions = ctx.state.get("spaPositions");
313
423
  const adapters = buildAdapterMap(ctx);
424
+ for (const p of positions ?? [])
425
+ this._traded.add(getPerpSymbol(p.symbol, p.perpExchange).toUpperCase());
314
426
  if (positions && positions.length > 0) {
315
427
  const remaining = [];
316
428
  for (const pos of positions) {
429
+ if (this.quarantined(pos.symbol, ctx)) {
430
+ ctx.log(` [SPA] CRITICAL: ${pos.symbol} is quarantined — left open on stop; resolve manually (perp strategy quarantine)`);
431
+ remaining.push(pos);
432
+ continue;
433
+ }
317
434
  const spotExAdapter = adapters.get(pos.spotExchange) ?? adapters.get(pos.perpExchange);
318
435
  const perpAdapter = adapters.get(pos.perpExchange);
319
436
  if (!perpAdapter || !spotExAdapter) {
@@ -331,33 +448,62 @@ export class SpotPerpArbStrategy {
331
448
  symbol: pos.symbol, spotExchange: pos.spotExchange, perpExchange: pos.perpExchange,
332
449
  spotAdapter, perpAdapter, size: pos.size, log: ctx.log,
333
450
  });
334
- if (!closed) {
335
- ctx.log(` [SPA] CRITICAL: ${pos.symbol} could not be closed on stop`);
451
+ if (closed.closed)
452
+ this.recordClose(spotAdapter, pos, null, ctx);
453
+ else {
454
+ if (closed.manual)
455
+ this.quarantine(pos.symbol, closed.reason, pos.spotExchange, pos.perpExchange, ctx);
456
+ else if (closed.remainingSize !== null) {
457
+ pos.size = closed.remainingSize; // partial close: remainder still hedged
458
+ this.recordClose(spotAdapter, pos, closed.remainingSize, ctx);
459
+ }
460
+ ctx.log(` [SPA] CRITICAL: ${pos.symbol} could not be (fully) closed on stop`);
336
461
  remaining.push(pos);
337
462
  }
338
463
  }
339
464
  ctx.state.set("spaPositions", remaining);
340
465
  ctx.state.set("arbPositions", remaining.length);
341
466
  }
342
- const extra = ctx.state.get("extraAdapters");
343
- if (extra) {
344
- for (const [, a] of extra) {
345
- try {
346
- await a.cancelAllOrders();
347
- }
348
- catch { /* best effort */ }
349
- }
350
- }
351
- return [{ type: "cancel_all" }];
467
+ // Only the bot's own symbols, on each venue it used — never an account-wide cancel.
468
+ await cancelBotSymbols(adapters, this._traded, "[SPA]", ctx.log);
469
+ return [];
470
+ }
471
+ /** Close recorded in the arb state: entry removed (remaining null) or its legs set to the still-hedged remainder. */
472
+ recordClose(spot, pos, remaining, ctx) {
473
+ recordSpotPerpClose(spot, pos, remaining, "[SPA]", ctx.log);
474
+ }
475
+ quarantined(symbol, ctx) {
476
+ return isQuarantined(symbol, ctx.log);
477
+ }
478
+ /** Unknown / unhedged outcome (executor `manual`): stop touching this symbol until a human clears it. */
479
+ quarantine(symbol, reason, spotExchange, perpExchange, ctx) {
480
+ quarantineSymbol(this.name, symbol, reason, `Check ${spotExchange} spot (perp -e ${spotExchange} trade spot-balance, perp -e ${spotExchange} trade spot-orders) and the ${perpExchange} perp (perp -e ${perpExchange} positions); resolve by hand, then: perp strategy quarantine-clear ${symbol.toUpperCase()}`, ctx.log);
352
481
  }
353
482
  // ── Scoring (decision logic) ──
354
483
  scoreFundingLocal(adapter, symbol, exchangeName) {
355
484
  return scoreFunding(adapter, symbol, exchangeName, this.params.history_periods, this._fundingScoreCache);
356
485
  }
486
+ /** base (upper-case) → perp venues it may hedge on (canonical symbol + price guard, `botSpotPerpMatches`). */
487
+ async spotPerpVenues(spotExName, spotMarkets, adapters, perpMarketsCache, ctx) {
488
+ const byPerp = await botSpotPerpMatches(spotExName, spotMarkets, [...adapters.keys()], perpMarketsCache, (msg) => ctx.log(` [SPA] ${msg}`));
489
+ const byBase = new Map();
490
+ for (const [perpVenue, bases] of byPerp) {
491
+ for (const b of bases) {
492
+ const set = byBase.get(b);
493
+ if (set)
494
+ set.add(perpVenue);
495
+ else
496
+ byBase.set(b, new Set([perpVenue]));
497
+ }
498
+ }
499
+ return byBase;
500
+ }
357
501
  /** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
358
- async findPerpCandidates(symbol, adapters, ratesByExchange) {
502
+ async findPerpCandidates(symbol, adapters, ratesByExchange, allowedPerpVenues) {
359
503
  const candidates = [];
360
504
  for (const [exName, adapter] of adapters) {
505
+ if (!allowedPerpVenues.has(exName))
506
+ continue;
361
507
  const perpSym = getPerpSymbol(symbol, exName);
362
508
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
363
509
  if (!ri || ri.rate <= 0)
@@ -403,6 +549,11 @@ export class SpotPerpArbStrategy {
403
549
  // ── Open position (decision + sizing, delegates execution) ──
404
550
  async tryOpenPosition(spotExchange, perpExchange, symbol, entryRate, adapters, ctx, ratesByExchange, exchangeBalances) {
405
551
  const p = this.params;
552
+ // Engine: equity unknown → no new entries (closes / rotations' closes still run).
553
+ if (ctx.state.get("equityUnknown") === true) {
554
+ ctx.log(` [SPA] Skip ${symbol}: equity unknown — no new entries`);
555
+ return false;
556
+ }
406
557
  const spotExAdapter = adapters.get(spotExchange);
407
558
  const perpAdapter = adapters.get(perpExchange);
408
559
  if (!spotExAdapter || !perpAdapter) {
@@ -455,11 +606,12 @@ export class SpotPerpArbStrategy {
455
606
  if (sameExchange) {
456
607
  const perpBal = await perpAdapter.getBalance();
457
608
  const perpAvail = parseFloat(perpBal.available);
458
- const totalNeeded = targetSizeUsd + targetSizeUsd / leverage;
459
- if (perpAvail < totalNeeded) {
460
- const max = perpAvail * 0.8 / (1 + 1 / leverage);
609
+ // Spot leg: notional × SPOT_FEE_BUFFER; perp leg: requiredArbMargin (notional / leverage × ARB_MARGIN_BUFFER).
610
+ const need = (n) => n * SPOT_FEE_BUFFER + requiredArbMargin(n, leverage);
611
+ if (perpAvail < need(targetSizeUsd)) {
612
+ const max = perpAvail / need(1);
461
613
  if (max < 10) {
462
- ctx.log(` [SPA] Skip ${symbol}: insufficient balance`);
614
+ ctx.log(` [SPA] Skip ${symbol}: insufficient balance — $${perpAvail.toFixed(2)} available, $${need(10).toFixed(2)} needed for the $10 minimum (spot + perp margin at ${leverage}x)`);
463
615
  return false;
464
616
  }
465
617
  targetSizeUsd = Math.floor(max);
@@ -473,38 +625,45 @@ export class SpotPerpArbStrategy {
473
625
  return false;
474
626
  // Check spot USDC balance (only add if separate from perp — skip for unified accounts like HL)
475
627
  let spotAvailable = sBal.available;
476
- const isUnified = spotExAdapter?.isUnifiedAccount ?? false;
477
- if (!isUnified) {
478
- try {
479
- const spotBals = await spotAdapter.getSpotBalances();
480
- const spotUsdc = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
481
- if (spotUsdc)
482
- spotAvailable += parseFloat(spotUsdc.available);
483
- }
484
- catch { /* non-critical */ }
628
+ const spotDef = getVenue(spotExchange);
629
+ if (!spotSharesPerpMargin(spotDef, spotExAdapter)) {
630
+ const spotQuote = findSpotQuoteBalance(spotDef, spotNetworkOf(spotAdapter), await spotAdapter.getSpotBalances());
631
+ if (spotQuote)
632
+ spotAvailable += parseFloat(spotQuote.available);
485
633
  }
486
- const maxNtl = Math.min(spotAvailable, pBal.available * leverage) * 0.8;
634
+ // Spot leg: notional × SPOT_FEE_BUFFER of spot buying power; perp leg: requiredArbMargin at the leverage.
635
+ const maxNtl = Math.min(spotAvailable / SPOT_FEE_BUFFER, pBal.available / requiredArbMargin(1, leverage));
487
636
  if (maxNtl < targetSizeUsd) {
488
637
  if (maxNtl < 10) {
489
- ctx.log(` [SPA] Skip ${symbol}: insufficient balance (spot=$${spotAvailable.toFixed(2)}, perp=$${pBal.available.toFixed(2)})`);
638
+ ctx.log(` [SPA] Skip ${symbol}: insufficient balance for the $10 minimum — spot $${spotAvailable.toFixed(2)} (needs $${(10 * SPOT_FEE_BUFFER).toFixed(2)}), perp $${pBal.available.toFixed(2)} (needs $${requiredArbMargin(10, leverage).toFixed(2)} margin at ${leverage}x)`);
490
639
  return false;
491
640
  }
492
641
  targetSizeUsd = Math.floor(maxNtl);
493
642
  }
494
643
  }
495
644
  // Delegate to executor
645
+ this._traded.add(perpSymbol.toUpperCase());
496
646
  const result = await this.executor.openPosition({
497
647
  symbol, perpSymbol, spotExchange, perpExchange,
498
648
  spotAdapter, perpAdapter, sizeUsd: targetSizeUsd, leverage, log: ctx.log,
499
649
  });
500
650
  if (result.success) {
501
651
  const cur = ctx.state.get("spaPositions");
502
- cur.push({ id: genPosId(), symbol, spotExchange, perpExchange, size: result.size, entryTime: Date.now(), entryRate, fundingHistory: [], lastFundingCheck: 0 });
652
+ const pos = { id: genPosId(), symbol, spotExchange, perpExchange, size: result.size, entryTime: Date.now(), entryRate, fundingHistory: [], lastFundingCheck: 0 };
653
+ cur.push(pos);
654
+ recordSpotPerpEntry(spotAdapter, {
655
+ id: pos.id, symbol, spotExchange, perpExchange, entryTime: pos.entryTime, entrySpread: entryRate,
656
+ spotSize: result.spotSize, perpSize: Number(result.size), spotAvgPrice: result.spotAvgPrice, perpEntryPrice: result.perpEntryPrice,
657
+ }, "[SPA]", ctx.log);
503
658
  ctx.state.set("spaPositions", cur);
504
659
  ctx.state.set("arbPositions", cur.length);
505
660
  ctx.log(` [SPA] Opened! (${cur.length}/${p.max_positions})`);
506
661
  return true;
507
662
  }
663
+ if (result.manual === true) {
664
+ this.quarantine(symbol, result.error ?? "entry outcome unknown", spotExchange, perpExchange, ctx);
665
+ return false;
666
+ }
508
667
  this._failCooldown.set(`${symbol}:entry`, Date.now() + 5 * 60 * 1000);
509
668
  return false;
510
669
  }
@@ -515,25 +674,30 @@ export class SpotPerpArbStrategy {
515
674
  }
516
675
  }
517
676
  // ── Position recovery ──
677
+ /** Rebuild positions from the venues; `unreadable` = venues whose positions / spot balances failed to load (never "none"). */
518
678
  async recoverPositions(adapters, ctx) {
519
679
  const recovered = [];
680
+ const unreadable = new Set();
520
681
  const perpByEx = new Map();
521
682
  for (const [name, a] of adapters) {
522
683
  try {
523
684
  const pos = await a.getPositions();
524
685
  perpByEx.set(name, pos.map(p => ({ symbol: p.symbol.toUpperCase(), side: p.side, size: p.size })));
525
686
  }
526
- catch { /* skip */ }
687
+ catch (e) {
688
+ unreadable.add(name);
689
+ ctx.log(` [SPA] ${name} positions unreadable during recovery (${e instanceof Error ? e.message : String(e)}) — no entries on ${name} until recovered`);
690
+ }
527
691
  }
528
692
  const used = new Set();
529
693
  for (const [spotExName, spotExAdapter] of adapters) {
530
694
  try {
531
- const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
695
+ const spotAdapter = await getBotSpotAdapter(spotExName, spotExAdapter);
532
696
  if (!spotAdapter)
533
697
  continue;
534
698
  const bals = await spotAdapter.getSpotBalances();
535
- for (const bal of bals.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"))) {
536
- const base = bal.token.toUpperCase().replace(/-SPOT$/, "");
699
+ for (const bal of nonQuoteSpotBalances(getVenue(spotExName), spotNetworkOf(spotAdapter), bals)) {
700
+ const base = spotTokenBase(bal.token);
537
701
  for (const [perpExName, perps] of perpByEx) {
538
702
  for (const perp of perps) {
539
703
  const key = `${perpExName}:${perp.symbol}`;
@@ -549,9 +713,12 @@ export class SpotPerpArbStrategy {
549
713
  }
550
714
  }
551
715
  }
552
- catch { /* no spot */ }
716
+ catch (e) {
717
+ unreadable.add(spotExName);
718
+ ctx.log(` [SPA] ${spotExName} spot recovery failed: ${e instanceof Error ? e.message : String(e)} — no entries on ${spotExName} until recovered`);
719
+ }
553
720
  }
554
- return recovered;
721
+ return { recovered, unreadable };
555
722
  }
556
723
  }
557
724
  registerStrategy("spot-perp-arb", (_config) => new SpotPerpArbStrategy());
@@ -6,20 +6,63 @@
6
6
  */
7
7
  import type { ExchangeAdapter } from "../../exchanges/index.js";
8
8
  import type { SpotAdapter } from "../../exchanges/spot-interface.js";
9
- export interface ExecutionResult {
10
- success: boolean;
11
- size?: string;
12
- notional?: number;
13
- error?: string;
14
- }
9
+ export type ExecutionResult = {
10
+ success: true;
11
+ /** Perp short size (= the hedged spot, on the perp lot grid). */
12
+ size: string;
13
+ notional: number;
14
+ /** Spot base units bought (the verified fill). */
15
+ spotSize: number;
16
+ /** The spot fill's average price; null = the venue reported none. */
17
+ spotAvgPrice: number | null;
18
+ /** The venue's entry price of the perp short after the hedge; null = not readable. */
19
+ perpEntryPrice: number | null;
20
+ } | {
21
+ success: false;
22
+ error: string;
23
+ /** true = outcome unknown or a leg left unhedged: the caller must quarantine the symbol (no retry). */
24
+ manual?: boolean;
25
+ };
26
+ /**
27
+ * closed = position fully closed; remainingSize = partially closed, this size is still open (hedged).
28
+ * manual = the outcome is unknown / unverified or a leg is unhedged: the caller must quarantine
29
+ * the position and never retry it automatically (design §12.3).
30
+ */
31
+ export type CloseResult = {
32
+ closed: true;
33
+ remainingSize: null;
34
+ manual: false;
35
+ } | {
36
+ closed: false;
37
+ remainingSize: string | null;
38
+ manual: false;
39
+ } | {
40
+ closed: false;
41
+ remainingSize: null;
42
+ manual: true;
43
+ reason: string;
44
+ };
45
+ /** Rotation outcome (ok: the venue's entry price of the new short, null = not readable); manual = a leg's state is unknown or unhedged: the caller must quarantine. */
46
+ export type RotationResult = {
47
+ ok: true;
48
+ entryPrice: number | null;
49
+ } | {
50
+ ok: false;
51
+ manual: false;
52
+ } | {
53
+ ok: false;
54
+ manual: true;
55
+ reason: string;
56
+ };
15
57
  export declare class SpotPerpExecutor {
16
- private _spotAdapterCache;
17
- /** Get or create cached spot adapter. */
58
+ /** Spot adapter for a bot spot venue (factory-cached); null outside the bot spot scope. */
18
59
  getSpotAdapter(name: string, adapter: ExchangeAdapter): Promise<SpotAdapter | null>;
19
- /** Transfer USDC: perp -> spot account. */
20
- transferToSpot(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
21
- /** Transfer USDC: spot -> perp account. */
22
- transferToPerp(spotAdapter: SpotAdapter, exchange: string, amount: number): Promise<void>;
60
+ /** Transfer the spot quote asset perp -> spot. false = shared margin (nothing moved). */
61
+ transferToSpot(spotAdapter: SpotAdapter, amount: number): Promise<boolean>;
62
+ /** Transfer the spot quote asset spot -> perp. false = shared margin (nothing moved). */
63
+ transferToPerp(spotAdapter: SpotAdapter, amount: number): Promise<boolean>;
64
+ /** Rollback transfer spot → perp: failure is logged loudly (funds remain in spot), never swallowed. */
65
+ private returnToPerp;
23
66
  /**
24
67
  * Full spot-perp entry flow:
25
68
  * 1. Check liquidity
@@ -47,6 +90,11 @@ export declare class SpotPerpExecutor {
47
90
  * 1. Close perp short FIRST (reduceOnly) — hedge stays intact if this fails
48
91
  * 2. Sell spot
49
92
  * 3. Transfer USDC back to perp (always, not just same-exchange)
93
+ *
94
+ * Returns `manual: true` (caller quarantines, never retries) when the spot outcome is
95
+ * unknown (pending sell, failed post-trade verification) or spot is left unhedged.
96
+ * A perp short is re-opened only when THIS call closed it; if the perp was already
97
+ * gone and spot cannot be sold, nothing new is opened — the position is quarantined.
50
98
  */
51
99
  closePosition(params: {
52
100
  symbol: string;
@@ -56,14 +104,20 @@ export declare class SpotPerpExecutor {
56
104
  perpAdapter: ExchangeAdapter;
57
105
  size: string;
58
106
  log: (msg: string) => void;
59
- }): Promise<boolean>;
107
+ }): Promise<CloseResult>;
108
+ /** Base-token balance (total), read UNCACHED; a token with no balance row holds 0. Errors propagate. */
109
+ private baseBalance;
110
+ private baseOf;
111
+ /** Spot quote total (0 when shared with perp margin — nothing to move, or no quote row). */
112
+ private quoteOf;
60
113
  /**
61
- * Rotate perp short to better exchange:
114
+ * Rotate perp short to better exchange (spot stays unchanged):
62
115
  * 1. Set leverage on new exchange
63
- * 2. Close current perp short (reduceOnly)
64
- * 3. Open new perp short on new exchange
65
- * 4. Rollback if new short fails
66
- * Spot stays unchanged.
116
+ * 2. Close current perp short (reduceOnly, verified)
117
+ * 3. Open new perp short on new exchange (verified)
118
+ * 4. If the new short definitely failed: re-open the old short (verified)
119
+ * Any leg whose outcome is unknown, or a failed rollback, returns manual (quarantine):
120
+ * retrying could double-short or leave the spot unhedged.
67
121
  */
68
122
  rotateShort(params: {
69
123
  symbol: string;
@@ -75,5 +129,5 @@ export declare class SpotPerpExecutor {
75
129
  size: string;
76
130
  leverage: number;
77
131
  log: (msg: string) => void;
78
- }): Promise<boolean>;
132
+ }): Promise<RotationResult>;
79
133
  }