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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b63e4e5855db8e7ada05e58f6dad106bf6f24e29628dfce7e0fd04e062382b5
4
- data.tar.gz: 51382cf687f1e6a3136d7c57d1656cca27065c19075075c42d4170ae6e9ffc80
3
+ metadata.gz: 9b0990bd1855430e5d4b8bdda6132202c74b6e2151c64c9c4aab5edeaae0c8d0
4
+ data.tar.gz: 5ce771e18c6e07370686d20a55c39e687fdd0340a628538de68fa524aea40f93
5
5
  SHA512:
6
- metadata.gz: efa5ee0604c2daf571073432447c86cb24706ab1a95d00d318204c26dfcd66c32616e96071ee8b86992ca8f32f5f87fdaa387a30bb748781095466f44b222a0b
7
- data.tar.gz: b0bb1d9eac28616b4cc1beae77159ea8221c5ee554d80f3776698b8cca2aa05e05d67c860303fba7cce7a52f61471fcc5398eb6c2bd4438cfe2a348165c73749
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.5"
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.5"
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.5"
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.5" # lockstep with lib/honeymaker/version.rb (test/native/lockstep_test.rb)
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
- /// 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).
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 costmin = REAL_COSTMIN
447
- .iter()
448
- .find(|(k, _)| string_eq(&quote, k))
449
- .map(|(_, lit)| serde_json::from_str::<Value>(lit).expect("literal"))
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(&quote, 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
+ }
@@ -87,4 +87,8 @@ impl Num for RubyDecimal {
87
87
  fn is_zero(&self) -> Result<bool, magnus::Error> {
88
88
  self.value().funcall("zero?", ())
89
89
  }
90
+
91
+ fn gt(&self, o: &Self) -> Result<bool, magnus::Error> {
92
+ self.value().funcall(">", (o.value(),))
93
+ }
90
94
  }
@@ -18,7 +18,9 @@ module Honeymaker
18
18
  ticker: ticker,
19
19
  base: parts[0],
20
20
  quote: parts[1],
21
- minimum_base_size: product["minQty"]&.to_s,
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
- response.body["data"].filter_map do |product|
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: 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: (REAL_COSTMIN[quote] || info["costmin"] || 0).to_s,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.12.5"
4
+ VERSION = "0.12.6"
5
5
  end
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.00001", ticker[:minimum_base_size]
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.5
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