honeymaker 0.11.9 → 0.11.10

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: e6f44f3c6ed35e460dcb6b6d53a73bebec433c34ad1109ab8b1531f799ad93b0
4
- data.tar.gz: cd55a0b081c84f712e67ba00ede7633764592b0ff97d2b47fa50f29fc7c0cdd0
3
+ metadata.gz: 81ec4e430e403ef063e19f4c1a0827f9652bed081e7bdf845ba0f2c88b5c7a28
4
+ data.tar.gz: 779b9f7391e4259a711c478a2493445fecf88ef04bc46e64affdaf2b1267decb
5
5
  SHA512:
6
- metadata.gz: 32e67280cabf17a5a7305660b92ad2ff2eda948fb947a50a771f6bbd102f7cd9cbbeefdda50b09c16d1087bdd637c4649ba25963046828c9dcbd73eec87c5a4b
7
- data.tar.gz: 01b15492c985691a7fcde609a56f3f6e197febbf7fdaaace27186ecb66268a128f796e99ca2a28aef7700c7e56bedb753ceff557400c7f54e142b07a7cb36a20
6
+ metadata.gz: 2107a78f1bbec5167448470cfe8f26871c37fea3237be0ee1137d33cbe738ed9b23292ff99d35e1c9114f27a0634ba0aa315357cd126fd57f805919858a7c4eb
7
+ data.tar.gz: 1f694295c4ed6699728fd0fa500a5a081d1835b620a6b6d06e7ee3ab239804defe4bb8a9271ad638a0321e27aa5091dc6d5adbda5e69cfd97bdf89d9e310dd39
@@ -69,6 +69,15 @@ module Honeymaker
69
69
  3600
70
70
  end
71
71
 
72
+ # Venue symbols the last #get_tickers_info saw listed but could not describe, symbol => why. They
73
+ # are missing from the catalogue WITHOUT being delisted: a caller that reads absence as delisting
74
+ # must keep what it has for them. Empty for a venue whose catalogue is a single request.
75
+ # It describes the last get_tickers_info call only; the #tickers_info cache does not carry it, so a
76
+ # caller that needs both reads them from one get_tickers_info.
77
+ def unreadable_symbols
78
+ @unreadable_symbols || {}
79
+ end
80
+
72
81
  private
73
82
 
74
83
  # No HTTP answer means the transport failed, and its text rarely says how ("end of file
@@ -78,6 +87,17 @@ module Honeymaker
78
87
  status.nil? ? { status: nil, error_chain: Utils.error_chain(error) } : { status: status }
79
88
  end
80
89
 
90
+ # Records one instrument the venue lists but whose description could not be read, keyed as the
91
+ # venue emits its ticker. The reason is what a person needs to see: the venue's own answer when it
92
+ # gave one ("HTTP 400 {...InvalidSymbol...}"), otherwise the exception.
93
+ def set_aside(symbol, error)
94
+ status = error.respond_to?(:response_status) ? error.response_status : nil
95
+ body = error.respond_to?(:response_body) ? error.response_body.to_s : ""
96
+ why = body.empty? ? "#{error.class}: #{error.message}" : body
97
+ (@unreadable_symbols ||= {})[symbol.to_s.upcase] = (status ? "HTTP #{status} #{why}" : why)[0, 200]
98
+ nil
99
+ end
100
+
81
101
  def with_rescue
82
102
  Result::Success.new(yield)
83
103
  rescue Faraday::Error => e
@@ -15,35 +15,39 @@ module Honeymaker
15
15
  Faraday::TooManyRequestsError, Faraday::ServerError
16
16
  ].freeze
17
17
 
18
+ # A symbol still failing after its retries is usually the venue or the connection, not the
19
+ # instrument, and each costs up to ~100 s. Past this many in one run the venue itself is down, so
20
+ # the whole catalogue fails, as any failure did before.
21
+ # ponytail: flat per-run budget; raise it if a rollout ever 5xxs a bigger family than this.
22
+ NO_ANSWER_LIMIT = 10
23
+
24
+ # /v1/symbols says what Gemini lists; the details only describe it. A listed symbol whose details
25
+ # cannot be read this run is SET ASIDE: left out of the catalogue and named in
26
+ # #unreadable_symbols, which callers must read as "unknown", never as delisted. Gemini lists new
27
+ # symbols before it can describe them (details/gramsgd answered 400 InvalidSymbol for hours on
28
+ # 2026-09-22), and failing the whole catalogue for that one left every Gemini pair without a
29
+ # verdict.
18
30
  def get_tickers_info
31
+ @unreadable_symbols = {}
19
32
  with_rescue do
20
33
  symbols = catalogue_get("/v1/symbols")
34
+ no_answers = 0
21
35
 
22
36
  tickers = symbols.filter_map do |symbol|
23
- # A symbol that still fails raises, failing the WHOLE catalogue. Never skip it: callers
24
- # read a pair missing from the catalogue as delisted, and data-api revokes it.
25
- detail = catalogue_get("/v1/symbols/details/#{symbol}")
26
- next unless spot?(symbol, detail)
27
-
28
- tick_size = detail["tick_size"]&.to_s || "0.01"
29
- quote_increment = detail["quote_increment"]&.to_s || "0.01"
30
-
31
- {
32
- ticker: symbol.upcase,
33
- base: detail["base_currency"].upcase,
34
- quote: detail["quote_currency"].upcase,
35
- minimum_base_size: detail["min_order_size"],
36
- minimum_quote_size: "0",
37
- maximum_base_size: nil,
38
- maximum_quote_size: nil,
39
- base_decimals: Utils.decimals(tick_size),
40
- quote_decimals: Utils.decimals(quote_increment),
41
- price_decimals: Utils.decimals(quote_increment),
42
- available: true,
43
- trading_enabled: detail["status"] == "open"
44
- }
37
+ describe(symbol, catalogue_get("/v1/symbols/details/#{symbol}"))
38
+ rescue *TRANSIENT_ERRORS => e
39
+ raise if (no_answers += 1) >= NO_ANSWER_LIMIT
40
+
41
+ set_aside(symbol, e)
42
+ rescue StandardError => e
43
+ set_aside(symbol, e)
44
+ end
45
+ tickers = set_aside_shared_pairs(tickers)
46
+ if tickers.empty? && @unreadable_symbols.any?
47
+ raise Error, "Gemini: no symbol could be read, e.g. #{@unreadable_symbols.first.join(': ')}"
45
48
  end
46
- reject_shared_pairs!(tickers)
49
+
50
+ tickers
47
51
  end
48
52
  end
49
53
 
@@ -64,23 +68,52 @@ module Honeymaker
64
68
  @connection ||= build_connection(BASE_URL)
65
69
  end
66
70
 
71
+ def describe(symbol, detail)
72
+ return unless spot?(symbol, detail)
73
+
74
+ tick_size = detail["tick_size"]&.to_s || "0.01"
75
+ quote_increment = detail["quote_increment"]&.to_s || "0.01"
76
+
77
+ {
78
+ ticker: symbol.upcase,
79
+ base: detail["base_currency"].upcase,
80
+ quote: detail["quote_currency"].upcase,
81
+ minimum_base_size: detail["min_order_size"],
82
+ minimum_quote_size: "0",
83
+ maximum_base_size: nil,
84
+ maximum_quote_size: nil,
85
+ base_decimals: Utils.decimals(tick_size),
86
+ quote_decimals: Utils.decimals(quote_increment),
87
+ price_decimals: Utils.decimals(quote_increment),
88
+ available: true,
89
+ trading_enabled: detail["status"] == "open"
90
+ }
91
+ end
92
+
67
93
  # Gemini lists perpetual swaps beside spot, under the same base and quote (BTCGUSDPERP is
68
94
  # BTC/GUSD, product_type "swap"), so only product_type tells them apart. An instrument without
69
- # one is not assumed to be spot: the catalogue fails and callers keep the one they have.
95
+ # one is not assumed to be spot: it is set aside. So is a body that is not an object -- JSON
96
+ # served as text/plain arrives as a String, and String#[] finds "product_type" inside it.
70
97
  def spot?(symbol, detail)
98
+ raise Error, "Gemini #{symbol}: details are not an object" unless detail.is_a?(Hash)
99
+
71
100
  product_type = detail["product_type"]
72
- raise Error, "Gemini #{symbol}: no product_type in its details" unless product_type.is_a?(String)
101
+ raise Error, "Gemini #{symbol}: no product_type in its details" unless product_type.is_a?(String) && !product_type.empty?
102
+ # A missing status would otherwise read as "not open" and revoke the pair.
103
+ raise Error, "Gemini #{symbol}: no status in its details" unless detail["status"].is_a?(String)
73
104
 
74
105
  product_type == "spot"
75
106
  end
76
107
 
77
- # One pair, one instrument. Two would leave callers to pick one by list order.
78
- def reject_shared_pairs!(tickers)
79
- shared = tickers.group_by { |t| "#{t[:base]}/#{t[:quote]}" }.find { |_, group| group.size > 1 }
80
- return tickers unless shared
81
-
82
- pair, group = shared
83
- raise Error, "Gemini #{pair} is claimed by #{group.map { |t| t[:ticker] }.join(' and ')}"
108
+ # One pair, one instrument. Two would leave callers to pick one by list order, so neither is
109
+ # picked: both are set aside until Gemini lists only one.
110
+ def set_aside_shared_pairs(tickers)
111
+ shared = tickers.group_by { |t| [t[:base], t[:quote]] }.values.reject(&:one?)
112
+ shared.each do |group|
113
+ claim = "Gemini #{group.first[:base]}/#{group.first[:quote]} is claimed by #{group.map { |t| t[:ticker] }.join(' and ')}"
114
+ group.each { |t| @unreadable_symbols[t[:ticker]] = claim }
115
+ end
116
+ tickers - shared.flatten
84
117
  end
85
118
 
86
119
  def catalogue_get(path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.11.9"
4
+ VERSION = "0.11.10"
5
5
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The set-aside rule, driven through honeymaker's real middleware stack: one symbol Gemini lists but
6
+ # cannot describe is left out and named, and never fails the whole catalogue.
7
+ class Honeymaker::Exchanges::GeminiCatalogueTest < Minitest::Test
8
+ JSON_CT = { "Content-Type" => "application/json" }.freeze
9
+
10
+ def detail(sym, base, quote, **over)
11
+ { "symbol" => sym.upcase, "base_currency" => base, "quote_currency" => quote, "tick_size" => "0.00000001",
12
+ "quote_increment" => "0.01", "min_order_size" => "0.00001", "status" => "open", "product_type" => "spot" }.merge(over.transform_keys(&:to_s))
13
+ end
14
+
15
+ def exchange_with(symbols, &details)
16
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
17
+ s.get("/v1/symbols") { [200, JSON_CT, JSON.dump(symbols)] }
18
+ symbols.each { |sym| s.get("/v1/symbols/details/#{sym}") { details.call(sym) } }
19
+ end
20
+ ex = Honeymaker::Exchanges::Gemini.new
21
+ @sleeps = sleeps = []
22
+ ex.define_singleton_method(:sleep) { |s| sleeps << s }
23
+ conn = Faraday.new(url: "https://api.gemini.com") do |c|
24
+ c.request :json; c.response :json; c.response :raise_error; c.adapter :test, stubs
25
+ end
26
+ ex.instance_variable_set(:@connection, conn)
27
+ ex
28
+ end
29
+
30
+ def ok(sym)
31
+ base = sym[0..-4].upcase
32
+ [200, JSON_CT, JSON.dump(detail(sym, base, "USD"))]
33
+ end
34
+
35
+ def test_incident_replay_sets_gramsgd_aside_and_reads_the_rest
36
+ body = '{"result":"error","reason":"InvalidSymbol","message":"Received unsupported symbol \'gramsgd\'"}'
37
+ ex = exchange_with(%w[btcusd gramsgd ethusd]) { |s| s == "gramsgd" ? [400, JSON_CT, body] : ok(s) }
38
+ r = ex.get_tickers_info
39
+ assert r.success?
40
+ assert_equal %w[BTCUSD ETHUSD], r.data.map { |t| t[:ticker] }
41
+ assert_equal({ "GRAMSGD" => "HTTP 400 #{body}" }, ex.unreadable_symbols)
42
+ assert_equal [0.5] * 4, @sleeps, "a 400 is not retried"
43
+ end
44
+
45
+ def test_text_plain_details_are_set_aside_not_read_as_non_spot
46
+ ex = exchange_with(%w[btcusd ethusd]) do |s|
47
+ s == "ethusd" ? [200, { "Content-Type" => "text/plain" }, JSON.dump(detail("ethusd", "ETH", "USD"))] : ok(s)
48
+ end
49
+ r = ex.get_tickers_info
50
+ assert r.success?
51
+ assert_equal %w[BTCUSD], r.data.map { |t| t[:ticker] }
52
+ assert_match(/not an object/, ex.unreadable_symbols["ETHUSD"])
53
+ end
54
+
55
+ def test_garbage_shapes_are_each_set_aside
56
+ shapes = {
57
+ "aaausd" => [200, JSON_CT, JSON.dump(detail("aaausd", "AAA", "USD").except("product_type"))],
58
+ "bbbusd" => [200, { "Content-Type" => "text/html" }, "<html>oops</html>"],
59
+ "cccusd" => [200, JSON_CT, "{not json"],
60
+ "dddusd" => [200, JSON_CT, JSON.dump([1, 2])],
61
+ "eeeusd" => [200, JSON_CT, JSON.dump(detail("eeeusd", nil, "USD"))],
62
+ "fffusd" => [403, { "Content-Type" => "text/html" }, "<html>blocked</html>"],
63
+ "gggusd" => [404, JSON_CT, "{}"],
64
+ "hhhusd" => [200, JSON_CT, JSON.dump(detail("hhhusd", "HHH", "USD", product_type: ""))],
65
+ "iiiusd" => [200, JSON_CT, JSON.dump(detail("iiiusd", "III", "USD").except("status"))]
66
+ }
67
+ ex = exchange_with(%w[btcusd] + shapes.keys) { |s| shapes[s] || ok(s) }
68
+ r = ex.get_tickers_info
69
+ assert r.success?, r.errors.inspect
70
+ assert_equal %w[BTCUSD], r.data.map { |t| t[:ticker] }
71
+ assert_equal shapes.keys.map(&:upcase).sort, ex.unreadable_symbols.keys.sort
72
+ end
73
+
74
+ def test_one_symbol_without_answer_is_set_aside_after_its_retries
75
+ ex = exchange_with(%w[btcusd ethusd solusd]) { |s| s == "ethusd" ? [503, JSON_CT, "{}"] : ok(s) }
76
+ r = ex.get_tickers_info
77
+ assert r.success?
78
+ assert_equal %w[BTCUSD SOLUSD], r.data.map { |t| t[:ticker] }
79
+ assert_equal [0.5, 0.5, 0.5, 2, 0.5, 4, 0.5, 0.5], @sleeps
80
+ end
81
+
82
+ def test_the_no_answer_budget_fails_the_whole_catalogue
83
+ syms = (1..12).map { |i| format("x%02dusd", i) }
84
+ requested = []
85
+ ex = exchange_with(syms) { |s| requested << s; [503, JSON_CT, "{}"] }
86
+ r = ex.get_tickers_info
87
+ assert r.failure?
88
+ assert_equal({ status: 503 }, r.data)
89
+ assert_equal 10, requested.uniq.size, "stops at the budget"
90
+ end
91
+
92
+ def test_nine_no_answers_among_readable_symbols_still_succeed
93
+ syms = (1..9).flat_map { |i| [format("x%02dusd", i), format("y%02dusd", i)] }
94
+ ex = exchange_with(syms) { |s| s.start_with?("x") ? [503, JSON_CT, "{}"] : ok(s) }
95
+ r = ex.get_tickers_info
96
+ assert r.success?, r.errors.inspect
97
+ assert_equal 9, r.data.size
98
+ assert_equal 9, ex.unreadable_symbols.size
99
+ end
100
+
101
+ def test_every_pair_claimed_twice_is_set_aside_and_the_rest_kept
102
+ twins = { "btcusd2" => %w[BTC USD], "ethusd2" => %w[ETH USD] }
103
+ ex = exchange_with(%w[btcusd btcusd2 ethusd ethusd2 solusd]) do |s|
104
+ twins[s] ? [200, JSON_CT, JSON.dump(detail(s, *twins[s]))] : ok(s)
105
+ end
106
+ r = ex.get_tickers_info
107
+ assert r.success?
108
+ assert_equal %w[SOLUSD], r.data.map { |t| t[:ticker] }
109
+ assert_equal %w[BTCUSD BTCUSD2 ETHUSD ETHUSD2], ex.unreadable_symbols.keys.sort
110
+ end
111
+
112
+ def test_nothing_readable_is_a_failure_and_state_resets_between_runs
113
+ ex = exchange_with(%w[btcusd]) { |_| [400, JSON_CT, "{}"] }
114
+ assert ex.get_tickers_info.failure?
115
+ assert_equal %w[BTCUSD], ex.unreadable_symbols.keys
116
+ assert_equal({}, Honeymaker::Exchanges::Binance.new.unreadable_symbols)
117
+ end
118
+ end
@@ -76,28 +76,27 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
76
76
  end
77
77
  end
78
78
 
79
- # All or nothing: data-api reads a pair missing from the catalogue as delisted, so a symbol that
80
- # keeps failing must fail the whole catalogue, and nothing after it is fetched.
81
- def test_get_tickers_info_fails_whole_catalogue_when_a_symbol_keeps_failing
79
+ # Set aside, not fatal: a symbol that keeps failing is left out and named, and the run goes on.
80
+ def test_get_tickers_info_sets_aside_a_symbol_that_keeps_failing
82
81
  connection = stub_catalogue(%w[btcusd ethusd solusd])
83
82
  connection.expects(:get).with("/v1/symbols/details/ethusd").times(3)
84
83
  .raises(Faraday::ConnectionFailed, "end of file reached")
85
- connection.expects(:get).with("/v1/symbols/details/solusd").never
86
84
 
87
85
  result = @exchange.get_tickers_info
88
86
 
89
- assert result.failure?
90
- refute_kind_of Array, result.data, "no partial catalogue"
91
- assert_equal ["end of file reached"], result.errors
92
- assert_equal [0.5, 0.5, 0.5, 2, 0.5, 4, 0.5], @sleeps
87
+ assert result.success?
88
+ assert_equal %w[BTCUSD SOLUSD], result.data.map { |t| t[:ticker] }
89
+ assert_equal({ "ETHUSD" => "Faraday::ConnectionFailed: end of file reached" }, @exchange.unreadable_symbols)
90
+ assert_equal [0.5, 0.5, 0.5, 2, 0.5, 4, 0.5, 0.5], @sleeps
93
91
  end
94
92
 
95
93
  def test_get_tickers_info_does_not_retry_a_non_transient_error
96
- connection = stub_catalogue(%w[btcusd])
94
+ connection = stub_catalogue(%w[btcusd ethusd])
97
95
  connection.expects(:get).with("/v1/symbols/details/btcusd").once
98
96
  .raises(Faraday::ResourceNotFound, "404")
99
97
 
100
- assert @exchange.get_tickers_info.failure?
98
+ assert_equal %w[ETHUSD], @exchange.get_tickers_info.data.map { |t| t[:ticker] }
99
+ assert_equal %w[BTCUSD], @exchange.unreadable_symbols.keys
101
100
  end
102
101
 
103
102
  # Gemini lists perpetual swaps in /v1/symbols with the same base and quote as the spot pair
@@ -113,29 +112,29 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
113
112
  assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
114
113
  end
115
114
 
116
- # An unclassified instrument is not assumed to be spot: the whole catalogue fails, and callers keep
117
- # the one they have.
118
- def test_get_tickers_info_fails_when_a_symbol_has_no_product_type
115
+ # An unclassified instrument is not assumed to be spot: it is set aside, and callers keep what
116
+ # they have for it.
117
+ def test_get_tickers_info_sets_aside_a_symbol_with_no_product_type
119
118
  connection = stub_catalogue(%w[btcusd ethusd])
120
119
  connection.stubs(:get).with("/v1/symbols/details/ethusd")
121
120
  .returns(stub(body: detail_body.except("product_type").merge("symbol" => "ETHUSD")))
122
121
 
123
122
  result = @exchange.get_tickers_info
124
123
 
125
- assert result.failure?
126
- assert_nil result.data
127
- assert_match(/ethusd/, result.errors.first)
124
+ assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
125
+ assert_match(/ethusd: no product_type/, @exchange.unreadable_symbols["ETHUSD"])
128
126
  end
129
127
 
130
- def test_get_tickers_info_fails_when_two_instruments_claim_one_pair
131
- connection = stub_catalogue(%w[btcusd btcusd2])
128
+ def test_get_tickers_info_sets_aside_both_instruments_claiming_one_pair
129
+ connection = stub_catalogue(%w[btcusd btcusd2 ethusd])
132
130
  connection.stubs(:get).with("/v1/symbols/details/btcusd2")
133
131
  .returns(stub(body: detail_body.merge("symbol" => "BTCUSD2")))
134
132
 
135
133
  result = @exchange.get_tickers_info
136
134
 
137
- assert result.failure?
138
- assert_match(%r{BTC/USD}, result.errors.first)
135
+ assert_equal %w[ETHUSD], result.data.map { |t| t[:ticker] }
136
+ assert_equal %w[BTCUSD BTCUSD2], @exchange.unreadable_symbols.keys.sort
137
+ assert_match(%r{BTC/USD}, @exchange.unreadable_symbols["BTCUSD"])
139
138
  end
140
139
 
141
140
  def test_get_bid_ask_parses_response
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.11.9
4
+ version: 0.11.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger
@@ -180,6 +180,7 @@ files:
180
180
  - test/honeymaker/exchanges/bitvavo_test.rb
181
181
  - test/honeymaker/exchanges/bybit_test.rb
182
182
  - test/honeymaker/exchanges/coinbase_test.rb
183
+ - test/honeymaker/exchanges/gemini_catalogue_test.rb
183
184
  - test/honeymaker/exchanges/gemini_test.rb
184
185
  - test/honeymaker/exchanges/hyperliquid_test.rb
185
186
  - test/honeymaker/exchanges/kraken_test.rb