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,592 @@
1
+ use bigdecimal::BigDecimal;
2
+ use honeymaker::kraken::normalize::{self, Finished};
3
+ use serde_json::{Value, json};
4
+
5
+ fn venue<T>(f: Finished<T>) -> Option<Vec<Value>> {
6
+ match f {
7
+ Finished::Venue(e) => Some(e),
8
+ _ => None,
9
+ }
10
+ }
11
+
12
+ #[test]
13
+ fn a_venue_refusal_carries_krakens_error_array_verbatim_on_every_normalizer() {
14
+ let body =
15
+ json!({ "error": ["EOrder:Insufficient funds", "EGeneral:Invalid arguments:volume"] });
16
+ let want = Some(vec![
17
+ json!("EOrder:Insufficient funds"),
18
+ json!("EGeneral:Invalid arguments:volume"),
19
+ ]);
20
+ assert_eq!(
21
+ venue(normalize::add_order::<BigDecimal>(&body).unwrap()),
22
+ want
23
+ );
24
+ assert_eq!(venue(normalize::orders::<BigDecimal>(&body).unwrap()), want);
25
+ assert_eq!(
26
+ venue(normalize::balances::<BigDecimal>(&body).unwrap()),
27
+ want
28
+ );
29
+ assert_eq!(
30
+ venue(normalize::tickers::<BigDecimal>(&body).unwrap()),
31
+ want
32
+ );
33
+ assert_eq!(
34
+ venue(normalize::bid_ask::<BigDecimal>(&body).unwrap()),
35
+ want
36
+ );
37
+ }
38
+
39
+ #[test]
40
+ fn only_a_truthy_element_makes_a_refusal_but_every_element_is_kept() {
41
+ let ok = json!({ "error": [null, false], "result": { "txid": ["O1"] } });
42
+ assert!(
43
+ matches!(normalize::add_order::<BigDecimal>(&ok).unwrap(), Finished::Ok(v) if v == json!("O1"))
44
+ );
45
+ let mixed = json!({ "error": [null, "EAPI:Invalid key"] });
46
+ assert_eq!(
47
+ venue(normalize::add_order::<BigDecimal>(&mixed).unwrap()),
48
+ Some(vec![json!(null), json!("EAPI:Invalid key")])
49
+ );
50
+ }
51
+
52
+ use honeymaker::kraken::requests::{Param, build};
53
+ use honeymaker::kraken::sign::{Credentials, set_fixed_nonce};
54
+ use std::collections::BTreeMap;
55
+ use std::str::FromStr;
56
+ use std::sync::Mutex;
57
+
58
+ static NONCE: Mutex<()> = Mutex::new(()); // the fixed-nonce hook is process-global
59
+
60
+ fn dec(s: &str) -> BigDecimal {
61
+ BigDecimal::from_str(s).unwrap()
62
+ }
63
+ fn load(name: &str) -> Value {
64
+ serde_json::from_str(
65
+ &std::fs::read_to_string(format!(
66
+ "{}/tests/vectors/{name}",
67
+ env!("CARGO_MANIFEST_DIR")
68
+ ))
69
+ .unwrap(),
70
+ )
71
+ .unwrap()
72
+ }
73
+ fn ok<T>(f: Finished<T>) -> T {
74
+ match f {
75
+ Finished::Ok(v) => v,
76
+ _ => panic!("not Ok"),
77
+ }
78
+ }
79
+ fn one(s: &str) -> Param {
80
+ Param::One(s.into())
81
+ }
82
+
83
+ #[test]
84
+ fn add_order_signs_and_sends_cl_ord_id_and_deadline_exactly_as_legacy() {
85
+ let _g = NONCE.lock().unwrap_or_else(|p| p.into_inner());
86
+ for v in load("kraken_add_order_wire.json").as_array().unwrap() {
87
+ let mut params = BTreeMap::new();
88
+ for (k, x) in v["args"].as_object().unwrap() {
89
+ let p = match x {
90
+ Value::Array(a) => {
91
+ Param::Many(a.iter().map(|s| s.as_str().unwrap().to_string()).collect())
92
+ }
93
+ Value::Bool(b) => one(&b.to_string()),
94
+ other => one(other.as_str().unwrap()),
95
+ };
96
+ params.insert(k.clone(), p);
97
+ }
98
+ set_fixed_nonce(v["nonce"].as_u64());
99
+ let creds = Credentials {
100
+ api_key: v["api_key"].as_str().unwrap().into(),
101
+ api_secret: v["api_secret"].as_str().unwrap().into(),
102
+ };
103
+ let built = build("add_order", &params, Some(&creds)).unwrap();
104
+ set_fixed_nonce(None);
105
+ assert_eq!(built.body.as_deref(), v["body"].as_str());
106
+ for (name, value) in v["headers"].as_object().unwrap() {
107
+ assert!(
108
+ built
109
+ .headers
110
+ .iter()
111
+ .any(|(k, x)| k == name && Some(x.as_str()) == value.as_str()),
112
+ "{name}"
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ #[test]
119
+ fn open_and_closed_orders_lay_out_their_filters_after_the_nonce() {
120
+ let b = build(
121
+ "open_orders",
122
+ &BTreeMap::from([("cl_ord_id".to_string(), one("c-1"))]),
123
+ None,
124
+ )
125
+ .unwrap();
126
+ assert_eq!(
127
+ (b.path, b.pairs[0].0.as_str(), &b.pairs[1..]),
128
+ (
129
+ "/0/private/OpenOrders",
130
+ "nonce",
131
+ &[("cl_ord_id".to_string(), "c-1".to_string())][..]
132
+ )
133
+ );
134
+ let b = build(
135
+ "closed_orders",
136
+ &BTreeMap::from([
137
+ ("ofs".to_string(), one("50")),
138
+ ("start".to_string(), one("1727690400")),
139
+ ("cl_ord_id".to_string(), one("c-1")),
140
+ ]),
141
+ None,
142
+ )
143
+ .unwrap();
144
+ assert_eq!(b.path, "/0/private/ClosedOrders");
145
+ assert_eq!(
146
+ b.pairs[1..],
147
+ [("cl_ord_id", "c-1"), ("start", "1727690400"), ("ofs", "50")]
148
+ .map(|(k, v)| (k.to_string(), v.to_string()))
149
+ );
150
+ }
151
+
152
+ fn kraken_order(cl: Option<&str>) -> Value {
153
+ let mut o = json!({ "refid": null, "userref": 0, "status": "closed", "opentm": 1727697600.1234, "closetm": 1727697601.5,
154
+ "descr": { "pair": "XBTEUR", "type": "buy", "ordertype": "market", "price": "0", "order": "buy 60.00000000 XBTEUR @ market" },
155
+ "vol": "60.00000000", "vol_exec": "0.00119975", "cost": "60.00000", "fee": "0.24000", "price": "50010.5", "misc": "", "oflags": "fciq,viqc" });
156
+ if let Some(c) = cl {
157
+ o["cl_ord_id"] = json!(c);
158
+ }
159
+ o
160
+ }
161
+
162
+ #[test]
163
+ fn orders_report_their_client_order_id_and_userref() {
164
+ let got = ok(normalize::orders::<BigDecimal>(&json!({ "error": [], "result": { "O1": kraken_order(Some("c-1")), "O2": kraken_order(None) } })).unwrap());
165
+ assert_eq!(
166
+ (got[0].cl_ord_id.clone(), got[0].userref.clone()),
167
+ (Some(json!("c-1")), Some(json!(0)))
168
+ );
169
+ assert_eq!(got[1].cl_ord_id, None);
170
+ assert_eq!(
171
+ (got[0].quote_amount.clone(), got[0].amount.clone()),
172
+ (Some(dec("60")), None),
173
+ "viqc: vol is quote"
174
+ );
175
+ }
176
+
177
+ #[test]
178
+ fn listed_orders_read_open_and_closed_with_the_raw_count() {
179
+ let (open, count) = ok(normalize::listed_orders::<BigDecimal>(
180
+ &json!({ "error": [], "result": { "open": { "O1": kraken_order(Some("c-1")) } } }),
181
+ "open",
182
+ )
183
+ .unwrap());
184
+ assert_eq!((open.len(), count), (1, None));
185
+ let (closed, count) = ok(normalize::listed_orders::<BigDecimal>(&json!({ "error": [], "result": { "closed": { "O1": kraken_order(None), "O2": kraken_order(None) }, "count": 7 } }), "closed").unwrap());
186
+ assert_eq!((closed.len(), count), (2, Some(json!(7))));
187
+ let (none, count) = ok(normalize::listed_orders::<BigDecimal>(
188
+ &json!({ "error": [], "result": { "closed": {}, "count": 0 } }),
189
+ "closed",
190
+ )
191
+ .unwrap());
192
+ assert_eq!((none.len(), count), (0, Some(json!(0))));
193
+ // Ruling 1 (review round 1): a missing or null container is an error, never "no orders".
194
+ for (body, key) in [
195
+ (json!({}), "open"),
196
+ (json!({ "error": [] }), "open"),
197
+ (json!({ "error": [], "result": null }), "open"),
198
+ (json!({ "error": [], "result": { "open": null } }), "open"),
199
+ (json!({ "error": [], "result": { "count": 0 } }), "closed"),
200
+ (
201
+ json!({ "error": [], "result": { "closed": [1] } }),
202
+ "closed",
203
+ ),
204
+ (json!({ "error": [], "result": "x" }), "closed"),
205
+ ] {
206
+ assert!(
207
+ normalize::listed_orders::<BigDecimal>(&body, key).is_err(),
208
+ "{body} {key}"
209
+ );
210
+ }
211
+ assert!(matches!(
212
+ normalize::listed_orders::<BigDecimal>(&json!("oops"), "open").unwrap(),
213
+ Finished::Unreadable
214
+ ));
215
+ }
216
+
217
+ #[test]
218
+ fn ticker_prices_are_the_first_pairs_bid_ask_and_last_trade() {
219
+ let body = json!({ "error": [], "result": { "XXBTZEUR": { "a": ["50000.2", "1", "1.000"], "b": ["49990.1", "1", "1.000"], "c": ["49995.3", "0.001"] } } });
220
+ let p = ok(normalize::ticker_prices::<BigDecimal>(&body).unwrap()).unwrap();
221
+ assert_eq!(
222
+ (p.bid, p.ask, p.last),
223
+ (dec("49990.1"), dec("50000.2"), dec("49995.3"))
224
+ );
225
+ assert!(
226
+ ok(normalize::ticker_prices::<BigDecimal>(&json!({ "error": [], "result": {} })).unwrap())
227
+ .is_none()
228
+ );
229
+ assert!(
230
+ normalize::ticker_prices::<BigDecimal>(
231
+ &json!({ "error": [], "result": { "X": { "a": ["1"], "b": ["1"] } } })
232
+ )
233
+ .is_err(),
234
+ "no c"
235
+ );
236
+ assert!(matches!(
237
+ normalize::ticker_prices::<BigDecimal>(&json!([1])).unwrap(),
238
+ Finished::Unreadable
239
+ ));
240
+ }
241
+
242
+ use honeymaker::kraken::lookup::{ClientIdLookup, MAX_CLOSED_PAGES, Step};
243
+
244
+ fn open(orders: Value) -> Value {
245
+ json!({ "error": [], "result": { "open": orders } })
246
+ }
247
+ fn closed(orders: Value, count: Value) -> Value {
248
+ json!({ "error": [], "result": { "closed": orders, "count": count } })
249
+ }
250
+ fn others(from: usize, n: usize) -> Value {
251
+ Value::Object(
252
+ (from..from + n)
253
+ .map(|i| (format!("OX{i}"), kraken_order(Some(&format!("other-{i}")))))
254
+ .collect(),
255
+ )
256
+ }
257
+ fn param(p: &BTreeMap<String, Param>, k: &str) -> Option<String> {
258
+ p.get(k).map(|v| match v {
259
+ Param::One(s) => s.clone(),
260
+ Param::Many(m) => m.join(","),
261
+ })
262
+ }
263
+
264
+ /// Drives the lookup over scripted bodies; returns the calls it made and how it ended.
265
+ #[allow(clippy::type_complexity)] // Preserve the brief's scripted-call helper signature.
266
+ fn run(
267
+ pages: Vec<Value>,
268
+ ) -> (
269
+ Vec<(String, Option<String>, Option<String>)>,
270
+ Step<BigDecimal>,
271
+ ) {
272
+ let mut l = ClientIdLookup::new("c-1", 1_727_690_400);
273
+ let mut pages = pages.into_iter();
274
+ let mut calls = vec![];
275
+ let mut step = l.first::<BigDecimal>();
276
+ loop {
277
+ match step {
278
+ Step::Call(op, p) => {
279
+ assert_eq!(
280
+ param(&p, "cl_ord_id").as_deref(),
281
+ Some("c-1"),
282
+ "the filter goes to Kraken"
283
+ );
284
+ calls.push((op.to_string(), param(&p, "start"), param(&p, "ofs")));
285
+ // A shape error is how the client learns of an unreadable page: it becomes Ambiguous.
286
+ step = match l.feed::<BigDecimal>(
287
+ &pages
288
+ .next()
289
+ .expect("lookup asked for more pages than scripted"),
290
+ ) {
291
+ Ok(next) => next,
292
+ Err(e) => return (calls, Step::Incomplete(format!("unreadable: {e:?}"))),
293
+ };
294
+ }
295
+ other => return (calls, other),
296
+ }
297
+ }
298
+ }
299
+
300
+ #[test]
301
+ fn a_numeric_cl_ord_id_echo_matches_its_text() {
302
+ let mut l = ClientIdLookup::new("123", 1_727_690_400);
303
+ let mut o = kraken_order(None);
304
+ o["cl_ord_id"] = json!(123);
305
+ let step = l.feed::<BigDecimal>(&open(json!({ "O1": o }))).unwrap();
306
+ assert!(matches!(step, Step::Found(o) if o.order_id == json!("O1")));
307
+ }
308
+
309
+ #[test]
310
+ fn an_open_order_is_found_without_reading_closed_orders() {
311
+ let (calls, end) = run(vec![open(json!({ "O1": kraken_order(Some("c-1")) }))]);
312
+ assert!(matches!(end, Step::Found(o) if o.order_id == json!("O1")));
313
+ assert_eq!(calls, vec![("open_orders".into(), None, None)]);
314
+ }
315
+
316
+ #[test]
317
+ fn a_closed_order_is_found_on_a_later_page_when_the_filter_is_ignored() {
318
+ let mut page2 = others(50, 9);
319
+ page2["O-MINE"] = kraken_order(Some("c-1"));
320
+ let (calls, end) = run(vec![
321
+ open(json!({})),
322
+ closed(others(0, 50), json!(60)),
323
+ closed(page2, json!(60)),
324
+ ]);
325
+ assert!(matches!(end, Step::Found(o) if o.order_id == json!("O-MINE")));
326
+ assert_eq!(
327
+ calls[1..],
328
+ [
329
+ (
330
+ "closed_orders".into(),
331
+ Some("1727690400".into()),
332
+ Some("0".into())
333
+ ),
334
+ (
335
+ "closed_orders".into(),
336
+ Some("1727690400".into()),
337
+ Some("50".into())
338
+ )
339
+ ]
340
+ );
341
+ }
342
+
343
+ #[test]
344
+ fn absence_needs_every_closed_page() {
345
+ let (calls, end) = run(vec![
346
+ open(json!({})),
347
+ closed(others(0, 2), json!(3)),
348
+ closed(others(2, 1), json!(3)),
349
+ ]);
350
+ assert!(matches!(end, Step::Absent));
351
+ assert_eq!(calls.len(), 3);
352
+ let (_, end) = run(vec![open(json!({})), closed(json!({}), json!(0))]);
353
+ assert!(
354
+ matches!(end, Step::Absent),
355
+ "no orders at all, stated explicitly, is a complete answer"
356
+ );
357
+ }
358
+
359
+ #[test]
360
+ fn absence_needs_every_distinct_closed_order_not_just_enough_rows() {
361
+ let order = |id: &str| (id.to_string(), kraken_order(Some(&format!("other-{id}"))));
362
+ let page = |ids: &[&str]| Value::Object(ids.iter().map(|&i| order(i)).collect());
363
+ // {A,B} then {B}, count 3: three rows served, two distinct orders, C never read.
364
+ let (calls, end) = run(vec![
365
+ open(json!({})),
366
+ closed(page(&["A", "B"]), json!(3)),
367
+ closed(page(&["B"]), json!(3)),
368
+ ]);
369
+ assert!(
370
+ matches!(end, Step::Incomplete(_)),
371
+ "never Absent with an order unread"
372
+ );
373
+ assert_eq!(calls.len(), 3);
374
+ // {A,B} then {B,C}: an overlap that does cover every order.
375
+ let (calls, end) = run(vec![
376
+ open(json!({})),
377
+ closed(page(&["A", "B"]), json!(3)),
378
+ closed(page(&["B", "C"]), json!(3)),
379
+ ]);
380
+ assert!(matches!(end, Step::Absent));
381
+ assert_eq!(
382
+ calls[2].2.as_deref(),
383
+ Some("2"),
384
+ "ofs stays the raw row offset"
385
+ );
386
+ // A count that shrinks between pages never lowers the bar (R22).
387
+ let (_, end) = run(vec![
388
+ open(json!({})),
389
+ closed(page(&["A", "B"]), json!(4)),
390
+ closed(page(&["C"]), json!(3)),
391
+ closed(json!({}), json!(3)),
392
+ ]);
393
+ assert!(
394
+ matches!(end, Step::Incomplete(_)),
395
+ "3 distinct orders against the 4 first reported"
396
+ );
397
+ }
398
+
399
+ #[test]
400
+ fn a_missing_or_null_container_is_never_absence() {
401
+ // `{}` for OpenOrders, then an empty ClosedOrders: must not conclude "not placed".
402
+ let (calls, end) = run(vec![json!({}), closed(json!({}), json!(0))]);
403
+ assert!(matches!(end, Step::Incomplete(_)));
404
+ assert_eq!(calls.len(), 1, "stops at the unreadable OpenOrders answer");
405
+ let (_, end) = run(vec![open(json!(null)), closed(json!({}), json!(0))]);
406
+ assert!(matches!(end, Step::Incomplete(_)));
407
+ let (_, end) = run(vec![
408
+ open(json!({})),
409
+ json!({ "error": [], "result": { "count": 0 } }),
410
+ ]);
411
+ assert!(
412
+ matches!(end, Step::Incomplete(_)),
413
+ "`closed` missing with count 0"
414
+ );
415
+ }
416
+
417
+ #[test]
418
+ fn an_order_with_another_or_no_client_id_is_not_a_match() {
419
+ let (_, end) = run(vec![
420
+ open(json!({ "O1": kraken_order(None), "O2": kraken_order(Some("c-10")) })),
421
+ closed(json!({}), json!(0)),
422
+ ]);
423
+ assert!(matches!(end, Step::Absent));
424
+ }
425
+
426
+ #[test]
427
+ fn anything_short_of_a_complete_scan_is_never_absence() {
428
+ let (_, end) = run(vec![
429
+ open(json!({})),
430
+ closed(others(0, 2), json!(5)),
431
+ json!({ "error": ["EAPI:Rate limit exceeded"] }),
432
+ ]);
433
+ assert!(matches!(end, Step::Venue(e) if e == vec![json!("EAPI:Rate limit exceeded")]));
434
+ let (_, end) = run(vec![
435
+ open(json!({})),
436
+ closed(others(0, 2), json!(5)),
437
+ json!("<html>"),
438
+ ]);
439
+ assert!(matches!(end, Step::Unreadable));
440
+ let (_, end) = run(vec![
441
+ open(json!({})),
442
+ closed(others(0, 2), json!(5)),
443
+ closed(json!({}), json!(5)),
444
+ ]);
445
+ assert!(
446
+ matches!(end, Step::Incomplete(_)),
447
+ "an empty page before count"
448
+ );
449
+ for bad in [json!(null), json!("3"), json!(-1), json!(2.5)] {
450
+ let (_, end) = run(vec![open(json!({})), closed(json!({}), bad.clone())]);
451
+ assert!(matches!(end, Step::Incomplete(_)), "count {bad}");
452
+ }
453
+ let mut pages = vec![open(json!({}))];
454
+ pages.extend((0..MAX_CLOSED_PAGES as usize).map(|i| closed(others(i, 1), json!(1_000_000))));
455
+ let (calls, end) = run(pages);
456
+ assert!(matches!(end, Step::Incomplete(_)), "the page cap");
457
+ assert_eq!(calls.len(), 1 + MAX_CLOSED_PAGES as usize);
458
+ }
459
+
460
+ #[test]
461
+ fn lookup_requests_never_bound_the_scan_with_end() {
462
+ let mut lookup = ClientIdLookup::new("c-1", 1_727_690_400);
463
+ let first = lookup.first::<BigDecimal>();
464
+ let second = lookup.feed::<BigDecimal>(&open(json!({}))).unwrap();
465
+ let third = lookup
466
+ .feed::<BigDecimal>(&closed(others(0, 1), json!(2)))
467
+ .unwrap();
468
+ for step in [first, second, third] {
469
+ let Step::Call(_, params) = step else {
470
+ panic!("expected a request");
471
+ };
472
+ assert!(!params.contains_key("end"));
473
+ }
474
+ }
475
+
476
+ #[test]
477
+ fn lookup_propagates_container_shape_errors_on_both_endpoints() {
478
+ for key in ["open", "closed"] {
479
+ let mut bodies = vec![json!({}), json!({ "error": [] })];
480
+ for malformed in [json!(null), json!([]), json!("bad"), json!(1), json!(false)] {
481
+ bodies.push(json!({ "error": [], "result": malformed }));
482
+ bodies.push(json!({ "error": [], "result": { key: malformed, "count": 0 } }));
483
+ }
484
+ bodies.push(json!({ "error": [], "result": { "count": 0 } }));
485
+ for body in bodies {
486
+ let mut lookup = ClientIdLookup::new("c-1", 1_727_690_400);
487
+ if key == "closed" {
488
+ assert!(matches!(
489
+ lookup.feed::<BigDecimal>(&open(json!({}))).unwrap(),
490
+ Step::Call("closed_orders", _)
491
+ ));
492
+ }
493
+ assert!(lookup.feed::<BigDecimal>(&body).is_err(), "{key}: {body}");
494
+ }
495
+ }
496
+ }
497
+
498
+ #[test]
499
+ fn a_growing_count_requires_the_additional_distinct_orders() {
500
+ let (calls, end) = run(vec![
501
+ open(json!({})),
502
+ closed(others(0, 1), json!(2)),
503
+ closed(others(1, 1), json!(3)),
504
+ closed(others(2, 1), json!(3)),
505
+ ]);
506
+ assert!(matches!(end, Step::Absent));
507
+ assert_eq!(calls.len(), 4);
508
+ assert_eq!(calls[3].2.as_deref(), Some("2"));
509
+ }
510
+
511
+ #[test]
512
+ fn a_missing_closed_count_is_incomplete() {
513
+ let (_, end) = run(vec![
514
+ open(json!({})),
515
+ json!({ "error": [], "result": { "closed": {} } }),
516
+ ]);
517
+ assert!(matches!(end, Step::Incomplete(_)));
518
+ }
519
+
520
+ #[test]
521
+ fn the_last_allowed_closed_page_can_complete_the_scan() {
522
+ for found in [false, true] {
523
+ let mut pages = vec![open(json!({}))];
524
+ pages.extend((0..MAX_CLOSED_PAGES as usize).map(|i| {
525
+ let orders = if found && i + 1 == MAX_CLOSED_PAGES as usize {
526
+ json!({ "O-MINE": kraken_order(Some("c-1")) })
527
+ } else {
528
+ others(i, 1)
529
+ };
530
+ closed(orders, json!(MAX_CLOSED_PAGES))
531
+ }));
532
+ let (calls, end) = run(pages);
533
+ if found {
534
+ assert!(matches!(end, Step::Found(o) if o.order_id == json!("O-MINE")));
535
+ } else {
536
+ assert!(matches!(end, Step::Absent));
537
+ }
538
+ assert_eq!(calls.len(), 1 + MAX_CLOSED_PAGES as usize);
539
+ }
540
+ }
541
+
542
+ #[test]
543
+ fn free_balance_follows_rails_last_entry_wins_rule() {
544
+ let fb =
545
+ |body: Value, sym: &str| ok(normalize::free_balance::<BigDecimal>(&body, sym).unwrap());
546
+ let body = json!({ "error": [], "result": { "ZEUR": { "balance": "1000.5", "hold_trade": "0.5" }, "XXBT": { "balance": "0.01", "hold_trade": "0" } } });
547
+ assert_eq!(fb(body.clone(), "EUR"), dec("1000"));
548
+ assert_eq!(fb(body.clone(), "XBT"), dec("0.01"));
549
+ assert_eq!(
550
+ fb(body, "USD"),
551
+ dec("0"),
552
+ "absent: Rails' {{ free: 0 }} default"
553
+ );
554
+ // Rails assigns balances[asset.id] per entry: a later EUR.HOLD replaces ZEUR, zeros included.
555
+ let later = json!({ "error": [], "result": { "ZEUR": { "balance": "100", "hold_trade": "0" }, "EUR.HOLD": { "balance": "0", "hold_trade": "0" } } });
556
+ assert_eq!(fb(later, "EUR"), dec("0"));
557
+ }
558
+
559
+ #[test]
560
+ fn free_balance_raises_like_rails_dig_or_raise_on_a_missing_result_or_hold_trade() {
561
+ // Rails digs result and hold_trade with dig_or_raise: nil raises, it is never read as 0.
562
+ for body in [
563
+ json!({ "error": [], "result": { "ZUSD": { "balance": "5" } } }),
564
+ json!({ "error": [], "result": { "ZUSD": { "balance": "5", "hold_trade": null } } }),
565
+ json!({ "error": [], "result": { "ZUSD": { "hold_trade": "0" } } }),
566
+ json!({ "error": [] }),
567
+ json!({ "error": [], "result": null }),
568
+ ] {
569
+ assert!(
570
+ normalize::free_balance::<BigDecimal>(&body, "USD").is_err(),
571
+ "{body}"
572
+ );
573
+ }
574
+ // Only the matching entry is dug: another asset's missing hold_trade is not read.
575
+ let other = json!({ "error": [], "result": { "ZUSD": { "balance": "5" } } });
576
+ assert_eq!(
577
+ ok(normalize::free_balance::<BigDecimal>(&other, "EUR").unwrap()),
578
+ dec("0")
579
+ );
580
+ }
581
+
582
+ #[test]
583
+ fn credentials_debug_shows_the_key_and_redacts_the_secret() {
584
+ let c = Credentials {
585
+ api_key: "KEY-123".into(),
586
+ api_secret: "c2VjcmV0LXNlY3JldA==".into(),
587
+ };
588
+ let shown = format!("{c:?}");
589
+ assert!(shown.contains("KEY-123"), "{shown}");
590
+ assert!(shown.contains("<redacted>"), "{shown}");
591
+ assert!(!shown.contains("c2VjcmV0LXNlY3JldA=="), "{shown}");
592
+ }