honeymaker 0.10.0 → 0.11.1
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 +23 -1
- data/lib/honeymaker/clients/bingx.rb +7 -1
- data/lib/honeymaker/clients/bitget.rb +3 -3
- data/lib/honeymaker/clients/kucoin.rb +3 -3
- data/lib/honeymaker/version.rb +1 -1
- data/test/honeymaker/client_test.rb +38 -1
- data/test/honeymaker/clients/bingx_client_test.rb +24 -0
- data/test/honeymaker/clients/bitget_client_test.rb +31 -0
- data/test/honeymaker/clients/kucoin_client_test.rb +31 -0
- 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: c0d4a030c628dda5a597efb6ff65b552ce961cfb855e7f6affa668a8f0e6f492
|
|
4
|
+
data.tar.gz: 43372bdcc5a896dcf83e7f8fcd913d091a1b2c79b61e03e2b1e430dc20821ee0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aabd7c7f6859af5e36fb7bc301a227603683b9505e78a306b3944b8fba8ed6c5df45c05a9846557b18c59f461bfe3601f7492ede5a02c6125a87a1d9d58e76fe
|
|
7
|
+
data.tar.gz: ef8db9f4be0d5d60aa65a0d9baa41283b20cf44ddf6f624b4c0617cc5fab6449ac77878b190d0dff2ebca848876461a500b95d6b53f39f9b465fa817567ad923
|
data/lib/honeymaker/client.rb
CHANGED
|
@@ -70,8 +70,30 @@ module Honeymaker
|
|
|
70
70
|
status = e.respond_to?(:response_status) ? e.response_status : nil
|
|
71
71
|
Result::Failure.new(error_message, data: { status: status })
|
|
72
72
|
rescue StandardError => e
|
|
73
|
+
# NOT an exchange error — a bug here, in a vendor gem, or in the caller, and it arrives in the
|
|
74
|
+
# same Result::Failure as a genuine venue rejection. Callers classify that text to decide "out
|
|
75
|
+
# of funds" / "bad key" / "safe to retry", so an unlabelled TypeError gets attributed to the
|
|
76
|
+
# exchange: a hyperliquid-rb signing crash sat in production order history for three months
|
|
77
|
+
# reading exactly like a venue rejection. Name the class so the true source is unmistakable,
|
|
78
|
+
# and flag it so a caller can refuse to classify it as the venue's at all.
|
|
79
|
+
#
|
|
80
|
+
# The class is a PREFIX, not a replacement: the network failures that also land here
|
|
81
|
+
# (Net::ReadTimeout, execution expired, connection refused) are matched by substring for
|
|
82
|
+
# transient retry, and those matches must keep working.
|
|
73
83
|
msg = e.message
|
|
74
|
-
|
|
84
|
+
labelled = (msg && !msg.empty?) ? "#{e.class}: #{msg}" : e.class.to_s
|
|
85
|
+
Result::Failure.new(labelled, data: { client_error: true })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A business error the exchange returned inside an HTTP-200 envelope (KuCoin's non-"200000"
|
|
89
|
+
# code, Bitget's non-"00000"). Keep the exchange's own code and message: callers classify on
|
|
90
|
+
# this text to tell insufficient funds from a bad key from a stale timestamp, so collapsing it
|
|
91
|
+
# to a bare constant silently disables every one of those checks and leaves the operator with
|
|
92
|
+
# an unactionable log line. Falls back to the constant only when the body carries no detail.
|
|
93
|
+
def api_error(exchange, body)
|
|
94
|
+
detail = body.is_a?(Hash) ? [body["code"], body["msg"] || body["message"]] : []
|
|
95
|
+
detail = detail.compact.map(&:to_s).reject(&:empty?).join(": ")
|
|
96
|
+
Result::Failure.new(detail.empty? ? "#{exchange} API error" : "#{exchange} API error #{detail}")
|
|
75
97
|
end
|
|
76
98
|
|
|
77
99
|
def connection
|
|
@@ -57,6 +57,11 @@ module Honeymaker
|
|
|
57
57
|
return result if result.failure?
|
|
58
58
|
|
|
59
59
|
raw = result.data
|
|
60
|
+
# BingX rejects with HTTP 200 and a non-zero `code`. Without this the rejection fell
|
|
61
|
+
# through as a Success carrying order_id "#{symbol}-" (nil id): the bot recorded a trade
|
|
62
|
+
# that was never placed, then polled a malformed id forever.
|
|
63
|
+
return api_error("BingX", raw) unless raw.is_a?(Hash) && raw["code"].to_i.zero?
|
|
64
|
+
|
|
60
65
|
order_id = raw.dig("data", "orderId") || raw.dig("data", "data", "orderId")
|
|
61
66
|
Result::Success.new({ order_id: "#{symbol}-#{order_id}", raw: raw })
|
|
62
67
|
end
|
|
@@ -66,8 +71,9 @@ module Honeymaker
|
|
|
66
71
|
symbol: symbol, orderId: order_id, clientOrderID: client_order_id
|
|
67
72
|
})
|
|
68
73
|
return result if result.failure?
|
|
74
|
+
return api_error("BingX", result.data) unless result.data.is_a?(Hash) && result.data["code"].to_i.zero?
|
|
69
75
|
|
|
70
|
-
raw = result.data.
|
|
76
|
+
raw = result.data.key?("data") ? result.data["data"] : result.data
|
|
71
77
|
Result::Success.new(normalize_order("#{symbol}-#{raw['orderId']}", raw))
|
|
72
78
|
end
|
|
73
79
|
|
|
@@ -44,7 +44,7 @@ module Honeymaker
|
|
|
44
44
|
result = get_account_assets
|
|
45
45
|
return result if result.failure?
|
|
46
46
|
|
|
47
|
-
return
|
|
47
|
+
return api_error("Bitget", result.data) unless result.data["code"] == "00000"
|
|
48
48
|
|
|
49
49
|
balances = {}
|
|
50
50
|
(result.data["data"] || []).each do |asset|
|
|
@@ -64,7 +64,7 @@ module Honeymaker
|
|
|
64
64
|
size: size, quoteSize: quote_size, price: price, force: force, clientOid: client_oid
|
|
65
65
|
})
|
|
66
66
|
return result if result.failure?
|
|
67
|
-
return
|
|
67
|
+
return api_error("Bitget", result.data) unless result.data["code"] == "00000"
|
|
68
68
|
|
|
69
69
|
order_id = result.data.dig("data", "orderId")
|
|
70
70
|
Result::Success.new({ order_id: "#{symbol}-#{order_id}", raw: result.data })
|
|
@@ -73,7 +73,7 @@ module Honeymaker
|
|
|
73
73
|
def get_order(order_id: nil, client_oid: nil)
|
|
74
74
|
result = get_signed("/api/v2/spot/trade/orderInfo", { orderId: order_id, clientOid: client_oid })
|
|
75
75
|
return result if result.failure?
|
|
76
|
-
return
|
|
76
|
+
return api_error("Bitget", result.data) unless result.data["code"] == "00000"
|
|
77
77
|
|
|
78
78
|
order_list = result.data["data"]
|
|
79
79
|
raw = order_list.is_a?(Array) ? order_list.first : order_list
|
|
@@ -39,7 +39,7 @@ module Honeymaker
|
|
|
39
39
|
result = get_accounts(type: type)
|
|
40
40
|
return result if result.failure?
|
|
41
41
|
|
|
42
|
-
return
|
|
42
|
+
return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
|
|
43
43
|
|
|
44
44
|
balances = {}
|
|
45
45
|
(result.data["data"] || []).each do |account|
|
|
@@ -61,7 +61,7 @@ module Honeymaker
|
|
|
61
61
|
timeInForce: time_in_force, stp: stp
|
|
62
62
|
})
|
|
63
63
|
return result if result.failure?
|
|
64
|
-
return
|
|
64
|
+
return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
|
|
65
65
|
|
|
66
66
|
order_id = result.data.dig("data", "orderId")
|
|
67
67
|
Result::Success.new({ order_id: order_id, raw: result.data })
|
|
@@ -70,7 +70,7 @@ module Honeymaker
|
|
|
70
70
|
def get_order(order_id:)
|
|
71
71
|
result = get_signed("/api/v1/orders/#{order_id}")
|
|
72
72
|
return result if result.failure?
|
|
73
|
-
return
|
|
73
|
+
return api_error("KuCoin", result.data) unless result.data["code"] == "200000"
|
|
74
74
|
|
|
75
75
|
raw = result.data["data"]
|
|
76
76
|
return Result::Failure.new("Order not found") unless raw
|
data/lib/honeymaker/version.rb
CHANGED
|
@@ -41,7 +41,44 @@ class Honeymaker::ClientTest < Minitest::Test
|
|
|
41
41
|
client = Honeymaker::Client.new
|
|
42
42
|
result = client.send(:with_rescue) { raise StandardError, "boom" }
|
|
43
43
|
assert result.failure?
|
|
44
|
-
assert_equal ["boom"], result.errors
|
|
44
|
+
assert_equal ["StandardError: boom"], result.errors
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A bug in this gem, in a vendor gem, or in the caller is NOT an exchange error, but it lands in
|
|
48
|
+
# the same Result::Failure. Callers classify failure text to decide "out of funds" / "bad key" /
|
|
49
|
+
# "retry me", so an unlabelled TypeError reads as something the venue said and gets filed and
|
|
50
|
+
# reported as one. Naming the class makes the true source unmistakable, and the flag lets a
|
|
51
|
+
# caller refuse to classify it at all.
|
|
52
|
+
def test_with_rescue_names_the_exception_class_for_non_api_errors
|
|
53
|
+
client = Honeymaker::Client.new
|
|
54
|
+
result = client.send(:with_rescue) { raise TypeError, "String can't be coerced into Float" }
|
|
55
|
+
assert result.failure?
|
|
56
|
+
assert_equal ["TypeError: String can't be coerced into Float"], result.errors
|
|
57
|
+
assert result.data[:client_error], "a local exception must be flagged, not passed off as the venue's"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_with_rescue_labels_an_empty_message_with_its_class
|
|
61
|
+
client = Honeymaker::Client.new
|
|
62
|
+
result = client.send(:with_rescue) { raise NoMethodError, "" }
|
|
63
|
+
assert result.failure?
|
|
64
|
+
assert_equal ["NoMethodError"], result.errors
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Network failures reach callers through this branch too, and the transient-retry matchers key
|
|
68
|
+
# off substrings of them. Prefixing must not break that.
|
|
69
|
+
def test_with_rescue_keeps_network_substrings_matchable
|
|
70
|
+
client = Honeymaker::Client.new
|
|
71
|
+
result = client.send(:with_rescue) { raise Net::ReadTimeout, "Net::ReadTimeout with #<TCPSocket:(closed)>" }
|
|
72
|
+
assert_includes result.errors.first, "Net::ReadTimeout"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# An exchange error is not ours: it must stay unflagged and unprefixed so the venue's own text
|
|
76
|
+
# reaches the classifiers verbatim.
|
|
77
|
+
def test_with_rescue_does_not_flag_api_errors
|
|
78
|
+
client = Honeymaker::Client.new
|
|
79
|
+
result = client.send(:with_rescue) { raise Faraday::TimeoutError, "timeout" }
|
|
80
|
+
assert result.failure?
|
|
81
|
+
assert_nil result.data[:client_error]
|
|
45
82
|
end
|
|
46
83
|
|
|
47
84
|
def test_hmac_sha256
|
|
@@ -41,6 +41,30 @@ class Honeymaker::Clients::BingXTest < Minitest::Test
|
|
|
41
41
|
assert result.success?
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# BingX rejects with HTTP 200 and a non-zero `code`. Without a guard the rejection was read as
|
|
45
|
+
# a placed order and handed back as order_id "BTC-USDT-" (nil id), so a bot recorded a trade
|
|
46
|
+
# that does not exist and then polled a malformed id forever.
|
|
47
|
+
def test_place_order_fails_on_nonzero_code
|
|
48
|
+
stub_connection(:post, { "code" => 100004, "msg" => "insufficient balance" })
|
|
49
|
+
result = @client.place_order(symbol: "BTC-USDT", side: "BUY", type: "MARKET", quantity: "0.001")
|
|
50
|
+
assert result.failure?
|
|
51
|
+
assert_includes result.errors.first, "insufficient balance"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_get_order_fails_on_nonzero_code
|
|
55
|
+
stub_connection(:get, { "code" => 80016, "msg" => "Order does not exist" })
|
|
56
|
+
result = @client.get_order(symbol: "BTC-USDT", order_id: "123")
|
|
57
|
+
assert result.failure?
|
|
58
|
+
assert_includes result.errors.first, "Order does not exist"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_place_order_succeeds_on_zero_code
|
|
62
|
+
stub_connection(:post, { "code" => 0, "data" => { "orderId" => "123" } })
|
|
63
|
+
result = @client.place_order(symbol: "BTC-USDT", side: "BUY", type: "MARKET", quantity: "0.001")
|
|
64
|
+
assert result.success?
|
|
65
|
+
assert_equal "BTC-USDT-123", result.data[:order_id]
|
|
66
|
+
end
|
|
67
|
+
|
|
44
68
|
def test_cancel_order
|
|
45
69
|
stub_connection(:post, { "data" => { "orderId" => "123" } })
|
|
46
70
|
result = @client.cancel_order(symbol: "BTC-USDT", order_id: "123")
|
|
@@ -104,6 +104,37 @@ class Honeymaker::Clients::BitgetTest < Minitest::Test
|
|
|
104
104
|
assert headers.key?(:"ACCESS-SIGN")
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Bitget signals business errors in an HTTP-200 envelope. Callers classify on this text
|
|
108
|
+
# (insufficient funds, bad key, stale timestamp), so the code and msg must survive.
|
|
109
|
+
def test_place_order_keeps_api_code_and_message
|
|
110
|
+
stub_connection(:post, { "code" => "43012", "msg" => "Insufficient balance" })
|
|
111
|
+
result = @client.place_order(symbol: "BTCUSDT", side: "buy", order_type: "market", quote_size: "100")
|
|
112
|
+
assert result.failure?
|
|
113
|
+
assert_includes result.errors.first, "Insufficient balance"
|
|
114
|
+
assert_includes result.errors.first, "43012"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_get_order_keeps_api_code_and_message
|
|
118
|
+
stub_connection(:get, { "code" => "40008", "msg" => "Request timestamp expired" })
|
|
119
|
+
result = @client.get_order(order_id: "BTCUSDT-123")
|
|
120
|
+
assert result.failure?
|
|
121
|
+
assert_includes result.errors.first, "Request timestamp expired"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def test_get_balances_keeps_api_code_and_message
|
|
125
|
+
stub_connection(:get, { "code" => "40012", "msg" => "Apikey does not exist" })
|
|
126
|
+
result = @client.get_balances
|
|
127
|
+
assert result.failure?
|
|
128
|
+
assert_includes result.errors.first, "Apikey does not exist"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def test_api_error_falls_back_when_body_carries_no_detail
|
|
132
|
+
stub_connection(:post, { "code" => nil })
|
|
133
|
+
result = @client.place_order(symbol: "BTCUSDT", side: "buy", order_type: "market", quote_size: "100")
|
|
134
|
+
assert result.failure?
|
|
135
|
+
assert_equal "Bitget API error", result.errors.first
|
|
136
|
+
end
|
|
137
|
+
|
|
107
138
|
private
|
|
108
139
|
|
|
109
140
|
def stub_connection(method, body)
|
|
@@ -107,6 +107,37 @@ class Honeymaker::Clients::KucoinTest < Minitest::Test
|
|
|
107
107
|
assert_equal expected, headers[:"KC-API-PASSPHRASE"]
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# KuCoin signals business errors in an HTTP-200 envelope. Callers classify on this text
|
|
111
|
+
# (insufficient funds, bad key, stale timestamp), so the code and msg must survive.
|
|
112
|
+
def test_place_order_keeps_api_code_and_message
|
|
113
|
+
stub_connection(:post, { "code" => "200004", "msg" => "Balance insufficient!" })
|
|
114
|
+
result = @client.place_order(client_oid: "c1", side: "buy", symbol: "BTC-USDT", type: "market", funds: "100")
|
|
115
|
+
assert result.failure?
|
|
116
|
+
assert_includes result.errors.first, "Balance insufficient!"
|
|
117
|
+
assert_includes result.errors.first, "200004"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def test_get_order_keeps_api_code_and_message
|
|
121
|
+
stub_connection(:get, { "code" => "400002", "msg" => "Invalid KC-API-TIMESTAMP." })
|
|
122
|
+
result = @client.get_order(order_id: "123")
|
|
123
|
+
assert result.failure?
|
|
124
|
+
assert_includes result.errors.first, "Invalid KC-API-TIMESTAMP."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_get_balances_keeps_api_code_and_message
|
|
128
|
+
stub_connection(:get, { "code" => "400003", "msg" => "KC-API-KEY not exists" })
|
|
129
|
+
result = @client.get_balances
|
|
130
|
+
assert result.failure?
|
|
131
|
+
assert_includes result.errors.first, "KC-API-KEY not exists"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def test_api_error_falls_back_when_body_carries_no_detail
|
|
135
|
+
stub_connection(:post, { "code" => nil })
|
|
136
|
+
result = @client.place_order(client_oid: "c1", side: "buy", symbol: "BTC-USDT", type: "market", funds: "100")
|
|
137
|
+
assert result.failure?
|
|
138
|
+
assert_equal "KuCoin API error", result.errors.first
|
|
139
|
+
end
|
|
140
|
+
|
|
110
141
|
private
|
|
111
142
|
|
|
112
143
|
def stub_connection(method, body)
|