honeymaker 0.12.3 → 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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +19 -4
  5. data/Cargo.lock +1078 -0
  6. data/Cargo.toml +14 -0
  7. data/README.md +39 -0
  8. data/Rakefile +28 -16
  9. data/crates/honeymaker/Cargo.toml +18 -0
  10. data/crates/honeymaker/src/encode.rs +106 -0
  11. data/crates/honeymaker/src/kraken/lookup.rs +140 -0
  12. data/crates/honeymaker/src/kraken/mod.rs +5 -0
  13. data/crates/honeymaker/src/kraken/normalize.rs +536 -0
  14. data/crates/honeymaker/src/kraken/requests.rs +286 -0
  15. data/crates/honeymaker/src/kraken/sign.rs +166 -0
  16. data/crates/honeymaker/src/kraken/trades.rs +201 -0
  17. data/crates/honeymaker/src/lib.rs +10 -0
  18. data/crates/honeymaker/src/num.rs +115 -0
  19. data/crates/honeymaker/src/semantics.rs +100 -0
  20. data/crates/honeymaker/src/types.rs +85 -0
  21. data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
  22. data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
  23. data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
  24. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  25. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  26. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  27. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  28. data/crates/honeymaker-client/Cargo.toml +30 -0
  29. data/crates/honeymaker-client/examples/probe.rs +147 -0
  30. data/crates/honeymaker-client/src/kraken.rs +514 -0
  31. data/crates/honeymaker-client/src/lib.rs +4 -0
  32. data/crates/honeymaker-client/src/transport/connect.rs +583 -0
  33. data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
  34. data/crates/honeymaker-client/src/transport/response.rs +169 -0
  35. data/crates/honeymaker-client/src/transport.rs +467 -0
  36. data/crates/honeymaker-client/tests/classification.rs +203 -0
  37. data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
  38. data/crates/honeymaker-client/tests/response.rs +522 -0
  39. data/crates/honeymaker-client/tests/support/mod.rs +550 -0
  40. data/crates/honeymaker-client/tests/transport.rs +828 -0
  41. data/ext/honeymaker_native/Cargo.toml +16 -0
  42. data/ext/honeymaker_native/extconf.rb +6 -0
  43. data/ext/honeymaker_native/src/convert.rs +150 -0
  44. data/ext/honeymaker_native/src/kraken.rs +362 -0
  45. data/ext/honeymaker_native/src/lib.rs +90 -0
  46. data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
  47. data/lib/honeymaker/clients/bingx.rb +10 -6
  48. data/lib/honeymaker/clients/bitrue.rb +43 -13
  49. data/lib/honeymaker/clients/bitvavo.rb +9 -4
  50. data/lib/honeymaker/clients/bybit.rb +19 -2
  51. data/lib/honeymaker/clients/coinbase.rb +3 -2
  52. data/lib/honeymaker/clients/gemini.rb +10 -0
  53. data/lib/honeymaker/clients/hyperliquid.rb +62 -3
  54. data/lib/honeymaker/clients/kraken.rb +6 -1
  55. data/lib/honeymaker/clients/mexc.rb +16 -2
  56. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  57. data/lib/honeymaker/exchanges/mexc.rb +8 -3
  58. data/lib/honeymaker/native/kraken.rb +263 -0
  59. data/lib/honeymaker/native.rb +42 -0
  60. data/lib/honeymaker/version.rb +1 -1
  61. data/lib/honeymaker.rb +12 -2
  62. data/script/parity/kraken_client_live.rb +191 -0
  63. data/script/parity/kraken_live.rb +70 -0
  64. data/script/parity/kraken_transport.rb +604 -0
  65. data/script/parity/resolve.c +49 -0
  66. data/script/release/push_gems.sh +28 -0
  67. data/script/vectors/kraken.rb +50 -0
  68. data/script/vectors/kraken_stage2.rb +146 -0
  69. data/test/fixtures/coinbase_products.json +1 -1
  70. data/test/honeymaker/clients/bingx_client_test.rb +19 -6
  71. data/test/honeymaker/clients/bitrue_client_test.rb +48 -0
  72. data/test/honeymaker/clients/bitvavo_client_test.rb +15 -0
  73. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  74. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  75. data/test/honeymaker/clients/gemini_client_test.rb +15 -0
  76. data/test/honeymaker/clients/hyperliquid_client_test.rb +120 -4
  77. data/test/honeymaker/clients/kraken_client_test.rb +15 -0
  78. data/test/honeymaker/clients/mexc_client_test.rb +12 -0
  79. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  80. data/test/honeymaker/exchanges/mexc_test.rb +14 -2
  81. data/test/native/backend_switch_test.rb +53 -0
  82. data/test/native/convert_test.rb +137 -0
  83. data/test/native/gem_isolation_test.rb +17 -0
  84. data/test/native/loader_test.rb +29 -0
  85. data/test/native/lockstep_test.rb +23 -0
  86. data/test/native/signing_smoke_test.rb +28 -0
  87. data/test/parity/kraken_client_parity_test.rb +398 -0
  88. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  89. data/test/release/branch_hardening_test.rb +34 -0
  90. data/test/release/push_gems_test.rb +79 -0
  91. data/test/smoke.rb +24 -0
  92. data/test/support/kraken_signing_smoke.rb +21 -0
  93. data/test/support/parity_helper.rb +62 -0
  94. data/test/support/venue_double.rb +100 -0
  95. metadata +84 -4
@@ -0,0 +1,90 @@
1
+ use honeymaker_core::num::Num;
2
+ use magnus::{Ruby, Value, prelude::*, value::BoxValue};
3
+
4
+ /// Ruby's BigDecimal behind the core's Num trait. BoxValue registers the object with the GC,
5
+ /// so it is safe inside the Vecs and structs the normalizers return. Calls retain the GVL.
6
+ pub struct RubyDecimal(BoxValue<Value>);
7
+
8
+ impl RubyDecimal {
9
+ pub fn value(&self) -> Value {
10
+ *self.0
11
+ }
12
+
13
+ fn wrap(v: Value) -> Self {
14
+ RubyDecimal(BoxValue::new(v))
15
+ }
16
+
17
+ fn ruby() -> Ruby {
18
+ Ruby::get().expect("called on a Ruby thread")
19
+ }
20
+ }
21
+
22
+ impl Num for RubyDecimal {
23
+ type Error = magnus::Error;
24
+
25
+ fn parse(text: &str) -> Result<Self, magnus::Error> {
26
+ Ok(Self::wrap(
27
+ Self::ruby()
28
+ .module_kernel()
29
+ .funcall("BigDecimal", (text,))?,
30
+ ))
31
+ }
32
+
33
+ fn parse_to_s(v: &serde_json::Value) -> Result<Self, magnus::Error> {
34
+ let ruby = Self::ruby();
35
+ let obj = BoxValue::new(crate::convert::from_json(&ruby, v)?);
36
+ let text = BoxValue::new(obj.funcall::<_, _, Value>("to_s", ())?);
37
+ Ok(Self::wrap(
38
+ ruby.module_kernel().funcall("BigDecimal", (*text,))?,
39
+ ))
40
+ }
41
+
42
+ fn parse_json(v: &serde_json::Value) -> Result<Self, magnus::Error> {
43
+ let ruby = Self::ruby();
44
+ let obj = BoxValue::new(crate::convert::from_json(&ruby, v)?);
45
+ Ok(Self::wrap(
46
+ ruby.module_kernel().funcall("BigDecimal", (*obj,))?,
47
+ ))
48
+ }
49
+
50
+ fn string_op(
51
+ v: &serde_json::Value,
52
+ op: honeymaker_core::semantics::StringOp<'_>,
53
+ ) -> Result<serde_json::Value, magnus::Error> {
54
+ use honeymaker_core::semantics::StringOp;
55
+ let ruby = Self::ruby();
56
+ let obj = BoxValue::new(crate::convert::from_json(&ruby, v)?);
57
+ let result: Value = match op {
58
+ StringOp::Index(key) => obj.funcall("[]", (key,))?,
59
+ StringOp::Split(sep) => obj.funcall("split", (sep,))?,
60
+ StringOp::First => obj.funcall("[]", (0,))?,
61
+ StringOp::DowncaseSymbol | StringOp::Symbol => {
62
+ let text = BoxValue::new(if matches!(op, StringOp::DowncaseSymbol) {
63
+ obj.funcall("downcase", ())?
64
+ } else {
65
+ *obj
66
+ });
67
+ // Validate now, in legacy's evaluation order, before parsing decimals.
68
+ text.funcall::<_, _, Value>("to_sym", ())?;
69
+ *text
70
+ }
71
+ };
72
+ crate::convert::to_json(&ruby, result)
73
+ }
74
+
75
+ fn add(&self, o: &Self) -> Result<Self, magnus::Error> {
76
+ Ok(Self::wrap(self.value().funcall("+", (o.value(),))?))
77
+ }
78
+
79
+ fn sub(&self, o: &Self) -> Result<Self, magnus::Error> {
80
+ Ok(Self::wrap(self.value().funcall("-", (o.value(),))?))
81
+ }
82
+
83
+ fn div(&self, o: &Self) -> Result<Self, magnus::Error> {
84
+ Ok(Self::wrap(self.value().funcall("/", (o.value(),))?))
85
+ }
86
+
87
+ fn is_zero(&self) -> Result<bool, magnus::Error> {
88
+ self.value().funcall("zero?", ())
89
+ }
90
+ }
@@ -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
@@ -96,6 +96,15 @@ module Honeymaker
96
96
  })
97
97
  end
98
98
 
99
+ # Closed orders that /v5/order/realtime no longer returns.
100
+ def order_history(category:, symbol: nil, order_id: nil, order_link_id: nil, order_status: nil,
101
+ start_time: nil, end_time: nil, limit: nil, cursor: nil)
102
+ get_authenticated("/v5/order/history", {
103
+ category: category, symbol: symbol, orderId: order_id, orderLinkId: order_link_id,
104
+ orderStatus: order_status, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
105
+ })
106
+ end
107
+
99
108
  def transaction_log(account_type: nil, category: nil, currency: nil, type: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
100
109
  get_authenticated("/v5/account/transaction-log", {
101
110
  accountType: account_type, category: category, currency: currency, type: type,
@@ -116,6 +125,13 @@ module Honeymaker
116
125
  })
117
126
  end
118
127
 
128
+ # Off-chain deposits from another Bybit user; query-record does not include them.
129
+ def internal_deposit_records(tx_id: nil, coin: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
130
+ get_authenticated("/v5/asset/deposit/query-internal-record", {
131
+ txID: tx_id, coin: coin, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
132
+ })
133
+ end
134
+
119
135
  def withdraw_records(coin: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil, withdraw_type: nil)
120
136
  get_authenticated("/v5/asset/withdraw/query-record", {
121
137
  coin: coin, startTime: start_time, endTime: end_time,
@@ -207,9 +223,10 @@ module Honeymaker
207
223
  def parse_order_status(status)
208
224
  case status
209
225
  when "Created", "Untriggered" then :unknown
210
- when "New", "PartiallyFilled", "PartiallyFilledCanceled" then :open
226
+ when "New", "PartiallyFilled" then :open
211
227
  when "Filled" then :closed
212
- when "Cancelled", "Expired", "Deactivated" then :cancelled
228
+ # PartiallyFilledCanceled is final: a spot order cancelled after a partial fill.
229
+ when "Cancelled", "PartiallyFilledCanceled", "Expired", "Deactivated" then :cancelled
213
230
  when "Rejected" then :failed
214
231
  else :unknown
215
232
  end
@@ -105,8 +105,9 @@ module Honeymaker
105
105
  get("/api/v3/brokerage/key_permissions")
106
106
  end
107
107
 
108
- def list_accounts
109
- get("/api/v3/brokerage/accounts")
108
+ # One page of accounts; pass the response's `cursor` back while `has_next` is true.
109
+ def list_accounts(limit: nil, cursor: nil)
110
+ get("/api/v3/brokerage/accounts", { limit: limit, cursor: cursor })
110
111
  end
111
112
 
112
113
  def list_portfolios
@@ -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|
@@ -148,6 +148,7 @@ module Honeymaker
148
148
  return Result::Success.new({}) if wanted.empty?
149
149
 
150
150
  by_order = Hash.new { |h, k| h[k] = [] }
151
+ seen = {}
151
152
  offset = 0
152
153
  pages = 0
153
154
  loop do
@@ -161,7 +162,11 @@ module Honeymaker
161
162
  trades = result.data.dig("result", "trades") || {}
162
163
  break if trades.empty?
163
164
 
164
- trades.each_value do |t|
165
+ trades.each_pair do |trade_id, t|
166
+ # Offset paging over a list that grows at the top can serve a trade twice: count it once.
167
+ next if seen.key?(trade_id)
168
+
169
+ seen[trade_id] = true
165
170
  otxid = t["ordertxid"]
166
171
  by_order[otxid] << t if wanted.include?(otxid)
167
172
  end
@@ -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,
@@ -6,7 +6,7 @@ module Honeymaker
6
6
  BASE_URL = "https://api.coinbase.com"
7
7
 
8
8
  ASSET_BLACKLIST = [
9
- "RENDER", # has the same external_id as RNDR
9
+ "RNDR", # delisted; has the same external_id as RENDER
10
10
  "ZETACHAIN", # has the same external_id as ZETA
11
11
  "WAXL" # has the same external_id as AXL
12
12
  ].freeze
@@ -51,10 +51,11 @@ module Honeymaker
51
51
 
52
52
  private
53
53
 
54
- # Coinbase only signals problems explicitly: a `trading_disabled` flag or a non-"online"
55
- # `status`. Absent either signal, treat the pair as enabled.
54
+ # Coinbase only signals problems explicitly: a `trading_disabled`, `is_disabled`, `limit_only`
55
+ # or `cancel_only` flag, or a non-"online" `status`. Absent any signal, treat the pair as
56
+ # enabled. limit_only is reported as not tradable, like Kraken's limit_only status.
56
57
  def trading_enabled?(product)
57
- return false if product["trading_disabled"] == true
58
+ return false if %w[trading_disabled is_disabled limit_only cancel_only].any? { |flag| product[flag] == true }
58
59
  return false if product.key?("status") && product["status"] != "online"
59
60
 
60
61
  true
@@ -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"],