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