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,949 @@
|
|
|
1
|
+
mod support;
|
|
2
|
+
use bigdecimal::BigDecimal;
|
|
3
|
+
use chrono::{DateTime, Utc};
|
|
4
|
+
use honeymaker::kraken::sign::set_fixed_nonce;
|
|
5
|
+
use honeymaker_client::kraken::*;
|
|
6
|
+
use honeymaker_client::transport::Timeouts;
|
|
7
|
+
use serde_json::{Value, json};
|
|
8
|
+
use std::str::FromStr;
|
|
9
|
+
use std::time::Duration;
|
|
10
|
+
use support::*;
|
|
11
|
+
|
|
12
|
+
/// The fixed-nonce hook is process-global, so tests that fix it or read the live sequence hold this
|
|
13
|
+
/// across their awaits. An async mutex: a std guard held across `.await` fails clippy's
|
|
14
|
+
/// `await_holding_lock` under `-D warnings`.
|
|
15
|
+
static NONCE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
16
|
+
|
|
17
|
+
fn dec(s: &str) -> BigDecimal {
|
|
18
|
+
BigDecimal::from_str(s).unwrap()
|
|
19
|
+
}
|
|
20
|
+
fn at(s: &str) -> DateTime<Utc> {
|
|
21
|
+
s.parse().unwrap()
|
|
22
|
+
}
|
|
23
|
+
/// Distinct limits, so no test can race two of them.
|
|
24
|
+
fn quick() -> Timeouts {
|
|
25
|
+
Timeouts {
|
|
26
|
+
open: Duration::from_millis(300),
|
|
27
|
+
read: Duration::from_millis(400),
|
|
28
|
+
write: Duration::from_millis(500),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
fn creds() -> Credentials {
|
|
32
|
+
Credentials {
|
|
33
|
+
api_key: "key".into(),
|
|
34
|
+
api_secret: "c2VjcmV0".into(),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
fn client(k: &Kraken, credentials: Option<Credentials>) -> Client {
|
|
38
|
+
Client::new(Config {
|
|
39
|
+
base_url: k.url(),
|
|
40
|
+
credentials,
|
|
41
|
+
timeouts: quick(),
|
|
42
|
+
..Config::default()
|
|
43
|
+
})
|
|
44
|
+
.unwrap()
|
|
45
|
+
}
|
|
46
|
+
fn market(cl: &str) -> NewOrder {
|
|
47
|
+
NewOrder {
|
|
48
|
+
pair: "XBTEUR".into(),
|
|
49
|
+
kind: OrderKind::Market,
|
|
50
|
+
volume: "0.0012".into(),
|
|
51
|
+
quote_volume: true,
|
|
52
|
+
cl_ord_id: cl.into(),
|
|
53
|
+
deadline: at("2026-09-30T12:00:10Z"),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const TICKER: &str = r#"{"error":[],"result":{"XXBTZEUR":{"a":["50000.2","1","1.000"],"b":["49990.1","1","1.000"],"c":["49995.3","0.001"]}}}"#;
|
|
57
|
+
fn ticker() -> KReply {
|
|
58
|
+
KReply::Http(200, "application/json", TICKER.into())
|
|
59
|
+
}
|
|
60
|
+
fn raw_order(
|
|
61
|
+
cl: Option<&str>,
|
|
62
|
+
status: &str,
|
|
63
|
+
oflags: &str,
|
|
64
|
+
ordertype: &str,
|
|
65
|
+
price: &str,
|
|
66
|
+
descr_price: &str,
|
|
67
|
+
) -> Value {
|
|
68
|
+
let mut o = json!({ "userref": 0, "status": status, "opentm": 1727697600.1234, "vol": if oflags.contains("viqc") { "60.00000000" } else { "0.00100000" },
|
|
69
|
+
"vol_exec": if status == "closed" { "0.00119975" } else { "0" }, "cost": if status == "closed" { "60.0" } else { "0" },
|
|
70
|
+
"price": price, "oflags": oflags, "misc": "", "descr": { "pair": "XBTEUR", "type": "buy", "ordertype": ordertype, "price": descr_price } });
|
|
71
|
+
if let Some(c) = cl {
|
|
72
|
+
o["cl_ord_id"] = json!(c);
|
|
73
|
+
}
|
|
74
|
+
o
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[tokio::test(flavor = "current_thread")]
|
|
78
|
+
async fn prices_are_bid_ask_and_last_from_a_get_with_the_gems_headers() {
|
|
79
|
+
let k = kraken(vec![("/0/public/Ticker", vec![ticker()])]).await;
|
|
80
|
+
let p = client(&k, None).prices("XBTEUR").await.unwrap();
|
|
81
|
+
assert_eq!(
|
|
82
|
+
p,
|
|
83
|
+
Prices {
|
|
84
|
+
bid: dec("49990.1"),
|
|
85
|
+
ask: dec("50000.2"),
|
|
86
|
+
last: dec("49995.3")
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
let r = &k.requests()[0];
|
|
90
|
+
assert_eq!(
|
|
91
|
+
(r.method.as_str(), r.target.as_str()),
|
|
92
|
+
("GET", "/0/public/Ticker?pair=XBTEUR")
|
|
93
|
+
);
|
|
94
|
+
assert_eq!(
|
|
95
|
+
(r.header("user-agent"), r.header("accept")),
|
|
96
|
+
(Some("Honeymaker Ruby"), Some("application/json"))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[tokio::test(flavor = "current_thread")]
|
|
101
|
+
async fn the_user_agent_is_the_callers_to_set() {
|
|
102
|
+
let k = kraken(vec![("/0/public/Ticker", vec![ticker()])]).await;
|
|
103
|
+
let c = Client::new(Config {
|
|
104
|
+
base_url: k.url(),
|
|
105
|
+
user_agent: "deltabadger-engine".into(),
|
|
106
|
+
..Config::default()
|
|
107
|
+
})
|
|
108
|
+
.unwrap();
|
|
109
|
+
c.prices("XBTEUR").await.unwrap();
|
|
110
|
+
assert_eq!(
|
|
111
|
+
k.requests()[0].header("user-agent"),
|
|
112
|
+
Some("deltabadger-engine")
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[tokio::test(flavor = "current_thread")]
|
|
117
|
+
async fn a_ticker_refusal_or_an_empty_result_is_rejected_like_rails() {
|
|
118
|
+
let k = kraken(vec![(
|
|
119
|
+
"/0/public/Ticker",
|
|
120
|
+
vec![
|
|
121
|
+
kok(json!({ "error": ["EQuery:Unknown asset pair"] })),
|
|
122
|
+
kok(json!({ "error": [], "result": {} })),
|
|
123
|
+
],
|
|
124
|
+
)])
|
|
125
|
+
.await;
|
|
126
|
+
let c = client(&k, None);
|
|
127
|
+
assert_eq!(
|
|
128
|
+
c.prices("XBTEUR").await,
|
|
129
|
+
Err(VenueError::Rejected(vec![
|
|
130
|
+
"EQuery:Unknown asset pair".into()
|
|
131
|
+
]))
|
|
132
|
+
);
|
|
133
|
+
assert_eq!(
|
|
134
|
+
c.prices("XBTEUR").await,
|
|
135
|
+
Err(VenueError::Rejected(vec![
|
|
136
|
+
"Failed to get Kraken XBTEUR ticker information".into()
|
|
137
|
+
]))
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fn wire_vectors() -> Value {
|
|
142
|
+
serde_json::from_str(
|
|
143
|
+
&std::fs::read_to_string(concat!(
|
|
144
|
+
env!("CARGO_MANIFEST_DIR"),
|
|
145
|
+
"/../honeymaker/tests/vectors/kraken_add_order_wire.json"
|
|
146
|
+
))
|
|
147
|
+
.unwrap(),
|
|
148
|
+
)
|
|
149
|
+
.unwrap()
|
|
150
|
+
}
|
|
151
|
+
fn vector_client(k: &Kraken, v: &Value) -> Client {
|
|
152
|
+
Client::new(Config {
|
|
153
|
+
base_url: k.url(),
|
|
154
|
+
timeouts: quick(),
|
|
155
|
+
credentials: Some(Credentials {
|
|
156
|
+
api_key: v["api_key"].as_str().unwrap().into(),
|
|
157
|
+
api_secret: v["api_secret"].as_str().unwrap().into(),
|
|
158
|
+
}),
|
|
159
|
+
..Config::default()
|
|
160
|
+
})
|
|
161
|
+
.unwrap()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[tokio::test(flavor = "current_thread")]
|
|
165
|
+
async fn add_order_sends_the_signed_request_legacy_sends() {
|
|
166
|
+
let _g = NONCE.lock().await;
|
|
167
|
+
let vectors = wire_vectors();
|
|
168
|
+
let orders = [
|
|
169
|
+
NewOrder {
|
|
170
|
+
deadline: at("2026-09-30T12:00:10.000Z"),
|
|
171
|
+
..market("6f1c1a52-7c8e-4d0e-9a57-0b6f0f1d2e3a")
|
|
172
|
+
},
|
|
173
|
+
NewOrder {
|
|
174
|
+
pair: "XBTEUR".into(),
|
|
175
|
+
kind: OrderKind::Limit {
|
|
176
|
+
price: "49870.1".into(),
|
|
177
|
+
},
|
|
178
|
+
volume: "0.00119".into(),
|
|
179
|
+
quote_volume: false,
|
|
180
|
+
cl_ord_id: "0d9e2c1b-2f4a-4b8c-8d1e-5a6b7c8d9e0f".into(),
|
|
181
|
+
deadline: at("2026-09-30T12:00:10.123Z"),
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
for (v, order) in vectors.as_array().unwrap().iter().zip(orders) {
|
|
185
|
+
let k = kraken(vec![("/0/private/AddOrder", vec![kok(json!({ "error": [], "result": { "descr": { "order": "buy" }, "txid": ["OTX-1"] } }))])]).await;
|
|
186
|
+
set_fixed_nonce(v["nonce"].as_u64());
|
|
187
|
+
let r = vector_client(&k, v).add_order(&order).await;
|
|
188
|
+
set_fixed_nonce(None);
|
|
189
|
+
assert_eq!(r, Ok("OTX-1".to_string()));
|
|
190
|
+
let req = &k.requests()[0];
|
|
191
|
+
assert_eq!(
|
|
192
|
+
(req.method.as_str(), req.target.as_str()),
|
|
193
|
+
("POST", "/0/private/AddOrder")
|
|
194
|
+
);
|
|
195
|
+
assert_eq!(Some(req.body.as_str()), v["body"].as_str());
|
|
196
|
+
assert_eq!(req.header("api-sign"), v["headers"]["API-Sign"].as_str());
|
|
197
|
+
assert_eq!(
|
|
198
|
+
req.header("content-type"),
|
|
199
|
+
Some("application/x-www-form-urlencoded")
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[tokio::test(flavor = "current_thread")]
|
|
205
|
+
async fn add_order_validate_sends_validate_true_as_legacy_does() {
|
|
206
|
+
let _g = NONCE.lock().await;
|
|
207
|
+
let v = &wire_vectors()[2];
|
|
208
|
+
let k = kraken(vec![(
|
|
209
|
+
"/0/private/AddOrder",
|
|
210
|
+
vec![kok(
|
|
211
|
+
json!({ "error": [], "result": { "descr": { "order": "buy 60 XBTEUR @ market" } } }),
|
|
212
|
+
)],
|
|
213
|
+
)])
|
|
214
|
+
.await;
|
|
215
|
+
let order = NewOrder {
|
|
216
|
+
volume: "60".into(),
|
|
217
|
+
..market("6f1c1a52-7c8e-4d0e-9a57-0b6f0f1d2e3a")
|
|
218
|
+
};
|
|
219
|
+
set_fixed_nonce(v["nonce"].as_u64());
|
|
220
|
+
let r = vector_client(&k, v)
|
|
221
|
+
.add_order_validate(&NewOrder {
|
|
222
|
+
deadline: at("2026-09-30T12:00:10.000Z"),
|
|
223
|
+
..order
|
|
224
|
+
})
|
|
225
|
+
.await;
|
|
226
|
+
set_fixed_nonce(None);
|
|
227
|
+
assert_eq!(r, Ok(()));
|
|
228
|
+
assert_eq!(Some(k.requests()[0].body.as_str()), v["body"].as_str());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#[tokio::test(flavor = "current_thread")]
|
|
232
|
+
async fn add_order_is_sent_exactly_once_whatever_happens() {
|
|
233
|
+
let nil = "Failed to set Kraken market order (order_id is nil)";
|
|
234
|
+
let cases: Vec<(KReply, Result<String, VenueError>)> = vec![
|
|
235
|
+
(
|
|
236
|
+
kok(json!({ "error": [], "result": { "txid": ["OTX-1"] } })),
|
|
237
|
+
Ok("OTX-1".into()),
|
|
238
|
+
),
|
|
239
|
+
(
|
|
240
|
+
kok(json!({ "error": ["EOrder:Insufficient funds"] })),
|
|
241
|
+
Err(VenueError::Rejected(vec![
|
|
242
|
+
"EOrder:Insufficient funds".into(),
|
|
243
|
+
])),
|
|
244
|
+
),
|
|
245
|
+
(
|
|
246
|
+
kok(json!({ "error": [], "result": { "descr": { "order": "buy" } } })),
|
|
247
|
+
Err(VenueError::Ambiguous(nil.into())),
|
|
248
|
+
),
|
|
249
|
+
(
|
|
250
|
+
kok(json!({ "error": [], "result": { "txid": [""] } })),
|
|
251
|
+
Err(VenueError::Ambiguous(nil.into())),
|
|
252
|
+
),
|
|
253
|
+
(
|
|
254
|
+
KReply::Http(
|
|
255
|
+
500,
|
|
256
|
+
"application/json",
|
|
257
|
+
r#"{"error":["EService:Unavailable"]}"#.into(),
|
|
258
|
+
),
|
|
259
|
+
Err(VenueError::Ambiguous(
|
|
260
|
+
r#"{"error":["EService:Unavailable"]}"#.into(),
|
|
261
|
+
)),
|
|
262
|
+
),
|
|
263
|
+
(
|
|
264
|
+
KReply::Http(403, "text/html", "<html>blocked</html>".into()),
|
|
265
|
+
Err(VenueError::Rejected(vec!["<html>blocked</html>".into()])),
|
|
266
|
+
),
|
|
267
|
+
(
|
|
268
|
+
KReply::Http(200, "text/html", "<html>maintenance</html>".into()),
|
|
269
|
+
Err(VenueError::Ambiguous(UNREADABLE.into())),
|
|
270
|
+
),
|
|
271
|
+
(
|
|
272
|
+
KReply::Http(200, "application/json", "{".into()),
|
|
273
|
+
Err(VenueError::Ambiguous("{".into())),
|
|
274
|
+
),
|
|
275
|
+
(
|
|
276
|
+
KReply::Close,
|
|
277
|
+
Err(VenueError::Ambiguous("end of file reached".into())),
|
|
278
|
+
),
|
|
279
|
+
(
|
|
280
|
+
KReply::Hang,
|
|
281
|
+
Err(VenueError::Ambiguous("Net::ReadTimeout".into())),
|
|
282
|
+
),
|
|
283
|
+
];
|
|
284
|
+
for (reply, want) in cases {
|
|
285
|
+
let k = kraken(vec![("/0/private/AddOrder", vec![reply])]).await;
|
|
286
|
+
assert_eq!(
|
|
287
|
+
client(&k, Some(creds())).add_order(&market("c-1")).await,
|
|
288
|
+
want
|
|
289
|
+
);
|
|
290
|
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
291
|
+
assert_eq!(
|
|
292
|
+
(k.conns(), k.requests().len()),
|
|
293
|
+
(1, 1),
|
|
294
|
+
"exactly one POST for {want:?}"
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[tokio::test(flavor = "current_thread")]
|
|
300
|
+
async fn an_unacknowledged_limit_order_says_limit() {
|
|
301
|
+
let k = kraken(vec![(
|
|
302
|
+
"/0/private/AddOrder",
|
|
303
|
+
vec![kok(json!({ "error": [], "result": {} }))],
|
|
304
|
+
)])
|
|
305
|
+
.await;
|
|
306
|
+
let order = NewOrder {
|
|
307
|
+
kind: OrderKind::Limit {
|
|
308
|
+
price: "49870.1".into(),
|
|
309
|
+
},
|
|
310
|
+
..market("c-1")
|
|
311
|
+
};
|
|
312
|
+
assert_eq!(
|
|
313
|
+
client(&k, Some(creds())).add_order(&order).await,
|
|
314
|
+
Err(VenueError::Ambiguous(
|
|
315
|
+
"Failed to set Kraken limit order (order_id is nil)".into()
|
|
316
|
+
))
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#[tokio::test(flavor = "current_thread")]
|
|
321
|
+
async fn add_order_to_a_dead_port_is_transient() {
|
|
322
|
+
let port = closed_port().await;
|
|
323
|
+
let c = Client::new(Config {
|
|
324
|
+
base_url: format!("http://127.0.0.1:{port}"),
|
|
325
|
+
credentials: Some(creds()),
|
|
326
|
+
timeouts: quick(),
|
|
327
|
+
..Config::default()
|
|
328
|
+
})
|
|
329
|
+
.unwrap();
|
|
330
|
+
assert_eq!(
|
|
331
|
+
c.add_order(&market("c-1")).await,
|
|
332
|
+
Err(VenueError::Transient(format!(
|
|
333
|
+
"connection refused: 127.0.0.1:{port}"
|
|
334
|
+
)))
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#[tokio::test(flavor = "current_thread")]
|
|
339
|
+
async fn orders_are_query_orders_parsed_as_rails_parses_them() {
|
|
340
|
+
let mut sell_order = raw_order(None, "closed", "", "market", "50000", "0");
|
|
341
|
+
sell_order["descr"]["type"] = json!("SELL");
|
|
342
|
+
let k = kraken(vec![(
|
|
343
|
+
"/0/private/QueryOrders",
|
|
344
|
+
vec![kok(json!({ "error": [], "result": {
|
|
345
|
+
"OTX-1": raw_order(None, "closed", "fciq,viqc", "market", "50010.5", "0"),
|
|
346
|
+
"OTX-2": raw_order(None, "open", "", "limit", "0", "49870.0"),
|
|
347
|
+
"OTX-3": sell_order } }))],
|
|
348
|
+
)])
|
|
349
|
+
.await;
|
|
350
|
+
let got = client(&k, Some(creds()))
|
|
351
|
+
.orders(&[
|
|
352
|
+
"OTX-1".into(),
|
|
353
|
+
"OTX-2".into(),
|
|
354
|
+
"OTX-3".into(),
|
|
355
|
+
"OTX-9".into(),
|
|
356
|
+
])
|
|
357
|
+
.await
|
|
358
|
+
.unwrap();
|
|
359
|
+
assert_eq!(got.len(), 3, "ids Kraken does not report are absent");
|
|
360
|
+
assert_eq!(
|
|
361
|
+
got[0],
|
|
362
|
+
OrderState {
|
|
363
|
+
txid: "OTX-1".into(),
|
|
364
|
+
status: OrderStatus::Closed,
|
|
365
|
+
price: Some(dec("50010.5")),
|
|
366
|
+
amount: None,
|
|
367
|
+
quote_amount: Some(dec("60")),
|
|
368
|
+
amount_exec: dec("0.00119975"),
|
|
369
|
+
quote_amount_exec: dec("60"),
|
|
370
|
+
limit: false,
|
|
371
|
+
sell: false
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
assert!(
|
|
375
|
+
got[2].sell,
|
|
376
|
+
"an existing sell stays a sell (descr.type, downcased as parse_order_data does)"
|
|
377
|
+
);
|
|
378
|
+
assert_eq!(
|
|
379
|
+
(
|
|
380
|
+
got[1].status,
|
|
381
|
+
got[1].price.clone(),
|
|
382
|
+
got[1].amount.clone(),
|
|
383
|
+
got[1].limit
|
|
384
|
+
),
|
|
385
|
+
(
|
|
386
|
+
OrderStatus::Open,
|
|
387
|
+
Some(dec("49870")),
|
|
388
|
+
Some(dec("0.001")),
|
|
389
|
+
true
|
|
390
|
+
)
|
|
391
|
+
);
|
|
392
|
+
assert!(
|
|
393
|
+
k.requests()[0]
|
|
394
|
+
.body
|
|
395
|
+
.ends_with("&txid=OTX-1%2COTX-2%2COTX-3%2COTX-9&consolidate_taker=true"),
|
|
396
|
+
"{}",
|
|
397
|
+
k.requests()[0].body
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[tokio::test(flavor = "current_thread")]
|
|
402
|
+
async fn a_client_order_id_is_looked_up_open_then_closed_since_the_given_time() {
|
|
403
|
+
let k = kraken(vec![
|
|
404
|
+
("/0/private/OpenOrders", vec![kok(json!({ "error": [], "result": { "open": {} } }))]),
|
|
405
|
+
("/0/private/ClosedOrders", vec![kok(json!({ "error": [], "result": { "closed": { "OTX-7": raw_order(Some("c-1"), "closed", "viqc", "market", "50000", "0") }, "count": 1 } }))]),
|
|
406
|
+
]).await;
|
|
407
|
+
let found = client(&k, Some(creds()))
|
|
408
|
+
.order_by_client_id("c-1", at("2026-09-30T11:00:00Z"))
|
|
409
|
+
.await
|
|
410
|
+
.unwrap()
|
|
411
|
+
.unwrap();
|
|
412
|
+
assert_eq!(
|
|
413
|
+
(found.txid.as_str(), found.status, found.sell),
|
|
414
|
+
("OTX-7", OrderStatus::Closed, false)
|
|
415
|
+
);
|
|
416
|
+
let reqs = k.requests();
|
|
417
|
+
assert_eq!(
|
|
418
|
+
reqs.iter().map(|r| r.target.as_str()).collect::<Vec<_>>(),
|
|
419
|
+
["/0/private/OpenOrders", "/0/private/ClosedOrders"]
|
|
420
|
+
);
|
|
421
|
+
assert!(reqs[0].body.ends_with("&cl_ord_id=c-1"));
|
|
422
|
+
assert!(
|
|
423
|
+
reqs[1]
|
|
424
|
+
.body
|
|
425
|
+
.ends_with("&cl_ord_id=c-1&start=1790766000&ofs=0"),
|
|
426
|
+
"{}",
|
|
427
|
+
reqs[1].body
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#[tokio::test(flavor = "current_thread")]
|
|
432
|
+
async fn absence_is_reported_only_after_every_closed_page() {
|
|
433
|
+
let other = |i: usize| {
|
|
434
|
+
raw_order(
|
|
435
|
+
Some(&format!("other-{i}")),
|
|
436
|
+
"closed",
|
|
437
|
+
"viqc",
|
|
438
|
+
"market",
|
|
439
|
+
"50000",
|
|
440
|
+
"0",
|
|
441
|
+
)
|
|
442
|
+
};
|
|
443
|
+
let k = kraken(vec![
|
|
444
|
+
(
|
|
445
|
+
"/0/private/OpenOrders",
|
|
446
|
+
vec![kok(json!({ "error": [], "result": { "open": {} } }))],
|
|
447
|
+
),
|
|
448
|
+
(
|
|
449
|
+
"/0/private/ClosedOrders",
|
|
450
|
+
vec![
|
|
451
|
+
kok(json!({ "error": [], "result": { "closed": { "O1": other(1) }, "count": 2 } })),
|
|
452
|
+
kok(json!({ "error": [], "result": { "closed": { "O2": other(2) }, "count": 2 } })),
|
|
453
|
+
],
|
|
454
|
+
),
|
|
455
|
+
])
|
|
456
|
+
.await;
|
|
457
|
+
assert_eq!(
|
|
458
|
+
client(&k, Some(creds()))
|
|
459
|
+
.order_by_client_id("c-1", at("2026-09-30T11:00:00Z"))
|
|
460
|
+
.await,
|
|
461
|
+
Ok(None)
|
|
462
|
+
);
|
|
463
|
+
assert!(k.requests()[2].body.ends_with("&ofs=1"));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#[tokio::test(flavor = "current_thread")]
|
|
467
|
+
async fn a_failed_closed_page_is_an_error_not_absence() {
|
|
468
|
+
let other = raw_order(Some("other"), "closed", "viqc", "market", "50000", "0");
|
|
469
|
+
for page2 in [
|
|
470
|
+
KReply::Http(502, "text/html", "bad gateway".into()),
|
|
471
|
+
kok(json!({ "error": ["EAPI:Rate limit exceeded"] })),
|
|
472
|
+
KReply::Close,
|
|
473
|
+
] {
|
|
474
|
+
let k = kraken(vec![
|
|
475
|
+
("/0/private/OpenOrders", vec![kok(json!({ "error": [], "result": { "open": {} } }))]),
|
|
476
|
+
("/0/private/ClosedOrders", vec![kok(json!({ "error": [], "result": { "closed": { "O1": other.clone() }, "count": 3 } })), page2]),
|
|
477
|
+
]).await;
|
|
478
|
+
assert!(
|
|
479
|
+
client(&k, Some(creds()))
|
|
480
|
+
.order_by_client_id("c-1", at("2026-09-30T11:00:00Z"))
|
|
481
|
+
.await
|
|
482
|
+
.is_err()
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#[tokio::test(flavor = "current_thread")]
|
|
488
|
+
async fn placement_refusals_rails_treats_as_ambiguous_keep_the_intent() {
|
|
489
|
+
let ambiguous = [
|
|
490
|
+
"EGeneral:Internal error",
|
|
491
|
+
"EAPI:Invalid nonce",
|
|
492
|
+
"EService:Unavailable",
|
|
493
|
+
"EService:Busy",
|
|
494
|
+
"EService:Deadline elapsed",
|
|
495
|
+
"Net::ReadTimeout",
|
|
496
|
+
"Net::OpenTimeout",
|
|
497
|
+
"Faraday::TimeoutError",
|
|
498
|
+
"Faraday::ConnectionFailed",
|
|
499
|
+
"execution expired",
|
|
500
|
+
"Connection reset",
|
|
501
|
+
"Errno::ECONNRESET",
|
|
502
|
+
"connection refused",
|
|
503
|
+
"Connection refused",
|
|
504
|
+
"Errno::ECONNREFUSED",
|
|
505
|
+
"end of file reached",
|
|
506
|
+
"unexpected eof while reading",
|
|
507
|
+
];
|
|
508
|
+
for s in ambiguous {
|
|
509
|
+
for reply in [
|
|
510
|
+
kok(json!({ "error": [s] })),
|
|
511
|
+
KReply::Http(400, "application/json", json!({ "error": [s] }).to_string()),
|
|
512
|
+
] {
|
|
513
|
+
let k = kraken(vec![("/0/private/AddOrder", vec![reply])]).await;
|
|
514
|
+
let r = client(&k, Some(creds())).add_order(&market("c-1")).await;
|
|
515
|
+
assert!(
|
|
516
|
+
matches!(r, Err(VenueError::Ambiguous(ref m)) if m.contains(s)),
|
|
517
|
+
"{s}: {r:?}"
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
let k = kraken(vec![(
|
|
522
|
+
"/0/private/AddOrder",
|
|
523
|
+
vec![KReply::Http(
|
|
524
|
+
403,
|
|
525
|
+
"text/plain",
|
|
526
|
+
"upstream answered HTTP 503".into(),
|
|
527
|
+
)],
|
|
528
|
+
)])
|
|
529
|
+
.await;
|
|
530
|
+
assert!(
|
|
531
|
+
matches!(
|
|
532
|
+
client(&k, Some(creds())).add_order(&market("c-1")).await,
|
|
533
|
+
Err(VenueError::Ambiguous(_))
|
|
534
|
+
),
|
|
535
|
+
"/\\bHTTP 5\\d\\d\\b/"
|
|
536
|
+
);
|
|
537
|
+
for definitive in [
|
|
538
|
+
"EOrder:Insufficient funds",
|
|
539
|
+
"EService:Market in cancel_only mode",
|
|
540
|
+
"EAPI:Rate limit exceeded",
|
|
541
|
+
"EGeneral:Timestamp for this request was 1200ms ahead; EService:Busy",
|
|
542
|
+
] {
|
|
543
|
+
let k = kraken(vec![(
|
|
544
|
+
"/0/private/AddOrder",
|
|
545
|
+
vec![kok(json!({ "error": [definitive] }))],
|
|
546
|
+
)])
|
|
547
|
+
.await;
|
|
548
|
+
assert_eq!(
|
|
549
|
+
client(&k, Some(creds())).add_order(&market("c-1")).await,
|
|
550
|
+
Err(VenueError::Rejected(vec![definitive.into()])),
|
|
551
|
+
"{definitive}"
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
let k = kraken(vec![(
|
|
555
|
+
"/0/private/AddOrder",
|
|
556
|
+
vec![kok(
|
|
557
|
+
json!({ "error": ["EGeneral:Invalid arguments", "EService:Busy"] }),
|
|
558
|
+
)],
|
|
559
|
+
)])
|
|
560
|
+
.await;
|
|
561
|
+
assert_eq!(
|
|
562
|
+
client(&k, Some(creds())).add_order(&market("c-1")).await,
|
|
563
|
+
Err(VenueError::Ambiguous(
|
|
564
|
+
"EGeneral:Invalid arguments and EService:Busy".into()
|
|
565
|
+
)),
|
|
566
|
+
"to_sentence of all the strings"
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[tokio::test(flavor = "current_thread")]
|
|
571
|
+
async fn a_proxied_client_reaches_kraken_through_connect() {
|
|
572
|
+
let pki = pki();
|
|
573
|
+
let t = target(
|
|
574
|
+
Tls::Trusted,
|
|
575
|
+
Act::Reply(http("200 OK", "application/json", TICKER)),
|
|
576
|
+
&pki,
|
|
577
|
+
)
|
|
578
|
+
.await;
|
|
579
|
+
let p = proxy(ProxyAct::Tunnel(t.addr)).await;
|
|
580
|
+
let c = Client::new(Config {
|
|
581
|
+
base_url: t.https_localhost(),
|
|
582
|
+
proxy: Some(format!("http://127.0.0.1:{}", p.addr.port())),
|
|
583
|
+
tls_roots: Some(pki.roots.clone()),
|
|
584
|
+
timeouts: quick(),
|
|
585
|
+
..Config::default()
|
|
586
|
+
})
|
|
587
|
+
.unwrap();
|
|
588
|
+
assert_eq!(c.prices("XBTEUR").await.unwrap().last, dec("49995.3"));
|
|
589
|
+
assert!(p.seen.heads()[0].starts_with("connect localhost:"));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#[test]
|
|
593
|
+
fn a_bad_proxy_fails_construction_not_the_first_call() {
|
|
594
|
+
assert!(
|
|
595
|
+
Client::new(Config {
|
|
596
|
+
proxy: Some("socks5://h:1".into()),
|
|
597
|
+
..Config::default()
|
|
598
|
+
})
|
|
599
|
+
.is_err()
|
|
600
|
+
);
|
|
601
|
+
let d = Config::default();
|
|
602
|
+
assert_eq!(
|
|
603
|
+
(d.base_url.as_str(), d.user_agent.as_str()),
|
|
604
|
+
("https://api.kraken.com", "Honeymaker Ruby")
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#[tokio::test(flavor = "current_thread")]
|
|
609
|
+
async fn every_placement_text_outcome_sends_once_on_success_and_http_error_paths() {
|
|
610
|
+
let ambiguous = [
|
|
611
|
+
"EGeneral:Internal error",
|
|
612
|
+
"EAPI:Invalid nonce",
|
|
613
|
+
"EService:Unavailable",
|
|
614
|
+
"EService:Busy",
|
|
615
|
+
"EService:Deadline elapsed",
|
|
616
|
+
"Net::ReadTimeout",
|
|
617
|
+
"Net::OpenTimeout",
|
|
618
|
+
"Faraday::TimeoutError",
|
|
619
|
+
"Faraday::ConnectionFailed",
|
|
620
|
+
"execution expired",
|
|
621
|
+
"Connection reset",
|
|
622
|
+
"Errno::ECONNRESET",
|
|
623
|
+
"connection refused",
|
|
624
|
+
"Connection refused",
|
|
625
|
+
"Errno::ECONNREFUSED",
|
|
626
|
+
"end of file reached",
|
|
627
|
+
"unexpected eof while reading",
|
|
628
|
+
"upstream HTTP 503 failed",
|
|
629
|
+
];
|
|
630
|
+
let definitive = [
|
|
631
|
+
"EOrder:Insufficient funds",
|
|
632
|
+
"EAPI:Rate limit exceeded",
|
|
633
|
+
"EService:Market in cancel_only mode",
|
|
634
|
+
"Timestamp for this request is outside of the recvWindow; EService:Busy",
|
|
635
|
+
"Timestamp for this request was 1200ms ahead; EService:Busy",
|
|
636
|
+
"XHTTP 503",
|
|
637
|
+
"HTTP 5030",
|
|
638
|
+
"HTTP 503_suffix",
|
|
639
|
+
];
|
|
640
|
+
for (messages, is_ambiguous) in [(&ambiguous[..], true), (&definitive[..], false)] {
|
|
641
|
+
for message in messages {
|
|
642
|
+
for status in [200, 400, 503] {
|
|
643
|
+
let body = json!({"error": [message]}).to_string();
|
|
644
|
+
let k = kraken(vec![(
|
|
645
|
+
"/0/private/AddOrder",
|
|
646
|
+
vec![KReply::Http(status, "application/json", body.clone())],
|
|
647
|
+
)])
|
|
648
|
+
.await;
|
|
649
|
+
let got = client(&k, Some(creds())).add_order(&market("once")).await;
|
|
650
|
+
let text = if status == 200 {
|
|
651
|
+
message.to_string()
|
|
652
|
+
} else {
|
|
653
|
+
body
|
|
654
|
+
};
|
|
655
|
+
let want = if is_ambiguous || status >= 500 {
|
|
656
|
+
Err(VenueError::Ambiguous(text))
|
|
657
|
+
} else {
|
|
658
|
+
Err(VenueError::Rejected(vec![text]))
|
|
659
|
+
};
|
|
660
|
+
assert_eq!(got, want, "{status}: {message}");
|
|
661
|
+
assert_eq!(
|
|
662
|
+
(k.conns(), k.requests().len()),
|
|
663
|
+
(1, 1),
|
|
664
|
+
"{status}: {message}"
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
#[tokio::test(flavor = "current_thread")]
|
|
672
|
+
async fn malformed_add_order_results_are_ambiguous_and_sent_once() {
|
|
673
|
+
for body in [
|
|
674
|
+
json!(null),
|
|
675
|
+
json!([]),
|
|
676
|
+
json!({}),
|
|
677
|
+
json!({"error": [], "result": null}),
|
|
678
|
+
json!({"error": [], "result": {"txid": 42}}),
|
|
679
|
+
json!({"error": [], "result": {"txid": [null]}}),
|
|
680
|
+
] {
|
|
681
|
+
let k = kraken(vec![("/0/private/AddOrder", vec![kok(body)])]).await;
|
|
682
|
+
assert!(matches!(
|
|
683
|
+
client(&k, Some(creds())).add_order(&market("once")).await,
|
|
684
|
+
Err(VenueError::Ambiguous(_))
|
|
685
|
+
));
|
|
686
|
+
assert_eq!((k.conns(), k.requests().len()), (1, 1));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
#[tokio::test(flavor = "current_thread")]
|
|
691
|
+
async fn validate_outcomes_are_sent_once_and_need_no_txid() {
|
|
692
|
+
for (reply, want) in [
|
|
693
|
+
(kok(json!({"error": [], "result": {}})), Ok(())),
|
|
694
|
+
(
|
|
695
|
+
kok(json!({"error": ["EOrder:Insufficient funds"]})),
|
|
696
|
+
Err(VenueError::Rejected(vec![
|
|
697
|
+
"EOrder:Insufficient funds".into(),
|
|
698
|
+
])),
|
|
699
|
+
),
|
|
700
|
+
(
|
|
701
|
+
KReply::Close,
|
|
702
|
+
Err(VenueError::Ambiguous("end of file reached".into())),
|
|
703
|
+
),
|
|
704
|
+
(
|
|
705
|
+
KReply::Hang,
|
|
706
|
+
Err(VenueError::Ambiguous("Net::ReadTimeout".into())),
|
|
707
|
+
),
|
|
708
|
+
] {
|
|
709
|
+
let k = kraken(vec![("/0/private/AddOrder", vec![reply])]).await;
|
|
710
|
+
assert_eq!(
|
|
711
|
+
client(&k, Some(creds()))
|
|
712
|
+
.add_order_validate(&market("once"))
|
|
713
|
+
.await,
|
|
714
|
+
want
|
|
715
|
+
);
|
|
716
|
+
assert_eq!((k.conns(), k.requests().len()), (1, 1));
|
|
717
|
+
assert!(k.requests()[0].body.contains("&validate=true"));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
#[tokio::test(flavor = "current_thread")]
|
|
722
|
+
async fn a_refused_proxy_sends_no_add_order_and_is_transient() {
|
|
723
|
+
let p = proxy(ProxyAct::Answer(http(
|
|
724
|
+
"407 Proxy Authentication Required",
|
|
725
|
+
"text/plain",
|
|
726
|
+
"proxy refused",
|
|
727
|
+
)))
|
|
728
|
+
.await;
|
|
729
|
+
let c = Client::new(Config {
|
|
730
|
+
base_url: "https://127.0.0.1:1".into(),
|
|
731
|
+
proxy: Some(format!("http://127.0.0.1:{}", p.addr.port())),
|
|
732
|
+
credentials: Some(creds()),
|
|
733
|
+
timeouts: quick(),
|
|
734
|
+
..Config::default()
|
|
735
|
+
})
|
|
736
|
+
.unwrap();
|
|
737
|
+
assert!(matches!(
|
|
738
|
+
c.add_order(&market("once")).await,
|
|
739
|
+
Err(VenueError::Transient(_))
|
|
740
|
+
));
|
|
741
|
+
assert_eq!(p.seen.conns(), 1);
|
|
742
|
+
assert_eq!(p.seen.heads().len(), 1);
|
|
743
|
+
assert!(p.seen.heads()[0].starts_with("connect "));
|
|
744
|
+
assert!(!p.seen.heads()[0].contains("AddOrder"));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
#[tokio::test(flavor = "current_thread")]
|
|
748
|
+
async fn an_open_match_finishes_without_reading_closed_orders() {
|
|
749
|
+
let k = kraken(vec![(
|
|
750
|
+
"/0/private/OpenOrders",
|
|
751
|
+
vec![kok(json!({"error": [], "result": {"open": {
|
|
752
|
+
"OTX-OPEN": raw_order(Some("c-1"), "open", "", "limit", "0", "49870")
|
|
753
|
+
}}}))],
|
|
754
|
+
)])
|
|
755
|
+
.await;
|
|
756
|
+
let found = client(&k, Some(creds()))
|
|
757
|
+
.order_by_client_id("c-1", at("2026-09-30T11:00:00Z"))
|
|
758
|
+
.await
|
|
759
|
+
.unwrap()
|
|
760
|
+
.unwrap();
|
|
761
|
+
assert_eq!(
|
|
762
|
+
(found.txid.as_str(), found.status),
|
|
763
|
+
("OTX-OPEN", OrderStatus::Open)
|
|
764
|
+
);
|
|
765
|
+
assert_eq!(k.requests().len(), 1);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
#[tokio::test(flavor = "current_thread")]
|
|
769
|
+
async fn incomplete_or_unreadable_lookups_never_report_absence() {
|
|
770
|
+
let other = raw_order(Some("other"), "closed", "", "market", "50000", "0");
|
|
771
|
+
for pages in [
|
|
772
|
+
vec![kok(json!({"error": [], "result": {"closed": {}}}))],
|
|
773
|
+
vec![kok(
|
|
774
|
+
json!({"error": [], "result": {"closed": {}, "count": 1}}),
|
|
775
|
+
)],
|
|
776
|
+
vec![kok(
|
|
777
|
+
json!({"error": [], "result": {"closed": {"O1": other.clone()}, "count": 2}}),
|
|
778
|
+
)],
|
|
779
|
+
vec![
|
|
780
|
+
kok(json!({"error": [], "result": {"closed": {"O1": other.clone()}, "count": 3}})),
|
|
781
|
+
kok(json!({"error": [], "result": {"closed": {"O2": other.clone()}, "count": 1}})),
|
|
782
|
+
kok(json!({"error": [], "result": {"closed": {}, "count": 1}})),
|
|
783
|
+
],
|
|
784
|
+
vec![kok(json!({"error": [], "result": {"closed": []}}))],
|
|
785
|
+
vec![kok(
|
|
786
|
+
json!({"error": [], "result": {"closed": {"O1": {"status": "closed", "vol": "bad"}}, "count": 1}}),
|
|
787
|
+
)],
|
|
788
|
+
] {
|
|
789
|
+
let k = kraken(vec![
|
|
790
|
+
(
|
|
791
|
+
"/0/private/OpenOrders",
|
|
792
|
+
vec![kok(json!({"error": [], "result": {"open": {}}}))],
|
|
793
|
+
),
|
|
794
|
+
("/0/private/ClosedOrders", pages),
|
|
795
|
+
])
|
|
796
|
+
.await;
|
|
797
|
+
assert!(matches!(
|
|
798
|
+
client(&k, Some(creds()))
|
|
799
|
+
.order_by_client_id("c-1", at("2026-09-30T11:00:00Z"))
|
|
800
|
+
.await,
|
|
801
|
+
Err(VenueError::Ambiguous(_))
|
|
802
|
+
));
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
#[tokio::test(flavor = "current_thread")]
|
|
807
|
+
async fn fills_are_trades_history_aggregated_per_order_since_the_given_time() {
|
|
808
|
+
let t = |o: &str, ty: &str, vol: &str, cost: &str| json!({ "ordertxid": o, "vol": vol, "cost": cost, "fee": "0.1", "type": ty, "ordertype": "market", "pair": "XXBTZEUR", "time": 1.5 });
|
|
809
|
+
let k = kraken(vec![("/0/private/TradesHistory", vec![
|
|
810
|
+
kok(json!({ "error": [], "result": { "trades": { "T1": t("OTX-5", "buy", "0.0006", "30.0"), "T2": t("OTX-8", "sell", "1", "2") }, "count": 3 } })),
|
|
811
|
+
kok(json!({ "error": [], "result": { "trades": { "T3": t("OTX-5", "buy", "0.0006", "30.012") }, "count": 3 } })),
|
|
812
|
+
])]).await;
|
|
813
|
+
let fills = client(&k, Some(creds()))
|
|
814
|
+
.fills_from_trades(
|
|
815
|
+
&["OTX-5".into(), "OTX-8".into()],
|
|
816
|
+
at("2026-09-30T10:00:00Z"),
|
|
817
|
+
)
|
|
818
|
+
.await
|
|
819
|
+
.unwrap();
|
|
820
|
+
assert_eq!(
|
|
821
|
+
fills[0],
|
|
822
|
+
OrderState {
|
|
823
|
+
txid: "OTX-5".into(),
|
|
824
|
+
status: OrderStatus::Closed,
|
|
825
|
+
price: Some(dec("50010")),
|
|
826
|
+
amount: None,
|
|
827
|
+
quote_amount: None,
|
|
828
|
+
amount_exec: dec("0.0012"),
|
|
829
|
+
quote_amount_exec: dec("60.012"),
|
|
830
|
+
limit: false,
|
|
831
|
+
sell: false
|
|
832
|
+
}
|
|
833
|
+
);
|
|
834
|
+
assert!(
|
|
835
|
+
fills[1].sell && fills[1].txid == "OTX-8",
|
|
836
|
+
"a sell's trades aggregate as a sell"
|
|
837
|
+
);
|
|
838
|
+
let reqs = k.requests();
|
|
839
|
+
assert!(
|
|
840
|
+
reqs[0].body.ends_with("&start=1790762400&ofs=0")
|
|
841
|
+
&& reqs[1].body.ends_with("&start=1790762400&ofs=2")
|
|
842
|
+
);
|
|
843
|
+
assert!(
|
|
844
|
+
reqs.iter().all(|r| !r.body.contains("end=")),
|
|
845
|
+
"no `end`: it could filter out fills on clock skew (R22)"
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
#[tokio::test(flavor = "current_thread")]
|
|
850
|
+
async fn fills_fail_rather_than_return_a_partial_scan() {
|
|
851
|
+
let t = json!({ "ordertxid": "OTX-9", "vol": "1", "cost": "1", "fee": "0", "type": "buy", "ordertype": "market" });
|
|
852
|
+
// Page cap: every page has one unrelated trade and Kraken says there are a thousand.
|
|
853
|
+
let pages: Vec<KReply> = (0..TRADES_MAX_PAGES).map(|i| kok(json!({ "error": [], "result": { "trades": { format!("T{i}"): t.clone() }, "count": 1000 } }))).collect();
|
|
854
|
+
let k = kraken(vec![("/0/private/TradesHistory", pages)]).await;
|
|
855
|
+
assert!(matches!(
|
|
856
|
+
client(&k, Some(creds()))
|
|
857
|
+
.fills_from_trades(&["OTX-5".into()], at("2026-09-30T10:00:00Z"))
|
|
858
|
+
.await,
|
|
859
|
+
Err(VenueError::Ambiguous(_))
|
|
860
|
+
));
|
|
861
|
+
assert_eq!(k.requests().len(), TRADES_MAX_PAGES as usize);
|
|
862
|
+
// No count, and a premature empty page.
|
|
863
|
+
for page in [
|
|
864
|
+
json!({ "error": [], "result": { "trades": { "T1": t.clone() } } }),
|
|
865
|
+
json!({ "error": [], "result": { "trades": {}, "count": 5 } }),
|
|
866
|
+
] {
|
|
867
|
+
let k = kraken(vec![("/0/private/TradesHistory", vec![kok(page)])]).await;
|
|
868
|
+
assert!(
|
|
869
|
+
client(&k, Some(creds()))
|
|
870
|
+
.fills_from_trades(&["OTX-5".into()], at("2026-09-30T10:00:00Z"))
|
|
871
|
+
.await
|
|
872
|
+
.is_err()
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
#[tokio::test(flavor = "current_thread")]
|
|
878
|
+
async fn orders_of_no_txids_is_empty_without_a_request() {
|
|
879
|
+
let k = kraken(vec![]).await;
|
|
880
|
+
assert_eq!(client(&k, Some(creds())).orders(&[]).await, Ok(vec![]));
|
|
881
|
+
assert!(k.requests().is_empty());
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
#[tokio::test(flavor = "current_thread")]
|
|
885
|
+
async fn balance_is_rails_free_balance_of_the_asset() {
|
|
886
|
+
let k = kraken(vec![("/0/private/BalanceEx", vec![kok(json!({ "error": [], "result": { "ZEUR": { "balance": "1000.5", "hold_trade": "0.5" } } }))])]).await;
|
|
887
|
+
let c = client(&k, Some(creds()));
|
|
888
|
+
assert_eq!(c.balance("EUR").await, Ok(dec("1000")));
|
|
889
|
+
assert_eq!(c.balance("USD").await, Ok(dec("0")));
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
#[tokio::test(flavor = "current_thread")]
|
|
893
|
+
async fn balance_without_hold_trade_is_ambiguous_not_the_whole_balance() {
|
|
894
|
+
let k = kraken(vec![(
|
|
895
|
+
"/0/private/BalanceEx",
|
|
896
|
+
vec![kok(
|
|
897
|
+
json!({ "error": [], "result": { "ZEUR": { "balance": "1000.5" } } }),
|
|
898
|
+
)],
|
|
899
|
+
)])
|
|
900
|
+
.await;
|
|
901
|
+
assert!(matches!(
|
|
902
|
+
client(&k, Some(creds())).balance("EUR").await,
|
|
903
|
+
Err(VenueError::Ambiguous(_))
|
|
904
|
+
));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#[tokio::test(flavor = "current_thread")]
|
|
908
|
+
async fn without_credentials_a_private_call_goes_unsigned_and_kraken_refuses_it() {
|
|
909
|
+
let k = kraken(vec![(
|
|
910
|
+
"/0/private/BalanceEx",
|
|
911
|
+
vec![kok(json!({ "error": ["EAPI:Invalid key"] }))],
|
|
912
|
+
)])
|
|
913
|
+
.await;
|
|
914
|
+
assert_eq!(
|
|
915
|
+
client(&k, None).balance("EUR").await,
|
|
916
|
+
Err(VenueError::Rejected(vec!["EAPI:Invalid key".into()]))
|
|
917
|
+
);
|
|
918
|
+
assert_eq!(k.requests()[0].header("api-key"), None);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
#[tokio::test(flavor = "current_thread")]
|
|
922
|
+
async fn clients_for_one_key_share_one_increasing_nonce_sequence() {
|
|
923
|
+
let _g = NONCE.lock().await;
|
|
924
|
+
let k = kraken(vec![(
|
|
925
|
+
"/0/private/BalanceEx",
|
|
926
|
+
vec![kok(json!({ "error": [], "result": {} }))],
|
|
927
|
+
)])
|
|
928
|
+
.await;
|
|
929
|
+
let (a, b) = (client(&k, Some(creds())), client(&k, Some(creds())));
|
|
930
|
+
for _ in 0..5 {
|
|
931
|
+
a.balance("EUR").await.unwrap();
|
|
932
|
+
b.balance("EUR").await.unwrap();
|
|
933
|
+
}
|
|
934
|
+
let nonces: Vec<u64> = k
|
|
935
|
+
.requests()
|
|
936
|
+
.iter()
|
|
937
|
+
.map(|r| {
|
|
938
|
+
r.body
|
|
939
|
+
.strip_prefix("nonce=")
|
|
940
|
+
.unwrap()
|
|
941
|
+
.split('&')
|
|
942
|
+
.next()
|
|
943
|
+
.unwrap()
|
|
944
|
+
.parse()
|
|
945
|
+
.unwrap()
|
|
946
|
+
})
|
|
947
|
+
.collect();
|
|
948
|
+
assert!(nonces.windows(2).all(|w| w[0] < w[1]), "{nonces:?}");
|
|
949
|
+
}
|