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,536 @@
|
|
|
1
|
+
use crate::num::{NormError, Num};
|
|
2
|
+
use crate::semantics::{StringOp, is_string, object_key, string_eq, truthy};
|
|
3
|
+
use crate::{OrderStatus, OrderType};
|
|
4
|
+
use serde_json::{Map, Value};
|
|
5
|
+
use std::borrow::Cow;
|
|
6
|
+
|
|
7
|
+
pub enum Finished<T> {
|
|
8
|
+
Ok(T),
|
|
9
|
+
/// Kraken answered with a truthy element in `error`: the whole array, verbatim.
|
|
10
|
+
Venue(Vec<Value>),
|
|
11
|
+
Unreadable,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub type R<T, D> = Result<Finished<T>, NormError<<D as Num>::Error>>;
|
|
15
|
+
|
|
16
|
+
const ASSET_MAP: &[(&str, &str)] = &[
|
|
17
|
+
("ZUSD", "USD"),
|
|
18
|
+
("ZEUR", "EUR"),
|
|
19
|
+
("ZGBP", "GBP"),
|
|
20
|
+
("ZJPY", "JPY"),
|
|
21
|
+
("ZCHF", "CHF"),
|
|
22
|
+
("ZCAD", "CAD"),
|
|
23
|
+
("ZAUD", "AUD"),
|
|
24
|
+
("XXBT", "XBT"),
|
|
25
|
+
("XETH", "ETH"),
|
|
26
|
+
("XXDG", "XDG"),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/// Legacy `BigDecimal(x.to_s)`.
|
|
30
|
+
pub fn dec<D: Num>(v: Option<&Value>) -> Result<D, NormError<D::Error>> {
|
|
31
|
+
D::parse_to_s(v.unwrap_or(&Value::Null)).map_err(NormError::Num)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Not a Hash → unreadable; `error.is_a?(Array) && error.any?` → venue, carrying the array.
|
|
35
|
+
fn envelope(data: &Value) -> Result<&Map<String, Value>, Finished<()>> {
|
|
36
|
+
let Some(obj) = data.as_object() else {
|
|
37
|
+
return Err(Finished::Unreadable);
|
|
38
|
+
};
|
|
39
|
+
if let Some(Value::Array(e)) = obj.get("error")
|
|
40
|
+
&& e.iter().any(|x| truthy(Some(x)))
|
|
41
|
+
{
|
|
42
|
+
return Err(Finished::Venue(e.clone()));
|
|
43
|
+
}
|
|
44
|
+
Ok(obj)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
macro_rules! envelope_or_return {
|
|
48
|
+
($d:expr) => {
|
|
49
|
+
match envelope($d) {
|
|
50
|
+
Ok(o) => o,
|
|
51
|
+
Err(Finished::Venue(e)) => return Ok(Finished::Venue(e)),
|
|
52
|
+
Err(_) => return Ok(Finished::Unreadable),
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// `(result.data["result"] || {}).each do |key, value|`: Array elements are
|
|
58
|
+
/// destructured, with missing values becoming nil and extra values ignored.
|
|
59
|
+
fn result_entries(obj: &Map<String, Value>) -> Result<Vec<(Value, &Value)>, String> {
|
|
60
|
+
match obj.get("result") {
|
|
61
|
+
v if !truthy(v) => Ok(Vec::new()),
|
|
62
|
+
Some(v) if is_string(v) => Err(format!("result is not an object: {v}")),
|
|
63
|
+
Some(Value::Object(m)) => Ok(m.iter().map(|(k, v)| (object_key(k), v)).collect()),
|
|
64
|
+
Some(Value::Array(a)) => Ok(a
|
|
65
|
+
.iter()
|
|
66
|
+
.map(|entry| match entry {
|
|
67
|
+
Value::Array(pair) => (
|
|
68
|
+
pair.first().cloned().unwrap_or(Value::Null),
|
|
69
|
+
pair.get(1).unwrap_or(&Value::Null),
|
|
70
|
+
),
|
|
71
|
+
other => (other.clone(), &Value::Null),
|
|
72
|
+
})
|
|
73
|
+
.collect()),
|
|
74
|
+
Some(other) => Err(format!("result is not an object: {other}")), // legacy raises on these
|
|
75
|
+
None => Ok(Vec::new()),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Legacy string-key indexing accepts both Hash and String. String#[] returns
|
|
80
|
+
/// the requested substring when present, otherwise nil; other shapes raise.
|
|
81
|
+
fn string_fields<'a, D: Num>(
|
|
82
|
+
value: &'a Value,
|
|
83
|
+
keys: &[&str],
|
|
84
|
+
) -> Result<Cow<'a, Map<String, Value>>, NormError<D::Error>> {
|
|
85
|
+
if is_string(value) {
|
|
86
|
+
let mut fields = Map::new();
|
|
87
|
+
for key in keys {
|
|
88
|
+
let found = D::string_op(value, StringOp::Index(key)).map_err(NormError::Num)?;
|
|
89
|
+
if !found.is_null() {
|
|
90
|
+
fields.insert(key.to_string(), found);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return Ok(Cow::Owned(fields));
|
|
94
|
+
}
|
|
95
|
+
match value {
|
|
96
|
+
Value::Object(m) => Ok(Cow::Borrowed(m)),
|
|
97
|
+
other => Err(format!("cannot index with a String: {other}").into()),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pub struct KrakenBalance<D> {
|
|
102
|
+
pub asset: Value,
|
|
103
|
+
pub free: D,
|
|
104
|
+
pub locked: D,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pub fn balances<D: Num>(data: &Value) -> R<Vec<KrakenBalance<D>>, D> {
|
|
108
|
+
let obj = envelope_or_return!(data);
|
|
109
|
+
let mut out: Vec<KrakenBalance<D>> = Vec::new();
|
|
110
|
+
for (symbol, balance) in result_entries(obj)? {
|
|
111
|
+
if !is_string(&symbol) {
|
|
112
|
+
return Err(format!("symbol.split: {symbol}").into());
|
|
113
|
+
}
|
|
114
|
+
let parts = D::string_op(&symbol, StringOp::Split(".")).map_err(NormError::Num)?;
|
|
115
|
+
let first = parts[0].clone();
|
|
116
|
+
let asset = ASSET_MAP
|
|
117
|
+
.iter()
|
|
118
|
+
.find(|(k, _)| string_eq(&first, k))
|
|
119
|
+
.map(|(_, v)| Value::from(*v))
|
|
120
|
+
.unwrap_or(first);
|
|
121
|
+
let bal = string_fields::<D>(balance, &["balance", "hold_trade"])?;
|
|
122
|
+
let total: D = dec(bal.get("balance"))?;
|
|
123
|
+
let hold = bal
|
|
124
|
+
.get("hold_trade")
|
|
125
|
+
.filter(|v| truthy(Some(v)))
|
|
126
|
+
.cloned()
|
|
127
|
+
.unwrap_or(Value::String("0".into()));
|
|
128
|
+
let locked: D = dec(Some(&hold))?;
|
|
129
|
+
let free = total.sub(&locked).map_err(NormError::Num)?;
|
|
130
|
+
if free.is_zero().map_err(NormError::Num)? && locked.is_zero().map_err(NormError::Num)? {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// A Ruby Hash keeps a repeated key's first position; the last value wins.
|
|
134
|
+
match out.iter_mut().find(|b| b.asset == asset) {
|
|
135
|
+
Some(b) => {
|
|
136
|
+
b.free = free;
|
|
137
|
+
b.locked = locked;
|
|
138
|
+
}
|
|
139
|
+
None => out.push(KrakenBalance {
|
|
140
|
+
asset,
|
|
141
|
+
free,
|
|
142
|
+
locked,
|
|
143
|
+
}),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
Ok(Finished::Ok(out))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// deltabadger's Exchanges::Kraken#get_balances for one asset: each code is split at "." and
|
|
150
|
+
/// mapped (ZEUR → EUR, XXBT → XBT); free = balance − hold_trade; a later entry for the same asset
|
|
151
|
+
/// replaces an earlier one (Hash assignment), zeros included; an absent asset is 0. Rails digs
|
|
152
|
+
/// result and the matching entry's hold_trade with dig_or_raise, so nil there is an error, not 0
|
|
153
|
+
/// (false raises too, on `.each` / `.to_d`).
|
|
154
|
+
pub fn free_balance<D: Num>(data: &Value, symbol: &str) -> R<D, D> {
|
|
155
|
+
let obj = envelope_or_return!(data);
|
|
156
|
+
if !truthy(obj.get("result")) {
|
|
157
|
+
return Err("result: missing".to_string().into());
|
|
158
|
+
}
|
|
159
|
+
let mut free = D::parse("0").map_err(NormError::Num)?;
|
|
160
|
+
for (code, balance) in result_entries(obj)? {
|
|
161
|
+
let Some(code) = code.as_str() else {
|
|
162
|
+
return Err(format!("asset code: {code}").into());
|
|
163
|
+
};
|
|
164
|
+
let base = code.split('.').next().unwrap_or(code);
|
|
165
|
+
let name = ASSET_MAP
|
|
166
|
+
.iter()
|
|
167
|
+
.find(|(k, _)| *k == base)
|
|
168
|
+
.map(|(_, v)| *v)
|
|
169
|
+
.unwrap_or(base);
|
|
170
|
+
if name != symbol {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
let b = string_fields::<D>(balance, &["balance", "hold_trade"])?;
|
|
174
|
+
let total: D = dec(b.get("balance"))?;
|
|
175
|
+
let hold = b.get("hold_trade").filter(|v| truthy(Some(v)));
|
|
176
|
+
let Some(hold) = hold else {
|
|
177
|
+
return Err(format!("hold_trade: missing for {code}").into());
|
|
178
|
+
};
|
|
179
|
+
free = total.sub(&dec::<D>(Some(hold))?).map_err(NormError::Num)?;
|
|
180
|
+
}
|
|
181
|
+
Ok(Finished::Ok(free))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
pub struct NormalizedOrder<D> {
|
|
185
|
+
pub order_id: Value,
|
|
186
|
+
pub status: OrderStatus,
|
|
187
|
+
pub side: Option<Value>,
|
|
188
|
+
pub order_type: OrderType,
|
|
189
|
+
pub price: Option<D>,
|
|
190
|
+
pub amount: Option<D>,
|
|
191
|
+
pub quote_amount: Option<D>,
|
|
192
|
+
pub amount_exec: D,
|
|
193
|
+
pub quote_amount_exec: D,
|
|
194
|
+
/// Kraken's client order id and user reference, as sent (absent or null → None).
|
|
195
|
+
pub cl_ord_id: Option<Value>,
|
|
196
|
+
pub userref: Option<Value>,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
pub fn order_type(v: Option<&Value>) -> OrderType {
|
|
200
|
+
match v {
|
|
201
|
+
Some(v) if string_eq(v, "market") => OrderType::Market,
|
|
202
|
+
Some(v) if string_eq(v, "limit") => OrderType::Limit,
|
|
203
|
+
_ => OrderType::Unknown,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fn status(v: Option<&Value>) -> OrderStatus {
|
|
208
|
+
match v {
|
|
209
|
+
Some(v) if string_eq(v, "open") => OrderStatus::Open,
|
|
210
|
+
Some(v) if string_eq(v, "closed") => OrderStatus::Closed,
|
|
211
|
+
Some(v) if string_eq(v, "canceled") || string_eq(v, "expired") => OrderStatus::Cancelled,
|
|
212
|
+
_ => OrderStatus::Unknown,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
pub fn orders<D: Num>(data: &Value) -> R<Vec<NormalizedOrder<D>>, D> {
|
|
217
|
+
let obj = envelope_or_return!(data);
|
|
218
|
+
Ok(Finished::Ok(order_entries::<D>(result_entries(obj)?)?))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fn order_entries<D: Num>(
|
|
222
|
+
entries: Vec<(Value, &Value)>,
|
|
223
|
+
) -> Result<Vec<NormalizedOrder<D>>, NormError<D::Error>> {
|
|
224
|
+
let empty = Map::new();
|
|
225
|
+
let mut out = Vec::new();
|
|
226
|
+
for (order_id, raw) in entries {
|
|
227
|
+
let raw = string_fields::<D>(
|
|
228
|
+
raw,
|
|
229
|
+
&[
|
|
230
|
+
"descr",
|
|
231
|
+
"status",
|
|
232
|
+
"oflags",
|
|
233
|
+
"vol",
|
|
234
|
+
"vol_exec",
|
|
235
|
+
"cost",
|
|
236
|
+
"price",
|
|
237
|
+
"cl_ord_id",
|
|
238
|
+
"userref",
|
|
239
|
+
],
|
|
240
|
+
)?;
|
|
241
|
+
let descr = match raw.get("descr") {
|
|
242
|
+
v if !truthy(v) => Cow::Borrowed(&empty),
|
|
243
|
+
Some(value) => string_fields::<D>(value, &["ordertype", "type", "price"])?,
|
|
244
|
+
None => Cow::Borrowed(&empty),
|
|
245
|
+
};
|
|
246
|
+
let order_type = order_type(descr.get("ordertype"));
|
|
247
|
+
let side = match descr.get("type") {
|
|
248
|
+
None | Some(Value::Null) => None,
|
|
249
|
+
Some(v) if is_string(v) => {
|
|
250
|
+
Some(D::string_op(v, StringOp::DowncaseSymbol).map_err(NormError::Num)?)
|
|
251
|
+
}
|
|
252
|
+
Some(other) => return Err(format!("descr.type: {other}").into()),
|
|
253
|
+
};
|
|
254
|
+
let viqc = match raw.get("oflags") {
|
|
255
|
+
v if !truthy(v) => false,
|
|
256
|
+
Some(v) if is_string(v) => D::string_op(v, StringOp::Split(","))
|
|
257
|
+
.map_err(NormError::Num)?
|
|
258
|
+
.as_array()
|
|
259
|
+
.unwrap()
|
|
260
|
+
.iter()
|
|
261
|
+
.any(|f| string_eq(f, "viqc")),
|
|
262
|
+
Some(other) => return Err(format!("oflags: {other}").into()),
|
|
263
|
+
None => false,
|
|
264
|
+
};
|
|
265
|
+
let vol: D = dec(raw.get("vol"))?;
|
|
266
|
+
let (amount, quote_amount) = if viqc {
|
|
267
|
+
(None, Some(vol))
|
|
268
|
+
} else {
|
|
269
|
+
(Some(vol), None)
|
|
270
|
+
};
|
|
271
|
+
let amount_exec: D = dec(raw.get("vol_exec"))?;
|
|
272
|
+
let quote_amount_exec: D = dec(raw.get("cost"))?;
|
|
273
|
+
let mut price: D = dec(raw.get("price"))?;
|
|
274
|
+
if price.is_zero().map_err(NormError::Num)? && order_type == OrderType::Limit {
|
|
275
|
+
price = dec(descr.get("price"))?;
|
|
276
|
+
}
|
|
277
|
+
let price = if price.is_zero().map_err(NormError::Num)? {
|
|
278
|
+
None
|
|
279
|
+
} else {
|
|
280
|
+
Some(price)
|
|
281
|
+
};
|
|
282
|
+
out.push(NormalizedOrder {
|
|
283
|
+
order_id,
|
|
284
|
+
status: status(raw.get("status")),
|
|
285
|
+
side,
|
|
286
|
+
order_type,
|
|
287
|
+
price,
|
|
288
|
+
amount,
|
|
289
|
+
quote_amount,
|
|
290
|
+
amount_exec,
|
|
291
|
+
quote_amount_exec,
|
|
292
|
+
cl_ord_id: raw.get("cl_ord_id").filter(|v| !v.is_null()).cloned(),
|
|
293
|
+
userref: raw.get("userref").filter(|v| !v.is_null()).cloned(),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
Ok(out)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// OpenOrders (`result.open`) and ClosedOrders (`result.closed` + `result.count`): QueryOrders'
|
|
300
|
+
/// order shape under one more key. Both containers must be objects: a missing or null one is an
|
|
301
|
+
/// unreadable answer, never "no orders" (a lookup would otherwise conclude absence from it).
|
|
302
|
+
/// The count is returned raw.
|
|
303
|
+
pub fn listed_orders<D: Num>(
|
|
304
|
+
data: &Value,
|
|
305
|
+
key: &str,
|
|
306
|
+
) -> R<(Vec<NormalizedOrder<D>>, Option<Value>), D> {
|
|
307
|
+
let obj = envelope_or_return!(data);
|
|
308
|
+
let result = match obj.get("result") {
|
|
309
|
+
Some(Value::Object(m)) => m,
|
|
310
|
+
other => return Err(format!("result is not an object: {other:?}").into()),
|
|
311
|
+
};
|
|
312
|
+
let entries: Vec<(Value, &Value)> = match result.get(key) {
|
|
313
|
+
Some(Value::Object(m)) => m.iter().map(|(k, v)| (object_key(k), v)).collect(),
|
|
314
|
+
other => return Err(format!("{key} is not an object: {other:?}").into()),
|
|
315
|
+
};
|
|
316
|
+
Ok(Finished::Ok((
|
|
317
|
+
order_entries::<D>(entries)?,
|
|
318
|
+
result.get("count").cloned(),
|
|
319
|
+
)))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
pub struct TickerPrices<D> {
|
|
323
|
+
pub bid: D,
|
|
324
|
+
pub ask: D,
|
|
325
|
+
pub last: D,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// deltabadger's Exchanges::Kraken#get_ticker_information: the first result entry's `b[0]`,
|
|
329
|
+
/// `a[0]` and `c[0]` (last trade closed). None when the result names no pair.
|
|
330
|
+
pub fn ticker_prices<D: Num>(data: &Value) -> R<Option<TickerPrices<D>>, D> {
|
|
331
|
+
let obj = envelope_or_return!(data);
|
|
332
|
+
let Some((_, info)) = result_entries(obj)?.into_iter().next() else {
|
|
333
|
+
return Ok(Finished::Ok(None));
|
|
334
|
+
};
|
|
335
|
+
let info = info
|
|
336
|
+
.as_object()
|
|
337
|
+
.ok_or_else(|| format!("ticker entry is not an object: {info}"))?;
|
|
338
|
+
let first = |k: &str| -> Result<D, NormError<D::Error>> {
|
|
339
|
+
match info.get(k) {
|
|
340
|
+
Some(Value::Array(a)) => dec(a.first()),
|
|
341
|
+
other => Err(format!("{k}[0] missing: {other:?}").into()),
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
Ok(Finished::Ok(Some(TickerPrices {
|
|
345
|
+
bid: first("b")?,
|
|
346
|
+
ask: first("a")?,
|
|
347
|
+
last: first("c")?,
|
|
348
|
+
})))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/// `(result.data.dig("result", "txid") || []).first` as the raw JSON value.
|
|
352
|
+
pub fn add_order<D: Num>(data: &Value) -> R<Value, D> {
|
|
353
|
+
let obj = envelope_or_return!(data);
|
|
354
|
+
let txid = match obj.get("result") {
|
|
355
|
+
None | Some(Value::Null) => None,
|
|
356
|
+
Some(v) if is_string(v) => return Err(format!("result: {v}").into()),
|
|
357
|
+
Some(Value::Object(m)) => m.get("txid").filter(|v| truthy(Some(v))).cloned(),
|
|
358
|
+
Some(other) => return Err(format!("result: {other}").into()),
|
|
359
|
+
};
|
|
360
|
+
Ok(Finished::Ok(match txid {
|
|
361
|
+
None => Value::Null,
|
|
362
|
+
Some(Value::Array(a)) => a.first().cloned().unwrap_or(Value::Null),
|
|
363
|
+
Some(v) if is_string(&v) => return Err(format!("txid: {v}").into()),
|
|
364
|
+
Some(Value::Object(m)) => m
|
|
365
|
+
.into_iter()
|
|
366
|
+
.next()
|
|
367
|
+
.map(|(k, v)| Value::Array(vec![object_key(&k), v]))
|
|
368
|
+
.unwrap_or(Value::Null),
|
|
369
|
+
Some(other) => return Err(format!("txid: {other}").into()),
|
|
370
|
+
}))
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
pub struct Ticker {
|
|
374
|
+
pub ticker: Value,
|
|
375
|
+
pub base: Value,
|
|
376
|
+
pub quote: Value,
|
|
377
|
+
pub minimum_base_size: Value,
|
|
378
|
+
/// REAL_COSTMIN[quote] || costmin || 0 as a raw value; the wrapper applies Ruby's `.to_s`
|
|
379
|
+
/// (Float#to_s gives "5.0e-05" for XBT, which is part of the contract).
|
|
380
|
+
pub minimum_quote_size: Value,
|
|
381
|
+
pub base_decimals: Value,
|
|
382
|
+
pub quote_decimals: Value,
|
|
383
|
+
pub price_decimals: Value,
|
|
384
|
+
pub trading_enabled: bool,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const REAL_COSTMIN: &[(&str, &str)] = &[
|
|
388
|
+
// JSON literals of the Ruby constants (Integer vs Float kept)
|
|
389
|
+
("AUD", "10"),
|
|
390
|
+
("CAD", "5"),
|
|
391
|
+
("CHF", "5"),
|
|
392
|
+
("DAI", "5"),
|
|
393
|
+
("ETH", "0.002"),
|
|
394
|
+
("EUR", "0.5"),
|
|
395
|
+
("GBP", "5"),
|
|
396
|
+
("JPY", "500"),
|
|
397
|
+
("PYUSD", "5"),
|
|
398
|
+
("RLUSD", "5"),
|
|
399
|
+
("USD", "5"),
|
|
400
|
+
("USDC", "5"),
|
|
401
|
+
("USDQ", "5"),
|
|
402
|
+
("USDR", "5"),
|
|
403
|
+
("USDT", "5"),
|
|
404
|
+
("XBT", "0.00005"),
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
pub fn tickers<D: Num>(data: &Value) -> R<Vec<Ticker>, D> {
|
|
408
|
+
let obj = data.as_object().ok_or("body is not an object")?;
|
|
409
|
+
if let Some(Value::Array(e)) = obj.get("error")
|
|
410
|
+
&& e.iter().any(|x| truthy(Some(x)))
|
|
411
|
+
{
|
|
412
|
+
return Ok(Finished::Venue(e.clone()));
|
|
413
|
+
}
|
|
414
|
+
// Unlike the client, the exchange calls each_with_object without a fallback.
|
|
415
|
+
if !matches!(obj.get("result"), Some(Value::Object(_) | Value::Array(_))) {
|
|
416
|
+
return Err("result cannot be enumerated".into());
|
|
417
|
+
}
|
|
418
|
+
let mut seen: Vec<(Value, Cow<'_, Map<String, Value>>)> = Vec::new();
|
|
419
|
+
for (_, raw) in result_entries(obj)? {
|
|
420
|
+
let info = string_fields::<D>(raw, &["wsname"])?;
|
|
421
|
+
// Legacy: `next if wsname.nil? || wsname.empty?`. Anything else without #empty?/#split
|
|
422
|
+
// (false, 1) raises there and fails the WHOLE catalogue; never silently drop the pair.
|
|
423
|
+
let ws = match info.get("wsname") {
|
|
424
|
+
None | Some(Value::Null) => continue,
|
|
425
|
+
Some(w) if is_string(w) && string_eq(w, "") => continue,
|
|
426
|
+
Some(v) if is_string(v) => v,
|
|
427
|
+
Some(Value::Array(a)) if a.is_empty() => continue, // [].empty? is true in Ruby too
|
|
428
|
+
Some(Value::Object(m)) if m.is_empty() => continue,
|
|
429
|
+
Some(other) => return Err(format!("wsname: {other}").into()),
|
|
430
|
+
};
|
|
431
|
+
if !seen.iter().any(|(w, _)| w == ws) {
|
|
432
|
+
// A String containing "wsname" passes indexing but later lacks Hash#key?.
|
|
433
|
+
if !raw.is_object() || is_string(raw) {
|
|
434
|
+
return Err("pair info has no key? method".into());
|
|
435
|
+
}
|
|
436
|
+
seen.push((ws.clone(), info));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
let get = |info: &Map<String, Value>, k: &str| info.get(k).cloned().unwrap_or(Value::Null);
|
|
440
|
+
Ok(Finished::Ok(
|
|
441
|
+
seen.into_iter()
|
|
442
|
+
.map(|(ws, info)| {
|
|
443
|
+
let parts = D::string_op(&ws, StringOp::Split("/")).map_err(NormError::Num)?;
|
|
444
|
+
let base = parts[0].clone();
|
|
445
|
+
let quote = parts[1].clone();
|
|
446
|
+
let costmin = REAL_COSTMIN
|
|
447
|
+
.iter()
|
|
448
|
+
.find(|(k, _)| string_eq("e, k))
|
|
449
|
+
.map(|(_, lit)| serde_json::from_str::<Value>(lit).expect("literal"))
|
|
450
|
+
.or_else(|| info.get("costmin").filter(|v| truthy(Some(v))).cloned())
|
|
451
|
+
.unwrap_or(Value::from(0));
|
|
452
|
+
let trading_enabled = if info
|
|
453
|
+
.get("aclass_base")
|
|
454
|
+
.is_some_and(|v| string_eq(v, "tokenized_asset"))
|
|
455
|
+
{
|
|
456
|
+
false
|
|
457
|
+
} else {
|
|
458
|
+
info.get("status")
|
|
459
|
+
.map(|s| string_eq(s, "online"))
|
|
460
|
+
.unwrap_or(true)
|
|
461
|
+
};
|
|
462
|
+
Ok(Ticker {
|
|
463
|
+
ticker: get(&info, "altname"),
|
|
464
|
+
base,
|
|
465
|
+
quote,
|
|
466
|
+
minimum_base_size: get(&info, "ordermin"),
|
|
467
|
+
minimum_quote_size: costmin,
|
|
468
|
+
base_decimals: get(&info, "lot_decimals"),
|
|
469
|
+
quote_decimals: get(&info, "cost_decimals"),
|
|
470
|
+
price_decimals: get(&info, "pair_decimals"),
|
|
471
|
+
trading_enabled,
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
.collect::<Result<Vec<_>, NormError<D::Error>>>()?,
|
|
475
|
+
))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/// Legacy: raise error.first if errors; first result entry; BigDecimal(data["b"][0]) (no to_s).
|
|
479
|
+
pub fn bid_ask<D: Num>(data: &Value) -> R<(D, D), D> {
|
|
480
|
+
let obj = data.as_object().ok_or("body is not an object")?;
|
|
481
|
+
if let Some(Value::Array(e)) = obj.get("error")
|
|
482
|
+
&& e.iter().any(|x| truthy(Some(x)))
|
|
483
|
+
{
|
|
484
|
+
return Ok(Finished::Venue(e.clone()));
|
|
485
|
+
}
|
|
486
|
+
let first = match obj.get("result") {
|
|
487
|
+
Some(Value::Object(m)) => m.values().next(),
|
|
488
|
+
Some(Value::Array(a)) => match a.first() {
|
|
489
|
+
Some(Value::Array(pair)) => pair.get(1),
|
|
490
|
+
_ => None,
|
|
491
|
+
},
|
|
492
|
+
_ => None,
|
|
493
|
+
}
|
|
494
|
+
.ok_or("no ticker in result")?;
|
|
495
|
+
let first = string_fields::<D>(first, &["b", "a"])?;
|
|
496
|
+
let side = |k: &str| -> Result<D, NormError<D::Error>> {
|
|
497
|
+
// Ruby [0]: array entry, first character, integer bit, or absent Hash key.
|
|
498
|
+
let value = match first.get(k) {
|
|
499
|
+
Some(Value::Array(a)) => a.first().cloned().unwrap_or(Value::Null),
|
|
500
|
+
Some(v) if is_string(v) => D::string_op(v, StringOp::First).map_err(NormError::Num)?,
|
|
501
|
+
Some(Value::Object(_)) => Value::Null,
|
|
502
|
+
Some(Value::Number(n)) => {
|
|
503
|
+
let text = n.to_string();
|
|
504
|
+
if text.contains(['.', 'e', 'E']) {
|
|
505
|
+
return Err(format!("{k} has no [] method").into());
|
|
506
|
+
}
|
|
507
|
+
Value::from((text.as_bytes().last().unwrap() - b'0') % 2)
|
|
508
|
+
}
|
|
509
|
+
_ => return Err(format!("{k}[0] missing").into()),
|
|
510
|
+
};
|
|
511
|
+
D::parse_json(&value).map_err(NormError::Num)
|
|
512
|
+
};
|
|
513
|
+
Ok(Finished::Ok((side("b")?, side("a")?)))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/// Exchanges::Kraken::ERROR_PATTERNS, first match wins. Ruby's \w and \S are ASCII here.
|
|
517
|
+
pub type ClassifiedError = (&'static str, Vec<(&'static str, Vec<u8>)>);
|
|
518
|
+
pub fn classify_error(message: &[u8]) -> Option<ClassifiedError> {
|
|
519
|
+
static RES: std::sync::OnceLock<Vec<(&'static str, regex::bytes::Regex)>> =
|
|
520
|
+
std::sync::OnceLock::new();
|
|
521
|
+
let res = RES.get_or_init(|| vec![
|
|
522
|
+
("regional_restriction", regex::bytes::Regex::new(r"(?-u)\AEAccount:Invalid permissions:(?P<asset>[^ \t\r\n\x0B\x0C]+) trading restricted for (?P<country>[A-Za-z0-9_]+)\.?\z").unwrap()),
|
|
523
|
+
("transient_nonce", regex::bytes::Regex::new(r"EAPI:Invalid nonce").unwrap()),
|
|
524
|
+
("transient_unavailable", regex::bytes::Regex::new(r"EGeneral:Internal error|EService:(?:Unavailable|Busy|Deadline elapsed)").unwrap()),
|
|
525
|
+
]);
|
|
526
|
+
for (code, re) in res {
|
|
527
|
+
if let Some(c) = re.captures(message) {
|
|
528
|
+
let caps = ["asset", "country"]
|
|
529
|
+
.iter()
|
|
530
|
+
.filter_map(|n| c.name(n).map(|m| (*n, m.as_bytes().to_vec())))
|
|
531
|
+
.collect();
|
|
532
|
+
return Some((code, caps));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
None
|
|
536
|
+
}
|