honeymaker 0.12.4 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78709a467495784c1d111fff613edd1188ad5a7bee608abff82dbee3fdb5842d
4
- data.tar.gz: ad8c934dfbe76031ca3c54e74a6e81a79119c0ec489ce415152a56465b8aa234
3
+ metadata.gz: 3b63e4e5855db8e7ada05e58f6dad106bf6f24e29628dfce7e0fd04e062382b5
4
+ data.tar.gz: 51382cf687f1e6a3136d7c57d1656cca27065c19075075c42d4170ae6e9ffc80
5
5
  SHA512:
6
- metadata.gz: 0e45cbd2df342d861721818bf52245f23457a170fbbc0cdf464de89d2e82b5c41d2bd1d9c459ebbd019c87a4fce0c56cb87310a449a19afc0df405a09195f61c
7
- data.tar.gz: 76cecab1382c572bb8a8f1f87ed843737ee40d762a4318031f0723ee5fd67f70ff4920471881101a95f620b2fb7c3fc049b143a997fc1db50a8dfc2e97ea5aba
6
+ metadata.gz: efa5ee0604c2daf571073432447c86cb24706ab1a95d00d318204c26dfcd66c32616e96071ee8b86992ca8f32f5f87fdaa387a30bb748781095466f44b222a0b
7
+ data.tar.gz: b0bb1d9eac28616b4cc1beae77159ea8221c5ee554d80f3776698b8cca2aa05e05d67c860303fba7cce7a52f61471fcc5398eb6c2bd4438cfe2a348165c73749
data/Cargo.lock CHANGED
@@ -265,7 +265,7 @@ dependencies = [
265
265
 
266
266
  [[package]]
267
267
  name = "honeymaker"
268
- version = "0.12.4"
268
+ version = "0.12.5"
269
269
  dependencies = [
270
270
  "base64",
271
271
  "bigdecimal",
@@ -278,7 +278,7 @@ dependencies = [
278
278
 
279
279
  [[package]]
280
280
  name = "honeymaker-client"
281
- version = "0.12.4"
281
+ version = "0.12.5"
282
282
  dependencies = [
283
283
  "base64",
284
284
  "bigdecimal",
@@ -300,7 +300,7 @@ dependencies = [
300
300
 
301
301
  [[package]]
302
302
  name = "honeymaker_native"
303
- version = "0.12.4"
303
+ version = "0.12.5"
304
304
  dependencies = [
305
305
  "honeymaker",
306
306
  "magnus",
data/Cargo.toml CHANGED
@@ -3,7 +3,7 @@ members = ["crates/honeymaker", "crates/honeymaker-client", "ext/honeymaker_nati
3
3
  resolver = "3"
4
4
 
5
5
  [workspace.package]
6
- version = "0.12.4" # lockstep with lib/honeymaker/version.rb (test/native/lockstep_test.rb)
6
+ version = "0.12.5" # lockstep with lib/honeymaker/version.rb (test/native/lockstep_test.rb)
7
7
  edition = "2024"
8
8
  rust-version = "1.94"
9
9
  license = "MIT"
@@ -18,10 +18,11 @@ module Honeymaker
18
18
  get_public("/openApi/spot/v1/market/depth", { symbol: symbol, limit: limit })
19
19
  end
20
20
 
21
- def get_klines(symbol:, interval:, start_time: nil, end_time: nil, limit: nil)
21
+ # time_zone: 0 aligns candles to UTC; BingX's default is 8 (UTC+8).
22
+ def get_klines(symbol:, interval:, start_time: nil, end_time: nil, limit: nil, time_zone: nil)
22
23
  get_public("/openApi/spot/v1/market/kline", {
23
24
  symbol: symbol, interval: interval,
24
- startTime: start_time, endTime: end_time, limit: limit
25
+ startTime: start_time, endTime: end_time, limit: limit, timeZone: time_zone
25
26
  })
26
27
  end
27
28
 
@@ -87,22 +88,25 @@ module Honeymaker
87
88
  get_signed("/openApi/wallets/v1/capital/config/getall")
88
89
  end
89
90
 
90
- def get_trade_fills(symbol: nil, order_id: nil, start_time: nil, end_time: nil, from_id: nil, limit: nil)
91
- get_signed("/openApi/spot/v1/trade/fills", {
91
+ # Query transaction details. symbol is required; only the past 7 days; limit ≤ 1000.
92
+ def get_trade_fills(symbol:, order_id: nil, start_time: nil, end_time: nil, from_id: nil, limit: nil)
93
+ get_signed("/openApi/spot/v1/trade/myTrades", {
92
94
  symbol: symbol, orderId: order_id,
93
95
  startTime: start_time, endTime: end_time, fromId: from_id, limit: limit
94
96
  })
95
97
  end
96
98
 
99
+ # Deposit records: a bare array, offset + limit ≤ 1000.
97
100
  def deposit_history(coin: nil, status: nil, start_time: nil, end_time: nil, offset: nil, limit: nil)
98
- get_signed("/openApi/wallets/v1/capital/deposit/hisrec", {
101
+ get_signed("/openApi/api/v3/capital/deposit/hisrec", {
99
102
  coin: coin, status: status,
100
103
  startTime: start_time, endTime: end_time, offset: offset, limit: limit
101
104
  })
102
105
  end
103
106
 
107
+ # Withdraw records: a bare array, offset + limit ≤ 1000.
104
108
  def withdraw_history(coin: nil, status: nil, start_time: nil, end_time: nil, offset: nil, limit: nil, id: nil)
105
- get_signed("/openApi/wallets/v1/capital/withdraw/history", {
109
+ get_signed("/openApi/api/v3/capital/withdraw/history", {
106
110
  coin: coin, status: status,
107
111
  startTime: start_time, endTime: end_time, offset: offset, limit: limit, id: id
108
112
  })
@@ -14,14 +14,22 @@ module Honeymaker
14
14
  get_public("/api/v1/ticker/price", { symbol: symbol })
15
15
  end
16
16
 
17
+ # Without a symbol, /ticker/price answers nothing usable; /ticker/24hr lists every pair
18
+ # (weight 40) with lastPrice.
19
+ def ticker_24hr(symbol: nil)
20
+ get_public("/api/v1/ticker/24hr", { symbol: symbol })
21
+ end
22
+
17
23
  def symbol_order_book_ticker(symbol: nil)
18
24
  get_public("/api/v1/ticker/bookTicker", { symbol: symbol })
19
25
  end
20
26
 
21
- def candlestick_data(symbol:, interval:, start_time: nil, end_time: nil, limit: 500)
27
+ # Documented: symbol, scale, fromIdx, limit (max 1440). startTime/endTime are not documented and
28
+ # are ignored; fromIdx (ms) caps the newest candle, so a walk goes back with fromIdx = oldest - 1.
29
+ def candlestick_data(symbol:, interval:, start_time: nil, end_time: nil, limit: 500, from_idx: nil)
22
30
  get_public("/api/v1/market/kline", {
23
31
  symbol: symbol, scale: interval, startTime: start_time,
24
- endTime: end_time, limit: limit
32
+ endTime: end_time, limit: limit, fromIdx: from_idx
25
33
  })
26
34
  end
27
35
 
@@ -77,34 +85,56 @@ module Honeymaker
77
85
  })
78
86
  end
79
87
 
80
- def get_all_coins_information(recv_window: 5000)
81
- get_signed("/api/v1/capital/config/getall", { recvWindow: recv_window })
88
+ # /api/v1/capital/config/getall redirects to the website. The documented source of withdrawal
89
+ # networks and fees is the public exchangeInfo `coins[].chainDetail[]`; it is answered here in the
90
+ # Binance shape the method always promised: [{ "coin", "networkList" => [{ "network",
91
+ # "withdrawFee", "withdrawMin", "withdrawMax", "withdrawEnable", "depositEnable", "isDefault" }] }].
92
+ # Bitrue marks no default chain; a chain closed for withdrawal is left out.
93
+ def get_all_coins_information
94
+ result = exchange_information
95
+ return result if result.failure?
96
+
97
+ coins = Array(result.data["coins"]).map do |coin|
98
+ networks = Array(coin["chainDetail"]).select { |c| c["enableWithdraw"] }.map do |c|
99
+ { "network" => c["chain"], "withdrawFee" => c["withdrawFee"], "withdrawMin" => c["minWithdraw"],
100
+ "withdrawMax" => c["maxWithdraw"], "withdrawEnable" => true,
101
+ "depositEnable" => c["enableDeposit"], "isDefault" => false }
102
+ end
103
+ { "coin" => coin["coin"].to_s.upcase, "networkList" => networks }
104
+ end
105
+ Result::Success.new(coins)
82
106
  end
83
107
 
84
- def withdraw(coin:, address:, amount:, network: nil, address_tag: nil, recv_window: 5000)
85
- post_signed("/api/v1/capital/withdraw/apply", {
86
- coin: coin, address: address, amount: amount,
87
- network: network, addressTag: address_tag, recvWindow: recv_window
108
+ # POST /api/v1/withdraw/commit: coin without chain name, chainName, addressTo; needs the
109
+ # withdraw privilege, an IP-limited key and a whitelisted address.
110
+ def withdraw(coin:, address:, amount:, network:, address_tag: nil, recv_window: 5000)
111
+ post_signed("/api/v1/withdraw/commit", {
112
+ coin: coin, amount: amount, addressTo: address,
113
+ chainName: network, tag: address_tag, recvWindow: recv_window
88
114
  })
89
115
  end
90
116
 
117
+ # GET /api/v2/myTrades (v1 is undocumented): limit ≤ 1000, a startTime..endTime span ≤ 3 months.
91
118
  def account_trade_list(symbol:, start_time: nil, end_time: nil, from_id: nil, limit: 500, recv_window: 5000)
92
- get_signed("/api/v1/myTrades", {
119
+ get_signed("/api/v2/myTrades", {
93
120
  symbol: symbol, startTime: start_time, endTime: end_time,
94
121
  fromId: from_id, limit: limit, recvWindow: recv_window
95
122
  })
96
123
  end
97
124
 
98
- def deposit_history(coin: nil, status: nil, start_time: nil, end_time: nil, offset: nil, limit: 1000, recv_window: 5000)
99
- get_signed("/api/v1/capital/deposit/hisrec", {
125
+ # GET /api/v1/deposit/history and /api/v1/withdraw/history (the /capital/ paths redirect to the
126
+ # website): coin is required and has no chain suffix; status defaults to 0 (init) — finished is
127
+ # 1 for a deposit, 5 for a withdrawal; offset + limit ≤ 1000; answers { "code" => 200, "data" => [...] }.
128
+ def deposit_history(coin:, status: nil, start_time: nil, end_time: nil, offset: nil, limit: 1000, recv_window: 5000)
129
+ get_signed("/api/v1/deposit/history", {
100
130
  coin: coin, status: status,
101
131
  startTime: start_time, endTime: end_time,
102
132
  offset: offset, limit: limit, recvWindow: recv_window
103
133
  })
104
134
  end
105
135
 
106
- def withdraw_history(coin: nil, status: nil, start_time: nil, end_time: nil, offset: nil, limit: 1000, recv_window: 5000)
107
- get_signed("/api/v1/capital/withdraw/history", {
136
+ def withdraw_history(coin:, status: nil, start_time: nil, end_time: nil, offset: nil, limit: 1000, recv_window: 5000)
137
+ get_signed("/api/v1/withdraw/history", {
108
138
  coin: coin, status: status,
109
139
  startTime: start_time, endTime: end_time,
110
140
  offset: offset, limit: limit, recvWindow: recv_window
@@ -90,12 +90,14 @@ module Honeymaker
90
90
  })
91
91
  end
92
92
 
93
+ # GET /v2/deposit is the deposit ADDRESS, and GET /v2/withdrawal answers 405. The histories are
94
+ # /v2/depositHistory and /v2/withdrawalHistory: limit ≤ 1000 (default 500), latest first.
93
95
  def get_deposit_history(symbol: nil, limit: nil, start_time: nil, end_time: nil)
94
- get_signed("/v2/deposit", { symbol: symbol, limit: limit, start: start_time, end: end_time })
96
+ get_signed("/v2/depositHistory", { symbol: symbol, limit: limit, start: start_time, end: end_time })
95
97
  end
96
98
 
97
99
  def get_withdrawal_history(symbol: nil, limit: nil, start_time: nil, end_time: nil)
98
- get_signed("/v2/withdrawal", { symbol: symbol, limit: limit, start: start_time, end: end_time })
100
+ get_signed("/v2/withdrawalHistory", { symbol: symbol, limit: limit, start: start_time, end: end_time })
99
101
  end
100
102
 
101
103
  def withdraw(symbol:, amount:, address:, payment_id: nil)
@@ -106,8 +108,11 @@ module Honeymaker
106
108
 
107
109
  # --- History ---
108
110
 
109
- def get_transactions(limit: nil, start_time: nil, end_time: nil)
110
- get_signed("/v2/transactions", { limit: limit, start: start_time, end: end_time })
111
+ # /v2/transactions is a 404. The transaction history is /v2/account/history: every type (buy,
112
+ # sell, deposit, withdrawal, staking, ...), fromDate/toDate in ms, page from 1, maxItems ≤ 100;
113
+ # answers { "items" => [...], "currentPage", "totalPages", "maxItems" }.
114
+ def get_transactions(limit: nil, start_time: nil, end_time: nil, page: nil, type: nil)
115
+ get_signed("/v2/account/history", { fromDate: start_time, toDate: end_time, page: page, maxItems: limit, type: type })
111
116
  end
112
117
 
113
118
  private
@@ -84,6 +84,16 @@ module Honeymaker
84
84
  post_signed("/v1/transfers", { timestamp: timestamp, limit_transfers: limit_transfers })
85
85
  end
86
86
 
87
+ # POST /v2/transfers, which replaces the retiring /v1/transfers: limit_transfers ≤ 50 (default 10),
88
+ # timestamp in ms, rows carry feeAmount/feeCurrency/txHash; 1 request per 5 s per account.
89
+ def get_transfers_v2(timestamp: nil, limit_transfers: nil, currency: nil, network: nil, account: nil,
90
+ show_completed_deposit_advances: nil)
91
+ post_signed("/v2/transfers", {
92
+ timestamp: timestamp, limit_transfers: limit_transfers, currency: currency, network: network,
93
+ account: account, show_completed_deposit_advances: show_completed_deposit_advances
94
+ })
95
+ end
96
+
87
97
  def withdraw(currency:, address:, amount:)
88
98
  post_signed("/v1/withdraw/#{currency}", { address: address, amount: amount })
89
99
  end
@@ -5,6 +5,20 @@ module Honeymaker
5
5
  class Hyperliquid < Client
6
6
  URL = "https://api.hyperliquid.xyz"
7
7
  RATE_LIMITS = { default: 200, orders: 200 }.freeze
8
+ # A cap on the userFillsByTime walk for one order's cost: the venue keeps only the 10000 most
9
+ # recent fills, at most 2000 (500 by the general note) per answer, so 25 pages reach them all.
10
+ FILLS_PAGES = 25
11
+
12
+ NONCE_LOCK = Mutex.new
13
+ @last_nonce = 0
14
+
15
+ # Hyperliquid keeps the 100 highest nonces per signer and refuses a reused one. hyperliquid-rb
16
+ # signs with the wall clock in ms, so two orders signed in the same ms by one agent collide —
17
+ # and the app builds a client per call, so the counter lives on the class: strictly
18
+ # increasing for the whole process, never behind the clock (the venue's own advice).
19
+ def self.next_nonce
20
+ NONCE_LOCK.synchronize { @last_nonce = [(Time.now.to_f * 1000).to_i, @last_nonce + 1].max }
21
+ end
8
22
 
9
23
  def initialize(api_key: nil, api_secret: nil, proxy: nil, logger: nil)
10
24
  super
@@ -87,13 +101,13 @@ module Honeymaker
87
101
  quote_amount_exec = BigDecimal("0")
88
102
  price = limit_price
89
103
  if amount_exec.positive?
90
- fills_result = order["timestamp"] ? user_fills_by_time(user: user, start_time: order["timestamp"]) : nil
104
+ fills_result = order["timestamp"] ? order_fills(user: user, oid: oid, start_time: order["timestamp"], size: amount_exec) : nil
91
105
  # A FAILED exact-cost lookup (timeout / rate-limit) is PROPAGATED so the consumer's typed-error
92
106
  # retry runs — never record an executed order with an estimated cost just because userFills
93
107
  # blipped (that would silently corrupt accounting and skip the retry).
94
108
  return fills_result if fills_result&.failure?
95
109
 
96
- matched = Array(fills_result&.data).select { |f| f["oid"].to_s == oid.to_s }
110
+ matched = Array(fills_result&.data)
97
111
  matched_quote = matched.sum(BigDecimal("0")) { |f| BigDecimal(f["px"].to_s) * BigDecimal(f["sz"].to_s) }
98
112
  # userFills SUCCEEDED but has no matching fill (aged out of the window) → estimate from the
99
113
  # limit price so a filled order never reports quote_amount_exec 0. Only on success, never failure.
@@ -127,6 +141,11 @@ module Honeymaker
127
141
  post_info(body)
128
142
  end
129
143
 
144
+ # "missing", "user", "agent" (with the master under data.user), "vault" or "subAccount".
145
+ def user_role(user:)
146
+ post_info({ type: "userRole", user: user })
147
+ end
148
+
130
149
  # --- Trading (requires hyperliquid-rb gem) ---
131
150
 
132
151
  def order(coin:, is_buy:, size:, limit_px:, order_type: { limit: { tif: "Gtc" } })
@@ -157,6 +176,28 @@ module Honeymaker
157
176
 
158
177
  private
159
178
 
179
+ # This order's fills, from its timestamp on. One answer is capped, so the walk goes on from the
180
+ # newest time it saw (inclusive, so fills are deduped by tid) until the order's executed size
181
+ # is covered or a page brings nothing new.
182
+ def order_fills(user:, oid:, start_time:, size:)
183
+ seen = {}
184
+ matched = []
185
+ FILLS_PAGES.times do
186
+ page = user_fills_by_time(user: user, start_time: start_time)
187
+ return page if page.failure?
188
+
189
+ fresh = Array(page.data).reject { |f| seen[f["tid"] || f.hash] }
190
+ break if fresh.empty?
191
+
192
+ fresh.each { |f| seen[f["tid"] || f.hash] = true }
193
+ matched.concat(fresh.select { |f| f["oid"].to_s == oid.to_s })
194
+ break if matched.sum(BigDecimal("0")) { |f| BigDecimal(f["sz"].to_s) } >= size
195
+
196
+ start_time = fresh.map { |f| f["time"].to_i }.max
197
+ end
198
+ Result::Success.new(matched)
199
+ end
200
+
160
201
  # Trading does not go over this client's Faraday connection: it goes through hyperliquid-rb,
161
202
  # which raises its own error for every non-2xx instead of answering. Client#with_rescue can
162
203
  # only see an unlabelled StandardError there, so it flags it `client_error` — "no clean
@@ -211,12 +252,30 @@ module Honeymaker
211
252
  raise Error, "Trading requires api_secret (agent key)" unless @api_secret && !@api_secret.empty?
212
253
  @exchange ||= begin
213
254
  require "hyperliquid"
214
- ::Hyperliquid::Exchange.new(private_key: @api_secret)
255
+ build_exchange
215
256
  rescue LoadError
216
257
  raise Error, "Add 'hyperliquid-rb' to your Gemfile to use Hyperliquid trading"
217
258
  end
218
259
  end
219
260
 
261
+ # hyperliquid-rb's defaults do not fit a client built per call:
262
+ # - its Info opens a websocket and a ping thread unless skip_ws, and nothing here ever stops
263
+ # them — one leaked socket per trading client. Nothing here subscribes, so never open it;
264
+ # - its two Transports build their own Faraday without our proxy, so placement and the asset
265
+ # lookup went out direct. hyperliquid-rb 0.1.1 has no proxy option; set it on both;
266
+ # - its nonce is the wall-clock ms (see .next_nonce).
267
+ def build_exchange
268
+ exchange = ::Hyperliquid::Exchange.new(private_key: @api_secret, skip_ws: true)
269
+ if @proxy
270
+ [exchange.instance_variable_get(:@transport), exchange.info.transport].each do |transport|
271
+ transport.instance_variable_get(:@connection).proxy = @proxy
272
+ end
273
+ end
274
+ exchange.singleton_class.send(:define_method, :timestamp_ms) { Honeymaker::Clients::Hyperliquid.next_nonce }
275
+ exchange.singleton_class.send(:private, :timestamp_ms)
276
+ exchange
277
+ end
278
+
220
279
  def post_info(body)
221
280
  with_rescue do
222
281
  response = connection.post do |req|
@@ -6,8 +6,9 @@ module Honeymaker
6
6
  URL = "https://api.mexc.com"
7
7
  RATE_LIMITS = { default: 100, orders: 200 }.freeze
8
8
 
9
- def get_all_coins_information
10
- get_public("/api/v3/capital/config/getall")
9
+ # Signed (permission SPOT_WITHDRAW_READ): unsigned, MEXC answers 400 "api key required".
10
+ def get_all_coins_information(recv_window: 5000)
11
+ get_signed("/api/v3/capital/config/getall", { recvWindow: recv_window })
11
12
  end
12
13
 
13
14
  def exchange_information
@@ -108,6 +109,19 @@ module Honeymaker
108
109
  get_signed("/api/v3/capital/withdraw/address", { recvWindow: recv_window })
109
110
  end
110
111
 
112
+ # POST /api/v3/capital/withdraw, the endpoint MEXC replaces /withdraw/apply with ("previous,
113
+ # offline soon"). It takes netWork — networkList[].netWork from get_all_coins_information, a
114
+ # different value from the old `network` — and answers a single { "id" } object, not an array.
115
+ def capital_withdraw(coin:, address:, amount:, net_work: nil, memo: nil, withdraw_order_id: nil,
116
+ contract_address: nil, remark: nil, recv_window: 5000)
117
+ post_signed("/api/v3/capital/withdraw", {
118
+ coin: coin, address: address, amount: amount, netWork: net_work, memo: memo,
119
+ withdrawOrderId: withdraw_order_id, contractAddress: contract_address, remark: remark,
120
+ recvWindow: recv_window
121
+ })
122
+ end
123
+
124
+ # The previous endpoint, which MEXC marks "offline soon"; see #capital_withdraw.
111
125
  def withdraw(coin:, address:, amount:, network: nil, memo: nil, recv_window: 5000)
112
126
  post_signed("/api/v3/capital/withdraw/apply", {
113
127
  coin: coin, address: address, amount: amount,
@@ -16,10 +16,15 @@ module Honeymaker
16
16
  ticker: product["symbol"],
17
17
  base: product["baseAsset"],
18
18
  quote: product["quoteAsset"],
19
- minimum_base_size: f[:lot_size]&.[]("minQty"),
20
- minimum_quote_size: f[:notional]&.[]("minNotional"),
19
+ # MEXC sends no LOT_SIZE or NOTIONAL filter (only PERCENT_PRICE_BY_SIDE). Its minimums are
20
+ # baseSizePrecision ("min order quantity") and quoteAmountPrecision ("min order amount";
21
+ # quoteAmountPrecisionMarket for a market order, so the larger of the two), and
22
+ # maxQuoteAmount caps the order value. A filter still wins if MEXC ever sends one.
23
+ minimum_base_size: f[:lot_size]&.[]("minQty") || product["baseSizePrecision"],
24
+ minimum_quote_size: f[:notional]&.[]("minNotional") ||
25
+ product.values_at("quoteAmountPrecision", "quoteAmountPrecisionMarket").compact.max_by { |v| BigDecimal(v.to_s) },
21
26
  maximum_base_size: f[:lot_size]&.[]("maxQty"),
22
- maximum_quote_size: f[:notional]&.[]("maxNotional"),
27
+ maximum_quote_size: f[:notional]&.[]("maxNotional") || product["maxQuoteAmount"],
23
28
  base_decimals: f[:lot_size] ? Utils.decimals(f[:lot_size]["stepSize"]) : product["baseAssetPrecision"],
24
29
  quote_decimals: product["quoteAssetPrecision"],
25
30
  price_decimals: f[:price] ? Utils.decimals(f[:price]["tickSize"]) : product["quotePrecision"],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.12.4"
4
+ VERSION = "0.12.5"
5
5
  end
@@ -83,16 +83,29 @@ class Honeymaker::Clients::BingXTest < Minitest::Test
83
83
  assert result.success?
84
84
  end
85
85
 
86
+ # /openApi/spot/v1/trade/fills and /openApi/wallets/v1/capital/{deposit,withdraw}/... answer
87
+ # 100400 "this api is not exist"; these are the documented paths.
88
+ def test_get_trade_fills_reads_my_trades
89
+ @client.expects(:get_signed).with("/openApi/spot/v1/trade/myTrades",
90
+ { symbol: "BTC-USDT", orderId: nil, startTime: 1, endTime: 2, fromId: nil, limit: 500 })
91
+ @client.get_trade_fills(symbol: "BTC-USDT", start_time: 1, end_time: 2, limit: 500)
92
+ end
93
+
86
94
  def test_deposit_history
87
- stub_connection(:get, [{ "coin" => "BTC" }])
88
- result = @client.deposit_history
89
- assert result.success?
95
+ @client.expects(:get_signed).with("/openApi/api/v3/capital/deposit/hisrec", has_entries(offset: 0, limit: 1000))
96
+ .returns(Honeymaker::Result::Success.new([{ "coin" => "BTC" }]))
97
+ assert @client.deposit_history(offset: 0, limit: 1000).success?
90
98
  end
91
99
 
92
100
  def test_withdraw_history
93
- stub_connection(:get, [{ "coin" => "ETH" }])
94
- result = @client.withdraw_history
95
- assert result.success?
101
+ @client.expects(:get_signed).with("/openApi/api/v3/capital/withdraw/history", has_entries(startTime: 1, endTime: 2))
102
+ .returns(Honeymaker::Result::Success.new([{ "coin" => "ETH" }]))
103
+ assert @client.withdraw_history(start_time: 1, end_time: 2).success?
104
+ end
105
+
106
+ def test_get_klines_sends_time_zone
107
+ @client.expects(:get_public).with("/openApi/spot/v1/market/kline", has_entries(symbol: "BTC-USDT", timeZone: 0))
108
+ @client.get_klines(symbol: "BTC-USDT", interval: "1d", time_zone: 0)
96
109
  end
97
110
 
98
111
  private
@@ -53,6 +53,54 @@ class Honeymaker::Clients::BitrueTest < Minitest::Test
53
53
  assert result.success?
54
54
  end
55
55
 
56
+ # The /capital/ paths and /api/v1/myTrades are not in Bitrue's docs; the former redirect to the website.
57
+ def test_account_trade_list_reads_v2
58
+ @client.expects(:get_signed).with("/api/v2/myTrades", has_entries(symbol: "BTCUSDT", startTime: 1, endTime: 2, limit: 1000))
59
+ @client.account_trade_list(symbol: "BTCUSDT", start_time: 1, end_time: 2, limit: 1000)
60
+ end
61
+
62
+ def test_deposit_history
63
+ @client.expects(:get_signed).with("/api/v1/deposit/history", has_entries(coin: "usdt", status: 1, offset: 0, limit: 1000))
64
+ @client.deposit_history(coin: "usdt", status: 1, offset: 0)
65
+ end
66
+
67
+ def test_withdraw_history
68
+ @client.expects(:get_signed).with("/api/v1/withdraw/history", has_entries(coin: "usdt", status: 5))
69
+ @client.withdraw_history(coin: "usdt", status: 5)
70
+ end
71
+
72
+ def test_withdraw_commits
73
+ @client.expects(:post_signed).with("/api/v1/withdraw/commit",
74
+ has_entries(coin: "usdt", amount: "10", addressTo: "0xabc", chainName: "TRX"))
75
+ @client.withdraw(coin: "usdt", address: "0xabc", amount: "10", network: "TRX")
76
+ end
77
+
78
+ def test_get_all_coins_information_reads_exchange_info_chains
79
+ stub_connection(:get, { "coins" => [{ "coin" => "usdt", "chainDetail" => [
80
+ { "chain" => "OMNI", "enableWithdraw" => false, "enableDeposit" => false, "withdrawFee" => "1.28" },
81
+ { "chain" => "TRX", "enableWithdraw" => true, "enableDeposit" => true, "withdrawFee" => "1.00",
82
+ "minWithdraw" => "2.00", "maxWithdraw" => "2000000.00" }
83
+ ] }] })
84
+
85
+ result = @client.get_all_coins_information
86
+
87
+ assert result.success?
88
+ coin = result.data.first
89
+ assert_equal "USDT", coin["coin"]
90
+ assert_equal [{ "network" => "TRX", "withdrawFee" => "1.00", "withdrawMin" => "2.00", "withdrawMax" => "2000000.00",
91
+ "withdrawEnable" => true, "depositEnable" => true, "isDefault" => false }], coin["networkList"]
92
+ end
93
+
94
+ def test_ticker_24hr
95
+ @client.expects(:get_public).with("/api/v1/ticker/24hr", { symbol: nil })
96
+ @client.ticker_24hr
97
+ end
98
+
99
+ def test_candlestick_data_sends_from_idx
100
+ @client.expects(:get_public).with("/api/v1/market/kline", has_entries(symbol: "BTCUSDT", scale: "1D", fromIdx: 5, limit: 1440))
101
+ @client.candlestick_data(symbol: "BTCUSDT", interval: "1D", from_idx: 5, limit: 1440)
102
+ end
103
+
56
104
  def test_headers_include_api_key
57
105
  headers = @client.send(:auth_headers)
58
106
  assert_equal "test_key", headers[:"X-MBX-APIKEY"]
@@ -95,6 +95,21 @@ class Honeymaker::Clients::BitvavoTest < Minitest::Test
95
95
  assert result.success?
96
96
  end
97
97
 
98
+ def test_deposit_history_path
99
+ @client.expects(:get_signed).with("/v2/depositHistory", { symbol: "BTC", limit: 1000, start: 1, end: 2 })
100
+ @client.get_deposit_history(symbol: "BTC", limit: 1000, start_time: 1, end_time: 2)
101
+ end
102
+
103
+ def test_withdrawal_history_path
104
+ @client.expects(:get_signed).with("/v2/withdrawalHistory", { symbol: nil, limit: 1000, start: 1, end: 2 })
105
+ @client.get_withdrawal_history(limit: 1000, start_time: 1, end_time: 2)
106
+ end
107
+
108
+ def test_transactions_read_account_history
109
+ @client.expects(:get_signed).with("/v2/account/history", { fromDate: 1, toDate: 2, page: 3, maxItems: 100, type: "deposit" })
110
+ @client.get_transactions(limit: 100, start_time: 1, end_time: 2, page: 3, type: "deposit")
111
+ end
112
+
98
113
  def test_get_deposit_history
99
114
  stub_connection(:get, [{ "symbol" => "BTC", "amount" => "1.0" }])
100
115
  result = @client.get_deposit_history
@@ -89,6 +89,21 @@ class Honeymaker::Clients::GeminiTest < Minitest::Test
89
89
  assert_equal OpenSSL::HMAC.hexdigest("sha384", "test_secret", payload), request.headers[:"X-GEMINI-SIGNATURE"]
90
90
  end
91
91
 
92
+ def test_get_transfers_v2
93
+ @client.expects(:post_signed).with("/v2/transfers", has_entries(timestamp: 1, limit_transfers: 50))
94
+ .returns(Honeymaker::Result::Success.new([{ "eid" => 1, "feeCurrency" => "BTC" }]))
95
+ assert @client.get_transfers_v2(timestamp: 1, limit_transfers: 50).success?
96
+ end
97
+
98
+ def test_get_transfers_v2_signs_the_v2_request
99
+ connection = stub
100
+ connection.expects(:post).yields(req = stub_everything).returns(stub(body: []))
101
+ req.expects(:url).with("/v2/transfers")
102
+ req.expects(:headers=).with { |h| JSON.parse(Base64.decode64(h[:"X-GEMINI-PAYLOAD"]))["request"] == "/v2/transfers" }
103
+ @client.instance_variable_set(:@connection, connection)
104
+ assert @client.get_transfers_v2(limit_transfers: 50).success?
105
+ end
106
+
92
107
  private
93
108
 
94
109
  def stub_connection(method, body)
@@ -70,8 +70,10 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
70
70
  # quote_amount_exec 0 (the job subtracts the quote delta from missed_quote_amount).
71
71
  order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00018", limit_px: "64689.0")
72
72
  stub_connection(:post, order_body)
73
- @client.expects(:user_fills_by_time)
74
- .returns(Honeymaker::Result::Success.new([fill(oid: 111, px: "1.0", sz: "5.0")])) # no match
73
+ # no match; the walk reads on until a page brings nothing new
74
+ @client.expects(:user_fills_by_time).twice
75
+ .returns(Honeymaker::Result::Success.new([fill(oid: 111, px: "1.0", sz: "5.0", tid: 1)]),
76
+ Honeymaker::Result::Success.new([fill(oid: 111, px: "1.0", sz: "5.0", tid: 1)]))
75
77
 
76
78
  result = @client.order_status(user: "0xabc", oid: 123456789)
77
79
 
@@ -190,6 +192,80 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
190
192
  assert result.success?
191
193
  end
192
194
 
195
+ # One userFillsByTime answer is capped; an order whose fills spill past it must still be costed
196
+ # exactly, not estimated from the limit price.
197
+ def test_order_status_reads_fills_past_the_first_page
198
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00020")
199
+ stub_connection(:post, order_body)
200
+ first = [fill(oid: 1, px: "1.0", sz: "1.0", time: 1781698875556),
201
+ fill(oid: 123456789, px: "64000.0", sz: "0.00010", time: 1781698875600, tid: 7)]
202
+ second = [fill(oid: 123456789, px: "64000.0", sz: "0.00010", time: 1781698875600, tid: 7), # boundary re-read
203
+ fill(oid: 123456789, px: "66000.0", sz: "0.00010", time: 1781698875700)]
204
+ seq = sequence("pages")
205
+ @client.expects(:user_fills_by_time).with(user: "0xabc", start_time: 1781698875556).in_sequence(seq)
206
+ .returns(Honeymaker::Result::Success.new(first))
207
+ @client.expects(:user_fills_by_time).with(user: "0xabc", start_time: 1781698875600).in_sequence(seq)
208
+ .returns(Honeymaker::Result::Success.new(second))
209
+
210
+ result = @client.order_status(user: "0xabc", oid: 123456789)
211
+
212
+ assert_equal BigDecimal("13.0"), result.data[:quote_amount_exec]
213
+ assert_equal BigDecimal("65000"), result.data[:price]
214
+ end
215
+
216
+ def test_order_status_propagates_a_failed_later_page
217
+ stub_connection(:post, order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00020"))
218
+ @client.expects(:user_fills_by_time).twice
219
+ .returns(Honeymaker::Result::Success.new([fill(oid: 123456789, px: "64000.0", sz: "0.00010")]),
220
+ Honeymaker::Result::Failure.new("Net::ReadTimeout"))
221
+
222
+ result = @client.order_status(user: "0xabc", oid: 123456789)
223
+
224
+ assert result.failure?
225
+ assert_includes result.errors, "Net::ReadTimeout"
226
+ end
227
+
228
+ def test_user_role
229
+ connection = stub
230
+ connection.expects(:post).yields(req = stub_everything).returns(stub(body: { "role" => "agent", "data" => { "user" => "0xm" } }))
231
+ req.expects(:body=).with({ type: "userRole", user: "0xabc" }.to_json)
232
+ @client.instance_variable_set(:@connection, connection)
233
+
234
+ result = @client.user_role(user: "0xabc")
235
+
236
+ assert_equal "agent", result.data["role"]
237
+ end
238
+
239
+ # == Trading client construction ==
240
+
241
+ def test_trading_client_opens_no_websocket
242
+ with_fake_hyperliquid do
243
+ client = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01")
244
+ assert client.send(:build_exchange).info.skip_ws
245
+ end
246
+ end
247
+
248
+ def test_trading_client_sends_through_the_configured_proxy
249
+ with_fake_hyperliquid do
250
+ client = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01", proxy: "http://proxy.local:8080")
251
+ exchange = client.send(:build_exchange)
252
+ [exchange.instance_variable_get(:@transport), exchange.info.transport].each do |transport|
253
+ assert_equal "proxy.local", transport.instance_variable_get(:@connection).proxy.uri.host
254
+ end
255
+ end
256
+ end
257
+
258
+ def test_trading_clients_sign_strictly_increasing_nonces_within_one_millisecond
259
+ with_fake_hyperliquid do
260
+ Time.stubs(:now).returns(Time.at(1_781_698_875.556))
261
+ a = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01").send(:build_exchange)
262
+ b = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01").send(:build_exchange)
263
+ nonces = [a.nonce, b.nonce, a.nonce]
264
+ assert_equal nonces.sort.uniq, nonces
265
+ assert_operator nonces.first, :>=, 1_781_698_875_556
266
+ end
267
+ end
268
+
193
269
  # == Raised trading errors keep their HTTP status ==
194
270
  #
195
271
  # Order placement goes through hyperliquid-rb, which raises instead of answering. Whether the
@@ -285,8 +361,48 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
285
361
  }
286
362
  end
287
363
 
288
- def fill(oid:, px:, sz:, coin: "@142", side: "B")
364
+ def fill(oid:, px:, sz:, coin: "@142", side: "B", time: 1781698875556, tid: nil)
365
+ @tid = (@tid || 0) + 1
289
366
  { "coin" => coin, "oid" => oid, "px" => px, "sz" => sz, "side" => side,
290
- "time" => 1781698875556, "fee" => "0.011", "tid" => 99 }
367
+ "time" => time, "fee" => "0.011", "tid" => tid || @tid }
368
+ end
369
+
370
+ # hyperliquid-rb's shape, as far as build_exchange touches it. The gem itself is an optional
371
+ # dependency (native secp256k1) that this bundle does not carry.
372
+ module FakeHyperliquid
373
+ class Transport
374
+ def initialize = @connection = Faraday.new(url: "https://api.hyperliquid.xyz")
375
+ end
376
+
377
+ class Info
378
+ attr_reader :transport, :skip_ws
379
+
380
+ def initialize(skip_ws:)
381
+ @skip_ws = skip_ws
382
+ @transport = Transport.new
383
+ end
384
+ end
385
+
386
+ class Exchange
387
+ attr_reader :info
388
+
389
+ def initialize(private_key:, skip_ws: false)
390
+ @info = Info.new(skip_ws: skip_ws)
391
+ @transport = Transport.new
392
+ end
393
+
394
+ def nonce = timestamp_ms
395
+
396
+ private
397
+
398
+ def timestamp_ms = (Time.now.to_f * 1000).to_i
399
+ end
400
+ end
401
+
402
+ def with_fake_hyperliquid
403
+ Object.const_set(:Hyperliquid, FakeHyperliquid)
404
+ yield
405
+ ensure
406
+ Object.send(:remove_const, :Hyperliquid)
291
407
  end
292
408
  end
@@ -70,6 +70,18 @@ class Honeymaker::Clients::MexcTest < Minitest::Test
70
70
  assert result.success?
71
71
  end
72
72
 
73
+ def test_capital_withdraw_posts_the_new_endpoint
74
+ @client.expects(:post_signed).with("/api/v3/capital/withdraw",
75
+ has_entries(coin: "USDT", address: "addr", amount: "10", netWork: "TRX"))
76
+ .returns(Honeymaker::Result::Success.new({ "id" => "w1" }))
77
+ assert_equal "w1", @client.capital_withdraw(coin: "USDT", address: "addr", amount: "10", net_work: "TRX").data["id"]
78
+ end
79
+
80
+ def test_get_all_coins_information_is_signed
81
+ @client.expects(:get_signed).with("/api/v3/capital/config/getall", { recvWindow: 5000 })
82
+ @client.get_all_coins_information
83
+ end
84
+
73
85
  def test_withdraw_history
74
86
  stub_connection(:get, [{ "coin" => "ETH" }])
75
87
  result = @client.withdraw_history
@@ -71,8 +71,20 @@ class Honeymaker::Exchanges::MexcTest < Minitest::Test
71
71
  assert_equal 2, metal[:base_decimals] # baseAssetPrecision
72
72
  assert_equal 5, metal[:quote_decimals] # quoteAssetPrecision
73
73
  assert_equal 5, metal[:price_decimals] # quotePrecision
74
- assert_nil metal[:minimum_base_size]
75
- assert_nil metal[:minimum_quote_size]
74
+ # The minimums live in MEXC's own fields; reading only the (absent) filters reported zero.
75
+ assert_equal "0.1", metal[:minimum_base_size] # baseSizePrecision
76
+ assert_equal "1", metal[:minimum_quote_size] # quoteAmountPrecision
77
+ assert_nil metal[:maximum_base_size]
78
+ assert_equal "2000000", metal[:maximum_quote_size] # maxQuoteAmount
79
+ end
80
+
81
+ # A market order's minimum (quoteAmountPrecisionMarket) can be the larger one.
82
+ def test_minimum_quote_size_is_the_larger_of_the_limit_and_market_minimums
83
+ body = load_fixture("mexc_exchange_info.json")
84
+ body["symbols"].first["quoteAmountPrecisionMarket"] = "5"
85
+ stub_connection(body)
86
+
87
+ assert_equal "5", @exchange.get_tickers_info.data.first[:minimum_quote_size]
76
88
  end
77
89
 
78
90
  def test_get_bid_ask_parses_response
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: honeymaker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.4
4
+ version: 0.12.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger