honeymaker 0.12.3 → 0.12.4

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 (81) 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/bybit.rb +19 -2
  48. data/lib/honeymaker/clients/coinbase.rb +3 -2
  49. data/lib/honeymaker/clients/kraken.rb +6 -1
  50. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  51. data/lib/honeymaker/native/kraken.rb +263 -0
  52. data/lib/honeymaker/native.rb +42 -0
  53. data/lib/honeymaker/version.rb +1 -1
  54. data/lib/honeymaker.rb +12 -2
  55. data/script/parity/kraken_client_live.rb +191 -0
  56. data/script/parity/kraken_live.rb +70 -0
  57. data/script/parity/kraken_transport.rb +604 -0
  58. data/script/parity/resolve.c +49 -0
  59. data/script/release/push_gems.sh +28 -0
  60. data/script/vectors/kraken.rb +50 -0
  61. data/script/vectors/kraken_stage2.rb +146 -0
  62. data/test/fixtures/coinbase_products.json +1 -1
  63. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  64. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  65. data/test/honeymaker/clients/kraken_client_test.rb +15 -0
  66. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  67. data/test/native/backend_switch_test.rb +53 -0
  68. data/test/native/convert_test.rb +137 -0
  69. data/test/native/gem_isolation_test.rb +17 -0
  70. data/test/native/loader_test.rb +29 -0
  71. data/test/native/lockstep_test.rb +23 -0
  72. data/test/native/signing_smoke_test.rb +28 -0
  73. data/test/parity/kraken_client_parity_test.rb +398 -0
  74. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  75. data/test/release/branch_hardening_test.rb +34 -0
  76. data/test/release/push_gems_test.rb +79 -0
  77. data/test/smoke.rb +24 -0
  78. data/test/support/kraken_signing_smoke.rb +21 -0
  79. data/test/support/parity_helper.rb +62 -0
  80. data/test/support/venue_double.rb +100 -0
  81. metadata +84 -4
@@ -0,0 +1,286 @@
1
+ use super::sign::{Credentials, next_nonce, private_headers, public_headers};
2
+ use crate::encode::www_form;
3
+ use std::collections::BTreeMap;
4
+
5
+ #[derive(Clone, Debug)]
6
+ pub enum Param {
7
+ One(String),
8
+ /// Repeat the wire key, as URI.encode_www_form does for Arrays. `oflags`
9
+ /// follows the legacy caller instead: join with commas, or omit when empty.
10
+ Many(Vec<String>),
11
+ }
12
+
13
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
14
+ pub enum Method {
15
+ Get,
16
+ Post,
17
+ }
18
+
19
+ pub struct Built {
20
+ pub method: Method,
21
+ pub path: &'static str,
22
+ /// Wire pairs in legacy field order, retaining repeated keys for Many.
23
+ /// POST includes nonce first; GET callers encode/sort these as query params.
24
+ pub pairs: Vec<(String, String)>,
25
+ /// POST only: the exact form body that is signed.
26
+ pub body: Option<String>,
27
+ pub headers: Vec<(String, String)>,
28
+ }
29
+
30
+ pub type Fields = &'static [(&'static str, &'static str)];
31
+
32
+ /// Wire order and names as in the legacy hash literals, `(wire name, param name)`. "nonce" goes
33
+ /// first for private calls.
34
+ pub fn layout(op: &str) -> Option<(Method, &'static str, Fields)> {
35
+ use Method::*;
36
+ Some(match op {
37
+ // Rust callers only (the gem has no wrapper for them); wire names from Kraken's REST docs.
38
+ "open_orders" => (
39
+ Post,
40
+ "/0/private/OpenOrders",
41
+ &[
42
+ ("trades", "trades"),
43
+ ("userref", "userref"),
44
+ ("cl_ord_id", "cl_ord_id"),
45
+ ],
46
+ ),
47
+ "closed_orders" => (
48
+ Post,
49
+ "/0/private/ClosedOrders",
50
+ &[
51
+ ("trades", "trades"),
52
+ ("userref", "userref"),
53
+ ("cl_ord_id", "cl_ord_id"),
54
+ ("start", "start"),
55
+ ("end", "end_time"),
56
+ ("ofs", "ofs"),
57
+ ("closetime", "closetime"),
58
+ ("consolidate_taker", "consolidate_taker"),
59
+ ],
60
+ ),
61
+ "query_orders_info" => (
62
+ Post,
63
+ "/0/private/QueryOrders",
64
+ &[
65
+ ("trades", "trades"),
66
+ ("userref", "userref"),
67
+ ("txid", "txid"),
68
+ ("consolidate_taker", "consolidate_taker"),
69
+ ],
70
+ ),
71
+ "add_order" => (
72
+ Post,
73
+ "/0/private/AddOrder",
74
+ &[
75
+ ("ordertype", "ordertype"),
76
+ ("type", "type"),
77
+ ("volume", "volume"),
78
+ ("pair", "pair"),
79
+ ("userref", "userref"),
80
+ ("cl_ord_id", "cl_ord_id"),
81
+ ("displayvol", "displayvol"),
82
+ ("price", "price"),
83
+ ("price2", "price2"),
84
+ ("trigger", "trigger"),
85
+ ("leverage", "leverage"),
86
+ ("reduce_only", "reduce_only"),
87
+ ("stptype", "stptype"),
88
+ ("oflags", "oflags"),
89
+ ("timeinforce", "timeinforce"),
90
+ ("starttm", "starttm"),
91
+ ("expiretm", "expiretm"),
92
+ ("close[ordertype]", "close"),
93
+ ("close[price]", "close_price"),
94
+ ("close[price2]", "close_price2"),
95
+ ("deadline", "deadline"),
96
+ ("validate", "validate"),
97
+ ],
98
+ ),
99
+ "cancel_order" => (
100
+ Post,
101
+ "/0/private/CancelOrder",
102
+ &[("txid", "txid"), ("cl_ord_id", "cl_ord_id")],
103
+ ),
104
+ "get_tradable_asset_pairs" => (
105
+ Get,
106
+ "/0/public/AssetPairs",
107
+ &[
108
+ ("pair", "pairs"),
109
+ ("info", "info"),
110
+ ("country_code", "country_code"),
111
+ ("aclass_base", "aclass_base"),
112
+ ],
113
+ ),
114
+ "get_asset_info" => (
115
+ Get,
116
+ "/0/public/Assets",
117
+ &[("asset", "assets"), ("aclass", "aclass")],
118
+ ),
119
+ "get_ticker_information" => (Get, "/0/public/Ticker", &[("pair", "pair")]),
120
+ "get_extended_balance" => (Post, "/0/private/BalanceEx", &[]),
121
+ "get_api_key_info" => (Post, "/0/private/GetApiKeyInfo", &[]),
122
+ "get_ohlc_data" => (
123
+ Get,
124
+ "/0/public/OHLC",
125
+ &[
126
+ ("pair", "pair"),
127
+ ("interval", "interval"),
128
+ ("since", "since"),
129
+ ],
130
+ ),
131
+ "get_trades_history" => (
132
+ Post,
133
+ "/0/private/TradesHistory",
134
+ &[
135
+ ("type", "type"),
136
+ ("trades", "trades"),
137
+ ("start", "start"),
138
+ ("end", "end_time"),
139
+ ("ofs", "ofs"),
140
+ ],
141
+ ),
142
+ "get_ledgers" => (
143
+ Post,
144
+ "/0/private/Ledgers",
145
+ &[
146
+ ("asset", "asset"),
147
+ ("type", "type"),
148
+ ("start", "start"),
149
+ ("end", "end_time"),
150
+ ("ofs", "ofs"),
151
+ ],
152
+ ),
153
+ "get_withdraw_addresses" => (
154
+ Post,
155
+ "/0/private/WithdrawAddresses",
156
+ &[("asset", "asset"), ("method", "method")],
157
+ ),
158
+ "get_withdraw_methods" => (Post, "/0/private/WithdrawMethods", &[("asset", "asset")]),
159
+ "withdraw" => (
160
+ Post,
161
+ "/0/private/Withdraw",
162
+ &[
163
+ ("asset", "asset"),
164
+ ("key", "key"),
165
+ ("amount", "amount"),
166
+ ("address", "address"),
167
+ ],
168
+ ),
169
+ "get_earn_allocations" => (
170
+ Post,
171
+ "/0/private/Earn/Allocations",
172
+ &[
173
+ ("ascending", "ascending"),
174
+ ("converted_asset", "converted_asset"),
175
+ ("hide_zero_allocations", "hide_zero_allocations"),
176
+ ],
177
+ ),
178
+ _ => return None,
179
+ })
180
+ }
181
+
182
+ pub fn build(
183
+ op: &str,
184
+ params: &BTreeMap<String, Param>,
185
+ creds: Option<&Credentials>,
186
+ ) -> Result<Built, String> {
187
+ let (method, path, fields) = layout(op).ok_or_else(|| format!("unknown Kraken op {op}"))?;
188
+ let mut pairs = Vec::new();
189
+ for (wire, name) in fields {
190
+ match params.get(*name) {
191
+ None => {}
192
+ Some(Param::Many(v)) if *name == "oflags" => {
193
+ if !v.is_empty() {
194
+ pairs.push((wire.to_string(), v.join(",")));
195
+ }
196
+ }
197
+ Some(Param::Many(v)) => pairs.extend(v.iter().map(|s| (wire.to_string(), s.clone()))),
198
+ Some(Param::One(s)) => pairs.push((wire.to_string(), s.clone())),
199
+ }
200
+ }
201
+ Ok(match method {
202
+ Method::Get => Built {
203
+ method,
204
+ path,
205
+ pairs,
206
+ body: None,
207
+ headers: public_headers(),
208
+ },
209
+ Method::Post => {
210
+ pairs.insert(
211
+ 0,
212
+ (
213
+ "nonce".into(),
214
+ next_nonce(creds.map(|c| c.api_key.as_str())).to_string(),
215
+ ),
216
+ );
217
+ let body = www_form(&pairs);
218
+ let headers = private_headers(path, &body, creds);
219
+ Built {
220
+ method,
221
+ path,
222
+ pairs,
223
+ body: Some(body),
224
+ headers,
225
+ }
226
+ }
227
+ })
228
+ }
229
+
230
+ #[cfg(test)]
231
+ mod tests {
232
+ use super::*;
233
+
234
+ #[test]
235
+ fn build_repeats_keys_in_wire_order_after_nonce() {
236
+ let params = BTreeMap::from([
237
+ ("consolidate_taker".into(), Param::One("false".into())),
238
+ ("txid".into(), Param::Many(vec!["O1".into(), "O2".into()])),
239
+ ("userref".into(), Param::One("7".into())),
240
+ ("trades".into(), Param::One("true".into())),
241
+ ]);
242
+ let built = build("query_orders_info", &params, None).unwrap();
243
+ assert_eq!(built.method, Method::Post);
244
+ assert_eq!(built.path, "/0/private/QueryOrders");
245
+ assert_eq!(built.pairs[0].0, "nonce");
246
+ assert!(built.pairs[0].1.parse::<u64>().unwrap() > 0);
247
+ assert_eq!(
248
+ built.pairs[1..],
249
+ [
250
+ ("trades", "true"),
251
+ ("userref", "7"),
252
+ ("txid", "O1"),
253
+ ("txid", "O2"),
254
+ ("consolidate_taker", "false")
255
+ ]
256
+ .map(|(k, v)| (k.to_string(), v.to_string()))
257
+ );
258
+ assert_eq!(
259
+ built.body.unwrap(),
260
+ format!(
261
+ "nonce={}&trades=true&userref=7&txid=O1&txid=O2&consolidate_taker=false",
262
+ built.pairs[0].1
263
+ )
264
+ );
265
+ }
266
+
267
+ #[test]
268
+ fn build_joins_oflags_and_omits_empty_arrays() {
269
+ let mut params = BTreeMap::from([(
270
+ "oflags".into(),
271
+ Param::Many(vec!["viqc".into(), "fciq".into()]),
272
+ )]);
273
+ let built = build("add_order", &params, None).unwrap();
274
+ assert_eq!(built.pairs[1..], [("oflags".into(), "viqc,fciq".into())]);
275
+ params.insert("oflags".into(), Param::Many(vec![]));
276
+ assert_eq!(build("add_order", &params, None).unwrap().pairs.len(), 1);
277
+ params.insert("txid".into(), Param::Many(vec![]));
278
+ assert_eq!(
279
+ build("query_orders_info", &params, None)
280
+ .unwrap()
281
+ .pairs
282
+ .len(),
283
+ 1
284
+ );
285
+ }
286
+ }
@@ -0,0 +1,166 @@
1
+ use crate::encode::ruby_decode64;
2
+ use base64::Engine;
3
+ use hmac::{Hmac, Mac};
4
+ use sha2::{Digest, Sha256, Sha512};
5
+ use std::collections::HashMap;
6
+ use std::sync::Mutex;
7
+
8
+ #[derive(Clone)]
9
+ pub struct Credentials {
10
+ pub api_key: String,
11
+ pub api_secret: String,
12
+ }
13
+
14
+ /// The secret never reaches a log or a panic message.
15
+ impl std::fmt::Debug for Credentials {
16
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17
+ f.debug_struct("Credentials")
18
+ .field("api_key", &self.api_key)
19
+ .field("api_secret", &"<redacted>")
20
+ .finish()
21
+ }
22
+ }
23
+
24
+ impl Credentials {
25
+ pub fn authenticated(&self) -> bool {
26
+ !self.api_key.is_empty() && !self.api_secret.is_empty()
27
+ }
28
+ }
29
+
30
+ // Kraken rejects a nonce <= the last it saw per API key: µs clock, max(now, last + 1), per key.
31
+ // Cross-process reuse of a key is not protected, as in the legacy gem. The binding calls this
32
+ // with the GVL held, so a fork never finds these mutexes locked.
33
+ static NONCES: Mutex<Option<HashMap<[u8; 32], u64>>> = Mutex::new(None);
34
+ static FIXED: Mutex<Option<u64>> = Mutex::new(None);
35
+
36
+ pub fn next_nonce(api_key: Option<&str>) -> u64 {
37
+ if let Some(n) = *FIXED.lock().unwrap_or_else(|p| p.into_inner()) {
38
+ return n;
39
+ }
40
+ let key: [u8; 32] = Sha256::digest(api_key.unwrap_or("\0__no_api_key__").as_bytes()).into();
41
+ let now = std::time::SystemTime::now()
42
+ .duration_since(std::time::UNIX_EPOCH)
43
+ .unwrap_or_default()
44
+ .as_micros() as u64;
45
+ let mut g = NONCES.lock().unwrap_or_else(|p| p.into_inner());
46
+ let map = g.get_or_insert_with(HashMap::new);
47
+ let next = now.max(map.get(&key).copied().unwrap_or(0) + 1);
48
+ map.insert(key, next);
49
+ next
50
+ }
51
+
52
+ /// Test hook: every nonce is `n` until reset to None.
53
+ pub fn set_fixed_nonce(n: Option<u64>) {
54
+ *FIXED.lock().unwrap_or_else(|p| p.into_inner()) = n;
55
+ }
56
+
57
+ pub fn reset_nonces() {
58
+ *NONCES.lock().unwrap_or_else(|p| p.into_inner()) = None;
59
+ }
60
+
61
+ pub fn public_headers() -> Vec<(String, String)> {
62
+ vec![
63
+ ("Accept".into(), "application/json".into()),
64
+ ("Content-Type".into(), "application/json".into()),
65
+ ("User-Agent".into(), "Honeymaker Ruby".into()),
66
+ ]
67
+ }
68
+
69
+ /// Legacy: nonce taken back out of the body; message = path + SHA256(nonce + body);
70
+ /// key = Base64.decode64(secret); HMAC-SHA512; strict Base64.
71
+ pub fn private_headers(
72
+ path: &str,
73
+ body: &str,
74
+ creds: Option<&Credentials>,
75
+ ) -> Vec<(String, String)> {
76
+ let Some(c) = creds.filter(|c| c.authenticated()) else {
77
+ return public_headers();
78
+ };
79
+ let nonce = body
80
+ .split('&')
81
+ .find_map(|kv| kv.strip_prefix("nonce="))
82
+ .unwrap_or("");
83
+ let mut data = nonce.as_bytes().to_vec();
84
+ data.extend_from_slice(body.as_bytes());
85
+ let mut message = path.as_bytes().to_vec();
86
+ message.extend_from_slice(&Sha256::digest(&data));
87
+ let mut mac = Hmac::<Sha512>::new_from_slice(&ruby_decode64(c.api_secret.as_bytes()))
88
+ .expect("HMAC takes any key length");
89
+ mac.update(&message);
90
+ let sign = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
91
+ vec![
92
+ ("API-Key".into(), c.api_key.clone()),
93
+ ("API-Sign".into(), sign),
94
+ ("Accept".into(), "application/json".into()),
95
+ (
96
+ "Content-Type".into(),
97
+ "application/x-www-form-urlencoded".into(),
98
+ ),
99
+ ("User-Agent".into(), "Honeymaker Ruby".into()),
100
+ ]
101
+ }
102
+
103
+ #[cfg(test)]
104
+ mod tests {
105
+ use super::*;
106
+
107
+ // FIXED affects every key, so all nonce tests hold this guard until both stores are reset.
108
+ static TEST_LOCK: Mutex<()> = Mutex::new(());
109
+ struct NonceState {
110
+ _guard: std::sync::MutexGuard<'static, ()>,
111
+ }
112
+ impl NonceState {
113
+ fn new() -> Self {
114
+ let guard = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
115
+ set_fixed_nonce(None);
116
+ reset_nonces();
117
+ Self { _guard: guard }
118
+ }
119
+ }
120
+ impl Drop for NonceState {
121
+ fn drop(&mut self) {
122
+ set_fixed_nonce(None);
123
+ reset_nonces();
124
+ }
125
+ }
126
+
127
+ #[test]
128
+ fn ten_thousand_nonces_are_strictly_increasing() {
129
+ let _state = NonceState::new();
130
+ let mut last = next_nonce(Some("monotonic"));
131
+ for _ in 1..10_000 {
132
+ let next = next_nonce(Some("monotonic"));
133
+ assert!(next > last, "{next} <= {last}");
134
+ last = next;
135
+ }
136
+ }
137
+
138
+ #[test]
139
+ fn keys_have_independent_nonce_sequences() {
140
+ let _state = NonceState::new();
141
+ let future = u64::MAX - 10_000;
142
+ let key: [u8; 32] = Sha256::digest(b"key-a").into();
143
+ NONCES
144
+ .lock()
145
+ .unwrap()
146
+ .get_or_insert_with(HashMap::new)
147
+ .insert(key, future);
148
+ assert_eq!(next_nonce(Some("key-a")), future + 1);
149
+ let b = next_nonce(Some("key-b"));
150
+ assert!(b < future, "key-a must not advance key-b");
151
+ assert_eq!(next_nonce(Some("key-a")), future + 2);
152
+ assert!(next_nonce(Some("key-b")) > b);
153
+ }
154
+
155
+ #[test]
156
+ fn fixed_nonce_is_returned_until_cleared() {
157
+ let _state = NonceState::new();
158
+ set_fixed_nonce(Some(42));
159
+ for key in [Some("key-a"), Some("key-b"), None] {
160
+ assert_eq!(next_nonce(key), 42);
161
+ assert_eq!(next_nonce(key), 42);
162
+ }
163
+ set_fixed_nonce(None);
164
+ assert!(next_nonce(Some("key-a")) > 42);
165
+ }
166
+ }
@@ -0,0 +1,201 @@
1
+ use super::normalize::{dec, order_type};
2
+ use crate::OrderType;
3
+ use crate::num::{NormError, Num};
4
+ use serde_json::Value;
5
+
6
+ pub struct TradeAggregate<D> {
7
+ pub side: Option<Value>, // first["type"]&.to_sym (not downcased)
8
+ pub order_type: OrderType,
9
+ pub vol: D,
10
+ pub cost: D,
11
+ pub fee: D,
12
+ pub price: Option<D>, // vol.zero? ? nil : cost / vol
13
+ }
14
+
15
+ /// Per-order executed aggregate over its (non-empty) trades, as legacy `aggregate_trades`.
16
+ pub fn aggregate<D: Num>(trades: &[Value]) -> Result<TradeAggregate<D>, NormError<D::Error>> {
17
+ let first = trades.first().ok_or("no trades")?;
18
+ let first = first.as_object().ok_or_else(|| format!("trade: {first}"))?;
19
+ // Array#sum starts from Integer 0: 0 + BigDecimal("-0") is +0.0, so start from "0" too.
20
+ let sum = |f: &str| -> Result<D, NormError<D::Error>> {
21
+ let mut acc = D::parse("0").map_err(NormError::Num)?;
22
+ for t in trades {
23
+ acc = acc.add(&dec(t.get(f))?).map_err(NormError::Num)?;
24
+ }
25
+ Ok(acc)
26
+ };
27
+ let (vol, cost, fee) = (sum("vol")?, sum("cost")?, sum("fee")?);
28
+ let price = if vol.is_zero().map_err(NormError::Num)? {
29
+ None
30
+ } else {
31
+ Some(cost.div(&vol).map_err(NormError::Num)?)
32
+ };
33
+ let side = match first.get("type") {
34
+ None | Some(Value::Null) => None,
35
+ Some(v) if crate::semantics::is_string(v) => {
36
+ Some(D::string_op(v, crate::semantics::StringOp::Symbol).map_err(NormError::Num)?)
37
+ }
38
+ Some(other) => return Err(format!("type.to_sym: {other}").into()), // false/1 have no to_sym
39
+ };
40
+ Ok(TradeAggregate {
41
+ side,
42
+ order_type: order_type(first.get("ordertype")),
43
+ vol,
44
+ cost,
45
+ fee,
46
+ price,
47
+ })
48
+ }
49
+
50
+ use super::requests::Param;
51
+ use crate::semantics::truthy;
52
+ use std::collections::{BTreeMap, HashSet};
53
+
54
+ pub enum TradesStep<D> {
55
+ Call(BTreeMap<String, Param>),
56
+ Done(Vec<(Value, TradeAggregate<D>)>),
57
+ Venue(Vec<Value>),
58
+ Unreadable,
59
+ /// The scan could not be completed; never a partial answer.
60
+ Incomplete(String),
61
+ }
62
+
63
+ /// Honeymaker::Clients::Kraken#closed_orders_from_trades for Rust callers, made strict: it completes
64
+ /// (the distinct trades read reach Kraken's count) or it fails. Containers and records must have
65
+ /// Kraken's shape; where Ruby would stop early, trust a malformed container or skip a malformed
66
+ /// record and return what it had, this returns `Incomplete` or a shape error. The gem keeps its
67
+ /// own loop.
68
+ pub struct TradesPager {
69
+ wanted: Vec<String>,
70
+ start: Option<i64>,
71
+ max_pages: u32,
72
+ offset: u64,
73
+ /// The largest `count` any page reported: the bar for completeness (R22).
74
+ count: u64,
75
+ pages: u32,
76
+ seen: HashSet<String>,
77
+ by_order: Vec<(Value, Vec<Value>)>,
78
+ }
79
+
80
+ impl TradesPager {
81
+ pub fn new(order_ids: Vec<String>, start: Option<i64>, max_pages: u32) -> Self {
82
+ Self {
83
+ wanted: order_ids,
84
+ start,
85
+ max_pages,
86
+ offset: 0,
87
+ count: 0,
88
+ pages: 0,
89
+ seen: HashSet::new(),
90
+ by_order: Vec::new(),
91
+ }
92
+ }
93
+
94
+ /// No wanted ids: nothing to ask (legacy's `return Result::Success.new({}) if wanted.empty?`).
95
+ pub fn start<D: Num>(&mut self) -> Result<TradesStep<D>, NormError<D::Error>> {
96
+ if self.wanted.is_empty() {
97
+ return Ok(TradesStep::Done(Vec::new()));
98
+ }
99
+ Ok(TradesStep::Call(self.params()))
100
+ }
101
+
102
+ fn params(&self) -> BTreeMap<String, Param> {
103
+ let mut p = BTreeMap::from([("ofs".to_string(), Param::One(self.offset.to_string()))]);
104
+ if let Some(s) = self.start {
105
+ p.insert("start".to_string(), Param::One(s.to_string()));
106
+ }
107
+ p
108
+ }
109
+
110
+ fn done<D: Num>(&mut self) -> Result<TradesStep<D>, NormError<D::Error>> {
111
+ std::mem::take(&mut self.by_order)
112
+ .into_iter()
113
+ .map(|(k, trades)| Ok((k, aggregate::<D>(&trades)?)))
114
+ .collect::<Result<Vec<_>, _>>()
115
+ .map(TradesStep::Done)
116
+ }
117
+
118
+ pub fn feed<D: Num>(&mut self, data: &Value) -> Result<TradesStep<D>, NormError<D::Error>> {
119
+ let Some(obj) = data.as_object() else {
120
+ return Ok(TradesStep::Unreadable);
121
+ };
122
+ if let Some(Value::Array(e)) = obj.get("error")
123
+ && e.iter().any(|x| truthy(Some(x)))
124
+ {
125
+ return Ok(TradesStep::Venue(e.clone()));
126
+ }
127
+ // dig("result", "trades") raises unless result is nil or a Hash; nil is an unreadable page here.
128
+ let result = match obj.get("result") {
129
+ None | Some(Value::Null) => {
130
+ return Ok(TradesStep::Incomplete(
131
+ "TradesHistory answered without a result".into(),
132
+ ));
133
+ }
134
+ Some(Value::Object(m)) => m,
135
+ Some(other) => return Err(format!("undefined method 'dig' for {other}").into()),
136
+ };
137
+ let count = result.get("count").and_then(Value::as_u64);
138
+ let trades = match result.get("trades") {
139
+ Some(Value::Object(m)) => m,
140
+ other => {
141
+ return Ok(TradesStep::Incomplete(format!(
142
+ "TradesHistory trades is not an object: {other:?}"
143
+ )));
144
+ }
145
+ };
146
+ // Every record must be an order's trade before any is filtered out: a malformed one is an
147
+ // error, never a skipped row.
148
+ let mut rows = Vec::with_capacity(trades.len());
149
+ for (id, t) in trades {
150
+ match t.get("ordertxid") {
151
+ Some(Value::String(o)) if t.is_object() => rows.push((id, o.clone(), t)),
152
+ _ => return Err(format!("trade {id} is not a Kraken trade: {t}").into()),
153
+ }
154
+ }
155
+ for (id, otxid, t) in rows {
156
+ // Offset paging can serve a trade twice (review ruling 3): count it once.
157
+ if !self.seen.insert(id.clone()) {
158
+ continue;
159
+ }
160
+ if self.wanted.contains(&otxid) {
161
+ let key = Value::String(otxid);
162
+ match self.by_order.iter_mut().find(|(k, _)| *k == key) {
163
+ Some((_, v)) => v.push(t.clone()),
164
+ None => self.by_order.push((key, vec![t.clone()])),
165
+ }
166
+ }
167
+ }
168
+ let Some(page_count) = count else {
169
+ return Ok(TradesStep::Incomplete(format!(
170
+ "TradesHistory count unreadable: {:?}",
171
+ result.get("count")
172
+ )));
173
+ };
174
+ // Held to the largest count seen: a shrinking count never lowers the bar (R22).
175
+ self.count = self.count.max(page_count);
176
+ let count = self.count;
177
+ let distinct = self.seen.len() as u64;
178
+ if distinct >= count {
179
+ return self.done();
180
+ }
181
+ if trades.is_empty() {
182
+ return Ok(TradesStep::Incomplete(format!(
183
+ "TradesHistory ended after {distinct} of {count} trades"
184
+ )));
185
+ }
186
+ self.pages += 1;
187
+ self.offset += trades.len() as u64; // raw rows: the position in Kraken's list
188
+ if self.offset >= count {
189
+ return Ok(TradesStep::Incomplete(format!(
190
+ "TradesHistory served {count} rows but only {distinct} distinct trades"
191
+ )));
192
+ }
193
+ if self.pages >= self.max_pages {
194
+ return Ok(TradesStep::Incomplete(format!(
195
+ "more than {} TradesHistory pages",
196
+ self.max_pages
197
+ )));
198
+ }
199
+ Ok(TradesStep::Call(self.params()))
200
+ }
201
+ }
@@ -0,0 +1,10 @@
1
+ //! Unified clients for cryptocurrency exchange APIs: request builders, signing, normalizers.
2
+ //! No Ruby and no I/O. The honeymaker gem wraps it; deltabadger-rs will use it directly.
3
+
4
+ pub mod encode;
5
+ pub mod kraken;
6
+ pub mod num;
7
+ pub mod semantics;
8
+ pub mod types;
9
+
10
+ pub use types::{Balance, Order, OrderId, OrderStatus, OrderType, Side};