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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +19 -4
  5. data/Cargo.lock +1078 -0
  6. data/Cargo.toml +14 -0
  7. data/README.md +39 -0
  8. data/Rakefile +28 -16
  9. data/crates/honeymaker/Cargo.toml +18 -0
  10. data/crates/honeymaker/src/encode.rs +106 -0
  11. data/crates/honeymaker/src/kraken/lookup.rs +140 -0
  12. data/crates/honeymaker/src/kraken/mod.rs +5 -0
  13. data/crates/honeymaker/src/kraken/normalize.rs +536 -0
  14. data/crates/honeymaker/src/kraken/requests.rs +286 -0
  15. data/crates/honeymaker/src/kraken/sign.rs +166 -0
  16. data/crates/honeymaker/src/kraken/trades.rs +201 -0
  17. data/crates/honeymaker/src/lib.rs +10 -0
  18. data/crates/honeymaker/src/num.rs +115 -0
  19. data/crates/honeymaker/src/semantics.rs +100 -0
  20. data/crates/honeymaker/src/types.rs +85 -0
  21. data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
  22. data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
  23. data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
  24. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  25. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  26. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  27. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  28. data/crates/honeymaker-client/Cargo.toml +30 -0
  29. data/crates/honeymaker-client/examples/probe.rs +147 -0
  30. data/crates/honeymaker-client/src/kraken.rs +514 -0
  31. data/crates/honeymaker-client/src/lib.rs +4 -0
  32. data/crates/honeymaker-client/src/transport/connect.rs +583 -0
  33. data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
  34. data/crates/honeymaker-client/src/transport/response.rs +169 -0
  35. data/crates/honeymaker-client/src/transport.rs +467 -0
  36. data/crates/honeymaker-client/tests/classification.rs +203 -0
  37. data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
  38. data/crates/honeymaker-client/tests/response.rs +522 -0
  39. data/crates/honeymaker-client/tests/support/mod.rs +550 -0
  40. data/crates/honeymaker-client/tests/transport.rs +828 -0
  41. data/ext/honeymaker_native/Cargo.toml +16 -0
  42. data/ext/honeymaker_native/extconf.rb +6 -0
  43. data/ext/honeymaker_native/src/convert.rs +150 -0
  44. data/ext/honeymaker_native/src/kraken.rs +362 -0
  45. data/ext/honeymaker_native/src/lib.rs +90 -0
  46. data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
  47. data/lib/honeymaker/clients/bingx.rb +10 -6
  48. data/lib/honeymaker/clients/bitrue.rb +43 -13
  49. data/lib/honeymaker/clients/bitvavo.rb +9 -4
  50. data/lib/honeymaker/clients/bybit.rb +19 -2
  51. data/lib/honeymaker/clients/coinbase.rb +3 -2
  52. data/lib/honeymaker/clients/gemini.rb +10 -0
  53. data/lib/honeymaker/clients/hyperliquid.rb +62 -3
  54. data/lib/honeymaker/clients/kraken.rb +6 -1
  55. data/lib/honeymaker/clients/mexc.rb +16 -2
  56. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  57. data/lib/honeymaker/exchanges/mexc.rb +8 -3
  58. data/lib/honeymaker/native/kraken.rb +263 -0
  59. data/lib/honeymaker/native.rb +42 -0
  60. data/lib/honeymaker/version.rb +1 -1
  61. data/lib/honeymaker.rb +12 -2
  62. data/script/parity/kraken_client_live.rb +191 -0
  63. data/script/parity/kraken_live.rb +70 -0
  64. data/script/parity/kraken_transport.rb +604 -0
  65. data/script/parity/resolve.c +49 -0
  66. data/script/release/push_gems.sh +28 -0
  67. data/script/vectors/kraken.rb +50 -0
  68. data/script/vectors/kraken_stage2.rb +146 -0
  69. data/test/fixtures/coinbase_products.json +1 -1
  70. data/test/honeymaker/clients/bingx_client_test.rb +19 -6
  71. data/test/honeymaker/clients/bitrue_client_test.rb +48 -0
  72. data/test/honeymaker/clients/bitvavo_client_test.rb +15 -0
  73. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  74. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  75. data/test/honeymaker/clients/gemini_client_test.rb +15 -0
  76. data/test/honeymaker/clients/hyperliquid_client_test.rb +120 -4
  77. data/test/honeymaker/clients/kraken_client_test.rb +15 -0
  78. data/test/honeymaker/clients/mexc_client_test.rb +12 -0
  79. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  80. data/test/honeymaker/exchanges/mexc_test.rb +14 -2
  81. data/test/native/backend_switch_test.rb +53 -0
  82. data/test/native/convert_test.rb +137 -0
  83. data/test/native/gem_isolation_test.rb +17 -0
  84. data/test/native/loader_test.rb +29 -0
  85. data/test/native/lockstep_test.rb +23 -0
  86. data/test/native/signing_smoke_test.rb +28 -0
  87. data/test/parity/kraken_client_parity_test.rb +398 -0
  88. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  89. data/test/release/branch_hardening_test.rb +34 -0
  90. data/test/release/push_gems_test.rb +79 -0
  91. data/test/smoke.rb +24 -0
  92. data/test/support/kraken_signing_smoke.rb +21 -0
  93. data/test/support/parity_helper.rb +62 -0
  94. data/test/support/venue_double.rb +100 -0
  95. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.12.3"
4
+ VERSION = "0.12.5"
5
5
  end
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)