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.
- checksums.yaml +7 -0
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +33 -0
- data/LICENSE +21 -0
- data/README.md +219 -0
- data/Rakefile +78 -0
- data/crates/honeymaker/tests/vectors/decimal.json +146 -0
- data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
- data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
- data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
- data/lib/honeymaker/3.4/honeymaker_native.so +0 -0
- data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
- data/lib/honeymaker/client.rb +139 -0
- data/lib/honeymaker/clients/binance.rb +687 -0
- data/lib/honeymaker/clients/binance_us.rb +42 -0
- data/lib/honeymaker/clients/bingx.rb +242 -0
- data/lib/honeymaker/clients/bitget.rb +318 -0
- data/lib/honeymaker/clients/bitrue.rb +245 -0
- data/lib/honeymaker/clients/bitvavo.rb +244 -0
- data/lib/honeymaker/clients/bybit.rb +308 -0
- data/lib/honeymaker/clients/coinbase.rb +326 -0
- data/lib/honeymaker/clients/gemini.rb +189 -0
- data/lib/honeymaker/clients/hyperliquid.rb +232 -0
- data/lib/honeymaker/clients/kraken.rb +379 -0
- data/lib/honeymaker/clients/kraken_futures.rb +78 -0
- data/lib/honeymaker/clients/kucoin.rb +310 -0
- data/lib/honeymaker/clients/mexc.rb +249 -0
- data/lib/honeymaker/exchange.rb +129 -0
- data/lib/honeymaker/exchanges/binance.rb +55 -0
- data/lib/honeymaker/exchanges/binance_us.rb +9 -0
- data/lib/honeymaker/exchanges/bingx.rb +56 -0
- data/lib/honeymaker/exchanges/bitget.rb +52 -0
- data/lib/honeymaker/exchanges/bitrue.rb +53 -0
- data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
- data/lib/honeymaker/exchanges/bybit.rb +57 -0
- data/lib/honeymaker/exchanges/coinbase.rb +69 -0
- data/lib/honeymaker/exchanges/gemini.rb +134 -0
- data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
- data/lib/honeymaker/exchanges/kraken.rb +122 -0
- data/lib/honeymaker/exchanges/kucoin.rb +52 -0
- data/lib/honeymaker/exchanges/mexc.rb +56 -0
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/result.rb +46 -0
- data/lib/honeymaker/utils.rb +39 -0
- data/lib/honeymaker/version.rb +5 -0
- data/lib/honeymaker.rb +97 -0
- data/script/parity/kraken_client_live.rb +191 -0
- data/script/parity/kraken_live.rb +70 -0
- data/script/parity/kraken_transport.rb +604 -0
- data/script/parity/resolve.c +49 -0
- data/script/release/push_gems.sh +28 -0
- data/script/vectors/kraken.rb +50 -0
- data/script/vectors/kraken_stage2.rb +146 -0
- data/sig/honeymaker.rbs +4 -0
- data/test/fixtures/binance_book_ticker.json +7 -0
- data/test/fixtures/binance_exchange_info.json +30 -0
- data/test/fixtures/bingx_symbols.json +26 -0
- data/test/fixtures/bingx_ticker.json +12 -0
- data/test/fixtures/bitget_symbols.json +28 -0
- data/test/fixtures/bitget_tickers.json +14 -0
- data/test/fixtures/bitmart_symbols.json +26 -0
- data/test/fixtures/bitmart_ticker.json +12 -0
- data/test/fixtures/bitrue_book_ticker.json +7 -0
- data/test/fixtures/bitrue_exchange_info.json +34 -0
- data/test/fixtures/bitvavo_book_ticker.json +7 -0
- data/test/fixtures/bitvavo_markets.json +30 -0
- data/test/fixtures/bybit_instruments.json +23 -0
- data/test/fixtures/bybit_tickers.json +19 -0
- data/test/fixtures/coinbase_product.json +7 -0
- data/test/fixtures/coinbase_products.json +24 -0
- data/test/fixtures/gemini_pubticker.json +10 -0
- data/test/fixtures/gemini_symbol_detail.json +10 -0
- data/test/fixtures/gemini_symbols.json +1 -0
- data/test/fixtures/hyperliquid_all_mids.json +5 -0
- data/test/fixtures/hyperliquid_spot_meta.json +12 -0
- data/test/fixtures/kraken_asset_pairs.json +43 -0
- data/test/fixtures/kraken_ticker.json +16 -0
- data/test/fixtures/kucoin_orderbook_level1.json +10 -0
- data/test/fixtures/kucoin_symbols.json +17 -0
- data/test/fixtures/mexc_book_ticker.json +7 -0
- data/test/fixtures/mexc_exchange_info.json +64 -0
- data/test/honeymaker/client_test.rb +90 -0
- data/test/honeymaker/clients/binance_client_test.rb +151 -0
- data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
- data/test/honeymaker/clients/bingx_client_test.rb +106 -0
- data/test/honeymaker/clients/bitget_client_test.rb +146 -0
- data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
- data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
- data/test/honeymaker/clients/bybit_client_test.rb +137 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
- data/test/honeymaker/clients/gemini_client_test.rb +100 -0
- data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
- data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
- data/test/honeymaker/clients/kraken_client_test.rb +255 -0
- data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
- data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
- data/test/honeymaker/clients/mexc_client_test.rb +98 -0
- data/test/honeymaker/clients/validation_test.rb +167 -0
- data/test/honeymaker/error_chain_test.rb +133 -0
- data/test/honeymaker/exchange_test.rb +138 -0
- data/test/honeymaker/exchanges/binance_test.rb +83 -0
- data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
- data/test/honeymaker/exchanges/bingx_test.rb +65 -0
- data/test/honeymaker/exchanges/bitget_test.rb +65 -0
- data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
- data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
- data/test/honeymaker/exchanges/bybit_test.rb +55 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
- data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
- data/test/honeymaker/exchanges/gemini_test.rb +184 -0
- data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
- data/test/honeymaker/exchanges/kraken_test.rb +191 -0
- data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
- data/test/honeymaker/exchanges/mexc_test.rb +97 -0
- data/test/honeymaker/honeymaker_test.rb +27 -0
- data/test/honeymaker/result_test.rb +42 -0
- data/test/honeymaker/utils_test.rb +64 -0
- data/test/native/backend_switch_test.rb +53 -0
- data/test/native/convert_test.rb +137 -0
- data/test/native/gem_isolation_test.rb +17 -0
- data/test/native/loader_test.rb +29 -0
- data/test/native/lockstep_test.rb +23 -0
- data/test/native/signing_smoke_test.rb +28 -0
- data/test/parity/kraken_client_parity_test.rb +398 -0
- data/test/parity/kraken_exchange_parity_test.rb +172 -0
- data/test/release/branch_hardening_test.rb +34 -0
- data/test/release/push_gems_test.rb +79 -0
- data/test/smoke.rb +24 -0
- data/test/support/fixture_helper.rb +13 -0
- data/test/support/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- data/test/test_helper.rb +9 -0
- 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,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
|