honeymaker 0.12.2 → 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 +17 -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 +27 -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,30 @@
1
+ [package]
2
+ name = "honeymaker-client"
3
+ description = "Async transport and venue clients over the honeymaker core, for Rust callers (not in the gem build)"
4
+ version.workspace = true
5
+ edition.workspace = true
6
+ rust-version.workspace = true
7
+ license.workspace = true
8
+ repository.workspace = true
9
+ publish = false
10
+
11
+ [dependencies]
12
+ honeymaker = { path = "../honeymaker" }
13
+ base64 = "0.22"
14
+ bigdecimal = "0.4"
15
+ bytes = "1"
16
+ chrono = { version = "0.4", default-features = false, features = ["std"] }
17
+ flate2 = "1" # default backend is miniz_oxide (pure Rust)
18
+ http-body-util = "0.1"
19
+ hyper = { version = "1", features = ["client", "http1"] }
20
+ hyper-util = { version = "0.1", features = ["tokio"] }
21
+ rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
22
+ serde = "1"
23
+ serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision"] }
24
+ tokio = { version = "1", features = ["net", "time", "io-util", "macros"] } # macros: tokio::join! in Transport::send
25
+ tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
26
+ webpki-roots = "1"
27
+
28
+ [dev-dependencies]
29
+ rcgen = "0.13"
30
+ tokio = { version = "1", features = ["macros", "rt", "test-util", "net", "time", "io-util", "sync"] } # sync: the tests' async nonce lock
@@ -0,0 +1,147 @@
1
+ //! Drives the Kraken client from one JSON request on stdin and prints the outcome as JSON. The
2
+ //! Ruby parity and live scripts run it next to the gem. Not a public interface.
3
+ use bigdecimal::BigDecimal;
4
+ use chrono::{DateTime, Utc};
5
+ use honeymaker_client::kraken::{
6
+ Client, Config, Credentials, DEFAULT_URL, NewOrder, OrderKind, OrderState, OrderStatus,
7
+ VenueError,
8
+ };
9
+ use honeymaker_client::transport::Timeouts;
10
+ use rustls::pki_types::{CertificateDer, pem::PemObject};
11
+ use serde_json::{Value, json};
12
+ use std::io::Read;
13
+ use std::sync::Arc;
14
+ use std::time::{Duration, Instant};
15
+
16
+ fn main() {
17
+ let mut input = String::new();
18
+ std::io::stdin().read_to_string(&mut input).unwrap();
19
+ let req: Value = serde_json::from_str(&input).unwrap();
20
+ let rt = tokio::runtime::Builder::new_current_thread()
21
+ .enable_all()
22
+ .build()
23
+ .unwrap();
24
+ println!("{}", rt.block_on(run(&req)));
25
+ }
26
+
27
+ fn d(x: &BigDecimal) -> Value {
28
+ json!(x.to_plain_string())
29
+ }
30
+ fn state(o: &OrderState) -> Value {
31
+ let status = match o.status {
32
+ OrderStatus::Unknown => "unknown",
33
+ OrderStatus::Open => "open",
34
+ OrderStatus::Closed => "closed",
35
+ OrderStatus::Cancelled => "cancelled",
36
+ };
37
+ json!({ "txid": o.txid, "status": status, "price": o.price.as_ref().map(d), "amount": o.amount.as_ref().map(d),
38
+ "quote_amount": o.quote_amount.as_ref().map(d), "amount_exec": d(&o.amount_exec),
39
+ "quote_amount_exec": d(&o.quote_amount_exec), "limit": o.limit, "sell": o.sell })
40
+ }
41
+ fn strings(v: &Value) -> Vec<String> {
42
+ v.as_array()
43
+ .unwrap()
44
+ .iter()
45
+ .map(|s| s.as_str().unwrap().to_string())
46
+ .collect()
47
+ }
48
+ fn time(v: &Value) -> DateTime<Utc> {
49
+ v.as_str().unwrap().parse().unwrap()
50
+ }
51
+ fn order(a: &Value) -> NewOrder {
52
+ NewOrder {
53
+ pair: a["pair"].as_str().unwrap().into(),
54
+ kind: match a["kind"].as_str().unwrap() {
55
+ "limit" => OrderKind::Limit {
56
+ price: a["price"].as_str().unwrap().into(),
57
+ },
58
+ _ => OrderKind::Market,
59
+ },
60
+ volume: a["volume"].as_str().unwrap().into(),
61
+ quote_volume: a["quote_volume"].as_bool().unwrap_or(false),
62
+ cl_ord_id: a["cl_ord_id"].as_str().unwrap().into(),
63
+ deadline: time(&a["deadline"]),
64
+ }
65
+ }
66
+
67
+ async fn run(req: &Value) -> Value {
68
+ // A live AddOrder only ever runs with validate=true: a real placement needs a test double's URL.
69
+ if req["call"] == "add_order" && !req["base_url"].is_string() {
70
+ return json!({ "class": "refused", "message": "add_order needs an explicit base_url (live checks use add_order_validate)" });
71
+ }
72
+ let configured = Instant::now();
73
+ if let Some(n) = req["fixed_nonce"].as_u64() {
74
+ honeymaker::kraken::sign::set_fixed_nonce(Some(n));
75
+ }
76
+ let secs = |i: usize, default: f64| {
77
+ Duration::from_secs_f64(req["timeouts"][i].as_f64().unwrap_or(default))
78
+ };
79
+ let tls_roots = req["ca_pem"].as_str().map(|path| {
80
+ let mut store = rustls::RootCertStore::empty();
81
+ for c in CertificateDer::pem_file_iter(path).unwrap() {
82
+ store.add(c.unwrap()).unwrap();
83
+ }
84
+ Arc::new(store)
85
+ });
86
+ let credentials = req["api_key"].as_str().map(|k| Credentials {
87
+ api_key: k.into(),
88
+ api_secret: req["api_secret"].as_str().unwrap_or("").into(),
89
+ });
90
+ let config = Config {
91
+ base_url: req["base_url"].as_str().unwrap_or(DEFAULT_URL).into(),
92
+ proxy: req["proxy"].as_str().map(str::to_string),
93
+ timeouts: Timeouts {
94
+ open: secs(0, 5.0),
95
+ read: secs(1, 30.0),
96
+ write: secs(2, 10.0),
97
+ },
98
+ credentials,
99
+ tls_roots,
100
+ ..Config::default()
101
+ };
102
+ let client = match Client::new(config) {
103
+ Ok(c) => c,
104
+ Err(e) => {
105
+ return json!({ "class": "config", "message": e.to_string(), "ms": configured.elapsed().as_millis() as u64 });
106
+ }
107
+ };
108
+ let a = &req["args"];
109
+ let started = Instant::now();
110
+ let result: Result<Value, VenueError> = match req["call"].as_str().unwrap() {
111
+ "prices" => client
112
+ .prices(a["pair"].as_str().unwrap())
113
+ .await
114
+ .map(|p| json!({ "bid": d(&p.bid), "ask": d(&p.ask), "last": d(&p.last) })),
115
+ "add_order" => client.add_order(&order(a)).await.map(Value::from),
116
+ "add_order_validate" => client
117
+ .add_order_validate(&order(a))
118
+ .await
119
+ .map(|()| Value::Bool(true)),
120
+ "orders" => client
121
+ .orders(&strings(&a["txids"]))
122
+ .await
123
+ .map(|v| Value::Array(v.iter().map(state).collect())),
124
+ "order_by_client_id" => client
125
+ .order_by_client_id(a["cl_ord_id"].as_str().unwrap(), time(&a["since"]))
126
+ .await
127
+ .map(|o| o.as_ref().map(state).unwrap_or(Value::Null)),
128
+ "fills_from_trades" => client
129
+ .fills_from_trades(&strings(&a["txids"]), time(&a["since"]))
130
+ .await
131
+ .map(|v| Value::Array(v.iter().map(state).collect())),
132
+ "balance" => client
133
+ .balance(a["asset"].as_str().unwrap())
134
+ .await
135
+ .map(|b| d(&b)),
136
+ other => panic!("unknown call {other}"),
137
+ };
138
+ let ms = started.elapsed().as_millis() as u64;
139
+ match result {
140
+ Ok(value) => json!({ "class": "ok", "value": value, "ms": ms }),
141
+ Err(VenueError::Rejected(errors)) => {
142
+ json!({ "class": "rejected", "errors": errors, "ms": ms })
143
+ }
144
+ Err(VenueError::Ambiguous(m)) => json!({ "class": "ambiguous", "message": m, "ms": ms }),
145
+ Err(VenueError::Transient(m)) => json!({ "class": "transient", "message": m, "ms": ms }),
146
+ }
147
+ }
@@ -0,0 +1,514 @@
1
+ //! Kraken for Rust callers: the calls deltabadger's engine makes, with its `Venue` trait's
2
+ //! signatures and error classes. Requests, signing, nonces and normalizers are the core's.
3
+ use crate::transport::{Failure, Reply};
4
+ use serde_json::Value;
5
+
6
+ pub const UNREADABLE: &str = "Kraken: unreadable response";
7
+
8
+ #[derive(Clone, Debug, PartialEq)]
9
+ pub enum VenueError {
10
+ /// Kraken answered and refused: its `error` array verbatim, or an HTTP answer Rails treats as
11
+ /// definitive (e.g. 4xx). `add_order` applies Rails' placement rules via `placement()`:
12
+ /// transient/network/HTTP 5xx strings become `Ambiguous`; recvWindow and definitive refusals
13
+ /// remain `Rejected`. Every other call returns Kraken's error array as `Rejected`, leaving
14
+ /// interpretation to the caller.
15
+ Rejected(Vec<String>),
16
+ /// The request may have reached Kraken.
17
+ Ambiguous(String),
18
+ /// Nothing reached Kraken.
19
+ Transient(String),
20
+ }
21
+
22
+ /// The transport/status part of Exchange#ambiguous_placement_error?: `add_order` additionally
23
+ /// applies Rails' text rules via `placement()` (transient/network/HTTP 5xx → `Ambiguous`;
24
+ /// recvWindow and definitive refusals → `Rejected`). Every other call returns Kraken's error
25
+ /// array as `Rejected` and leaves interpretation to the caller.
26
+ /// A transport failure is decided by its cause alone; an HTTP answer by its
27
+ /// status (≥ 500 or 2xx: ambiguous; anything else: a definitive rejection); a 2xx body that is
28
+ /// not a JSON object is unreadable, hence ambiguous.
29
+ #[doc(hidden)]
30
+ pub fn outcome(reply: Result<Reply, Failure>) -> Result<Value, VenueError> {
31
+ match reply {
32
+ Err(f) if f.pre_transmission() => Err(VenueError::Transient(f.message)),
33
+ Err(f) => Err(VenueError::Ambiguous(f.message)),
34
+ Ok(Reply::Status { status, message }) if status >= 500 || (200..300).contains(&status) => {
35
+ Err(VenueError::Ambiguous(message))
36
+ }
37
+ Ok(Reply::Status { message, .. }) => Err(VenueError::Rejected(vec![message])),
38
+ Ok(Reply::Parsed(v)) if v.is_object() => Ok(v),
39
+ Ok(_) => Err(VenueError::Ambiguous(UNREADABLE.into())),
40
+ }
41
+ }
42
+
43
+ use crate::transport::{ConfigError, Request, Timeouts, Transport};
44
+ use bigdecimal::BigDecimal;
45
+ use chrono::{DateTime, SecondsFormat, Utc};
46
+ use honeymaker::encode::www_form;
47
+ use honeymaker::kraken::lookup::{ClientIdLookup, Step};
48
+ use honeymaker::kraken::normalize::{self, Finished, NormalizedOrder};
49
+ use honeymaker::kraken::requests::{self, Method, Param};
50
+ use honeymaker::kraken::trades::{TradesPager, TradesStep};
51
+ use honeymaker::num::NormError;
52
+ use honeymaker::{OrderStatus as CoreStatus, OrderType, semantics};
53
+ use std::collections::BTreeMap;
54
+ use std::sync::Arc;
55
+
56
+ pub use honeymaker::kraken::sign::Credentials;
57
+
58
+ pub const DEFAULT_URL: &str = "https://api.kraken.com";
59
+ pub const DEFAULT_USER_AGENT: &str = "Honeymaker Ruby";
60
+
61
+ /// Honeymaker::Clients::Kraken#closed_orders_from_trades's default.
62
+ pub const TRADES_MAX_PAGES: u32 = 20;
63
+
64
+ #[derive(Clone, Debug, PartialEq)]
65
+ pub struct Prices {
66
+ pub bid: BigDecimal,
67
+ pub ask: BigDecimal,
68
+ pub last: BigDecimal,
69
+ }
70
+
71
+ #[derive(Clone, Debug, PartialEq)]
72
+ pub enum OrderKind {
73
+ Market,
74
+ Limit { price: String },
75
+ }
76
+
77
+ /// A buy (the engine only buys), formatted as the wire expects: volume and price already floored,
78
+ /// in `to_s('F')` form.
79
+ #[derive(Clone, Debug, PartialEq)]
80
+ pub struct NewOrder {
81
+ pub pair: String,
82
+ pub kind: OrderKind,
83
+ pub volume: String,
84
+ /// Kraken `oflags=viqc`: the volume is in quote currency.
85
+ pub quote_volume: bool,
86
+ pub cl_ord_id: String,
87
+ pub deadline: DateTime<Utc>,
88
+ }
89
+
90
+ #[derive(Clone, Copy, Debug, PartialEq)]
91
+ pub enum OrderStatus {
92
+ Unknown,
93
+ Open,
94
+ Closed,
95
+ Cancelled,
96
+ }
97
+
98
+ /// An order as deltabadger's Exchanges::Kraken#parse_order_data reports it.
99
+ #[derive(Clone, Debug, PartialEq)]
100
+ pub struct OrderState {
101
+ pub txid: String,
102
+ pub status: OrderStatus,
103
+ pub price: Option<BigDecimal>,
104
+ pub amount: Option<BigDecimal>,
105
+ pub quote_amount: Option<BigDecimal>,
106
+ pub amount_exec: BigDecimal,
107
+ pub quote_amount_exec: BigDecimal,
108
+ pub limit: bool,
109
+ /// `descr.type` is "sell" (parse_order_data downcases it); for a trade aggregate, its `type`
110
+ /// (a trade with no `type` gives false).
111
+ pub sell: bool,
112
+ }
113
+
114
+ pub struct Config {
115
+ pub base_url: String,
116
+ /// The proxy deltabadger configures for Kraken: `http://[user:pass@]host:port`.
117
+ pub proxy: Option<String>,
118
+ pub user_agent: String,
119
+ pub timeouts: Timeouts,
120
+ /// None: private calls go unsigned and Kraken refuses them, as Rails does with no saved key.
121
+ pub credentials: Option<Credentials>,
122
+ /// Tests and the parity scripts only: trust this store instead of the webpki roots.
123
+ #[doc(hidden)]
124
+ pub tls_roots: Option<Arc<rustls::RootCertStore>>,
125
+ }
126
+
127
+ impl Default for Config {
128
+ fn default() -> Self {
129
+ Self {
130
+ base_url: DEFAULT_URL.into(),
131
+ proxy: None,
132
+ user_agent: DEFAULT_USER_AGENT.into(),
133
+ timeouts: Timeouts::default(),
134
+ credentials: None,
135
+ tls_roots: None,
136
+ }
137
+ }
138
+ }
139
+
140
+ /// Private calls must be serial per API key (see `call`): concurrent ones can reach Kraken out of
141
+ /// nonce order and be refused with `EAPI:Invalid nonce`.
142
+ pub struct Client {
143
+ transport: Transport,
144
+ credentials: Option<Credentials>,
145
+ user_agent: String,
146
+ }
147
+
148
+ fn one(s: &str) -> Param {
149
+ Param::One(s.to_string())
150
+ }
151
+
152
+ fn rejected(errors: Vec<Value>) -> VenueError {
153
+ VenueError::Rejected(
154
+ errors
155
+ .iter()
156
+ .map(|v| semantics::to_s(v).unwrap_or_else(|_| v.to_string()))
157
+ .collect(),
158
+ )
159
+ }
160
+
161
+ /// Exchange::PLACEMENT_SAFE_TRANSIENT_ERRORS: provably pre-trade, so a definitive refusal.
162
+ const PLACEMENT_SAFE: [&str; 2] = [
163
+ "Timestamp for this request is outside of the recvWindow",
164
+ "Timestamp for this request was",
165
+ ];
166
+ /// Client::NETWORK_TRANSIENT_PATTERNS, verbatim.
167
+ const NETWORK_TRANSIENT: [&str; 12] = [
168
+ "Net::ReadTimeout",
169
+ "Net::OpenTimeout",
170
+ "Faraday::TimeoutError",
171
+ "Faraday::ConnectionFailed",
172
+ "execution expired",
173
+ "Connection reset",
174
+ "Errno::ECONNRESET",
175
+ "connection refused",
176
+ "Connection refused",
177
+ "Errno::ECONNREFUSED",
178
+ "end of file reached",
179
+ "unexpected eof while reading",
180
+ ];
181
+ /// Exchanges::Kraken::ERRORS[:transient], verbatim.
182
+ const KRAKEN_TRANSIENT: [&str; 5] = [
183
+ "EGeneral:Internal error",
184
+ "EAPI:Invalid nonce",
185
+ "EService:Unavailable",
186
+ "EService:Busy",
187
+ "EService:Deadline elapsed",
188
+ ];
189
+
190
+ /// `/\bHTTP 5\d\d\b/`.
191
+ fn http_5xx(m: &str) -> bool {
192
+ let word = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
193
+ let b = m.as_bytes();
194
+ m.match_indices("HTTP 5").any(|(i, _)| {
195
+ (i == 0 || !word(b[i - 1]))
196
+ && b.get(i + 6).is_some_and(u8::is_ascii_digit)
197
+ && b.get(i + 7).is_some_and(u8::is_ascii_digit)
198
+ && !b.get(i + 8).is_some_and(|&c| word(c))
199
+ })
200
+ }
201
+
202
+ /// ActiveSupport's Array#to_sentence.
203
+ fn to_sentence(v: &[String]) -> String {
204
+ match v {
205
+ [] => String::new(),
206
+ [a] => a.clone(),
207
+ [a, b] => format!("{a} and {b}"),
208
+ [init @ .., last] => format!("{}, and {last}", init.join(", ")),
209
+ }
210
+ }
211
+
212
+ /// Exchange#ambiguous_placement_error?'s text rules for a refused AddOrder (Ruling R19): a refusal
213
+ /// Rails treats as "may have been placed" is Ambiguous, so the engine keeps the intent and
214
+ /// reconciles. Transport failures and statuses were already classified by `outcome`.
215
+ fn placement(e: VenueError) -> VenueError {
216
+ let VenueError::Rejected(errors) = e else {
217
+ return e;
218
+ };
219
+ let any = |patterns: &[&str]| {
220
+ errors
221
+ .iter()
222
+ .any(|m| patterns.iter().any(|p| m.contains(p)))
223
+ };
224
+ if any(&PLACEMENT_SAFE) {
225
+ return VenueError::Rejected(errors);
226
+ }
227
+ if any(&NETWORK_TRANSIENT) || any(&KRAKEN_TRANSIENT) || errors.iter().any(|m| http_5xx(m)) {
228
+ return VenueError::Ambiguous(to_sentence(&errors));
229
+ }
230
+ VenueError::Rejected(errors)
231
+ }
232
+
233
+ /// A body the normalizer could not read: Rails treats a client_error as ambiguous (Ruling R8).
234
+ fn unreadable<E: std::fmt::Debug>(e: NormError<E>) -> VenueError {
235
+ VenueError::Ambiguous(format!("{UNREADABLE} ({e:?})"))
236
+ }
237
+
238
+ fn finished<T>(f: Finished<T>) -> Result<T, VenueError> {
239
+ match f {
240
+ Finished::Ok(v) => Ok(v),
241
+ Finished::Venue(e) => Err(rejected(e)),
242
+ Finished::Unreadable => Err(VenueError::Ambiguous(UNREADABLE.into())),
243
+ }
244
+ }
245
+
246
+ fn text(v: &Value) -> String {
247
+ semantics::to_s(v).unwrap_or_else(|_| v.to_string())
248
+ }
249
+
250
+ fn state(o: NormalizedOrder<BigDecimal>) -> OrderState {
251
+ OrderState {
252
+ txid: text(&o.order_id),
253
+ status: match o.status {
254
+ CoreStatus::Open => OrderStatus::Open,
255
+ CoreStatus::Closed => OrderStatus::Closed,
256
+ CoreStatus::Cancelled => OrderStatus::Cancelled,
257
+ CoreStatus::Failed | CoreStatus::Unknown => OrderStatus::Unknown,
258
+ },
259
+ price: o.price,
260
+ amount: o.amount,
261
+ quote_amount: o.quote_amount,
262
+ amount_exec: o.amount_exec,
263
+ quote_amount_exec: o.quote_amount_exec,
264
+ limit: o.order_type == OrderType::Limit,
265
+ sell: o
266
+ .side
267
+ .as_ref()
268
+ .is_some_and(|s| semantics::string_eq(s, "sell")),
269
+ }
270
+ }
271
+
272
+ /// What Exchanges::Kraken#set_market_order / #set_limit_order send, plus cl_ord_id and deadline.
273
+ fn order_params(o: &NewOrder, validate: bool) -> BTreeMap<String, Param> {
274
+ let mut p = BTreeMap::from([
275
+ (
276
+ "ordertype".to_string(),
277
+ one(match o.kind {
278
+ OrderKind::Market => "market",
279
+ OrderKind::Limit { .. } => "limit",
280
+ }),
281
+ ),
282
+ ("type".to_string(), one("buy")),
283
+ ("volume".to_string(), one(&o.volume)),
284
+ ("pair".to_string(), one(&o.pair)),
285
+ (
286
+ "oflags".to_string(),
287
+ Param::Many(if o.quote_volume {
288
+ vec!["viqc".into()]
289
+ } else {
290
+ vec![]
291
+ }),
292
+ ),
293
+ ("cl_ord_id".to_string(), one(&o.cl_ord_id)),
294
+ // Ruling R11: RFC 3339, UTC, milliseconds, Z.
295
+ (
296
+ "deadline".to_string(),
297
+ one(&o.deadline.to_rfc3339_opts(SecondsFormat::Millis, true)),
298
+ ),
299
+ ]);
300
+ if let OrderKind::Limit { price } = &o.kind {
301
+ p.insert("price".to_string(), one(price));
302
+ }
303
+ if validate {
304
+ p.insert("validate".to_string(), one("true"));
305
+ }
306
+ p
307
+ }
308
+
309
+ impl Client {
310
+ pub fn new(config: Config) -> Result<Self, ConfigError> {
311
+ let transport = Transport::new(
312
+ &config.base_url,
313
+ config.proxy.as_deref(),
314
+ config.timeouts,
315
+ config.tls_roots,
316
+ )?;
317
+ Ok(Self {
318
+ transport,
319
+ credentials: config.credentials,
320
+ user_agent: config.user_agent,
321
+ })
322
+ }
323
+
324
+ /// One Kraken call: the core lays it out and signs it (nonce included); the transport sends it
325
+ /// once, on its own connection. Private calls must be serial per key: the nonce is taken before
326
+ /// connecting, so concurrent calls can arrive out of order and get `EAPI:Invalid nonce`.
327
+ async fn call(&self, op: &str, params: BTreeMap<String, Param>) -> Result<Value, VenueError> {
328
+ let built = requests::build(op, &params, self.credentials.as_ref())
329
+ .expect("a layout exists for every op this client calls");
330
+ let headers = built
331
+ .headers
332
+ .into_iter()
333
+ .map(|(k, v)| {
334
+ if k == "User-Agent" {
335
+ (k, self.user_agent.clone())
336
+ } else {
337
+ (k, v)
338
+ }
339
+ })
340
+ .collect();
341
+ let req = match built.method {
342
+ Method::Post => Request {
343
+ method: "POST",
344
+ path_and_query: built.path.to_string(),
345
+ headers,
346
+ body: built.body,
347
+ },
348
+ Method::Get => {
349
+ let mut pairs = built.pairs;
350
+ pairs.sort_by(|a, b| a.0.cmp(&b.0)); // Faraday's params encoder sorts (Ruling R17)
351
+ let q = www_form(&pairs);
352
+ let path_and_query = if q.is_empty() {
353
+ built.path.to_string()
354
+ } else {
355
+ format!("{}?{q}", built.path)
356
+ };
357
+ Request {
358
+ method: "GET",
359
+ path_and_query,
360
+ headers,
361
+ body: None,
362
+ }
363
+ }
364
+ };
365
+ outcome(self.transport.request(&req).await)
366
+ }
367
+
368
+ pub async fn prices(&self, pair: &str) -> Result<Prices, VenueError> {
369
+ let data = self
370
+ .call(
371
+ "get_ticker_information",
372
+ BTreeMap::from([("pair".to_string(), one(pair))]),
373
+ )
374
+ .await?;
375
+ match finished(normalize::ticker_prices::<BigDecimal>(&data).map_err(unreadable)?)? {
376
+ Some(t) => Ok(Prices {
377
+ bid: t.bid,
378
+ ask: t.ask,
379
+ last: t.last,
380
+ }),
381
+ None => Err(VenueError::Rejected(vec![format!(
382
+ "Failed to get Kraken {pair} ticker information"
383
+ )])),
384
+ }
385
+ }
386
+
387
+ /// Sent at most once. Never retried. A refusal Rails would treat as possibly placed is Ambiguous.
388
+ pub async fn add_order(&self, order: &NewOrder) -> Result<String, VenueError> {
389
+ let data = self
390
+ .call("add_order", order_params(order, false))
391
+ .await
392
+ .map_err(placement)?;
393
+ match finished(normalize::add_order::<BigDecimal>(&data).map_err(unreadable)?)
394
+ .map_err(placement)?
395
+ {
396
+ Value::String(txid) if !txid.is_empty() => Ok(txid),
397
+ _ => Err(VenueError::Ambiguous(format!(
398
+ "Failed to set Kraken {} order (order_id is nil)",
399
+ match order.kind {
400
+ OrderKind::Market => "market",
401
+ OrderKind::Limit { .. } => "limit",
402
+ }
403
+ ))),
404
+ }
405
+ }
406
+
407
+ /// AddOrder with validate=true: Kraken checks the signed order without placing it (live checks).
408
+ /// Deliberately skips placement rules because `validate=true` never places an order.
409
+ pub async fn add_order_validate(&self, order: &NewOrder) -> Result<(), VenueError> {
410
+ let data = self.call("add_order", order_params(order, true)).await?;
411
+ finished(normalize::add_order::<BigDecimal>(&data).map_err(unreadable)?).map(|_| ())
412
+ }
413
+
414
+ /// QueryOrders (the caller batches ≤ 50); ids Kraken does not report are absent. No ids → empty,
415
+ /// with no request (Rails' each_slice yields nothing).
416
+ pub async fn orders(&self, txids: &[String]) -> Result<Vec<OrderState>, VenueError> {
417
+ if txids.is_empty() {
418
+ return Ok(vec![]);
419
+ }
420
+ let params = BTreeMap::from([
421
+ ("txid".to_string(), one(&txids.join(","))),
422
+ ("consolidate_taker".to_string(), one("true")),
423
+ ]);
424
+ let data = self.call("query_orders_info", params).await?;
425
+ Ok(
426
+ finished(normalize::orders::<BigDecimal>(&data).map_err(unreadable)?)?
427
+ .into_iter()
428
+ .map(state)
429
+ .collect(),
430
+ )
431
+ }
432
+
433
+ /// OpenOrders, then every ClosedOrders page since `since`. `Ok(None)` only after a complete scan.
434
+ ///
435
+ /// Plan 2b: do not clear an intent on `Ok(None)` until the private live check
436
+ /// (script/parity/kraken_client_live.rb with a key) has confirmed Kraken's `cl_ord_id` filter,
437
+ /// `count` and `cl_ord_id` echo on both OpenOrders and ClosedOrders. Until then, absence rests
438
+ /// on Kraken's docs, not on an observed answer.
439
+ pub async fn order_by_client_id(
440
+ &self,
441
+ cl_ord_id: &str,
442
+ since: DateTime<Utc>,
443
+ ) -> Result<Option<OrderState>, VenueError> {
444
+ let mut lookup = ClientIdLookup::new(cl_ord_id, since.timestamp());
445
+ let mut step = lookup.first::<BigDecimal>();
446
+ loop {
447
+ step = match step {
448
+ Step::Call(op, params) => {
449
+ let data = self.call(op, params).await?;
450
+ lookup.feed::<BigDecimal>(&data).map_err(unreadable)?
451
+ }
452
+ Step::Found(o) => return Ok(Some(state(o))),
453
+ Step::Absent => return Ok(None),
454
+ Step::Venue(e) => return Err(rejected(e)),
455
+ Step::Unreadable => return Err(VenueError::Ambiguous(UNREADABLE.into())),
456
+ Step::Incomplete(m) => return Err(VenueError::Ambiguous(m)),
457
+ };
458
+ }
459
+ }
460
+ }
461
+
462
+ impl Client {
463
+ /// TradesHistory since `since`, aggregated per order (Exchanges::Kraken#recover_missing_from_trades).
464
+ ///
465
+ /// For the Plan 2b author: `price` is the aggregate's cost / vol kept to 34 significant digits;
466
+ /// recompute it with `ruby::BigDec::div` (R12) where Rails' value matters. Rails passes `since − 1 h`
467
+ /// to this scan, so the caller subtracts the hour. A trade with no `type` gives `sell = false`.
468
+ pub async fn fills_from_trades(
469
+ &self,
470
+ txids: &[String],
471
+ since: DateTime<Utc>,
472
+ ) -> Result<Vec<OrderState>, VenueError> {
473
+ // No `end` (R22): completeness rests on distinct-id coverage, which fails closed on churn.
474
+ let mut pager = TradesPager::new(txids.to_vec(), Some(since.timestamp()), TRADES_MAX_PAGES);
475
+ let mut step = pager.start::<BigDecimal>().map_err(unreadable)?;
476
+ loop {
477
+ step = match step {
478
+ TradesStep::Call(params) => {
479
+ let data = self.call("get_trades_history", params).await?;
480
+ pager.feed::<BigDecimal>(&data).map_err(unreadable)?
481
+ }
482
+ TradesStep::Done(by_order) => {
483
+ return Ok(by_order
484
+ .into_iter()
485
+ .map(|(txid, a)| OrderState {
486
+ txid: text(&txid),
487
+ status: OrderStatus::Closed,
488
+ price: a.price,
489
+ amount: None,
490
+ quote_amount: None,
491
+ amount_exec: a.vol,
492
+ quote_amount_exec: a.cost,
493
+ limit: a.order_type == OrderType::Limit,
494
+ sell: a
495
+ .side
496
+ .as_ref()
497
+ .is_some_and(|s| semantics::string_eq(s, "sell")),
498
+ })
499
+ .collect());
500
+ }
501
+ TradesStep::Venue(e) => return Err(rejected(e)),
502
+ TradesStep::Unreadable => return Err(VenueError::Ambiguous(UNREADABLE.into())),
503
+ // Strict (review ruling 2): an incomplete scan is never a partial answer.
504
+ TradesStep::Incomplete(m) => return Err(VenueError::Ambiguous(m)),
505
+ };
506
+ }
507
+ }
508
+
509
+ /// Free balance of an asset (Rails' rule, Ruling R9); absent → 0.
510
+ pub async fn balance(&self, asset_symbol: &str) -> Result<BigDecimal, VenueError> {
511
+ let data = self.call("get_extended_balance", BTreeMap::new()).await?;
512
+ finished(normalize::free_balance::<BigDecimal>(&data, asset_symbol).map_err(unreadable)?)
513
+ }
514
+ }