honeymaker 0.12.3 → 0.12.5

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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +19 -4
  5. data/Cargo.lock +1078 -0
  6. data/Cargo.toml +14 -0
  7. data/README.md +39 -0
  8. data/Rakefile +28 -16
  9. data/crates/honeymaker/Cargo.toml +18 -0
  10. data/crates/honeymaker/src/encode.rs +106 -0
  11. data/crates/honeymaker/src/kraken/lookup.rs +140 -0
  12. data/crates/honeymaker/src/kraken/mod.rs +5 -0
  13. data/crates/honeymaker/src/kraken/normalize.rs +536 -0
  14. data/crates/honeymaker/src/kraken/requests.rs +286 -0
  15. data/crates/honeymaker/src/kraken/sign.rs +166 -0
  16. data/crates/honeymaker/src/kraken/trades.rs +201 -0
  17. data/crates/honeymaker/src/lib.rs +10 -0
  18. data/crates/honeymaker/src/num.rs +115 -0
  19. data/crates/honeymaker/src/semantics.rs +100 -0
  20. data/crates/honeymaker/src/types.rs +85 -0
  21. data/crates/honeymaker/tests/kraken_stage2.rs +592 -0
  22. data/crates/honeymaker/tests/kraken_trades_pager.rs +307 -0
  23. data/crates/honeymaker/tests/kraken_vectors.rs +78 -0
  24. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  25. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  26. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  27. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  28. data/crates/honeymaker-client/Cargo.toml +30 -0
  29. data/crates/honeymaker-client/examples/probe.rs +147 -0
  30. data/crates/honeymaker-client/src/kraken.rs +514 -0
  31. data/crates/honeymaker-client/src/lib.rs +4 -0
  32. data/crates/honeymaker-client/src/transport/connect.rs +583 -0
  33. data/crates/honeymaker-client/src/transport/io_timeout.rs +352 -0
  34. data/crates/honeymaker-client/src/transport/response.rs +169 -0
  35. data/crates/honeymaker-client/src/transport.rs +467 -0
  36. data/crates/honeymaker-client/tests/classification.rs +203 -0
  37. data/crates/honeymaker-client/tests/kraken_client.rs +949 -0
  38. data/crates/honeymaker-client/tests/response.rs +522 -0
  39. data/crates/honeymaker-client/tests/support/mod.rs +550 -0
  40. data/crates/honeymaker-client/tests/transport.rs +828 -0
  41. data/ext/honeymaker_native/Cargo.toml +16 -0
  42. data/ext/honeymaker_native/extconf.rb +6 -0
  43. data/ext/honeymaker_native/src/convert.rs +150 -0
  44. data/ext/honeymaker_native/src/kraken.rs +362 -0
  45. data/ext/honeymaker_native/src/lib.rs +90 -0
  46. data/ext/honeymaker_native/src/ruby_decimal.rs +90 -0
  47. data/lib/honeymaker/clients/bingx.rb +10 -6
  48. data/lib/honeymaker/clients/bitrue.rb +43 -13
  49. data/lib/honeymaker/clients/bitvavo.rb +9 -4
  50. data/lib/honeymaker/clients/bybit.rb +19 -2
  51. data/lib/honeymaker/clients/coinbase.rb +3 -2
  52. data/lib/honeymaker/clients/gemini.rb +10 -0
  53. data/lib/honeymaker/clients/hyperliquid.rb +62 -3
  54. data/lib/honeymaker/clients/kraken.rb +6 -1
  55. data/lib/honeymaker/clients/mexc.rb +16 -2
  56. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  57. data/lib/honeymaker/exchanges/mexc.rb +8 -3
  58. data/lib/honeymaker/native/kraken.rb +263 -0
  59. data/lib/honeymaker/native.rb +42 -0
  60. data/lib/honeymaker/version.rb +1 -1
  61. data/lib/honeymaker.rb +12 -2
  62. data/script/parity/kraken_client_live.rb +191 -0
  63. data/script/parity/kraken_live.rb +70 -0
  64. data/script/parity/kraken_transport.rb +604 -0
  65. data/script/parity/resolve.c +49 -0
  66. data/script/release/push_gems.sh +28 -0
  67. data/script/vectors/kraken.rb +50 -0
  68. data/script/vectors/kraken_stage2.rb +146 -0
  69. data/test/fixtures/coinbase_products.json +1 -1
  70. data/test/honeymaker/clients/bingx_client_test.rb +19 -6
  71. data/test/honeymaker/clients/bitrue_client_test.rb +48 -0
  72. data/test/honeymaker/clients/bitvavo_client_test.rb +15 -0
  73. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  74. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  75. data/test/honeymaker/clients/gemini_client_test.rb +15 -0
  76. data/test/honeymaker/clients/hyperliquid_client_test.rb +120 -4
  77. data/test/honeymaker/clients/kraken_client_test.rb +15 -0
  78. data/test/honeymaker/clients/mexc_client_test.rb +12 -0
  79. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  80. data/test/honeymaker/exchanges/mexc_test.rb +14 -2
  81. data/test/native/backend_switch_test.rb +53 -0
  82. data/test/native/convert_test.rb +137 -0
  83. data/test/native/gem_isolation_test.rb +17 -0
  84. data/test/native/loader_test.rb +29 -0
  85. data/test/native/lockstep_test.rb +23 -0
  86. data/test/native/signing_smoke_test.rb +28 -0
  87. data/test/parity/kraken_client_parity_test.rb +398 -0
  88. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  89. data/test/release/branch_hardening_test.rb +34 -0
  90. data/test/release/push_gems_test.rb +79 -0
  91. data/test/smoke.rb +24 -0
  92. data/test/support/kraken_signing_smoke.rb +21 -0
  93. data/test/support/parity_helper.rb +62 -0
  94. data/test/support/venue_double.rb +100 -0
  95. metadata +84 -4
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Push built gems: platform gems first, the source gem last. Idempotent: a gem already on
3
+ # rubygems.org is accepted only if byte-identical (a rerun after a partial publish).
4
+ set -euo pipefail
5
+ dir="${1:?gem dir}"
6
+ version="${2:?version}"
7
+ api="${RUBYGEMS_API:-https://rubygems.org}"
8
+ # Exactly the release set, or nothing is pushed: 5 platform gems + the source gem.
9
+ expected=(x86_64-linux aarch64-linux x86_64-darwin arm64-darwin x64-mingw-ucrt)
10
+ for plat in "${expected[@]}"; do
11
+ [ -f "$dir/honeymaker-$version-$plat.gem" ] || { echo "::error::missing honeymaker-$version-$plat.gem" >&2; exit 1; }
12
+ done
13
+ [ -f "$dir/honeymaker-$version.gem" ] || { echo "::error::missing honeymaker-$version.gem (source)" >&2; exit 1; }
14
+ count=$(find "$dir" -maxdepth 1 -name '*.gem' | wc -l | tr -d ' ')
15
+ [ "$count" = 6 ] || { echo "::error::expected 6 gems in $dir, found $count" >&2; exit 1; }
16
+ push() {
17
+ local g="$1" ver plat sha remote
18
+ if gem push "$g"; then return 0; fi
19
+ ver=$(ruby -rrubygems/package -e 'print Gem::Package.new(ARGV[0]).spec.version' "$g")
20
+ plat=$(ruby -rrubygems/package -e 'print Gem::Package.new(ARGV[0]).spec.platform' "$g")
21
+ sha=$(ruby -rdigest -e 'print Digest::SHA256.file(ARGV[0]).hexdigest' "$g")
22
+ remote=$(curl -fsS "$api/api/v2/rubygems/honeymaker/versions/${ver}.json?platform=${plat}" \
23
+ | ruby -rjson -e 'print JSON.parse($stdin.read)["sha"]')
24
+ if [ "$sha" != "$remote" ]; then echo "::error::$g differs from the published gem" >&2; return 1; fi
25
+ echo "$g already published with the same checksum"
26
+ }
27
+ for plat in "${expected[@]}"; do push "$dir/honeymaker-$version-$plat.gem"; done
28
+ push "$dir/honeymaker-$version.gem"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Regenerates crates/honeymaker/tests/vectors/*.json from the LEGACY Ruby implementation.
4
+ # bundle exec ruby -Ilib script/vectors/kraken.rb
5
+ require "honeymaker"
6
+ require "json"
7
+ require "bigdecimal"
8
+
9
+ out_dir = File.expand_path("../../crates/honeymaker/tests/vectors", __dir__)
10
+
11
+ secrets = [
12
+ Base64.strict_encode64("test_secret_key_1234567890123456"),
13
+ "dGVzd A==\n!!-_x", "abc", "=abc", "", " ", "a", "ab==cd", "not base64 at all ~~~",
14
+ Base64.strict_encode64((0..255).to_a.pack("C*"))
15
+ ]
16
+ bodies = [
17
+ [["nonce", "1727000000000001"]],
18
+ [["nonce", "1727000000000002"], ["ordertype", "market"], ["type", "buy"], ["volume", "0.001"], ["pair", "XBTUSDT"],
19
+ ["oflags", "viqc"], ["close[ordertype]", "limit"]],
20
+ [["nonce", "1727000000000003"], ["txid", "O1,O2"], ["consolidate_taker", "true"]],
21
+ [["nonce", "1727000000000004"], ["asset", "a b~*-._/,:[]\u00e9+&="]]
22
+ ]
23
+ paths = %w[/0/private/BalanceEx /0/private/AddOrder /0/private/QueryOrders /0/private/Earn/Allocations]
24
+
25
+ signing = []
26
+ secrets.each do |secret|
27
+ [["key", secret], ["", secret], ["key", ""]].each do |key, sec|
28
+ bodies.each_with_index do |pairs, i|
29
+ body = URI.encode_www_form(pairs)
30
+ c = Honeymaker::Clients::Kraken.new(api_key: key, api_secret: sec)
31
+ signing << { api_key: key, api_secret: sec, path: paths[i], pairs: pairs, body: body,
32
+ decoded_secret_hex: Base64.decode64(sec).unpack1("H*"),
33
+ headers: c.send(:private_headers, paths[i], body).transform_keys(&:to_s) }
34
+ end
35
+ end
36
+ end
37
+
38
+ decimal_inputs = ["0.5", "1", "-0", "-0.0000", "0.0000000001", "123456789012345678901234567890.123456789",
39
+ "1e-9", "1E5", " 1.5 ", "1_000.5", ".5", "5.", "+2", "", "abc", "1,5", "0x10", "NaN",
40
+ "Infinity", "-Infinity", "1d2", "true"]
41
+ decimals = decimal_inputs.map do |s|
42
+ v = BigDecimal(s)
43
+ { input: s, ok: true, finite: v.finite?, plain: v.finite? ? v.to_s("F") : v.to_s, sign: v.sign }
44
+ rescue ArgumentError => e
45
+ { input: s, ok: false, error: e.message }
46
+ end
47
+
48
+ File.write(File.join(out_dir, "kraken_signing.json"), JSON.pretty_generate(signing))
49
+ File.write(File.join(out_dir, "decimal.json"), JSON.pretty_generate(decimals))
50
+ puts "wrote #{signing.size} signing vectors, #{decimals.size} decimals"
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Regenerates the stage-2 vectors from the LEGACY Ruby implementation.
4
+ # bundle exec ruby -Ilib script/vectors/kraken_stage2.rb
5
+ require "honeymaker"
6
+ require "json"
7
+ require "faraday"
8
+
9
+ OUT = File.expand_path("../../crates/honeymaker/tests/vectors", __dir__)
10
+ SECRET = Base64.strict_encode64("test_secret_key_1234567890123456")
11
+ NONCE = 1_727_000_000_000_001
12
+
13
+ def legacy
14
+ c = Honeymaker::Clients::Kraken.new(api_key: "key", api_secret: SECRET)
15
+ c.define_singleton_method(:nonce) { NONCE }
16
+ c
17
+ end
18
+
19
+ # --- A: AddOrder with cl_ord_id and deadline, as legacy signs and sends it ---
20
+ orders = [
21
+ { ordertype: "market", type: "buy", volume: "0.0012", pair: "XBTEUR", oflags: ["viqc"],
22
+ cl_ord_id: "6f1c1a52-7c8e-4d0e-9a57-0b6f0f1d2e3a", deadline: "2026-09-30T12:00:10.000Z" },
23
+ { ordertype: "limit", type: "buy", volume: "0.00119", pair: "XBTEUR", price: "49870.1", oflags: [],
24
+ cl_ord_id: "0d9e2c1b-2f4a-4b8c-8d1e-5a6b7c8d9e0f", deadline: "2026-09-30T12:00:10.123Z" },
25
+ { ordertype: "market", type: "buy", volume: "60", pair: "XBTEUR", oflags: ["viqc"],
26
+ cl_ord_id: "6f1c1a52-7c8e-4d0e-9a57-0b6f0f1d2e3a", deadline: "2026-09-30T12:00:10.000Z", validate: true }
27
+ ]
28
+ wire = orders.map do |args|
29
+ body = nil
30
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
31
+ s.post("/0/private/AddOrder") do |env|
32
+ body = env.body
33
+ [200, { "Content-Type" => "application/json" }, '{"error":[],"result":{"txid":["OTX-1"]}}']
34
+ end
35
+ end
36
+ c = legacy
37
+ c.instance_variable_set(:@connection, Faraday.new(url: Honeymaker::Clients::Kraken::URL) { |f| f.response :json; f.adapter :test, stubs })
38
+ raise "legacy did not answer" unless c.add_order(**args).success?
39
+
40
+ { args: args, nonce: NONCE, api_key: "key", api_secret: SECRET, body: body,
41
+ headers: c.send(:private_headers, "/0/private/AddOrder", body).transform_keys(&:to_s) }
42
+ end
43
+ File.write(File.join(OUT, "kraken_add_order_wire.json"), JSON.pretty_generate(wire))
44
+ puts "wrote #{wire.size} AddOrder wire vectors"
45
+
46
+ # --- B: closed_orders_from_trades, the verbatim Ruby loop (with Task 8's dedupe), over scripted
47
+ # pages. Each case says what the STRICT Rust scan must do: "same" as Ruby (a completed scan, a
48
+ # refusal, an unreadable page or a Ruby exception), "incomplete" where Ruby stopped early or
49
+ # trusted a malformed container and returned what it had, or "error" where Ruby skipped a malformed
50
+ # record that Rust must refuse. The label is what Rust must do, not what Ruby did: some
51
+ # "incomplete" cases are Ruby raises (NoMethodError on trades as a non-empty array, a non-empty
52
+ # string or a number, and on count true or an array), not partial answers. Their "outcome" says so.
53
+ def page(trades, count = :none)
54
+ result = { "trades" => trades }
55
+ result["count"] = count unless count == :none
56
+ { "error" => [], "result" => result }
57
+ end
58
+
59
+ def trade(o, vol, cost, type: "buy", ordertype: "market", fee: "0.1")
60
+ { "ordertxid" => o, "vol" => vol, "cost" => cost, "fee" => fee, "type" => type, "ordertype" => ordertype, "time" => 1.5 }
61
+ end
62
+
63
+ T = ->(i, o = "O1") { { "T#{i}" => trade(o, "1", "2") } }
64
+ cases = [
65
+ ["one page", :same, %w[O1], 1_700_000_000, 20, [page({ "T1" => trade("O1", "0.0006", "30"), "T2" => trade("O1", "0.0006", "30") }, 2)]],
66
+ ["partial fill across pages, unwanted skipped", :same, %w[O1 O2], 1_700_000_000, 20,
67
+ [page({ "T1" => trade("O1", "1", "2"), "T2" => trade("O9", "1", "2") }, 3),
68
+ page({ "T3" => trade("O1", "1", "2"), "T4" => trade("O2", "2", "8", ordertype: "limit", type: "sell") }, 3)]],
69
+ ["no wanted ids: no request", :same, [], nil, 20, []],
70
+ ["a trade on two pages: rows reach count, distinct trades do not", :incomplete, %w[O1], nil, 20,
71
+ [page({ "T1" => trade("O1", "1", "2"), "T2" => trade("O1", "3", "9") }, 3), page({ "T2" => trade("O1", "3", "9") }, 3)]],
72
+ ["overlapping pages that cover every trade", :same, %w[O1], nil, 20,
73
+ [page({ "T1" => trade("O1", "1", "2"), "T2" => trade("O1", "3", "9") }, 3),
74
+ page({ "T2" => trade("O1", "3", "9"), "T3" => trade("O1", "1", "1") }, 3)]],
75
+ ["complete exactly at the page cap", :same, %w[O1], nil, 2, [page(T[1], 2), page(T[2], 2)]],
76
+ ["an empty page after the last trade", :same, %w[O1], nil, 20, [page({}, 0)]],
77
+ ["a fill beyond the page cap", :incomplete, %w[O1], nil, 2, [page(T[1, "O9"], 3), page(T[2, "O9"], 3), page(T[3], 3)]],
78
+ ["count as a string", :incomplete, %w[O1], nil, 20, [page(T[1], "2"), page(T[2], "2")]],
79
+ ["count 1_000 pages to max_pages", :incomplete, %w[O1], nil, 2, [page(T[1], "1_000"), page(T[2], "1_000"), page(T[3], "1_000")]],
80
+ ["bignum string count", :incomplete, %w[O1], nil, 3, [page(T[1], "99999999999999999999999"), page(T[2], "1"), page(T[3], "1")]],
81
+ ["bignum integer count", :incomplete, %w[O1], nil, 3, [page(T[1], 10**25), page(T[2], 10**25), page(T[3], 10**25), page(T[4], 10**25)]],
82
+ ["float count", :incomplete, %w[O1], nil, 20, [page(T[1], 2.9), page(T[2], 2.9), page(T[3], 2.9)]],
83
+ ["missing count", :incomplete, %w[O1], nil, 20, [page(T[1]), page(T[2])]],
84
+ ["nil count", :incomplete, %w[O1], nil, 20, [page(T[1], nil), page(T[2])]],
85
+ ["negative count", :incomplete, %w[O1], nil, 20, [page(T[1], -1), page(T[2])]],
86
+ *[" 3", "3abc", "0x10", "_3", "3__0", "+3", "-3", "", "abc"].map do |c|
87
+ ["count #{c.inspect}", :incomplete, %w[O1], nil, 4, (1..4).map { |i| page(T[i], c) }]
88
+ end,
89
+ ["empty trades before count", :incomplete, %w[O1], nil, 20, [page({}, 5), page(T[1], 5)]],
90
+ ["missing trades", :incomplete, %w[O1], nil, 20, [{ "error" => [], "result" => { "count" => 3 } }, page(T[1])]],
91
+ ["trades false", :incomplete, %w[O1], nil, 20, [page(false, 3), page(T[1])]],
92
+ ["trades []", :incomplete, %w[O1], nil, 20, [page([], 3), page(T[1])]],
93
+ ["trades \"\"", :incomplete, %w[O1], nil, 20, [page("", 3), page(T[1])]],
94
+ ["nil result", :incomplete, %w[O1], nil, 20, [{ "error" => [] }, page(T[1])]],
95
+ ["result as a string raises", :same, %w[O1], nil, 20, [{ "error" => [], "result" => "x" }]],
96
+ ["result as an array raises", :same, %w[O1], nil, 20, [{ "error" => [], "result" => [1] }]],
97
+ ["result false raises", :same, %w[O1], nil, 20, [{ "error" => [], "result" => false }]],
98
+ ["trades as a non-empty array", :incomplete, %w[O1], nil, 20, [page([1], 1)]],
99
+ ["trades as a non-empty string", :incomplete, %w[O1], nil, 20, [page("x", 1)]],
100
+ ["trades as a number", :incomplete, %w[O1], nil, 20, [page(5, 1)]],
101
+ *[[false, "false"], [[], "[]"], ["", "\"\""], [nil, "null"]].map do |trades, label|
102
+ ["trades #{label} with count 0", :incomplete, %w[O1], nil, 20, [page(trades, 0)]]
103
+ end,
104
+ ["trades missing with count 0", :incomplete, %w[O1], nil, 20, [{ "error" => [], "result" => { "count" => 0 } }]],
105
+ ["a trade that is a string", :error, %w[O1], nil, 20, [page({ "T1" => "no id here", "T2" => trade("O1", "1", "2") }, 2)]],
106
+ ["a record without ordertxid", :error, %w[O1], nil, 20, [page({ "T1" => { "vol" => "1", "cost" => "2" } }, 1)]],
107
+ ["a record with a numeric ordertxid", :error, %w[O1], nil, 20, [page({ "T1" => trade(5, "1", "2") }, 1)]],
108
+ ["a trade that is a number raises", :same, %w[O1], nil, 20, [page({ "T1" => 5 }, 1)]],
109
+ ["a trade that is null raises", :same, %w[O1], nil, 20, [page({ "T1" => nil }, 1)]],
110
+ ["count true", :incomplete, %w[O1], nil, 20, [page(T[1], true)]],
111
+ ["count as an array", :incomplete, %w[O1], nil, 20, [page(T[1], [1])]],
112
+ ["refusal on page 2", :same, %w[O1], nil, 20, [page(T[1], 5), { "error" => ["EAPI:Rate limit exceeded"] }]],
113
+ ["falsy errors are not a refusal", :same, %w[O1], nil, 20, [{ "error" => [nil, false], "result" => { "trades" => T[1], "count" => 1 } }]],
114
+ ["non-object page 2 is unreadable", :same, %w[O1], nil, 20, [page(T[1], 5), "oops"]],
115
+ ["pages run out: legacy sees null", :same, %w[O1], nil, 20, [page(T[1], 5)]],
116
+ ["zero volume has no price", :same, %w[O1], nil, 20, [page({ "T1" => trade("O1", "0", "0") }, 1)]]
117
+ ]
118
+
119
+ pager = cases.map do |name, strict, ids, start, max_pages, pages|
120
+ texts = pages.map { |p| JSON.generate(p) }
121
+ queue = texts.dup
122
+ requests = []
123
+ c = legacy
124
+ c.define_singleton_method(:get_trades_history) do |start: nil, ofs: nil, **|
125
+ requests << [start, ofs]
126
+ Honeymaker::Result::Success.new(JSON.parse(queue.shift || "null"))
127
+ end
128
+ outcome = begin
129
+ r = c.closed_orders_from_trades(order_ids: ids, start: start, max_pages: max_pages)
130
+ if r.success?
131
+ { "ok" => r.data.map do |txid, a|
132
+ [txid, { "vol" => a[:amount_exec].to_s("F"), "cost" => a[:quote_amount_exec].to_s("F"), "fee" => a[:fee].to_s("F"),
133
+ "price" => a[:price]&.to_s("F"), "side" => a[:side]&.to_s, "order_type" => a[:order_type].to_s }]
134
+ end }
135
+ elsif r.data.is_a?(Hash) && r.data[:unreadable]
136
+ { "unreadable" => true }
137
+ else
138
+ { "venue" => r.errors }
139
+ end
140
+ rescue StandardError => e
141
+ { "raise" => e.class.name }
142
+ end
143
+ { name: name, strict: strict, order_ids: ids, start: start, max_pages: max_pages, pages: texts, requests: requests, outcome: outcome }
144
+ end
145
+ File.write(File.join(OUT, "kraken_trades_pager.json"), JSON.pretty_generate(pager))
146
+ puts "wrote #{pager.size} trades-pager vectors"
@@ -11,7 +11,7 @@
11
11
  "quote_max_size": "250000000"
12
12
  },
13
13
  {
14
- "product_id": "RENDER-USD",
14
+ "product_id": "RNDR-USD",
15
15
  "base_increment": "0.01",
16
16
  "quote_increment": "0.01",
17
17
  "price_increment": "0.01",
@@ -83,16 +83,29 @@ class Honeymaker::Clients::BingXTest < Minitest::Test
83
83
  assert result.success?
84
84
  end
85
85
 
86
+ # /openApi/spot/v1/trade/fills and /openApi/wallets/v1/capital/{deposit,withdraw}/... answer
87
+ # 100400 "this api is not exist"; these are the documented paths.
88
+ def test_get_trade_fills_reads_my_trades
89
+ @client.expects(:get_signed).with("/openApi/spot/v1/trade/myTrades",
90
+ { symbol: "BTC-USDT", orderId: nil, startTime: 1, endTime: 2, fromId: nil, limit: 500 })
91
+ @client.get_trade_fills(symbol: "BTC-USDT", start_time: 1, end_time: 2, limit: 500)
92
+ end
93
+
86
94
  def test_deposit_history
87
- stub_connection(:get, [{ "coin" => "BTC" }])
88
- result = @client.deposit_history
89
- assert result.success?
95
+ @client.expects(:get_signed).with("/openApi/api/v3/capital/deposit/hisrec", has_entries(offset: 0, limit: 1000))
96
+ .returns(Honeymaker::Result::Success.new([{ "coin" => "BTC" }]))
97
+ assert @client.deposit_history(offset: 0, limit: 1000).success?
90
98
  end
91
99
 
92
100
  def test_withdraw_history
93
- stub_connection(:get, [{ "coin" => "ETH" }])
94
- result = @client.withdraw_history
95
- assert result.success?
101
+ @client.expects(:get_signed).with("/openApi/api/v3/capital/withdraw/history", has_entries(startTime: 1, endTime: 2))
102
+ .returns(Honeymaker::Result::Success.new([{ "coin" => "ETH" }]))
103
+ assert @client.withdraw_history(start_time: 1, end_time: 2).success?
104
+ end
105
+
106
+ def test_get_klines_sends_time_zone
107
+ @client.expects(:get_public).with("/openApi/spot/v1/market/kline", has_entries(symbol: "BTC-USDT", timeZone: 0))
108
+ @client.get_klines(symbol: "BTC-USDT", interval: "1d", time_zone: 0)
96
109
  end
97
110
 
98
111
  private
@@ -53,6 +53,54 @@ class Honeymaker::Clients::BitrueTest < Minitest::Test
53
53
  assert result.success?
54
54
  end
55
55
 
56
+ # The /capital/ paths and /api/v1/myTrades are not in Bitrue's docs; the former redirect to the website.
57
+ def test_account_trade_list_reads_v2
58
+ @client.expects(:get_signed).with("/api/v2/myTrades", has_entries(symbol: "BTCUSDT", startTime: 1, endTime: 2, limit: 1000))
59
+ @client.account_trade_list(symbol: "BTCUSDT", start_time: 1, end_time: 2, limit: 1000)
60
+ end
61
+
62
+ def test_deposit_history
63
+ @client.expects(:get_signed).with("/api/v1/deposit/history", has_entries(coin: "usdt", status: 1, offset: 0, limit: 1000))
64
+ @client.deposit_history(coin: "usdt", status: 1, offset: 0)
65
+ end
66
+
67
+ def test_withdraw_history
68
+ @client.expects(:get_signed).with("/api/v1/withdraw/history", has_entries(coin: "usdt", status: 5))
69
+ @client.withdraw_history(coin: "usdt", status: 5)
70
+ end
71
+
72
+ def test_withdraw_commits
73
+ @client.expects(:post_signed).with("/api/v1/withdraw/commit",
74
+ has_entries(coin: "usdt", amount: "10", addressTo: "0xabc", chainName: "TRX"))
75
+ @client.withdraw(coin: "usdt", address: "0xabc", amount: "10", network: "TRX")
76
+ end
77
+
78
+ def test_get_all_coins_information_reads_exchange_info_chains
79
+ stub_connection(:get, { "coins" => [{ "coin" => "usdt", "chainDetail" => [
80
+ { "chain" => "OMNI", "enableWithdraw" => false, "enableDeposit" => false, "withdrawFee" => "1.28" },
81
+ { "chain" => "TRX", "enableWithdraw" => true, "enableDeposit" => true, "withdrawFee" => "1.00",
82
+ "minWithdraw" => "2.00", "maxWithdraw" => "2000000.00" }
83
+ ] }] })
84
+
85
+ result = @client.get_all_coins_information
86
+
87
+ assert result.success?
88
+ coin = result.data.first
89
+ assert_equal "USDT", coin["coin"]
90
+ assert_equal [{ "network" => "TRX", "withdrawFee" => "1.00", "withdrawMin" => "2.00", "withdrawMax" => "2000000.00",
91
+ "withdrawEnable" => true, "depositEnable" => true, "isDefault" => false }], coin["networkList"]
92
+ end
93
+
94
+ def test_ticker_24hr
95
+ @client.expects(:get_public).with("/api/v1/ticker/24hr", { symbol: nil })
96
+ @client.ticker_24hr
97
+ end
98
+
99
+ def test_candlestick_data_sends_from_idx
100
+ @client.expects(:get_public).with("/api/v1/market/kline", has_entries(symbol: "BTCUSDT", scale: "1D", fromIdx: 5, limit: 1440))
101
+ @client.candlestick_data(symbol: "BTCUSDT", interval: "1D", from_idx: 5, limit: 1440)
102
+ end
103
+
56
104
  def test_headers_include_api_key
57
105
  headers = @client.send(:auth_headers)
58
106
  assert_equal "test_key", headers[:"X-MBX-APIKEY"]
@@ -95,6 +95,21 @@ class Honeymaker::Clients::BitvavoTest < Minitest::Test
95
95
  assert result.success?
96
96
  end
97
97
 
98
+ def test_deposit_history_path
99
+ @client.expects(:get_signed).with("/v2/depositHistory", { symbol: "BTC", limit: 1000, start: 1, end: 2 })
100
+ @client.get_deposit_history(symbol: "BTC", limit: 1000, start_time: 1, end_time: 2)
101
+ end
102
+
103
+ def test_withdrawal_history_path
104
+ @client.expects(:get_signed).with("/v2/withdrawalHistory", { symbol: nil, limit: 1000, start: 1, end: 2 })
105
+ @client.get_withdrawal_history(limit: 1000, start_time: 1, end_time: 2)
106
+ end
107
+
108
+ def test_transactions_read_account_history
109
+ @client.expects(:get_signed).with("/v2/account/history", { fromDate: 1, toDate: 2, page: 3, maxItems: 100, type: "deposit" })
110
+ @client.get_transactions(limit: 100, start_time: 1, end_time: 2, page: 3, type: "deposit")
111
+ end
112
+
98
113
  def test_get_deposit_history
99
114
  stub_connection(:get, [{ "symbol" => "BTC", "amount" => "1.0" }])
100
115
  result = @client.get_deposit_history
@@ -46,6 +46,31 @@ class Honeymaker::Clients::BybitTest < Minitest::Test
46
46
  assert_equal :closed, result.data[:status]
47
47
  end
48
48
 
49
+ def test_get_order_partially_filled_canceled_is_cancelled
50
+ stub_connection(:get, { "retCode" => 0, "retMsg" => "OK", "result" => { "list" => [{
51
+ "orderId" => "123", "orderType" => "Limit", "side" => "Buy", "orderStatus" => "PartiallyFilledCanceled",
52
+ "avgPrice" => "50000", "qty" => "0.002", "cumExecQty" => "0.001", "cumExecValue" => "50"
53
+ }] } })
54
+ result = @client.get_order(category: "spot", order_id: "123")
55
+ assert_equal :cancelled, result.data[:status]
56
+ assert_equal BigDecimal("0.001"), result.data[:amount_exec]
57
+ end
58
+
59
+ def test_order_history
60
+ @client.expects(:get_authenticated).with("/v5/order/history", {
61
+ category: "spot", symbol: nil, orderId: "123", orderLinkId: nil, orderStatus: nil,
62
+ startTime: 1, endTime: 2, limit: 50, cursor: "c"
63
+ }).returns(Honeymaker::Result::Success.new({ "retCode" => 0 }))
64
+ assert @client.order_history(category: "spot", order_id: "123", start_time: 1, end_time: 2, limit: 50, cursor: "c").success?
65
+ end
66
+
67
+ def test_internal_deposit_records
68
+ @client.expects(:get_authenticated).with("/v5/asset/deposit/query-internal-record", {
69
+ txID: nil, coin: "USDT", startTime: 1, endTime: 2, limit: 50, cursor: "c"
70
+ }).returns(Honeymaker::Result::Success.new({ "retCode" => 0 }))
71
+ assert @client.internal_deposit_records(coin: "USDT", start_time: 1, end_time: 2, limit: 50, cursor: "c").success?
72
+ end
73
+
49
74
  def test_cancel_order
50
75
  stub_connection(:post, { "result" => { "orderId" => "123" } })
51
76
  result = @client.cancel_order(category: "spot", symbol: "BTCUSDT", order_id: "123")
@@ -69,6 +69,20 @@ class Honeymaker::Clients::CoinbaseTest < Minitest::Test
69
69
  assert result.success?
70
70
  end
71
71
 
72
+ def test_list_accounts_passes_cursor_and_limit
73
+ response = stub(body: { "accounts" => [], "has_next" => false, "cursor" => "" })
74
+ request = stub(url: nil)
75
+ request.stubs(:headers=)
76
+ request.stubs(:http_method).returns(:get)
77
+ request.stubs(:path).returns("/api/v3/brokerage/accounts")
78
+ request.expects(:params=).with({ limit: 250, cursor: "c2" })
79
+ connection = stub
80
+ connection.stubs(:get).yields(request).returns(response)
81
+ @client.instance_variable_set(:@connection, connection)
82
+
83
+ assert @client.list_accounts(limit: 250, cursor: "c2").success?
84
+ end
85
+
72
86
  def test_send_money
73
87
  stub_connection(:post, { "data" => { "id" => "tx1" } })
74
88
  result = @client.send_money(account_id: "acc1", to: "addr", amount: "0.1", currency: "BTC")
@@ -89,6 +89,21 @@ class Honeymaker::Clients::GeminiTest < Minitest::Test
89
89
  assert_equal OpenSSL::HMAC.hexdigest("sha384", "test_secret", payload), request.headers[:"X-GEMINI-SIGNATURE"]
90
90
  end
91
91
 
92
+ def test_get_transfers_v2
93
+ @client.expects(:post_signed).with("/v2/transfers", has_entries(timestamp: 1, limit_transfers: 50))
94
+ .returns(Honeymaker::Result::Success.new([{ "eid" => 1, "feeCurrency" => "BTC" }]))
95
+ assert @client.get_transfers_v2(timestamp: 1, limit_transfers: 50).success?
96
+ end
97
+
98
+ def test_get_transfers_v2_signs_the_v2_request
99
+ connection = stub
100
+ connection.expects(:post).yields(req = stub_everything).returns(stub(body: []))
101
+ req.expects(:url).with("/v2/transfers")
102
+ req.expects(:headers=).with { |h| JSON.parse(Base64.decode64(h[:"X-GEMINI-PAYLOAD"]))["request"] == "/v2/transfers" }
103
+ @client.instance_variable_set(:@connection, connection)
104
+ assert @client.get_transfers_v2(limit_transfers: 50).success?
105
+ end
106
+
92
107
  private
93
108
 
94
109
  def stub_connection(method, body)
@@ -70,8 +70,10 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
70
70
  # quote_amount_exec 0 (the job subtracts the quote delta from missed_quote_amount).
71
71
  order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00018", limit_px: "64689.0")
72
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
73
+ # no match; the walk reads on until a page brings nothing new
74
+ @client.expects(:user_fills_by_time).twice
75
+ .returns(Honeymaker::Result::Success.new([fill(oid: 111, px: "1.0", sz: "5.0", tid: 1)]),
76
+ Honeymaker::Result::Success.new([fill(oid: 111, px: "1.0", sz: "5.0", tid: 1)]))
75
77
 
76
78
  result = @client.order_status(user: "0xabc", oid: 123456789)
77
79
 
@@ -190,6 +192,80 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
190
192
  assert result.success?
191
193
  end
192
194
 
195
+ # One userFillsByTime answer is capped; an order whose fills spill past it must still be costed
196
+ # exactly, not estimated from the limit price.
197
+ def test_order_status_reads_fills_past_the_first_page
198
+ order_body = order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00020")
199
+ stub_connection(:post, order_body)
200
+ first = [fill(oid: 1, px: "1.0", sz: "1.0", time: 1781698875556),
201
+ fill(oid: 123456789, px: "64000.0", sz: "0.00010", time: 1781698875600, tid: 7)]
202
+ second = [fill(oid: 123456789, px: "64000.0", sz: "0.00010", time: 1781698875600, tid: 7), # boundary re-read
203
+ fill(oid: 123456789, px: "66000.0", sz: "0.00010", time: 1781698875700)]
204
+ seq = sequence("pages")
205
+ @client.expects(:user_fills_by_time).with(user: "0xabc", start_time: 1781698875556).in_sequence(seq)
206
+ .returns(Honeymaker::Result::Success.new(first))
207
+ @client.expects(:user_fills_by_time).with(user: "0xabc", start_time: 1781698875600).in_sequence(seq)
208
+ .returns(Honeymaker::Result::Success.new(second))
209
+
210
+ result = @client.order_status(user: "0xabc", oid: 123456789)
211
+
212
+ assert_equal BigDecimal("13.0"), result.data[:quote_amount_exec]
213
+ assert_equal BigDecimal("65000"), result.data[:price]
214
+ end
215
+
216
+ def test_order_status_propagates_a_failed_later_page
217
+ stub_connection(:post, order_status_body(status: "filled", sz: "0.0", orig_sz: "0.00020"))
218
+ @client.expects(:user_fills_by_time).twice
219
+ .returns(Honeymaker::Result::Success.new([fill(oid: 123456789, px: "64000.0", sz: "0.00010")]),
220
+ Honeymaker::Result::Failure.new("Net::ReadTimeout"))
221
+
222
+ result = @client.order_status(user: "0xabc", oid: 123456789)
223
+
224
+ assert result.failure?
225
+ assert_includes result.errors, "Net::ReadTimeout"
226
+ end
227
+
228
+ def test_user_role
229
+ connection = stub
230
+ connection.expects(:post).yields(req = stub_everything).returns(stub(body: { "role" => "agent", "data" => { "user" => "0xm" } }))
231
+ req.expects(:body=).with({ type: "userRole", user: "0xabc" }.to_json)
232
+ @client.instance_variable_set(:@connection, connection)
233
+
234
+ result = @client.user_role(user: "0xabc")
235
+
236
+ assert_equal "agent", result.data["role"]
237
+ end
238
+
239
+ # == Trading client construction ==
240
+
241
+ def test_trading_client_opens_no_websocket
242
+ with_fake_hyperliquid do
243
+ client = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01")
244
+ assert client.send(:build_exchange).info.skip_ws
245
+ end
246
+ end
247
+
248
+ def test_trading_client_sends_through_the_configured_proxy
249
+ with_fake_hyperliquid do
250
+ client = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01", proxy: "http://proxy.local:8080")
251
+ exchange = client.send(:build_exchange)
252
+ [exchange.instance_variable_get(:@transport), exchange.info.transport].each do |transport|
253
+ assert_equal "proxy.local", transport.instance_variable_get(:@connection).proxy.uri.host
254
+ end
255
+ end
256
+ end
257
+
258
+ def test_trading_clients_sign_strictly_increasing_nonces_within_one_millisecond
259
+ with_fake_hyperliquid do
260
+ Time.stubs(:now).returns(Time.at(1_781_698_875.556))
261
+ a = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01").send(:build_exchange)
262
+ b = Honeymaker::Clients::Hyperliquid.new(api_key: "0xabc", api_secret: "0x01").send(:build_exchange)
263
+ nonces = [a.nonce, b.nonce, a.nonce]
264
+ assert_equal nonces.sort.uniq, nonces
265
+ assert_operator nonces.first, :>=, 1_781_698_875_556
266
+ end
267
+ end
268
+
193
269
  # == Raised trading errors keep their HTTP status ==
194
270
  #
195
271
  # Order placement goes through hyperliquid-rb, which raises instead of answering. Whether the
@@ -285,8 +361,48 @@ class Honeymaker::Clients::HyperliquidTest < Minitest::Test
285
361
  }
286
362
  end
287
363
 
288
- def fill(oid:, px:, sz:, coin: "@142", side: "B")
364
+ def fill(oid:, px:, sz:, coin: "@142", side: "B", time: 1781698875556, tid: nil)
365
+ @tid = (@tid || 0) + 1
289
366
  { "coin" => coin, "oid" => oid, "px" => px, "sz" => sz, "side" => side,
290
- "time" => 1781698875556, "fee" => "0.011", "tid" => 99 }
367
+ "time" => time, "fee" => "0.011", "tid" => tid || @tid }
368
+ end
369
+
370
+ # hyperliquid-rb's shape, as far as build_exchange touches it. The gem itself is an optional
371
+ # dependency (native secp256k1) that this bundle does not carry.
372
+ module FakeHyperliquid
373
+ class Transport
374
+ def initialize = @connection = Faraday.new(url: "https://api.hyperliquid.xyz")
375
+ end
376
+
377
+ class Info
378
+ attr_reader :transport, :skip_ws
379
+
380
+ def initialize(skip_ws:)
381
+ @skip_ws = skip_ws
382
+ @transport = Transport.new
383
+ end
384
+ end
385
+
386
+ class Exchange
387
+ attr_reader :info
388
+
389
+ def initialize(private_key:, skip_ws: false)
390
+ @info = Info.new(skip_ws: skip_ws)
391
+ @transport = Transport.new
392
+ end
393
+
394
+ def nonce = timestamp_ms
395
+
396
+ private
397
+
398
+ def timestamp_ms = (Time.now.to_f * 1000).to_i
399
+ end
400
+ end
401
+
402
+ def with_fake_hyperliquid
403
+ Object.const_set(:Hyperliquid, FakeHyperliquid)
404
+ yield
405
+ ensure
406
+ Object.send(:remove_const, :Hyperliquid)
291
407
  end
292
408
  end
@@ -223,6 +223,21 @@ class Honeymaker::Clients::KrakenTest < Minitest::Test
223
223
  assert_equal BigDecimal("0.00016557"), agg[:amount_exec]
224
224
  end
225
225
 
226
+ # A trade that lands at the top of TradesHistory between two page reads pushes TID-2 from page 1
227
+ # onto page 2. Offset paging then serves TID-2 twice; it must be counted once.
228
+ def test_closed_orders_from_trades_counts_a_trade_seen_on_two_pages_once
229
+ t = { "ordertxid" => "OABC", "pair" => "XXBTZUSD", "type" => "buy", "ordertype" => "market",
230
+ "cost" => "6.0", "vol" => "0.0001", "fee" => "0.01", "time" => 1_700_000_000.0 }
231
+ p1 = { "error" => [], "result" => { "count" => 3, "trades" => { "TID-1" => t, "TID-2" => t } } }
232
+ p2 = { "error" => [], "result" => { "count" => 3, "trades" => { "TID-2" => t } } }
233
+ @client.stubs(:get_trades_history).returns(Honeymaker::Result::Success.new(p1), Honeymaker::Result::Success.new(p2))
234
+ agg = @client.closed_orders_from_trades(order_ids: ["OABC"], start: 1).data["OABC"]
235
+ assert_equal BigDecimal("0.0002"), agg[:amount_exec]
236
+ assert_equal BigDecimal("12.0"), agg[:quote_amount_exec]
237
+ assert_equal BigDecimal("0.02"), agg[:fee]
238
+ assert_equal 2, agg[:trade_count]
239
+ end
240
+
226
241
  private
227
242
 
228
243
  def assert_strictly_increasing(values)
@@ -70,6 +70,18 @@ class Honeymaker::Clients::MexcTest < Minitest::Test
70
70
  assert result.success?
71
71
  end
72
72
 
73
+ def test_capital_withdraw_posts_the_new_endpoint
74
+ @client.expects(:post_signed).with("/api/v3/capital/withdraw",
75
+ has_entries(coin: "USDT", address: "addr", amount: "10", netWork: "TRX"))
76
+ .returns(Honeymaker::Result::Success.new({ "id" => "w1" }))
77
+ assert_equal "w1", @client.capital_withdraw(coin: "USDT", address: "addr", amount: "10", net_work: "TRX").data["id"]
78
+ end
79
+
80
+ def test_get_all_coins_information_is_signed
81
+ @client.expects(:get_signed).with("/api/v3/capital/config/getall", { recvWindow: 5000 })
82
+ @client.get_all_coins_information
83
+ end
84
+
73
85
  def test_withdraw_history
74
86
  stub_connection(:get, [{ "coin" => "ETH" }])
75
87
  result = @client.withdraw_history