honeymaker 0.12.4-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +33 -0
  5. data/LICENSE +21 -0
  6. data/README.md +219 -0
  7. data/Rakefile +78 -0
  8. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  9. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  10. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  11. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  12. data/lib/honeymaker/3.4/honeymaker_native.so +0 -0
  13. data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
  14. data/lib/honeymaker/client.rb +139 -0
  15. data/lib/honeymaker/clients/binance.rb +687 -0
  16. data/lib/honeymaker/clients/binance_us.rb +42 -0
  17. data/lib/honeymaker/clients/bingx.rb +242 -0
  18. data/lib/honeymaker/clients/bitget.rb +318 -0
  19. data/lib/honeymaker/clients/bitrue.rb +245 -0
  20. data/lib/honeymaker/clients/bitvavo.rb +244 -0
  21. data/lib/honeymaker/clients/bybit.rb +308 -0
  22. data/lib/honeymaker/clients/coinbase.rb +326 -0
  23. data/lib/honeymaker/clients/gemini.rb +189 -0
  24. data/lib/honeymaker/clients/hyperliquid.rb +232 -0
  25. data/lib/honeymaker/clients/kraken.rb +379 -0
  26. data/lib/honeymaker/clients/kraken_futures.rb +78 -0
  27. data/lib/honeymaker/clients/kucoin.rb +310 -0
  28. data/lib/honeymaker/clients/mexc.rb +249 -0
  29. data/lib/honeymaker/exchange.rb +129 -0
  30. data/lib/honeymaker/exchanges/binance.rb +55 -0
  31. data/lib/honeymaker/exchanges/binance_us.rb +9 -0
  32. data/lib/honeymaker/exchanges/bingx.rb +56 -0
  33. data/lib/honeymaker/exchanges/bitget.rb +52 -0
  34. data/lib/honeymaker/exchanges/bitrue.rb +53 -0
  35. data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
  36. data/lib/honeymaker/exchanges/bybit.rb +57 -0
  37. data/lib/honeymaker/exchanges/coinbase.rb +69 -0
  38. data/lib/honeymaker/exchanges/gemini.rb +134 -0
  39. data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
  40. data/lib/honeymaker/exchanges/kraken.rb +122 -0
  41. data/lib/honeymaker/exchanges/kucoin.rb +52 -0
  42. data/lib/honeymaker/exchanges/mexc.rb +56 -0
  43. data/lib/honeymaker/native/kraken.rb +263 -0
  44. data/lib/honeymaker/native.rb +42 -0
  45. data/lib/honeymaker/result.rb +46 -0
  46. data/lib/honeymaker/utils.rb +39 -0
  47. data/lib/honeymaker/version.rb +5 -0
  48. data/lib/honeymaker.rb +97 -0
  49. data/script/parity/kraken_client_live.rb +191 -0
  50. data/script/parity/kraken_live.rb +70 -0
  51. data/script/parity/kraken_transport.rb +604 -0
  52. data/script/parity/resolve.c +49 -0
  53. data/script/release/push_gems.sh +28 -0
  54. data/script/vectors/kraken.rb +50 -0
  55. data/script/vectors/kraken_stage2.rb +146 -0
  56. data/sig/honeymaker.rbs +4 -0
  57. data/test/fixtures/binance_book_ticker.json +7 -0
  58. data/test/fixtures/binance_exchange_info.json +30 -0
  59. data/test/fixtures/bingx_symbols.json +26 -0
  60. data/test/fixtures/bingx_ticker.json +12 -0
  61. data/test/fixtures/bitget_symbols.json +28 -0
  62. data/test/fixtures/bitget_tickers.json +14 -0
  63. data/test/fixtures/bitmart_symbols.json +26 -0
  64. data/test/fixtures/bitmart_ticker.json +12 -0
  65. data/test/fixtures/bitrue_book_ticker.json +7 -0
  66. data/test/fixtures/bitrue_exchange_info.json +34 -0
  67. data/test/fixtures/bitvavo_book_ticker.json +7 -0
  68. data/test/fixtures/bitvavo_markets.json +30 -0
  69. data/test/fixtures/bybit_instruments.json +23 -0
  70. data/test/fixtures/bybit_tickers.json +19 -0
  71. data/test/fixtures/coinbase_product.json +7 -0
  72. data/test/fixtures/coinbase_products.json +24 -0
  73. data/test/fixtures/gemini_pubticker.json +10 -0
  74. data/test/fixtures/gemini_symbol_detail.json +10 -0
  75. data/test/fixtures/gemini_symbols.json +1 -0
  76. data/test/fixtures/hyperliquid_all_mids.json +5 -0
  77. data/test/fixtures/hyperliquid_spot_meta.json +12 -0
  78. data/test/fixtures/kraken_asset_pairs.json +43 -0
  79. data/test/fixtures/kraken_ticker.json +16 -0
  80. data/test/fixtures/kucoin_orderbook_level1.json +10 -0
  81. data/test/fixtures/kucoin_symbols.json +17 -0
  82. data/test/fixtures/mexc_book_ticker.json +7 -0
  83. data/test/fixtures/mexc_exchange_info.json +64 -0
  84. data/test/honeymaker/client_test.rb +90 -0
  85. data/test/honeymaker/clients/binance_client_test.rb +151 -0
  86. data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
  87. data/test/honeymaker/clients/bingx_client_test.rb +106 -0
  88. data/test/honeymaker/clients/bitget_client_test.rb +146 -0
  89. data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
  90. data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
  91. data/test/honeymaker/clients/bybit_client_test.rb +137 -0
  92. data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
  93. data/test/honeymaker/clients/gemini_client_test.rb +100 -0
  94. data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
  95. data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
  96. data/test/honeymaker/clients/kraken_client_test.rb +255 -0
  97. data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
  98. data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
  99. data/test/honeymaker/clients/mexc_client_test.rb +98 -0
  100. data/test/honeymaker/clients/validation_test.rb +167 -0
  101. data/test/honeymaker/error_chain_test.rb +133 -0
  102. data/test/honeymaker/exchange_test.rb +138 -0
  103. data/test/honeymaker/exchanges/binance_test.rb +83 -0
  104. data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
  105. data/test/honeymaker/exchanges/bingx_test.rb +65 -0
  106. data/test/honeymaker/exchanges/bitget_test.rb +65 -0
  107. data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
  108. data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
  109. data/test/honeymaker/exchanges/bybit_test.rb +55 -0
  110. data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
  111. data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
  112. data/test/honeymaker/exchanges/gemini_test.rb +184 -0
  113. data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
  114. data/test/honeymaker/exchanges/kraken_test.rb +191 -0
  115. data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
  116. data/test/honeymaker/exchanges/mexc_test.rb +97 -0
  117. data/test/honeymaker/honeymaker_test.rb +27 -0
  118. data/test/honeymaker/result_test.rb +42 -0
  119. data/test/honeymaker/utils_test.rb +64 -0
  120. data/test/native/backend_switch_test.rb +53 -0
  121. data/test/native/convert_test.rb +137 -0
  122. data/test/native/gem_isolation_test.rb +17 -0
  123. data/test/native/loader_test.rb +29 -0
  124. data/test/native/lockstep_test.rb +23 -0
  125. data/test/native/signing_smoke_test.rb +28 -0
  126. data/test/parity/kraken_client_parity_test.rb +398 -0
  127. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  128. data/test/release/branch_hardening_test.rb +34 -0
  129. data/test/release/push_gems_test.rb +79 -0
  130. data/test/smoke.rb +24 -0
  131. data/test/support/fixture_helper.rb +13 -0
  132. data/test/support/kraken_signing_smoke.rb +21 -0
  133. data/test/support/parity_helper.rb +62 -0
  134. data/test/support/venue_double.rb +100 -0
  135. data/test/test_helper.rb +9 -0
  136. metadata +254 -0
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Kraken < Exchange
6
+ BASE_URL = "https://api.kraken.com"
7
+
8
+ ASSET_BLACKLIST = [
9
+ "COPM" # has the same external_id (ecomi) as OMI
10
+ ].freeze
11
+
12
+ # Patterns are unanchored (except regional_restriction) because the consumer may
13
+ # pass a sentence-joined error string. transient_nonce is matched first as it is
14
+ # the more specific/actionable case.
15
+ ERROR_PATTERNS = [
16
+ {
17
+ code: :regional_restriction,
18
+ pattern: /\AEAccount:Invalid permissions:(?<asset>\S+) trading restricted for (?<country>\w+)\.?\z/
19
+ },
20
+ {
21
+ code: :transient_nonce,
22
+ pattern: /EAPI:Invalid nonce/
23
+ },
24
+ {
25
+ code: :transient_unavailable,
26
+ pattern: /EGeneral:Internal error|EService:(?:Unavailable|Busy|Deadline elapsed)/
27
+ }
28
+ ].freeze
29
+
30
+ REAL_COSTMIN = {
31
+ "AUD" => 10,
32
+ "CAD" => 5,
33
+ "CHF" => 5,
34
+ "DAI" => 5,
35
+ "ETH" => 0.002,
36
+ "EUR" => 0.5,
37
+ "GBP" => 5,
38
+ "JPY" => 500,
39
+ "PYUSD" => 5,
40
+ "RLUSD" => 5,
41
+ "USD" => 5,
42
+ "USDC" => 5,
43
+ "USDQ" => 5,
44
+ "USDR" => 5,
45
+ "USDT" => 5,
46
+ "XBT" => 0.00005
47
+ }.freeze
48
+
49
+ def get_tickers_info
50
+ with_rescue do
51
+ # aclass_base=all, not a bare call: the default response carries only the "currency" class,
52
+ # so Kraken tokenized equities (xStocks) are invisible without it. One request returns both
53
+ # classes and they are disjoint.
54
+ response = connection.get("/0/public/AssetPairs", { aclass_base: "all" })
55
+
56
+ error = response.body["error"]
57
+ return Result::Failure.new(*error) if error.is_a?(Array) && error.any?
58
+
59
+ # Every tokenized pair is returned TWICE - once under an SPV key (NVDASPVUSD), once under
60
+ # the x key (NVDAxUSD) - sharing one wsname and altname. Currency pairs are never
61
+ # duplicated, so keying by wsname collapses exactly the aliases and nothing else.
62
+ deduped = response.body["result"].each_with_object({}) do |(_, info), acc|
63
+ wsname = info["wsname"]
64
+ next if wsname.nil? || wsname.empty?
65
+
66
+ acc[wsname] ||= info
67
+ end
68
+
69
+ deduped.filter_map do |wsname, info|
70
+ base, quote = wsname.split("/")
71
+
72
+ {
73
+ ticker: info["altname"],
74
+ base: base,
75
+ quote: quote,
76
+ minimum_base_size: info["ordermin"],
77
+ minimum_quote_size: (REAL_COSTMIN[quote] || info["costmin"] || 0).to_s,
78
+ maximum_base_size: nil,
79
+ maximum_quote_size: nil,
80
+ base_decimals: info["lot_decimals"],
81
+ quote_decimals: info["cost_decimals"],
82
+ price_decimals: info["pair_decimals"],
83
+ available: true,
84
+ # Tokenized equities are listed but never tradable through this client: AddOrder needs
85
+ # an asset_class parameter it does not send, and Kraken closes the tokenized order
86
+ # books to EEA clients over the API regardless of that. Listing them anyway is what
87
+ # lets a holding be resolved and valued.
88
+ trading_enabled: if info["aclass_base"] == "tokenized_asset"
89
+ false
90
+ else
91
+ info.key?("status") ? info["status"] == "online" : true
92
+ end
93
+ }
94
+ end
95
+ end
96
+ end
97
+
98
+ def get_bid_ask(symbol)
99
+ with_rescue do
100
+ response = connection.get("/0/public/Ticker") do |req|
101
+ req.params = { pair: symbol }
102
+ end
103
+
104
+ error = response.body["error"]
105
+ raise StandardError, error.first if error.is_a?(Array) && error.any?
106
+
107
+ _key, data = response.body["result"].first
108
+ {
109
+ bid: BigDecimal(data["b"][0]),
110
+ ask: BigDecimal(data["a"][0])
111
+ }
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def connection
118
+ @connection ||= build_connection(BASE_URL)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Kucoin < Exchange
6
+ BASE_URL = "https://api.kucoin.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/api/v2/symbols")
11
+
12
+ response.body["data"].filter_map do |product|
13
+ {
14
+ ticker: product["symbol"],
15
+ base: product["baseCurrency"],
16
+ quote: product["quoteCurrency"],
17
+ minimum_base_size: product["baseMinSize"],
18
+ minimum_quote_size: product["quoteMinSize"],
19
+ maximum_base_size: product["baseMaxSize"],
20
+ maximum_quote_size: product["quoteMaxSize"],
21
+ base_decimals: Utils.decimals(product["baseIncrement"]),
22
+ quote_decimals: Utils.decimals(product["quoteIncrement"]),
23
+ price_decimals: Utils.decimals(product["priceIncrement"]),
24
+ available: true,
25
+ trading_enabled: product["enableTrading"]
26
+ }
27
+ end
28
+ end
29
+ end
30
+
31
+ def get_bid_ask(symbol)
32
+ with_rescue do
33
+ response = connection.get("/api/v1/market/orderbook/level1") do |req|
34
+ req.params = { symbol: symbol }
35
+ end
36
+
37
+ data = response.body["data"]
38
+ {
39
+ bid: BigDecimal(data["bestBid"]),
40
+ ask: BigDecimal(data["bestAsk"])
41
+ }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def connection
48
+ @connection ||= build_connection(BASE_URL)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Exchanges
5
+ class Mexc < Exchange
6
+ BASE_URL = "https://api.mexc.com"
7
+
8
+ def get_tickers_info
9
+ with_rescue do
10
+ response = connection.get("/api/v3/exchangeInfo")
11
+
12
+ response.body["symbols"].filter_map do |product|
13
+ f = Utils.parse_filters(product["filters"])
14
+
15
+ {
16
+ ticker: product["symbol"],
17
+ base: product["baseAsset"],
18
+ quote: product["quoteAsset"],
19
+ minimum_base_size: f[:lot_size]&.[]("minQty"),
20
+ minimum_quote_size: f[:notional]&.[]("minNotional"),
21
+ maximum_base_size: f[:lot_size]&.[]("maxQty"),
22
+ maximum_quote_size: f[:notional]&.[]("maxNotional"),
23
+ base_decimals: f[:lot_size] ? Utils.decimals(f[:lot_size]["stepSize"]) : product["baseAssetPrecision"],
24
+ quote_decimals: product["quoteAssetPrecision"],
25
+ price_decimals: f[:price] ? Utils.decimals(f[:price]["tickSize"]) : product["quotePrecision"],
26
+ available: true,
27
+ # MEXC encodes status as "1", not Binance's "TRADING" — every symbol it returns is "1".
28
+ # isSpotTradingAllowed is the real gate: ~5% of listed symbols reject a spot order.
29
+ # `!= false` keeps the fail-open default if the key is ever absent, matching the siblings.
30
+ trading_enabled: product["status"] == "1" && product["isSpotTradingAllowed"] != false
31
+ }
32
+ end
33
+ end
34
+ end
35
+
36
+ def get_bid_ask(symbol)
37
+ with_rescue do
38
+ response = connection.get("/api/v3/ticker/bookTicker") do |req|
39
+ req.params = { symbol: symbol }
40
+ end
41
+
42
+ {
43
+ bid: BigDecimal(response.body["bidPrice"]),
44
+ ask: BigDecimal(response.body["askPrice"])
45
+ }
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def connection
52
+ @connection ||= build_connection(BASE_URL)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ class Result
5
+ attr_reader :data, :errors
6
+
7
+ def initialize(data:, errors:)
8
+ @data = data
9
+ @errors = errors
10
+ end
11
+
12
+ def success?
13
+ errors.empty?
14
+ end
15
+
16
+ def failure?
17
+ !success?
18
+ end
19
+
20
+ def or(other)
21
+ return data if success?
22
+
23
+ other
24
+ end
25
+
26
+ def ==(other)
27
+ return false unless other.is_a?(Result)
28
+
29
+ data == other.data && errors == other.errors
30
+ end
31
+
32
+ class Success < Result
33
+ def initialize(data = nil)
34
+ @data = data
35
+ @errors = []
36
+ end
37
+ end
38
+
39
+ class Failure < Result
40
+ def initialize(*errors, **kwargs)
41
+ @data = kwargs[:data]
42
+ @errors = errors.empty? ? ["Error"] : errors
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Utils
5
+ def self.decimals(num)
6
+ return 0 if num.nil?
7
+ str = num.to_s.sub(/\.?0+$/, "")
8
+ return 0 unless str.include?(".")
9
+ str.split(".").last.length
10
+ end
11
+
12
+ # The exception classes behind an error, outermost first, following both Faraday's
13
+ # #wrapped_exception and Ruby's #cause (adapters nest them differently), each named once.
14
+ # Reported on a transport failure so a caller can tell where it happened; see Client#with_rescue.
15
+ def self.error_chain(error, limit: 10)
16
+ names = []
17
+ seen = {}.compare_by_identity # exceptions, not classes: adapters re-wrap in the same class
18
+ queue = [error]
19
+ until queue.empty? || seen.size >= limit
20
+ node = queue.shift
21
+ next if seen.key?(node)
22
+
23
+ seen[node] = true
24
+ names << node.class.name unless names.include?(node.class.name)
25
+ queue << node.wrapped_exception if node.respond_to?(:wrapped_exception) && node.wrapped_exception
26
+ queue << node.cause if node.cause
27
+ end
28
+ names
29
+ end
30
+
31
+ def self.parse_filters(filters)
32
+ {
33
+ price: filters.find { |f| f["filterType"] == "PRICE_FILTER" },
34
+ lot_size: filters.find { |f| f["filterType"] == "LOT_SIZE" },
35
+ notional: filters.find { |f| %w[NOTIONAL MIN_NOTIONAL].include?(f["filterType"]) }
36
+ }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ VERSION = "0.12.4"
5
+ end