honeymaker 0.12.3 → 0.12.5
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 +4 -4
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +19 -4
- data/Cargo.lock +1078 -0
- data/Cargo.toml +14 -0
- data/README.md +39 -0
- data/Rakefile +28 -16
- data/crates/honeymaker/Cargo.toml +18 -0
- data/crates/honeymaker/src/encode.rs +106 -0
- data/crates/honeymaker/src/kraken/lookup.rs +140 -0
- data/crates/honeymaker/src/kraken/mod.rs +5 -0
- data/crates/honeymaker/src/kraken/normalize.rs +536 -0
- data/crates/honeymaker/src/kraken/requests.rs +286 -0
- data/crates/honeymaker/src/kraken/sign.rs +166 -0
- data/crates/honeymaker/src/kraken/trades.rs +201 -0
- data/crates/honeymaker/src/lib.rs +10 -0
- data/crates/honeymaker/src/num.rs +115 -0
- data/crates/honeymaker/src/semantics.rs +100 -0
- data/crates/honeymaker/src/types.rs +85 -0
- data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
- data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
- data/crates/honeymaker/tests/kraken_vectors.rs +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/crates/honeymaker-client/Cargo.toml +30 -0
- data/crates/honeymaker-client/examples/probe.rs +147 -0
- data/crates/honeymaker-client/src/kraken.rs +514 -0
- data/crates/honeymaker-client/src/lib.rs +4 -0
- data/crates/honeymaker-client/src/transport/connect.rs +583 -0
- data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
- data/crates/honeymaker-client/src/transport/response.rs +169 -0
- data/crates/honeymaker-client/src/transport.rs +467 -0
- data/crates/honeymaker-client/tests/classification.rs +203 -0
- data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
- data/crates/honeymaker-client/tests/response.rs +522 -0
- data/crates/honeymaker-client/tests/support/mod.rs +550 -0
- data/crates/honeymaker-client/tests/transport.rs +828 -0
- data/ext/honeymaker_native/Cargo.toml +16 -0
- data/ext/honeymaker_native/extconf.rb +6 -0
- data/ext/honeymaker_native/src/convert.rs +150 -0
- data/ext/honeymaker_native/src/kraken.rs +362 -0
- data/ext/honeymaker_native/src/lib.rs +90 -0
- data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
- data/lib/honeymaker/clients/bingx.rb +10 -6
- data/lib/honeymaker/clients/bitrue.rb +43 -13
- data/lib/honeymaker/clients/bitvavo.rb +9 -4
- data/lib/honeymaker/clients/bybit.rb +19 -2
- data/lib/honeymaker/clients/coinbase.rb +3 -2
- data/lib/honeymaker/clients/gemini.rb +10 -0
- data/lib/honeymaker/clients/hyperliquid.rb +62 -3
- data/lib/honeymaker/clients/kraken.rb +6 -1
- data/lib/honeymaker/clients/mexc.rb +16 -2
- data/lib/honeymaker/exchanges/coinbase.rb +5 -4
- data/lib/honeymaker/exchanges/mexc.rb +8 -3
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/version.rb +1 -1
- data/lib/honeymaker.rb +12 -2
- 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/test/fixtures/coinbase_products.json +1 -1
- data/test/honeymaker/clients/bingx_client_test.rb +19 -6
- data/test/honeymaker/clients/bitrue_client_test.rb +48 -0
- data/test/honeymaker/clients/bitvavo_client_test.rb +15 -0
- data/test/honeymaker/clients/bybit_client_test.rb +25 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
- data/test/honeymaker/clients/gemini_client_test.rb +15 -0
- data/test/honeymaker/clients/hyperliquid_client_test.rb +120 -4
- data/test/honeymaker/clients/kraken_client_test.rb +15 -0
- data/test/honeymaker/clients/mexc_client_test.rb +12 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
- data/test/honeymaker/exchanges/mexc_test.rb +14 -2
- 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/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- metadata +84 -4
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Honeymaker
|
|
6
|
+
module Native
|
|
7
|
+
# Kraken on the Rust core. Signatures and argument expressions are copied from Clients::Kraken
|
|
8
|
+
# (a test compares signatures). Transport, body encoding and Results are the inherited,
|
|
9
|
+
# unchanged Ruby: connection + URI.encode_www_form + with_rescue. Rust lays out and signs the
|
|
10
|
+
# requests and normalizes the answers.
|
|
11
|
+
class KrakenClient < Honeymaker::Client
|
|
12
|
+
URL = "https://api.kraken.com"
|
|
13
|
+
RATE_LIMITS = { default: 1000, orders: 1000 }.freeze
|
|
14
|
+
UNREADABLE = "Kraken: unreadable response"
|
|
15
|
+
|
|
16
|
+
def self.reset_nonce_state!
|
|
17
|
+
Native.load!
|
|
18
|
+
Ext::Kraken.reset_nonce_state!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def query_orders_info(txid:, trades: nil, userref: nil, consolidate_taker: true)
|
|
22
|
+
result = post_private("query_orders_info", trades: trades, userref: userref, txid: txid, consolidate_taker: consolidate_taker)
|
|
23
|
+
finish("query_orders_info", result) do |orders|
|
|
24
|
+
(result.data["result"] || {}).each { |id, raw| orders[id][:raw] = raw }
|
|
25
|
+
orders
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_order(ordertype:, type:, volume:, pair:, userref: nil, cl_ord_id: nil,
|
|
30
|
+
displayvol: nil, price: nil, price2: nil, trigger: nil, leverage: nil,
|
|
31
|
+
reduce_only: nil, stptype: nil, oflags: [], timeinforce: nil,
|
|
32
|
+
starttm: nil, expiretm: nil, close: nil, close_price: nil,
|
|
33
|
+
close_price2: nil, deadline: nil, validate: nil)
|
|
34
|
+
result = post_private("add_order",
|
|
35
|
+
ordertype: ordertype, type: type, volume: volume, pair: pair, userref: userref,
|
|
36
|
+
cl_ord_id: cl_ord_id, displayvol: displayvol, price: price, price2: price2,
|
|
37
|
+
trigger: trigger, leverage: leverage, reduce_only: reduce_only, stptype: stptype,
|
|
38
|
+
oflags: oflags.any? ? oflags.join(",") : nil,
|
|
39
|
+
timeinforce: timeinforce, starttm: starttm, expiretm: expiretm, close: close,
|
|
40
|
+
close_price: close_price, close_price2: close_price2, deadline: deadline, validate: validate)
|
|
41
|
+
finish("add_order", result) { |order_id| { order_id: order_id, raw: result.data } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cancel_order(txid: nil, cl_ord_id: nil)
|
|
45
|
+
post_private("cancel_order", txid: txid, cl_ord_id: cl_ord_id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get_tradable_asset_pairs(pairs: nil, info: nil, country_code: nil, aclass_base: nil)
|
|
49
|
+
get_public("get_tradable_asset_pairs", pairs: pairs ? pairs.join(",") : nil, info: info,
|
|
50
|
+
country_code: country_code, aclass_base: aclass_base)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_asset_info(assets: nil, aclass: nil)
|
|
54
|
+
get_public("get_asset_info", assets: assets ? assets.join(",") : nil, aclass: aclass)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_ticker_information(pair: nil)
|
|
58
|
+
get_public("get_ticker_information", pair: pair)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_extended_balance
|
|
62
|
+
post_private("get_extended_balance")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_api_key_info
|
|
66
|
+
post_private("get_api_key_info")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_balances
|
|
70
|
+
finish("balances", get_extended_balance) { |balances| balances }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get_ohlc_data(pair:, interval: nil, since: nil)
|
|
74
|
+
get_public("get_ohlc_data", pair: pair, interval: interval, since: since)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def get_trades_history(type: nil, trades: nil, start: nil, end_time: nil, ofs: nil)
|
|
78
|
+
post_private("get_trades_history", type: type, trades: trades, start: start, end_time: end_time, ofs: ofs)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def get_ledgers(asset: nil, type: nil, start: nil, end_time: nil, ofs: nil)
|
|
82
|
+
post_private("get_ledgers", asset: asset, type: type, start: start, end_time: end_time, ofs: ofs)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The paging loop stays Ruby (verbatim legacy) so dig/to_i/include? keep Ruby's
|
|
86
|
+
# coercions; Rust aggregates. A Rust pager comes with stage 2, when Rust callers need one.
|
|
87
|
+
def closed_orders_from_trades(order_ids:, start: nil, max_pages: 20)
|
|
88
|
+
wanted = Array(order_ids)
|
|
89
|
+
return Result::Success.new({}) if wanted.empty?
|
|
90
|
+
|
|
91
|
+
by_order = Hash.new { |h, k| h[k] = [] }
|
|
92
|
+
seen = {}
|
|
93
|
+
offset = 0
|
|
94
|
+
pages = 0
|
|
95
|
+
loop do
|
|
96
|
+
result = get_trades_history(start: start, ofs: offset)
|
|
97
|
+
return result if result.failure?
|
|
98
|
+
return unreadable unless result.data.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
errors = result.data["error"]
|
|
101
|
+
return Result::Failure.new(*errors) if errors.is_a?(Array) && errors.any?
|
|
102
|
+
|
|
103
|
+
trades = result.data.dig("result", "trades") || {}
|
|
104
|
+
break if trades.empty?
|
|
105
|
+
|
|
106
|
+
trades.each_pair do |trade_id, t|
|
|
107
|
+
# Offset paging over a list that grows at the top can serve a trade twice: count it once.
|
|
108
|
+
next if seen.key?(trade_id)
|
|
109
|
+
|
|
110
|
+
seen[trade_id] = true
|
|
111
|
+
otxid = t["ordertxid"]
|
|
112
|
+
by_order[otxid] << t if wanted.include?(otxid)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Do NOT early-exit when each id has been *seen* — a partial fill can have trades on
|
|
116
|
+
# later pages. Page the whole [start, now] window (bounded by `start` + max_pages).
|
|
117
|
+
pages += 1
|
|
118
|
+
offset += trades.size
|
|
119
|
+
count = result.data.dig("result", "count").to_i
|
|
120
|
+
break if offset >= count || pages >= max_pages
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Native.load!
|
|
124
|
+
Result::Success.new(Ext::Kraken.aggregate_trades(by_order))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def get_withdraw_addresses(asset: nil, method: nil)
|
|
128
|
+
post_private("get_withdraw_addresses", asset: asset, method: method)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get_withdraw_methods(asset: nil)
|
|
132
|
+
post_private("get_withdraw_methods", asset: asset)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def withdraw(asset:, key:, amount:, address: nil)
|
|
136
|
+
post_private("withdraw", asset: asset, key: key, amount: amount, address: address)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def get_earn_allocations(ascending: nil, converted_asset: nil, hide_zero_allocations: nil)
|
|
140
|
+
post_private("get_earn_allocations", ascending: ascending, converted_asset: converted_asset,
|
|
141
|
+
hide_zero_allocations: hide_zero_allocations)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def validate_trading_credentials
|
|
147
|
+
result = get_extended_balance
|
|
148
|
+
return Result::Failure.new("Invalid trading credentials") if result.failure?
|
|
149
|
+
|
|
150
|
+
errors = result.data["error"]
|
|
151
|
+
if errors.is_a?(Array) && errors.none?
|
|
152
|
+
Result::Success.new(true)
|
|
153
|
+
else
|
|
154
|
+
Result::Failure.new("Invalid trading credentials")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate_read_credentials
|
|
159
|
+
validate_trading_credentials
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def native
|
|
163
|
+
@native ||= begin
|
|
164
|
+
Native.load!
|
|
165
|
+
Ext::Kraken.new(@api_key, @api_secret)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The legacy post_private: Rust lays out the pairs (nonce first) and signs; Ruby encodes.
|
|
170
|
+
def post_private(op, params = {})
|
|
171
|
+
with_rescue do
|
|
172
|
+
path, pairs = native.build_post(op, params.transform_keys(&:to_s))
|
|
173
|
+
response = connection.post do |req|
|
|
174
|
+
req.url path
|
|
175
|
+
req.body = URI.encode_www_form(pairs)
|
|
176
|
+
req.headers = native.sign(req.path, req.body)
|
|
177
|
+
end
|
|
178
|
+
response.body
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The legacy get_public: Rust lays out the params; Faraday encodes them.
|
|
183
|
+
def get_public(op, params = {})
|
|
184
|
+
with_rescue do
|
|
185
|
+
path, query, headers = native.build_get(op, params.transform_keys(&:to_s))
|
|
186
|
+
response = connection.get do |req|
|
|
187
|
+
req.url path
|
|
188
|
+
req.headers = headers
|
|
189
|
+
req.params = query
|
|
190
|
+
end
|
|
191
|
+
response.body
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def finish(op, result)
|
|
196
|
+
return result if result.failure?
|
|
197
|
+
# Guard in Ruby, before any conversion: a binary maintenance page must answer like the
|
|
198
|
+
# hardened legacy, not fail inside the converter.
|
|
199
|
+
return unreadable unless result.data.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
verdict = native.finish(op, result.data)
|
|
202
|
+
case verdict[0]
|
|
203
|
+
when "ok" then Result::Success.new(yield(verdict[1]))
|
|
204
|
+
when "venue" then Result::Failure.new(*result.data["error"])
|
|
205
|
+
when "unreadable" then unreadable
|
|
206
|
+
else raise ShapeError, "unexpected verdict #{verdict[0].inspect}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def unreadable
|
|
211
|
+
Result::Failure.new(UNREADABLE, data: { unreadable: true })
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class KrakenExchange < Honeymaker::Exchange
|
|
216
|
+
BASE_URL = "https://api.kraken.com"
|
|
217
|
+
|
|
218
|
+
def classify_error(message)
|
|
219
|
+
return nil if message.nil?
|
|
220
|
+
|
|
221
|
+
Native.load!
|
|
222
|
+
Ext::Kraken.classify_error(message)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def get_tickers_info
|
|
226
|
+
with_rescue do
|
|
227
|
+
response = connection.get("/0/public/AssetPairs", { aclass_base: "all" })
|
|
228
|
+
raise ShapeError, "Kraken: catalogue body is not an object" unless response.body.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
verdict = native.finish("tickers_info", response.body)
|
|
231
|
+
return Result::Failure.new(*response.body["error"]) if verdict[0] == "venue"
|
|
232
|
+
|
|
233
|
+
verdict[1].each { |t| t[:minimum_quote_size] = t[:minimum_quote_size].to_s }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def get_bid_ask(symbol)
|
|
238
|
+
with_rescue do
|
|
239
|
+
response = connection.get("/0/public/Ticker") { |req| req.params = { pair: symbol } }
|
|
240
|
+
raise ShapeError, "Kraken: ticker body is not an object" unless response.body.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
verdict = native.finish("bid_ask", response.body)
|
|
243
|
+
raise StandardError, response.body["error"].first if verdict[0] == "venue"
|
|
244
|
+
|
|
245
|
+
verdict[1]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def native
|
|
252
|
+
@native ||= begin
|
|
253
|
+
Native.load!
|
|
254
|
+
Ext::Kraken.new(nil, nil)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def connection
|
|
259
|
+
@connection ||= build_connection(BASE_URL)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Honeymaker
|
|
4
|
+
# Rust-backed venue logic. A process picks its backend per exchange once, when honeymaker is
|
|
5
|
+
# required: HONEYMAKER_NATIVE=kraken. With the flag off the extension is never loaded.
|
|
6
|
+
module Native
|
|
7
|
+
# A venue payload whose shape legacy would have tripped over with an incidental NoMethodError
|
|
8
|
+
# or TypeError. Still a StandardError, which is all consumers rescue.
|
|
9
|
+
class ShapeError < StandardError; end
|
|
10
|
+
|
|
11
|
+
SUPPORTED = %w[kraken].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def enabled
|
|
15
|
+
@enabled ||= begin
|
|
16
|
+
names = ENV.fetch("HONEYMAKER_NATIVE", "").split(",").map { |name| name.strip.downcase }.reject(&:empty?).uniq
|
|
17
|
+
unsupported = names - SUPPORTED
|
|
18
|
+
unless unsupported.empty?
|
|
19
|
+
raise ArgumentError, "Unsupported HONEYMAKER_NATIVE names: #{unsupported.join(', ')}; supported names: #{SUPPORTED.join(', ')}"
|
|
20
|
+
end
|
|
21
|
+
names.freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enabled?(name)
|
|
26
|
+
name = name.to_s.strip.downcase
|
|
27
|
+
names = enabled
|
|
28
|
+
SUPPORTED.include?(name) && names.include?(name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load!
|
|
32
|
+
return if defined?(Ext)
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
require "honeymaker/#{RUBY_VERSION[/\A\d+\.\d+/]}/honeymaker_native" # fat platform gem
|
|
36
|
+
rescue LoadError
|
|
37
|
+
require "honeymaker/honeymaker_native" # compiled from source / rake compile
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/honeymaker/version.rb
CHANGED
data/lib/honeymaker.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "honeymaker/result"
|
|
|
9
9
|
require_relative "honeymaker/utils"
|
|
10
10
|
require_relative "honeymaker/exchange"
|
|
11
11
|
require_relative "honeymaker/client"
|
|
12
|
+
require_relative "honeymaker/native"
|
|
12
13
|
require_relative "honeymaker/clients/binance"
|
|
13
14
|
require_relative "honeymaker/clients/binance_us"
|
|
14
15
|
require_relative "honeymaker/clients/kraken"
|
|
@@ -37,13 +38,18 @@ require_relative "honeymaker/exchanges/hyperliquid"
|
|
|
37
38
|
require_relative "honeymaker/exchanges/bingx"
|
|
38
39
|
require_relative "honeymaker/exchanges/bitrue"
|
|
39
40
|
|
|
41
|
+
if Honeymaker::Native.enabled?("kraken")
|
|
42
|
+
Honeymaker::Native.load! # fail at boot, not at the first order
|
|
43
|
+
require_relative "honeymaker/native/kraken"
|
|
44
|
+
end
|
|
45
|
+
|
|
40
46
|
module Honeymaker
|
|
41
47
|
class Error < StandardError; end
|
|
42
48
|
|
|
43
49
|
EXCHANGES = {
|
|
44
50
|
"binance" => Exchanges::Binance,
|
|
45
51
|
"binance_us" => Exchanges::BinanceUs,
|
|
46
|
-
"kraken" => Exchanges::Kraken,
|
|
52
|
+
"kraken" => Native.enabled?("kraken") ? Native::KrakenExchange : Exchanges::Kraken,
|
|
47
53
|
"coinbase" => Exchanges::Coinbase,
|
|
48
54
|
"mexc" => Exchanges::Mexc,
|
|
49
55
|
"gemini" => Exchanges::Gemini,
|
|
@@ -59,7 +65,7 @@ module Honeymaker
|
|
|
59
65
|
CLIENTS = {
|
|
60
66
|
"binance" => Clients::Binance,
|
|
61
67
|
"binance_us" => Clients::BinanceUs,
|
|
62
|
-
"kraken" => Clients::Kraken,
|
|
68
|
+
"kraken" => Native.enabled?("kraken") ? Native::KrakenClient : Clients::Kraken,
|
|
63
69
|
"coinbase" => Clients::Coinbase,
|
|
64
70
|
"bybit" => Clients::Bybit,
|
|
65
71
|
"mexc" => Clients::Mexc,
|
|
@@ -73,6 +79,10 @@ module Honeymaker
|
|
|
73
79
|
"kraken_futures" => Clients::KrakenFutures
|
|
74
80
|
}.freeze
|
|
75
81
|
|
|
82
|
+
def self.backend(name)
|
|
83
|
+
Native.enabled?(name) ? :native : :ruby
|
|
84
|
+
end
|
|
85
|
+
|
|
76
86
|
def self.exchange(name)
|
|
77
87
|
klass = EXCHANGES[name.to_s]
|
|
78
88
|
raise Error, "Unknown exchange: #{name}" unless klass
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Live, read-only comparison of honeymaker-client (examples/probe) against the gem on the real Kraken
|
|
4
|
+
# API, back to back. Places NO order: AddOrder runs only with validate=true, and only when
|
|
5
|
+
# KRAKEN_KEY_CAN_TRADE=1.
|
|
6
|
+
# bundle exec ruby -Ilib script/parity/kraken_client_live.rb # public checks
|
|
7
|
+
# KRAKEN_API_KEY=… KRAKEN_API_SECRET=… KRAKEN_KNOWN_CL_ORD_ID=… KRAKEN_KNOWN_TXID=… \
|
|
8
|
+
# [KRAKEN_KEY_CAN_TRADE=1] [LOCAL_PROXY=1] bundle exec ruby -Ilib script/parity/kraken_client_live.rb
|
|
9
|
+
# With a key, the known order (one placed with that cl_ord_id) is mandatory: it is the only proof
|
|
10
|
+
# of Kraken's cl_ord_id filter, count and echo that order_by_client_id's absence rests on.
|
|
11
|
+
# The key must be one lent for this check, never a bot's key: nonces are per key.
|
|
12
|
+
require "honeymaker"
|
|
13
|
+
require "bigdecimal"
|
|
14
|
+
require "json"
|
|
15
|
+
require "open3"
|
|
16
|
+
require "securerandom"
|
|
17
|
+
require "socket"
|
|
18
|
+
require "time"
|
|
19
|
+
|
|
20
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
21
|
+
system("cargo", "build", "--locked", "--quiet", "-p", "honeymaker-client", "--example", "probe", chdir: ROOT, exception: true)
|
|
22
|
+
PROBE = File.join(ROOT, "target/debug/examples/probe")
|
|
23
|
+
KEY = ENV["KRAKEN_API_KEY"]
|
|
24
|
+
SECRET = ENV["KRAKEN_API_SECRET"]
|
|
25
|
+
PRIVATE = !KEY.to_s.empty?
|
|
26
|
+
KNOWN_CL_ORD_ID = ENV["KRAKEN_KNOWN_CL_ORD_ID"].to_s
|
|
27
|
+
KNOWN_TXID = ENV["KRAKEN_KNOWN_TXID"].to_s
|
|
28
|
+
if PRIVATE && (KNOWN_CL_ORD_ID.empty? || KNOWN_TXID.empty?)
|
|
29
|
+
abort "FAILED: a private run needs KRAKEN_KNOWN_CL_ORD_ID and KRAKEN_KNOWN_TXID (an order placed with that cl_ord_id)"
|
|
30
|
+
end
|
|
31
|
+
ASSET_MAP = Honeymaker::Clients::Kraken::ASSET_MAP
|
|
32
|
+
FAILURES = []
|
|
33
|
+
|
|
34
|
+
def rust(call, args, proxy)
|
|
35
|
+
out, st = Open3.capture2(PROBE, stdin_data: JSON.generate({ call: call, args: args, proxy: proxy, api_key: KEY, api_secret: SECRET }))
|
|
36
|
+
raise "probe failed" unless st.success?
|
|
37
|
+
|
|
38
|
+
sleep 1 # Kraken's private counter decays ~0.33/s
|
|
39
|
+
JSON.parse(out)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def legacy(proxy) = Honeymaker::Clients::Kraken.new(api_key: KEY, api_secret: SECRET, proxy: proxy)
|
|
43
|
+
def raw_post(proxy, path, params) = legacy(proxy).then { |c| c.send(:post_private, path, { nonce: c.send(:nonce), **params }) }.tap { sleep 1 }
|
|
44
|
+
|
|
45
|
+
def check(name, ok, detail = nil)
|
|
46
|
+
puts "#{ok ? 'PASS' : 'FAIL'} #{name}#{" — #{detail}" if detail}"
|
|
47
|
+
FAILURES << name unless ok
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def d(x) = x.nil? ? nil : BigDecimal(x.to_s)
|
|
51
|
+
|
|
52
|
+
# deltabadger's get_balances for one asset: last matching entry wins (Ruling R9). Rails digs
|
|
53
|
+
# result and hold_trade with dig_or_raise, so a nil there is :raise (the client answers Ambiguous).
|
|
54
|
+
def rails_free(result, sym)
|
|
55
|
+
return :raise if result.nil?
|
|
56
|
+
|
|
57
|
+
result.reduce(BigDecimal("0")) do |free, (code, b)|
|
|
58
|
+
base = code.split(".").first
|
|
59
|
+
next free unless (ASSET_MAP[base] || base) == sym
|
|
60
|
+
return :raise if b["balance"].nil? || b["hold_trade"].nil?
|
|
61
|
+
|
|
62
|
+
BigDecimal(b["balance"].to_s) - BigDecimal(b["hold_trade"].to_s)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run(proxy)
|
|
67
|
+
tag = proxy ? " via CONNECT proxy" : ""
|
|
68
|
+
l = legacy(proxy).get_ticker_information(pair: "XBTEUR")
|
|
69
|
+
r = rust("prices", { pair: "XBTEUR" }, proxy)
|
|
70
|
+
if l.success? && r["class"] == "ok"
|
|
71
|
+
info = l.data["result"].values.first
|
|
72
|
+
rp = r["value"].transform_values { |v| BigDecimal(v) }
|
|
73
|
+
sane = rp.values.all?(&:positive?) && rp["bid"] <= rp["ask"]
|
|
74
|
+
near = ((rp["last"] - BigDecimal(info["c"][0])).abs / rp["last"]) < BigDecimal("0.01")
|
|
75
|
+
check("Ticker#{tag}", sane && near, "legacy last #{info['c'][0]}, rust last #{r['value']['last']}")
|
|
76
|
+
else
|
|
77
|
+
check("Ticker#{tag}", false, "legacy #{l.errors.inspect}, rust #{r.inspect}")
|
|
78
|
+
end
|
|
79
|
+
return unless PRIVATE
|
|
80
|
+
|
|
81
|
+
bal = legacy(proxy).get_extended_balance.data["result"]
|
|
82
|
+
syms = bal.to_h.keys.map { |c| b = c.split(".").first; ASSET_MAP[b] || b }.uniq | %w[EUR USD]
|
|
83
|
+
syms.each do |sym|
|
|
84
|
+
r = rust("balance", { asset: sym }, proxy)
|
|
85
|
+
want = rails_free(bal, sym)
|
|
86
|
+
if want == :raise
|
|
87
|
+
check("balance #{sym}#{tag} (Rails raises)", r["class"] == "ambiguous", r.inspect)
|
|
88
|
+
else
|
|
89
|
+
check("balance #{sym}#{tag}", r["class"] == "ok" && d(r["value"]) == want, "#{r['value']} vs #{want.to_s('F')}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
closed = raw_post(proxy, "/0/private/ClosedOrders", { ofs: 0 }).data["result"]
|
|
94
|
+
txids = closed["closed"].keys.first(5)
|
|
95
|
+
if txids.any?
|
|
96
|
+
q = legacy(proxy).query_orders_info(txid: txids.join(",")).data
|
|
97
|
+
r = rust("orders", { txids: txids }, proxy)
|
|
98
|
+
same = r["class"] == "ok" && r["value"].all? do |o|
|
|
99
|
+
g = q[o["txid"]]
|
|
100
|
+
g && g[:status].to_s == o["status"] && d(g[:price]) == d(o["price"]) && d(g[:amount]) == d(o["amount"]) &&
|
|
101
|
+
d(g[:quote_amount]) == d(o["quote_amount"]) && d(g[:amount_exec]) == d(o["amount_exec"]) &&
|
|
102
|
+
d(g[:quote_amount_exec]) == d(o["quote_amount_exec"]) && (g[:order_type] == :limit) == o["limit"] &&
|
|
103
|
+
(g[:side] == :sell) == o["sell"]
|
|
104
|
+
end
|
|
105
|
+
check("QueryOrders#{tag}", same && r["value"].size == q.size)
|
|
106
|
+
|
|
107
|
+
since = Time.now.to_i - (90 * 86_400)
|
|
108
|
+
lf = legacy(proxy).closed_orders_from_trades(order_ids: txids, start: since).data
|
|
109
|
+
r = rust("fills_from_trades", { txids: txids, since: Time.at(since).utc.iso8601 }, proxy)
|
|
110
|
+
same = r["class"] == "ok" && r["value"].size == lf.size && r["value"].all? do |o|
|
|
111
|
+
a = lf[o["txid"]]
|
|
112
|
+
a && d(a[:amount_exec]) == d(o["amount_exec"]) && d(a[:quote_amount_exec]) == d(o["quote_amount_exec"]) &&
|
|
113
|
+
(a[:price].nil? ? o["price"].nil? : (d(o["price"]) - a[:price]).abs <= a[:price].abs * BigDecimal("1e-30"))
|
|
114
|
+
end
|
|
115
|
+
check("TradesHistory aggregate#{tag}", same)
|
|
116
|
+
else
|
|
117
|
+
puts "SKIP QueryOrders/TradesHistory: the account has no closed orders"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nobody = SecureRandom.uuid
|
|
121
|
+
r = rust("order_by_client_id", { cl_ord_id: nobody, since: (Time.now.utc - 86_400).iso8601 }, proxy)
|
|
122
|
+
check("order_by_client_id(unknown) is a complete absence#{tag}", r["class"] == "ok" && r["value"].nil?, r.inspect)
|
|
123
|
+
filtered = raw_post(proxy, "/0/private/ClosedOrders", { cl_ord_id: nobody, ofs: 0 }).data["result"]
|
|
124
|
+
if closed["count"].to_i.positive?
|
|
125
|
+
check("ClosedOrders honours the cl_ord_id filter#{tag}", filtered["count"].to_i.zero? && filtered["closed"].to_h.empty?)
|
|
126
|
+
else
|
|
127
|
+
puts "SKIP cl_ord_id filter proof: the account has no closed orders"
|
|
128
|
+
end
|
|
129
|
+
r = rust("order_by_client_id", { cl_ord_id: KNOWN_CL_ORD_ID, since: (Time.now.utc - (30 * 86_400)).iso8601 }, proxy)
|
|
130
|
+
check("order_by_client_id finds a real order#{tag}", r["class"] == "ok" && r.dig("value", "txid") == KNOWN_TXID, r.inspect)
|
|
131
|
+
open_known = raw_post(proxy, "/0/private/OpenOrders", { cl_ord_id: KNOWN_CL_ORD_ID }).data["result"]["open"].to_h
|
|
132
|
+
if open_known.key?(KNOWN_TXID)
|
|
133
|
+
check("OpenOrders filters by cl_ord_id and echoes it#{tag}",
|
|
134
|
+
open_known.keys == [KNOWN_TXID] && open_known[KNOWN_TXID]["cl_ord_id"].to_s == KNOWN_CL_ORD_ID, open_known.keys.inspect)
|
|
135
|
+
else
|
|
136
|
+
puts "SKIP OpenOrders echo: the known order is no longer open"
|
|
137
|
+
closed_known = raw_post(proxy, "/0/private/ClosedOrders", { cl_ord_id: KNOWN_CL_ORD_ID, ofs: 0 }).data["result"]
|
|
138
|
+
check("ClosedOrders filters by cl_ord_id, counts and echoes it#{tag}",
|
|
139
|
+
closed_known["count"].to_i == 1 && closed_known["closed"].to_h.keys == [KNOWN_TXID] &&
|
|
140
|
+
closed_known["closed"][KNOWN_TXID]["cl_ord_id"].to_s == KNOWN_CL_ORD_ID, closed_known.inspect)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return unless ENV["KRAKEN_KEY_CAN_TRADE"] == "1"
|
|
144
|
+
|
|
145
|
+
deadline = -> { (Time.now.utc + 30).iso8601(3) }
|
|
146
|
+
l = legacy(proxy).add_order(ordertype: "market", type: "buy", volume: "0.0001", pair: "XBTEUR",
|
|
147
|
+
cl_ord_id: SecureRandom.uuid, deadline: deadline.call, validate: true)
|
|
148
|
+
sleep 1
|
|
149
|
+
r = rust("add_order_validate", { pair: "XBTEUR", kind: "market", volume: "0.0001", quote_volume: false,
|
|
150
|
+
cl_ord_id: SecureRandom.uuid, deadline: deadline.call }, proxy)
|
|
151
|
+
check("AddOrder validate=true with cl_ord_id and deadline#{tag}", l.success? && r["class"] == "ok", "legacy #{l.errors.inspect}, rust #{r.inspect}")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A local CONNECT proxy that only tunnels to api.kraken.com:443, recording each CONNECT line.
|
|
155
|
+
def local_proxy
|
|
156
|
+
seen = []
|
|
157
|
+
tcp = TCPServer.new("127.0.0.1", 0)
|
|
158
|
+
Thread.new do
|
|
159
|
+
loop do
|
|
160
|
+
s = tcp.accept
|
|
161
|
+
Thread.new do
|
|
162
|
+
head = +""
|
|
163
|
+
head << s.readpartial(4096) until head.include?("\r\n\r\n")
|
|
164
|
+
line = head.lines.first.strip
|
|
165
|
+
seen << line
|
|
166
|
+
raise "refused #{line}" unless line == "CONNECT api.kraken.com:443 HTTP/1.1"
|
|
167
|
+
|
|
168
|
+
up = TCPSocket.new("api.kraken.com", 443)
|
|
169
|
+
s.write "HTTP/1.1 200 Connection established\r\n\r\n"
|
|
170
|
+
t = Thread.new { IO.copy_stream(s, up) rescue nil; up.close_write rescue nil }
|
|
171
|
+
IO.copy_stream(up, s) rescue nil
|
|
172
|
+
t.join(1)
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil
|
|
175
|
+
ensure
|
|
176
|
+
s.close rescue nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
["http://127.0.0.1:#{tcp.addr[1]}", seen]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
puts "private checks: #{PRIVATE ? 'on' : 'off (no key)'}; AddOrder validate: #{ENV['KRAKEN_KEY_CAN_TRADE'] == '1' ? 'on' : 'off'}"
|
|
184
|
+
run(nil)
|
|
185
|
+
if ENV["LOCAL_PROXY"] == "1"
|
|
186
|
+
url, seen = local_proxy
|
|
187
|
+
run(url)
|
|
188
|
+
check("both backends tunnelled through the proxy", seen.size >= 2 && seen.uniq == ["CONNECT api.kraken.com:443 HTTP/1.1"], seen.inspect)
|
|
189
|
+
end
|
|
190
|
+
abort "FAILED: #{FAILURES.join(', ')}" if FAILURES.any?
|
|
191
|
+
puts "live comparison passed"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Read-only live comparison, legacy vs native, back to back against the real Kraken API.
|
|
4
|
+
# KRAKEN_API_KEY=… KRAKEN_API_SECRET=… KRAKEN_TXIDS=O1,O2 bundle exec ruby -Ilib script/parity/kraken_live.rb
|
|
5
|
+
# Places NO order: AddOrder runs with validate: true.
|
|
6
|
+
require "honeymaker"
|
|
7
|
+
Honeymaker::Native.load!
|
|
8
|
+
require "honeymaker/native/kraken"
|
|
9
|
+
|
|
10
|
+
# Public checks always run (no key needed). Private checks run only with a key that is OURS or
|
|
11
|
+
# lent for this purpose (a separate key: Kraken nonces are per key, so a bot's own key would race).
|
|
12
|
+
key, secret, proxy = ENV["KRAKEN_API_KEY"], ENV["KRAKEN_API_SECRET"], ENV["PROXY"]
|
|
13
|
+
private_checks = !key.to_s.empty?
|
|
14
|
+
txids = ENV.fetch("KRAKEN_TXIDS", "").split(",").reject(&:empty?)
|
|
15
|
+
abort "KRAKEN_TXIDS must name at least one order when a key is given" if private_checks && txids.empty?
|
|
16
|
+
trade = ENV["KRAKEN_KEY_CAN_TRADE"] == "1" # only then run AddOrder validate: true
|
|
17
|
+
legacy = Honeymaker::Clients::Kraken.new(api_key: key, api_secret: secret, proxy: proxy)
|
|
18
|
+
native = Honeymaker::Native::KrakenClient.new(api_key: key, api_secret: secret, proxy: proxy)
|
|
19
|
+
|
|
20
|
+
def shape(v)
|
|
21
|
+
case v
|
|
22
|
+
when Hash then v.to_h { |k, x| [k, shape(x)] }
|
|
23
|
+
when Array then v.map { |x| shape(x) }
|
|
24
|
+
else v.class
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A raw Kraken envelope that really answered: error == [] and a non-empty result of the given class.
|
|
29
|
+
def envelope?(d, klass = Hash)
|
|
30
|
+
d.is_a?(Hash) && d["error"] == [] && d["result"].is_a?(klass) && !d["result"].empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
DYNAMIC = { "GetApiKeyInfo" => ->(d) { d.merge("result" => d["result"].except("nonce", "lastUsed")) } }.freeze
|
|
34
|
+
|
|
35
|
+
RAW = ->(d) { envelope?(d) }
|
|
36
|
+
checks = [
|
|
37
|
+
[:equal, "AssetPairs", ->(c) { c.get_tradable_asset_pairs(aclass_base: "all") }, RAW],
|
|
38
|
+
[:equal, "Assets", ->(c) { c.get_asset_info }, RAW],
|
|
39
|
+
[:equal, "BalanceEx", ->(c) { c.get_extended_balance }, RAW],
|
|
40
|
+
[:equal, "balances", ->(c) { c.get_balances }, ->(d) { d.is_a?(Hash) && d.values.all? { |b| b[:free].is_a?(BigDecimal) } }],
|
|
41
|
+
[:equal, "GetApiKeyInfo", ->(c) { c.get_api_key_info }, RAW],
|
|
42
|
+
[:equal, "TradesHistory", ->(c) { c.get_trades_history }, ->(d) { envelope?(d) && d["result"]["trades"].is_a?(Hash) }],
|
|
43
|
+
[:equal, "Ledgers", ->(c) { c.get_ledgers }, ->(d) { envelope?(d) && d["result"]["ledger"].is_a?(Hash) }],
|
|
44
|
+
[:equal, "QueryOrders", ->(c) { c.query_orders_info(txid: txids.join(",")) }, ->(d) { d.is_a?(Hash) && txids.all? { |t| d.key?(t) } }],
|
|
45
|
+
[:equal, "closed_orders_from_trades", ->(c) { c.closed_orders_from_trades(order_ids: txids, start: Time.now.to_i - (90 * 86_400)) }, ->(d) { d.is_a?(Hash) }],
|
|
46
|
+
[:equal, "validate", ->(c) { c.validate(:trading) }, ->(d) { d == true }],
|
|
47
|
+
[:shape, "Ticker", ->(c) { c.get_ticker_information(pair: "XBTUSD") }, RAW],
|
|
48
|
+
[:shape, "OHLC", ->(c) { c.get_ohlc_data(pair: "XBTUSD", interval: 1440) }, RAW],
|
|
49
|
+
[:shape, "AddOrder validate", ->(c) { c.add_order(ordertype: "market", type: "buy", volume: "0.0001", pair: "XBTUSD", validate: true) },
|
|
50
|
+
->(d) { d.is_a?(Hash) && envelope?(d[:raw]) && d[:raw]["result"]["descr"].is_a?(Hash) }]
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
failures = 0
|
|
54
|
+
public_names = %w[AssetPairs Assets Ticker OHLC]
|
|
55
|
+
checks.select! { |_, name, _, _| public_names.include?(name) || private_checks }
|
|
56
|
+
checks.reject! { |_, name, _, _| name == "AddOrder validate" && !trade }
|
|
57
|
+
puts "private checks: #{private_checks ? 'on' : 'off (no key)'}; AddOrder validate: #{trade ? 'on' : 'off'}"
|
|
58
|
+
checks.each do |mode, name, call, valid|
|
|
59
|
+
l = call.call(legacy)
|
|
60
|
+
sleep 1 # Kraken's private counter decays ~0.33/s; keep the pair adjacent but polite
|
|
61
|
+
n = call.call(native)
|
|
62
|
+
norm = DYNAMIC.fetch(name, ->(d) { d })
|
|
63
|
+
ok = l.success? && n.success? && valid.call(l.data) && valid.call(n.data) &&
|
|
64
|
+
(mode == :equal ? norm.call(l.data) == norm.call(n.data) : shape(l.data) == shape(n.data))
|
|
65
|
+
failures += 1 unless ok
|
|
66
|
+
puts format("%-26s %-5s %s", name, mode, ok ? "OK" : "FAIL\n legacy=#{l.inspect[0, 500]}\n native=#{n.inspect[0, 500]}")
|
|
67
|
+
sleep 1
|
|
68
|
+
end
|
|
69
|
+
puts failures.zero? ? "LIVE PARITY PASSED" : "LIVE PARITY FAILED: #{failures}"
|
|
70
|
+
exit(failures.zero? ? 0 : 1)
|