honeymaker 0.9.10 → 0.9.11

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: bc5bacd0fa2240b323691f78b301b4aadf6d4e13229a05ab1edb621e20aaa89d
4
- data.tar.gz: b1a21915e693afd3cadb1e40aa317991db4ea667f08f82974d9607d60770be2e
3
+ metadata.gz: 50e3e2d9451d404977937fddc4d265c25da9ca8ed848ba6d6c008f24e8e129c9
4
+ data.tar.gz: a949bcc4a042ce6076986ae76c19fdb4a4bea583f0934fd6e1a5548e492d8898
5
5
  SHA512:
6
- metadata.gz: d25a9d2f3a4626d684c10b0eda8eecd3c57ca037506869bce682e8c0f2516c61b1122b1536c899f729c7a2642ea3a3ba9427ef816cab8e8f7b1fede7818cf979
7
- data.tar.gz: 5e4f0f34aef020640c8d9deea8fb8b1bb596cbc0b37c477005313590d10a55b5c51f2d144e21dfad0ba182929e7a2df470026fb0a7da39e4b6e44f3a5221b699
6
+ metadata.gz: 9489aa1616d24f9d17b37c603b468426b0354d87aa27a16970165919859565a35195aef1a665348e6e1d24e3ca636eb23901d34c002a2a85f932a99d6a7335b1
7
+ data.tar.gz: 70f1ebd03b468460c76c28bd5fcc69b975d64562790cef14075e13236e647d2132294a13e2bf3267b822526c6ae7d9905e7c5d18bfe28a0ea4e5548a169e4daa
@@ -57,31 +57,55 @@ module Honeymaker
57
57
  Result::Success.new(balances)
58
58
  end
59
59
 
60
+ # Hyperliquid's orderStatus body is NESTED:
61
+ # { "status" => "order"|"unknownOid",
62
+ # "order" => { "order" => { coin, side, limitPx, sz(remaining), origSz, oid, timestamp, ... },
63
+ # "status" => <real order status>, "statusTimestamp" => ... } }
64
+ # The real status/sizes live under order["order"]/order["status"] — NOT the top level — and the
65
+ # body carries NO fills. So the ordered amount is origSz, executed is origSz - remaining sz, and the
66
+ # exact cost comes from a bounded userFillsByTime (only fetched when something actually executed,
67
+ # since userFillsByTime is API weight 20 vs orderStatus's weight 2).
60
68
  def order_status(user:, oid:)
61
69
  result = post_info({ type: "orderStatus", user: user, oid: oid })
62
70
  return result if result.failure?
63
71
 
64
72
  raw = result.data
65
- order = raw["order"] || {}
66
- fills = raw["fills"] || []
67
- status_str = raw["status"]
73
+ # A distinct not-found signal — aged-out orders are normal; the caller recovers fills / abandons.
74
+ return Result::Failure.new("unknownOid", data: { not_found: true }) if raw["status"] == "unknownOid"
75
+
76
+ wrapper = raw["order"] || {}
77
+ order = wrapper["order"] || {}
78
+ status_str = wrapper["status"]
68
79
 
69
80
  coin = order["coin"]
70
81
  side = order["side"] == "B" ? :buy : :sell
71
82
  limit_price = BigDecimal((order["limitPx"] || "0").to_s)
72
- ordered_size = BigDecimal((order["sz"] || "0").to_s)
73
-
74
- amount_exec = fills.sum { |f| BigDecimal(f["sz"].to_s) }
75
- quote_amount_exec = fills.sum { |f| BigDecimal(f["px"].to_s) * BigDecimal(f["sz"].to_s) }
76
- avg_price = amount_exec.positive? ? (quote_amount_exec / amount_exec) : limit_price
77
- avg_price = nil if avg_price.zero?
78
-
79
- status = parse_order_status(status_str)
83
+ ordered_size = BigDecimal((order["origSz"] || "0").to_s)
84
+ remaining_size = BigDecimal((order["sz"] || "0").to_s)
85
+ amount_exec = [ordered_size - remaining_size, BigDecimal("0")].max
86
+
87
+ quote_amount_exec = BigDecimal("0")
88
+ price = limit_price
89
+ if amount_exec.positive?
90
+ fills_result = order["timestamp"] ? user_fills_by_time(user: user, start_time: order["timestamp"]) : nil
91
+ # A FAILED exact-cost lookup (timeout / rate-limit) is PROPAGATED so the consumer's typed-error
92
+ # retry runs — never record an executed order with an estimated cost just because userFills
93
+ # blipped (that would silently corrupt accounting and skip the retry).
94
+ return fills_result if fills_result&.failure?
95
+
96
+ matched = Array(fills_result&.data).select { |f| f["oid"].to_s == oid.to_s }
97
+ matched_quote = matched.sum(BigDecimal("0")) { |f| BigDecimal(f["px"].to_s) * BigDecimal(f["sz"].to_s) }
98
+ # userFills SUCCEEDED but has no matching fill (aged out of the window) → estimate from the
99
+ # limit price so a filled order never reports quote_amount_exec 0. Only on success, never failure.
100
+ quote_amount_exec = matched_quote.positive? ? matched_quote : (limit_price * amount_exec)
101
+ price = quote_amount_exec / amount_exec
102
+ end
103
+ price = nil if price.nil? || price.zero?
80
104
 
81
105
  Result::Success.new({
82
- order_id: "#{coin}-#{oid}",
83
- status: status, side: side, order_type: :limit,
84
- price: avg_price, amount: ordered_size, quote_amount: nil,
106
+ order_id: "#{coin}-#{oid}", coin: coin,
107
+ status: parse_order_status(status_str), side: side, order_type: :limit,
108
+ price: price, amount: ordered_size, quote_amount: nil,
85
109
  amount_exec: amount_exec, quote_amount_exec: quote_amount_exec, raw: raw
86
110
  })
87
111
  end
@@ -133,13 +157,21 @@ module Honeymaker
133
157
 
134
158
  private
135
159
 
160
+ # Suffix-aware so the whole Hyperliquid cancel family (marginCanceled, scheduledCancel,
161
+ # reduceOnlyCanceled, siblingFilledCanceled, …) maps correctly. A triggered order has fired
162
+ # and become a live resting order → :open. An unmapped status is logged, never swallowed.
136
163
  def parse_order_status(status)
137
164
  case status
138
- when "open", "marginCanceled" then :open
139
165
  when "filled" then :closed
140
- when "canceled", "triggered", "rejected" then :cancelled
141
- when "unknownOid" then :unknown
142
- else :unknown
166
+ when "open", "triggered" then :open
167
+ else
168
+ str = status.to_s
169
+ if str.match?(/cancel/i) || str.match?(/reject/i)
170
+ :cancelled
171
+ else
172
+ @logger&.warn("[honeymaker] Unmapped Hyperliquid order status: #{status.inspect}")
173
+ :unknown
174
+ end
143
175
  end
144
176
  end
145
177
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.9.10"
4
+ VERSION = "0.9.11"
5
5
  end
@@ -30,10 +30,146 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
30
30
  assert result.success?
31
31
  end
32
32
 
33
- def test_order_status
34
- stub_connection(:post, { "status" => "filled" })
35
- result = @client.order_status(user: "0xabc", oid: 123)
33
+ # Hyperliquid's orderStatus body is NESTED: { "status" => "order", "order" => { "order" => {...},
34
+ # "status" => <real status>, ... } }. The real order status, coin, sizes live under order["order"]
35
+ # and order["status"] — NOT the top level. orderStatus carries NO "fills" key, so executed cost is
36
+ # sourced from a bounded userFillsByTime call (only when something actually executed).
37
+
38
+ def test_order_status_filled_parses_nested_shape_and_sources_cost_from_fills
39
+ # Filled: remaining sz == 0, origSz is the ordered amount, real status under the wrapper.
40
+ # statusTimestamp deliberately DIFFERS from the order timestamp so a parser that bounds
41
+ # userFillsByTime on the wrong field fails the .with below.
42
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00020",
43
+ timestamp: 1781698875556, status_timestamp: 1781699999999)
44
+ stub_connection(:post, order_body)
45
+ # Cost comes from a BOUNDED userFillsByTime (type + start == the ORDER timestamp), summing
46
+ # ONLY this oid's fills (the unrelated-oid fill must be excluded); two fills → VWAP price.
47
+ @client.expects(:user_fills_by_time)
48
+ .with(user: "0xabc", start_time: 1781698875556)
49
+ .returns(Honeymaker::Result::Success.new([
50
+ fill(oid: 123456789, px: "64000.0", sz: "0.00010"),
51
+ fill(oid: 123456789, px: "66000.0", sz: "0.00010"),
52
+ fill(oid: 999999999, px: "1.0", sz: "5.0") # other order
53
+ ]))
54
+
55
+ result = @client.order_status(user: "0xabc", oid: 123456789)
56
+
57
+ assert result.success?
58
+ data = result.data
59
+ assert_equal :closed, data[:status]
60
+ assert_equal "@142", data[:coin]
61
+ assert_equal :buy, data[:side]
62
+ assert_equal BigDecimal("0.00020"), data[:amount] # origSz, NOT remaining sz
63
+ assert_equal BigDecimal("0.00020"), data[:amount_exec] # origSz - sz, NOT the 5.0 unrelated fill
64
+ assert_equal BigDecimal("13.0"), data[:quote_amount_exec] # 6.4 + 6.6, unrelated fill excluded
65
+ assert_equal BigDecimal("65000"), data[:price] # 13.0 / 0.0002 volume-weighted
66
+ end
67
+
68
+ def test_order_status_filled_falls_back_to_limit_cost_when_no_matching_fill
69
+ # userFills can age out / return nothing for THIS oid — a filled order must NEVER report
70
+ # quote_amount_exec 0 (the job subtracts the quote delta from missed_quote_amount).
71
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00018", limit_px: "64689.0")
72
+ stub_connection(:post, order_body)
73
+ @client.expects(:user_fills_by_time)
74
+ .returns(Honeymaker::Result::Success.new([fill(oid: 111, px: "1.0", sz: "5.0")])) # no match
75
+
76
+ result = @client.order_status(user: "0xabc", oid: 123456789)
77
+
78
+ assert_equal :closed, result.data[:status]
79
+ assert_equal BigDecimal("64689.0") * BigDecimal("0.00018"), result.data[:quote_amount_exec]
80
+ assert_equal BigDecimal("64689.0"), result.data[:price]
81
+ end
82
+
83
+ def test_order_status_propagates_user_fills_failure_for_executed_order
84
+ # A transient userFills failure must NOT degrade to an estimated cost — propagate it so the
85
+ # consumer retries and the exact quote_amount_exec is eventually recorded.
86
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00018")
87
+ stub_connection(:post, order_body)
88
+ @client.expects(:user_fills_by_time).returns(Honeymaker::Result::Failure.new("Net::ReadTimeout"))
89
+
90
+ result = @client.order_status(user: "0xabc", oid: 123456789)
91
+
92
+ assert result.failure?
93
+ assert_includes result.errors, "Net::ReadTimeout"
94
+ end
95
+
96
+ def test_order_status_open_does_not_call_user_fills
97
+ # Resting order: sz == origSz, nothing executed → no weight-20 userFills call.
98
+ order_body = order_status_body(status: "open", sz: "0.00018", orig_sz: "0.00018")
99
+ connection = stub
100
+ connection.expects(:post).once.returns(stub(body: order_body)) # exactly one call — orderStatus only
101
+ @client.instance_variable_set(:@connection, connection)
102
+ @client.expects(:user_fills_by_time).never # gated on amount_exec > 0
103
+
104
+ result = @client.order_status(user: "0xabc", oid: 123456789)
105
+
106
+ assert result.success?
107
+ assert_equal :open, result.data[:status]
108
+ assert_equal BigDecimal("0"), result.data[:amount_exec]
109
+ assert_equal BigDecimal("64689.0"), result.data[:price] # falls back to the limit price
110
+ end
111
+
112
+ def test_order_status_partial_fill_is_open_with_executed_cost
113
+ order_body = order_status_body(status: "open", sz: "0.00010", orig_sz: "0.00018",
114
+ timestamp: 1781698875556, status_timestamp: 1781699999999)
115
+ stub_connection(:post, order_body)
116
+ @client.expects(:user_fills_by_time)
117
+ .with(user: "0xabc", start_time: 1781698875556)
118
+ .returns(Honeymaker::Result::Success.new([fill(oid: 123456789, px: "64500.0", sz: "0.00008")]))
119
+
120
+ result = @client.order_status(user: "0xabc", oid: 123456789)
121
+
36
122
  assert result.success?
123
+ assert_equal :open, result.data[:status]
124
+ assert_equal BigDecimal("0.00008"), result.data[:amount_exec] # origSz - sz
125
+ assert_equal BigDecimal("64500.0") * BigDecimal("0.00008"), result.data[:quote_amount_exec]
126
+ end
127
+
128
+ def test_order_status_margin_canceled_maps_to_cancelled
129
+ stub_connection(:post, order_status_body(status: "marginCanceled", sz: "0.00018", orig_sz: "0.00018"))
130
+ result = @client.order_status(user: "0xabc", oid: 1)
131
+ assert_equal :cancelled, result.data[:status]
132
+ end
133
+
134
+ def test_order_status_scheduled_cancel_maps_to_cancelled
135
+ stub_connection(:post, order_status_body(status: "scheduledCancel", sz: "0.00018", orig_sz: "0.00018"))
136
+ result = @client.order_status(user: "0xabc", oid: 1)
137
+ assert_equal :cancelled, result.data[:status]
138
+ end
139
+
140
+ def test_order_status_reduce_only_canceled_maps_to_cancelled
141
+ # Suffix-aware /cancel/i covers the whole cancel family, not just the literals above.
142
+ stub_connection(:post, order_status_body(status: "reduceOnlyCanceled", sz: "0.00018", orig_sz: "0.00018"))
143
+ result = @client.order_status(user: "0xabc", oid: 1)
144
+ assert_equal :cancelled, result.data[:status]
145
+ end
146
+
147
+ def test_order_status_rejected_maps_to_cancelled
148
+ stub_connection(:post, order_status_body(status: "rejected", sz: "0.00018", orig_sz: "0.00018"))
149
+ result = @client.order_status(user: "0xabc", oid: 1)
150
+ assert_equal :cancelled, result.data[:status]
151
+ end
152
+
153
+ def test_order_status_unmapped_status_falls_back_to_unknown
154
+ # A genuinely new Hyperliquid status must surface as :unknown (and be logged), never crash.
155
+ stub_connection(:post, order_status_body(status: "someBrandNewStatus", sz: "0.00018", orig_sz: "0.00018"))
156
+ result = @client.order_status(user: "0xabc", oid: 1)
157
+ assert_equal :unknown, result.data[:status]
158
+ end
159
+
160
+ def test_order_status_triggered_maps_to_open
161
+ # A triggered order has fired and become a live resting order → still open.
162
+ stub_connection(:post, order_status_body(status: "triggered", sz: "0.00018", orig_sz: "0.00018"))
163
+ result = @client.order_status(user: "0xabc", oid: 1)
164
+ assert_equal :open, result.data[:status]
165
+ end
166
+
167
+ def test_order_status_unknown_oid_returns_not_found_signal
168
+ stub_connection(:post, { "status" => "unknownOid" })
169
+ result = @client.order_status(user: "0xabc", oid: 1)
170
+
171
+ assert result.failure?
172
+ assert_equal({ not_found: true }, result.data)
37
173
  end
38
174
 
39
175
  def test_open_orders
@@ -62,4 +198,25 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
62
198
  connection.stubs(method).returns(response)
63
199
  @client.instance_variable_set(:@connection, connection)
64
200
  end
201
+
202
+ def order_status_body(status:, sz:, orig_sz:, coin: "@142", side: "B", limit_px: "64689.0",
203
+ oid: 123456789, timestamp: 1781698875556, status_timestamp: 1781699999999)
204
+ {
205
+ "status" => "order",
206
+ "order" => {
207
+ "order" => {
208
+ "coin" => coin, "side" => side, "limitPx" => limit_px,
209
+ "sz" => sz, "origSz" => orig_sz, "oid" => oid, "timestamp" => timestamp,
210
+ "orderType" => "Limit", "tif" => "Gtc"
211
+ },
212
+ "status" => status,
213
+ "statusTimestamp" => status_timestamp
214
+ }
215
+ }
216
+ end
217
+
218
+ def fill(oid:, px:, sz:, coin: "@142", side: "B")
219
+ { "coin" => coin, "oid" => oid, "px" => px, "sz" => sz, "side" => side,
220
+ "time" => 1781698875556, "fee" => "0.011", "tid" => 99 }
221
+ end
65
222
  end
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.9.10
4
+ version: 0.9.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger