honeymaker 0.12.4-aarch64-linux

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 (136) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +33 -0
  5. data/LICENSE +21 -0
  6. data/README.md +219 -0
  7. data/Rakefile +78 -0
  8. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  9. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  10. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  11. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  12. data/lib/honeymaker/3.4/honeymaker_native.so +0 -0
  13. data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
  14. data/lib/honeymaker/client.rb +139 -0
  15. data/lib/honeymaker/clients/binance.rb +687 -0
  16. data/lib/honeymaker/clients/binance_us.rb +42 -0
  17. data/lib/honeymaker/clients/bingx.rb +242 -0
  18. data/lib/honeymaker/clients/bitget.rb +318 -0
  19. data/lib/honeymaker/clients/bitrue.rb +245 -0
  20. data/lib/honeymaker/clients/bitvavo.rb +244 -0
  21. data/lib/honeymaker/clients/bybit.rb +308 -0
  22. data/lib/honeymaker/clients/coinbase.rb +326 -0
  23. data/lib/honeymaker/clients/gemini.rb +189 -0
  24. data/lib/honeymaker/clients/hyperliquid.rb +232 -0
  25. data/lib/honeymaker/clients/kraken.rb +379 -0
  26. data/lib/honeymaker/clients/kraken_futures.rb +78 -0
  27. data/lib/honeymaker/clients/kucoin.rb +310 -0
  28. data/lib/honeymaker/clients/mexc.rb +249 -0
  29. data/lib/honeymaker/exchange.rb +129 -0
  30. data/lib/honeymaker/exchanges/binance.rb +55 -0
  31. data/lib/honeymaker/exchanges/binance_us.rb +9 -0
  32. data/lib/honeymaker/exchanges/bingx.rb +56 -0
  33. data/lib/honeymaker/exchanges/bitget.rb +52 -0
  34. data/lib/honeymaker/exchanges/bitrue.rb +53 -0
  35. data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
  36. data/lib/honeymaker/exchanges/bybit.rb +57 -0
  37. data/lib/honeymaker/exchanges/coinbase.rb +69 -0
  38. data/lib/honeymaker/exchanges/gemini.rb +134 -0
  39. data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
  40. data/lib/honeymaker/exchanges/kraken.rb +122 -0
  41. data/lib/honeymaker/exchanges/kucoin.rb +52 -0
  42. data/lib/honeymaker/exchanges/mexc.rb +56 -0
  43. data/lib/honeymaker/native/kraken.rb +263 -0
  44. data/lib/honeymaker/native.rb +42 -0
  45. data/lib/honeymaker/result.rb +46 -0
  46. data/lib/honeymaker/utils.rb +39 -0
  47. data/lib/honeymaker/version.rb +5 -0
  48. data/lib/honeymaker.rb +97 -0
  49. data/script/parity/kraken_client_live.rb +191 -0
  50. data/script/parity/kraken_live.rb +70 -0
  51. data/script/parity/kraken_transport.rb +604 -0
  52. data/script/parity/resolve.c +49 -0
  53. data/script/release/push_gems.sh +28 -0
  54. data/script/vectors/kraken.rb +50 -0
  55. data/script/vectors/kraken_stage2.rb +146 -0
  56. data/sig/honeymaker.rbs +4 -0
  57. data/test/fixtures/binance_book_ticker.json +7 -0
  58. data/test/fixtures/binance_exchange_info.json +30 -0
  59. data/test/fixtures/bingx_symbols.json +26 -0
  60. data/test/fixtures/bingx_ticker.json +12 -0
  61. data/test/fixtures/bitget_symbols.json +28 -0
  62. data/test/fixtures/bitget_tickers.json +14 -0
  63. data/test/fixtures/bitmart_symbols.json +26 -0
  64. data/test/fixtures/bitmart_ticker.json +12 -0
  65. data/test/fixtures/bitrue_book_ticker.json +7 -0
  66. data/test/fixtures/bitrue_exchange_info.json +34 -0
  67. data/test/fixtures/bitvavo_book_ticker.json +7 -0
  68. data/test/fixtures/bitvavo_markets.json +30 -0
  69. data/test/fixtures/bybit_instruments.json +23 -0
  70. data/test/fixtures/bybit_tickers.json +19 -0
  71. data/test/fixtures/coinbase_product.json +7 -0
  72. data/test/fixtures/coinbase_products.json +24 -0
  73. data/test/fixtures/gemini_pubticker.json +10 -0
  74. data/test/fixtures/gemini_symbol_detail.json +10 -0
  75. data/test/fixtures/gemini_symbols.json +1 -0
  76. data/test/fixtures/hyperliquid_all_mids.json +5 -0
  77. data/test/fixtures/hyperliquid_spot_meta.json +12 -0
  78. data/test/fixtures/kraken_asset_pairs.json +43 -0
  79. data/test/fixtures/kraken_ticker.json +16 -0
  80. data/test/fixtures/kucoin_orderbook_level1.json +10 -0
  81. data/test/fixtures/kucoin_symbols.json +17 -0
  82. data/test/fixtures/mexc_book_ticker.json +7 -0
  83. data/test/fixtures/mexc_exchange_info.json +64 -0
  84. data/test/honeymaker/client_test.rb +90 -0
  85. data/test/honeymaker/clients/binance_client_test.rb +151 -0
  86. data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
  87. data/test/honeymaker/clients/bingx_client_test.rb +106 -0
  88. data/test/honeymaker/clients/bitget_client_test.rb +146 -0
  89. data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
  90. data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
  91. data/test/honeymaker/clients/bybit_client_test.rb +137 -0
  92. data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
  93. data/test/honeymaker/clients/gemini_client_test.rb +100 -0
  94. data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
  95. data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
  96. data/test/honeymaker/clients/kraken_client_test.rb +255 -0
  97. data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
  98. data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
  99. data/test/honeymaker/clients/mexc_client_test.rb +98 -0
  100. data/test/honeymaker/clients/validation_test.rb +167 -0
  101. data/test/honeymaker/error_chain_test.rb +133 -0
  102. data/test/honeymaker/exchange_test.rb +138 -0
  103. data/test/honeymaker/exchanges/binance_test.rb +83 -0
  104. data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
  105. data/test/honeymaker/exchanges/bingx_test.rb +65 -0
  106. data/test/honeymaker/exchanges/bitget_test.rb +65 -0
  107. data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
  108. data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
  109. data/test/honeymaker/exchanges/bybit_test.rb +55 -0
  110. data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
  111. data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
  112. data/test/honeymaker/exchanges/gemini_test.rb +184 -0
  113. data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
  114. data/test/honeymaker/exchanges/kraken_test.rb +191 -0
  115. data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
  116. data/test/honeymaker/exchanges/mexc_test.rb +97 -0
  117. data/test/honeymaker/honeymaker_test.rb +27 -0
  118. data/test/honeymaker/result_test.rb +42 -0
  119. data/test/honeymaker/utils_test.rb +64 -0
  120. data/test/native/backend_switch_test.rb +53 -0
  121. data/test/native/convert_test.rb +137 -0
  122. data/test/native/gem_isolation_test.rb +17 -0
  123. data/test/native/loader_test.rb +29 -0
  124. data/test/native/lockstep_test.rb +23 -0
  125. data/test/native/signing_smoke_test.rb +28 -0
  126. data/test/parity/kraken_client_parity_test.rb +398 -0
  127. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  128. data/test/release/branch_hardening_test.rb +34 -0
  129. data/test/release/push_gems_test.rb +79 -0
  130. data/test/smoke.rb +24 -0
  131. data/test/support/fixture_helper.rb +13 -0
  132. data/test/support/kraken_signing_smoke.rb +21 -0
  133. data/test/support/parity_helper.rb +62 -0
  134. data/test/support/venue_double.rb +100 -0
  135. data/test/test_helper.rb +9 -0
  136. metadata +254 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Binance < Exchange
6
+ BASE_URL = "https://api.binance.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/api/v3/exchangeInfo") do |req|
11
+ req.params = { permissions: "SPOT" }
12
+ end
13
+
14
+ response.body["symbols"].filter_map do |product|
15
+ f = Utils.parse_filters(product["filters"])
16
+
17
+ {
18
+ ticker: product["symbol"],
19
+ base: product["baseAsset"],
20
+ quote: product["quoteAsset"],
21
+ minimum_base_size: f[:lot_size]["minQty"],
22
+ minimum_quote_size: f[:notional]["minNotional"],
23
+ maximum_base_size: f[:lot_size]["maxQty"],
24
+ maximum_quote_size: f[:notional]["maxNotional"],
25
+ base_decimals: Utils.decimals(f[:lot_size]["stepSize"]),
26
+ quote_decimals: product["quoteAssetPrecision"],
27
+ price_decimals: Utils.decimals(f[:price]["tickSize"]),
28
+ available: true,
29
+ trading_enabled: product["status"] == "TRADING"
30
+ }
31
+ end
32
+ end
33
+ end
34
+
35
+ def get_bid_ask(symbol)
36
+ with_rescue do
37
+ response = connection.get("/api/v3/ticker/bookTicker") do |req|
38
+ req.params = { symbol: symbol }
39
+ end
40
+
41
+ {
42
+ bid: BigDecimal(response.body["bidPrice"]),
43
+ ask: BigDecimal(response.body["askPrice"])
44
+ }
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def connection
51
+ @connection ||= build_connection(self.class::BASE_URL)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class BinanceUs < Binance
6
+ BASE_URL = "https://api.binance.us"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class BingX < Exchange
6
+ BASE_URL = "https://open-api.bingx.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/openApi/spot/v1/common/symbols")
11
+
12
+ response.body["data"]["symbols"].filter_map do |product|
13
+ ticker = product["symbol"]
14
+ parts = ticker.split("-")
15
+ next unless parts.size == 2
16
+
17
+ {
18
+ ticker: ticker,
19
+ base: parts[0],
20
+ quote: parts[1],
21
+ minimum_base_size: product["minQty"]&.to_s,
22
+ minimum_quote_size: product["minNotional"]&.to_s,
23
+ maximum_base_size: product["maxQty"]&.to_s,
24
+ maximum_quote_size: product["maxNotional"]&.to_s,
25
+ base_decimals: Utils.decimals(product["stepSize"]),
26
+ quote_decimals: Utils.decimals(product["tickSize"]),
27
+ price_decimals: Utils.decimals(product["tickSize"]),
28
+ available: true,
29
+ trading_enabled: product["status"].to_i == 1
30
+ }
31
+ end
32
+ end
33
+ end
34
+
35
+ def get_bid_ask(symbol)
36
+ with_rescue do
37
+ response = connection.get("/openApi/spot/v2/market/ticker") do |req|
38
+ req.params = { symbol: symbol }
39
+ end
40
+
41
+ data = response.body["data"].first
42
+ {
43
+ bid: BigDecimal(data["bestBidPrice"]),
44
+ ask: BigDecimal(data["bestAskPrice"])
45
+ }
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def connection
52
+ @connection ||= build_connection(BASE_URL, content_type_match: //)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Bitget < Exchange
6
+ BASE_URL = "https://api.bitget.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/api/v2/spot/public/symbols")
11
+
12
+ response.body["data"].filter_map do |product|
13
+ {
14
+ ticker: product["symbol"],
15
+ base: product["baseCoin"],
16
+ quote: product["quoteCoin"],
17
+ minimum_base_size: product["minTradeAmount"],
18
+ minimum_quote_size: product["minTradeUSDT"],
19
+ maximum_base_size: product["maxTradeAmount"],
20
+ maximum_quote_size: nil,
21
+ base_decimals: product["quantityPrecision"].to_i,
22
+ quote_decimals: product["quotePrecision"].to_i,
23
+ price_decimals: product["pricePrecision"].to_i,
24
+ available: true,
25
+ trading_enabled: product["status"] == "online"
26
+ }
27
+ end
28
+ end
29
+ end
30
+
31
+ def get_bid_ask(symbol)
32
+ with_rescue do
33
+ response = connection.get("/api/v2/spot/market/tickers") do |req|
34
+ req.params = { symbol: symbol }
35
+ end
36
+
37
+ ticker = response.body["data"].first
38
+ {
39
+ bid: BigDecimal(ticker["bestBid"]),
40
+ ask: BigDecimal(ticker["bestAsk"])
41
+ }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def connection
48
+ @connection ||= build_connection(BASE_URL)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Bitrue < Exchange
6
+ BASE_URL = "https://openapi.bitrue.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/api/v1/exchangeInfo")
11
+
12
+ response.body["symbols"].filter_map do |product|
13
+ f = Utils.parse_filters(product["filters"] || [])
14
+
15
+ {
16
+ ticker: product["symbol"],
17
+ base: product["baseAsset"]&.upcase,
18
+ quote: product["quoteAsset"]&.upcase,
19
+ minimum_base_size: f[:lot_size]&.dig("minQty"),
20
+ minimum_quote_size: f[:lot_size]&.dig("minVal"),
21
+ maximum_base_size: f[:lot_size]&.dig("maxQty"),
22
+ maximum_quote_size: nil,
23
+ base_decimals: f[:lot_size] ? Utils.decimals(f[:lot_size]["stepSize"]) : product["baseAssetPrecision"],
24
+ quote_decimals: product["quotePrecision"],
25
+ price_decimals: f[:price] ? Utils.decimals(f[:price]["tickSize"]) : product["quotePrecision"],
26
+ available: true,
27
+ trading_enabled: product["status"] == "TRADING"
28
+ }
29
+ end
30
+ end
31
+ end
32
+
33
+ def get_bid_ask(symbol)
34
+ with_rescue do
35
+ response = connection.get("/api/v1/ticker/bookTicker") do |req|
36
+ req.params = { symbol: symbol }
37
+ end
38
+
39
+ {
40
+ bid: BigDecimal(response.body["bidPrice"]),
41
+ ask: BigDecimal(response.body["askPrice"])
42
+ }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def connection
49
+ @connection ||= build_connection(BASE_URL)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Bitvavo < Exchange
6
+ BASE_URL = "https://api.bitvavo.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/v2/markets")
11
+
12
+ response.body.filter_map do |product|
13
+ market = product["market"]
14
+ base, quote = market.split("-")
15
+
16
+ {
17
+ ticker: market,
18
+ base: base,
19
+ quote: quote,
20
+ minimum_base_size: product["minOrderInBaseAsset"],
21
+ minimum_quote_size: product["minOrderInQuoteAsset"],
22
+ maximum_base_size: nil,
23
+ maximum_quote_size: nil,
24
+ base_decimals: product["quantityDecimals"] || 8,
25
+ quote_decimals: product["notionalDecimals"] || 8,
26
+ price_decimals: count_decimals(product.fetch("tickSize")),
27
+ available: true,
28
+ trading_enabled: product["status"] == "trading"
29
+ }
30
+ end
31
+ end
32
+ end
33
+
34
+ def get_bid_ask(symbol)
35
+ with_rescue do
36
+ response = connection.get("/v2/ticker/book") do |req|
37
+ req.params = { market: symbol }
38
+ end
39
+
40
+ {
41
+ bid: BigDecimal(response.body["bid"]),
42
+ ask: BigDecimal(response.body["ask"])
43
+ }
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Bitvavo's tickSize is always a power of ten (e.g. "1.00", "0.0000100"),
50
+ # so the count of significant decimal places is the price precision.
51
+ def count_decimals(value)
52
+ # Strip trailing zeros AND a dangling dot so whole-number ticks like
53
+ # "1.00" -> "1" yield 0 (Ruby's "1.".split(".") would wrongly give ["1"]).
54
+ str = value.to_s.sub(/\.?0+$/, "")
55
+ return 0 unless str.include?(".")
56
+
57
+ str.split(".").last.length
58
+ end
59
+
60
+ def connection
61
+ @connection ||= build_connection(BASE_URL)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Bybit < Exchange
6
+ BASE_URL = "https://api.bybit.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/v5/market/instruments-info") do |req|
11
+ req.params = { category: "spot" }
12
+ end
13
+
14
+ response.body["result"]["list"].filter_map do |product|
15
+ lot_size_filter = product["lotSizeFilter"]
16
+ price_filter = product["priceFilter"]
17
+
18
+ {
19
+ ticker: product["symbol"],
20
+ base: product["baseCoin"],
21
+ quote: product["quoteCoin"],
22
+ minimum_base_size: lot_size_filter["minOrderQty"],
23
+ minimum_quote_size: lot_size_filter["minOrderAmt"],
24
+ maximum_base_size: lot_size_filter["maxOrderQty"],
25
+ maximum_quote_size: lot_size_filter["maxOrderAmt"],
26
+ base_decimals: Utils.decimals(lot_size_filter["basePrecision"]),
27
+ quote_decimals: Utils.decimals(lot_size_filter["quotePrecision"]),
28
+ price_decimals: Utils.decimals(price_filter["tickSize"]),
29
+ available: true,
30
+ trading_enabled: product["status"] == "Trading"
31
+ }
32
+ end
33
+ end
34
+ end
35
+
36
+ def get_bid_ask(symbol)
37
+ with_rescue do
38
+ response = connection.get("/v5/market/tickers") do |req|
39
+ req.params = { category: "spot", symbol: symbol }
40
+ end
41
+
42
+ ticker = response.body["result"]["list"].first
43
+ {
44
+ bid: BigDecimal(ticker["bid1Price"]),
45
+ ask: BigDecimal(ticker["ask1Price"])
46
+ }
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def connection
53
+ @connection ||= build_connection(BASE_URL)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Coinbase < Exchange
6
+ BASE_URL = "https://api.coinbase.com"
7
+
8
+ ASSET_BLACKLIST = [
9
+ "RNDR", # delisted; has the same external_id as RENDER
10
+ "ZETACHAIN", # has the same external_id as ZETA
11
+ "WAXL" # has the same external_id as AXL
12
+ ].freeze
13
+
14
+ def get_tickers_info
15
+ with_rescue do
16
+ response = connection.get("/api/v3/brokerage/market/products")
17
+
18
+ response.body["products"].filter_map do |product|
19
+ ticker = product["product_id"]
20
+ base, quote = ticker.split("-")
21
+ next if ASSET_BLACKLIST.include?(base)
22
+
23
+ {
24
+ ticker: ticker,
25
+ base: base,
26
+ quote: quote,
27
+ minimum_base_size: product["base_min_size"],
28
+ minimum_quote_size: product["quote_min_size"],
29
+ maximum_base_size: product["base_max_size"],
30
+ maximum_quote_size: product["quote_max_size"],
31
+ base_decimals: Utils.decimals(product["base_increment"]),
32
+ quote_decimals: Utils.decimals(product["quote_increment"]),
33
+ price_decimals: Utils.decimals(product["price_increment"]),
34
+ available: true,
35
+ trading_enabled: trading_enabled?(product)
36
+ }
37
+ end
38
+ end
39
+ end
40
+
41
+ def get_bid_ask(symbol)
42
+ with_rescue do
43
+ response = connection.get("/api/v3/brokerage/market/products/#{symbol}")
44
+
45
+ {
46
+ bid: BigDecimal(response.body["bid"]),
47
+ ask: BigDecimal(response.body["ask"])
48
+ }
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # Coinbase only signals problems explicitly: a `trading_disabled`, `is_disabled`, `limit_only`
55
+ # or `cancel_only` flag, or a non-"online" `status`. Absent any signal, treat the pair as
56
+ # enabled. limit_only is reported as not tradable, like Kraken's limit_only status.
57
+ def trading_enabled?(product)
58
+ return false if %w[trading_disabled is_disabled limit_only cancel_only].any? { |flag| product[flag] == true }
59
+ return false if product.key?("status") && product["status"] != "online"
60
+
61
+ true
62
+ end
63
+
64
+ def connection
65
+ @connection ||= build_connection(BASE_URL)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Gemini < Exchange
6
+ BASE_URL = "https://api.gemini.com"
7
+
8
+ # Gemini has no bulk symbol-details endpoint, so the catalogue is one request per symbol --
9
+ # ~350 of them against a public limit of 120/min. Unpaced they ran at ~208/min, and a single
10
+ # dropped connection failed the whole catalogue: the adapter retries nothing on its own.
11
+ REQUEST_INTERVAL = 0.5
12
+ REQUEST_ATTEMPTS = 3
13
+ TRANSIENT_ERRORS = [
14
+ Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::RequestTimeoutError,
15
+ Faraday::TooManyRequestsError, Faraday::ServerError
16
+ ].freeze
17
+
18
+ # A symbol still failing after its retries is usually the venue or the connection, not the
19
+ # instrument, and each costs up to ~100 s. Past this many in one run the venue itself is down, so
20
+ # the whole catalogue fails, as any failure did before.
21
+ # ponytail: flat per-run budget; raise it if a rollout ever 5xxs a bigger family than this.
22
+ NO_ANSWER_LIMIT = 10
23
+
24
+ # /v1/symbols says what Gemini lists; the details only describe it. A listed symbol whose details
25
+ # cannot be read this run is SET ASIDE: left out of the catalogue and named in
26
+ # #unreadable_symbols, which callers must read as "unknown", never as delisted. Gemini lists new
27
+ # symbols before it can describe them (details/gramsgd answered 400 InvalidSymbol for hours on
28
+ # 2026-09-22), and failing the whole catalogue for that one left every Gemini pair without a
29
+ # verdict.
30
+ def get_tickers_info
31
+ @unreadable_symbols = {}
32
+ with_rescue do
33
+ symbols = catalogue_get("/v1/symbols")
34
+ no_answers = 0
35
+
36
+ tickers = symbols.filter_map do |symbol|
37
+ describe(symbol, catalogue_get("/v1/symbols/details/#{symbol}"))
38
+ rescue *TRANSIENT_ERRORS => e
39
+ raise if (no_answers += 1) >= NO_ANSWER_LIMIT
40
+
41
+ set_aside(symbol, e)
42
+ rescue StandardError => e
43
+ set_aside(symbol, e)
44
+ end
45
+ tickers = set_aside_shared_pairs(tickers)
46
+ if tickers.empty? && @unreadable_symbols.any?
47
+ raise Error, "Gemini: no symbol could be read, e.g. #{@unreadable_symbols.first.join(': ')}"
48
+ end
49
+
50
+ tickers
51
+ end
52
+ end
53
+
54
+ def get_bid_ask(symbol)
55
+ with_rescue do
56
+ response = connection.get("/v1/pubticker/#{symbol.downcase}")
57
+
58
+ {
59
+ bid: BigDecimal(response.body["bid"]),
60
+ ask: BigDecimal(response.body["ask"])
61
+ }
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def connection
68
+ @connection ||= build_connection(BASE_URL)
69
+ end
70
+
71
+ def describe(symbol, detail)
72
+ return unless spot?(symbol, detail)
73
+
74
+ tick_size = detail["tick_size"]&.to_s || "0.01"
75
+ quote_increment = detail["quote_increment"]&.to_s || "0.01"
76
+
77
+ {
78
+ ticker: symbol.upcase,
79
+ base: detail["base_currency"].upcase,
80
+ quote: detail["quote_currency"].upcase,
81
+ minimum_base_size: detail["min_order_size"],
82
+ minimum_quote_size: "0",
83
+ maximum_base_size: nil,
84
+ maximum_quote_size: nil,
85
+ base_decimals: Utils.decimals(tick_size),
86
+ quote_decimals: Utils.decimals(quote_increment),
87
+ price_decimals: Utils.decimals(quote_increment),
88
+ available: true,
89
+ trading_enabled: detail["status"] == "open"
90
+ }
91
+ end
92
+
93
+ # Gemini lists perpetual swaps beside spot, under the same base and quote (BTCGUSDPERP is
94
+ # BTC/GUSD, product_type "swap"), so only product_type tells them apart. An instrument without
95
+ # one is not assumed to be spot: it is set aside. So is a body that is not an object -- JSON
96
+ # served as text/plain arrives as a String, and String#[] finds "product_type" inside it.
97
+ def spot?(symbol, detail)
98
+ raise Error, "Gemini #{symbol}: details are not an object" unless detail.is_a?(Hash)
99
+
100
+ product_type = detail["product_type"]
101
+ raise Error, "Gemini #{symbol}: no product_type in its details" unless product_type.is_a?(String) && !product_type.empty?
102
+ # A missing status would otherwise read as "not open" and revoke the pair.
103
+ raise Error, "Gemini #{symbol}: no status in its details" unless detail["status"].is_a?(String)
104
+
105
+ product_type == "spot"
106
+ end
107
+
108
+ # One pair, one instrument. Two would leave callers to pick one by list order, so neither is
109
+ # picked: both are set aside until Gemini lists only one.
110
+ def set_aside_shared_pairs(tickers)
111
+ shared = tickers.group_by { |t| [t[:base], t[:quote]] }.values.reject(&:one?)
112
+ shared.each do |group|
113
+ claim = "Gemini #{group.first[:base]}/#{group.first[:quote]} is claimed by #{group.map { |t| t[:ticker] }.join(' and ')}"
114
+ group.each { |t| @unreadable_symbols[t[:ticker]] = claim }
115
+ end
116
+ tickers - shared.flatten
117
+ end
118
+
119
+ def catalogue_get(path)
120
+ attempt = 1
121
+ begin
122
+ sleep(REQUEST_INTERVAL)
123
+ connection.get(path).body
124
+ rescue *TRANSIENT_ERRORS
125
+ raise if attempt >= REQUEST_ATTEMPTS
126
+
127
+ sleep(2**attempt)
128
+ attempt += 1
129
+ retry
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Hyperliquid < Exchange
6
+ BASE_URL = "https://api.hyperliquid.xyz"
7
+
8
+ # Hyperliquid enforces a hard 10-USDC minimum order value on every spot pair (spotMeta exposes
9
+ # no per-token base floor). Surfaced as minimum_quote_size so consumers skip/reject sub-10
10
+ # orders up front instead of having the exchange reject them.
11
+ MINIMUM_QUOTE_SIZE = 10
12
+
13
+ def get_tickers_info
14
+ with_rescue do
15
+ response = connection.post("/info") do |req|
16
+ req.body = { type: "spotMeta" }.to_json
17
+ end
18
+
19
+ tokens = response.body["tokens"]
20
+ universe = response.body["universe"]
21
+ token_map = tokens.each_with_object({}) { |t, h| h[t["index"]] = t }
22
+
23
+ universe.filter_map do |pair|
24
+ base_token = token_map[pair["tokens"][0]]
25
+ quote_token = token_map[pair["tokens"][1]]
26
+ next unless base_token && quote_token
27
+
28
+ {
29
+ ticker: pair["name"],
30
+ base: base_token["name"],
31
+ quote: quote_token["name"],
32
+ minimum_base_size: nil,
33
+ minimum_quote_size: MINIMUM_QUOTE_SIZE,
34
+ maximum_base_size: nil,
35
+ maximum_quote_size: nil,
36
+ base_decimals: base_token["szDecimals"] || 0,
37
+ quote_decimals: 2,
38
+ price_decimals: 5,
39
+ available: true,
40
+ trading_enabled: true
41
+ }
42
+ end
43
+ end
44
+ end
45
+
46
+ def get_bid_ask(symbol)
47
+ with_rescue do
48
+ response = connection.post("/info") do |req|
49
+ req.body = { type: "allMids" }.to_json
50
+ end
51
+
52
+ price = BigDecimal(response.body[symbol])
53
+ {
54
+ bid: price,
55
+ ask: price
56
+ }
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def connection
63
+ @connection ||= build_connection(BASE_URL)
64
+ end
65
+ end
66
+ end
67
+ end