honeymaker 0.12.4-x86_64-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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Clients
5
+ class Kucoin < Client
6
+ URL = "https://api.kucoin.com"
7
+ RATE_LIMITS = { default: 100, orders: 200 }.freeze
8
+
9
+ attr_reader :passphrase
10
+
11
+ def initialize(api_key: nil, api_secret: nil, passphrase: nil, proxy: nil, logger: nil)
12
+ super(api_key: api_key, api_secret: api_secret, proxy: proxy, logger: logger)
13
+ @passphrase = passphrase
14
+ end
15
+
16
+ def get_symbols
17
+ get_public("/api/v2/symbols")
18
+ end
19
+
20
+ def get_ticker(symbol:)
21
+ get_public("/api/v1/market/orderbook/level1", { symbol: symbol })
22
+ end
23
+
24
+ def get_all_tickers
25
+ get_public("/api/v1/market/allTickers")
26
+ end
27
+
28
+ def get_klines(symbol:, type:, start_at: nil, end_at: nil)
29
+ get_public("/api/v1/market/candles", {
30
+ symbol: symbol, type: type, startAt: start_at, endAt: end_at
31
+ })
32
+ end
33
+
34
+ def get_accounts(currency: nil, type: nil)
35
+ get_signed("/api/v1/accounts", { currency: currency, type: type })
36
+ end
37
+
38
+ def get_balances(type: "trade")
39
+ result = get_accounts(type: type)
40
+ return result if result.failure?
41
+
42
+ return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
43
+
44
+ balances = {}
45
+ (result.data["data"] || []).each do |account|
46
+ symbol = account["currency"]
47
+ free = BigDecimal((account["available"] || "0").to_s)
48
+ locked = BigDecimal((account["holds"] || "0").to_s)
49
+ next if free.zero? && locked.zero?
50
+ balances[symbol] = { free: free, locked: locked }
51
+ end
52
+
53
+ Result::Success.new(balances)
54
+ end
55
+
56
+ def place_order(client_oid:, side:, symbol:, type:, size: nil, funds: nil, price: nil,
57
+ time_in_force: nil, stp: nil)
58
+ result = post_signed("/api/v1/orders", {
59
+ clientOid: client_oid, side: side, symbol: symbol, type: type,
60
+ size: size, funds: funds, price: price,
61
+ timeInForce: time_in_force, stp: stp
62
+ })
63
+ return result if result.failure?
64
+ return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
65
+
66
+ order_id = result.data.dig("data", "orderId")
67
+ Result::Success.new({ order_id: order_id, raw: result.data })
68
+ end
69
+
70
+ def get_order(order_id:)
71
+ result = get_signed("/api/v1/orders/#{order_id}")
72
+ return result if result.failure?
73
+ return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
74
+
75
+ raw = result.data["data"]
76
+ return Result::Failure.new("Order not found") unless raw
77
+
78
+ Result::Success.new(normalize_order(order_id, raw))
79
+ end
80
+
81
+ def cancel_order(order_id:)
82
+ delete_signed("/api/v1/orders/#{order_id}")
83
+ end
84
+
85
+ def get_orderbook(symbol:, limit: 20)
86
+ get_public("/api/v1/market/orderbook/level2_#{limit}", { symbol: symbol })
87
+ end
88
+
89
+ def get_currencies
90
+ get_public("/api/v3/currencies")
91
+ end
92
+
93
+ def get_withdrawal_quotas(currency:, chain: nil)
94
+ get_signed("/api/v1/withdrawals/quotas", { currency: currency, chain: chain })
95
+ end
96
+
97
+ def get_fills(order_id: nil, symbol: nil, side: nil, type: nil, start_at: nil, end_at: nil,
98
+ trade_type: nil, limit: nil, current_page: nil)
99
+ get_signed("/api/v1/fills", {
100
+ orderId: order_id, symbol: symbol, side: side, type: type,
101
+ startAt: start_at, endAt: end_at, tradeType: trade_type,
102
+ pageSize: limit, currentPage: current_page
103
+ })
104
+ end
105
+
106
+ def get_historical_orders(symbol: nil, side: nil, start_at: nil, end_at: nil, current_page: nil, page_size: nil)
107
+ get_signed("/api/v1/hist-orders", {
108
+ symbol: symbol, side: side,
109
+ startAt: start_at, endAt: end_at,
110
+ currentPage: current_page, pageSize: page_size
111
+ })
112
+ end
113
+
114
+ def get_deposits(currency: nil, start_at: nil, end_at: nil, status: nil, current_page: nil, page_size: nil)
115
+ get_signed("/api/v1/deposits", {
116
+ currency: currency, startAt: start_at, endAt: end_at,
117
+ status: status, currentPage: current_page, pageSize: page_size
118
+ })
119
+ end
120
+
121
+ def get_withdrawals(currency: nil, start_at: nil, end_at: nil, status: nil, current_page: nil, page_size: nil)
122
+ get_signed("/api/v1/withdrawals", {
123
+ currency: currency, startAt: start_at, endAt: end_at,
124
+ status: status, currentPage: current_page, pageSize: page_size
125
+ })
126
+ end
127
+
128
+ def withdraw(currency:, address:, amount:, chain: nil, memo: nil)
129
+ post_signed("/api/v1/withdrawals", {
130
+ currency: currency, address: address, amount: amount,
131
+ chain: chain, memo: memo
132
+ })
133
+ end
134
+
135
+ # --- Margin ---
136
+
137
+ def margin_borrow_history(currency: nil, is_isolated: nil, symbol: nil, order_no: nil, start_time: nil, end_time: nil, current_page: nil, page_size: nil)
138
+ get_signed("/api/v3/margin/borrow", {
139
+ currency: currency, isIsolated: is_isolated, symbol: symbol, orderNo: order_no,
140
+ startTime: start_time, endTime: end_time, currentPage: current_page, pageSize: page_size
141
+ })
142
+ end
143
+
144
+ def margin_repay_history(currency: nil, is_isolated: nil, symbol: nil, order_no: nil, start_time: nil, end_time: nil, current_page: nil, page_size: nil)
145
+ get_signed("/api/v3/margin/repay", {
146
+ currency: currency, isIsolated: is_isolated, symbol: symbol, orderNo: order_no,
147
+ startTime: start_time, endTime: end_time, currentPage: current_page, pageSize: page_size
148
+ })
149
+ end
150
+
151
+ def margin_interest_history(currency: nil, is_isolated: nil, symbol: nil, start_time: nil, end_time: nil, current_page: nil, page_size: nil)
152
+ get_signed("/api/v3/margin/interest", {
153
+ currency: currency, isIsolated: is_isolated, symbol: symbol,
154
+ startTime: start_time, endTime: end_time, currentPage: current_page, pageSize: page_size
155
+ })
156
+ end
157
+
158
+ # --- Futures ---
159
+
160
+ def futures_funding_history(symbol:, start_at: nil, end_at: nil, reverse: nil, offset: nil, forward: nil, max_count: nil)
161
+ with_rescue do
162
+ params = {
163
+ symbol: symbol, startAt: start_at, endAt: end_at,
164
+ reverse: reverse, offset: offset, forward: forward, maxCount: max_count
165
+ }.compact
166
+ query_string = params.empty? ? "" : "?#{Faraday::Utils.build_query(params)}"
167
+ path = "/api/v1/funding-history"
168
+ ts = timestamp_ms.to_s
169
+ pre_sign = "#{ts}GET#{path}#{query_string}"
170
+
171
+ response = futures_connection.get do |req|
172
+ req.url path
173
+ req.headers = signed_headers(ts, pre_sign)
174
+ req.params = params
175
+ end
176
+ response.body
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ def normalize_order(order_id, raw)
183
+ order_type = parse_order_type(raw["type"])
184
+ side = raw["side"]&.downcase&.to_sym
185
+ status = parse_order_status(raw)
186
+
187
+ deal_funds = BigDecimal((raw["dealFunds"] || "0").to_s)
188
+ deal_size = BigDecimal((raw["dealSize"] || "0").to_s)
189
+ price = deal_funds.positive? && deal_size.positive? ? deal_funds / deal_size : BigDecimal((raw["price"] || "0").to_s)
190
+ price = nil if price.zero?
191
+
192
+ amount = raw["size"] ? BigDecimal(raw["size"].to_s) : nil
193
+ quote_amount = raw["funds"] ? BigDecimal(raw["funds"].to_s) : nil
194
+
195
+ {
196
+ order_id: order_id, status: status, side: side, order_type: order_type,
197
+ price: price, amount: amount, quote_amount: quote_amount,
198
+ amount_exec: deal_size, quote_amount_exec: deal_funds, raw: raw
199
+ }
200
+ end
201
+
202
+ def parse_order_type(type)
203
+ case type&.downcase
204
+ when "market" then :market
205
+ when "limit" then :limit
206
+ else :unknown
207
+ end
208
+ end
209
+
210
+ def parse_order_status(raw)
211
+ if raw["cancelExist"]
212
+ :cancelled
213
+ elsif raw["isActive"]
214
+ :open
215
+ else
216
+ BigDecimal((raw["dealSize"] || "0").to_s).positive? ? :closed : :unknown
217
+ end
218
+ end
219
+
220
+ def validate_trading_credentials
221
+ result = get_accounts(type: "trade")
222
+ return Result::Failure.new("Invalid trading credentials") if result.failure?
223
+ result.data["code"] == "200000" ? Result::Success.new(true) : Result::Failure.new("Invalid trading credentials")
224
+ end
225
+
226
+ def validate_read_credentials
227
+ validate_trading_credentials
228
+ end
229
+
230
+ def get_public(path, params = {})
231
+ with_rescue do
232
+ response = connection.get do |req|
233
+ req.url path
234
+ req.headers = unauthenticated_headers
235
+ req.params = params.compact
236
+ end
237
+ response.body
238
+ end
239
+ end
240
+
241
+ def get_signed(path, params = {})
242
+ with_rescue do
243
+ params = params.compact
244
+ query_string = params.empty? ? "" : "?#{Faraday::Utils.build_query(params)}"
245
+ ts = timestamp_ms.to_s
246
+ pre_sign = "#{ts}GET#{path}#{query_string}"
247
+
248
+ response = connection.get do |req|
249
+ req.url path
250
+ req.headers = signed_headers(ts, pre_sign)
251
+ req.params = params
252
+ end
253
+ response.body
254
+ end
255
+ end
256
+
257
+ def post_signed(path, body = {})
258
+ with_rescue do
259
+ body = body.compact
260
+ ts = timestamp_ms.to_s
261
+ pre_sign = "#{ts}POST#{path}#{body.to_json}"
262
+
263
+ response = connection.post do |req|
264
+ req.url path
265
+ req.headers = signed_headers(ts, pre_sign)
266
+ req.body = body
267
+ end
268
+ response.body
269
+ end
270
+ end
271
+
272
+ def delete_signed(path)
273
+ with_rescue do
274
+ ts = timestamp_ms.to_s
275
+ pre_sign = "#{ts}DELETE#{path}"
276
+
277
+ response = connection.delete do |req|
278
+ req.url path
279
+ req.headers = signed_headers(ts, pre_sign)
280
+ end
281
+ response.body
282
+ end
283
+ end
284
+
285
+ def unauthenticated_headers
286
+ { Accept: "application/json", "Content-Type": "application/json" }
287
+ end
288
+
289
+ def signed_headers(timestamp, pre_sign)
290
+ signature = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", @api_secret, pre_sign))
291
+ signed_passphrase = Base64.strict_encode64(
292
+ OpenSSL::HMAC.digest("sha256", @api_secret, @passphrase)
293
+ )
294
+ {
295
+ "KC-API-KEY": @api_key,
296
+ "KC-API-SIGN": signature,
297
+ "KC-API-TIMESTAMP": timestamp,
298
+ "KC-API-PASSPHRASE": signed_passphrase,
299
+ "KC-API-KEY-VERSION": "2",
300
+ Accept: "application/json",
301
+ "Content-Type": "application/json"
302
+ }
303
+ end
304
+
305
+ def futures_connection
306
+ @futures_connection ||= build_client_connection("https://api-futures.kucoin.com")
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Clients
5
+ class Mexc < Client
6
+ URL = "https://api.mexc.com"
7
+ RATE_LIMITS = { default: 100, orders: 200 }.freeze
8
+
9
+ def get_all_coins_information
10
+ get_public("/api/v3/capital/config/getall")
11
+ end
12
+
13
+ def exchange_information
14
+ get_public("/api/v3/exchangeInfo")
15
+ end
16
+
17
+ def symbol_price_ticker(symbol: nil)
18
+ get_public("/api/v3/ticker/price", { symbol: symbol })
19
+ end
20
+
21
+ def symbol_order_book_ticker(symbol: nil)
22
+ get_public("/api/v3/ticker/bookTicker", { symbol: symbol })
23
+ end
24
+
25
+ def candlestick_data(symbol:, interval:, start_time: nil, end_time: nil, limit: 500)
26
+ get_public("/api/v3/klines", {
27
+ symbol: symbol, interval: interval,
28
+ startTime: start_time, endTime: end_time, limit: limit
29
+ })
30
+ end
31
+
32
+ def account_information(recv_window: 5000)
33
+ get_signed("/api/v3/account", { recvWindow: recv_window })
34
+ end
35
+
36
+ def get_balances
37
+ result = account_information
38
+ return result if result.failure?
39
+
40
+ balances = {}
41
+ Array(result.data["balances"]).each do |balance|
42
+ symbol = balance["asset"]
43
+ free = BigDecimal(balance["free"].to_s)
44
+ locked = BigDecimal(balance["locked"].to_s)
45
+ next if free.zero? && locked.zero?
46
+ balances[symbol] = { free: free, locked: locked }
47
+ end
48
+
49
+ Result::Success.new(balances)
50
+ end
51
+
52
+ def query_order(symbol:, order_id: nil, orig_client_order_id: nil, recv_window: 5000)
53
+ result = get_signed("/api/v3/order", {
54
+ symbol: symbol, orderId: order_id,
55
+ origClientOrderId: orig_client_order_id, recvWindow: recv_window
56
+ })
57
+ return result if result.failure?
58
+
59
+ raw = result.data
60
+ Result::Success.new(normalize_order("#{symbol}-#{raw['orderId']}", raw))
61
+ end
62
+
63
+ def new_order(symbol:, side:, type:, time_in_force: nil, quantity: nil, quote_order_qty: nil,
64
+ price: nil, new_client_order_id: nil, recv_window: 5000)
65
+ result = post_signed("/api/v3/order", {
66
+ symbol: symbol, side: side, type: type,
67
+ timeInForce: time_in_force, quantity: quantity,
68
+ quoteOrderQty: quote_order_qty, price: price,
69
+ newClientOrderId: new_client_order_id, recvWindow: recv_window
70
+ })
71
+ return result if result.failure?
72
+
73
+ raw = result.data
74
+ Result::Success.new({ order_id: "#{symbol}-#{raw['orderId']}", raw: raw })
75
+ end
76
+
77
+ def cancel_order(symbol:, order_id: nil, orig_client_order_id: nil,
78
+ new_client_order_id: nil, recv_window: 5000)
79
+ delete_signed("/api/v3/order", {
80
+ symbol: symbol, orderId: order_id,
81
+ origClientOrderId: orig_client_order_id,
82
+ newClientOrderId: new_client_order_id, recvWindow: recv_window
83
+ })
84
+ end
85
+
86
+ def account_trade_list(symbol:, order_id: nil, start_time: nil, end_time: nil, limit: 500, recv_window: 5000)
87
+ get_signed("/api/v3/myTrades", {
88
+ symbol: symbol, orderId: order_id,
89
+ startTime: start_time, endTime: end_time, limit: limit, recvWindow: recv_window
90
+ })
91
+ end
92
+
93
+ def deposit_history(coin: nil, status: nil, start_time: nil, end_time: nil, limit: 1000, recv_window: 5000)
94
+ get_signed("/api/v3/capital/deposit/hisrec", {
95
+ coin: coin, status: status,
96
+ startTime: start_time, endTime: end_time, limit: limit, recvWindow: recv_window
97
+ })
98
+ end
99
+
100
+ def withdraw_history(coin: nil, status: nil, start_time: nil, end_time: nil, limit: 1000, recv_window: 5000)
101
+ get_signed("/api/v3/capital/withdraw/history", {
102
+ coin: coin, status: status,
103
+ startTime: start_time, endTime: end_time, limit: limit, recvWindow: recv_window
104
+ })
105
+ end
106
+
107
+ def get_withdraw_addresses(recv_window: 5000)
108
+ get_signed("/api/v3/capital/withdraw/address", { recvWindow: recv_window })
109
+ end
110
+
111
+ def withdraw(coin:, address:, amount:, network: nil, memo: nil, recv_window: 5000)
112
+ post_signed("/api/v3/capital/withdraw/apply", {
113
+ coin: coin, address: address, amount: amount,
114
+ network: network, memo: memo, recvWindow: recv_window
115
+ })
116
+ end
117
+
118
+ # --- Other ---
119
+
120
+ def dust_conversion_history(recv_window: 5000)
121
+ get_signed("/api/v3/capital/convert", { recvWindow: recv_window })
122
+ end
123
+
124
+ def universal_transfer_history(type:, start_time: nil, end_time: nil, page: nil, size: nil, recv_window: 5000)
125
+ get_signed("/api/v3/capital/transfer", {
126
+ type: type, startTime: start_time, endTime: end_time,
127
+ page: page, size: size, recvWindow: recv_window
128
+ })
129
+ end
130
+
131
+ private
132
+
133
+ def normalize_order(order_id, raw)
134
+ order_type = parse_order_type(raw["type"])
135
+ side = raw["side"]&.downcase&.to_sym
136
+ status = parse_order_status(raw["status"])
137
+
138
+ amount = BigDecimal(raw["origQty"].to_s)
139
+ amount = nil if amount.zero?
140
+ quote_amount = raw["origQuoteOrderQty"] ? BigDecimal(raw["origQuoteOrderQty"].to_s) : nil
141
+ quote_amount = nil if quote_amount&.zero?
142
+
143
+ amount_exec = BigDecimal(raw["executedQty"].to_s)
144
+ quote_amount_exec = BigDecimal(raw["cummulativeQuoteQty"].to_s)
145
+ quote_amount_exec = nil if quote_amount_exec.negative?
146
+
147
+ price = BigDecimal(raw["price"].to_s)
148
+ if price.zero? && quote_amount_exec&.positive? && amount_exec.positive?
149
+ price = quote_amount_exec / amount_exec
150
+ end
151
+ price = nil if price.zero?
152
+
153
+ {
154
+ order_id: order_id, status: status, side: side, order_type: order_type,
155
+ price: price, amount: amount, quote_amount: quote_amount,
156
+ amount_exec: amount_exec, quote_amount_exec: quote_amount_exec, raw: raw
157
+ }
158
+ end
159
+
160
+ def parse_order_type(type)
161
+ case type
162
+ when "MARKET" then :market
163
+ when "LIMIT" then :limit
164
+ else :unknown
165
+ end
166
+ end
167
+
168
+ def parse_order_status(status)
169
+ case status
170
+ when "NEW", "PARTIALLY_FILLED" then :open
171
+ when "FILLED" then :closed
172
+ when "CANCELED", "EXPIRED" then :cancelled
173
+ when "REJECTED" then :failed
174
+ else :unknown
175
+ end
176
+ end
177
+
178
+ def validate_trading_credentials
179
+ result = account_information
180
+ result.success? ? Result::Success.new(true) : Result::Failure.new("Invalid trading credentials")
181
+ end
182
+
183
+ def validate_read_credentials
184
+ validate_trading_credentials
185
+ end
186
+
187
+ def get_public(path, params = {})
188
+ with_rescue do
189
+ response = connection.get do |req|
190
+ req.url path
191
+ req.headers = headers
192
+ req.params = params.compact
193
+ end
194
+ response.body
195
+ end
196
+ end
197
+
198
+ def get_signed(path, params = {})
199
+ with_rescue do
200
+ response = connection.get do |req|
201
+ req.url path
202
+ req.headers = headers
203
+ req.params = params.compact.merge(timestamp: timestamp_ms)
204
+ req.params[:signature] = sign_params(req.params)
205
+ end
206
+ response.body
207
+ end
208
+ end
209
+
210
+ def post_signed(path, params = {})
211
+ with_rescue do
212
+ response = connection.post do |req|
213
+ req.url path
214
+ req.headers = headers
215
+ req.params = params.compact.merge(timestamp: timestamp_ms)
216
+ req.params[:signature] = sign_params(req.params)
217
+ end
218
+ response.body
219
+ end
220
+ end
221
+
222
+ def delete_signed(path, params = {})
223
+ with_rescue do
224
+ response = connection.delete do |req|
225
+ req.url path
226
+ req.headers = headers
227
+ req.params = params.compact.merge(timestamp: timestamp_ms)
228
+ req.params[:signature] = sign_params(req.params)
229
+ end
230
+ response.body
231
+ end
232
+ end
233
+
234
+ def headers
235
+ if authenticated?
236
+ { "X-MEXC-APIKEY": @api_key, Accept: "application/json", "Content-Type": "application/json" }
237
+ else
238
+ { Accept: "application/json", "Content-Type": "application/json" }
239
+ end
240
+ end
241
+
242
+ def sign_params(params)
243
+ return unless @api_secret
244
+ query = Faraday::Utils.build_query(params)
245
+ hmac_sha256(@api_secret, query)
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ class Exchange
5
+ OPTIONS = {
6
+ request: {
7
+ open_timeout: 5,
8
+ read_timeout: 30,
9
+ write_timeout: 5
10
+ }
11
+ }.freeze
12
+
13
+ ERROR_PATTERNS = [].freeze
14
+
15
+ def classify_error(message)
16
+ return nil if message.nil?
17
+
18
+ self.class::ERROR_PATTERNS.each do |entry|
19
+ next unless (match = entry[:pattern].match(message))
20
+ return { code: entry[:code], **match.named_captures.transform_keys(&:to_sym) }
21
+ end
22
+ nil
23
+ end
24
+
25
+ def get_tickers_info
26
+ raise NotImplementedError, "#{self.class} must implement #get_tickers_info"
27
+ end
28
+
29
+ def get_bid_ask(symbol)
30
+ raise NotImplementedError, "#{self.class} must implement #get_bid_ask"
31
+ end
32
+
33
+ def get_price(symbol)
34
+ result = get_bid_ask(symbol)
35
+ return result if result.failure?
36
+
37
+ Result::Success.new((result.data[:bid] + result.data[:ask]) / 2)
38
+ end
39
+
40
+ def tickers_info
41
+ if @tickers_info_cache && @tickers_info_expires_at && @tickers_info_expires_at > Time.now
42
+ return Result::Success.new(@tickers_info_cache)
43
+ end
44
+
45
+ result = get_tickers_info
46
+ if result.success?
47
+ @tickers_info_cache = result.data
48
+ @tickers_info_expires_at = Time.now + cache_ttl
49
+ end
50
+ result
51
+ end
52
+
53
+ def find_ticker(symbol)
54
+ result = tickers_info
55
+ return result if result.failure?
56
+
57
+ ticker = result.data.find { |t| t[:ticker] == symbol }
58
+ ticker ? Result::Success.new(ticker) : Result::Failure.new("Unknown symbol: #{symbol}")
59
+ end
60
+
61
+ def symbols
62
+ result = tickers_info
63
+ return result if result.failure?
64
+
65
+ Result::Success.new(result.data.map { |t| { base: t[:base], quote: t[:quote] } })
66
+ end
67
+
68
+ def cache_ttl
69
+ 3600
70
+ end
71
+
72
+ # Venue symbols the last #get_tickers_info saw listed but could not describe, symbol => why. They
73
+ # are missing from the catalogue WITHOUT being delisted: a caller that reads absence as delisting
74
+ # must keep what it has for them. Empty for a venue whose catalogue is a single request.
75
+ # It describes the last get_tickers_info call only; the #tickers_info cache does not carry it, so a
76
+ # caller that needs both reads them from one get_tickers_info.
77
+ def unreadable_symbols
78
+ @unreadable_symbols || {}
79
+ end
80
+
81
+ private
82
+
83
+ # No HTTP answer means the transport failed, and its text rarely says how ("end of file
84
+ # reached"). The chain of exception classes does — whether the request never left or may have
85
+ # landed — so it travels with the failure. Facts only: what they mean is the caller's call.
86
+ def transport_data(error, status)
87
+ status.nil? ? { status: nil, error_chain: Utils.error_chain(error) } : { status: status }
88
+ end
89
+
90
+ # Records one instrument the venue lists but whose description could not be read, keyed as the
91
+ # venue emits its ticker. The reason is what a person needs to see: the venue's own answer when it
92
+ # gave one ("HTTP 400 {...InvalidSymbol...}"), otherwise the exception.
93
+ def set_aside(symbol, error)
94
+ status = error.respond_to?(:response_status) ? error.response_status : nil
95
+ body = error.respond_to?(:response_body) ? error.response_body.to_s : ""
96
+ why = body.empty? ? "#{error.class}: #{error.message}" : body
97
+ (@unreadable_symbols ||= {})[symbol.to_s.upcase] = (status ? "HTTP #{status} #{why}" : why)[0, 200]
98
+ nil
99
+ end
100
+
101
+ def with_rescue
102
+ Result::Success.new(yield)
103
+ rescue Faraday::Error => e
104
+ body = e.respond_to?(:response_body) ? e.response_body : nil
105
+ error_message = (body && !body.empty?) ? body : e.message.to_s
106
+ error_message = "Unknown API error" if error_message.nil? || error_message.empty?
107
+ status = e.respond_to?(:response_status) ? e.response_status : nil
108
+ Result::Failure.new(error_message, data: transport_data(e, status))
109
+ rescue StandardError => e
110
+ msg = e.message
111
+ Result::Failure.new((msg && !msg.empty?) ? msg : "Unknown error")
112
+ end
113
+
114
+ def build_connection(url, content_type_match: nil)
115
+ Faraday.new(url: url, **OPTIONS) do |config|
116
+ config.request :json
117
+ if content_type_match
118
+ config.response :json, content_type: content_type_match
119
+ else
120
+ config.response :json
121
+ end
122
+ config.response :raise_error
123
+ config.adapter :net_http_persistent do |http|
124
+ http.idle_timeout = 100
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end