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.
- checksums.yaml +4 -4
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +19 -4
- data/Cargo.lock +1078 -0
- data/Cargo.toml +14 -0
- data/README.md +39 -0
- data/Rakefile +28 -16
- data/crates/honeymaker/Cargo.toml +18 -0
- data/crates/honeymaker/src/encode.rs +106 -0
- data/crates/honeymaker/src/kraken/lookup.rs +140 -0
- data/crates/honeymaker/src/kraken/mod.rs +5 -0
- data/crates/honeymaker/src/kraken/normalize.rs +536 -0
- data/crates/honeymaker/src/kraken/requests.rs +286 -0
- data/crates/honeymaker/src/kraken/sign.rs +166 -0
- data/crates/honeymaker/src/kraken/trades.rs +201 -0
- data/crates/honeymaker/src/lib.rs +10 -0
- data/crates/honeymaker/src/num.rs +115 -0
- data/crates/honeymaker/src/semantics.rs +100 -0
- data/crates/honeymaker/src/types.rs +85 -0
- data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
- data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
- data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
- data/crates/honeymaker/tests/vectors/decimal.json +146 -0
- data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
- data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
- data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
- data/crates/honeymaker-client/Cargo.toml +30 -0
- data/crates/honeymaker-client/examples/probe.rs +147 -0
- data/crates/honeymaker-client/src/kraken.rs +514 -0
- data/crates/honeymaker-client/src/lib.rs +4 -0
- data/crates/honeymaker-client/src/transport/connect.rs +583 -0
- data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
- data/crates/honeymaker-client/src/transport/response.rs +169 -0
- data/crates/honeymaker-client/src/transport.rs +467 -0
- data/crates/honeymaker-client/tests/classification.rs +203 -0
- data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
- data/crates/honeymaker-client/tests/response.rs +522 -0
- data/crates/honeymaker-client/tests/support/mod.rs +550 -0
- data/crates/honeymaker-client/tests/transport.rs +828 -0
- data/ext/honeymaker_native/Cargo.toml +16 -0
- data/ext/honeymaker_native/extconf.rb +6 -0
- data/ext/honeymaker_native/src/convert.rs +150 -0
- data/ext/honeymaker_native/src/kraken.rs +362 -0
- data/ext/honeymaker_native/src/lib.rs +90 -0
- data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
- data/lib/honeymaker/clients/bingx.rb +10 -6
- data/lib/honeymaker/clients/bitrue.rb +43 -13
- data/lib/honeymaker/clients/bitvavo.rb +9 -4
- data/lib/honeymaker/clients/bybit.rb +19 -2
- data/lib/honeymaker/clients/coinbase.rb +3 -2
- data/lib/honeymaker/clients/gemini.rb +10 -0
- data/lib/honeymaker/clients/hyperliquid.rb +62 -3
- data/lib/honeymaker/clients/kraken.rb +6 -1
- data/lib/honeymaker/clients/mexc.rb +16 -2
- data/lib/honeymaker/exchanges/coinbase.rb +5 -4
- data/lib/honeymaker/exchanges/mexc.rb +8 -3
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/version.rb +1 -1
- data/lib/honeymaker.rb +12 -2
- data/script/parity/kraken_client_live.rb +191 -0
- data/script/parity/kraken_live.rb +70 -0
- data/script/parity/kraken_transport.rb +604 -0
- data/script/parity/resolve.c +49 -0
- data/script/release/push_gems.sh +28 -0
- data/script/vectors/kraken.rb +50 -0
- data/script/vectors/kraken_stage2.rb +146 -0
- data/test/fixtures/coinbase_products.json +1 -1
- data/test/honeymaker/clients/bingx_client_test.rb +19 -6
- data/test/honeymaker/clients/bitrue_client_test.rb +48 -0
- data/test/honeymaker/clients/bitvavo_client_test.rb +15 -0
- data/test/honeymaker/clients/bybit_client_test.rb +25 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
- data/test/honeymaker/clients/gemini_client_test.rb +15 -0
- data/test/honeymaker/clients/hyperliquid_client_test.rb +120 -4
- data/test/honeymaker/clients/kraken_client_test.rb +15 -0
- data/test/honeymaker/clients/mexc_client_test.rb +12 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
- data/test/honeymaker/exchanges/mexc_test.rb +14 -2
- data/test/native/backend_switch_test.rb +53 -0
- data/test/native/convert_test.rb +137 -0
- data/test/native/gem_isolation_test.rb +17 -0
- data/test/native/loader_test.rb +29 -0
- data/test/native/lockstep_test.rb +23 -0
- data/test/native/signing_smoke_test.rb +28 -0
- data/test/parity/kraken_client_parity_test.rb +398 -0
- data/test/parity/kraken_exchange_parity_test.rb +172 -0
- data/test/release/branch_hardening_test.rb +34 -0
- data/test/release/push_gems_test.rb +79 -0
- data/test/smoke.rb +24 -0
- data/test/support/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- 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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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/
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
107
|
-
get_signed("/api/v1/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
110
|
-
|
|
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"
|
|
226
|
+
when "New", "PartiallyFilled" then :open
|
|
211
227
|
when "Filled" then :closed
|
|
212
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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"] ?
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
"
|
|
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`
|
|
55
|
-
# `status`. Absent
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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"],
|