honeymaker 0.12.4-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +33 -0
  5. data/LICENSE +21 -0
  6. data/README.md +219 -0
  7. data/Rakefile +78 -0
  8. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  9. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  10. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  11. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  12. data/lib/honeymaker/3.4/honeymaker_native.so +0 -0
  13. data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
  14. data/lib/honeymaker/client.rb +139 -0
  15. data/lib/honeymaker/clients/binance.rb +687 -0
  16. data/lib/honeymaker/clients/binance_us.rb +42 -0
  17. data/lib/honeymaker/clients/bingx.rb +242 -0
  18. data/lib/honeymaker/clients/bitget.rb +318 -0
  19. data/lib/honeymaker/clients/bitrue.rb +245 -0
  20. data/lib/honeymaker/clients/bitvavo.rb +244 -0
  21. data/lib/honeymaker/clients/bybit.rb +308 -0
  22. data/lib/honeymaker/clients/coinbase.rb +326 -0
  23. data/lib/honeymaker/clients/gemini.rb +189 -0
  24. data/lib/honeymaker/clients/hyperliquid.rb +232 -0
  25. data/lib/honeymaker/clients/kraken.rb +379 -0
  26. data/lib/honeymaker/clients/kraken_futures.rb +78 -0
  27. data/lib/honeymaker/clients/kucoin.rb +310 -0
  28. data/lib/honeymaker/clients/mexc.rb +249 -0
  29. data/lib/honeymaker/exchange.rb +129 -0
  30. data/lib/honeymaker/exchanges/binance.rb +55 -0
  31. data/lib/honeymaker/exchanges/binance_us.rb +9 -0
  32. data/lib/honeymaker/exchanges/bingx.rb +56 -0
  33. data/lib/honeymaker/exchanges/bitget.rb +52 -0
  34. data/lib/honeymaker/exchanges/bitrue.rb +53 -0
  35. data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
  36. data/lib/honeymaker/exchanges/bybit.rb +57 -0
  37. data/lib/honeymaker/exchanges/coinbase.rb +69 -0
  38. data/lib/honeymaker/exchanges/gemini.rb +134 -0
  39. data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
  40. data/lib/honeymaker/exchanges/kraken.rb +122 -0
  41. data/lib/honeymaker/exchanges/kucoin.rb +52 -0
  42. data/lib/honeymaker/exchanges/mexc.rb +56 -0
  43. data/lib/honeymaker/native/kraken.rb +263 -0
  44. data/lib/honeymaker/native.rb +42 -0
  45. data/lib/honeymaker/result.rb +46 -0
  46. data/lib/honeymaker/utils.rb +39 -0
  47. data/lib/honeymaker/version.rb +5 -0
  48. data/lib/honeymaker.rb +97 -0
  49. data/script/parity/kraken_client_live.rb +191 -0
  50. data/script/parity/kraken_live.rb +70 -0
  51. data/script/parity/kraken_transport.rb +604 -0
  52. data/script/parity/resolve.c +49 -0
  53. data/script/release/push_gems.sh +28 -0
  54. data/script/vectors/kraken.rb +50 -0
  55. data/script/vectors/kraken_stage2.rb +146 -0
  56. data/sig/honeymaker.rbs +4 -0
  57. data/test/fixtures/binance_book_ticker.json +7 -0
  58. data/test/fixtures/binance_exchange_info.json +30 -0
  59. data/test/fixtures/bingx_symbols.json +26 -0
  60. data/test/fixtures/bingx_ticker.json +12 -0
  61. data/test/fixtures/bitget_symbols.json +28 -0
  62. data/test/fixtures/bitget_tickers.json +14 -0
  63. data/test/fixtures/bitmart_symbols.json +26 -0
  64. data/test/fixtures/bitmart_ticker.json +12 -0
  65. data/test/fixtures/bitrue_book_ticker.json +7 -0
  66. data/test/fixtures/bitrue_exchange_info.json +34 -0
  67. data/test/fixtures/bitvavo_book_ticker.json +7 -0
  68. data/test/fixtures/bitvavo_markets.json +30 -0
  69. data/test/fixtures/bybit_instruments.json +23 -0
  70. data/test/fixtures/bybit_tickers.json +19 -0
  71. data/test/fixtures/coinbase_product.json +7 -0
  72. data/test/fixtures/coinbase_products.json +24 -0
  73. data/test/fixtures/gemini_pubticker.json +10 -0
  74. data/test/fixtures/gemini_symbol_detail.json +10 -0
  75. data/test/fixtures/gemini_symbols.json +1 -0
  76. data/test/fixtures/hyperliquid_all_mids.json +5 -0
  77. data/test/fixtures/hyperliquid_spot_meta.json +12 -0
  78. data/test/fixtures/kraken_asset_pairs.json +43 -0
  79. data/test/fixtures/kraken_ticker.json +16 -0
  80. data/test/fixtures/kucoin_orderbook_level1.json +10 -0
  81. data/test/fixtures/kucoin_symbols.json +17 -0
  82. data/test/fixtures/mexc_book_ticker.json +7 -0
  83. data/test/fixtures/mexc_exchange_info.json +64 -0
  84. data/test/honeymaker/client_test.rb +90 -0
  85. data/test/honeymaker/clients/binance_client_test.rb +151 -0
  86. data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
  87. data/test/honeymaker/clients/bingx_client_test.rb +106 -0
  88. data/test/honeymaker/clients/bitget_client_test.rb +146 -0
  89. data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
  90. data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
  91. data/test/honeymaker/clients/bybit_client_test.rb +137 -0
  92. data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
  93. data/test/honeymaker/clients/gemini_client_test.rb +100 -0
  94. data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
  95. data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
  96. data/test/honeymaker/clients/kraken_client_test.rb +255 -0
  97. data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
  98. data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
  99. data/test/honeymaker/clients/mexc_client_test.rb +98 -0
  100. data/test/honeymaker/clients/validation_test.rb +167 -0
  101. data/test/honeymaker/error_chain_test.rb +133 -0
  102. data/test/honeymaker/exchange_test.rb +138 -0
  103. data/test/honeymaker/exchanges/binance_test.rb +83 -0
  104. data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
  105. data/test/honeymaker/exchanges/bingx_test.rb +65 -0
  106. data/test/honeymaker/exchanges/bitget_test.rb +65 -0
  107. data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
  108. data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
  109. data/test/honeymaker/exchanges/bybit_test.rb +55 -0
  110. data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
  111. data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
  112. data/test/honeymaker/exchanges/gemini_test.rb +184 -0
  113. data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
  114. data/test/honeymaker/exchanges/kraken_test.rb +191 -0
  115. data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
  116. data/test/honeymaker/exchanges/mexc_test.rb +97 -0
  117. data/test/honeymaker/honeymaker_test.rb +27 -0
  118. data/test/honeymaker/result_test.rb +42 -0
  119. data/test/honeymaker/utils_test.rb +64 -0
  120. data/test/native/backend_switch_test.rb +53 -0
  121. data/test/native/convert_test.rb +137 -0
  122. data/test/native/gem_isolation_test.rb +17 -0
  123. data/test/native/loader_test.rb +29 -0
  124. data/test/native/lockstep_test.rb +23 -0
  125. data/test/native/signing_smoke_test.rb +28 -0
  126. data/test/parity/kraken_client_parity_test.rb +398 -0
  127. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  128. data/test/release/branch_hardening_test.rb +34 -0
  129. data/test/release/push_gems_test.rb +79 -0
  130. data/test/smoke.rb +24 -0
  131. data/test/support/fixture_helper.rb +13 -0
  132. data/test/support/kraken_signing_smoke.rb +21 -0
  133. data/test/support/parity_helper.rb +62 -0
  134. data/test/support/venue_double.rb +100 -0
  135. data/test/test_helper.rb +9 -0
  136. metadata +254 -0
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
6
+ def setup
7
+ @client = Honeymaker::Clients::Hyperliquid.new
8
+ end
9
+
10
+ def test_url
11
+ assert_equal "https://api.hyperliquid.xyz", Honeymaker::Clients::Hyperliquid::URL
12
+ end
13
+
14
+ def test_spot_meta
15
+ stub_connection(:post, { "tokens" => [], "universe" => [] })
16
+ result = @client.spot_meta
17
+ assert result.success?
18
+ assert_equal [], result.data["tokens"]
19
+ end
20
+
21
+ def test_spot_meta_and_asset_ctxs
22
+ stub_connection(:post, [{ "tokens" => [] }, []])
23
+ result = @client.spot_meta_and_asset_ctxs
24
+ assert result.success?
25
+ end
26
+
27
+ def test_spot_clearinghouse_state
28
+ stub_connection(:post, { "balances" => [] })
29
+ result = @client.spot_clearinghouse_state(user: "0xabc")
30
+ assert result.success?
31
+ end
32
+
33
+ # Hyperliquid's orderStatus body is NESTED: { "status" => "order", "order" => { "order" => {...},
34
+ # "status" => <real status>, ... } }. The real order status, coin, sizes live under order["order"]
35
+ # and order["status"] — NOT the top level. orderStatus carries NO "fills" key, so executed cost is
36
+ # sourced from a bounded userFillsByTime call (only when something actually executed).
37
+
38
+ def test_order_status_filled_parses_nested_shape_and_sources_cost_from_fills
39
+ # Filled: remaining sz == 0, origSz is the ordered amount, real status under the wrapper.
40
+ # statusTimestamp deliberately DIFFERS from the order timestamp so a parser that bounds
41
+ # userFillsByTime on the wrong field fails the .with below.
42
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00020",
43
+ timestamp: 1781698875556, status_timestamp: 1781699999999)
44
+ stub_connection(:post, order_body)
45
+ # Cost comes from a BOUNDED userFillsByTime (type + start == the ORDER timestamp), summing
46
+ # ONLY this oid's fills (the unrelated-oid fill must be excluded); two fills → VWAP price.
47
+ @client.expects(:user_fills_by_time)
48
+ .with(user: "0xabc", start_time: 1781698875556)
49
+ .returns(Honeymaker::Result::Success.new([
50
+ fill(oid: 123456789, px: "64000.0", sz: "0.00010"),
51
+ fill(oid: 123456789, px: "66000.0", sz: "0.00010"),
52
+ fill(oid: 999999999, px: "1.0", sz: "5.0") # other order
53
+ ]))
54
+
55
+ result = @client.order_status(user: "0xabc", oid: 123456789)
56
+
57
+ assert result.success?
58
+ data = result.data
59
+ assert_equal :closed, data[:status]
60
+ assert_equal "@142", data[:coin]
61
+ assert_equal :buy, data[:side]
62
+ assert_equal BigDecimal("0.00020"), data[:amount] # origSz, NOT remaining sz
63
+ assert_equal BigDecimal("0.00020"), data[:amount_exec] # origSz - sz, NOT the 5.0 unrelated fill
64
+ assert_equal BigDecimal("13.0"), data[:quote_amount_exec] # 6.4 + 6.6, unrelated fill excluded
65
+ assert_equal BigDecimal("65000"), data[:price] # 13.0 / 0.0002 volume-weighted
66
+ end
67
+
68
+ def test_order_status_filled_falls_back_to_limit_cost_when_no_matching_fill
69
+ # userFills can age out / return nothing for THIS oid — a filled order must NEVER report
70
+ # quote_amount_exec 0 (the job subtracts the quote delta from missed_quote_amount).
71
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00018", limit_px: "64689.0")
72
+ stub_connection(:post, order_body)
73
+ @client.expects(:user_fills_by_time)
74
+ .returns(Honeymaker::Result::Success.new([fill(oid: 111, px: "1.0", sz: "5.0")])) # no match
75
+
76
+ result = @client.order_status(user: "0xabc", oid: 123456789)
77
+
78
+ assert_equal :closed, result.data[:status]
79
+ assert_equal BigDecimal("64689.0") * BigDecimal("0.00018"), result.data[:quote_amount_exec]
80
+ assert_equal BigDecimal("64689.0"), result.data[:price]
81
+ end
82
+
83
+ def test_order_status_propagates_user_fills_failure_for_executed_order
84
+ # A transient userFills failure must NOT degrade to an estimated cost — propagate it so the
85
+ # consumer retries and the exact quote_amount_exec is eventually recorded.
86
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00018")
87
+ stub_connection(:post, order_body)
88
+ @client.expects(:user_fills_by_time).returns(Honeymaker::Result::Failure.new("Net::ReadTimeout"))
89
+
90
+ result = @client.order_status(user: "0xabc", oid: 123456789)
91
+
92
+ assert result.failure?
93
+ assert_includes result.errors, "Net::ReadTimeout"
94
+ end
95
+
96
+ def test_order_status_open_does_not_call_user_fills
97
+ # Resting order: sz == origSz, nothing executed → no weight-20 userFills call.
98
+ order_body = order_status_body(status: "open", sz: "0.00018", orig_sz: "0.00018")
99
+ connection = stub
100
+ connection.expects(:post).once.returns(stub(body: order_body)) # exactly one call — orderStatus only
101
+ @client.instance_variable_set(:@connection, connection)
102
+ @client.expects(:user_fills_by_time).never # gated on amount_exec > 0
103
+
104
+ result = @client.order_status(user: "0xabc", oid: 123456789)
105
+
106
+ assert result.success?
107
+ assert_equal :open, result.data[:status]
108
+ assert_equal BigDecimal("0"), result.data[:amount_exec]
109
+ assert_equal BigDecimal("64689.0"), result.data[:price] # falls back to the limit price
110
+ end
111
+
112
+ def test_order_status_partial_fill_is_open_with_executed_cost
113
+ order_body = order_status_body(status: "open", sz: "0.00010", orig_sz: "0.00018",
114
+ timestamp: 1781698875556, status_timestamp: 1781699999999)
115
+ stub_connection(:post, order_body)
116
+ @client.expects(:user_fills_by_time)
117
+ .with(user: "0xabc", start_time: 1781698875556)
118
+ .returns(Honeymaker::Result::Success.new([fill(oid: 123456789, px: "64500.0", sz: "0.00008")]))
119
+
120
+ result = @client.order_status(user: "0xabc", oid: 123456789)
121
+
122
+ assert result.success?
123
+ assert_equal :open, result.data[:status]
124
+ assert_equal BigDecimal("0.00008"), result.data[:amount_exec] # origSz - sz
125
+ assert_equal BigDecimal("64500.0") * BigDecimal("0.00008"), result.data[:quote_amount_exec]
126
+ end
127
+
128
+ def test_order_status_margin_canceled_maps_to_cancelled
129
+ stub_connection(:post, order_status_body(status: "marginCanceled", sz: "0.00018", orig_sz: "0.00018"))
130
+ result = @client.order_status(user: "0xabc", oid: 1)
131
+ assert_equal :cancelled, result.data[:status]
132
+ end
133
+
134
+ def test_order_status_scheduled_cancel_maps_to_cancelled
135
+ stub_connection(:post, order_status_body(status: "scheduledCancel", sz: "0.00018", orig_sz: "0.00018"))
136
+ result = @client.order_status(user: "0xabc", oid: 1)
137
+ assert_equal :cancelled, result.data[:status]
138
+ end
139
+
140
+ def test_order_status_reduce_only_canceled_maps_to_cancelled
141
+ # Suffix-aware /cancel/i covers the whole cancel family, not just the literals above.
142
+ stub_connection(:post, order_status_body(status: "reduceOnlyCanceled", sz: "0.00018", orig_sz: "0.00018"))
143
+ result = @client.order_status(user: "0xabc", oid: 1)
144
+ assert_equal :cancelled, result.data[:status]
145
+ end
146
+
147
+ def test_order_status_rejected_maps_to_cancelled
148
+ stub_connection(:post, order_status_body(status: "rejected", sz: "0.00018", orig_sz: "0.00018"))
149
+ result = @client.order_status(user: "0xabc", oid: 1)
150
+ assert_equal :cancelled, result.data[:status]
151
+ end
152
+
153
+ def test_order_status_unmapped_status_falls_back_to_unknown
154
+ # A genuinely new Hyperliquid status must surface as :unknown (and be logged), never crash.
155
+ stub_connection(:post, order_status_body(status: "someBrandNewStatus", sz: "0.00018", orig_sz: "0.00018"))
156
+ result = @client.order_status(user: "0xabc", oid: 1)
157
+ assert_equal :unknown, result.data[:status]
158
+ end
159
+
160
+ def test_order_status_triggered_maps_to_open
161
+ # A triggered order has fired and become a live resting order → still open.
162
+ stub_connection(:post, order_status_body(status: "triggered", sz: "0.00018", orig_sz: "0.00018"))
163
+ result = @client.order_status(user: "0xabc", oid: 1)
164
+ assert_equal :open, result.data[:status]
165
+ end
166
+
167
+ def test_order_status_unknown_oid_returns_not_found_signal
168
+ stub_connection(:post, { "status" => "unknownOid" })
169
+ result = @client.order_status(user: "0xabc", oid: 1)
170
+
171
+ assert result.failure?
172
+ assert_equal({ not_found: true }, result.data)
173
+ end
174
+
175
+ def test_open_orders
176
+ stub_connection(:post, [])
177
+ result = @client.open_orders(user: "0xabc")
178
+ assert result.success?
179
+ end
180
+
181
+ def test_user_fills
182
+ stub_connection(:post, [{ "oid" => 1, "side" => "B" }])
183
+ result = @client.user_fills(user: "0xabc")
184
+ assert result.success?
185
+ end
186
+
187
+ def test_user_fills_by_time
188
+ stub_connection(:post, [{ "oid" => 1, "side" => "B" }])
189
+ result = @client.user_fills_by_time(user: "0xabc", start_time: 1710936000000)
190
+ assert result.success?
191
+ end
192
+
193
+ # == Raised trading errors keep their HTTP status ==
194
+ #
195
+ # Order placement goes through hyperliquid-rb, which raises instead of answering. Whether the
196
+ # venue REFUSED the request or may have TAKEN it is the whole question for a caller deciding
197
+ # between "record a rejected order" and "a human has to go and look" — and the status is the only
198
+ # thing that answers it. It must survive the trip.
199
+
200
+ # hyperliquid-rb's ClientError/ServerError shape. Rebuilt here because the gem is an optional
201
+ # dependency this one does not carry.
202
+ class RaisedWithStatus < StandardError
203
+ attr_reader :status
204
+
205
+ def initialize(message, status:)
206
+ @status = status
207
+ super(message)
208
+ end
209
+ end
210
+
211
+ def test_order_reports_a_refusal_with_its_status
212
+ @client.stubs(:exchange_client).raises(RaisedWithStatus.new("HTTP 429: rate limited", status: 429))
213
+
214
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
215
+
216
+ assert result.failure?
217
+ assert_equal 429, result.data[:status]
218
+ assert_nil result.data[:client_error], "the venue answered — this is not an unknown outcome"
219
+ assert_equal ["HTTP 429: rate limited"], result.errors
220
+ end
221
+
222
+ # 5xx stays 5xx: a gateway that may have passed the order on is exactly the case a caller must
223
+ # keep treating as unresolved.
224
+ def test_order_reports_a_gateway_failure_with_its_status
225
+ @client.stubs(:exchange_client).raises(RaisedWithStatus.new("HTTP 502: bad gateway", status: 502))
226
+
227
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
228
+
229
+ assert result.failure?
230
+ assert_equal 502, result.data[:status]
231
+ end
232
+
233
+ def test_cancel_reports_a_refusal_with_its_status
234
+ @client.stubs(:exchange_client).raises(RaisedWithStatus.new("HTTP 422: unknown oid", status: 422))
235
+
236
+ result = @client.cancel(coin: "@142", oid: 1)
237
+
238
+ assert result.failure?
239
+ assert_equal 422, result.data[:status]
240
+ end
241
+
242
+ # Everything the venue did not answer is untouched: a signing crash inside the gem, a network
243
+ # failure, a bug here. Those stay flagged as ours and unclassifiable.
244
+ def test_order_leaves_a_statusless_error_classified_as_before
245
+ @client.stubs(:exchange_client).raises(TypeError, "String can't be coerced into Float")
246
+
247
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
248
+
249
+ assert result.failure?
250
+ assert result.data[:client_error], "no status means no answer from the venue"
251
+ assert_equal ["TypeError: String can't be coerced into Float"], result.errors
252
+ end
253
+
254
+ def test_order_wraps_an_accepted_placement
255
+ @client.stubs(:exchange_client).returns(stub(order: { "status" => "ok" }))
256
+
257
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
258
+
259
+ assert result.success?
260
+ assert_equal({ "status" => "ok" }, result.data)
261
+ end
262
+
263
+ private
264
+
265
+ def stub_connection(method, body)
266
+ response = stub(body: body)
267
+ connection = stub
268
+ connection.stubs(method).returns(response)
269
+ @client.instance_variable_set(:@connection, connection)
270
+ end
271
+
272
+ def order_status_body(status:, sz:, orig_sz:, coin: "@142", side: "B", limit_px: "64689.0",
273
+ oid: 123456789, timestamp: 1781698875556, status_timestamp: 1781699999999)
274
+ {
275
+ "status" => "order",
276
+ "order" => {
277
+ "order" => {
278
+ "coin" => coin, "side" => side, "limitPx" => limit_px,
279
+ "sz" => sz, "origSz" => orig_sz, "oid" => oid, "timestamp" => timestamp,
280
+ "orderType" => "Limit", "tif" => "Gtc"
281
+ },
282
+ "status" => status,
283
+ "statusTimestamp" => status_timestamp
284
+ }
285
+ }
286
+ end
287
+
288
+ def fill(oid:, px:, sz:, coin: "@142", side: "B")
289
+ { "coin" => coin, "oid" => oid, "px" => px, "sz" => sz, "side" => side,
290
+ "time" => 1781698875556, "fee" => "0.011", "tid" => 99 }
291
+ end
292
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Clients::KrakenTest < Minitest::Test
6
+ def setup
7
+ Honeymaker::Clients::Kraken.reset_nonce_state! if Honeymaker::Clients::Kraken.respond_to?(:reset_nonce_state!)
8
+ @client = Honeymaker::Clients::Kraken.new(
9
+ api_key: "test_key",
10
+ api_secret: Base64.strict_encode64("test_secret_key_1234567890123456")
11
+ )
12
+ end
13
+
14
+ def test_url
15
+ assert_equal "https://api.kraken.com", Honeymaker::Clients::Kraken::URL
16
+ end
17
+
18
+ def test_non_object_2xx_is_unreadable_not_empty
19
+ [["<html>maintenance</html>", ->(c) { c.get_balances }],
20
+ [nil, ->(c) { c.query_orders_info(txid: "O1") }],
21
+ ["oops", ->(c) { c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "XBTUSD") }],
22
+ ["oops", ->(c) { c.closed_orders_from_trades(order_ids: ["O1"]) }]].each do |body, call|
23
+ stub_connection(:post, body)
24
+ r = call.call(@client)
25
+ assert_equal ["Kraken: unreadable response"], r.errors
26
+ assert_equal({ unreadable: true }, r.data)
27
+ end
28
+ end
29
+
30
+ def test_get_tradable_asset_pairs
31
+ stub_connection(:get, { "error" => [], "result" => { "XBTUSDT" => { "altname" => "XBTUSDT" } } })
32
+ result = @client.get_tradable_asset_pairs
33
+ assert result.success?
34
+ assert result.data["result"].key?("XBTUSDT")
35
+ end
36
+
37
+ # Without aclass_base Kraken serves only the "currency" class, so tokenized equities are invisible.
38
+ def test_get_tradable_asset_pairs_forwards_aclass_base
39
+ captured = nil
40
+ req = stub("req")
41
+ req.stubs(:url)
42
+ req.stubs(:headers=)
43
+ req.stubs(:params=).with { |p| captured = p; true }
44
+ connection = stub
45
+ connection.stubs(:get).yields(req).returns(stub(body: { "error" => [], "result" => {} }))
46
+ @client.instance_variable_set(:@connection, connection)
47
+
48
+ assert @client.get_tradable_asset_pairs(aclass_base: "all").success?
49
+ assert_equal "all", captured[:aclass_base]
50
+ end
51
+
52
+ def test_get_tradable_asset_pairs_omits_aclass_base_when_not_asked
53
+ captured = nil
54
+ req = stub("req")
55
+ req.stubs(:url)
56
+ req.stubs(:headers=)
57
+ req.stubs(:params=).with { |p| captured = p; true }
58
+ connection = stub
59
+ connection.stubs(:get).yields(req).returns(stub(body: { "error" => [], "result" => {} }))
60
+ @client.instance_variable_set(:@connection, connection)
61
+
62
+ @client.get_tradable_asset_pairs
63
+
64
+ refute captured.key?(:aclass_base), "params are compacted, so nil must not be sent"
65
+ end
66
+
67
+ def test_get_ticker_information
68
+ stub_connection(:get, { "error" => [], "result" => { "XBTUSDT" => { "a" => ["50000"] } } })
69
+ result = @client.get_ticker_information(pair: "XBTUSDT")
70
+ assert result.success?
71
+ end
72
+
73
+ def test_add_order
74
+ stub_connection(:post, { "error" => [], "result" => { "txid" => ["ORDER-123"] } })
75
+ result = @client.add_order(ordertype: "market", type: "buy", volume: "0.001", pair: "XBTUSDT")
76
+ assert result.success?
77
+ assert_equal "ORDER-123", result.data[:order_id]
78
+ assert_equal ["ORDER-123"], result.data[:raw]["result"]["txid"]
79
+ end
80
+
81
+ def test_cancel_order
82
+ stub_connection(:post, { "error" => [], "result" => { "count" => 1 } })
83
+ result = @client.cancel_order(txid: "ORDER-123")
84
+ assert result.success?
85
+ end
86
+
87
+ def test_get_extended_balance
88
+ stub_connection(:post, { "error" => [], "result" => { "XXBT" => { "balance" => "0.5" } } })
89
+ result = @client.get_extended_balance
90
+ assert result.success?
91
+ end
92
+
93
+ def test_get_api_key_info
94
+ stub_connection(:post, { "error" => [], "result" => { "apiKeyName" => "bot",
95
+ "permissions" => %w[query-funds query-ledger] } })
96
+ result = @client.get_api_key_info
97
+ assert result.success?
98
+ assert_equal %w[query-funds query-ledger], result.data["result"]["permissions"]
99
+ end
100
+
101
+ def test_get_trades_history
102
+ stub_connection(:post, { "error" => [], "result" => { "trades" => { "T1" => { "pair" => "XBTUSDT" } }, "count" => 1 } })
103
+ result = @client.get_trades_history
104
+ assert result.success?
105
+ assert result.data["result"]["trades"].key?("T1")
106
+ end
107
+
108
+ def test_get_ledgers
109
+ stub_connection(:post, { "error" => [], "result" => { "ledger" => { "L1" => { "type" => "trade" } }, "count" => 1 } })
110
+ result = @client.get_ledgers
111
+ assert result.success?
112
+ assert result.data["result"]["ledger"].key?("L1")
113
+ end
114
+
115
+ def test_private_headers_produce_signature
116
+ headers = @client.send(:private_headers, "/0/private/Balance", "nonce=12345")
117
+ assert headers.key?(:"API-Key")
118
+ assert headers.key?(:"API-Sign")
119
+ assert_equal "test_key", headers[:"API-Key"]
120
+ end
121
+
122
+ def test_public_headers_no_auth
123
+ client = Honeymaker::Clients::Kraken.new
124
+ headers = client.send(:public_headers)
125
+ refute headers.key?(:"API-Key")
126
+ end
127
+
128
+ def test_reset_nonce_state_is_available_for_test_isolation
129
+ assert_respond_to Honeymaker::Clients::Kraken, :reset_nonce_state!
130
+ end
131
+
132
+ def test_nonce_is_strictly_increasing_in_a_tight_loop
133
+ nonces = Array.new(10_000) { @client.send(:nonce) }
134
+
135
+ assert_strictly_increasing nonces
136
+ end
137
+
138
+ def test_nonce_is_strictly_increasing_when_clock_is_frozen
139
+ frozen_time = Time.utc(2026, 1, 1, 12, 0, 0)
140
+ Time.stubs(:now).returns(frozen_time)
141
+
142
+ nonces = Array.new(3) { @client.send(:nonce) }
143
+
144
+ assert_strictly_increasing nonces
145
+ end
146
+
147
+ def test_nonce_is_strictly_increasing_across_clients_with_the_same_api_key
148
+ frozen_time = Time.utc(2026, 1, 1, 12, 0, 0)
149
+ Time.stubs(:now).returns(frozen_time)
150
+ other_client = Honeymaker::Clients::Kraken.new(
151
+ api_key: "test_key",
152
+ api_secret: Base64.strict_encode64("test_secret_key_1234567890123456")
153
+ )
154
+
155
+ nonces = [
156
+ @client.send(:nonce),
157
+ other_client.send(:nonce),
158
+ @client.send(:nonce),
159
+ other_client.send(:nonce)
160
+ ]
161
+
162
+ assert_strictly_increasing nonces
163
+ end
164
+
165
+ def test_nonce_sequences_are_independent_for_different_api_keys
166
+ frozen_time = Time.utc(2026, 1, 1, 12, 0, 0)
167
+ Time.stubs(:now).returns(frozen_time)
168
+ first_key_client = Honeymaker::Clients::Kraken.new(api_key: "first_key", api_secret: @client.api_secret)
169
+ second_key_client = Honeymaker::Clients::Kraken.new(api_key: "second_key", api_secret: @client.api_secret)
170
+
171
+ first_key_first_nonce = first_key_client.send(:nonce)
172
+ first_key_second_nonce = first_key_client.send(:nonce)
173
+ second_key_first_nonce = second_key_client.send(:nonce)
174
+
175
+ assert_operator first_key_second_nonce, :>, first_key_first_nonce
176
+ assert_equal first_key_first_nonce, second_key_first_nonce
177
+ end
178
+
179
+ def test_closed_orders_from_trades_aggregates_partial_fills
180
+ page = { "error" => [], "result" => { "count" => 2, "trades" => {
181
+ "TID-1" => { "ordertxid" => "OABC", "pair" => "XXBTZUSD", "type" => "buy",
182
+ "ordertype" => "limit", "price" => "60000.0", "cost" => "6.0",
183
+ "vol" => "0.0001", "fee" => "0.01", "time" => 1_700_000_000.0 },
184
+ "TID-2" => { "ordertxid" => "OABC", "pair" => "XXBTZUSD", "type" => "buy",
185
+ "ordertype" => "limit", "price" => "61000.0", "cost" => "4.0",
186
+ "vol" => "0.00006557", "fee" => "0.006", "time" => 1_700_000_100.0 } } } }
187
+ stub_connection(:post, page)
188
+ result = @client.closed_orders_from_trades(order_ids: ["OABC"], start: 1_699_999_000)
189
+ assert result.success?
190
+ agg = result.data["OABC"]
191
+ assert_equal :closed, agg[:status]
192
+ assert_equal :buy, agg[:side]
193
+ assert_equal :limit, agg[:order_type]
194
+ assert_equal BigDecimal("10.0"), agg[:quote_amount_exec] # 6 + 4
195
+ assert_equal BigDecimal("0.00016557"), agg[:amount_exec] # summed vol
196
+ assert_equal BigDecimal("0.016"), agg[:fee] # 0.01 + 0.006
197
+ assert_equal BigDecimal("10.0") / BigDecimal("0.00016557"), agg[:price] # VWAP = cost/vol
198
+ assert_equal "XXBTZUSD", agg[:pair]
199
+ end
200
+
201
+ def test_closed_orders_from_trades_ignores_unrelated_orders
202
+ stub_connection(:post, { "error" => [], "result" => { "count" => 1, "trades" => {
203
+ "TID-9" => { "ordertxid" => "OOTHER", "pair" => "XXBTZUSD", "type" => "buy",
204
+ "ordertype" => "limit", "price" => "1", "cost" => "1", "vol" => "1",
205
+ "fee" => "0", "time" => 1_700_000_000.0 } } } })
206
+ result = @client.closed_orders_from_trades(order_ids: ["OABC"], start: 1)
207
+ assert result.success?
208
+ assert_empty result.data
209
+ end
210
+
211
+ def test_closed_orders_from_trades_aggregates_partial_fills_across_pages
212
+ page1 = Honeymaker::Result::Success.new({ "error" => [], "result" => { "count" => 2, "trades" => {
213
+ "TID-1" => { "ordertxid" => "OABC", "pair" => "XXBTZUSD", "type" => "buy", "ordertype" => "limit",
214
+ "price" => "60000.0", "cost" => "6.0", "vol" => "0.0001", "fee" => "0.01", "time" => 1.0 } } } })
215
+ page2 = Honeymaker::Result::Success.new({ "error" => [], "result" => { "count" => 2, "trades" => {
216
+ "TID-2" => { "ordertxid" => "OABC", "pair" => "XXBTZUSD", "type" => "buy", "ordertype" => "limit",
217
+ "price" => "61000.0", "cost" => "4.0", "vol" => "0.00006557", "fee" => "0.006", "time" => 2.0 } } } })
218
+ @client.stubs(:get_trades_history).returns(page1, page2) # Mocha returns successive values per call
219
+ result = @client.closed_orders_from_trades(order_ids: ["OABC"], start: 1, max_pages: 5)
220
+ assert result.success?
221
+ agg = result.data["OABC"]
222
+ assert_equal BigDecimal("10.0"), agg[:quote_amount_exec] # 6 + 4 across both pages
223
+ assert_equal BigDecimal("0.00016557"), agg[:amount_exec]
224
+ end
225
+
226
+ # A trade that lands at the top of TradesHistory between two page reads pushes TID-2 from page 1
227
+ # onto page 2. Offset paging then serves TID-2 twice; it must be counted once.
228
+ def test_closed_orders_from_trades_counts_a_trade_seen_on_two_pages_once
229
+ t = { "ordertxid" => "OABC", "pair" => "XXBTZUSD", "type" => "buy", "ordertype" => "market",
230
+ "cost" => "6.0", "vol" => "0.0001", "fee" => "0.01", "time" => 1_700_000_000.0 }
231
+ p1 = { "error" => [], "result" => { "count" => 3, "trades" => { "TID-1" => t, "TID-2" => t } } }
232
+ p2 = { "error" => [], "result" => { "count" => 3, "trades" => { "TID-2" => t } } }
233
+ @client.stubs(:get_trades_history).returns(Honeymaker::Result::Success.new(p1), Honeymaker::Result::Success.new(p2))
234
+ agg = @client.closed_orders_from_trades(order_ids: ["OABC"], start: 1).data["OABC"]
235
+ assert_equal BigDecimal("0.0002"), agg[:amount_exec]
236
+ assert_equal BigDecimal("12.0"), agg[:quote_amount_exec]
237
+ assert_equal BigDecimal("0.02"), agg[:fee]
238
+ assert_equal 2, agg[:trade_count]
239
+ end
240
+
241
+ private
242
+
243
+ def assert_strictly_increasing(values)
244
+ values.each_cons(2) do |previous, current|
245
+ assert_operator current, :>, previous
246
+ end
247
+ end
248
+
249
+ def stub_connection(method, body)
250
+ response = stub(body: body)
251
+ connection = stub
252
+ connection.stubs(method).returns(response)
253
+ @client.instance_variable_set(:@connection, connection)
254
+ end
255
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Clients::KrakenFuturesTest < Minitest::Test
6
+ def setup
7
+ @client = Honeymaker::Clients::KrakenFutures.new(
8
+ api_key: "test_key",
9
+ api_secret: Base64.strict_encode64("test_secret_key_1234567890123456")
10
+ )
11
+ end
12
+
13
+ def test_url
14
+ assert_equal "https://futures.kraken.com", Honeymaker::Clients::KrakenFutures::URL
15
+ end
16
+
17
+ def test_get_accounts
18
+ stub_connection(:get, { "result" => "success", "accounts" => {} })
19
+ result = @client.get_accounts
20
+ assert result.success?
21
+ end
22
+
23
+ def test_get_fills
24
+ stub_connection(:get, { "result" => "success", "fills" => [] })
25
+ result = @client.get_fills
26
+ assert result.success?
27
+ end
28
+
29
+ def test_get_open_positions
30
+ stub_connection(:get, { "result" => "success", "openPositions" => [] })
31
+ result = @client.get_open_positions
32
+ assert result.success?
33
+ end
34
+
35
+ def test_historical_funding_rates
36
+ stub_connection(:get, { "result" => "success", "rates" => [] })
37
+ result = @client.historical_funding_rates(symbol: "PF_XBTUSD")
38
+ assert result.success?
39
+ end
40
+
41
+ def test_client_registered
42
+ client = Honeymaker.client("kraken_futures", api_key: "k", api_secret: Base64.strict_encode64("s" * 32))
43
+ assert_instance_of Honeymaker::Clients::KrakenFutures, client
44
+ end
45
+
46
+ private
47
+
48
+ def stub_connection(method, body)
49
+ response = stub(body: body)
50
+ connection = stub
51
+ connection.stubs(method).returns(response)
52
+ @client.instance_variable_set(:@connection, connection)
53
+ end
54
+ end