honeymaker 0.11.5 → 0.11.9
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/lib/honeymaker/client.rb +8 -1
- data/lib/honeymaker/exchange.rb +9 -1
- data/lib/honeymaker/exchanges/gemini.rb +50 -4
- data/lib/honeymaker/utils.rb +19 -0
- data/lib/honeymaker/version.rb +1 -1
- data/test/fixtures/gemini_symbol_detail.json +2 -1
- data/test/honeymaker/error_chain_test.rb +133 -0
- data/test/honeymaker/exchanges/gemini_test.rb +134 -9
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e6f44f3c6ed35e460dcb6b6d53a73bebec433c34ad1109ab8b1531f799ad93b0
|
|
4
|
+
data.tar.gz: cd55a0b081c84f712e67ba00ede7633764592b0ff97d2b47fa50f29fc7c0cdd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32e67280cabf17a5a7305660b92ad2ff2eda948fb947a50a771f6bbd102f7cd9cbbeefdda50b09c16d1087bdd637c4649ba25963046828c9dcbd73eec87c5a4b
|
|
7
|
+
data.tar.gz: 01b15492c985691a7fcde609a56f3f6e197febbf7fdaaace27186ecb66268a128f796e99ca2a28aef7700c7e56bedb753ceff557400c7f54e142b07a7cb36a20
|
data/lib/honeymaker/client.rb
CHANGED
|
@@ -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:
|
|
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
|
data/lib/honeymaker/exchange.rb
CHANGED
|
@@ -71,13 +71,21 @@ module Honeymaker
|
|
|
71
71
|
|
|
72
72
|
private
|
|
73
73
|
|
|
74
|
+
# No HTTP answer means the transport failed, and its text rarely says how ("end of file
|
|
75
|
+
# reached"). The chain of exception classes does — whether the request never left or may have
|
|
76
|
+
# landed — so it travels with the failure. Facts only: what they mean is the caller's call.
|
|
77
|
+
def transport_data(error, status)
|
|
78
|
+
status.nil? ? { status: nil, error_chain: Utils.error_chain(error) } : { status: status }
|
|
79
|
+
end
|
|
80
|
+
|
|
74
81
|
def with_rescue
|
|
75
82
|
Result::Success.new(yield)
|
|
76
83
|
rescue Faraday::Error => e
|
|
77
84
|
body = e.respond_to?(:response_body) ? e.response_body : nil
|
|
78
85
|
error_message = (body && !body.empty?) ? body : e.message.to_s
|
|
79
86
|
error_message = "Unknown API error" if error_message.nil? || error_message.empty?
|
|
80
|
-
|
|
87
|
+
status = e.respond_to?(:response_status) ? e.response_status : nil
|
|
88
|
+
Result::Failure.new(error_message, data: transport_data(e, status))
|
|
81
89
|
rescue StandardError => e
|
|
82
90
|
msg = e.message
|
|
83
91
|
Result::Failure.new((msg && !msg.empty?) ? msg : "Unknown error")
|
|
@@ -5,13 +5,25 @@ module Honeymaker
|
|
|
5
5
|
class Gemini < Exchange
|
|
6
6
|
BASE_URL = "https://api.gemini.com"
|
|
7
7
|
|
|
8
|
+
# Gemini has no bulk symbol-details endpoint, so the catalogue is one request per symbol --
|
|
9
|
+
# ~350 of them against a public limit of 120/min. Unpaced they ran at ~208/min, and a single
|
|
10
|
+
# dropped connection failed the whole catalogue: the adapter retries nothing on its own.
|
|
11
|
+
REQUEST_INTERVAL = 0.5
|
|
12
|
+
REQUEST_ATTEMPTS = 3
|
|
13
|
+
TRANSIENT_ERRORS = [
|
|
14
|
+
Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::RequestTimeoutError,
|
|
15
|
+
Faraday::TooManyRequestsError, Faraday::ServerError
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
8
18
|
def get_tickers_info
|
|
9
19
|
with_rescue do
|
|
10
|
-
|
|
11
|
-
symbols = symbols_response.body
|
|
20
|
+
symbols = catalogue_get("/v1/symbols")
|
|
12
21
|
|
|
13
|
-
symbols.filter_map do |symbol|
|
|
14
|
-
|
|
22
|
+
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)
|
|
15
27
|
|
|
16
28
|
tick_size = detail["tick_size"]&.to_s || "0.01"
|
|
17
29
|
quote_increment = detail["quote_increment"]&.to_s || "0.01"
|
|
@@ -31,6 +43,7 @@ module Honeymaker
|
|
|
31
43
|
trading_enabled: detail["status"] == "open"
|
|
32
44
|
}
|
|
33
45
|
end
|
|
46
|
+
reject_shared_pairs!(tickers)
|
|
34
47
|
end
|
|
35
48
|
end
|
|
36
49
|
|
|
@@ -50,6 +63,39 @@ module Honeymaker
|
|
|
50
63
|
def connection
|
|
51
64
|
@connection ||= build_connection(BASE_URL)
|
|
52
65
|
end
|
|
66
|
+
|
|
67
|
+
# Gemini lists perpetual swaps beside spot, under the same base and quote (BTCGUSDPERP is
|
|
68
|
+
# 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.
|
|
70
|
+
def spot?(symbol, detail)
|
|
71
|
+
product_type = detail["product_type"]
|
|
72
|
+
raise Error, "Gemini #{symbol}: no product_type in its details" unless product_type.is_a?(String)
|
|
73
|
+
|
|
74
|
+
product_type == "spot"
|
|
75
|
+
end
|
|
76
|
+
|
|
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 ')}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def catalogue_get(path)
|
|
87
|
+
attempt = 1
|
|
88
|
+
begin
|
|
89
|
+
sleep(REQUEST_INTERVAL)
|
|
90
|
+
connection.get(path).body
|
|
91
|
+
rescue *TRANSIENT_ERRORS
|
|
92
|
+
raise if attempt >= REQUEST_ATTEMPTS
|
|
93
|
+
|
|
94
|
+
sleep(2**attempt)
|
|
95
|
+
attempt += 1
|
|
96
|
+
retry
|
|
97
|
+
end
|
|
98
|
+
end
|
|
53
99
|
end
|
|
54
100
|
end
|
|
55
101
|
end
|
data/lib/honeymaker/utils.rb
CHANGED
|
@@ -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" },
|
data/lib/honeymaker/version.rb
CHANGED
|
@@ -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
|
|
@@ -7,18 +7,13 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
|
|
|
7
7
|
|
|
8
8
|
def setup
|
|
9
9
|
@exchange = Honeymaker::Exchanges::Gemini.new
|
|
10
|
+
# Recorded, not slept: the pacing and the backoff are asserted as a sequence.
|
|
11
|
+
sleeps = @sleeps = []
|
|
12
|
+
@exchange.define_singleton_method(:sleep) { |seconds| sleeps << seconds }
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def test_get_tickers_info_parses_response
|
|
13
|
-
|
|
14
|
-
detail_body = load_fixture("gemini_symbol_detail.json")
|
|
15
|
-
|
|
16
|
-
symbols_response = stub(body: symbols_body)
|
|
17
|
-
detail_response = stub(body: detail_body)
|
|
18
|
-
connection = stub
|
|
19
|
-
connection.stubs(:get).with("/v1/symbols").returns(symbols_response)
|
|
20
|
-
connection.stubs(:get).with { |path| path.start_with?("/v1/symbols/details/") }.returns(detail_response)
|
|
21
|
-
@exchange.instance_variable_set(:@connection, connection)
|
|
16
|
+
stub_catalogue(load_fixture("gemini_symbols.json"))
|
|
22
17
|
|
|
23
18
|
result = @exchange.get_tickers_info
|
|
24
19
|
|
|
@@ -38,6 +33,111 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
|
|
|
38
33
|
assert ticker[:trading_enabled]
|
|
39
34
|
end
|
|
40
35
|
|
|
36
|
+
def test_get_tickers_info_paces_every_request
|
|
37
|
+
stub_catalogue(%w[btcusd ethusd])
|
|
38
|
+
|
|
39
|
+
assert @exchange.get_tickers_info.success?
|
|
40
|
+
assert_equal [0.5, 0.5, 0.5], @sleeps
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_get_tickers_info_retries_a_dropped_detail_request
|
|
44
|
+
connection = stub_catalogue(%w[btcusd ethusd])
|
|
45
|
+
connection.stubs(:get).with("/v1/symbols/details/ethusd")
|
|
46
|
+
.raises(Faraday::ConnectionFailed, "end of file reached")
|
|
47
|
+
.then.returns(stub(body: detail_for("ethusd")))
|
|
48
|
+
|
|
49
|
+
result = @exchange.get_tickers_info
|
|
50
|
+
|
|
51
|
+
assert result.success?
|
|
52
|
+
assert_equal 2, result.data.size
|
|
53
|
+
assert_equal [0.5, 0.5, 0.5, 2, 0.5], @sleeps
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_get_tickers_info_retries_the_symbols_request
|
|
57
|
+
connection = stub_catalogue(%w[btcusd])
|
|
58
|
+
connection.stubs(:get).with("/v1/symbols")
|
|
59
|
+
.raises(Faraday::TooManyRequestsError, "429")
|
|
60
|
+
.then.returns(stub(body: %w[btcusd]))
|
|
61
|
+
|
|
62
|
+
result = @exchange.get_tickers_info
|
|
63
|
+
|
|
64
|
+
assert result.success?
|
|
65
|
+
assert_equal 1, result.data.size
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_get_tickers_info_retries_every_transient_error_class
|
|
69
|
+
Honeymaker::Exchanges::Gemini::TRANSIENT_ERRORS.each do |error_class|
|
|
70
|
+
setup
|
|
71
|
+
connection = stub_catalogue(%w[btcusd])
|
|
72
|
+
connection.stubs(:get).with("/v1/symbols/details/btcusd")
|
|
73
|
+
.raises(error_class, "transient").then.returns(detail_response)
|
|
74
|
+
|
|
75
|
+
assert @exchange.get_tickers_info.success?, "#{error_class} should be retried"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
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
|
|
82
|
+
connection = stub_catalogue(%w[btcusd ethusd solusd])
|
|
83
|
+
connection.expects(:get).with("/v1/symbols/details/ethusd").times(3)
|
|
84
|
+
.raises(Faraday::ConnectionFailed, "end of file reached")
|
|
85
|
+
connection.expects(:get).with("/v1/symbols/details/solusd").never
|
|
86
|
+
|
|
87
|
+
result = @exchange.get_tickers_info
|
|
88
|
+
|
|
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
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_get_tickers_info_does_not_retry_a_non_transient_error
|
|
96
|
+
connection = stub_catalogue(%w[btcusd])
|
|
97
|
+
connection.expects(:get).with("/v1/symbols/details/btcusd").once
|
|
98
|
+
.raises(Faraday::ResourceNotFound, "404")
|
|
99
|
+
|
|
100
|
+
assert @exchange.get_tickers_info.failure?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Gemini lists perpetual swaps in /v1/symbols with the same base and quote as the spot pair
|
|
104
|
+
# (BTCGUSDPERP is BTC/GUSD). They are not spot instruments, and would take the spot pair's place.
|
|
105
|
+
def test_get_tickers_info_keeps_only_spot_instruments
|
|
106
|
+
connection = stub_catalogue(%w[btcusd btcusdperp])
|
|
107
|
+
connection.stubs(:get).with("/v1/symbols/details/btcusdperp")
|
|
108
|
+
.returns(stub(body: detail_body.merge("symbol" => "BTCUSDPERP", "product_type" => "swap")))
|
|
109
|
+
|
|
110
|
+
result = @exchange.get_tickers_info
|
|
111
|
+
|
|
112
|
+
assert result.success?
|
|
113
|
+
assert_equal %w[BTCUSD], result.data.map { |t| t[:ticker] }
|
|
114
|
+
end
|
|
115
|
+
|
|
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
|
|
119
|
+
connection = stub_catalogue(%w[btcusd ethusd])
|
|
120
|
+
connection.stubs(:get).with("/v1/symbols/details/ethusd")
|
|
121
|
+
.returns(stub(body: detail_body.except("product_type").merge("symbol" => "ETHUSD")))
|
|
122
|
+
|
|
123
|
+
result = @exchange.get_tickers_info
|
|
124
|
+
|
|
125
|
+
assert result.failure?
|
|
126
|
+
assert_nil result.data
|
|
127
|
+
assert_match(/ethusd/, result.errors.first)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def test_get_tickers_info_fails_when_two_instruments_claim_one_pair
|
|
131
|
+
connection = stub_catalogue(%w[btcusd btcusd2])
|
|
132
|
+
connection.stubs(:get).with("/v1/symbols/details/btcusd2")
|
|
133
|
+
.returns(stub(body: detail_body.merge("symbol" => "BTCUSD2")))
|
|
134
|
+
|
|
135
|
+
result = @exchange.get_tickers_info
|
|
136
|
+
|
|
137
|
+
assert result.failure?
|
|
138
|
+
assert_match(%r{BTC/USD}, result.errors.first)
|
|
139
|
+
end
|
|
140
|
+
|
|
41
141
|
def test_get_bid_ask_parses_response
|
|
42
142
|
body = load_fixture("gemini_pubticker.json")
|
|
43
143
|
stub_connection(body)
|
|
@@ -51,6 +151,31 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
|
|
|
51
151
|
|
|
52
152
|
private
|
|
53
153
|
|
|
154
|
+
def detail_body
|
|
155
|
+
load_fixture("gemini_symbol_detail.json")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def detail_response
|
|
159
|
+
stub(body: detail_body)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Every symbol resolves to the one detail fixture; a test overrides a path to make it fail.
|
|
163
|
+
# Each symbol answers with the fixture's details for its own pair ("ethusd" is ETH/USD), since a
|
|
164
|
+
# catalogue may not hold two instruments for one pair.
|
|
165
|
+
def stub_catalogue(symbols)
|
|
166
|
+
connection = stub
|
|
167
|
+
connection.stubs(:get).with("/v1/symbols").returns(stub(body: symbols))
|
|
168
|
+
symbols.each do |symbol|
|
|
169
|
+
connection.stubs(:get).with("/v1/symbols/details/#{symbol}").returns(stub(body: detail_for(symbol)))
|
|
170
|
+
end
|
|
171
|
+
@exchange.instance_variable_set(:@connection, connection)
|
|
172
|
+
connection
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def detail_for(symbol)
|
|
176
|
+
detail_body.merge("symbol" => symbol.upcase, "base_currency" => symbol[0, 3], "quote_currency" => symbol[3..])
|
|
177
|
+
end
|
|
178
|
+
|
|
54
179
|
def stub_connection(body)
|
|
55
180
|
response = stub(body: body)
|
|
56
181
|
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.
|
|
4
|
+
version: 0.11.9
|
|
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
|