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,16 @@
1
+ [package]
2
+ name = "honeymaker_native" # rb-sys finds the extension by this package name
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ publish = false
8
+
9
+ [lib]
10
+ name = "honeymaker_native" # Init_honeymaker_native, loaded as honeymaker/honeymaker_native
11
+ crate-type = ["cdylib"]
12
+
13
+ [dependencies]
14
+ honeymaker-core = { package = "honeymaker", path = "../../crates/honeymaker" }
15
+ magnus = "0.9"
16
+ serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision"] }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("honeymaker/honeymaker_native")
@@ -0,0 +1,150 @@
1
+ //! Ruby ⇄ JSON values. Root Ruby objects across calls with BoxValue; only Rust-owned JSON
2
+ //! values go in Rust collections. Ruby containers are filled as each element is created.
3
+
4
+ use magnus::{
5
+ Float, Integer, RArray, RHash, RString, Ruby, Value,
6
+ encoding::EncodingCapable,
7
+ r_hash::ForEach,
8
+ value::{BoxValue, ReprValue},
9
+ };
10
+
11
+ pub fn to_json(ruby: &Ruby, v: Value) -> Result<serde_json::Value, magnus::Error> {
12
+ use serde_json::Value as J;
13
+ let v = BoxValue::new(v);
14
+ if v.is_nil() {
15
+ return Ok(J::Null);
16
+ }
17
+ if v.is_kind_of(ruby.class_true_class()) {
18
+ return Ok(J::Bool(true));
19
+ }
20
+ if v.is_kind_of(ruby.class_false_class()) {
21
+ return Ok(J::Bool(false));
22
+ }
23
+ if let Some(s) = RString::from_value(*v) {
24
+ // Copy before any Ruby call: the borrowed slice cannot survive a GC/mutation.
25
+ let bytes = unsafe { s.as_slice() }.to_vec();
26
+ if s.enc_get() == ruby.utf8_encindex()
27
+ && let Ok(text) = std::str::from_utf8(&bytes)
28
+ {
29
+ return Ok(J::String(text.to_string()));
30
+ }
31
+ let encoding: Value = s.funcall("encoding", ())?;
32
+ let name: String = encoding.funcall("name", ())?;
33
+ return Ok(serde_json::json!({honeymaker_core::semantics::RUBY_STRING: [bytes, name]}));
34
+ }
35
+ if Integer::from_value(*v).is_some() || Float::from_value(*v).is_some() {
36
+ // Preserve Float#to_s exactly: legacy passes these digits to BigDecimal.
37
+ // Non-finite floats use the sentinel understood by semantics::to_s.
38
+ let text: String = v.funcall("to_s", ())?;
39
+ return Ok(match text.parse::<serde_json::Number>() {
40
+ Ok(n) => J::Number(n),
41
+ Err(_) => J::Object(serde_json::Map::from_iter([(
42
+ honeymaker_core::semantics::RUBY_FLOAT.to_string(),
43
+ J::String(text),
44
+ )])),
45
+ });
46
+ }
47
+ if let Some(a) = RArray::from_value(*v) {
48
+ let a = BoxValue::new(a);
49
+ let mut out = Vec::with_capacity(a.len());
50
+ for i in 0..a.len() {
51
+ out.push(to_json(ruby, a.entry(i as isize)?)?);
52
+ }
53
+ return Ok(J::Array(out));
54
+ }
55
+ if let Some(h) = RHash::from_value(*v) {
56
+ let h = BoxValue::new(h);
57
+ let mut m = serde_json::Map::new();
58
+ h.foreach(|k: Value, val: Value| {
59
+ let k = BoxValue::new(k);
60
+ let val = BoxValue::new(val);
61
+ let key = RString::from_value(*k).ok_or_else(|| shape(ruby, "non-string key"))?;
62
+ let prefix = honeymaker_core::semantics::RUBY_KEY;
63
+ let key = match key.to_string() {
64
+ Ok(text) if !text.starts_with("\0ruby_") => text,
65
+ _ => format!("{prefix}{}", to_json(ruby, *k)?),
66
+ };
67
+ m.insert(key, to_json(ruby, *val)?);
68
+ Ok(ForEach::Continue)
69
+ })?;
70
+ return Ok(J::Object(m));
71
+ }
72
+ Err(shape(ruby, &format!("unsupported value {}", v.inspect())))
73
+ }
74
+
75
+ pub fn from_json(ruby: &Ruby, v: &serde_json::Value) -> Result<Value, magnus::Error> {
76
+ use serde_json::Value as J;
77
+ Ok(match v {
78
+ J::Null => ruby.qnil().as_value(),
79
+ J::Bool(true) => ruby.qtrue().as_value(),
80
+ J::Bool(false) => ruby.qfalse().as_value(),
81
+ J::String(s) => ruby.str_new(s).as_value(),
82
+ J::Number(n) => {
83
+ let text = n.to_string();
84
+ let conv = if text.contains(['.', 'e', 'E']) {
85
+ "Float"
86
+ } else {
87
+ "Integer"
88
+ };
89
+ ruby.module_kernel().funcall(conv, (text,))?
90
+ }
91
+ J::Array(a) => {
92
+ let out = BoxValue::new(ruby.ary_new_capa(a.len()));
93
+ for e in a {
94
+ out.push(from_json(ruby, e)?)?;
95
+ }
96
+ out.as_value()
97
+ }
98
+ J::Object(m) => {
99
+ if let Some(J::Array(parts)) = m
100
+ .get(honeymaker_core::semantics::RUBY_STRING)
101
+ .filter(|_| m.len() == 1)
102
+ {
103
+ let bytes: Vec<u8> = serde_json::from_value(parts[0].clone())
104
+ .map_err(|_| shape(ruby, "invalid Ruby string bytes"))?;
105
+ let out = BoxValue::new(ruby.str_from_slice(&bytes));
106
+ out.funcall::<_, _, Value>(
107
+ "force_encoding",
108
+ (parts[1].as_str().unwrap_or("UTF-8"),),
109
+ )?;
110
+ return Ok(out.as_value());
111
+ }
112
+ if let Some(J::String(text)) = m
113
+ .get(honeymaker_core::semantics::RUBY_FLOAT)
114
+ .filter(|_| m.len() == 1)
115
+ {
116
+ let f = match text.as_str() {
117
+ "Infinity" => f64::INFINITY,
118
+ "-Infinity" => f64::NEG_INFINITY,
119
+ _ => f64::NAN,
120
+ };
121
+ // Kernel#Float("Infinity") raises, so build the float directly.
122
+ return Ok(ruby.float_from_f64(f).as_value());
123
+ }
124
+ let h = BoxValue::new(ruby.hash_new());
125
+ for (k, val) in m {
126
+ let key =
127
+ BoxValue::new(from_json(ruby, &honeymaker_core::semantics::object_key(k))?);
128
+ let value = from_json(ruby, val)?;
129
+ h.aset(*key, value)?;
130
+ }
131
+ h.as_value()
132
+ }
133
+ })
134
+ }
135
+
136
+ // Used by the normalizers added in subsequent tasks.
137
+ #[allow(dead_code)]
138
+ pub fn sym(s: &str) -> Value {
139
+ Ruby::get()
140
+ .expect("called on a Ruby thread")
141
+ .sym_new(s)
142
+ .as_value()
143
+ }
144
+
145
+ pub fn shape(ruby: &Ruby, msg: &str) -> magnus::Error {
146
+ let class = ruby
147
+ .eval::<magnus::ExceptionClass>("Honeymaker::Native::ShapeError")
148
+ .unwrap_or_else(|_| ruby.exception_standard_error());
149
+ magnus::Error::new(class, msg.to_string())
150
+ }
@@ -0,0 +1,362 @@
1
+ use crate::convert::{self, sym};
2
+ use crate::ruby_decimal::RubyDecimal;
3
+ use honeymaker_core::kraken::normalize::{self, Finished};
4
+ use honeymaker_core::kraken::requests::{Fields, Method, layout};
5
+ use honeymaker_core::kraken::sign::{self, Credentials};
6
+ use honeymaker_core::kraken::trades::aggregate;
7
+ use honeymaker_core::num::NormError;
8
+ use magnus::{
9
+ RArray, RHash, RString, Ruby, Value, encoding::EncodingCapable, prelude::*, r_hash::ForEach,
10
+ value::BoxValue,
11
+ };
12
+
13
+ #[magnus::wrap(class = "Honeymaker::Native::Ext::Kraken", free_immediately)]
14
+ pub struct Kraken {
15
+ creds: Option<Credentials>,
16
+ /// Legacy keys the nonce store by the raw api_key; nil gets its own slot.
17
+ nonce_key: Option<String>,
18
+ }
19
+
20
+ pub(crate) fn norm(ruby: &Ruby, e: NormError<magnus::Error>) -> magnus::Error {
21
+ match e {
22
+ NormError::Num(err) => err, // Ruby's own ArgumentError from BigDecimal()
23
+ NormError::Shape(msg) => convert::shape(ruby, &msg),
24
+ }
25
+ }
26
+
27
+ fn headers(ruby: &Ruby, pairs: &[(String, String)]) -> Result<RHash, magnus::Error> {
28
+ let h = ruby.hash_new();
29
+ for (k, v) in pairs {
30
+ h.aset(k.as_str(), v.as_str())?;
31
+ }
32
+ Ok(h)
33
+ }
34
+
35
+ pub(crate) fn verdict<T>(
36
+ ruby: &Ruby,
37
+ f: Finished<T>,
38
+ ok: impl FnOnce(T) -> Result<Value, magnus::Error>,
39
+ ) -> Result<RArray, magnus::Error> {
40
+ let out = ruby.ary_new();
41
+ match f {
42
+ Finished::Ok(v) => {
43
+ out.push(ruby.str_new("ok"))?;
44
+ out.push(ok(v)?)?;
45
+ }
46
+ // The Ruby wrapper builds its Failure from result.data["error"], exactly as legacy.
47
+ Finished::Venue(_) => out.push(ruby.str_new("venue"))?,
48
+ Finished::Unreadable => out.push(ruby.str_new("unreadable"))?,
49
+ }
50
+ Ok(out)
51
+ }
52
+
53
+ fn opt_dec(ruby: &Ruby, d: &Option<RubyDecimal>) -> Value {
54
+ d.as_ref()
55
+ .map(|d| d.value())
56
+ .unwrap_or_else(|| ruby.qnil().as_value())
57
+ }
58
+
59
+ fn side_symbol(ruby: &Ruby, value: Option<&serde_json::Value>) -> Result<Value, magnus::Error> {
60
+ match value {
61
+ Some(v) => convert::from_json(ruby, v)?.funcall("to_sym", ()),
62
+ None => Ok(ruby.qnil().as_value()),
63
+ }
64
+ }
65
+
66
+ impl Kraken {
67
+ fn new(api_key: Option<String>, api_secret: Option<String>) -> Self {
68
+ let creds = match (&api_key, &api_secret) {
69
+ (None, None) => None,
70
+ (k, s) => Some(Credentials {
71
+ api_key: k.clone().unwrap_or_default(),
72
+ api_secret: s.clone().unwrap_or_default(),
73
+ }),
74
+ };
75
+ Kraken {
76
+ creds,
77
+ nonce_key: api_key,
78
+ }
79
+ }
80
+
81
+ fn layout_for(
82
+ ruby: &Ruby,
83
+ op: &str,
84
+ want: Method,
85
+ ) -> Result<(&'static str, Fields), magnus::Error> {
86
+ match layout(op) {
87
+ Some((m, path, fields)) if m == want => Ok((path, fields)),
88
+ _ => Err(convert::shape(
89
+ ruby,
90
+ &format!("unknown Kraken {want:?} op {op}"),
91
+ )),
92
+ }
93
+ }
94
+
95
+ /// → [path, [["nonce", Integer], [wire, original value], ...]] (nils dropped, legacy order)
96
+ fn build_post(
97
+ ruby: &Ruby,
98
+ rb_self: &Self,
99
+ op: String,
100
+ params: RHash,
101
+ ) -> Result<RArray, magnus::Error> {
102
+ let (path, fields) = Self::layout_for(ruby, &op, Method::Post)?;
103
+ let pairs = ruby.ary_new();
104
+ let nonce = ruby.ary_new();
105
+ nonce.push(ruby.str_new("nonce"))?;
106
+ nonce.push(ruby.integer_from_u64(sign::next_nonce(rb_self.nonce_key.as_deref())))?;
107
+ pairs.push(nonce)?;
108
+ for (wire, name) in fields {
109
+ let v: Value = params.aref(ruby.str_new(name))?;
110
+ if !v.is_nil() {
111
+ let pair = ruby.ary_new();
112
+ pair.push(ruby.str_new(wire))?;
113
+ pair.push(v)?;
114
+ pairs.push(pair)?;
115
+ }
116
+ }
117
+ let out = ruby.ary_new();
118
+ out.push(ruby.str_new(path))?;
119
+ out.push(pairs)?;
120
+ Ok(out)
121
+ }
122
+
123
+ /// Signs the exact body Ruby encoded: legacy private_headers(req.path, req.body).
124
+ fn sign(
125
+ ruby: &Ruby,
126
+ rb_self: &Self,
127
+ path: String,
128
+ body: String,
129
+ ) -> Result<RHash, magnus::Error> {
130
+ headers(
131
+ ruby,
132
+ &sign::private_headers(&path, &body, rb_self.creds.as_ref()),
133
+ )
134
+ }
135
+
136
+ /// → [path, {wire => original value} (nils dropped), public headers]
137
+ fn build_get(
138
+ ruby: &Ruby,
139
+ _rb_self: &Self,
140
+ op: String,
141
+ params: RHash,
142
+ ) -> Result<RArray, magnus::Error> {
143
+ let (path, fields) = Self::layout_for(ruby, &op, Method::Get)?;
144
+ let query = ruby.hash_new();
145
+ for (wire, name) in fields {
146
+ let v: Value = params.aref(ruby.str_new(name))?;
147
+ if !v.is_nil() {
148
+ query.aset(ruby.str_new(wire), v)?;
149
+ }
150
+ }
151
+ let out = ruby.ary_new();
152
+ out.push(ruby.str_new(path))?;
153
+ out.push(query)?;
154
+ out.push(headers(ruby, &sign::public_headers())?)?;
155
+ Ok(out)
156
+ }
157
+
158
+ fn finish(
159
+ ruby: &Ruby,
160
+ _rb_self: &Self,
161
+ op: String,
162
+ data: Value,
163
+ ) -> Result<RArray, magnus::Error> {
164
+ let json = convert::to_json(ruby, data)?;
165
+ match op.as_str() {
166
+ "balances" => verdict(
167
+ ruby,
168
+ normalize::balances::<RubyDecimal>(&json).map_err(|e| norm(ruby, e))?,
169
+ |list| {
170
+ let h = ruby.hash_new();
171
+ for b in list {
172
+ let e = ruby.hash_new();
173
+ e.aset(sym("free"), b.free.value())?;
174
+ e.aset(sym("locked"), b.locked.value())?;
175
+ h.aset(convert::from_json(ruby, &b.asset)?, e)?;
176
+ }
177
+ Ok(h.as_value())
178
+ },
179
+ ),
180
+ "query_orders_info" => verdict(
181
+ ruby,
182
+ normalize::orders::<RubyDecimal>(&json).map_err(|e| norm(ruby, e))?,
183
+ |list| {
184
+ let h = ruby.hash_new();
185
+ for o in list {
186
+ let e = ruby.hash_new();
187
+ e.aset(sym("order_id"), convert::from_json(ruby, &o.order_id)?)?;
188
+ e.aset(sym("status"), sym(o.status.as_str()))?;
189
+ e.aset(sym("side"), side_symbol(ruby, o.side.as_ref())?)?;
190
+ e.aset(sym("order_type"), sym(o.order_type.as_str()))?;
191
+ e.aset(sym("price"), opt_dec(ruby, &o.price))?;
192
+ e.aset(sym("amount"), opt_dec(ruby, &o.amount))?;
193
+ e.aset(sym("quote_amount"), opt_dec(ruby, &o.quote_amount))?;
194
+ e.aset(sym("amount_exec"), o.amount_exec.value())?;
195
+ e.aset(sym("quote_amount_exec"), o.quote_amount_exec.value())?;
196
+ h.aset(convert::from_json(ruby, &o.order_id)?, e)?; // the wrapper appends :raw last
197
+ }
198
+ Ok(h.as_value())
199
+ },
200
+ ),
201
+ "add_order" => verdict(
202
+ ruby,
203
+ normalize::add_order::<RubyDecimal>(&json).map_err(|e| norm(ruby, e))?,
204
+ |id| convert::from_json(ruby, &id),
205
+ ),
206
+ "tickers_info" => verdict(
207
+ ruby,
208
+ normalize::tickers::<RubyDecimal>(&json).map_err(|e| norm(ruby, e))?,
209
+ |list| {
210
+ let out = ruby.ary_new();
211
+ for t in list {
212
+ let h = ruby.hash_new();
213
+ h.aset(sym("ticker"), convert::from_json(ruby, &t.ticker)?)?;
214
+ h.aset(sym("base"), convert::from_json(ruby, &t.base)?)?;
215
+ h.aset(sym("quote"), convert::from_json(ruby, &t.quote)?)?;
216
+ h.aset(
217
+ sym("minimum_base_size"),
218
+ convert::from_json(ruby, &t.minimum_base_size)?,
219
+ )?;
220
+ h.aset(
221
+ sym("minimum_quote_size"),
222
+ convert::from_json(ruby, &t.minimum_quote_size)?,
223
+ )?;
224
+ h.aset(sym("maximum_base_size"), ruby.qnil())?;
225
+ h.aset(sym("maximum_quote_size"), ruby.qnil())?;
226
+ h.aset(
227
+ sym("base_decimals"),
228
+ convert::from_json(ruby, &t.base_decimals)?,
229
+ )?;
230
+ h.aset(
231
+ sym("quote_decimals"),
232
+ convert::from_json(ruby, &t.quote_decimals)?,
233
+ )?;
234
+ h.aset(
235
+ sym("price_decimals"),
236
+ convert::from_json(ruby, &t.price_decimals)?,
237
+ )?;
238
+ h.aset(sym("available"), ruby.qtrue())?;
239
+ h.aset(sym("trading_enabled"), t.trading_enabled)?;
240
+ out.push(h)?;
241
+ }
242
+ Ok(out.as_value())
243
+ },
244
+ ),
245
+ "bid_ask" => verdict(
246
+ ruby,
247
+ normalize::bid_ask::<RubyDecimal>(&json).map_err(|e| norm(ruby, e))?,
248
+ |(bid, ask)| {
249
+ let h = ruby.hash_new();
250
+ h.aset(sym("bid"), bid.value())?;
251
+ h.aset(sym("ask"), ask.value())?;
252
+ Ok(h.as_value())
253
+ },
254
+ ),
255
+ other => Err(convert::shape(ruby, &format!("unknown finish op {other}"))),
256
+ }
257
+ }
258
+
259
+ /// by_order: { otxid => [Ruby trade hashes] } in first-seen order → legacy aggregate hashes.
260
+ fn aggregate_trades(ruby: &Ruby, by_order: RHash) -> Result<RHash, magnus::Error> {
261
+ let out = ruby.hash_new();
262
+ by_order.foreach(|otxid: Value, trades: RArray| {
263
+ let mut json = Vec::with_capacity(trades.len());
264
+ for i in 0..trades.len() {
265
+ json.push(convert::to_json(ruby, trades.entry(i as isize)?)?); // Rust values only
266
+ }
267
+ let a = aggregate::<RubyDecimal>(&json).map_err(|e| norm(ruby, e))?;
268
+ let first: Value = trades.entry(0)?;
269
+ let times = ruby.ary_new();
270
+ for i in 0..trades.len() {
271
+ let t: Value = trades.entry(i as isize)?;
272
+ let time: Value = t.funcall("[]", ("time",))?;
273
+ times.push(time.funcall::<_, _, Value>("to_f", ())?)?;
274
+ }
275
+ let h = ruby.hash_new();
276
+ h.aset(
277
+ sym("order_id"),
278
+ first.funcall::<_, _, Value>("[]", ("ordertxid",))?,
279
+ )?;
280
+ h.aset(sym("status"), sym("closed"))?;
281
+ h.aset(sym("side"), side_symbol(ruby, a.side.as_ref())?)?;
282
+ h.aset(sym("order_type"), sym(a.order_type.as_str()))?;
283
+ h.aset(sym("price"), opt_dec(ruby, &a.price))?;
284
+ h.aset(sym("amount"), ruby.qnil())?;
285
+ h.aset(sym("quote_amount"), ruby.qnil())?;
286
+ h.aset(sym("amount_exec"), a.vol.value())?;
287
+ h.aset(sym("quote_amount_exec"), a.cost.value())?;
288
+ h.aset(sym("fee"), a.fee.value())?;
289
+ h.aset(sym("pair"), first.funcall::<_, _, Value>("[]", ("pair",))?)?;
290
+ h.aset(sym("trade_count"), trades.len())?;
291
+ h.aset(
292
+ sym("last_trade_at"),
293
+ times.funcall::<_, _, Value>("max", ())?,
294
+ )?;
295
+ let raw = ruby.hash_new();
296
+ raw.aset(ruby.str_new("trades"), trades)?;
297
+ h.aset(sym("raw"), raw)?;
298
+ out.aset(otxid, h)?;
299
+ Ok(ForEach::Continue)
300
+ })?;
301
+ Ok(out)
302
+ }
303
+
304
+ fn classify_error(ruby: &Ruby, message: RString) -> Result<Value, magnus::Error> {
305
+ let message = BoxValue::new(message);
306
+ let encoding: Value = message.funcall("encoding", ())?;
307
+ if !encoding.funcall::<_, _, bool>("ascii_compatible?", ())?
308
+ || !message.funcall::<_, _, bool>("valid_encoding?", ())?
309
+ {
310
+ // Let Ruby raise its exact regexp encoding exception before byte matching.
311
+ message.funcall::<_, _, Value>("match", ("",))?;
312
+ }
313
+ let bytes = unsafe { message.as_slice() }.to_vec();
314
+ Ok(match normalize::classify_error(&bytes) {
315
+ None => ruby.qnil().as_value(),
316
+ Some((code, caps)) => {
317
+ let h = ruby.hash_new();
318
+ h.aset(sym("code"), sym(code))?;
319
+ for (k, v) in caps {
320
+ let capture = BoxValue::new(ruby.str_from_slice(&v));
321
+ capture.enc_associate(message.enc_get())?;
322
+ h.aset(sym(k), *capture)?;
323
+ }
324
+ h.as_value()
325
+ }
326
+ })
327
+ }
328
+
329
+ fn set_fixed_nonce(n: Option<u64>) {
330
+ sign::set_fixed_nonce(n);
331
+ }
332
+
333
+ fn reset_nonce_state() {
334
+ sign::reset_nonces();
335
+ }
336
+ }
337
+
338
+ pub fn define(ruby: &Ruby, ext: magnus::RModule) -> Result<(), magnus::Error> {
339
+ let c = ext.define_class("Kraken", ruby.class_object())?;
340
+ c.define_singleton_method("new", magnus::function!(Kraken::new, 2))?;
341
+ c.define_method("build_post", magnus::method!(Kraken::build_post, 2))?;
342
+ c.define_method("sign", magnus::method!(Kraken::sign, 2))?;
343
+ c.define_method("build_get", magnus::method!(Kraken::build_get, 2))?;
344
+ c.define_singleton_method(
345
+ "classify_error",
346
+ magnus::function!(Kraken::classify_error, 1),
347
+ )?;
348
+ c.define_method("finish", magnus::method!(Kraken::finish, 2))?;
349
+ c.define_singleton_method(
350
+ "aggregate_trades",
351
+ magnus::function!(Kraken::aggregate_trades, 1),
352
+ )?;
353
+ c.define_singleton_method(
354
+ "fixed_nonce=",
355
+ magnus::function!(Kraken::set_fixed_nonce, 1),
356
+ )?;
357
+ c.define_singleton_method(
358
+ "reset_nonce_state!",
359
+ magnus::function!(Kraken::reset_nonce_state, 0),
360
+ )?;
361
+ Ok(())
362
+ }
@@ -0,0 +1,90 @@
1
+ use magnus::{Error, Ruby, function, prelude::*};
2
+
3
+ mod convert;
4
+ mod kraken;
5
+ mod ruby_decimal;
6
+
7
+ fn version() -> &'static str {
8
+ env!("CARGO_PKG_VERSION")
9
+ }
10
+
11
+ fn roundtrip_for_tests(ruby: &Ruby, v: magnus::Value) -> Result<magnus::Value, Error> {
12
+ let j = convert::to_json(ruby, v)?;
13
+ convert::from_json(ruby, &j)
14
+ }
15
+
16
+ fn decimal_for_tests(text: String) -> Result<magnus::Value, Error> {
17
+ use honeymaker_core::num::Num;
18
+ Ok(ruby_decimal::RubyDecimal::parse(&text)?.value())
19
+ }
20
+
21
+ fn to_s_for_tests(ruby: &Ruby, v: magnus::Value) -> Result<String, Error> {
22
+ let j = convert::to_json(ruby, v)?;
23
+ honeymaker_core::semantics::to_s(&j).map_err(|msg| convert::shape(ruby, &msg))
24
+ }
25
+
26
+ fn decimal_convert_for_tests(
27
+ ruby: &Ruby,
28
+ v: magnus::Value,
29
+ stringify: bool,
30
+ ) -> Result<magnus::Value, Error> {
31
+ use honeymaker_core::num::Num;
32
+ use ruby_decimal::RubyDecimal;
33
+ let j = convert::to_json(ruby, v)?;
34
+ let decimal = if stringify {
35
+ RubyDecimal::parse_to_s(&j)?
36
+ } else {
37
+ RubyDecimal::parse_json(&j)?
38
+ };
39
+ Ok(decimal.value())
40
+ }
41
+
42
+ fn decimal_arithmetic_for_tests(
43
+ ruby: &Ruby,
44
+ left: String,
45
+ right: String,
46
+ ) -> Result<magnus::RArray, Error> {
47
+ use honeymaker_core::num::Num;
48
+ use magnus::value::BoxValue;
49
+ use ruby_decimal::RubyDecimal;
50
+
51
+ // Keep the decimals on the Rust heap across collection and compaction, as normalizers will.
52
+ let mut decimals = vec![RubyDecimal::parse(&left)?, RubyDecimal::parse(&right)?];
53
+ let sum = decimals[0].add(&decimals[1])?;
54
+ decimals.push(sum);
55
+ decimals.push(decimals[0].sub(&decimals[1])?);
56
+ decimals.push(decimals[0].div(&decimals[1])?);
57
+ ruby.gc_start();
58
+ let gc = BoxValue::new(ruby.class_object().const_get::<_, magnus::RModule>("GC")?);
59
+ if gc.respond_to("compact", false)? {
60
+ gc.funcall::<_, _, magnus::Value>("compact", ())?;
61
+ }
62
+ let out = BoxValue::new(ruby.ary_new_capa(4));
63
+ for decimal in &decimals[2..] {
64
+ out.push(decimal.value())?;
65
+ }
66
+ out.push(decimals[0].is_zero()?)?;
67
+ Ok(*out)
68
+ }
69
+
70
+ #[magnus::init]
71
+ fn init(ruby: &Ruby) -> Result<(), Error> {
72
+ use magnus::value::BoxValue;
73
+ ruby.require("bigdecimal")?;
74
+ let native = BoxValue::new(ruby.define_module("Honeymaker")?.define_module("Native")?);
75
+ let ext = BoxValue::new(native.define_module("Ext")?);
76
+ kraken::define(ruby, *ext)?;
77
+ ext.define_singleton_method("version", function!(version, 0))?;
78
+ ext.define_singleton_method("roundtrip_for_tests", function!(roundtrip_for_tests, 1))?;
79
+ ext.define_singleton_method("decimal_for_tests", function!(decimal_for_tests, 1))?;
80
+ ext.define_singleton_method("to_s_for_tests", function!(to_s_for_tests, 1))?;
81
+ ext.define_singleton_method(
82
+ "decimal_convert_for_tests",
83
+ function!(decimal_convert_for_tests, 2),
84
+ )?;
85
+ ext.define_singleton_method(
86
+ "decimal_arithmetic_for_tests",
87
+ function!(decimal_arithmetic_for_tests, 2),
88
+ )?;
89
+ Ok(())
90
+ }