honeymaker 0.12.4-x86_64-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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
6
+ include FixtureHelper
7
+
8
+ def setup
9
+ @exchange = Honeymaker::Exchanges::Gemini.new
10
+ # Recorded, not slept: the pacing and the backoff are asserted as a sequence.
11
+ sleeps = @sleeps = []
12
+ @exchange.define_singleton_method(:sleep) { |seconds| sleeps << seconds }
13
+ end
14
+
15
+ def test_get_tickers_info_parses_response
16
+ stub_catalogue(load_fixture("gemini_symbols.json"))
17
+
18
+ result = @exchange.get_tickers_info
19
+
20
+ assert result.success?
21
+ assert_equal 2, result.data.size
22
+
23
+ ticker = result.data.first
24
+ assert_equal "BTCUSD", ticker[:ticker]
25
+ assert_equal "BTC", ticker[:base]
26
+ assert_equal "USD", ticker[:quote]
27
+ assert_equal "0.00001", ticker[:minimum_base_size]
28
+ assert_equal "0", ticker[:minimum_quote_size]
29
+ assert_equal 8, ticker[:base_decimals]
30
+ assert_equal 2, ticker[:quote_decimals]
31
+ assert_equal 2, ticker[:price_decimals]
32
+ assert ticker[:available]
33
+ assert ticker[:trading_enabled]
34
+ end
35
+
36
+ def test_get_tickers_info_paces_every_request
37
+ stub_catalogue(%w[btcusd ethusd])
38
+
39
+ assert @exchange.get_tickers_info.success?
40
+ assert_equal [0.5, 0.5, 0.5], @sleeps
41
+ end
42
+
43
+ def test_get_tickers_info_retries_a_dropped_detail_request
44
+ connection = stub_catalogue(%w[btcusd ethusd])
45
+ connection.stubs(:get).with("/v1/symbols/details/ethusd")
46
+ .raises(Faraday::ConnectionFailed, "end of file reached")
47
+ .then.returns(stub(body: detail_for("ethusd")))
48
+
49
+ result = @exchange.get_tickers_info
50
+
51
+ assert result.success?
52
+ assert_equal 2, result.data.size
53
+ assert_equal [0.5, 0.5, 0.5, 2, 0.5], @sleeps
54
+ end
55
+
56
+ def test_get_tickers_info_retries_the_symbols_request
57
+ connection = stub_catalogue(%w[btcusd])
58
+ connection.stubs(:get).with("/v1/symbols")
59
+ .raises(Faraday::TooManyRequestsError, "429")
60
+ .then.returns(stub(body: %w[btcusd]))
61
+
62
+ result = @exchange.get_tickers_info
63
+
64
+ assert result.success?
65
+ assert_equal 1, result.data.size
66
+ end
67
+
68
+ def test_get_tickers_info_retries_every_transient_error_class
69
+ Honeymaker::Exchanges::Gemini::TRANSIENT_ERRORS.each do |error_class|
70
+ setup
71
+ connection = stub_catalogue(%w[btcusd])
72
+ connection.stubs(:get).with("/v1/symbols/details/btcusd")
73
+ .raises(error_class, "transient").then.returns(detail_response)
74
+
75
+ assert @exchange.get_tickers_info.success?, "#{error_class} should be retried"
76
+ end
77
+ end
78
+
79
+ # Set aside, not fatal: a symbol that keeps failing is left out and named, and the run goes on.
80
+ def test_get_tickers_info_sets_aside_a_symbol_that_keeps_failing
81
+ connection = stub_catalogue(%w[btcusd ethusd solusd])
82
+ connection.expects(:get).with("/v1/symbols/details/ethusd").times(3)
83
+ .raises(Faraday::ConnectionFailed, "end of file reached")
84
+
85
+ result = @exchange.get_tickers_info
86
+
87
+ assert result.success?
88
+ assert_equal %w[BTCUSD SOLUSD], result.data.map { |t| t[:ticker] }
89
+ assert_equal({ "ETHUSD" => "Faraday::ConnectionFailed: end of file reached" }, @exchange.unreadable_symbols)
90
+ assert_equal [0.5, 0.5, 0.5, 2, 0.5, 4, 0.5, 0.5], @sleeps
91
+ end
92
+
93
+ def test_get_tickers_info_does_not_retry_a_non_transient_error
94
+ connection = stub_catalogue(%w[btcusd ethusd])
95
+ connection.expects(:get).with("/v1/symbols/details/btcusd").once
96
+ .raises(Faraday::ResourceNotFound, "404")
97
+
98
+ assert_equal %w[ETHUSD], @exchange.get_tickers_info.data.map { |t| t[:ticker] }
99
+ assert_equal %w[BTCUSD], @exchange.unreadable_symbols.keys
100
+ end
101
+
102
+ # Gemini lists perpetual swaps in /v1/symbols with the same base and quote as the spot pair
103
+ # (BTCGUSDPERP is BTC/GUSD). They are not spot instruments, and would take the spot pair's place.
104
+ def test_get_tickers_info_keeps_only_spot_instruments
105
+ connection = stub_catalogue(%w[btcusd btcusdperp])
106
+ connection.stubs(:get).with("/v1/symbols/details/btcusdperp")
107
+ .returns(stub(body: detail_body.merge("symbol" => "BTCUSDPERP", "product_type" => "swap")))
108
+
109
+ result = @exchange.get_tickers_info
110
+
111
+ assert result.success?
112
+ assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
113
+ end
114
+
115
+ # An unclassified instrument is not assumed to be spot: it is set aside, and callers keep what
116
+ # they have for it.
117
+ def test_get_tickers_info_sets_aside_a_symbol_with_no_product_type
118
+ connection = stub_catalogue(%w[btcusd ethusd])
119
+ connection.stubs(:get).with("/v1/symbols/details/ethusd")
120
+ .returns(stub(body: detail_body.except("product_type").merge("symbol" => "ETHUSD")))
121
+
122
+ result = @exchange.get_tickers_info
123
+
124
+ assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
125
+ assert_match(/ethusd: no product_type/, @exchange.unreadable_symbols["ETHUSD"])
126
+ end
127
+
128
+ def test_get_tickers_info_sets_aside_both_instruments_claiming_one_pair
129
+ connection = stub_catalogue(%w[btcusd btcusd2 ethusd])
130
+ connection.stubs(:get).with("/v1/symbols/details/btcusd2")
131
+ .returns(stub(body: detail_body.merge("symbol" => "BTCUSD2")))
132
+
133
+ result = @exchange.get_tickers_info
134
+
135
+ assert_equal %w[ETHUSD], result.data.map { |t| t[:ticker] }
136
+ assert_equal %w[BTCUSD BTCUSD2], @exchange.unreadable_symbols.keys.sort
137
+ assert_match(%r{BTC/USD}, @exchange.unreadable_symbols["BTCUSD"])
138
+ end
139
+
140
+ def test_get_bid_ask_parses_response
141
+ body = load_fixture("gemini_pubticker.json")
142
+ stub_connection(body)
143
+
144
+ result = @exchange.get_bid_ask("BTCUSD")
145
+
146
+ assert result.success?
147
+ assert_equal BigDecimal("67123.45"), result.data[:bid]
148
+ assert_equal BigDecimal("67125.67"), result.data[:ask]
149
+ end
150
+
151
+ private
152
+
153
+ def detail_body
154
+ load_fixture("gemini_symbol_detail.json")
155
+ end
156
+
157
+ def detail_response
158
+ stub(body: detail_body)
159
+ end
160
+
161
+ # Every symbol resolves to the one detail fixture; a test overrides a path to make it fail.
162
+ # Each symbol answers with the fixture's details for its own pair ("ethusd" is ETH/USD), since a
163
+ # catalogue may not hold two instruments for one pair.
164
+ def stub_catalogue(symbols)
165
+ connection = stub
166
+ connection.stubs(:get).with("/v1/symbols").returns(stub(body: symbols))
167
+ symbols.each do |symbol|
168
+ connection.stubs(:get).with("/v1/symbols/details/#{symbol}").returns(stub(body: detail_for(symbol)))
169
+ end
170
+ @exchange.instance_variable_set(:@connection, connection)
171
+ connection
172
+ end
173
+
174
+ def detail_for(symbol)
175
+ detail_body.merge("symbol" => symbol.upcase, "base_currency" => symbol[0, 3], "quote_currency" => symbol[3..])
176
+ end
177
+
178
+ def stub_connection(body)
179
+ response = stub(body: body)
180
+ connection = stub
181
+ connection.stubs(:get).returns(response)
182
+ @exchange.instance_variable_set(:@connection, connection)
183
+ end
184
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Exchanges::HyperliquidTest < Minitest::Test
6
+ include FixtureHelper
7
+
8
+ def setup
9
+ @exchange = Honeymaker::Exchanges::Hyperliquid.new
10
+ end
11
+
12
+ def test_get_tickers_info_parses_response
13
+ body = load_fixture("hyperliquid_spot_meta.json")
14
+ stub_connection(body)
15
+
16
+ result = @exchange.get_tickers_info
17
+
18
+ assert result.success?
19
+ assert_equal 1, result.data.size
20
+
21
+ ticker = result.data.first
22
+ assert_equal "PURR/USDC", ticker[:ticker]
23
+ assert_equal "PURR", ticker[:base]
24
+ assert_equal "USDC", ticker[:quote]
25
+ assert_nil ticker[:minimum_base_size] # no per-token base floor in spotMeta
26
+ assert_equal 10, ticker[:minimum_quote_size] # Hyperliquid's hard 10-USDC spot order floor
27
+ assert_equal 2, ticker[:base_decimals]
28
+ assert_equal 2, ticker[:quote_decimals]
29
+ assert_equal 5, ticker[:price_decimals]
30
+ assert ticker[:available]
31
+ assert ticker[:trading_enabled] # no native status -> always true
32
+ end
33
+
34
+ def test_skips_pairs_with_missing_tokens
35
+ body = load_fixture("hyperliquid_spot_meta.json")
36
+ body["universe"] << { "name" => "MISSING/PAIR", "tokens" => [99, 100] }
37
+ stub_connection(body)
38
+
39
+ result = @exchange.get_tickers_info
40
+
41
+ assert result.success?
42
+ assert_equal 1, result.data.size
43
+ end
44
+
45
+ def test_get_bid_ask_parses_response
46
+ body = load_fixture("hyperliquid_all_mids.json")
47
+ stub_connection(body)
48
+
49
+ result = @exchange.get_bid_ask("BTC/USDC")
50
+
51
+ assert result.success?
52
+ assert_equal BigDecimal("67124.56"), result.data[:bid]
53
+ assert_equal BigDecimal("67124.56"), result.data[:ask]
54
+ end
55
+
56
+ private
57
+
58
+ def stub_connection(body)
59
+ response = stub(body: body)
60
+ connection = stub
61
+ connection.stubs(:post).with { |_, &block| block&.call(OpenStruct.new(body: nil)); true }.returns(response)
62
+ @exchange.instance_variable_set(:@connection, connection)
63
+ end
64
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Exchanges::KrakenTest < Minitest::Test
6
+ include FixtureHelper
7
+
8
+ def setup
9
+ @exchange = Honeymaker::Exchanges::Kraken.new
10
+ end
11
+
12
+ # Tokenized equities (xStocks) live behind aclass_base and are invisible to a bare AssetPairs call.
13
+ # aclass_base=all returns currency + tokenized in one request; the two sets are disjoint.
14
+ def test_get_tickers_info_requests_every_asset_class
15
+ response = stub(body: load_fixture("kraken_asset_pairs.json"))
16
+ connection = stub
17
+ connection.expects(:get).with("/0/public/AssetPairs", { aclass_base: "all" }).returns(response)
18
+ @exchange.instance_variable_set(:@connection, connection)
19
+
20
+ assert @exchange.get_tickers_info.success?
21
+ end
22
+
23
+ # Kraken returns every tokenized pair TWICE - once under an SPV key, once under the x key - with
24
+ # one shared wsname and altname. Only tokenized pairs are double-keyed; currency pairs never are.
25
+ # Mapping the raw response would ingest each of them twice.
26
+ def test_get_tickers_info_deduplicates_the_spv_alias
27
+ body = load_fixture("kraken_asset_pairs.json")
28
+ stub_request(body)
29
+
30
+ result = @exchange.get_tickers_info
31
+
32
+ nvda = result.data.select { |t| t[:base] == "NVDAx" }
33
+ assert_equal 1, nvda.size, "NVDAxUSD and NVDASPVUSD are one pair"
34
+ assert_equal "NVDAxUSD", nvda.first[:ticker]
35
+ end
36
+
37
+ # Listed, so a holding can be resolved and valued; not tradable, because AddOrder needs an
38
+ # asset_class parameter this client does not send, and Kraken closes the tokenized order books to
39
+ # EEA clients over the API regardless.
40
+ def test_tokenized_pairs_are_listed_but_not_trading_enabled
41
+ body = load_fixture("kraken_asset_pairs.json")
42
+ stub_request(body)
43
+
44
+ nvda = @exchange.get_tickers_info.data.find { |t| t[:base] == "NVDAx" }
45
+
46
+ assert_equal "online", body["result"]["NVDAxUSD"]["status"]
47
+ assert nvda[:available], "listed, so balances resolve"
48
+ refute nvda[:trading_enabled], "we cannot place these orders"
49
+ end
50
+
51
+ def test_get_tickers_info_parses_response
52
+ body = load_fixture("kraken_asset_pairs.json")
53
+ stub_request(body)
54
+
55
+ result = @exchange.get_tickers_info
56
+
57
+ assert result.success?
58
+ ticker = result.data.first
59
+ assert_equal "XBTUSDT", ticker[:ticker]
60
+ assert_equal "XBT", ticker[:base]
61
+ assert_equal "USDT", ticker[:quote]
62
+ assert_equal "0.00010000", ticker[:minimum_base_size]
63
+ assert_equal "5", ticker[:minimum_quote_size]
64
+ assert_equal 8, ticker[:base_decimals]
65
+ assert_equal 5, ticker[:quote_decimals]
66
+ assert_equal 1, ticker[:price_decimals]
67
+ assert ticker[:available]
68
+ assert ticker[:trading_enabled] # fixture has no status -> defaults to true
69
+ end
70
+
71
+ def test_get_tickers_info_disables_non_online_status
72
+ body = load_fixture("kraken_asset_pairs.json")
73
+ body["result"]["XBTUSDT"]["status"] = "cancel_only"
74
+ stub_request(body)
75
+
76
+ result = @exchange.get_tickers_info
77
+
78
+ ticker = result.data.first
79
+ assert ticker[:available] # still listed
80
+ refute ticker[:trading_enabled] # but not trading
81
+ end
82
+
83
+ def test_get_tickers_info_enables_online_status
84
+ body = load_fixture("kraken_asset_pairs.json")
85
+ body["result"]["XBTUSDT"]["status"] = "online"
86
+ stub_request(body)
87
+
88
+ result = @exchange.get_tickers_info
89
+
90
+ assert result.data.first[:trading_enabled]
91
+ end
92
+
93
+ def test_get_tickers_info_defaults_trading_enabled_when_status_absent
94
+ body = load_fixture("kraken_asset_pairs.json") # no status key
95
+ stub_request(body)
96
+
97
+ result = @exchange.get_tickers_info
98
+
99
+ assert result.data.first[:trading_enabled]
100
+ end
101
+
102
+ def test_get_tickers_info_skips_pairs_without_wsname
103
+ body = load_fixture("kraken_asset_pairs.json")
104
+ body["result"]["XBTUSDT"]["wsname"] = nil
105
+ stub_request(body)
106
+
107
+ result = @exchange.get_tickers_info
108
+
109
+ assert result.success?
110
+ # Asserts the wsname-less pair is dropped, not that the whole response is - the fixture now
111
+ # carries a tokenized pair too.
112
+ refute result.data.any? { |t| t[:ticker] == "XBTUSDT" }
113
+ end
114
+
115
+ def test_get_tickers_info_uses_real_costmin
116
+ body = load_fixture("kraken_asset_pairs.json")
117
+ stub_request(body)
118
+
119
+ result = @exchange.get_tickers_info
120
+
121
+ ticker = result.data.first
122
+ # USDT is in REAL_COSTMIN with value 5
123
+ assert_equal "5", ticker[:minimum_quote_size]
124
+ end
125
+
126
+ def test_get_tickers_info_handles_api_error
127
+ body = { "error" => ["EGeneral:Internal error"], "result" => {} }
128
+ stub_request(body)
129
+
130
+ result = @exchange.get_tickers_info
131
+
132
+ assert result.failure?
133
+ assert_includes result.errors, "EGeneral:Internal error"
134
+ end
135
+
136
+ def test_get_bid_ask_parses_response
137
+ body = load_fixture("kraken_ticker.json")
138
+ stub_request(body)
139
+
140
+ result = @exchange.get_bid_ask("XBTUSDT")
141
+
142
+ assert result.success?
143
+ assert_equal BigDecimal("67123.45"), result.data[:bid]
144
+ assert_equal BigDecimal("67125.67"), result.data[:ask]
145
+ end
146
+
147
+ def test_get_bid_ask_handles_api_error
148
+ body = { "error" => ["EGeneral:Internal error"], "result" => {} }
149
+ stub_request(body)
150
+
151
+ result = @exchange.get_bid_ask("XBTUSDT")
152
+
153
+ assert result.failure?
154
+ end
155
+
156
+ def test_classify_error_regional_restriction
157
+ result = @exchange.classify_error("EAccount:Invalid permissions:XAUT trading restricted for DK.")
158
+ assert_equal({ code: :regional_restriction, asset: "XAUT", country: "DK" }, result)
159
+ end
160
+
161
+ def test_classify_error_invalid_nonce
162
+ assert_equal({ code: :transient_nonce }, @exchange.classify_error("EAPI:Invalid nonce"))
163
+ end
164
+
165
+ def test_classify_error_internal_error_is_transient_unavailable
166
+ assert_equal({ code: :transient_unavailable }, @exchange.classify_error("EGeneral:Internal error"))
167
+ end
168
+
169
+ def test_classify_error_service_codes_are_transient_unavailable
170
+ assert_equal({ code: :transient_unavailable }, @exchange.classify_error("EService:Unavailable"))
171
+ assert_equal({ code: :transient_unavailable }, @exchange.classify_error("EService:Busy"))
172
+ assert_equal({ code: :transient_unavailable }, @exchange.classify_error("EService:Deadline elapsed"))
173
+ end
174
+
175
+ def test_classify_error_returns_nil_for_unknown_message
176
+ assert_nil @exchange.classify_error("EOrder:Unknown order")
177
+ end
178
+
179
+ def test_classify_error_returns_nil_for_nil_message
180
+ assert_nil @exchange.classify_error(nil)
181
+ end
182
+
183
+ private
184
+
185
+ def stub_request(body)
186
+ response = stub(body: body)
187
+ connection = stub
188
+ connection.stubs(:get).returns(response)
189
+ @exchange.instance_variable_set(:@connection, connection)
190
+ end
191
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Exchanges::KucoinTest < Minitest::Test
6
+ include FixtureHelper
7
+
8
+ def setup
9
+ @exchange = Honeymaker::Exchanges::Kucoin.new
10
+ end
11
+
12
+ def test_get_tickers_info_parses_response
13
+ body = load_fixture("kucoin_symbols.json")
14
+ stub_connection(body)
15
+
16
+ result = @exchange.get_tickers_info
17
+
18
+ assert result.success?
19
+ assert_equal 1, result.data.size
20
+
21
+ ticker = result.data.first
22
+ assert_equal "BTC-USDT", ticker[:ticker]
23
+ assert_equal "BTC", ticker[:base]
24
+ assert_equal "USDT", ticker[:quote]
25
+ assert_equal "0.00001", ticker[:minimum_base_size]
26
+ assert_equal "0.1", ticker[:minimum_quote_size]
27
+ assert_equal "10000000000", ticker[:maximum_base_size]
28
+ assert_equal "99999999", ticker[:maximum_quote_size]
29
+ assert_equal 8, ticker[:base_decimals]
30
+ assert_equal 6, ticker[:quote_decimals]
31
+ assert_equal 1, ticker[:price_decimals]
32
+ assert ticker[:available]
33
+ assert ticker[:trading_enabled]
34
+ end
35
+
36
+ def test_get_bid_ask_parses_response
37
+ body = load_fixture("kucoin_orderbook_level1.json")
38
+ stub_connection(body)
39
+
40
+ result = @exchange.get_bid_ask("BTC-USDT")
41
+
42
+ assert result.success?
43
+ assert_equal BigDecimal("67123.45"), result.data[:bid]
44
+ assert_equal BigDecimal("67125.67"), result.data[:ask]
45
+ end
46
+
47
+ private
48
+
49
+ def stub_connection(body)
50
+ response = stub(body: body)
51
+ connection = stub
52
+ connection.stubs(:get).returns(response)
53
+ @exchange.instance_variable_set(:@connection, connection)
54
+ end
55
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "digest"
5
+
6
+ class Honeymaker::Exchanges::MexcTest < Minitest::Test
7
+ include FixtureHelper
8
+
9
+ def setup
10
+ @exchange = Honeymaker::Exchanges::Mexc.new
11
+ end
12
+
13
+ # H1. MEXC's exchangeInfo has never carried Binance's status encoding. The fixture this file
14
+ # drives was a hand-edited clone of the Binance one ("TRADING"/"HALT", LOT_SIZE/PRICE_FILTER/
15
+ # MIN_NOTIONAL filters) written from the same wrong assumption as the parser it tested, so both
16
+ # agreed and both were wrong. Asserting the raw bytes is the only guard that survives an author
17
+ # who believes the wrong thing twice; the digest also binds this file to deltabadger's copy, which
18
+ # drives the same capture through the other implementation of this contract.
19
+ def test_fixture_carries_mexcs_own_status_encoding
20
+ body = load_fixture("mexc_exchange_info.json")
21
+
22
+ assert_equal "1", body["symbols"].first["status"]
23
+ refute body["symbols"].any? { |s| s["status"] == "TRADING" }
24
+ refute body["symbols"].any? { |s| s["filters"].any? { |f| f["filterType"] == "LOT_SIZE" } }
25
+
26
+ assert_equal "13a65c0c8ebde52fac4cdca9a5be9d9a18ba7b5438c496b1b30606cf52fd60d3",
27
+ Digest::SHA256.hexdigest(File.read(fixture_path("mexc_exchange_info.json")))
28
+ end
29
+
30
+ def test_get_tickers_info_parses_response
31
+ body = load_fixture("mexc_exchange_info.json")
32
+ stub_connection(body)
33
+
34
+ result = @exchange.get_tickers_info
35
+
36
+ assert result.success?
37
+ assert_equal 2, result.data.size
38
+
39
+ ticker = result.data.first
40
+ assert_equal "METALUSDT", ticker[:ticker]
41
+ assert_equal "METAL", ticker[:base]
42
+ assert_equal "USDT", ticker[:quote]
43
+ assert ticker[:available]
44
+ assert ticker[:trading_enabled]
45
+ end
46
+
47
+ # H2. status is "1" for every MEXC symbol, including the 103 the venue will not accept a spot
48
+ # order for. The status check alone re-enables all of them; isSpotTradingAllowed is what keeps
49
+ # them out of the picker.
50
+ def test_a_symbol_mexc_does_not_allow_for_spot_is_not_trading_enabled
51
+ body = load_fixture("mexc_exchange_info.json")
52
+ stub_connection(body)
53
+
54
+ result = @exchange.get_tickers_info
55
+
56
+ disallowed = result.data.find { |t| t[:ticker] == "PALMAIUSDT" }
57
+ assert_equal "1", body["symbols"].last["status"]
58
+ assert disallowed[:available] # still listed
59
+ refute disallowed[:trading_enabled] # but not spot-tradable
60
+ end
61
+
62
+ # H5. Real MEXC sends only PERCENT_PRICE_BY_SIDE, so the precision fallback is the ONLY path —
63
+ # there is no filter-bearing symbol to contrast it against.
64
+ def test_falls_back_to_precision_fields_when_no_size_filters
65
+ body = load_fixture("mexc_exchange_info.json")
66
+ stub_connection(body)
67
+
68
+ result = @exchange.get_tickers_info
69
+
70
+ metal = result.data.find { |t| t[:ticker] == "METALUSDT" }
71
+ assert_equal 2, metal[:base_decimals] # baseAssetPrecision
72
+ assert_equal 5, metal[:quote_decimals] # quoteAssetPrecision
73
+ assert_equal 5, metal[:price_decimals] # quotePrecision
74
+ assert_nil metal[:minimum_base_size]
75
+ assert_nil metal[:minimum_quote_size]
76
+ end
77
+
78
+ def test_get_bid_ask_parses_response
79
+ body = load_fixture("mexc_book_ticker.json")
80
+ stub_connection(body)
81
+
82
+ result = @exchange.get_bid_ask("BTCUSDT")
83
+
84
+ assert result.success?
85
+ assert_equal BigDecimal("67123.45"), result.data[:bid]
86
+ assert_equal BigDecimal("67125.67"), result.data[:ask]
87
+ end
88
+
89
+ private
90
+
91
+ def stub_connection(body)
92
+ response = stub(body: body)
93
+ connection = stub
94
+ connection.stubs(:get).returns(response)
95
+ @exchange.instance_variable_set(:@connection, connection)
96
+ end
97
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class HoneymakerTest < Minitest::Test
6
+ def test_version
7
+ refute_nil Honeymaker::VERSION
8
+ end
9
+
10
+ def test_exchange_returns_correct_class
11
+ exchange = Honeymaker.exchange("binance")
12
+ assert_instance_of Honeymaker::Exchanges::Binance, exchange
13
+ end
14
+
15
+ def test_exchange_with_symbol
16
+ exchange = Honeymaker.exchange(:kraken)
17
+ assert_instance_of Honeymaker::Exchanges::Kraken, exchange
18
+ end
19
+
20
+ def test_exchange_raises_for_unknown
21
+ assert_raises(Honeymaker::Error) { Honeymaker.exchange("unknown") }
22
+ end
23
+
24
+ def test_all_exchanges_registered
25
+ assert_equal 13, Honeymaker::EXCHANGES.size
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::ResultTest < Minitest::Test
6
+ def test_success_is_successful
7
+ result = Honeymaker::Result::Success.new("data")
8
+ assert result.success?
9
+ refute result.failure?
10
+ assert_equal "data", result.data
11
+ assert_empty result.errors
12
+ end
13
+
14
+ def test_success_with_nil_data
15
+ result = Honeymaker::Result::Success.new
16
+ assert result.success?
17
+ assert_nil result.data
18
+ end
19
+
20
+ def test_failure_is_not_successful
21
+ result = Honeymaker::Result::Failure.new("something went wrong")
22
+ refute result.success?
23
+ assert result.failure?
24
+ assert_equal ["something went wrong"], result.errors
25
+ end
26
+
27
+ def test_failure_with_multiple_errors
28
+ result = Honeymaker::Result::Failure.new("error1", "error2")
29
+ assert_equal ["error1", "error2"], result.errors
30
+ end
31
+
32
+ def test_failure_with_default_error
33
+ result = Honeymaker::Result::Failure.new
34
+ assert_equal ["Error"], result.errors
35
+ end
36
+
37
+ def test_failure_with_data
38
+ result = Honeymaker::Result::Failure.new("err", data: { partial: true })
39
+ assert_equal({ partial: true }, result.data)
40
+ assert_equal ["err"], result.errors
41
+ end
42
+ end