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,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "support/parity_helper"
5
+
6
+ class KrakenClientParityTest < Minitest::Test
7
+ include ParityHelper
8
+
9
+ NONCE = 1_727_000_000_000_001
10
+ SECRET = Base64.strict_encode64("test_secret_key_1234567890123456")
11
+ JSON_CT = { "content-type" => "application/json" }.freeze
12
+ SIGNED = %w[api-key api-sign content-type accept user-agent accept-encoding].freeze
13
+
14
+ def teardown
15
+ Honeymaker::Native::Ext::Kraken.fixed_nonce = nil if defined?(Honeymaker::Native::Ext::Kraken)
16
+ super
17
+ end
18
+
19
+ def clients(key, secret, url)
20
+ [Honeymaker::Clients::Kraken, Honeymaker::Native::KrakenClient].map do |k|
21
+ k.new(api_key: key, api_secret: secret).tap do |c|
22
+ c.instance_variable_set(:@connection, c.send(:build_client_connection, url))
23
+ end
24
+ end
25
+ end
26
+
27
+ # Legacy then native against ONE double (same URL); returns [lr, nr, lreqs, nreqs].
28
+ def run_both(replies, key: "test_key", secret: SECRET)
29
+ Honeymaker::Clients::Kraken.any_instance.stubs(:nonce).returns(NONCE)
30
+ Honeymaker::Native::Ext::Kraken.fixed_nonce = NONCE
31
+ d = VenueDouble.new(replies)
32
+ legacy, native = clients(key, secret, d.url)
33
+ lr = begin; yield(legacy); rescue StandardError => e; e; end
34
+ lq = d.requests
35
+ d.reset!(replies)
36
+ nr = begin; yield(native); rescue StandardError => e; e; end
37
+ [lr, nr, lq, d.requests]
38
+ ensure
39
+ d&.close
40
+ end
41
+
42
+ def assert_same_requests(legacy, native)
43
+ assert_equal legacy.size, native.size, "request count"
44
+ legacy.zip(native).each do |l, n|
45
+ assert_equal [l.method, l.target, l.body], [n.method, n.target, n.body]
46
+ assert_equal l.headers.slice(*SIGNED), n.headers.slice(*SIGNED)
47
+ end
48
+ end
49
+
50
+ def assert_parity(replies, **kw, &call)
51
+ lr, nr, lq, nq = run_both(replies, **kw, &call)
52
+ assert_same_ruby(lr, nr)
53
+ assert_same_requests(lq, nq)
54
+ end
55
+
56
+ def ok(result) = [200, JSON_CT, { "error" => [], "result" => result }.to_json]
57
+ def venue(*errors) = [200, JSON_CT, { "error" => errors, "result" => {} }.to_json]
58
+
59
+ COMMON = [
60
+ [500, JSON_CT, '{"error":["EService:Unavailable"]}'],
61
+ [500, { "content-type" => "text/plain" }, ""],
62
+ [200, { "content-type" => "text/html" }, "<html>maintenance</html>"],
63
+ [200, JSON_CT, ""],
64
+ [200, JSON_CT, "null"],
65
+ [200, JSON_CT, "{not json"],
66
+ [200, JSON_CT, '{"error":[],"result":{"dup":1,"dup":2}}'],
67
+ [200, JSON_CT, '{"error":["EGeneral:Invalid arguments"],"result":{}}'],
68
+ [200, JSON_CT, '{"error":[null,false],"result":{}}']
69
+ ].freeze
70
+
71
+ def each_reply(*specific, &blk) = (specific + COMMON).each { |r| blk.call(r) }
72
+
73
+ def test_get_extended_balance_and_get_balances
74
+ bal = { "XXBT" => { "balance" => "0.5000000000", "hold_trade" => "0.1" }, "ZEUR" => { "balance" => "10.5" },
75
+ "USDT.F" => { "balance" => "0", "hold_trade" => "0" }, "XXDG" => { "balance" => 12_345_678_901_234_567_890 },
76
+ "BONK" => { "balance" => 0.1 }, "ETH2.S" => { "balance" => "1e-9" }, "NEG" => { "balance" => "-0.0000", "hold_trade" => "0.1" },
77
+ "" => { "balance" => "1" }, "..." => { "balance" => "2" } }
78
+ each_reply(ok(bal), venue("EAPI:Invalid key"), ok({ "X" => { "balance" => nil } }), ok({ "X" => { "balance" => "NaN" } }), ok([]), ok(["x"])) do |reply|
79
+ assert_parity([reply], &:get_extended_balance)
80
+ assert_parity([reply], &:get_balances)
81
+ end
82
+ end
83
+
84
+ def test_query_orders_info
85
+ orders = {
86
+ "O1" => { "status" => "closed", "descr" => { "type" => "BUY", "ordertype" => "market", "price" => "0" },
87
+ "vol" => "10", "vol_exec" => "10", "cost" => "500.5", "price" => "50.05", "oflags" => "fciq,viqc" },
88
+ "O2" => { "status" => "open", "descr" => { "type" => "sell", "ordertype" => "limit", "price" => "60000" },
89
+ "vol" => "0.01", "vol_exec" => "0", "cost" => "0", "price" => "0", "oflags" => "" },
90
+ "O3" => { "status" => "expired", "descr" => { "ordertype" => "stop-loss" }, "vol" => "1", "vol_exec" => "0",
91
+ "cost" => "0", "price" => "0" },
92
+ "O4" => { "status" => "pending", "descr" => false, "vol" => 1, "vol_exec" => 0.5, "cost" => "0", "price" => "0" }
93
+ }
94
+ each_reply(ok(orders), ok({}), ok([]), ok(["x"])) do |reply|
95
+ assert_parity([reply]) { |c| c.query_orders_info(txid: "O1,O2,O3,O4") }
96
+ assert_parity([reply]) { |c| c.query_orders_info(txid: "O1", trades: true, userref: 7, consolidate_taker: false) }
97
+ end
98
+ end
99
+
100
+ def test_add_order_and_cancel
101
+ each_reply(ok({ "txid" => ["OX-1"], "descr" => { "order" => "buy 1 XBTUSD" } }), ok({ "descr" => {} }), ok({ "txid" => [] })) do |reply|
102
+ assert_parity([reply]) { |c| c.add_order(ordertype: "market", type: "buy", volume: "0.001", pair: "XBTUSDT", oflags: ["viqc"]) }
103
+ assert_parity([reply]) do |c|
104
+ c.add_order(ordertype: "limit", type: "sell", volume: BigDecimal("0.001"), pair: "XBTUSDT", price: "60000.1",
105
+ close: "limit", close_price: "61000", validate: true, userref: 42, timeinforce: "GTC", oflags: [])
106
+ end
107
+ assert_parity([reply]) { |c| c.cancel_order(txid: "OX-1") }
108
+ end
109
+ end
110
+
111
+ def test_public_endpoints
112
+ each_reply(ok({ "XXBTZUSD" => { "a" => ["50000.1", "1", "1.000"], "b" => ["50000.0", "2", "2.000"] } })) do |reply|
113
+ assert_parity([reply]) { |c| c.get_ticker_information(pair: "XBTUSD") }
114
+ assert_parity([reply], &:get_ticker_information)
115
+ assert_parity([reply]) { |c| c.get_tradable_asset_pairs(pairs: %w[XBTUSD ETHUSD], aclass_base: "all") }
116
+ assert_parity([reply]) { |c| c.get_tradable_asset_pairs(pairs: []) }
117
+ assert_parity([reply]) { |c| c.get_asset_info(assets: %w[XBT], aclass: "currency") }
118
+ assert_parity([reply]) { |c| c.get_ohlc_data(pair: "XBTUSD", interval: 60, since: 1_700_000_000) }
119
+ end
120
+ end
121
+
122
+ def test_other_private_endpoints
123
+ each_reply(ok({ "count" => 1 })) do |reply|
124
+ assert_parity([reply], &:get_api_key_info)
125
+ assert_parity([reply]) { |c| c.get_trades_history(type: "all", start: 1, end_time: 2, ofs: 50) }
126
+ assert_parity([reply]) { |c| c.get_ledgers(asset: "XBT", start: 1, end_time: 2, ofs: 0) }
127
+ assert_parity([reply]) { |c| c.get_withdraw_addresses(asset: "XBT", method: "Bitcoin") }
128
+ assert_parity([reply], &:get_withdraw_methods)
129
+ assert_parity([reply]) { |c| c.withdraw(asset: "XBT", key: "my key", amount: "0.01", address: "bc1q\u2026") }
130
+ assert_parity([reply]) { |c| c.get_earn_allocations(ascending: true, converted_asset: "USD", hide_zero_allocations: false) }
131
+ end
132
+ end
133
+
134
+ def page(trades, count)
135
+ ok({ "trades" => trades.to_h { |t| [t[:id], { "ordertxid" => t[:o], "type" => "buy", "ordertype" => "market",
136
+ "vol" => t[:vol], "cost" => t[:cost], "fee" => "0.01",
137
+ "pair" => "XXBTZUSD", "time" => t[:time] }] }, "count" => count })
138
+ end
139
+
140
+ def test_closed_orders_from_trades
141
+ p1 = page([{ id: "T1", o: "O1", vol: "0.1", cost: "5000", time: 1_700_000_000.1234 },
142
+ { id: "T2", o: "OX", vol: "1", cost: "1", time: 1_700_000_001 }], 3)
143
+ p2 = page([{ id: "T3", o: "O1", vol: "0.2", cost: "10001", time: "1700000002.5" }], "3")
144
+ assert_parity([p1, p2]) { |c| c.closed_orders_from_trades(order_ids: %w[O1 O2], start: 1_699_000_000) }
145
+ assert_parity([p1, p2]) { |c| c.closed_orders_from_trades(order_ids: "O1", max_pages: 1) }
146
+ assert_parity([page([{ id: "T9", o: "O1", vol: "-0", cost: "0", time: 1 }], 1)]) { |c| c.closed_orders_from_trades(order_ids: %w[O1]) }
147
+ COMMON.each { |bad| assert_parity([p1, bad]) { |c| c.closed_orders_from_trades(order_ids: %w[O1]) } }
148
+ # Malformed pages must raise like legacy, never return partial fills (Codex r2 #1).
149
+ ['{"error":[],"result":false}', '{"error":[],"result":"oops"}', '{"error":[],"result":{"trades":{"T5":null},"count":9}}',
150
+ '{"error":[],"result":{"trades":[1],"count":9}}',
151
+ '{"error":[],"result":{"trades":{"T6":{"ordertxid":"O1","vol":"1","cost":"1","fee":"0","time":1}},"count":true}}',
152
+ '{"error":[],"result":{"trades":[],"count":9}}', '{"error":[],"result":null}'].each do |bad|
153
+ assert_parity([p1, [200, JSON_CT, bad]]) { |c| c.closed_orders_from_trades(order_ids: %w[O1]) }
154
+ assert_parity([[200, JSON_CT, bad]]) { |c| c.closed_orders_from_trades(order_ids: %w[O1]) }
155
+ end
156
+ assert_parity([ok({ "trades" => {}, "count" => 0 })]) { |c| c.closed_orders_from_trades(order_ids: %w[O1]) }
157
+ assert_parity([p1]) { |c| c.closed_orders_from_trades(order_ids: []) }
158
+ end
159
+
160
+ def test_closed_orders_from_trades_counts_a_trade_seen_on_two_pages_once
161
+ t1 = { id: "T1", o: "O1", vol: "0.1", cost: "5000", time: 1_700_000_000 }
162
+ t2 = { id: "T2", o: "O1", vol: "0.2", cost: "10001", time: 1_700_000_001 }
163
+ p1 = page([t1, t2], 3)
164
+ p2 = page([t2], 3)
165
+ assert_parity([p1, p2]) { |c| c.closed_orders_from_trades(order_ids: %w[O1], start: 1_699_000_000) }
166
+ end
167
+
168
+ def test_aggregate_edges
169
+ [{ "type" => false }, { "type" => 1 }, { "type" => nil }, { "ordertype" => 5 }].each do |over|
170
+ bad_page = page([{ id: "T1", o: "O1", vol: "1", cost: "2", time: 1 }], 1)
171
+ body = JSON.parse(bad_page[2])
172
+ body["result"]["trades"]["T1"].merge!(over)
173
+ assert_parity([[200, JSON_CT, body.to_json]]) { |c| c.closed_orders_from_trades(order_ids: %w[O1]) }
174
+ end
175
+ assert_parity([page([{ id: "T1", o: "O1", vol: "1", cost: "2", time: 1 }], "1_000"),
176
+ page([{ id: "T2", o: "O1", vol: "1", cost: "2", time: 2 }], "99999999999999999999999")]) do |c|
177
+ c.closed_orders_from_trades(order_ids: %w[O1], max_pages: 2)
178
+ end
179
+ end
180
+
181
+ def test_validate
182
+ [ok({ "XXBT" => { "balance" => "1" } }), venue("EAPI:Invalid key"), [403, JSON_CT, "{}"],
183
+ [200, { "content-type" => "text/html" }, "x"], [200, JSON_CT, '{"error":[null]}']].each do |reply|
184
+ assert_parity([reply]) { |c| c.validate(:trading) }
185
+ assert_parity([reply]) { |c| c.validate(:read) }
186
+ end
187
+ assert_parity([ok({})], key: "", &:validate)
188
+ assert_parity([ok({})]) { |c| c.validate(:bogus) }
189
+ ["", "null", "[1]", "5", "true", '"abc"', '"error"'].each do |body|
190
+ assert_parity([[200, JSON_CT, body]]) { |c| c.validate(:trading) }
191
+ assert_parity([[200, JSON_CT, body]]) { |c| c.validate(:read) }
192
+ end
193
+ end
194
+
195
+ def test_string_balance_entries
196
+ ["abc", "balance", "hold_trade balance"].each do |balance|
197
+ assert_parity([ok({ "X" => balance })], &:get_balances)
198
+ end
199
+ end
200
+
201
+ def test_array_balance_entries
202
+ [[["XXBT", { "balance" => "1" }]], ["x"], [["X", "abc"]], [["X", "balance"]],
203
+ [[nil, { "balance" => "1" }]], [[]],
204
+ [["XXBT", { "balance" => "1" }, "ignored"], ["XBT", { "balance" => "2" }]]].each do |balances|
205
+ assert_parity([ok(balances)], &:get_balances)
206
+ end
207
+ end
208
+
209
+ def test_array_order_entries
210
+ raw = { "vol" => "1", "vol_exec" => "1", "cost" => "2", "price" => "0" }
211
+ [[["O1", raw]], [[5, raw]], [[nil, raw]], ["x"], [["O1", "abc"]], [[]],
212
+ [["O1", raw, "ignored"], ["O1", raw.merge("vol" => "2")]]].each do |orders|
213
+ assert_parity([ok(orders)]) { |c| c.query_orders_info(txid: "O1") }
214
+ end
215
+ end
216
+
217
+ def test_string_order_descriptions
218
+ ["abc", "type", "ordertype price", "type ordertype price"].each do |descr|
219
+ raw = { "descr" => descr, "vol" => "1", "vol_exec" => "1", "cost" => "2", "price" => "0" }
220
+ assert_parity([ok({ "O1" => raw })]) { |c| c.query_orders_info(txid: "O1") }
221
+ end
222
+ end
223
+
224
+ def test_hash_and_string_order_txids
225
+ [{ "Z" => "first", "A" => "second" }, {}, "OX-1"].each do |txid|
226
+ assert_parity([ok({ "txid" => txid })]) do |c|
227
+ c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X")
228
+ end
229
+ end
230
+ end
231
+
232
+ def test_side_downcase_is_context_independent
233
+ raw = { "descr" => { "type" => "ΑΣ" }, "vol" => "1", "vol_exec" => "1", "cost" => "2", "price" => "0" }
234
+ assert_parity([ok({ "O1" => raw })]) { |c| c.query_orders_info(txid: "O1") }
235
+ end
236
+
237
+ def test_nil_parity_has_no_deprecation_warnings
238
+ _, warnings = capture_io do
239
+ assert_parity([ok({})]) { |c| c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X") }
240
+ end
241
+ refute_match(/DEPRECATED/, warnings)
242
+ end
243
+
244
+ def test_binary_maintenance_page_is_unreadable_like_legacy
245
+ page = [200, { "content-type" => "text/html" }, "<html>\xFF\xFE maintenance</html>".b]
246
+ assert_parity([page], &:get_balances)
247
+ assert_parity([page]) { |c| c.query_orders_info(txid: "O1") }
248
+ assert_parity([page]) { |c| c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X") }
249
+ assert_parity([page]) { |c| c.validate(:trading) }
250
+ assert_parity([ok({ "trades" => { "T1" => { "ordertxid" => "O1", "vol" => "1", "cost" => "1", "fee" => "0", "time" => 1 } }, "count" => 9 }), page]) do |c|
251
+ c.closed_orders_from_trades(order_ids: %w[O1])
252
+ end
253
+ end
254
+
255
+ def test_invalid_utf8_in_add_order_json
256
+ ['{"error":[],"result":{"txid":["O1"],"descr":{"order":"buy X"}}}',
257
+ '{"error":[],"result":{"txid":["OX"]}}'].each do |body|
258
+ assert_parity([[200, JSON_CT, body.sub("X", "\xff")]]) do |c|
259
+ c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X")
260
+ end
261
+ end
262
+ end
263
+
264
+ def direct_results(data)
265
+ [Honeymaker::Clients::Kraken, Honeymaker::Native::KrakenClient].map do |klass|
266
+ client = klass.new
267
+ response = Honeymaker::Result::Success.new(data)
268
+ client.stubs(:get_extended_balance).returns(response)
269
+ client.stubs(:post_private).returns(response)
270
+ client.stubs(:get_trades_history).returns(response)
271
+ begin; yield(client); rescue StandardError => e; e; end
272
+ end
273
+ end
274
+
275
+ def test_binary_error_text_through_client
276
+ data = { "error" => ["EAPI:Invalid nonce \xff\xfe".b] }
277
+ assert_same_ruby(*direct_results(data, &:get_balances))
278
+ end
279
+
280
+ def test_binary_and_invalid_normalizer_values
281
+ ["\xff", "\xff".b].each do |suffix|
282
+ data = { "error" => [], "result" => { "txid" => ["O" + suffix] } }
283
+ assert_same_ruby(*direct_results(data) { |c| c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X") })
284
+ ["balance" + suffix, { "balance" => "1" + suffix }].each do |balance|
285
+ assert_same_ruby(*direct_results({ "result" => { "X" => balance } }, &:get_balances))
286
+ end
287
+ raw = { "vol" => "1", "vol_exec" => "1", "cost" => "2", "price" => "0" }
288
+ [{ "descr" => "type" + suffix }, { "descr" => { "type" => "BUY" + suffix } },
289
+ { "oflags" => "viqc," + suffix }].each do |over|
290
+ assert_same_ruby(*direct_results({ "result" => { "O1" => raw.merge(over) } }) { |c| c.query_orders_info(txid: "O1") })
291
+ end
292
+ end
293
+ end
294
+
295
+ def test_invalid_string_keys_and_trade_side
296
+ ["\xff", "\xff".b].each do |suffix|
297
+ assert_same_ruby(*direct_results({ "result" => { "X" + suffix => { "balance" => "1" } } }, &:get_balances))
298
+ raw = { "vol" => "1", "vol_exec" => "1", "cost" => "2", "price" => "0" }
299
+ assert_same_ruby(*direct_results({ "result" => { "O" + suffix => raw } }) { |c| c.query_orders_info(txid: "O1") })
300
+ trade = { "ordertxid" => "O1", "type" => "BUY" + suffix, "vol" => "1", "cost" => "2", "fee" => "0", "time" => 1 }
301
+ assert_same_ruby(*direct_results({ "result" => { "trades" => { "T1" => trade }, "count" => 1 } }) do |c|
302
+ c.closed_orders_from_trades(order_ids: ["O1"])
303
+ end)
304
+ end
305
+ end
306
+
307
+ def test_decimal_normalizers_under_gc_stress
308
+ cases = [
309
+ [{ "result" => { "XXBT" => { "balance" => "2", "hold_trade" => "0.5" } } }, ->(c) { c.get_balances }],
310
+ [{ "result" => { "O1" => { "vol" => "1", "vol_exec" => "1", "cost" => "2", "price" => "2" } } },
311
+ ->(c) { c.query_orders_info(txid: "O1") }],
312
+ [{ "result" => { "trades" => { "T1" => { "ordertxid" => "O1", "type" => "buy", "ordertype" => "market",
313
+ "vol" => "1", "cost" => "2", "fee" => "0.1", "time" => 1 } }, "count" => 1 } },
314
+ ->(c) { c.closed_orders_from_trades(order_ids: ["O1"]) }]
315
+ ]
316
+ cases.each do |data, call|
317
+ results = direct_results(data) do |client|
318
+ GC.stress = true
319
+ begin; call.call(client); ensure; GC.stress = false; end
320
+ end
321
+ assert_same_ruby(*results)
322
+ end
323
+ ensure
324
+ GC.stress = false
325
+ end
326
+
327
+ def test_unexpected_native_verdict_raises_shape_error
328
+ client = Honeymaker::Native::KrakenClient.new
329
+ client.stubs(:native).returns(stub(finish: ["bogus"]))
330
+ error = assert_raises(Honeymaker::Native::ShapeError) do
331
+ client.send(:finish, "balances", Honeymaker::Result::Success.new({})) { |x| x }
332
+ end
333
+ assert_equal 'unexpected verdict "bogus"', error.message
334
+ end
335
+
336
+ def test_unauthenticated_private_call
337
+ assert_parity([venue("EAPI:Invalid key")], key: nil, secret: nil, &:get_extended_balance)
338
+ end
339
+
340
+ def test_constants_and_surface
341
+ assert_equal Honeymaker::Clients::Kraken.rate_limits, Honeymaker::Native::KrakenClient.rate_limits
342
+ assert_equal Honeymaker::Clients::Kraken::URL, Honeymaker::Native::KrakenClient::URL
343
+ legacy, native = Honeymaker::Clients::Kraken, Honeymaker::Native::KrakenClient
344
+ assert_equal legacy.public_instance_methods(false).sort, native.public_instance_methods(false).sort
345
+ legacy.public_instance_methods(false).each do |m|
346
+ assert_equal legacy.instance_method(m).parameters, native.instance_method(m).parameters, "#{m} signature"
347
+ end
348
+ assert_respond_to native, :reset_nonce_state!
349
+ end
350
+
351
+ def test_rejected_arguments_raise_before_any_request
352
+ [->(c) { c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X", oflags: nil) },
353
+ ->(c) { c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X", oflags: "viqc") },
354
+ ->(c) { c.get_tradable_asset_pairs(pairs: "XBTUSD") },
355
+ ->(c) { c.get_asset_info(assets: 5) }].each do |call|
356
+ lr, nr, lq, nq = run_both([ok({})], &call)
357
+ assert_same_ruby(lr, nr)
358
+ assert_equal [0, 0], [lq.size, nq.size], "no request may leave on a rejected argument"
359
+ end
360
+ end
361
+
362
+ def test_argument_value_shapes_reach_the_wire_like_legacy
363
+ assert_parity([ok({})]) { |c| c.query_orders_info(txid: %w[O1 O2]) } # repeated txid=
364
+ assert_parity([ok({})]) { |c| c.add_order(ordertype: "market", type: "buy", volume: 1, pair: "X", oflags: [false]) }
365
+ assert_parity([ok({})]) { |c| c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "X", validate: false, userref: 0) }
366
+ assert_parity([ok({})]) { |c| c.get_ohlc_data(pair: :XBTUSD, interval: 60.0, since: nil) }
367
+ end
368
+
369
+ def test_concurrent_private_calls_get_unique_increasing_nonces
370
+ Honeymaker::Native::Ext::Kraken.fixed_nonce = nil
371
+ d = VenueDouble.new([ok({})])
372
+ c = Honeymaker::Native::KrakenClient.new(api_key: "k", api_secret: SECRET)
373
+ c.instance_variable_set(:@connection, c.send(:build_client_connection, d.url))
374
+ 8.times.map { Thread.new { 10.times { assert c.get_extended_balance.success? } } }.each(&:join)
375
+ nonces = d.requests.map { |r| r.body[/nonce=(\d+)/, 1].to_i }
376
+ assert_equal 80, nonces.size
377
+ assert_equal nonces.uniq.size, nonces.size, "unique per key"
378
+ ensure
379
+ d&.close
380
+ end
381
+
382
+ def test_api_key_with_trailing_newline
383
+ assert_parity([ok({})], key: "test_key\n", &:get_extended_balance)
384
+ end
385
+
386
+ def test_native_uses_the_proxied_connection
387
+ d = VenueDouble.new([ok({})])
388
+ p = ProxyDouble.new(d.port)
389
+ c = Honeymaker::Native::KrakenClient.new(api_key: "k", api_secret: SECRET, proxy: p.url)
390
+ c.instance_variable_set(:@connection, c.send(:build_client_connection, d.url))
391
+ assert c.get_extended_balance.success?
392
+ assert_equal 1, p.lines.size, "request went through the proxy"
393
+ assert_match %r{\APOST http://127\.0\.0\.1:#{d.port}/0/private/BalanceEx }, p.lines.first
394
+ ensure
395
+ p&.close
396
+ d&.close
397
+ end
398
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "support/parity_helper"
5
+
6
+ class KrakenExchangeParityTest < Minitest::Test
7
+ include ParityHelper
8
+ include FixtureHelper
9
+
10
+ JSON_CT = { "content-type" => "application/json" }.freeze
11
+
12
+ def assert_parity(reply)
13
+ d = VenueDouble.new([reply])
14
+ results = [Honeymaker::Exchanges::Kraken, Honeymaker::Native::KrakenExchange].map do |k|
15
+ d.reset!([reply])
16
+ e = k.new
17
+ e.instance_variable_set(:@connection, e.send(:build_connection, d.url))
18
+ r = begin; yield(e); rescue StandardError => x; x; end
19
+ [r, d.requests.map { |q| [q.method, q.target] }]
20
+ end
21
+ assert_same_ruby(results[0][0], results[1][0])
22
+ assert_equal results[0][1], results[1][1]
23
+ ensure
24
+ d&.close
25
+ end
26
+
27
+ def test_tickers_info_from_the_real_fixture
28
+ assert_parity([200, JSON_CT, File.read(fixture_path("kraken_asset_pairs.json"))], &:get_tickers_info)
29
+ end
30
+
31
+ def test_tickers_info_edges
32
+ pairs = {
33
+ "XXBTZUSD" => { "altname" => "XBTUSD", "wsname" => "XBT/USD", "ordermin" => "0.0001", "costmin" => "0.5",
34
+ "lot_decimals" => 8, "cost_decimals" => 5, "pair_decimals" => 1, "status" => "online" },
35
+ "NVDASPVUSD" => { "altname" => "NVDAxUSD", "wsname" => "NVDAx/USD", "aclass_base" => "tokenized_asset", "ordermin" => "0.1" },
36
+ "NVDAxUSD" => { "altname" => "NVDAxUSD", "wsname" => "NVDAx/USD", "aclass_base" => "tokenized_asset" },
37
+ "NOWS" => { "altname" => "NOWS", "wsname" => "" },
38
+ "WEIRD" => { "altname" => "W", "wsname" => "ABC", "costmin" => 7.5, "status" => "cancel_only" },
39
+ "XF" => { "altname" => "XF", "wsname" => "ETH/XBT", "costmin" => "1" }
40
+ }
41
+ [[200, JSON_CT, { "error" => [], "result" => pairs }.to_json],
42
+ [200, JSON_CT, '{"error":["EGeneral:Temporary lockout"]}'],
43
+ [500, JSON_CT, "{}"]].each { |reply| assert_parity(reply, &:get_tickers_info) }
44
+ end
45
+
46
+ def test_one_malformed_row_fails_the_whole_catalogue_like_legacy
47
+ replies = [false, 1, { "x" => 1 }].map do |bad|
48
+ pairs = { "XXBTZUSD" => { "altname" => "XBTUSD", "wsname" => "XBT/USD", "status" => "online" },
49
+ "BAD" => { "altname" => "BAD", "wsname" => bad } }
50
+ [200, JSON_CT, { "error" => [], "result" => pairs }.to_json]
51
+ end
52
+ # Non-object bodies too: Failure on both, messages differ (spec §5.1).
53
+ replies += [[200, { "content-type" => "text/html" }, "x"], [200, JSON_CT, "null"],
54
+ [200, { "content-type" => "text/html" }, "\xFF\xFE".b]]
55
+ replies.each do |reply|
56
+ bad = reply[2][0, 40]
57
+ d = VenueDouble.new([reply])
58
+ rs = [Honeymaker::Exchanges::Kraken, Honeymaker::Native::KrakenExchange].map do |k|
59
+ d.reset!([reply])
60
+ e = k.new
61
+ e.instance_variable_set(:@connection, e.send(:build_connection, d.url))
62
+ e.get_tickers_info
63
+ end
64
+ assert rs.all?(&:failure?), "wsname=#{bad.inspect} must fail the catalogue on both"
65
+ assert_nullable_equal rs[0].data, rs[1].data
66
+ ensure
67
+ d&.close
68
+ end
69
+ end
70
+
71
+ def test_bid_ask_and_price
72
+ t = { "error" => [], "result" => { "XXBTZUSD" => { "a" => ["50000.10", "1", "1.000"], "b" => ["49999.90", "2", "2.000"] } } }.to_json
73
+ assert_parity([200, JSON_CT, t]) { |e| e.get_bid_ask("XBTUSD") }
74
+ assert_parity([200, JSON_CT, t]) { |e| e.get_price("XBTUSD") }
75
+ assert_parity([200, JSON_CT, '{"error":["EQuery:Unknown asset pair"]}']) { |e| e.get_bid_ask("NOPE") }
76
+ assert_parity([200, JSON_CT, '{"error":[],"result":{"X":{"a":["NaN"],"b":["-0"]}}}']) { |e| e.get_bid_ask("X") }
77
+ assert_parity([200, JSON_CT, '{"error":[],"result":{"X":{"a":[50001],"b":[50000]}}}']) { |e| e.get_bid_ask("X") }
78
+ assert_parity([200, JSON_CT, '{"error":[],"result":{"X":{"a":[1.5],"b":[1.25]}}}']) { |e| e.get_bid_ask("X") }
79
+ end
80
+
81
+ def test_invalid_utf8_ticker_fields_and_price_strings
82
+ ['{"error":[],"result":{"X":{"altname":"X","wsname":"X/USD"}}}',
83
+ '{"error":[],"result":{"X":{"altname":"A","wsname":"X/USD"}}}'].each do |body|
84
+ assert_parity([200, JSON_CT, body.sub('"altname":"X"', '"altname":"X' + "\xff" + '"').sub('"wsname":"X/USD"', '"wsname":"X' + "\xff" + '/USD"')], &:get_tickers_info)
85
+ end
86
+ ["1\xff", "\xff1"].each do |value|
87
+ ["[\"#{value}\"]", "\"#{value}\""].each do |price|
88
+ body = '{"error":[],"result":{"X":{"a":["2"],"b":' + price + '}}}'
89
+ assert_parity([200, JSON_CT, body]) { |e| e.get_bid_ask("X") }
90
+ end
91
+ end
92
+ end
93
+
94
+ def test_classify_invalid_utf8_keeps_legacy_exception
95
+ ["EAPI:Invalid nonce \xff", "EAPI:Invalid nonce".encode("UTF-16LE")].each do |message|
96
+ results = [Honeymaker::Exchanges::Kraken, Honeymaker::Native::KrakenExchange].map do |klass|
97
+ begin; klass.new.classify_error(message); rescue StandardError => e; e; end
98
+ end
99
+ assert_same_ruby(*results)
100
+ end
101
+ end
102
+
103
+ def test_classify_binary_error_bytes_and_capture_encoding
104
+ ["EAPI:Invalid nonce \xff\xfe".b,
105
+ "EAccount:Invalid permissions:X\xff trading restricted for US.".b,
106
+ "prefix \xff EService:Busy".b, "unknown \xff".b].each do |message|
107
+ assert_same_ruby(Honeymaker::Exchanges::Kraken.new.classify_error(message),
108
+ Honeymaker::Native::KrakenExchange.new.classify_error(message))
109
+ end
110
+ end
111
+
112
+ def test_classify_error_corpus
113
+ ["EAccount:Invalid permissions:NVDAx trading restricted for DE.",
114
+ "EAccount:Invalid permissions:XBT trading restricted for US",
115
+ "EAccount:Invalid permissions:XBT trading restricted for \u00dc.",
116
+ "prefix EAPI:Invalid nonce suffix", "EGeneral:Internal error", "EService:Unavailable",
117
+ "EService:Busy", "EService:Deadline elapsed", "EOrder:Insufficient funds", "", nil].each do |msg|
118
+ assert_nullable_equal Honeymaker::Exchanges::Kraken.new.classify_error(msg),
119
+ Honeymaker::Native::KrakenExchange.new.classify_error(msg), msg.inspect
120
+ end
121
+ end
122
+
123
+ def test_non_object_catalogue_body_is_a_failure_on_both
124
+ [[200, { "content-type" => "text/html" }, "x"], [200, JSON_CT, "null"]].each do |reply|
125
+ d = VenueDouble.new([reply])
126
+ rs = [Honeymaker::Exchanges::Kraken, Honeymaker::Native::KrakenExchange].map do |k|
127
+ d.reset!([reply])
128
+ e = k.new
129
+ e.instance_variable_set(:@connection, e.send(:build_connection, d.url))
130
+ e.get_tickers_info
131
+ end
132
+ assert rs.all?(&:failure?)
133
+ assert_nullable_equal rs[0].data, rs[1].data
134
+ ensure
135
+ d&.close
136
+ end
137
+ end
138
+
139
+ def assert_nullable_equal(expected, actual, msg = nil)
140
+ expected.nil? ? assert_nil(actual, msg) : assert_equal(expected, actual, msg)
141
+ end
142
+
143
+ def test_catalogue_ruby_shapes
144
+ [[], {}, [["X", { "wsname" => "X/USD", "altname" => "XUSD" }]],
145
+ { "X" => "no fields" },
146
+ { "X" => { "wsname" => {} } },
147
+ { "X" => { "wsname" => [] } }].each do |pairs|
148
+ assert_parity([200, JSON_CT, { "error" => [], "result" => pairs }.to_json], &:get_tickers_info)
149
+ end
150
+ pairs = ["/", "//", "X/", "X//", "/USD", "X//USD", "X/USD/"].to_h do |ws|
151
+ [ws, { "wsname" => ws, "altname" => ws, "costmin" => false, "status" => nil }]
152
+ end
153
+ assert_parity([200, JSON_CT, { "error" => [false, nil], "result" => pairs }.to_json], &:get_tickers_info)
154
+ end
155
+
156
+ def test_bid_ask_ruby_indexing_and_decimal_errors
157
+ [{ "a" => "23", "b" => "12" }, { "a" => 3, "b" => 2 },
158
+ { "a" => ["invalid"], "b" => ["1"] }, "ab"].each do |row|
159
+ [{ "X" => row }, [["X", row]]].each do |result|
160
+ assert_parity([200, JSON_CT, { "error" => [], "result" => result }.to_json]) { |e| e.get_bid_ask("X") }
161
+ end
162
+ end
163
+ end
164
+
165
+ def test_catalogue_under_gc_stress
166
+ body = File.read(fixture_path("kraken_asset_pairs.json"))
167
+ GC.stress = true
168
+ assert_parity([200, JSON_CT, body], &:get_tickers_info)
169
+ ensure
170
+ GC.stress = false
171
+ end
172
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "yaml"
5
+
6
+ class BranchHardeningTest < Minitest::Test
7
+ ROOT = File.expand_path("../..", __dir__)
8
+
9
+ def test_workflows_parse_and_use_read_only_default_permissions
10
+ %w[test native-gems release].each do |name|
11
+ path = File.join(ROOT, ".github/workflows/#{name}.yml")
12
+ workflow = YAML.load_file(path)
13
+ assert_equal({ "contents" => "read" }, workflow["permissions"], name)
14
+ File.foreach(path).grep(/uses: /).each do |line|
15
+ next if line.include?("uses: ./")
16
+
17
+ assert_match(%r{uses: [\w/-]+@[0-9a-f]{40} # v\d+\.\d+\.\d+\s*$}, line, path)
18
+ end
19
+ if name == "release"
20
+ assert_equal({ "id-token" => "write", "contents" => "read" }, workflow.dig("jobs", "publish", "permissions"))
21
+ end
22
+ end
23
+ end
24
+
25
+ def test_source_gem_does_not_pin_the_rust_toolchain
26
+ spec = Gem::Specification.load(File.join(ROOT, "honeymaker.gemspec"))
27
+ refute_includes spec.files, "rust-toolchain.toml"
28
+ assert_includes spec.files, "Cargo.toml"
29
+ end
30
+
31
+ def test_unused_integer_coercion_is_removed
32
+ refute_match(/pub fn to_i\b/, File.read(File.join(ROOT, "crates/honeymaker/src/semantics.rs")))
33
+ end
34
+ end