honeymaker 0.12.5 → 0.12.6
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/Cargo.lock +3 -3
- data/Cargo.toml +1 -1
- data/crates/honeymaker/src/kraken/normalize.rs +29 -7
- data/crates/honeymaker/src/num.rs +5 -0
- data/crates/honeymaker/tests/kraken_stage2.rs +27 -0
- data/ext/honeymaker_native/src/ruby_decimal.rs +4 -0
- data/lib/honeymaker/exchanges/bingx.rb +3 -1
- data/lib/honeymaker/exchanges/bitget.rb +31 -2
- data/lib/honeymaker/exchanges/hyperliquid.rb +1 -6
- data/lib/honeymaker/exchanges/kraken.rb +1 -20
- data/lib/honeymaker/floors.rb +59 -0
- data/lib/honeymaker/version.rb +1 -1
- data/lib/honeymaker.rb +1 -0
- data/test/honeymaker/exchanges/bingx_test.rb +1 -1
- data/test/honeymaker/exchanges/bitget_test.rb +52 -0
- data/test/honeymaker/exchanges/kraken_test.rb +12 -0
- data/test/honeymaker/floors_test.rb +37 -0
- data/test/parity/kraken_exchange_parity_test.rb +26 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b0990bd1855430e5d4b8bdda6132202c74b6e2151c64c9c4aab5edeaae0c8d0
|
|
4
|
+
data.tar.gz: 5ce771e18c6e07370686d20a55c39e687fdd0340a628538de68fa524aea40f93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ae0c506ddab62f846b54818da8c209e52c8c235a789242f946c693efed107290cc6d7aed28548a0788b2ebaf65f5e9b29cdf3ee312fbea1495a4a5504f51938
|
|
7
|
+
data.tar.gz: 6f1321e294b8dda3574e3199e290986a5ab1a48ba5924aeaa5808914b18678091b5543655071a9a585e91346e6075fe456261621836ca156b601763d533e6dea
|
data/Cargo.lock
CHANGED
|
@@ -265,7 +265,7 @@ dependencies = [
|
|
|
265
265
|
|
|
266
266
|
[[package]]
|
|
267
267
|
name = "honeymaker"
|
|
268
|
-
version = "0.12.
|
|
268
|
+
version = "0.12.6"
|
|
269
269
|
dependencies = [
|
|
270
270
|
"base64",
|
|
271
271
|
"bigdecimal",
|
|
@@ -278,7 +278,7 @@ dependencies = [
|
|
|
278
278
|
|
|
279
279
|
[[package]]
|
|
280
280
|
name = "honeymaker-client"
|
|
281
|
-
version = "0.12.
|
|
281
|
+
version = "0.12.6"
|
|
282
282
|
dependencies = [
|
|
283
283
|
"base64",
|
|
284
284
|
"bigdecimal",
|
|
@@ -300,7 +300,7 @@ dependencies = [
|
|
|
300
300
|
|
|
301
301
|
[[package]]
|
|
302
302
|
name = "honeymaker_native"
|
|
303
|
-
version = "0.12.
|
|
303
|
+
version = "0.12.6"
|
|
304
304
|
dependencies = [
|
|
305
305
|
"honeymaker",
|
|
306
306
|
"magnus",
|
data/Cargo.toml
CHANGED
|
@@ -3,7 +3,7 @@ members = ["crates/honeymaker", "crates/honeymaker-client", "ext/honeymaker_nati
|
|
|
3
3
|
resolver = "3"
|
|
4
4
|
|
|
5
5
|
[workspace.package]
|
|
6
|
-
version = "0.12.
|
|
6
|
+
version = "0.12.6" # lockstep with lib/honeymaker/version.rb (test/native/lockstep_test.rb)
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.94"
|
|
9
9
|
license = "MIT"
|
|
@@ -375,8 +375,8 @@ pub struct Ticker {
|
|
|
375
375
|
pub base: Value,
|
|
376
376
|
pub quote: Value,
|
|
377
377
|
pub minimum_base_size: Value,
|
|
378
|
-
///
|
|
379
|
-
/// (Float#to_s gives "5.0e-05" for XBT,
|
|
378
|
+
/// max(costmin || 0, REAL_COSTMIN[quote]) as a raw value (whichever wins, as given); the
|
|
379
|
+
/// wrapper applies Ruby's `.to_s` (Float#to_s gives "5.0e-05" for XBT, part of the contract).
|
|
380
380
|
pub minimum_quote_size: Value,
|
|
381
381
|
pub base_decimals: Value,
|
|
382
382
|
pub quote_decimals: Value,
|
|
@@ -384,6 +384,8 @@ pub struct Ticker {
|
|
|
384
384
|
pub trading_enabled: bool,
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
/// Mirror of Honeymaker::Floors::MINIMUM_QUOTE["kraken"] (lib/honeymaker/floors.rb, where each
|
|
388
|
+
/// floor's source is noted); test/parity/kraken_exchange_parity_test.rb checks every quote.
|
|
387
389
|
const REAL_COSTMIN: &[(&str, &str)] = &[
|
|
388
390
|
// JSON literals of the Ruby constants (Integer vs Float kept)
|
|
389
391
|
("AUD", "10"),
|
|
@@ -443,12 +445,18 @@ pub fn tickers<D: Num>(data: &Value) -> R<Vec<Ticker>, D> {
|
|
|
443
445
|
let parts = D::string_op(&ws, StringOp::Split("/")).map_err(NormError::Num)?;
|
|
444
446
|
let base = parts[0].clone();
|
|
445
447
|
let quote = parts[1].clone();
|
|
446
|
-
let
|
|
447
|
-
.
|
|
448
|
-
.
|
|
449
|
-
.
|
|
450
|
-
.or_else(|| info.get("costmin").filter(|v| truthy(Some(v))).cloned())
|
|
448
|
+
let published = info
|
|
449
|
+
.get("costmin")
|
|
450
|
+
.filter(|v| truthy(Some(v)))
|
|
451
|
+
.cloned()
|
|
451
452
|
.unwrap_or(Value::from(0));
|
|
453
|
+
let costmin = match REAL_COSTMIN.iter().find(|(k, _)| string_eq("e, k)) {
|
|
454
|
+
None => published,
|
|
455
|
+
Some((_, lit)) => at_least::<D>(
|
|
456
|
+
published,
|
|
457
|
+
serde_json::from_str::<Value>(lit).expect("literal"),
|
|
458
|
+
)?,
|
|
459
|
+
};
|
|
452
460
|
let trading_enabled = if info
|
|
453
461
|
.get("aclass_base")
|
|
454
462
|
.is_some_and(|v| string_eq(v, "tokenized_asset"))
|
|
@@ -475,6 +483,20 @@ pub fn tickers<D: Num>(data: &Value) -> R<Vec<Ticker>, D> {
|
|
|
475
483
|
))
|
|
476
484
|
}
|
|
477
485
|
|
|
486
|
+
/// Legacy Floors.minimum_quote: `BigDecimal(published.to_s) > BigDecimal(floor.to_s) ? published :
|
|
487
|
+
/// floor`, the floor when published does not parse (`rescue ArgumentError`).
|
|
488
|
+
fn at_least<D: Num>(published: Value, floor: Value) -> Result<Value, NormError<D::Error>> {
|
|
489
|
+
let Ok(p) = D::parse_to_s(&published) else {
|
|
490
|
+
return Ok(floor);
|
|
491
|
+
};
|
|
492
|
+
let f = D::parse_to_s(&floor).map_err(NormError::Num)?;
|
|
493
|
+
Ok(if p.gt(&f).map_err(NormError::Num)? {
|
|
494
|
+
published
|
|
495
|
+
} else {
|
|
496
|
+
floor
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
478
500
|
/// Legacy: raise error.first if errors; first result entry; BigDecimal(data["b"][0]) (no to_s).
|
|
479
501
|
pub fn bid_ask<D: Num>(data: &Value) -> R<(D, D), D> {
|
|
480
502
|
let obj = data.as_object().ok_or("body is not an object")?;
|
|
@@ -23,6 +23,8 @@ pub trait Num: Sized {
|
|
|
23
23
|
fn sub(&self, other: &Self) -> Result<Self, Self::Error>;
|
|
24
24
|
fn div(&self, other: &Self) -> Result<Self, Self::Error>;
|
|
25
25
|
fn is_zero(&self) -> Result<bool, Self::Error>;
|
|
26
|
+
/// Legacy `self > other`.
|
|
27
|
+
fn gt(&self, other: &Self) -> Result<bool, Self::Error>;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
#[derive(Debug)]
|
|
@@ -112,4 +114,7 @@ impl Num for BigDecimal {
|
|
|
112
114
|
fn is_zero(&self) -> Result<bool, String> {
|
|
113
115
|
Ok(self == &BigDecimal::from(0))
|
|
114
116
|
}
|
|
117
|
+
fn gt(&self, o: &Self) -> Result<bool, String> {
|
|
118
|
+
Ok(self > o)
|
|
119
|
+
}
|
|
115
120
|
}
|
|
@@ -590,3 +590,30 @@ fn credentials_debug_shows_the_key_and_redacts_the_secret() {
|
|
|
590
590
|
assert!(shown.contains("<redacted>"), "{shown}");
|
|
591
591
|
assert!(!shown.contains("c2VjcmV0LXNlY3JldA=="), "{shown}");
|
|
592
592
|
}
|
|
593
|
+
|
|
594
|
+
#[test]
|
|
595
|
+
fn the_cost_floor_is_a_max_never_an_override() {
|
|
596
|
+
let pair =
|
|
597
|
+
|ws: &str, costmin: Value| json!({ "altname": ws, "wsname": ws, "costmin": costmin });
|
|
598
|
+
let body = json!({ "error": [], "result": {
|
|
599
|
+
"A": pair("XBT/USD", json!("0.5")), // below the 5 USD floor
|
|
600
|
+
"B": pair("ETH/USD", json!("12.5")), // above it: the venue's own figure wins
|
|
601
|
+
"C": pair("SOL/USD", json!("abc")), // unreadable: the floor
|
|
602
|
+
"D": pair("SOL/XBT", json!(null)), // missing: the floor
|
|
603
|
+
"E": pair("SOL/FOO", json!("0.1")), // no floor for the quote: published as is
|
|
604
|
+
}});
|
|
605
|
+
let Finished::Ok(ts) = normalize::tickers::<BigDecimal>(&body).unwrap() else {
|
|
606
|
+
panic!("venue refusal")
|
|
607
|
+
};
|
|
608
|
+
let got: Vec<Value> = ts.into_iter().map(|t| t.minimum_quote_size).collect();
|
|
609
|
+
assert_eq!(
|
|
610
|
+
got,
|
|
611
|
+
vec![
|
|
612
|
+
json!(5),
|
|
613
|
+
json!("12.5"),
|
|
614
|
+
json!(5),
|
|
615
|
+
json!(0.00005),
|
|
616
|
+
json!("0.1")
|
|
617
|
+
]
|
|
618
|
+
);
|
|
619
|
+
}
|
|
@@ -18,7 +18,9 @@ module Honeymaker
|
|
|
18
18
|
ticker: ticker,
|
|
19
19
|
base: parts[0],
|
|
20
20
|
quote: parts[1],
|
|
21
|
-
|
|
21
|
+
# minQty is deprecated and far above what BingX accepts (~15 USDT on BTC-USDT against a
|
|
22
|
+
# 0.5 minNotional); the notional is the venue's only real floor.
|
|
23
|
+
minimum_base_size: "0",
|
|
22
24
|
minimum_quote_size: product["minNotional"]&.to_s,
|
|
23
25
|
maximum_base_size: product["maxQty"]&.to_s,
|
|
24
26
|
maximum_quote_size: product["maxNotional"]&.to_s,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal/util"
|
|
4
|
+
|
|
3
5
|
module Honeymaker
|
|
4
6
|
module Exchanges
|
|
5
7
|
class Bitget < Exchange
|
|
@@ -8,14 +10,16 @@ module Honeymaker
|
|
|
8
10
|
def get_tickers_info
|
|
9
11
|
with_rescue do
|
|
10
12
|
response = connection.get("/api/v2/spot/public/symbols")
|
|
13
|
+
products = response.body["data"]
|
|
14
|
+
prices = products.any? { |p| p["quoteCoin"] != "USDT" } ? usdt_prices : {}
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
products.filter_map do |product|
|
|
13
17
|
{
|
|
14
18
|
ticker: product["symbol"],
|
|
15
19
|
base: product["baseCoin"],
|
|
16
20
|
quote: product["quoteCoin"],
|
|
17
21
|
minimum_base_size: product["minTradeAmount"],
|
|
18
|
-
minimum_quote_size: product["minTradeUSDT"],
|
|
22
|
+
minimum_quote_size: quote_minimum(product["quoteCoin"], product["minTradeUSDT"], prices),
|
|
19
23
|
maximum_base_size: product["maxTradeAmount"],
|
|
20
24
|
maximum_quote_size: nil,
|
|
21
25
|
base_decimals: product["quantityPrecision"].to_i,
|
|
@@ -44,6 +48,31 @@ module Honeymaker
|
|
|
44
48
|
|
|
45
49
|
private
|
|
46
50
|
|
|
51
|
+
# minTradeUSDT is a USDT figure on every pair, whatever its quote
|
|
52
|
+
# (https://www.bitget.com/docs/catalog/classic-spot-market/classic-spot-market#get-symbol-info), so
|
|
53
|
+
# on ETHBTC its 1 meant a 1 BTC minimum. Converted at the quote's own USDT price (never the
|
|
54
|
+
# traded pair's); with no price to convert at, the minimum is left to the venue (0) rather than
|
|
55
|
+
# guessed. Same rule as deltabadger's Exchanges::Bitget#quote_minimum.
|
|
56
|
+
def quote_minimum(quote, usdt_minimum, prices)
|
|
57
|
+
minimum = usdt_minimum.to_s.to_d
|
|
58
|
+
return usdt_minimum if quote == "USDT" || minimum.zero?
|
|
59
|
+
|
|
60
|
+
if (price = prices["#{quote}USDT"].to_d).positive?
|
|
61
|
+
(minimum / price).to_s("F")
|
|
62
|
+
elsif (price = prices["USDT#{quote}"].to_d).positive?
|
|
63
|
+
(minimum * price).to_s("F")
|
|
64
|
+
else
|
|
65
|
+
"0"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# symbol => lastPr for the whole market; {} when the read fails, so conversions fall back to 0.
|
|
70
|
+
def usdt_prices
|
|
71
|
+
connection.get("/api/v2/spot/market/tickers").body["data"].to_h { |t| [t["symbol"], t["lastPr"]] }
|
|
72
|
+
rescue StandardError
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
|
|
47
76
|
def connection
|
|
48
77
|
@connection ||= build_connection(BASE_URL)
|
|
49
78
|
end
|
|
@@ -5,11 +5,6 @@ module Honeymaker
|
|
|
5
5
|
class Hyperliquid < Exchange
|
|
6
6
|
BASE_URL = "https://api.hyperliquid.xyz"
|
|
7
7
|
|
|
8
|
-
# Hyperliquid enforces a hard 10-USDC minimum order value on every spot pair (spotMeta exposes
|
|
9
|
-
# no per-token base floor). Surfaced as minimum_quote_size so consumers skip/reject sub-10
|
|
10
|
-
# orders up front instead of having the exchange reject them.
|
|
11
|
-
MINIMUM_QUOTE_SIZE = 10
|
|
12
|
-
|
|
13
8
|
def get_tickers_info
|
|
14
9
|
with_rescue do
|
|
15
10
|
response = connection.post("/info") do |req|
|
|
@@ -30,7 +25,7 @@ module Honeymaker
|
|
|
30
25
|
base: base_token["name"],
|
|
31
26
|
quote: quote_token["name"],
|
|
32
27
|
minimum_base_size: nil,
|
|
33
|
-
minimum_quote_size:
|
|
28
|
+
minimum_quote_size: Floors.minimum_quote(venue: "hyperliquid", quote: quote_token["name"]),
|
|
34
29
|
maximum_base_size: nil,
|
|
35
30
|
maximum_quote_size: nil,
|
|
36
31
|
base_decimals: base_token["szDecimals"] || 0,
|
|
@@ -27,25 +27,6 @@ module Honeymaker
|
|
|
27
27
|
}
|
|
28
28
|
].freeze
|
|
29
29
|
|
|
30
|
-
REAL_COSTMIN = {
|
|
31
|
-
"AUD" => 10,
|
|
32
|
-
"CAD" => 5,
|
|
33
|
-
"CHF" => 5,
|
|
34
|
-
"DAI" => 5,
|
|
35
|
-
"ETH" => 0.002,
|
|
36
|
-
"EUR" => 0.5,
|
|
37
|
-
"GBP" => 5,
|
|
38
|
-
"JPY" => 500,
|
|
39
|
-
"PYUSD" => 5,
|
|
40
|
-
"RLUSD" => 5,
|
|
41
|
-
"USD" => 5,
|
|
42
|
-
"USDC" => 5,
|
|
43
|
-
"USDQ" => 5,
|
|
44
|
-
"USDR" => 5,
|
|
45
|
-
"USDT" => 5,
|
|
46
|
-
"XBT" => 0.00005
|
|
47
|
-
}.freeze
|
|
48
|
-
|
|
49
30
|
def get_tickers_info
|
|
50
31
|
with_rescue do
|
|
51
32
|
# aclass_base=all, not a bare call: the default response carries only the "currency" class,
|
|
@@ -74,7 +55,7 @@ module Honeymaker
|
|
|
74
55
|
base: base,
|
|
75
56
|
quote: quote,
|
|
76
57
|
minimum_base_size: info["ordermin"],
|
|
77
|
-
minimum_quote_size: (
|
|
58
|
+
minimum_quote_size: Floors.minimum_quote(venue: "kraken", quote: quote, published: info["costmin"] || 0).to_s,
|
|
78
59
|
maximum_base_size: nil,
|
|
79
60
|
maximum_quote_size: nil,
|
|
80
61
|
base_decimals: info["lot_decimals"],
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Honeymaker
|
|
6
|
+
# Minimum order values venues enforce but do not publish (or publish too low), found by test
|
|
7
|
+
# orders. A ticker's minimum_quote_size is max(published, floor): a floor never lowers a minimum
|
|
8
|
+
# the venue itself raised. Consumed by the venue parsers here and by data-api for venues it
|
|
9
|
+
# builds itself (Alpaca crypto).
|
|
10
|
+
module Floors
|
|
11
|
+
ANY_QUOTE = :any
|
|
12
|
+
|
|
13
|
+
# [venue, asset_class] => { quote => floor }. A nil asset class covers every class the venue
|
|
14
|
+
# lists; ANY_QUOTE covers every quote.
|
|
15
|
+
MINIMUM_QUOTE = {
|
|
16
|
+
# Kraken's AssetPairs costmin is below what AddOrder accepts ("EOrder:Cost minimum not met");
|
|
17
|
+
# these are the per-quote costs it actually enforces. The native core mirrors this table
|
|
18
|
+
# (crates/honeymaker/src/kraken/normalize.rs REAL_COSTMIN; parity-tested).
|
|
19
|
+
["kraken", nil] => {
|
|
20
|
+
"AUD" => 10,
|
|
21
|
+
"CAD" => 5,
|
|
22
|
+
"CHF" => 5,
|
|
23
|
+
"DAI" => 5,
|
|
24
|
+
"ETH" => 0.002,
|
|
25
|
+
"EUR" => 0.5,
|
|
26
|
+
"GBP" => 5,
|
|
27
|
+
"JPY" => 500,
|
|
28
|
+
"PYUSD" => 5,
|
|
29
|
+
"RLUSD" => 5,
|
|
30
|
+
"USD" => 5,
|
|
31
|
+
"USDC" => 5,
|
|
32
|
+
"USDQ" => 5,
|
|
33
|
+
"USDR" => 5,
|
|
34
|
+
"USDT" => 5,
|
|
35
|
+
"XBT" => 0.00005
|
|
36
|
+
},
|
|
37
|
+
# "Order must have minimum value of 10 USDC." on every spot pair; spotMeta publishes no
|
|
38
|
+
# minimum. The value is measured in USDC whatever the pair's quote, so it covers all quotes.
|
|
39
|
+
["hyperliquid", nil] => { ANY_QUOTE => 10 },
|
|
40
|
+
# "cost basis must be >= minimal amount of order 10" on crypto; the assets API publishes only
|
|
41
|
+
# min_order_size (base). Stocks are not floored here (notional orders from $1).
|
|
42
|
+
["alpaca", "crypto"] => { "USD" => 10 }
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# The minimum quote size to store: max(published, floor). Returns published when no floor
|
|
46
|
+
# applies (nil when neither exists), and the floor when published is missing or not a number.
|
|
47
|
+
# Whichever wins is returned as given, so callers keep their own formatting.
|
|
48
|
+
def self.minimum_quote(venue:, quote:, asset_class: nil, published: nil)
|
|
49
|
+
quotes = MINIMUM_QUOTE[[venue.to_s, asset_class&.to_s]] || MINIMUM_QUOTE[[venue.to_s, nil]] || {}
|
|
50
|
+
floor = quotes.fetch(quote.to_s) { quotes[ANY_QUOTE] }
|
|
51
|
+
return published if floor.nil?
|
|
52
|
+
return floor if published.nil?
|
|
53
|
+
|
|
54
|
+
BigDecimal(published.to_s) > BigDecimal(floor.to_s) ? published : floor
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
floor
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/honeymaker/version.rb
CHANGED
data/lib/honeymaker.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "json"
|
|
|
7
7
|
require_relative "honeymaker/version"
|
|
8
8
|
require_relative "honeymaker/result"
|
|
9
9
|
require_relative "honeymaker/utils"
|
|
10
|
+
require_relative "honeymaker/floors"
|
|
10
11
|
require_relative "honeymaker/exchange"
|
|
11
12
|
require_relative "honeymaker/client"
|
|
12
13
|
require_relative "honeymaker/native"
|
|
@@ -23,7 +23,7 @@ class Honeymaker::Exchanges::BingXTest < Minitest::Test
|
|
|
23
23
|
assert_equal "BTC-USDT", ticker[:ticker]
|
|
24
24
|
assert_equal "BTC", ticker[:base]
|
|
25
25
|
assert_equal "USDT", ticker[:quote]
|
|
26
|
-
assert_equal "0
|
|
26
|
+
assert_equal "0", ticker[:minimum_base_size] # deprecated minQty (0.00001) ignored
|
|
27
27
|
assert_equal "1", ticker[:minimum_quote_size]
|
|
28
28
|
assert_equal "500", ticker[:maximum_base_size]
|
|
29
29
|
assert_equal 5, ticker[:base_decimals]
|
|
@@ -54,8 +54,60 @@ class Honeymaker::Exchanges::BitgetTest < Minitest::Test
|
|
|
54
54
|
assert_equal BigDecimal("67125.67"), result.data[:ask]
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def test_usdt_minimum_on_a_usdt_pair_is_not_converted_and_no_prices_are_read
|
|
58
|
+
connection = stub
|
|
59
|
+
connection.expects(:get).with("/api/v2/spot/public/symbols").returns(stub(body: load_fixture("bitget_symbols.json")))
|
|
60
|
+
@exchange.instance_variable_set(:@connection, connection)
|
|
61
|
+
|
|
62
|
+
assert_equal "5", @exchange.get_tickers_info.data.first[:minimum_quote_size]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_non_usdt_quote_converted_at_the_quotes_own_usdt_price
|
|
66
|
+
tickers = [{ "symbol" => "BTCUSDT", "lastPr" => "50000" },
|
|
67
|
+
{ "symbol" => "ETHBTC", "lastPr" => "0.05" }] # the traded pair's price: never used
|
|
68
|
+
data = quote_minimums({ "ETHBTC" => "BTC" }, tickers)
|
|
69
|
+
assert_equal BigDecimal("0.0001"), BigDecimal(data["ETHBTC"])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_inverse_usdt_quote_pair_multiplies
|
|
73
|
+
data = quote_minimums({ "BTCTRY" => "TRY" }, [{ "symbol" => "USDTTRY", "lastPr" => "40" }])
|
|
74
|
+
assert_equal BigDecimal("200"), BigDecimal(data["BTCTRY"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_missing_price_leaves_the_minimum_to_the_venue
|
|
78
|
+
data = quote_minimums({ "ETHBTC" => "BTC", "XEUR" => "EUR" },
|
|
79
|
+
[{ "symbol" => "BTCUSDT", "lastPr" => "0" }, { "symbol" => "ETHBTC", "lastPr" => "0.05" }])
|
|
80
|
+
assert_equal "0", data["ETHBTC"]
|
|
81
|
+
assert_equal "0", data["XEUR"]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_failed_price_read_falls_back_like_a_missing_price
|
|
85
|
+
data = quote_minimums({ "ETHBTC" => "BTC", "BTCUSDT" => "USDT" }, Faraday::ServerError.new("boom"))
|
|
86
|
+
assert_equal "0", data["ETHBTC"]
|
|
87
|
+
assert_equal "5", data["BTCUSDT"]
|
|
88
|
+
end
|
|
89
|
+
|
|
57
90
|
private
|
|
58
91
|
|
|
92
|
+
# symbol => quote rows, each with minTradeUSDT 5; tickers is the market's ticker list or the
|
|
93
|
+
# error its read raises. Returns symbol => minimum_quote_size.
|
|
94
|
+
def quote_minimums(pairs, tickers)
|
|
95
|
+
products = pairs.map do |symbol, quote|
|
|
96
|
+
load_fixture("bitget_symbols.json")["data"].first.merge("symbol" => symbol, "quoteCoin" => quote, "minTradeUSDT" => "5")
|
|
97
|
+
end
|
|
98
|
+
connection = stub
|
|
99
|
+
connection.stubs(:get).with("/api/v2/spot/public/symbols").returns(stub(body: { "data" => products }))
|
|
100
|
+
if tickers.is_a?(Exception)
|
|
101
|
+
connection.stubs(:get).with("/api/v2/spot/market/tickers").raises(tickers)
|
|
102
|
+
else
|
|
103
|
+
connection.stubs(:get).with("/api/v2/spot/market/tickers").returns(stub(body: { "data" => tickers }))
|
|
104
|
+
end
|
|
105
|
+
@exchange.instance_variable_set(:@connection, connection)
|
|
106
|
+
result = @exchange.get_tickers_info
|
|
107
|
+
assert result.success?, result.errors.inspect
|
|
108
|
+
result.data.to_h { |t| [t[:ticker], t[:minimum_quote_size]] }
|
|
109
|
+
end
|
|
110
|
+
|
|
59
111
|
def stub_connection(body)
|
|
60
112
|
response = stub(body: body)
|
|
61
113
|
connection = stub
|
|
@@ -123,6 +123,18 @@ class Honeymaker::Exchanges::KrakenTest < Minitest::Test
|
|
|
123
123
|
assert_equal "5", ticker[:minimum_quote_size]
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
def test_cost_floor_is_a_max_of_published_and_floor
|
|
127
|
+
body = load_fixture("kraken_asset_pairs.json")
|
|
128
|
+
key, info = body["result"].find { |_, i| i["wsname"]&.end_with?("/USDT") }
|
|
129
|
+
body["result"] = { key => info.merge("costmin" => "0.5") }
|
|
130
|
+
stub_request(body)
|
|
131
|
+
assert_equal "5", @exchange.get_tickers_info.data.first[:minimum_quote_size]
|
|
132
|
+
|
|
133
|
+
body["result"] = { key => info.merge("costmin" => "12.5") }
|
|
134
|
+
stub_request(body)
|
|
135
|
+
assert_equal "12.5", @exchange.get_tickers_info.data.first[:minimum_quote_size]
|
|
136
|
+
end
|
|
137
|
+
|
|
126
138
|
def test_get_tickers_info_handles_api_error
|
|
127
139
|
body = { "error" => ["EGeneral:Internal error"], "result" => {} }
|
|
128
140
|
stub_request(body)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class Honeymaker::FloorsTest < Minitest::Test
|
|
6
|
+
F = Honeymaker::Floors
|
|
7
|
+
|
|
8
|
+
def test_max_of_published_and_floor
|
|
9
|
+
assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD", published: "0.5")
|
|
10
|
+
assert_equal "12.5", F.minimum_quote(venue: "kraken", quote: "USD", published: "12.5")
|
|
11
|
+
assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD", published: "5.0") # tie: the floor
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_missing_or_unreadable_published_gives_the_floor
|
|
15
|
+
assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD")
|
|
16
|
+
assert_equal 5, F.minimum_quote(venue: "kraken", quote: "USD", published: "abc")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_no_floor_keeps_published
|
|
20
|
+
assert_equal "0.1", F.minimum_quote(venue: "kraken", quote: "FOO", published: "0.1")
|
|
21
|
+
assert_equal "3", F.minimum_quote(venue: "binance", quote: "USDT", published: "3")
|
|
22
|
+
assert_nil F.minimum_quote(venue: "binance", quote: "USDT")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_alpaca_floor_is_keyed_by_asset_class
|
|
26
|
+
assert_equal 10, F.minimum_quote(venue: "alpaca", quote: "USD", asset_class: "crypto", published: "1")
|
|
27
|
+
assert_equal 10, F.minimum_quote(venue: :alpaca, quote: "USD", asset_class: :crypto)
|
|
28
|
+
assert_equal "1", F.minimum_quote(venue: "alpaca", quote: "USD", asset_class: "stock", published: "1")
|
|
29
|
+
assert_nil F.minimum_quote(venue: "alpaca", quote: "USD", asset_class: "stock")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_hyperliquid_floor_covers_every_quote
|
|
33
|
+
assert_equal 10, F.minimum_quote(venue: "hyperliquid", quote: "USDC")
|
|
34
|
+
assert_equal 10, F.minimum_quote(venue: "hyperliquid", quote: "USDT0", published: "1")
|
|
35
|
+
assert_equal "25", F.minimum_quote(venue: "hyperliquid", quote: "USDC", published: "25")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -43,6 +43,32 @@ class KrakenExchangeParityTest < Minitest::Test
|
|
|
43
43
|
[500, JSON_CT, "{}"]].each { |reply| assert_parity(reply, &:get_tickers_info) }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# The native REAL_COSTMIN mirrors Honeymaker::Floors: every floored quote, published below,
|
|
47
|
+
# above, equal to, missing and unreadable, plus an unfloored quote.
|
|
48
|
+
def test_cost_floors_match_the_shared_table
|
|
49
|
+
floors = Honeymaker::Floors::MINIMUM_QUOTE[["kraken", nil]]
|
|
50
|
+
pairs = {}
|
|
51
|
+
(floors.keys + ["FOO"]).each do |quote|
|
|
52
|
+
floor = floors[quote] || 1
|
|
53
|
+
[(BigDecimal(floor.to_s) / 10).to_s("F"), (BigDecimal(floor.to_s) * 10).to_s("F"), floor.to_s, nil, "abc", floor * 3].each_with_index do |costmin, i|
|
|
54
|
+
ws = "B#{i}/#{quote}"
|
|
55
|
+
pairs[ws] = { "altname" => ws, "wsname" => ws, "costmin" => costmin }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
reply = [200, JSON_CT, { "error" => [], "result" => pairs }.to_json]
|
|
59
|
+
assert_parity(reply, &:get_tickers_info)
|
|
60
|
+
|
|
61
|
+
d = VenueDouble.new([reply])
|
|
62
|
+
e = Honeymaker::Native::KrakenExchange.new
|
|
63
|
+
e.instance_variable_set(:@connection, e.send(:build_connection, d.url))
|
|
64
|
+
got = e.get_tickers_info.data.to_h { |t| [t[:ticker], t[:minimum_quote_size]] }
|
|
65
|
+
assert_equal floors["USD"].to_s, got["B0/USD"]
|
|
66
|
+
assert_equal "50.0", got["B1/USD"]
|
|
67
|
+
assert_equal "0.1", got["B0/FOO"]
|
|
68
|
+
ensure
|
|
69
|
+
d&.close
|
|
70
|
+
end
|
|
71
|
+
|
|
46
72
|
def test_one_malformed_row_fails_the_whole_catalogue_like_legacy
|
|
47
73
|
replies = [false, 1, { "x" => 1 }].map do |bad|
|
|
48
74
|
pairs = { "XXBTZUSD" => { "altname" => "XBTUSD", "wsname" => "XBT/USD", "status" => "online" },
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: honeymaker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.12.
|
|
4
|
+
version: 0.12.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Deltabadger
|
|
@@ -179,6 +179,7 @@ files:
|
|
|
179
179
|
- lib/honeymaker/exchanges/kraken.rb
|
|
180
180
|
- lib/honeymaker/exchanges/kucoin.rb
|
|
181
181
|
- lib/honeymaker/exchanges/mexc.rb
|
|
182
|
+
- lib/honeymaker/floors.rb
|
|
182
183
|
- lib/honeymaker/native.rb
|
|
183
184
|
- lib/honeymaker/native/kraken.rb
|
|
184
185
|
- lib/honeymaker/result.rb
|
|
@@ -252,6 +253,7 @@ files:
|
|
|
252
253
|
- test/honeymaker/exchanges/kraken_test.rb
|
|
253
254
|
- test/honeymaker/exchanges/kucoin_test.rb
|
|
254
255
|
- test/honeymaker/exchanges/mexc_test.rb
|
|
256
|
+
- test/honeymaker/floors_test.rb
|
|
255
257
|
- test/honeymaker/honeymaker_test.rb
|
|
256
258
|
- test/honeymaker/result_test.rb
|
|
257
259
|
- test/honeymaker/utils_test.rb
|