honeymaker 0.11.6 → 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: 79df37d9e336e6aae8b38cbe4af40655742da763a47842b3b0128355130c2c48
4
- data.tar.gz: 8a821ce866ad46433104a085375f5ac3073ac159c44e0b31f4f57170414a99fc
3
+ metadata.gz: 81ec4e430e403ef063e19f4c1a0827f9652bed081e7bdf845ba0f2c88b5c7a28
4
+ data.tar.gz: 779b9f7391e4259a711c478a2493445fecf88ef04bc46e64affdaf2b1267decb
5
5
  SHA512:
6
- metadata.gz: ea5bbaf9abffab526e1929b0c6e9d3ebc80af1e609fdb696a5c141585d57e643d99370acb1dcf458f6fe7c02df22c269b240e316ca68dfbd8295ef1b882ae4a4
7
- data.tar.gz: 8dca53d930a98f7259f3c73d6e3827e40647fbf6ceaefd857c2795e1c499c630f335912c23a030a2a72aa326d578b1529d55bb825e2e54c08db56e794339da1f
6
+ metadata.gz: 2107a78f1bbec5167448470cfe8f26871c37fea3237be0ee1137d33cbe738ed9b23292ff99d35e1c9114f27a0634ba0aa315357cd126fd57f805919858a7c4eb
7
+ data.tar.gz: 1f694295c4ed6699728fd0fa500a5a081d1835b620a6b6d06e7ee3ab239804defe4bb8a9271ad638a0321e27aa5091dc6d5adbda5e69cfd97bdf89d9e310dd39
@@ -61,6 +61,13 @@ module Honeymaker
61
61
  raise NotImplementedError, "#{self.class} must implement #validate_read_credentials"
62
62
  end
63
63
 
64
+ # No HTTP answer means the transport failed, and its text rarely says how ("end of file
65
+ # reached"). The chain of exception classes does — whether the request never left or may have
66
+ # landed — so it travels with the failure. Facts only: what they mean is the caller's call.
67
+ def transport_data(error, status)
68
+ status.nil? ? { status: nil, error_chain: Utils.error_chain(error) } : { status: status }
69
+ end
70
+
64
71
  def with_rescue
65
72
  Result::Success.new(yield)
66
73
  rescue Faraday::Error => e
@@ -68,7 +75,7 @@ module Honeymaker
68
75
  error_message = (body && !body.empty?) ? body : e.message.to_s
69
76
  error_message = "Unknown API error" if error_message.nil? || error_message.empty?
70
77
  status = e.respond_to?(:response_status) ? e.response_status : nil
71
- Result::Failure.new(error_message, data: { status: status })
78
+ Result::Failure.new(error_message, data: transport_data(e, status))
72
79
  rescue StandardError => e
73
80
  # NOT an exchange error — a bug here, in a vendor gem, or in the caller, and it arrives in the
74
81
  # same Result::Failure as a genuine venue rejection. Callers classify that text to decide "out
@@ -69,15 +69,43 @@ 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
 
83
+ # No HTTP answer means the transport failed, and its text rarely says how ("end of file
84
+ # reached"). The chain of exception classes does — whether the request never left or may have
85
+ # landed — so it travels with the failure. Facts only: what they mean is the caller's call.
86
+ def transport_data(error, status)
87
+ status.nil? ? { status: nil, error_chain: Utils.error_chain(error) } : { status: status }
88
+ end
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
+
74
101
  def with_rescue
75
102
  Result::Success.new(yield)
76
103
  rescue Faraday::Error => e
77
104
  body = e.respond_to?(:response_body) ? e.response_body : nil
78
105
  error_message = (body && !body.empty?) ? body : e.message.to_s
79
106
  error_message = "Unknown API error" if error_message.nil? || error_message.empty?
80
- Result::Failure.new(error_message)
107
+ status = e.respond_to?(:response_status) ? e.response_status : nil
108
+ Result::Failure.new(error_message, data: transport_data(e, status))
81
109
  rescue StandardError => e
82
110
  msg = e.message
83
111
  Result::Failure.new((msg && !msg.empty?) ? msg : "Unknown error")
@@ -15,33 +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
35
+
36
+ tickers = symbols.filter_map do |symbol|
37
+ describe(symbol, catalogue_get("/v1/symbols/details/#{symbol}"))
38
+ rescue *TRANSIENT_ERRORS => e
39
+ raise if (no_answers += 1) >= NO_ANSWER_LIMIT
21
40
 
22
- 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
-
27
- tick_size = detail["tick_size"]&.to_s || "0.01"
28
- quote_increment = detail["quote_increment"]&.to_s || "0.01"
29
-
30
- {
31
- ticker: symbol.upcase,
32
- base: detail["base_currency"].upcase,
33
- quote: detail["quote_currency"].upcase,
34
- minimum_base_size: detail["min_order_size"],
35
- minimum_quote_size: "0",
36
- maximum_base_size: nil,
37
- maximum_quote_size: nil,
38
- base_decimals: Utils.decimals(tick_size),
39
- quote_decimals: Utils.decimals(quote_increment),
40
- price_decimals: Utils.decimals(quote_increment),
41
- available: true,
42
- trading_enabled: detail["status"] == "open"
43
- }
41
+ set_aside(symbol, e)
42
+ rescue StandardError => e
43
+ set_aside(symbol, e)
44
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(': ')}"
48
+ end
49
+
50
+ tickers
45
51
  end
46
52
  end
47
53
 
@@ -62,6 +68,54 @@ module Honeymaker
62
68
  @connection ||= build_connection(BASE_URL)
63
69
  end
64
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
+
93
+ # Gemini lists perpetual swaps beside spot, under the same base and quote (BTCGUSDPERP is
94
+ # BTC/GUSD, product_type "swap"), so only product_type tells them apart. An instrument without
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.
97
+ def spot?(symbol, detail)
98
+ raise Error, "Gemini #{symbol}: details are not an object" unless detail.is_a?(Hash)
99
+
100
+ product_type = detail["product_type"]
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)
104
+
105
+ product_type == "spot"
106
+ end
107
+
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
117
+ end
118
+
65
119
  def catalogue_get(path)
66
120
  attempt = 1
67
121
  begin
@@ -9,6 +9,25 @@ module Honeymaker
9
9
  str.split(".").last.length
10
10
  end
11
11
 
12
+ # The exception classes behind an error, outermost first, following both Faraday's
13
+ # #wrapped_exception and Ruby's #cause (adapters nest them differently), each named once.
14
+ # Reported on a transport failure so a caller can tell where it happened; see Client#with_rescue.
15
+ def self.error_chain(error, limit: 10)
16
+ names = []
17
+ seen = {}.compare_by_identity # exceptions, not classes: adapters re-wrap in the same class
18
+ queue = [error]
19
+ until queue.empty? || seen.size >= limit
20
+ node = queue.shift
21
+ next if seen.key?(node)
22
+
23
+ seen[node] = true
24
+ names << node.class.name unless names.include?(node.class.name)
25
+ queue << node.wrapped_exception if node.respond_to?(:wrapped_exception) && node.wrapped_exception
26
+ queue << node.cause if node.cause
27
+ end
28
+ names
29
+ end
30
+
12
31
  def self.parse_filters(filters)
13
32
  {
14
33
  price: filters.find { |f| f["filterType"] == "PRICE_FILTER" },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.11.6"
4
+ VERSION = "0.11.10"
5
5
  end
@@ -5,5 +5,6 @@
5
5
  "tick_size": "0.00000001",
6
6
  "quote_increment": "0.01",
7
7
  "min_order_size": "0.00001",
8
- "status": "open"
8
+ "status": "open",
9
+ "product_type": "spot"
9
10
  }
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "socket"
5
+
6
+ # A transport failure — the request got no HTTP answer at all — carries the chain of exception
7
+ # classes behind it, outermost first. The text of such a failure rarely says what happened
8
+ # ("end of file reached"); the chain does, and it is what lets a caller tell a request that never
9
+ # left (DNS, a refused connection, a proxy refusing CONNECT) from one that may have landed (EOF,
10
+ # reset, read timeout). Honeymaker only reports it; deciding what it means is the caller's job.
11
+ class Honeymaker::ErrorChainTest < Minitest::Test
12
+ def test_walks_wrapped_exceptions_and_causes_outermost_first
13
+ error = Faraday::ConnectionFailed.new(EOFError.new("end of file reached"))
14
+
15
+ assert_equal %w[Faraday::ConnectionFailed EOFError], Honeymaker::Utils.error_chain(error)
16
+ end
17
+
18
+ def test_follows_ruby_causes_and_names_each_class_once
19
+ error = begin
20
+ begin
21
+ raise Errno::ECONNREFUSED
22
+ rescue Errno::ECONNREFUSED
23
+ raise Faraday::ConnectionFailed, "connection refused"
24
+ end
25
+ rescue Faraday::ConnectionFailed => e
26
+ e
27
+ end
28
+
29
+ assert_equal %w[Faraday::ConnectionFailed Errno::ECONNREFUSED], Honeymaker::Utils.error_chain(error)
30
+ end
31
+
32
+ # Adapters re-wrap: a class seen once must not hide what sits beneath its second instance.
33
+ def test_a_repeated_class_does_not_cut_the_chain_short
34
+ inner = Faraday::ConnectionFailed.new(Errno::ECONNREFUSED.new)
35
+ error = Faraday::ConnectionFailed.new(inner)
36
+
37
+ assert_equal %w[Faraday::ConnectionFailed Errno::ECONNREFUSED], Honeymaker::Utils.error_chain(error)
38
+ end
39
+
40
+ def test_client_failure_carries_the_chain_and_keeps_its_text
41
+ result = Honeymaker::Client.new.send(:with_rescue) do
42
+ raise Faraday::ConnectionFailed, EOFError.new("end of file reached")
43
+ end
44
+
45
+ assert_equal ["end of file reached"], result.errors
46
+ assert_nil result.data[:status]
47
+ assert_equal %w[Faraday::ConnectionFailed EOFError], result.data[:error_chain]
48
+ end
49
+
50
+ def test_exchange_failure_carries_the_chain_and_keeps_its_text
51
+ result = Honeymaker::Exchange.new.send(:with_rescue) do
52
+ raise Faraday::ConnectionFailed, EOFError.new("end of file reached")
53
+ end
54
+
55
+ assert_equal ["end of file reached"], result.errors
56
+ assert_equal %w[Faraday::ConnectionFailed EOFError], result.data[:error_chain]
57
+ end
58
+
59
+ # An HTTP answer is not a transport failure: its status says what happened.
60
+ def test_a_failure_with_an_http_answer_has_no_chain
61
+ error = Faraday::ServerError.new("boom", { status: 503, body: "unavailable" })
62
+ [Honeymaker::Client.new, Honeymaker::Exchange.new].each do |receiver|
63
+ result = receiver.send(:with_rescue) { raise error }
64
+
65
+ assert_nil result.data[:error_chain], receiver.class.name
66
+ end
67
+ end
68
+
69
+ # --- The chains the real adapter produces, against local sockets -------------------------------
70
+
71
+ def setup
72
+ @servers = []
73
+ end
74
+
75
+ # Closing a server makes its blocked #accept raise, which ends the serving thread; joining it
76
+ # keeps that thread from outliving the test.
77
+ def teardown
78
+ @servers.each do |server, thread|
79
+ server.close
80
+ thread.join(1)
81
+ end
82
+ end
83
+
84
+ def test_real_chain_for_a_connection_closed_before_the_answer
85
+ port = local_server
86
+
87
+ assert_equal %w[Faraday::ConnectionFailed EOFError], chain_of("http://127.0.0.1:#{port}")
88
+ end
89
+
90
+ def test_real_chain_for_a_refused_connection
91
+ port = TCPServer.new("127.0.0.1", 0).then { |s| s.addr[1].tap { s.close } }
92
+
93
+ assert_includes chain_of("http://127.0.0.1:#{port}"), "Errno::ECONNREFUSED"
94
+ end
95
+
96
+ def test_real_chain_for_a_proxy_refusing_connect
97
+ port = local_server("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n")
98
+ client = Honeymaker::Client.new(proxy: "http://127.0.0.1:#{port}")
99
+ result = client.send(:with_rescue) { client.send(:build_client_connection, "https://example.com").get("/") }
100
+
101
+ assert_equal %w[Faraday::ConnectionFailed Net::HTTPClientException], result.data[:error_chain]
102
+ end
103
+
104
+ private
105
+
106
+ # A local server that reads each request and answers with `reply`, or hangs up without an answer
107
+ # when there is none. Returns its port; teardown closes it.
108
+ def local_server(reply = nil)
109
+ server = TCPServer.new("127.0.0.1", 0)
110
+ thread = Thread.new do
111
+ loop do
112
+ client = server.accept
113
+ begin
114
+ client.readpartial(4096)
115
+ client.write(reply) if reply
116
+ rescue EOFError, SystemCallError
117
+ nil
118
+ ensure
119
+ client.close
120
+ end
121
+ end
122
+ rescue IOError, SystemCallError
123
+ nil # the server was closed in teardown
124
+ end
125
+ @servers << [server, thread]
126
+ server.addr[1]
127
+ end
128
+
129
+ def chain_of(url)
130
+ client = Honeymaker::Client.new
131
+ client.send(:with_rescue) { client.send(:build_client_connection, url).get("/") }.data[:error_chain]
132
+ end
133
+ 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
@@ -13,15 +13,7 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
13
13
  end
14
14
 
15
15
  def test_get_tickers_info_parses_response
16
- symbols_body = load_fixture("gemini_symbols.json")
17
- detail_body = load_fixture("gemini_symbol_detail.json")
18
-
19
- symbols_response = stub(body: symbols_body)
20
- detail_response = stub(body: detail_body)
21
- connection = stub
22
- connection.stubs(:get).with("/v1/symbols").returns(symbols_response)
23
- connection.stubs(:get).with { |path| path.start_with?("/v1/symbols/details/") }.returns(detail_response)
24
- @exchange.instance_variable_set(:@connection, connection)
16
+ stub_catalogue(load_fixture("gemini_symbols.json"))
25
17
 
26
18
  result = @exchange.get_tickers_info
27
19
 
@@ -52,7 +44,7 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
52
44
  connection = stub_catalogue(%w[btcusd ethusd])
53
45
  connection.stubs(:get).with("/v1/symbols/details/ethusd")
54
46
  .raises(Faraday::ConnectionFailed, "end of file reached")
55
- .then.returns(detail_response)
47
+ .then.returns(stub(body: detail_for("ethusd")))
56
48
 
57
49
  result = @exchange.get_tickers_info
58
50
 
@@ -84,28 +76,65 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
84
76
  end
85
77
  end
86
78
 
87
- # All or nothing: data-api reads a pair missing from the catalogue as delisted, so a symbol that
88
- # keeps failing must fail the whole catalogue, and nothing after it is fetched.
89
- 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
90
81
  connection = stub_catalogue(%w[btcusd ethusd solusd])
91
82
  connection.expects(:get).with("/v1/symbols/details/ethusd").times(3)
92
83
  .raises(Faraday::ConnectionFailed, "end of file reached")
93
- connection.expects(:get).with("/v1/symbols/details/solusd").never
94
84
 
95
85
  result = @exchange.get_tickers_info
96
86
 
97
- assert result.failure?
98
- assert_nil result.data
99
- assert_equal ["end of file reached"], result.errors
100
- 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
101
91
  end
102
92
 
103
93
  def test_get_tickers_info_does_not_retry_a_non_transient_error
104
- connection = stub_catalogue(%w[btcusd])
94
+ connection = stub_catalogue(%w[btcusd ethusd])
105
95
  connection.expects(:get).with("/v1/symbols/details/btcusd").once
106
96
  .raises(Faraday::ResourceNotFound, "404")
107
97
 
108
- 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
100
+ end
101
+
102
+ # Gemini lists perpetual swaps in /v1/symbols with the same base and quote as the spot pair
103
+ # (BTCGUSDPERP is BTC/GUSD). They are not spot instruments, and would take the spot pair's place.
104
+ def test_get_tickers_info_keeps_only_spot_instruments
105
+ connection = stub_catalogue(%w[btcusd btcusdperp])
106
+ connection.stubs(:get).with("/v1/symbols/details/btcusdperp")
107
+ .returns(stub(body: detail_body.merge("symbol" => "BTCUSDPERP", "product_type" => "swap")))
108
+
109
+ result = @exchange.get_tickers_info
110
+
111
+ assert result.success?
112
+ assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
113
+ end
114
+
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
118
+ connection = stub_catalogue(%w[btcusd ethusd])
119
+ connection.stubs(:get).with("/v1/symbols/details/ethusd")
120
+ .returns(stub(body: detail_body.except("product_type").merge("symbol" => "ETHUSD")))
121
+
122
+ result = @exchange.get_tickers_info
123
+
124
+ assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
125
+ assert_match(/ethusd: no product_type/, @exchange.unreadable_symbols["ETHUSD"])
126
+ end
127
+
128
+ def test_get_tickers_info_sets_aside_both_instruments_claiming_one_pair
129
+ connection = stub_catalogue(%w[btcusd btcusd2 ethusd])
130
+ connection.stubs(:get).with("/v1/symbols/details/btcusd2")
131
+ .returns(stub(body: detail_body.merge("symbol" => "BTCUSD2")))
132
+
133
+ result = @exchange.get_tickers_info
134
+
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"])
109
138
  end
110
139
 
111
140
  def test_get_bid_ask_parses_response
@@ -121,19 +150,31 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
121
150
 
122
151
  private
123
152
 
153
+ def detail_body
154
+ load_fixture("gemini_symbol_detail.json")
155
+ end
156
+
124
157
  def detail_response
125
- stub(body: load_fixture("gemini_symbol_detail.json"))
158
+ stub(body: detail_body)
126
159
  end
127
160
 
128
161
  # Every symbol resolves to the one detail fixture; a test overrides a path to make it fail.
162
+ # Each symbol answers with the fixture's details for its own pair ("ethusd" is ETH/USD), since a
163
+ # catalogue may not hold two instruments for one pair.
129
164
  def stub_catalogue(symbols)
130
165
  connection = stub
131
166
  connection.stubs(:get).with("/v1/symbols").returns(stub(body: symbols))
132
- connection.stubs(:get).with { |path| path.start_with?("/v1/symbols/details/") }.returns(detail_response)
167
+ symbols.each do |symbol|
168
+ connection.stubs(:get).with("/v1/symbols/details/#{symbol}").returns(stub(body: detail_for(symbol)))
169
+ end
133
170
  @exchange.instance_variable_set(:@connection, connection)
134
171
  connection
135
172
  end
136
173
 
174
+ def detail_for(symbol)
175
+ detail_body.merge("symbol" => symbol.upcase, "base_currency" => symbol[0, 3], "quote_currency" => symbol[3..])
176
+ end
177
+
137
178
  def stub_connection(body)
138
179
  response = stub(body: body)
139
180
  connection = stub
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.6
4
+ version: 0.11.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger
@@ -170,6 +170,7 @@ files:
170
170
  - test/honeymaker/clients/kucoin_client_test.rb
171
171
  - test/honeymaker/clients/mexc_client_test.rb
172
172
  - test/honeymaker/clients/validation_test.rb
173
+ - test/honeymaker/error_chain_test.rb
173
174
  - test/honeymaker/exchange_test.rb
174
175
  - test/honeymaker/exchanges/binance_test.rb
175
176
  - test/honeymaker/exchanges/binance_us_test.rb
@@ -179,6 +180,7 @@ files:
179
180
  - test/honeymaker/exchanges/bitvavo_test.rb
180
181
  - test/honeymaker/exchanges/bybit_test.rb
181
182
  - test/honeymaker/exchanges/coinbase_test.rb
183
+ - test/honeymaker/exchanges/gemini_catalogue_test.rb
182
184
  - test/honeymaker/exchanges/gemini_test.rb
183
185
  - test/honeymaker/exchanges/hyperliquid_test.rb
184
186
  - test/honeymaker/exchanges/kraken_test.rb