honeymaker 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2367ab9c6994586eb565f383f4b897d7a7bdcfb5cf68c0593da80f6b4d4b0497
4
- data.tar.gz: 585589c4bc524266bed905b142913b6072e5ff8824602cdf5691e6ac2046bf39
3
+ metadata.gz: 79df37d9e336e6aae8b38cbe4af40655742da763a47842b3b0128355130c2c48
4
+ data.tar.gz: 8a821ce866ad46433104a085375f5ac3073ac159c44e0b31f4f57170414a99fc
5
5
  SHA512:
6
- metadata.gz: b4d5824b776181fff4494c1f4fe76e8ff46e21faf198cb23a894ea836fcfb4bc4f3eb8c0eec2315cc072a065ce9de5b4fd9e34d50f52390368069c5fbd091b13
7
- data.tar.gz: f63b8235706bc5257bc1710939fc68f315316650966476a55ff4a25c1de35bb3035f42f54a94105fd1e446bcb4c6e684b65b2418bb16db68f7310c5bdb17dd16
6
+ metadata.gz: ea5bbaf9abffab526e1929b0c6e9d3ebc80af1e609fdb696a5c141585d57e643d99370acb1dcf458f6fe7c02df22c269b240e316ca68dfbd8295ef1b882ae4a4
7
+ data.tar.gz: 8dca53d930a98f7259f3c73d6e3827e40647fbf6ceaefd857c2795e1c499c630f335912c23a030a2a72aa326d578b1529d55bb825e2e54c08db56e794339da1f
@@ -130,13 +130,13 @@ module Honeymaker
130
130
  # --- Trading (requires hyperliquid-rb gem) ---
131
131
 
132
132
  def order(coin:, is_buy:, size:, limit_px:, order_type: { limit: { tif: "Gtc" } })
133
- with_rescue do
133
+ with_status_rescue do
134
134
  exchange_client.order(coin, is_buy: is_buy, sz: size, limit_px: limit_px, order_type: order_type)
135
135
  end
136
136
  end
137
137
 
138
138
  def cancel(coin:, oid:)
139
- with_rescue do
139
+ with_status_rescue do
140
140
  exchange_client.cancel(coin, oid)
141
141
  end
142
142
  end
@@ -157,6 +157,28 @@ module Honeymaker
157
157
 
158
158
  private
159
159
 
160
+ # Trading does not go over this client's Faraday connection: it goes through hyperliquid-rb,
161
+ # which raises its own error for every non-2xx instead of answering. Client#with_rescue can
162
+ # only see an unlabelled StandardError there, so it flags it `client_error` — "no clean
163
+ # answer" — and a caller reading an order placement has to treat that as a request whose fate
164
+ # is unknown: nothing recorded as rejected, no retry, a line for a human to reconcile against
165
+ # the venue. But the status is right there on the exception, and it is exactly what tells a
166
+ # refusal (4xx — the gateway answered, nothing reached the matching engine) from a request the
167
+ # venue may still have taken (5xx). Hand it back in the same shape every other HTTP failure
168
+ # has, so one classifier covers both paths.
169
+ #
170
+ # Duck-typed on purpose: hyperliquid-rb is an optional dependency loaded only when trading is
171
+ # used, so its error classes cannot be named here. Anything without a status is classified
172
+ # exactly as it is everywhere else.
173
+ def with_status_rescue
174
+ Result::Success.new(yield)
175
+ rescue StandardError => e
176
+ status = e.respond_to?(:status) ? e.status.to_i : 0
177
+ return with_rescue { raise e } unless status.positive?
178
+
179
+ Result::Failure.new(e.message.to_s.empty? ? e.class.to_s : e.message, data: { status: status })
180
+ end
181
+
160
182
  # Suffix-aware so the whole Hyperliquid cancel family (marginCanceled, scheduledCancel,
161
183
  # reduceOnlyCanceled, siblingFilledCanceled, …) maps correctly. A triggered order has fired
162
184
  # and become a live resting order → :open. An unmapped status is logged, never swallowed.
@@ -5,13 +5,24 @@ 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
- symbols_response = connection.get("/v1/symbols")
11
- symbols = symbols_response.body
20
+ symbols = catalogue_get("/v1/symbols")
12
21
 
13
22
  symbols.filter_map do |symbol|
14
- detail = connection.get("/v1/symbols/details/#{symbol}").body
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}")
15
26
 
16
27
  tick_size = detail["tick_size"]&.to_s || "0.01"
17
28
  quote_increment = detail["quote_increment"]&.to_s || "0.01"
@@ -50,6 +61,20 @@ module Honeymaker
50
61
  def connection
51
62
  @connection ||= build_connection(BASE_URL)
52
63
  end
64
+
65
+ def catalogue_get(path)
66
+ attempt = 1
67
+ begin
68
+ sleep(REQUEST_INTERVAL)
69
+ connection.get(path).body
70
+ rescue *TRANSIENT_ERRORS
71
+ raise if attempt >= REQUEST_ATTEMPTS
72
+
73
+ sleep(2**attempt)
74
+ attempt += 1
75
+ retry
76
+ end
77
+ end
53
78
  end
54
79
  end
55
80
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.11.4"
4
+ VERSION = "0.11.6"
5
5
  end
@@ -190,6 +190,76 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
190
190
  assert result.success?
191
191
  end
192
192
 
193
+ # == Raised trading errors keep their HTTP status ==
194
+ #
195
+ # Order placement goes through hyperliquid-rb, which raises instead of answering. Whether the
196
+ # venue REFUSED the request or may have TAKEN it is the whole question for a caller deciding
197
+ # between "record a rejected order" and "a human has to go and look" — and the status is the only
198
+ # thing that answers it. It must survive the trip.
199
+
200
+ # hyperliquid-rb's ClientError/ServerError shape. Rebuilt here because the gem is an optional
201
+ # dependency this one does not carry.
202
+ class RaisedWithStatus < StandardError
203
+ attr_reader :status
204
+
205
+ def initialize(message, status:)
206
+ @status = status
207
+ super(message)
208
+ end
209
+ end
210
+
211
+ def test_order_reports_a_refusal_with_its_status
212
+ @client.stubs(:exchange_client).raises(RaisedWithStatus.new("HTTP 429: rate limited", status: 429))
213
+
214
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
215
+
216
+ assert result.failure?
217
+ assert_equal 429, result.data[:status]
218
+ assert_nil result.data[:client_error], "the venue answered — this is not an unknown outcome"
219
+ assert_equal ["HTTP 429: rate limited"], result.errors
220
+ end
221
+
222
+ # 5xx stays 5xx: a gateway that may have passed the order on is exactly the case a caller must
223
+ # keep treating as unresolved.
224
+ def test_order_reports_a_gateway_failure_with_its_status
225
+ @client.stubs(:exchange_client).raises(RaisedWithStatus.new("HTTP 502: bad gateway", status: 502))
226
+
227
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
228
+
229
+ assert result.failure?
230
+ assert_equal 502, result.data[:status]
231
+ end
232
+
233
+ def test_cancel_reports_a_refusal_with_its_status
234
+ @client.stubs(:exchange_client).raises(RaisedWithStatus.new("HTTP 422: unknown oid", status: 422))
235
+
236
+ result = @client.cancel(coin: "@142", oid: 1)
237
+
238
+ assert result.failure?
239
+ assert_equal 422, result.data[:status]
240
+ end
241
+
242
+ # Everything the venue did not answer is untouched: a signing crash inside the gem, a network
243
+ # failure, a bug here. Those stay flagged as ours and unclassifiable.
244
+ def test_order_leaves_a_statusless_error_classified_as_before
245
+ @client.stubs(:exchange_client).raises(TypeError, "String can't be coerced into Float")
246
+
247
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
248
+
249
+ assert result.failure?
250
+ assert result.data[:client_error], "no status means no answer from the venue"
251
+ assert_equal ["TypeError: String can't be coerced into Float"], result.errors
252
+ end
253
+
254
+ def test_order_wraps_an_accepted_placement
255
+ @client.stubs(:exchange_client).returns(stub(order: { "status" => "ok" }))
256
+
257
+ result = @client.order(coin: "@142", is_buy: true, size: 1, limit_px: 100)
258
+
259
+ assert result.success?
260
+ assert_equal({ "status" => "ok" }, result.data)
261
+ end
262
+
193
263
  private
194
264
 
195
265
  def stub_connection(method, body)
@@ -7,6 +7,9 @@ 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
@@ -38,6 +41,73 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
38
41
  assert ticker[:trading_enabled]
39
42
  end
40
43
 
44
+ def test_get_tickers_info_paces_every_request
45
+ stub_catalogue(%w[btcusd ethusd])
46
+
47
+ assert @exchange.get_tickers_info.success?
48
+ assert_equal [0.5, 0.5, 0.5], @sleeps
49
+ end
50
+
51
+ def test_get_tickers_info_retries_a_dropped_detail_request
52
+ connection = stub_catalogue(%w[btcusd ethusd])
53
+ connection.stubs(:get).with("/v1/symbols/details/ethusd")
54
+ .raises(Faraday::ConnectionFailed, "end of file reached")
55
+ .then.returns(detail_response)
56
+
57
+ result = @exchange.get_tickers_info
58
+
59
+ assert result.success?
60
+ assert_equal 2, result.data.size
61
+ assert_equal [0.5, 0.5, 0.5, 2, 0.5], @sleeps
62
+ end
63
+
64
+ def test_get_tickers_info_retries_the_symbols_request
65
+ connection = stub_catalogue(%w[btcusd])
66
+ connection.stubs(:get).with("/v1/symbols")
67
+ .raises(Faraday::TooManyRequestsError, "429")
68
+ .then.returns(stub(body: %w[btcusd]))
69
+
70
+ result = @exchange.get_tickers_info
71
+
72
+ assert result.success?
73
+ assert_equal 1, result.data.size
74
+ end
75
+
76
+ def test_get_tickers_info_retries_every_transient_error_class
77
+ Honeymaker::Exchanges::Gemini::TRANSIENT_ERRORS.each do |error_class|
78
+ setup
79
+ connection = stub_catalogue(%w[btcusd])
80
+ connection.stubs(:get).with("/v1/symbols/details/btcusd")
81
+ .raises(error_class, "transient").then.returns(detail_response)
82
+
83
+ assert @exchange.get_tickers_info.success?, "#{error_class} should be retried"
84
+ end
85
+ end
86
+
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
90
+ connection = stub_catalogue(%w[btcusd ethusd solusd])
91
+ connection.expects(:get).with("/v1/symbols/details/ethusd").times(3)
92
+ .raises(Faraday::ConnectionFailed, "end of file reached")
93
+ connection.expects(:get).with("/v1/symbols/details/solusd").never
94
+
95
+ result = @exchange.get_tickers_info
96
+
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
101
+ end
102
+
103
+ def test_get_tickers_info_does_not_retry_a_non_transient_error
104
+ connection = stub_catalogue(%w[btcusd])
105
+ connection.expects(:get).with("/v1/symbols/details/btcusd").once
106
+ .raises(Faraday::ResourceNotFound, "404")
107
+
108
+ assert @exchange.get_tickers_info.failure?
109
+ end
110
+
41
111
  def test_get_bid_ask_parses_response
42
112
  body = load_fixture("gemini_pubticker.json")
43
113
  stub_connection(body)
@@ -51,6 +121,19 @@ class Honeymaker::Exchanges::GeminiTest < Minitest::Test
51
121
 
52
122
  private
53
123
 
124
+ def detail_response
125
+ stub(body: load_fixture("gemini_symbol_detail.json"))
126
+ end
127
+
128
+ # Every symbol resolves to the one detail fixture; a test overrides a path to make it fail.
129
+ def stub_catalogue(symbols)
130
+ connection = stub
131
+ 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)
133
+ @exchange.instance_variable_set(:@connection, connection)
134
+ connection
135
+ end
136
+
54
137
  def stub_connection(body)
55
138
  response = stub(body: body)
56
139
  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
4
+ version: 0.11.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger