honeymaker 0.12.2 → 0.12.4

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 (81) 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/bybit.rb +19 -2
  48. data/lib/honeymaker/clients/coinbase.rb +3 -2
  49. data/lib/honeymaker/clients/kraken.rb +17 -1
  50. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  51. data/lib/honeymaker/native/kraken.rb +263 -0
  52. data/lib/honeymaker/native.rb +42 -0
  53. data/lib/honeymaker/version.rb +1 -1
  54. data/lib/honeymaker.rb +12 -2
  55. data/script/parity/kraken_client_live.rb +191 -0
  56. data/script/parity/kraken_live.rb +70 -0
  57. data/script/parity/kraken_transport.rb +604 -0
  58. data/script/parity/resolve.c +49 -0
  59. data/script/release/push_gems.sh +28 -0
  60. data/script/vectors/kraken.rb +50 -0
  61. data/script/vectors/kraken_stage2.rb +146 -0
  62. data/test/fixtures/coinbase_products.json +1 -1
  63. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  64. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  65. data/test/honeymaker/clients/kraken_client_test.rb +27 -0
  66. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  67. data/test/native/backend_switch_test.rb +53 -0
  68. data/test/native/convert_test.rb +137 -0
  69. data/test/native/gem_isolation_test.rb +17 -0
  70. data/test/native/loader_test.rb +29 -0
  71. data/test/native/lockstep_test.rb +23 -0
  72. data/test/native/signing_smoke_test.rb +28 -0
  73. data/test/parity/kraken_client_parity_test.rb +398 -0
  74. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  75. data/test/release/branch_hardening_test.rb +34 -0
  76. data/test/release/push_gems_test.rb +79 -0
  77. data/test/smoke.rb +24 -0
  78. data/test/support/kraken_signing_smoke.rb +21 -0
  79. data/test/support/parity_helper.rb +62 -0
  80. data/test/support/venue_double.rb +100 -0
  81. metadata +84 -4
@@ -0,0 +1,90 @@
1
+ use honeymaker_core::num::Num;
2
+ use magnus::{Ruby, Value, prelude::*, value::BoxValue};
3
+
4
+ /// Ruby's BigDecimal behind the core's Num trait. BoxValue registers the object with the GC,
5
+ /// so it is safe inside the Vecs and structs the normalizers return. Calls retain the GVL.
6
+ pub struct RubyDecimal(BoxValue<Value>);
7
+
8
+ impl RubyDecimal {
9
+ pub fn value(&self) -> Value {
10
+ *self.0
11
+ }
12
+
13
+ fn wrap(v: Value) -> Self {
14
+ RubyDecimal(BoxValue::new(v))
15
+ }
16
+
17
+ fn ruby() -> Ruby {
18
+ Ruby::get().expect("called on a Ruby thread")
19
+ }
20
+ }
21
+
22
+ impl Num for RubyDecimal {
23
+ type Error = magnus::Error;
24
+
25
+ fn parse(text: &str) -> Result<Self, magnus::Error> {
26
+ Ok(Self::wrap(
27
+ Self::ruby()
28
+ .module_kernel()
29
+ .funcall("BigDecimal", (text,))?,
30
+ ))
31
+ }
32
+
33
+ fn parse_to_s(v: &serde_json::Value) -> Result<Self, magnus::Error> {
34
+ let ruby = Self::ruby();
35
+ let obj = BoxValue::new(crate::convert::from_json(&ruby, v)?);
36
+ let text = BoxValue::new(obj.funcall::<_, _, Value>("to_s", ())?);
37
+ Ok(Self::wrap(
38
+ ruby.module_kernel().funcall("BigDecimal", (*text,))?,
39
+ ))
40
+ }
41
+
42
+ fn parse_json(v: &serde_json::Value) -> Result<Self, magnus::Error> {
43
+ let ruby = Self::ruby();
44
+ let obj = BoxValue::new(crate::convert::from_json(&ruby, v)?);
45
+ Ok(Self::wrap(
46
+ ruby.module_kernel().funcall("BigDecimal", (*obj,))?,
47
+ ))
48
+ }
49
+
50
+ fn string_op(
51
+ v: &serde_json::Value,
52
+ op: honeymaker_core::semantics::StringOp<'_>,
53
+ ) -> Result<serde_json::Value, magnus::Error> {
54
+ use honeymaker_core::semantics::StringOp;
55
+ let ruby = Self::ruby();
56
+ let obj = BoxValue::new(crate::convert::from_json(&ruby, v)?);
57
+ let result: Value = match op {
58
+ StringOp::Index(key) => obj.funcall("[]", (key,))?,
59
+ StringOp::Split(sep) => obj.funcall("split", (sep,))?,
60
+ StringOp::First => obj.funcall("[]", (0,))?,
61
+ StringOp::DowncaseSymbol | StringOp::Symbol => {
62
+ let text = BoxValue::new(if matches!(op, StringOp::DowncaseSymbol) {
63
+ obj.funcall("downcase", ())?
64
+ } else {
65
+ *obj
66
+ });
67
+ // Validate now, in legacy's evaluation order, before parsing decimals.
68
+ text.funcall::<_, _, Value>("to_sym", ())?;
69
+ *text
70
+ }
71
+ };
72
+ crate::convert::to_json(&ruby, result)
73
+ }
74
+
75
+ fn add(&self, o: &Self) -> Result<Self, magnus::Error> {
76
+ Ok(Self::wrap(self.value().funcall("+", (o.value(),))?))
77
+ }
78
+
79
+ fn sub(&self, o: &Self) -> Result<Self, magnus::Error> {
80
+ Ok(Self::wrap(self.value().funcall("-", (o.value(),))?))
81
+ }
82
+
83
+ fn div(&self, o: &Self) -> Result<Self, magnus::Error> {
84
+ Ok(Self::wrap(self.value().funcall("/", (o.value(),))?))
85
+ }
86
+
87
+ fn is_zero(&self) -> Result<bool, magnus::Error> {
88
+ self.value().funcall("zero?", ())
89
+ }
90
+ }
@@ -96,6 +96,15 @@ module Honeymaker
96
96
  })
97
97
  end
98
98
 
99
+ # Closed orders that /v5/order/realtime no longer returns.
100
+ def order_history(category:, symbol: nil, order_id: nil, order_link_id: nil, order_status: nil,
101
+ start_time: nil, end_time: nil, limit: nil, cursor: nil)
102
+ get_authenticated("/v5/order/history", {
103
+ category: category, symbol: symbol, orderId: order_id, orderLinkId: order_link_id,
104
+ orderStatus: order_status, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
105
+ })
106
+ end
107
+
99
108
  def transaction_log(account_type: nil, category: nil, currency: nil, type: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
100
109
  get_authenticated("/v5/account/transaction-log", {
101
110
  accountType: account_type, category: category, currency: currency, type: type,
@@ -116,6 +125,13 @@ module Honeymaker
116
125
  })
117
126
  end
118
127
 
128
+ # Off-chain deposits from another Bybit user; query-record does not include them.
129
+ def internal_deposit_records(tx_id: nil, coin: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
130
+ get_authenticated("/v5/asset/deposit/query-internal-record", {
131
+ txID: tx_id, coin: coin, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
132
+ })
133
+ end
134
+
119
135
  def withdraw_records(coin: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil, withdraw_type: nil)
120
136
  get_authenticated("/v5/asset/withdraw/query-record", {
121
137
  coin: coin, startTime: start_time, endTime: end_time,
@@ -207,9 +223,10 @@ module Honeymaker
207
223
  def parse_order_status(status)
208
224
  case status
209
225
  when "Created", "Untriggered" then :unknown
210
- when "New", "PartiallyFilled", "PartiallyFilledCanceled" then :open
226
+ when "New", "PartiallyFilled" then :open
211
227
  when "Filled" then :closed
212
- when "Cancelled", "Expired", "Deactivated" then :cancelled
228
+ # PartiallyFilledCanceled is final: a spot order cancelled after a partial fill.
229
+ when "Cancelled", "PartiallyFilledCanceled", "Expired", "Deactivated" then :cancelled
213
230
  when "Rejected" then :failed
214
231
  else :unknown
215
232
  end
@@ -105,8 +105,9 @@ module Honeymaker
105
105
  get("/api/v3/brokerage/key_permissions")
106
106
  end
107
107
 
108
- def list_accounts
109
- get("/api/v3/brokerage/accounts")
108
+ # One page of accounts; pass the response's `cursor` back while `has_next` is true.
109
+ def list_accounts(limit: nil, cursor: nil)
110
+ get("/api/v3/brokerage/accounts", { limit: limit, cursor: cursor })
110
111
  end
111
112
 
112
113
  def list_portfolios
@@ -7,6 +7,7 @@ module Honeymaker
7
7
  module Clients
8
8
  class Kraken < Client
9
9
  URL = "https://api.kraken.com"
10
+ UNREADABLE = "Kraken: unreadable response"
10
11
 
11
12
  RATE_LIMITS = { default: 1000, orders: 1000 }.freeze
12
13
 
@@ -23,6 +24,7 @@ module Honeymaker
23
24
  txid: txid, consolidate_taker: consolidate_taker
24
25
  })
25
26
  return result if result.failure?
27
+ return unreadable unless result.data.is_a?(Hash)
26
28
 
27
29
  errors = result.data["error"]
28
30
  return Result::Failure.new(*errors) if errors.is_a?(Array) && errors.any?
@@ -53,6 +55,7 @@ module Honeymaker
53
55
  "deadline" => deadline, "validate" => validate
54
56
  })
55
57
  return result if result.failure?
58
+ return unreadable unless result.data.is_a?(Hash)
56
59
 
57
60
  errors = result.data["error"]
58
61
  return Result::Failure.new(*errors) if errors.is_a?(Array) && errors.any?
@@ -96,6 +99,7 @@ module Honeymaker
96
99
  def get_balances
97
100
  result = get_extended_balance
98
101
  return result if result.failure?
102
+ return unreadable unless result.data.is_a?(Hash)
99
103
 
100
104
  errors = result.data["error"]
101
105
  return Result::Failure.new(*errors) if errors.is_a?(Array) && errors.any?
@@ -144,11 +148,13 @@ module Honeymaker
144
148
  return Result::Success.new({}) if wanted.empty?
145
149
 
146
150
  by_order = Hash.new { |h, k| h[k] = [] }
151
+ seen = {}
147
152
  offset = 0
148
153
  pages = 0
149
154
  loop do
150
155
  result = get_trades_history(start: start, ofs: offset)
151
156
  return result if result.failure?
157
+ return unreadable unless result.data.is_a?(Hash)
152
158
 
153
159
  errors = result.data["error"]
154
160
  return Result::Failure.new(*errors) if errors.is_a?(Array) && errors.any?
@@ -156,7 +162,11 @@ module Honeymaker
156
162
  trades = result.data.dig("result", "trades") || {}
157
163
  break if trades.empty?
158
164
 
159
- trades.each_value do |t|
165
+ trades.each_pair do |trade_id, t|
166
+ # Offset paging over a list that grows at the top can serve a trade twice: count it once.
167
+ next if seen.key?(trade_id)
168
+
169
+ seen[trade_id] = true
160
170
  otxid = t["ordertxid"]
161
171
  by_order[otxid] << t if wanted.include?(otxid)
162
172
  end
@@ -196,6 +206,12 @@ module Honeymaker
196
206
 
197
207
  private
198
208
 
209
+ # A 2xx body that isn't a JSON object (a maintenance page, "null"): the venue's answer is
210
+ # unknown, which is not the same as empty. Callers treat data[:unreadable] as ambiguous.
211
+ def unreadable
212
+ Result::Failure.new(UNREADABLE, data: { unreadable: true })
213
+ end
214
+
199
215
  def aggregate_trades(trades)
200
216
  first = trades.first
201
217
  vol = trades.sum { |t| BigDecimal(t["vol"].to_s) }
@@ -6,7 +6,7 @@ module Honeymaker
6
6
  BASE_URL = "https://api.coinbase.com"
7
7
 
8
8
  ASSET_BLACKLIST = [
9
- "RENDER", # has the same external_id as RNDR
9
+ "RNDR", # delisted; has the same external_id as RENDER
10
10
  "ZETACHAIN", # has the same external_id as ZETA
11
11
  "WAXL" # has the same external_id as AXL
12
12
  ].freeze
@@ -51,10 +51,11 @@ module Honeymaker
51
51
 
52
52
  private
53
53
 
54
- # Coinbase only signals problems explicitly: a `trading_disabled` flag or a non-"online"
55
- # `status`. Absent either signal, treat the pair as enabled.
54
+ # Coinbase only signals problems explicitly: a `trading_disabled`, `is_disabled`, `limit_only`
55
+ # or `cancel_only` flag, or a non-"online" `status`. Absent any signal, treat the pair as
56
+ # enabled. limit_only is reported as not tradable, like Kraken's limit_only status.
56
57
  def trading_enabled?(product)
57
- return false if product["trading_disabled"] == true
58
+ return false if %w[trading_disabled is_disabled limit_only cancel_only].any? { |flag| product[flag] == true }
58
59
  return false if product.key?("status") && product["status"] != "online"
59
60
 
60
61
  true
@@ -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.2"
4
+ VERSION = "0.12.4"
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