perp-cli 0.12.17 → 0.14.1

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 (363) 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 +303 -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 +248 -0
  101. package/dist/commands/funds.d.ts +25 -0
  102. package/dist/commands/funds.js +925 -245
  103. package/dist/commands/history.js +102 -54
  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 +357 -459
  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 +240 -6
  166. package/dist/exchanges/aster.js +1139 -195
  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 +152 -0
  200. package/dist/exchanges/ondo/adapter.js +811 -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 +95 -0
  206. package/dist/exchanges/ondo/funds.js +198 -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 +293 -4
  218. package/dist/exchanges/pacifica.js +1044 -90
  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 +1386 -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 +29 -0
  270. package/dist/exchanges/venues/ondo.js +136 -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 +39 -0
  274. package/dist/exchanges/venues/qfex.js +140 -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 +821 -403
  290. package/dist/mcp-trade-guard.d.ts +16 -0
  291. package/dist/mcp-trade-guard.js +59 -0
  292. package/dist/onchain-balances.d.ts +40 -0
  293. package/dist/onchain-balances.js +173 -0
  294. package/dist/pacifica/client.d.ts +41 -10
  295. package/dist/pacifica/client.js +167 -63
  296. package/dist/pacifica/constants.d.ts +0 -1
  297. package/dist/pacifica/constants.js +0 -5
  298. package/dist/pacifica/deposit.d.ts +30 -3
  299. package/dist/pacifica/deposit.js +119 -2
  300. package/dist/pacifica/faucet.d.ts +29 -0
  301. package/dist/pacifica/faucet.js +100 -0
  302. package/dist/pacifica/sign-tx.d.ts +15 -0
  303. package/dist/pacifica/sign-tx.js +41 -0
  304. package/dist/pacifica/types/account.d.ts +28 -4
  305. package/dist/pacifica/types/lake.d.ts +20 -5
  306. package/dist/pacifica/types/lake.js +4 -0
  307. package/dist/pacifica/types/market.d.ts +5 -0
  308. package/dist/pacifica/types/order.d.ts +4 -1
  309. package/dist/pacifica/types/position.d.ts +9 -1
  310. package/dist/plan-executor.d.ts +10 -5
  311. package/dist/plan-executor.js +165 -54
  312. package/dist/position-history.js +13 -0
  313. package/dist/rebalance.d.ts +26 -2
  314. package/dist/rebalance.js +86 -7
  315. package/dist/relay-swap.d.ts +232 -0
  316. package/dist/relay-swap.js +630 -0
  317. package/dist/risk.js +5 -1
  318. package/dist/secret-env.d.ts +15 -0
  319. package/dist/secret-env.js +36 -0
  320. package/dist/settings.d.ts +169 -6
  321. package/dist/settings.js +219 -38
  322. package/dist/signer/ows-evm.d.ts +13 -0
  323. package/dist/signer/ows-evm.js +22 -0
  324. package/dist/smart-order.d.ts +9 -0
  325. package/dist/smart-order.js +53 -8
  326. package/dist/strategies/dca.d.ts +6 -2
  327. package/dist/strategies/dca.js +66 -26
  328. package/dist/strategies/funding-arb.d.ts +11 -1
  329. package/dist/strategies/funding-arb.js +202 -89
  330. package/dist/strategies/grid.d.ts +31 -0
  331. package/dist/strategies/grid.js +89 -11
  332. package/dist/strategies/pending-stop.d.ts +8 -0
  333. package/dist/strategies/pending-stop.js +12 -0
  334. package/dist/strategies/split-order.d.ts +45 -17
  335. package/dist/strategies/split-order.js +157 -91
  336. package/dist/strategies/trailing-stop.d.ts +2 -1
  337. package/dist/strategies/trailing-stop.js +52 -6
  338. package/dist/strategies/twap.d.ts +6 -1
  339. package/dist/strategies/twap.js +39 -11
  340. package/dist/trade-validator.d.ts +22 -0
  341. package/dist/trade-validator.js +119 -15
  342. package/dist/utils/numeric.d.ts +46 -0
  343. package/dist/utils/numeric.js +79 -0
  344. package/dist/utils.d.ts +19 -0
  345. package/dist/utils.js +96 -19
  346. package/dist/withdraw-gate.d.ts +49 -0
  347. package/dist/withdraw-gate.js +131 -0
  348. package/package.json +15 -6
  349. package/skills/perp-cli/SKILL.md +159 -55
  350. package/skills/perp-cli/references/agent-operations.md +112 -58
  351. package/skills/perp-cli/references/commands.md +269 -73
  352. package/skills/perp-cli/references/strategies.md +34 -31
  353. package/skills/perp-cli/scripts/arb-monitor.sh +10 -5
  354. package/skills/perp-cli/scripts/funding-analysis.sh +14 -9
  355. package/skills/perp-cli/scripts/preflight.sh +21 -14
  356. package/skills/perp-cli/scripts/spot-perp-scan.sh +12 -7
  357. package/skills/perp-cli/scripts/validate-arb.sh +31 -15
  358. package/vendor/lighter-signer/SHA256SUMS +2 -0
  359. package/vendor/lighter-signer/VERSION +8 -0
  360. package/vendor/lighter-signer/lighter-signer.wasm +0 -0
  361. package/vendor/lighter-signer/wasm_exec.js +575 -0
  362. package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +0 -42
  363. package/dist/bot/strategies/funding-arb-v2-strategy.js +0 -864
@@ -1,198 +1,185 @@
1
1
  /**
2
- * Real-time 3-DEX funding rate comparison.
2
+ * Real-time cross-venue funding rate comparison.
3
3
  *
4
- * Fetches funding rates from Pacifica, Hyperliquid, and Lighter in parallel,
5
- * normalizes them to comparable hourly rates, and identifies arbitrage
6
- * opportunities across exchanges.
4
+ * Fans out over every venue with the "marketData" role (design §10.6) using
5
+ * each venue's `publicData.fetchFundingRates(net)`, normalizes every row to a
6
+ * per-hour rate with the row's own basis (Lighter
7
+ * family 8h, Aster per-symbol), and matches markets across venues by
8
+ * `canonical` (null → excluded, §10.9).
9
+ *
10
+ * A venue that fails is reported in `exchangeStatus` ("error") and
11
+ * `exchangeErrors` (message) — its rows are absent, never silently empty.
7
12
  */
8
- import { toHourlyRate, annualizeRate, computeAnnualSpread, estimateHourlyFunding } from "./normalize.js";
9
- import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates as fetchLtFundingRates, } from "../shared-api.js";
13
+ import { annualizeHourlyRate, estimateHourlyFundingFromHourly } from "./normalize.js";
10
14
  import { getHistoricalAverages } from "./history.js";
11
15
  import { SPOT_PERP_TOKEN_MAP, MAX_PRICE_DEVIATION_PCT } from "../exchanges/index.js";
16
+ import { getVenue, listVenuesWithRole, READ_ONLY_CREDENTIALS } from "../exchanges/venue.js";
17
+ import { rateToHourly } from "../exchanges/venue-types.js";
18
+ import { ensureVenuesRegistered } from "../exchanges/venues/index.js";
19
+ import { sharedApiNetwork } from "../api/public/urls.js";
20
+ import { PerpError } from "../errors.js";
12
21
  // ── Default top symbols to track ──
13
22
  export const TOP_SYMBOLS = [
14
23
  "BTC", "ETH", "SOL", "DOGE", "SUI", "AVAX", "LINK", "ARB",
15
24
  "WIF", "PEPE", "ONDO", "SEI", "TIA", "INJ", "NEAR",
16
25
  "APT", "OP", "FIL", "AAVE", "MKR",
17
26
  ];
18
- // ── Fetchers (using shared-api.ts) ──
19
- async function fetchPacificaRates() {
20
- try {
21
- const assets = await fetchPacificaPrices();
22
- return assets.map(p => {
23
- const hourly = toHourlyRate(p.funding, "pacifica");
24
- return {
25
- exchange: "pacifica",
26
- symbol: p.symbol,
27
- fundingRate: p.funding,
28
- hourlyRate: hourly,
29
- annualizedPct: annualizeRate(p.funding, "pacifica"),
30
- markPrice: p.mark,
31
- nextFundingTime: p.nextFunding,
32
- };
33
- });
34
- }
35
- catch {
36
- return [];
37
- }
27
+ /** Mark-price source preference for `bestMarkPrice` (most liquid first). */
28
+ const MARK_PRICE_PREFERENCE = [
29
+ "hyperliquid", "pacifica", "lighter", "aster", "lighter-rh", "ondo", "arcus", "qfex",
30
+ ];
31
+ // ── Per-venue fetch ──
32
+ function errorMessage(e) {
33
+ if (e instanceof PerpError)
34
+ return e.structured.message;
35
+ return e instanceof Error ? e.message : String(e);
38
36
  }
39
- async function fetchHyperliquidRates() {
40
- try {
41
- const assets = await fetchHyperliquidMeta();
42
- return assets.map(a => {
43
- const hourly = toHourlyRate(a.funding, "hyperliquid");
44
- return {
45
- exchange: "hyperliquid",
46
- symbol: a.symbol,
47
- fundingRate: a.funding,
48
- hourlyRate: hourly,
49
- annualizedPct: annualizeRate(a.funding, "hyperliquid"),
50
- markPrice: a.markPx,
51
- };
52
- });
53
- }
54
- catch {
55
- return [];
37
+ /**
38
+ * Funding rows for one venue, normalized. Rows the venue publishes without a
39
+ * mark price are priced from the same venue's markets (by venueSymbol); rows
40
+ * still unpriced, or without a canonical symbol, are excluded (never 0).
41
+ */
42
+ async function fetchVenueRates(def, net) {
43
+ const rows = await def.publicData.fetchFundingRates(net);
44
+ let markBySymbol = null;
45
+ if (rows.some(r => r.markPrice === null)) {
46
+ const markets = await def.publicData.fetchMarkets(net);
47
+ markBySymbol = new Map(markets.map(m => [m.venueSymbol, m.markPrice]));
56
48
  }
57
- }
58
- async function fetchLighterRates() {
59
- // Outer try/catch returning [] is intentional graceful degradation; see
60
- // fetchAsterRates for the same pattern. Inside, SSOT rule #2 applies —
61
- // skip rows whose mark price cannot be resolved instead of 0-coercing.
62
- try {
63
- const [details, funding] = await Promise.all([
64
- fetchLighterOrderBookDetails(),
65
- fetchLtFundingRates(),
66
- ]);
67
- const priceMap = new Map(details.map(d => [d.marketId, d.lastTradePrice]));
68
- const symMap = new Map(details.map(d => [d.marketId, d.symbol]));
69
- const out = [];
70
- for (const fr of funding) {
71
- const symbol = fr.symbol || symMap.get(fr.marketId) || "";
72
- if (!symbol)
73
- continue;
74
- const rate = fr.rate;
75
- // Prefer fr.markPrice; fall back to orderBook last-trade as a documented
76
- // price-source preference (NOT an error fallback).
77
- const directMark = fr.markPrice;
78
- const fallbackMark = priceMap.get(fr.marketId);
79
- let markPrice;
80
- if (directMark !== null && Number.isFinite(directMark) && directMark > 0) {
81
- markPrice = directMark;
82
- }
83
- else if (fallbackMark !== undefined && Number.isFinite(fallbackMark) && fallbackMark > 0) {
84
- markPrice = fallbackMark;
85
- }
86
- if (!Number.isFinite(rate) || markPrice === undefined)
87
- continue;
88
- out.push({
89
- exchange: "lighter",
90
- symbol,
91
- fundingRate: rate,
92
- hourlyRate: toHourlyRate(rate, "lighter"),
93
- annualizedPct: annualizeRate(rate, "lighter"),
94
- markPrice,
95
- });
49
+ const out = [];
50
+ const unpriced = [];
51
+ for (const r of rows) {
52
+ if (r.canonical === null)
53
+ continue; // not matchable across venues
54
+ const markRaw = r.markPrice !== null ? r.markPrice : markBySymbol?.get(r.venueSymbol);
55
+ const markPrice = markRaw === undefined ? NaN : Number(markRaw);
56
+ if (!Number.isFinite(markPrice) || markPrice <= 0) {
57
+ unpriced.push(r.symbol);
58
+ continue;
96
59
  }
97
- return out;
60
+ out.push(toExchangeRate(r, r.canonical, markPrice, def.fundingVerified));
98
61
  }
99
- catch {
100
- return [];
62
+ if (unpriced.length > 0) {
63
+ console.warn(`[${def.id}] excluded ${unpriced.length} funding row(s) without a mark price: ${unpriced.join(", ")}`);
101
64
  }
65
+ return out;
102
66
  }
103
- async function fetchAsterRates() {
104
- // Outer try/catch returning [] is intentional graceful degradation: when
105
- // the entire endpoint is unreachable, the cross-DEX comparison should
106
- // continue with the remaining venues.
107
- // Inside the try block, SSOT rule #2 applies — refuse to silently 0-coerce
108
- // missing markPrice / lastFundingRate, since those values feed downstream
109
- // sizing and any 0 corrupts the comparison.
110
- try {
111
- const [premiums, tickers] = await Promise.all([
112
- fetch("https://fapi.asterdex.com/fapi/v1/premiumIndex").then(r => r.json()),
113
- fetch("https://fapi.asterdex.com/fapi/v1/ticker/24hr").then(r => r.json()),
114
- ]);
115
- const priceMap = new Map();
116
- for (const t of tickers ?? []) {
117
- const last = Number(t.lastPrice);
118
- if (Number.isFinite(last) && last > 0)
119
- priceMap.set(String(t.symbol), last);
67
+ function toExchangeRate(r, canonical, markPrice, fundingVerified) {
68
+ const fundingRate = Number(r.rate);
69
+ if (!Number.isFinite(fundingRate)) {
70
+ throw new PerpError("EXCHANGE_ERROR", `${r.venue} ${r.symbol}: non-numeric funding rate "${r.rate}"`);
71
+ }
72
+ const hourlyRate = rateToHourly(fundingRate, r.basis);
73
+ return {
74
+ exchange: r.venue,
75
+ symbol: r.symbol,
76
+ canonical,
77
+ fundingRate,
78
+ basis: r.basis,
79
+ hourlyRate,
80
+ annualizedPct: annualizeHourlyRate(hourlyRate),
81
+ markPrice,
82
+ nextFundingTime: r.nextFundingTime,
83
+ historicalAvg: null,
84
+ fundingVerified,
85
+ };
86
+ }
87
+ /**
88
+ * Fetch every venue in `venues` concurrently; failures land in status/errors.
89
+ * Venues whose funding is unverified (VenueDefinition.fundingVerified false — QFEX until its
90
+ * rate unit is confirmed) are DROPPED unless `includeUnverified`: every consumer that ranks,
91
+ * alerts on or trades funding spreads (arb scan, MCP, alerts, dashboard, portfolio) leaves it
92
+ * false. Only explicit, labelled reads pass true (`arb scan --include-unverified`).
93
+ */
94
+ export async function fetchVenuesRates(venues, net, includeUnverified) {
95
+ venues = venues.filter(v => v.fundingVerified || includeUnverified);
96
+ const settled = await Promise.allSettled(venues.map(v => fetchVenueRates(v, net)));
97
+ const exchangeStatus = {};
98
+ const exchangeErrors = {};
99
+ const rates = [];
100
+ settled.forEach((res, i) => {
101
+ const id = venues[i].id;
102
+ if (res.status === "fulfilled") {
103
+ exchangeStatus[id] = "ok";
104
+ rates.push(...res.value);
120
105
  }
121
- const out = [];
122
- for (const p of premiums ?? []) {
123
- const rawSymbol = String(p.symbol ?? "");
124
- if (!rawSymbol.endsWith("USDT"))
125
- continue;
126
- const fundingRate = Number(p.lastFundingRate);
127
- // Prefer premiumIndex.markPrice; fall back to ticker.lastPrice as a
128
- // documented price-source preference (NOT an error fallback).
129
- const markFromPremium = Number(p.markPrice);
130
- const markPrice = Number.isFinite(markFromPremium) && markFromPremium > 0
131
- ? markFromPremium
132
- : priceMap.get(rawSymbol);
133
- if (!Number.isFinite(fundingRate) || markPrice === undefined || !Number.isFinite(markPrice) || markPrice <= 0) {
134
- continue;
135
- }
136
- const symbol = rawSymbol.replace(/USDT$/, "");
137
- const nextFundingTimeRaw = Number(p.nextFundingTime);
138
- out.push({
139
- exchange: "aster",
140
- symbol,
141
- fundingRate,
142
- hourlyRate: toHourlyRate(fundingRate, "aster"),
143
- annualizedPct: annualizeRate(fundingRate, "aster"),
144
- markPrice,
145
- nextFundingTime: Number.isFinite(nextFundingTimeRaw) ? nextFundingTimeRaw : 0,
146
- });
106
+ else {
107
+ exchangeStatus[id] = "error";
108
+ exchangeErrors[id] = errorMessage(res.reason);
147
109
  }
148
- return out;
110
+ });
111
+ return { rates, exchangeStatus, exchangeErrors };
112
+ }
113
+ const _warnedPriceMismatch = new Set();
114
+ /**
115
+ * Same canonical ticker on two venues is not proof of the same asset (e.g. two
116
+ * unrelated tokens both listed as "PRL"). Within a canonical group, drop any
117
+ * venue whose mark price deviates more than MAX_PRICE_DEVIATION_PCT from the
118
+ * group median, with a one-time stderr warning per (symbol, venue).
119
+ */
120
+ function dropPriceOutliers(canonical, list) {
121
+ if (list.length < 2)
122
+ return list;
123
+ const sorted = list.map(r => r.markPrice).sort((x, y) => x - y);
124
+ const mid = sorted.length % 2 === 1
125
+ ? sorted[(sorted.length - 1) / 2]
126
+ : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2;
127
+ if (!(mid > 0))
128
+ return list;
129
+ return list.filter(r => {
130
+ const devPct = Math.abs(r.markPrice - mid) / mid * 100;
131
+ if (devPct <= MAX_PRICE_DEVIATION_PCT)
132
+ return true;
133
+ const key = `${canonical}:${r.exchange}`;
134
+ if (!_warnedPriceMismatch.has(key)) {
135
+ _warnedPriceMismatch.add(key);
136
+ process.stderr.write(`[funding] excluded ${r.exchange} ${canonical}: mark ${r.markPrice} deviates ${devPct.toFixed(1)}% from cross-venue median ${mid} (likely a different asset with the same ticker)\n`);
137
+ }
138
+ return false;
139
+ });
140
+ }
141
+ function groupByCanonical(rates, symbols) {
142
+ const out = new Map();
143
+ for (const r of rates) {
144
+ if (symbols && !symbols.includes(r.canonical))
145
+ continue;
146
+ const list = out.get(r.canonical);
147
+ if (list)
148
+ list.push(r);
149
+ else
150
+ out.set(r.canonical, [r]);
149
151
  }
150
- catch {
151
- return [];
152
+ for (const [canonical, list] of [...out]) {
153
+ const kept = dropPriceOutliers(canonical, list);
154
+ if (kept.length === 0)
155
+ out.delete(canonical); // every venue disagreed — not comparable
156
+ else
157
+ out.set(canonical, kept);
152
158
  }
159
+ return out;
160
+ }
161
+ function bestMarkPrice(rates) {
162
+ const rank = (id) => MARK_PRICE_PREFERENCE.indexOf(id);
163
+ return [...rates].sort((a, b) => rank(a.exchange) - rank(b.exchange))[0].markPrice;
153
164
  }
154
165
  // ── Core comparison logic ──
155
166
  /**
156
- * Fetch funding rates from all 3 DEXs in parallel, normalize, and compare.
167
+ * Fetch funding rates from every market-data venue in parallel, normalize, and compare.
157
168
  * Returns rates sorted by max spread (descending).
169
+ * @param opts.network defaults to the network the CLI selected (setSharedApiNetwork)
158
170
  */
159
171
  export async function fetchAllFundingRates(opts) {
160
- const exchangeStatus = {};
161
- const [pacRates, hlRates, ltRates, astRates] = await Promise.all([
162
- fetchPacificaRates().then(r => { exchangeStatus.pacifica = r.length > 0 ? "ok" : "error"; return r; }),
163
- fetchHyperliquidRates().then(r => { exchangeStatus.hyperliquid = r.length > 0 ? "ok" : "error"; return r; }),
164
- fetchLighterRates().then(r => { exchangeStatus.lighter = r.length > 0 ? "ok" : "error"; return r; }),
165
- fetchAsterRates().then(r => { exchangeStatus.aster = r.length > 0 ? "ok" : "error"; return r; }),
166
- ]);
167
- const allRates = [...pacRates, ...hlRates, ...ltRates, ...astRates];
168
- // Build per-symbol rate map
169
- const rateMap = new Map();
170
- for (const r of allRates) {
171
- if (!r.symbol)
172
- continue;
173
- if (opts?.symbols && !opts.symbols.includes(r.symbol.toUpperCase()))
174
- continue;
175
- const key = r.symbol.toUpperCase();
176
- if (!rateMap.has(key))
177
- rateMap.set(key, []);
178
- rateMap.get(key).push(r);
179
- }
180
- // Attach historical averages when available
181
- try {
182
- const symbols = Array.from(rateMap.keys());
183
- const exchanges = ["pacifica", "hyperliquid", "lighter", "aster"];
184
- const historicals = getHistoricalAverages(symbols, exchanges);
185
- for (const [, rates] of rateMap) {
186
- for (const r of rates) {
187
- const key = `${r.symbol.toUpperCase()}:${r.exchange}`;
188
- const avg = historicals.get(key);
189
- if (avg)
190
- r.historicalAvg = avg;
191
- }
192
- }
193
- }
194
- catch {
195
- // Non-critical
172
+ ensureVenuesRegistered();
173
+ const net = opts?.network ?? sharedApiNetwork();
174
+ const { rates: allRates, exchangeStatus, exchangeErrors } = await fetchVenuesRates(listVenuesWithRole("marketData"), net, opts?.includeUnverified === true);
175
+ const rateMap = groupByCanonical(allRates, opts?.symbols);
176
+ // Attach historical averages (keyed by venue symbol + venue, as saved by saveFundingSnapshot)
177
+ const grouped = [...rateMap.values()].flat();
178
+ const historicals = getHistoricalAverages([...new Set(grouped.map(r => r.symbol.toUpperCase()))], [...new Set(grouped.map(r => r.exchange))]);
179
+ for (const r of grouped) {
180
+ const avg = historicals.get(`${r.symbol.toUpperCase()}:${r.exchange}`);
181
+ if (avg)
182
+ r.historicalAvg = avg;
196
183
  }
197
184
  const comparisons = [];
198
185
  const minSpread = opts?.minSpread ?? 0;
@@ -204,18 +191,24 @@ export async function fetchAllFundingRates(opts) {
204
191
  rates.sort((a, b) => a.hourlyRate - b.hourlyRate);
205
192
  const lowest = rates[0];
206
193
  const highest = rates[rates.length - 1];
207
- const maxSpreadAnnual = computeAnnualSpread(highest.fundingRate, highest.exchange, lowest.fundingRate, lowest.exchange);
194
+ // The median filter alone lets a 2-venue group through at up to 2 × MAX_PRICE_DEVIATION_PCT
195
+ // apart (each side deviates half the gap): the CHOSEN pair must itself agree on price.
196
+ const pairGapPct = Math.abs(lowest.markPrice - highest.markPrice) / ((lowest.markPrice + highest.markPrice) / 2) * 100;
197
+ if (!(pairGapPct <= MAX_PRICE_DEVIATION_PCT)) {
198
+ const key = `${symbol}:${lowest.exchange}:${highest.exchange}`;
199
+ if (!_warnedPriceMismatch.has(key)) {
200
+ _warnedPriceMismatch.add(key);
201
+ process.stderr.write(`[funding] excluded ${symbol} ${lowest.exchange}↔${highest.exchange}: pair mark gap ${pairGapPct.toFixed(1)}% > ${MAX_PRICE_DEVIATION_PCT}%\n`);
202
+ }
203
+ continue;
204
+ }
205
+ const maxSpreadAnnual = annualizeHourlyRate(highest.hourlyRate - lowest.hourlyRate);
208
206
  if (maxSpreadAnnual < minSpread)
209
207
  continue;
210
- // Best mark price: prefer HL (most liquid), then PAC, then LT
211
- const hlRate = rates.find(r => r.exchange === "hyperliquid");
212
- const pacRate = rates.find(r => r.exchange === "pacifica");
213
- const ltRate = rates.find(r => r.exchange === "lighter");
214
- const bestMarkPrice = hlRate?.markPrice || pacRate?.markPrice || ltRate?.markPrice || 0;
215
208
  // Estimate hourly income for $1000 notional arb
216
209
  const notional = 1000;
217
- const longIncome = estimateHourlyFunding(lowest.fundingRate, lowest.exchange, notional, "long");
218
- const shortIncome = estimateHourlyFunding(highest.fundingRate, highest.exchange, notional, "short");
210
+ const longIncome = estimateHourlyFundingFromHourly(lowest.hourlyRate, notional, "long");
211
+ const shortIncome = estimateHourlyFundingFromHourly(highest.hourlyRate, notional, "short");
219
212
  const estHourlyIncomeUsd = -(longIncome + shortIncome); // negate because income = -cost
220
213
  comparisons.push({
221
214
  symbol,
@@ -223,7 +216,7 @@ export async function fetchAllFundingRates(opts) {
223
216
  maxSpreadAnnual,
224
217
  longExchange: lowest.exchange, // long where funding is lowest
225
218
  shortExchange: highest.exchange, // short where funding is highest
226
- bestMarkPrice,
219
+ bestMarkPrice: bestMarkPrice(rates),
227
220
  estHourlyIncomeUsd,
228
221
  });
229
222
  }
@@ -233,19 +226,20 @@ export async function fetchAllFundingRates(opts) {
233
226
  timestamp: new Date().toISOString(),
234
227
  symbols: comparisons,
235
228
  exchangeStatus,
229
+ exchangeErrors,
236
230
  };
237
231
  }
238
232
  /**
239
- * Fetch rates for a single symbol across all exchanges.
233
+ * Fetch rates for a single (canonical) symbol across all exchanges.
240
234
  */
241
- export async function fetchSymbolFundingRates(symbol) {
242
- const snapshot = await fetchAllFundingRates({ symbols: [symbol.toUpperCase()] });
235
+ export async function fetchSymbolFundingRates(symbol, includeUnverified = false) {
236
+ const snapshot = await fetchAllFundingRates({ symbols: [symbol.toUpperCase()], includeUnverified });
243
237
  return snapshot.symbols[0] ?? null;
244
238
  }
245
239
  /**
246
240
  * Fetch spot+perp funding rate spreads.
247
241
  * Since spot has 0 funding cost, the spread is simply |perp funding rate|.
248
- * Only includes symbols available on at least one spot exchange (HL or LT).
242
+ * Only includes symbols available on at least one bot spot venue (def.spot, §12.4 #3).
249
243
  *
250
244
  * Key safety checks:
251
245
  * - U-token mapping: UBTC→BTC, UETH→ETH, USOL→SOL, UFART→FARTCOIN
@@ -253,112 +247,67 @@ export async function fetchSymbolFundingRates(symbol) {
253
247
  * (filters out same-ticker-different-token issues like HIP-1 TRUMP ≠ perp TRUMP)
254
248
  */
255
249
  export async function fetchSpotPerpSpreads(opts) {
256
- // Fetch all perp funding rates
257
- const [hlRates, ltRates, pacRates] = await Promise.all([
258
- fetchHyperliquidRates(),
259
- fetchLighterRates(),
260
- fetchPacificaRates(),
261
- ]);
262
- // Build actual spot availability maps + spot prices for cross-validation
263
- // hlSpotByPerp: perpSymbol → { spotToken, exchange }
264
- const hlSpotByPerp = new Map(); // perpSymbol → spotTokenName
265
- const hlSpotPrices = new Map(); // perpSymbol → spot mid price
266
- const ltSpotSymbols = new Set();
267
- const ltSpotPrices = new Map(); // symbol → spot mid price
268
- try {
269
- // HL: spotMetaAndAssetCtxs → universe + prices in one call
270
- const hlMetaCtx = await fetch("https://api.hyperliquid.xyz/info", {
271
- method: "POST", headers: { "Content-Type": "application/json" },
272
- body: JSON.stringify({ type: "spotMetaAndAssetCtxs" }),
273
- }).then(r => r.json());
274
- const meta = hlMetaCtx[0];
275
- const ctxs = hlMetaCtx[1] ?? [];
276
- const tokenNames = new Map();
277
- for (const t of meta.tokens ?? [])
278
- tokenNames.set(t.index, t.name);
279
- const usdcIdx = meta.tokens?.find(t => t.name === "USDC")?.index ?? 0;
280
- // We need to match universe entries to ctxs by index.
281
- // API returns ctxs in same order as universe entries.
282
- for (let i = 0; i < (meta.universe ?? []).length; i++) {
283
- const u = meta.universe[i];
284
- if (u.tokens[1] !== usdcIdx)
285
- continue; // only /USDC pairs
286
- const base = tokenNames.get(u.tokens[0])?.toUpperCase();
287
- if (!base)
288
- continue;
289
- const ctx = ctxs[i];
290
- const spotPrice = Number(ctx?.midPx ?? ctx?.markPx ?? 0);
291
- // Map to perp symbol: UBTC→BTC, or direct if no mapping
292
- const perpSymbol = SPOT_PERP_TOKEN_MAP[base] ?? base;
293
- // Only keep the best-priced entry per perp symbol (prefer higher-volume)
294
- if (!hlSpotByPerp.has(perpSymbol) || spotPrice > 0) {
295
- hlSpotByPerp.set(perpSymbol, base);
296
- if (spotPrice > 0)
297
- hlSpotPrices.set(perpSymbol, spotPrice);
250
+ // Spot legs come from the venue definitions (def.spot, §12): only bot/arb spot venues
251
+ // (separate/runtime account + transfer, §12.4 #3). Perp legs: those venues + pacifica.
252
+ ensureVenuesRegistered();
253
+ const net = opts?.network ?? sharedApiNetwork();
254
+ const { listBotSpotVenues, requireSpotAdapter, defaultSpotQuote } = await import("../exchanges/spot-factory.js");
255
+ const spotVenues = listBotSpotVenues();
256
+ const perpVenueIds = new Set(["hyperliquid", "lighter", "pacifica", ...spotVenues.map(v => v.id)]);
257
+ const { rates: perpRates, exchangeStatus, exchangeErrors } = await fetchVenuesRates([...perpVenueIds].map(id => getVenue(id)), net, false);
258
+ // Spot availability + prices per venue: perpSymbol → candidate spot prices (U-tokens map
259
+ // UBTC→BTC; several spot tokens may claim one perp symbol). A failure is reported as
260
+ // "<venue>-spot" in status/errors.
261
+ const spotPricesByVenue = new Map();
262
+ await Promise.all(spotVenues.map(async (def) => {
263
+ try {
264
+ if (def.create === null)
265
+ throw new Error(`${def.id} has no adapter`);
266
+ const perp = await def.create({ network: net, credentials: READ_ONLY_CREDENTIALS, noAgent: true, hlDex: null, readOnly: true, noBuilder: true });
267
+ const spot = await requireSpotAdapter(def.id, perp);
268
+ const quote = defaultSpotQuote(def, net);
269
+ const byPerp = new Map();
270
+ for (const m of await spot.getSpotMarkets()) {
271
+ if (m.quoteToken.toUpperCase() !== quote || m.markPrice === null)
272
+ continue; // null = no price → cannot be validated
273
+ const px = Number(m.markPrice);
274
+ if (!Number.isFinite(px) || px <= 0)
275
+ continue; // no price → cannot be validated → not a candidate
276
+ const base = m.baseToken.toUpperCase();
277
+ const perpSymbol = SPOT_PERP_TOKEN_MAP[base] ?? base;
278
+ const list = byPerp.get(perpSymbol);
279
+ if (list)
280
+ list.push(px);
281
+ else
282
+ byPerp.set(perpSymbol, [px]);
298
283
  }
284
+ spotPricesByVenue.set(def.id, byPerp);
285
+ exchangeStatus[`${def.id}-spot`] = "ok";
299
286
  }
300
- }
301
- catch { /* non-critical */ }
302
- try {
303
- // LT: explorer API → symbols with "/"
304
- const ltMarkets = await fetch("https://explorer.elliot.ai/api/markets")
305
- .then(r => r.json());
306
- for (const m of ltMarkets) {
307
- if (m.symbol.includes("/")) {
308
- ltSpotSymbols.add(m.symbol.split("/")[0].toUpperCase());
309
- }
287
+ catch (e) {
288
+ exchangeStatus[`${def.id}-spot`] = "error";
289
+ exchangeErrors[`${def.id}-spot`] = errorMessage(e);
310
290
  }
311
- // Fetch LT spot prices from orderbook for price validation
312
- for (const sym of ltSpotSymbols) {
313
- try {
314
- const book = await fetch(`https://mainnet.zklighter.elliot.ai/api/v1/orderBookOrders?market_id=${
315
- // We don't have market_id here, skip detailed price fetch for LT
316
- // LT tokens (ETH, LINK, etc.) are direct names, low risk of mismatch
317
- ""}`);
318
- // Skip — LT uses direct token names, not indices
319
- }
320
- catch { /* skip */ }
321
- }
322
- }
323
- catch { /* non-critical */ }
324
- // Merge all perp rates by symbol
325
- const perpRateMap = new Map();
326
- for (const r of [...hlRates, ...ltRates, ...pacRates]) {
327
- if (!r.symbol)
328
- continue;
329
- if (opts?.symbols && !opts.symbols.includes(r.symbol.toUpperCase()))
330
- continue;
331
- const key = r.symbol.toUpperCase();
332
- if (!perpRateMap.has(key))
333
- perpRateMap.set(key, []);
334
- perpRateMap.get(key).push(r);
335
- }
291
+ }));
292
+ // Merge all perp rates by canonical symbol
293
+ const perpRateMap = groupByCanonical(perpRates, opts?.symbols);
336
294
  const spreads = [];
337
295
  const minSpread = opts?.minSpread ?? 0;
338
296
  for (const [symbol, rates] of perpRateMap) {
339
297
  rates.sort((a, b) => Math.abs(b.hourlyRate) - Math.abs(a.hourlyRate));
340
298
  const bestPerp = rates[0];
341
- // Spot exchanges: only include if symbol has a verified spot market
299
+ // Spot exchanges: only include if the symbol has a spot market whose price is within
300
+ // MAX_PRICE_DEVIATION_PCT of the perp mark (same ticker, different token — e.g. HIP-1
301
+ // TRUMP ≠ perp TRUMP — is skipped).
342
302
  const uniqueSpot = [];
343
- // HL spot: check U-token mapped availability
344
- if (hlSpotByPerp.has(symbol)) {
345
- // Price cross-validation: spot price must be close to perp mark price
346
- const spotPrice = hlSpotPrices.get(symbol);
347
- const perpPrice = bestPerp.markPrice;
348
- if (spotPrice && perpPrice > 0) {
349
- const deviation = Math.abs(spotPrice - perpPrice) / perpPrice * 100;
350
- if (deviation <= MAX_PRICE_DEVIATION_PCT) {
351
- uniqueSpot.push("hyperliquid");
352
- }
353
- // else: same ticker, different token (e.g., HIP-1 TRUMP ≠ perp TRUMP) → skip
354
- }
355
- else if (spotPrice === undefined) {
356
- // No price data (empty orderbook) → skip, can't verify
357
- }
303
+ const perpPrice = bestPerp.markPrice;
304
+ for (const [venueId, byPerp] of spotPricesByVenue) {
305
+ const prices = byPerp.get(symbol);
306
+ if (!prices || !(perpPrice > 0))
307
+ continue;
308
+ if (prices.some(px => Math.abs(px - perpPrice) / perpPrice * 100 <= MAX_PRICE_DEVIATION_PCT))
309
+ uniqueSpot.push(venueId);
358
310
  }
359
- // LT spot: direct token names, low mismatch risk
360
- if (ltSpotSymbols.has(symbol))
361
- uniqueSpot.push("lighter");
362
311
  if (uniqueSpot.length === 0)
363
312
  continue;
364
313
  const annualSpreadPct = Math.abs(bestPerp.annualizedPct);
@@ -385,5 +334,7 @@ export async function fetchSpotPerpSpreads(opts) {
385
334
  return {
386
335
  timestamp: new Date().toISOString(),
387
336
  spreads,
337
+ exchangeStatus,
338
+ exchangeErrors,
388
339
  };
389
340
  }
File without changes