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 +4 -4
- data/lib/honeymaker/clients/hyperliquid.rb +50 -18
- data/lib/honeymaker/version.rb +1 -1
- data/test/honeymaker/clients/hyperliquid_client_test.rb +160 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50e3e2d9451d404977937fddc4d265c25da9ca8ed848ba6d6c008f24e8e129c9
|
|
4
|
+
data.tar.gz: a949bcc4a042ce6076986ae76c19fdb4a4bea583f0934fd6e1a5548e492d8898
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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["
|
|
73
|
-
|
|
74
|
-
amount_exec =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
84
|
-
price:
|
|
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 "
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
data/lib/honeymaker/version.rb
CHANGED
|
@@ -30,10 +30,146 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
|
|
|
30
30
|
assert result.success?
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|