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 +4 -4
- data/lib/honeymaker/client.rb +8 -1
- data/lib/honeymaker/exchange.rb +29 -1
- data/lib/honeymaker/exchanges/gemini.rb +76 -22
- 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_catalogue_test.rb +118 -0
- data/test/honeymaker/exchanges/gemini_test.rb +63 -22
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81ec4e430e403ef063e19f4c1a0827f9652bed081e7bdf845ba0f2c88b5c7a28
|
|
4
|
+
data.tar.gz: 779b9f7391e4259a711c478a2493445fecf88ef04bc46e64affdaf2b1267decb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2107a78f1bbec5167448470cfe8f26871c37fea3237be0ee1137d33cbe738ed9b23292ff99d35e1c9114f27a0634ba0aa315357cd126fd57f805919858a7c4eb
|
|
7
|
+
data.tar.gz: 1f694295c4ed6699728fd0fa500a5a081d1835b620a6b6d06e7ee3ab239804defe4bb8a9271ad638a0321e27aa5091dc6d5adbda5e69cfd97bdf89d9e310dd39
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
#
|
|
88
|
-
|
|
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.
|
|
98
|
-
|
|
99
|
-
assert_equal
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|