honeymaker 0.12.4-aarch64-linux → 0.12.6-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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce03d589763a1ce403d35622db819a32dfa54b6ac86c3fd19941ecb3083a82ff
4
- data.tar.gz: 2eef626eca2a9c2ff52e74c3f9b8695f6af426f62d6122331c77241319123f5c
3
+ metadata.gz: facaf702a43fc739fe970a71f72017dd240617a8938a53934adb892c968f91cf
4
+ data.tar.gz: f7076b3470b45e3250f5794894f8e7e6a93cbcf034c76a67e117eea52649e5d1
5
5
  SHA512:
6
- metadata.gz: a3b24b61eaa393c7e7db0a4e30b20d34bafad08aca60961e04480c8efb016b27800555f862a0078857eb69a2cd61e4e69119a372d13480eca34dcba2dff431a0
7
- data.tar.gz: a843783dd90df321366ed0c4b35232f66f4a0b44dd0dddb7fb46eababd92eb678219ad57f8814dea50b6f3df7e44aa6f430dd49fea1fca2a62c8c5214a817105
6
+ metadata.gz: 8b19a598c52226ea8b503c9b887e0d091cc387937799475af8b65473907c8ce3e5546126cdaa73f1c0610d6c5bcf36f46f181c190a01b14e9c7f8c85d297a637
7
+ data.tar.gz: 7363a8ca3f95e42abd6dbb6584db23bcb80c6dc2872067e23a930c75d51ff80f737df96a50ec01547051591a4a2725b137d56b5690c0a9f54fe496070180856a
@@ -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,
@@ -18,7 +18,9 @@ module Honeymaker
18
18
  ticker: ticker,
19
19
  base: parts[0],
20
20
  quote: parts[1],
21
- minimum_base_size: product["minQty"]&.to_s,
21
+ # minQty is deprecated and far above what BingX accepts (~15 USDT on BTC-USDT against a
22
+ # 0.5 minNotional); the notional is the venue's only real floor.
23
+ minimum_base_size: "0",
22
24
  minimum_quote_size: product["minNotional"]&.to_s,
23
25
  maximum_base_size: product["maxQty"]&.to_s,
24
26
  maximum_quote_size: product["maxNotional"]&.to_s,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal/util"
4
+
3
5
  module Honeymaker
4
6
  module Exchanges
5
7
  class Bitget < Exchange
@@ -8,14 +10,16 @@ module Honeymaker
8
10
  def get_tickers_info
9
11
  with_rescue do
10
12
  response = connection.get("/api/v2/spot/public/symbols")
13
+ products = response.body["data"]
14
+ prices = products.any? { |p| p["quoteCoin"] != "USDT" } ? usdt_prices : {}
11
15
 
12
- response.body["data"].filter_map do |product|
16
+ products.filter_map do |product|
13
17
  {
14
18
  ticker: product["symbol"],
15
19
  base: product["baseCoin"],
16
20
  quote: product["quoteCoin"],
17
21
  minimum_base_size: product["minTradeAmount"],
18
- minimum_quote_size: product["minTradeUSDT"],
22
+ minimum_quote_size: quote_minimum(product["quoteCoin"], product["minTradeUSDT"], prices),
19
23
  maximum_base_size: product["maxTradeAmount"],
20
24
  maximum_quote_size: nil,
21
25
  base_decimals: product["quantityPrecision"].to_i,
@@ -44,6 +48,31 @@ module Honeymaker
44
48
 
45
49
  private
46
50
 
51
+ # minTradeUSDT is a USDT figure on every pair, whatever its quote
52
+ # (https://www.bitget.com/docs/catalog/classic-spot-market/classic-spot-market#get-symbol-info), so
53
+ # on ETHBTC its 1 meant a 1 BTC minimum. Converted at the quote's own USDT price (never the
54
+ # traded pair's); with no price to convert at, the minimum is left to the venue (0) rather than
55
+ # guessed. Same rule as deltabadger's Exchanges::Bitget#quote_minimum.
56
+ def quote_minimum(quote, usdt_minimum, prices)
57
+ minimum = usdt_minimum.to_s.to_d
58
+ return usdt_minimum if quote == "USDT" || minimum.zero?
59
+
60
+ if (price = prices["#{quote}USDT"].to_d).positive?
61
+ (minimum / price).to_s("F")
62
+ elsif (price = prices["USDT#{quote}"].to_d).positive?
63
+ (minimum * price).to_s("F")
64
+ else
65
+ "0"
66
+ end
67
+ end
68
+
69
+ # symbol => lastPr for the whole market; {} when the read fails, so conversions fall back to 0.
70
+ def usdt_prices
71
+ connection.get("/api/v2/spot/market/tickers").body["data"].to_h { |t| [t["symbol"], t["lastPr"]] }
72
+ rescue StandardError
73
+ {}
74
+ end
75
+
47
76
  def connection
48
77
  @connection ||= build_connection(BASE_URL)
49
78
  end
@@ -5,11 +5,6 @@ module Honeymaker
5
5
  class Hyperliquid < Exchange
6
6
  BASE_URL = "https://api.hyperliquid.xyz"
7
7
 
8
- # Hyperliquid enforces a hard 10-USDC minimum order value on every spot pair (spotMeta exposes
9
- # no per-token base floor). Surfaced as minimum_quote_size so consumers skip/reject sub-10
10
- # orders up front instead of having the exchange reject them.
11
- MINIMUM_QUOTE_SIZE = 10
12
-
13
8
  def get_tickers_info
14
9
  with_rescue do
15
10
  response = connection.post("/info") do |req|
@@ -30,7 +25,7 @@ module Honeymaker
30
25
  base: base_token["name"],
31
26
  quote: quote_token["name"],
32
27
  minimum_base_size: nil,
33
- minimum_quote_size: MINIMUM_QUOTE_SIZE,
28
+ minimum_quote_size: Floors.minimum_quote(venue: "hyperliquid", quote: quote_token["name"]),
34
29
  maximum_base_size: nil,
35
30
  maximum_quote_size: nil,
36
31
  base_decimals: base_token["szDecimals"] || 0,
@@ -27,25 +27,6 @@ module Honeymaker
27
27
  }
28
28
  ].freeze
29
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
30
  def get_tickers_info
50
31
  with_rescue do
51
32
  # aclass_base=all, not a bare call: the default response carries only the "currency" class,
@@ -74,7 +55,7 @@ module Honeymaker
74
55
  base: base,
75
56
  quote: quote,
76
57
  minimum_base_size: info["ordermin"],
77
- minimum_quote_size: (REAL_COSTMIN[quote] || info["costmin"] || 0).to_s,
58
+ minimum_quote_size: Floors.minimum_quote(venue: "kraken", quote: quote, published: info["costmin"] || 0).to_s,
78
59
  maximum_base_size: nil,
79
60
  maximum_quote_size: nil,
80
61
  base_decimals: info["lot_decimals"],
@@ -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"],
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Honeymaker
6
+ # Minimum order values venues enforce but do not publish (or publish too low), found by test
7
+ # orders. A ticker's minimum_quote_size is max(published, floor): a floor never lowers a minimum
8
+ # the venue itself raised. Consumed by the venue parsers here and by data-api for venues it
9
+ # builds itself (Alpaca crypto).
10
+ module Floors
11
+ ANY_QUOTE = :any
12
+
13
+ # [venue, asset_class] => { quote => floor }. A nil asset class covers every class the venue
14
+ # lists; ANY_QUOTE covers every quote.
15
+ MINIMUM_QUOTE = {
16
+ # Kraken's AssetPairs costmin is below what AddOrder accepts ("EOrder:Cost minimum not met");
17
+ # these are the per-quote costs it actually enforces. The native core mirrors this table
18
+ # (crates/honeymaker/src/kraken/normalize.rs REAL_COSTMIN; parity-tested).
19
+ ["kraken", nil] => {
20
+ "AUD" => 10,
21
+ "CAD" => 5,
22
+ "CHF" => 5,
23
+ "DAI" => 5,
24
+ "ETH" => 0.002,
25
+ "EUR" => 0.5,
26
+ "GBP" => 5,
27
+ "JPY" => 500,
28
+ "PYUSD" => 5,
29
+ "RLUSD" => 5,
30
+ "USD" => 5,
31
+ "USDC" => 5,
32
+ "USDQ" => 5,
33
+ "USDR" => 5,
34
+ "USDT" => 5,
35
+ "XBT" => 0.00005
36
+ },
37
+ # "Order must have minimum value of 10 USDC." on every spot pair; spotMeta publishes no
38
+ # minimum. The value is measured in USDC whatever the pair's quote, so it covers all quotes.
39
+ ["hyperliquid", nil] => { ANY_QUOTE => 10 },
40
+ # "cost basis must be >= minimal amount of order 10" on crypto; the assets API publishes only
41
+ # min_order_size (base). Stocks are not floored here (notional orders from $1).
42
+ ["alpaca", "crypto"] => { "USD" => 10 }
43
+ }.freeze
44
+
45
+ # The minimum quote size to store: max(published, floor). Returns published when no floor
46
+ # applies (nil when neither exists), and the floor when published is missing or not a number.
47
+ # Whichever wins is returned as given, so callers keep their own formatting.
48
+ def self.minimum_quote(venue:, quote:, asset_class: nil, published: nil)
49
+ quotes = MINIMUM_QUOTE[[venue.to_s, asset_class&.to_s]] || MINIMUM_QUOTE[[venue.to_s, nil]] || {}
50
+ floor = quotes.fetch(quote.to_s) { quotes[ANY_QUOTE] }
51
+ return published if floor.nil?
52
+ return floor if published.nil?
53
+
54
+ BigDecimal(published.to_s) > BigDecimal(floor.to_s) ? published : floor
55
+ rescue ArgumentError
56
+ floor
57
+ end
58
+ end
59
+ end
@@ -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.6"
5
5
  end
data/lib/honeymaker.rb CHANGED
@@ -7,6 +7,7 @@ require "json"
7
7
  require_relative "honeymaker/version"
8
8
  require_relative "honeymaker/result"
9
9
  require_relative "honeymaker/utils"
10
+ require_relative "honeymaker/floors"
10
11
  require_relative "honeymaker/exchange"
11
12
  require_relative "honeymaker/client"
12
13
  require_relative "honeymaker/native"
@@ -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
@@ -23,7 +23,7 @@ class Honeymaker::Exchanges::BingXTest < Minitest::Test
23
23
  assert_equal "BTC-USDT", ticker[:ticker]
24
24
  assert_equal "BTC", ticker[:base]
25
25
  assert_equal "USDT", ticker[:quote]
26
- assert_equal "0.00001", ticker[:minimum_base_size]
26
+ assert_equal "0", ticker[:minimum_base_size] # deprecated minQty (0.00001) ignored
27
27
  assert_equal "1", ticker[:minimum_quote_size]
28
28
  assert_equal "500", ticker[:maximum_base_size]
29
29
  assert_equal 5, ticker[:base_decimals]
@@ -54,8 +54,60 @@ class Honeymaker::Exchanges::BitgetTest < Minitest::Test
54
54
  assert_equal BigDecimal("67125.67"), result.data[:ask]
55
55
  end
56
56
 
57
+ def test_usdt_minimum_on_a_usdt_pair_is_not_converted_and_no_prices_are_read
58
+ connection = stub
59
+ connection.expects(:get).with("/api/v2/spot/public/symbols").returns(stub(body: load_fixture("bitget_symbols.json")))
60
+ @exchange.instance_variable_set(:@connection, connection)
61
+
62
+ assert_equal "5", @exchange.get_tickers_info.data.first[:minimum_quote_size]
63
+ end
64
+
65
+ def test_non_usdt_quote_converted_at_the_quotes_own_usdt_price
66
+ tickers = [{ "symbol" => "BTCUSDT", "lastPr" => "50000" },
67
+ { "symbol" => "ETHBTC", "lastPr" => "0.05" }] # the traded pair's price: never used
68
+ data = quote_minimums({ "ETHBTC" => "BTC" }, tickers)
69
+ assert_equal BigDecimal("0.0001"), BigDecimal(data["ETHBTC"])
70
+ end
71
+
72
+ def test_inverse_usdt_quote_pair_multiplies
73
+ data = quote_minimums({ "BTCTRY" => "TRY" }, [{ "symbol" => "USDTTRY", "lastPr" => "40" }])
74
+ assert_equal BigDecimal("200"), BigDecimal(data["BTCTRY"])
75
+ end
76
+
77
+ def test_missing_price_leaves_the_minimum_to_the_venue
78
+ data = quote_minimums({ "ETHBTC" => "BTC", "XEUR" => "EUR" },
79
+ [{ "symbol" => "BTCUSDT", "lastPr" => "0" }, { "symbol" => "ETHBTC", "lastPr" => "0.05" }])
80
+ assert_equal "0", data["ETHBTC"]
81
+ assert_equal "0", data["XEUR"]
82
+ end
83
+
84
+ def test_failed_price_read_falls_back_like_a_missing_price
85
+ data = quote_minimums({ "ETHBTC" => "BTC", "BTCUSDT" => "USDT" }, Faraday::ServerError.new("boom"))
86
+ assert_equal "0", data["ETHBTC"]
87
+ assert_equal "5", data["BTCUSDT"]
88
+ end
89
+
57
90
  private
58
91
 
92
+ # symbol => quote rows, each with minTradeUSDT 5; tickers is the market's ticker list or the
93
+ # error its read raises. Returns symbol => minimum_quote_size.
94
+ def quote_minimums(pairs, tickers)
95
+ products = pairs.map do |symbol, quote|
96
+ load_fixture("bitget_symbols.json")["data"].first.merge("symbol" => symbol, "quoteCoin" => quote, "minTradeUSDT" => "5")
97
+ end
98
+ connection = stub
99
+ connection.stubs(:get).with("/api/v2/spot/public/symbols").returns(stub(body: { "data" => products }))
100
+ if tickers.is_a?(Exception)
101
+ connection.stubs(:get).with("/api/v2/spot/market/tickers").raises(tickers)
102
+ else
103
+ connection.stubs(:get).with("/api/v2/spot/market/tickers").returns(stub(body: { "data" => tickers }))
104
+ end
105
+ @exchange.instance_variable_set(:@connection, connection)
106
+ result = @exchange.get_tickers_info
107
+ assert result.success?, result.errors.inspect
108
+ result.data.to_h { |t| [t[:ticker], t[:minimum_quote_size]] }
109
+ end
110
+
59
111
  def stub_connection(body)
60
112
  response = stub(body: body)
61
113
  connection = stub
@@ -123,6 +123,18 @@ class Honeymaker::Exchanges::KrakenTest < Minitest::Test
123
123
  assert_equal "5", ticker[:minimum_quote_size]
124
124
  end
125
125
 
126
+ def test_cost_floor_is_a_max_of_published_and_floor
127
+ body = load_fixture("kraken_asset_pairs.json")
128
+ key, info = body["result"].find { |_, i| i["wsname"]&.end_with?("/USDT") }
129
+ body["result"] = { key => info.merge("costmin" => "0.5") }
130
+ stub_request(body)
131
+ assert_equal "5", @exchange.get_tickers_info.data.first[:minimum_quote_size]
132
+
133
+ body["result"] = { key => info.merge("costmin" => "12.5") }
134
+ stub_request(body)
135
+ assert_equal "12.5", @exchange.get_tickers_info.data.first[:minimum_quote_size]
136
+ end
137
+
126
138
  def test_get_tickers_info_handles_api_error
127
139
  body = { "error" => ["EGeneral:Internal error"], "result" => {} }
128
140
  stub_request(body)
@@ -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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::FloorsTest < Minitest::Test
6
+ F = Honeymaker::Floors
7
+
8
+ def test_max_of_published_and_floor
9
+ assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD", published: "0.5")
10
+ assert_equal "12.5", F.minimum_quote(venue: "kraken", quote: "USD", published: "12.5")
11
+ assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD", published: "5.0") # tie: the floor
12
+ end
13
+
14
+ def test_missing_or_unreadable_published_gives_the_floor
15
+ assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD")
16
+ assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD", published: "abc")
17
+ end
18
+
19
+ def test_no_floor_keeps_published
20
+ assert_equal "0.1", F.minimum_quote(venue: "kraken", quote: "FOO", published: "0.1")
21
+ assert_equal "3", F.minimum_quote(venue: "binance", quote: "USDT", published: "3")
22
+ assert_nil F.minimum_quote(venue: "binance", quote: "USDT")
23
+ end
24
+
25
+ def test_alpaca_floor_is_keyed_by_asset_class
26
+ assert_equal 10, F.minimum_quote(venue: "alpaca", quote: "USD", asset_class: "crypto", published: "1")
27
+ assert_equal 10, F.minimum_quote(venue: :alpaca, quote: "USD", asset_class: :crypto)
28
+ assert_equal "1", F.minimum_quote(venue: "alpaca", quote: "USD", asset_class: "stock", published: "1")
29
+ assert_nil F.minimum_quote(venue: "alpaca", quote: "USD", asset_class: "stock")
30
+ end
31
+
32
+ def test_hyperliquid_floor_covers_every_quote
33
+ assert_equal 10, F.minimum_quote(venue: "hyperliquid", quote: "USDC")
34
+ assert_equal 10, F.minimum_quote(venue: "hyperliquid", quote: "USDT0", published: "1")
35
+ assert_equal "25", F.minimum_quote(venue: "hyperliquid", quote: "USDC", published: "25")
36
+ end
37
+ end
@@ -43,6 +43,32 @@ class KrakenExchangeParityTest < Minitest::Test
43
43
  [500, JSON_CT, "{}"]].each { |reply| assert_parity(reply, &:get_tickers_info) }
44
44
  end
45
45
 
46
+ # The native REAL_COSTMIN mirrors Honeymaker::Floors: every floored quote, published below,
47
+ # above, equal to, missing and unreadable, plus an unfloored quote.
48
+ def test_cost_floors_match_the_shared_table
49
+ floors = Honeymaker::Floors::MINIMUM_QUOTE[["kraken", nil]]
50
+ pairs = {}
51
+ (floors.keys + ["FOO"]).each do |quote|
52
+ floor = floors[quote] || 1
53
+ [(BigDecimal(floor.to_s) / 10).to_s("F"), (BigDecimal(floor.to_s) * 10).to_s("F"), floor.to_s, nil, "abc", floor * 3].each_with_index do |costmin, i|
54
+ ws = "B#{i}/#{quote}"
55
+ pairs[ws] = { "altname" => ws, "wsname" => ws, "costmin" => costmin }
56
+ end
57
+ end
58
+ reply = [200, JSON_CT, { "error" => [], "result" => pairs }.to_json]
59
+ assert_parity(reply, &:get_tickers_info)
60
+
61
+ d = VenueDouble.new([reply])
62
+ e = Honeymaker::Native::KrakenExchange.new
63
+ e.instance_variable_set(:@connection, e.send(:build_connection, d.url))
64
+ got = e.get_tickers_info.data.to_h { |t| [t[:ticker], t[:minimum_quote_size]] }
65
+ assert_equal floors["USD"].to_s, got["B0/USD"]
66
+ assert_equal "50.0", got["B1/USD"]
67
+ assert_equal "0.1", got["B0/FOO"]
68
+ ensure
69
+ d&.close
70
+ end
71
+
46
72
  def test_one_malformed_row_fails_the_whole_catalogue_like_legacy
47
73
  replies = [false, 1, { "x" => 1 }].map do |bad|
48
74
  pairs = { "XXBTZUSD" => { "altname" => "XBTUSD", "wsname" => "XBT/USD", "status" => "online" },
metadata CHANGED
@@ -1,14 +1,14 @@
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.6
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Deltabadger
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-16 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -131,6 +131,7 @@ files:
131
131
  - lib/honeymaker/exchanges/kraken.rb
132
132
  - lib/honeymaker/exchanges/kucoin.rb
133
133
  - lib/honeymaker/exchanges/mexc.rb
134
+ - lib/honeymaker/floors.rb
134
135
  - lib/honeymaker/native.rb
135
136
  - lib/honeymaker/native/kraken.rb
136
137
  - lib/honeymaker/result.rb
@@ -204,6 +205,7 @@ files:
204
205
  - test/honeymaker/exchanges/kraken_test.rb
205
206
  - test/honeymaker/exchanges/kucoin_test.rb
206
207
  - test/honeymaker/exchanges/mexc_test.rb
208
+ - test/honeymaker/floors_test.rb
207
209
  - test/honeymaker/honeymaker_test.rb
208
210
  - test/honeymaker/result_test.rb
209
211
  - test/honeymaker/utils_test.rb