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.
- checksums.yaml +4 -4
- data/.github/workflows/native-gems.yml +100 -0
- data/.github/workflows/release.yml +36 -0
- data/.github/workflows/test.yml +19 -4
- data/Cargo.lock +1078 -0
- data/Cargo.toml +14 -0
- data/README.md +39 -0
- data/Rakefile +28 -16
- data/crates/honeymaker/Cargo.toml +18 -0
- data/crates/honeymaker/src/encode.rs +106 -0
- data/crates/honeymaker/src/kraken/lookup.rs +140 -0
- data/crates/honeymaker/src/kraken/mod.rs +5 -0
- data/crates/honeymaker/src/kraken/normalize.rs +536 -0
- data/crates/honeymaker/src/kraken/requests.rs +286 -0
- data/crates/honeymaker/src/kraken/sign.rs +166 -0
- data/crates/honeymaker/src/kraken/trades.rs +201 -0
- data/crates/honeymaker/src/lib.rs +10 -0
- data/crates/honeymaker/src/num.rs +115 -0
- data/crates/honeymaker/src/semantics.rs +100 -0
- data/crates/honeymaker/src/types.rs +85 -0
- data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
- data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
- data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
- data/crates/honeymaker/tests/vectors/decimal.json +146 -0
- data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
- data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
- data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
- data/crates/honeymaker-client/Cargo.toml +30 -0
- data/crates/honeymaker-client/examples/probe.rs +147 -0
- data/crates/honeymaker-client/src/kraken.rs +514 -0
- data/crates/honeymaker-client/src/lib.rs +4 -0
- data/crates/honeymaker-client/src/transport/connect.rs +583 -0
- data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
- data/crates/honeymaker-client/src/transport/response.rs +169 -0
- data/crates/honeymaker-client/src/transport.rs +467 -0
- data/crates/honeymaker-client/tests/classification.rs +203 -0
- data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
- data/crates/honeymaker-client/tests/response.rs +522 -0
- data/crates/honeymaker-client/tests/support/mod.rs +550 -0
- data/crates/honeymaker-client/tests/transport.rs +828 -0
- data/ext/honeymaker_native/Cargo.toml +16 -0
- data/ext/honeymaker_native/extconf.rb +6 -0
- data/ext/honeymaker_native/src/convert.rs +150 -0
- data/ext/honeymaker_native/src/kraken.rs +362 -0
- data/ext/honeymaker_native/src/lib.rs +90 -0
- data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
- data/lib/honeymaker/clients/bybit.rb +19 -2
- data/lib/honeymaker/clients/coinbase.rb +3 -2
- data/lib/honeymaker/clients/kraken.rb +17 -1
- data/lib/honeymaker/exchanges/coinbase.rb +5 -4
- data/lib/honeymaker/native/kraken.rb +263 -0
- data/lib/honeymaker/native.rb +42 -0
- data/lib/honeymaker/version.rb +1 -1
- data/lib/honeymaker.rb +12 -2
- data/script/parity/kraken_client_live.rb +191 -0
- data/script/parity/kraken_live.rb +70 -0
- data/script/parity/kraken_transport.rb +604 -0
- data/script/parity/resolve.c +49 -0
- data/script/release/push_gems.sh +28 -0
- data/script/vectors/kraken.rb +50 -0
- data/script/vectors/kraken_stage2.rb +146 -0
- data/test/fixtures/coinbase_products.json +1 -1
- data/test/honeymaker/clients/bybit_client_test.rb +25 -0
- data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
- data/test/honeymaker/clients/kraken_client_test.rb +27 -0
- data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
- data/test/native/backend_switch_test.rb +53 -0
- data/test/native/convert_test.rb +137 -0
- data/test/native/gem_isolation_test.rb +17 -0
- data/test/native/loader_test.rb +29 -0
- data/test/native/lockstep_test.rb +23 -0
- data/test/native/signing_smoke_test.rb +28 -0
- data/test/parity/kraken_client_parity_test.rb +398 -0
- data/test/parity/kraken_exchange_parity_test.rb +172 -0
- data/test/release/branch_hardening_test.rb +34 -0
- data/test/release/push_gems_test.rb +79 -0
- data/test/smoke.rb +24 -0
- data/test/support/kraken_signing_smoke.rb +21 -0
- data/test/support/parity_helper.rb +62 -0
- data/test/support/venue_double.rb +100 -0
- metadata +84 -4
|
@@ -0,0 +1,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, ¶ms, 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
|
+
}
|