honeymaker 0.12.2 → 0.12.4

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 (81) 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/bybit.rb +19 -2
  48. data/lib/honeymaker/clients/coinbase.rb +3 -2
  49. data/lib/honeymaker/clients/kraken.rb +17 -1
  50. data/lib/honeymaker/exchanges/coinbase.rb +5 -4
  51. data/lib/honeymaker/native/kraken.rb +263 -0
  52. data/lib/honeymaker/native.rb +42 -0
  53. data/lib/honeymaker/version.rb +1 -1
  54. data/lib/honeymaker.rb +12 -2
  55. data/script/parity/kraken_client_live.rb +191 -0
  56. data/script/parity/kraken_live.rb +70 -0
  57. data/script/parity/kraken_transport.rb +604 -0
  58. data/script/parity/resolve.c +49 -0
  59. data/script/release/push_gems.sh +28 -0
  60. data/script/vectors/kraken.rb +50 -0
  61. data/script/vectors/kraken_stage2.rb +146 -0
  62. data/test/fixtures/coinbase_products.json +1 -1
  63. data/test/honeymaker/clients/bybit_client_test.rb +25 -0
  64. data/test/honeymaker/clients/coinbase_client_test.rb +14 -0
  65. data/test/honeymaker/clients/kraken_client_test.rb +27 -0
  66. data/test/honeymaker/exchanges/coinbase_test.rb +22 -2
  67. data/test/native/backend_switch_test.rb +53 -0
  68. data/test/native/convert_test.rb +137 -0
  69. data/test/native/gem_isolation_test.rb +17 -0
  70. data/test/native/loader_test.rb +29 -0
  71. data/test/native/lockstep_test.rb +23 -0
  72. data/test/native/signing_smoke_test.rb +28 -0
  73. data/test/parity/kraken_client_parity_test.rb +398 -0
  74. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  75. data/test/release/branch_hardening_test.rb +34 -0
  76. data/test/release/push_gems_test.rb +79 -0
  77. data/test/smoke.rb +24 -0
  78. data/test/support/kraken_signing_smoke.rb +21 -0
  79. data/test/support/parity_helper.rb +62 -0
  80. data/test/support/venue_double.rb +100 -0
  81. 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",
@@ -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")
@@ -15,6 +15,18 @@ class Honeymaker::Clients::KrakenTest < Minitest::Test
15
15
  assert_equal "https://api.kraken.com", Honeymaker::Clients::Kraken::URL
16
16
  end
17
17
 
18
+ def test_non_object_2xx_is_unreadable_not_empty
19
+ [["<html>maintenance</html>", ->(c) { c.get_balances }],
20
+ [nil, ->(c) { c.query_orders_info(txid: "O1") }],
21
+ ["oops", ->(c) { c.add_order(ordertype: "market", type: "buy", volume: "1", pair: "XBTUSD") }],
22
+ ["oops", ->(c) { c.closed_orders_from_trades(order_ids: ["O1"]) }]].each do |body, call|
23
+ stub_connection(:post, body)
24
+ r = call.call(@client)
25
+ assert_equal ["Kraken: unreadable response"], r.errors
26
+ assert_equal({ unreadable: true }, r.data)
27
+ end
28
+ end
29
+
18
30
  def test_get_tradable_asset_pairs
19
31
  stub_connection(:get, { "error" => [], "result" => { "XBTUSDT" => { "altname" => "XBTUSDT" } } })
20
32
  result = @client.get_tradable_asset_pairs
@@ -211,6 +223,21 @@ class Honeymaker::Clients::KrakenTest < Minitest::Test
211
223
  assert_equal BigDecimal("0.00016557"), agg[:amount_exec]
212
224
  end
213
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
+
214
241
  private
215
242
 
216
243
  def assert_strictly_increasing(values)
@@ -16,7 +16,7 @@ class Honeymaker::Exchanges::CoinbaseTest < Minitest::Test
16
16
  result = @exchange.get_tickers_info
17
17
 
18
18
  assert result.success?
19
- # RENDER is blacklisted, so only BTC-USD should remain
19
+ # RNDR is blacklisted, so only BTC-USD should remain
20
20
  assert_equal 1, result.data.size
21
21
  ticker = result.data.first
22
22
  assert_equal "BTC-USD", ticker[:ticker]
@@ -54,6 +54,26 @@ class Honeymaker::Exchanges::CoinbaseTest < Minitest::Test
54
54
  refute result.data.first[:trading_enabled]
55
55
  end
56
56
 
57
+ def test_get_tickers_info_disables_restricted_modes
58
+ %w[limit_only cancel_only is_disabled].each do |flag|
59
+ body = load_fixture("coinbase_products.json")
60
+ body["products"].first[flag] = true
61
+ stub_connection(body)
62
+
63
+ ticker = @exchange.get_tickers_info.data.first
64
+ assert ticker[:available], flag
65
+ refute ticker[:trading_enabled], flag
66
+ end
67
+ end
68
+
69
+ def test_keeps_render
70
+ body = load_fixture("coinbase_products.json")
71
+ body["products"] << body["products"].last.merge("product_id" => "RENDER-USD")
72
+ stub_connection(body)
73
+
74
+ assert_includes @exchange.get_tickers_info.data.map { |t| t[:base] }, "RENDER"
75
+ end
76
+
57
77
  def test_filters_blacklisted_assets
58
78
  body = load_fixture("coinbase_products.json")
59
79
  stub_connection(body)
@@ -61,7 +81,7 @@ class Honeymaker::Exchanges::CoinbaseTest < Minitest::Test
61
81
  result = @exchange.get_tickers_info
62
82
 
63
83
  bases = result.data.map { |t| t[:base] }
64
- refute_includes bases, "RENDER"
84
+ refute_includes bases, "RNDR"
65
85
  end
66
86
 
67
87
  def test_get_bid_ask_parses_response
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "open3"
5
+
6
+ class Honeymaker::Native::BackendSwitchTest < Minitest::Test
7
+ LIB = File.expand_path("../../lib", __dir__)
8
+
9
+ def ruby(env, code)
10
+ out, status = Open3.capture2e(env, RbConfig.ruby, "-I#{LIB}", "-rbundler/setup", "-e", code)
11
+ [out.strip, status]
12
+ end
13
+
14
+ def test_flag_off_uses_ruby_and_never_loads_the_extension
15
+ out, st = ruby({ "HONEYMAKER_NATIVE" => nil }, <<~RUBY)
16
+ require "honeymaker"
17
+ print [Honeymaker::CLIENTS["kraken"], Honeymaker::EXCHANGES["kraken"], Honeymaker.backend("kraken"),
18
+ $LOADED_FEATURES.grep(/honeymaker_native\\.(so|bundle|dll)\\z/).size].join(",")
19
+ RUBY
20
+ assert st.success?, out
21
+ assert_equal "Honeymaker::Clients::Kraken,Honeymaker::Exchanges::Kraken,ruby,0", out
22
+ end
23
+
24
+ def test_unsupported_flags_fail_at_require_time
25
+ ["binance", " kraken, ,binance ", " KRAKEN, bybit "].each do |flag|
26
+ out, st = ruby({ "HONEYMAKER_NATIVE" => flag }, 'require "honeymaker"')
27
+ refute st.success?, out
28
+ assert_match(/unsupported.*(?:binance|bybit)/i, out)
29
+ assert_match(/supported.*kraken/i, out)
30
+ end
31
+ end
32
+
33
+ def test_backend_only_reports_supported_enabled_names
34
+ out, st = ruby({ "HONEYMAKER_NATIVE" => " KRAKEN, ,kraken " }, <<~RUBY)
35
+ require "honeymaker"
36
+ print [Honeymaker.backend(" KRAKEN "), Honeymaker.backend(:binance), Honeymaker.backend("unknown")].join(",")
37
+ RUBY
38
+ assert st.success?, out
39
+ assert_equal "native,ruby,ruby", out
40
+ end
41
+
42
+ def test_flag_on_selects_native_for_kraken_only
43
+ skip "native extension not compiled" unless Dir[File.join(LIB, "honeymaker", "**", "honeymaker_native.{so,bundle,dll}")].any?
44
+
45
+ out, st = ruby({ "HONEYMAKER_NATIVE" => "kraken" }, <<~RUBY)
46
+ require "honeymaker"
47
+ print [Honeymaker.client("kraken").class, Honeymaker.exchange("kraken").class, Honeymaker.backend(:kraken),
48
+ Honeymaker.client("binance").class, $LOADED_FEATURES.grep(/honeymaker_native\\.(so|bundle|dll)\\z/).size].join(",")
49
+ RUBY
50
+ assert st.success?, out
51
+ assert_equal "Honeymaker::Native::KrakenClient,Honeymaker::Native::KrakenExchange,native,Honeymaker::Clients::Binance,1", out
52
+ end
53
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "bigdecimal"
5
+
6
+ class Honeymaker::Native::ConvertTest < Minitest::Test
7
+ def setup
8
+ Honeymaker::Native.load!
9
+ rescue LoadError
10
+ raise if ENV["HONEYMAKER_REQUIRE_NATIVE"]
11
+
12
+ skip "native extension not compiled"
13
+ end
14
+
15
+ def ext = Honeymaker::Native::Ext
16
+
17
+ def test_numeric_text_matches_ruby_to_s
18
+ [1.0e-09, -0.0, 0.0, 1.0e20, 1688888888.1234, Float::MIN, Float::MAX,
19
+ Float::INFINITY, -Float::INFINITY, Float::NAN, 12345678901234567890123].each do |value|
20
+ assert_equal value.to_s, ext.to_s_for_tests(value)
21
+ end
22
+ end
23
+
24
+ def test_decimal_json_and_to_s_follow_ruby
25
+ ["0.5", 12345678901234567890123, 1.0e-09, -0.0, Float::INFINITY,
26
+ -Float::INFINITY, Float::NAN, nil, true, false, [], { "k" => 1 }].each do |value|
27
+ [true, false].each do |stringify|
28
+ want = (BigDecimal(stringify ? value.to_s : value) rescue $!)
29
+ got = (ext.decimal_convert_for_tests(value, stringify) rescue $!)
30
+ assert_equal want.class, got.class, [value, stringify].inspect
31
+ if want.is_a?(Exception)
32
+ assert_equal want.message, got.message
33
+ else
34
+ assert_equal [want.to_s, want.sign], [got.to_s, got.sign]
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def test_decimal_arithmetic_survives_gc_in_rust_container
41
+ [["1", "3"], ["-0.0", "2"], ["NaN", "Infinity"], ["1", "0"]].each do |a, b|
42
+ left, right = BigDecimal(a), BigDecimal(b)
43
+ want = [left + right, left - right, left / right]
44
+ GC.stress = true
45
+ got = ext.decimal_arithmetic_for_tests(a, b)
46
+ GC.stress = false
47
+ assert_equal want.map { |n| [n.to_s, n.sign] }, got.take(3).map { |n| [n.to_s, n.sign] }
48
+ assert_equal left.zero?, got[3]
49
+ end
50
+ ensure
51
+ GC.stress = false
52
+ end
53
+
54
+ def test_non_utf8_strings_roundtrip_with_bytes_and_encoding
55
+ ["O\xff", "O\xff".b, "é".b, "é".encode("ISO-8859-1")].each do |value|
56
+ got = ext.roundtrip_for_tests([value]).first
57
+ assert_equal value.bytes, got.bytes
58
+ assert_equal value.encoding, got.encoding
59
+ end
60
+ end
61
+
62
+ def test_invalid_string_hash_keys_roundtrip
63
+ ["O\xff", "O\xff".b, "é".b].each do |key|
64
+ got = ext.roundtrip_for_tests({ key => "value" }).keys.first
65
+ assert_equal key.bytes, got.bytes
66
+ assert_equal key.encoding, got.encoding
67
+ end
68
+ end
69
+
70
+ def test_internal_string_sentinel_names_are_ordinary_hash_keys
71
+ obj = { "\0ruby_string" => [[], "UTF-8"], "\0ruby_key:\"literal\"" => "value" }
72
+ assert_equal obj, ext.roundtrip_for_tests(obj)
73
+ assert_equal({ "\0ruby_string" => [[], "UTF-8"] }, ext.roundtrip_for_tests({ "\0ruby_string" => [[], "UTF-8"] }))
74
+ end
75
+
76
+ def test_invalid_decimal_strings_use_rubys_own_errors
77
+ ["1\xff", "1\xff".b].each do |value|
78
+ [true, false].each do |stringify|
79
+ want = (BigDecimal(value) rescue $!)
80
+ got = (ext.decimal_convert_for_tests(value, stringify) rescue $!)
81
+ assert_equal [want.class, want.message], [got.class, got.message]
82
+ end
83
+ end
84
+ end
85
+
86
+ def test_shape_errors
87
+ error = assert_raises(Honeymaker::Native::ShapeError) { ext.roundtrip_for_tests({ key: 1 }) }
88
+ assert_equal "non-string key", error.message
89
+ error = assert_raises(Honeymaker::Native::ShapeError) { ext.roundtrip_for_tests(:unsupported) }
90
+ assert_equal "unsupported value :unsupported", error.message
91
+ end
92
+
93
+ def test_false_negative_infinity_nan_and_negative_zero
94
+ got = ext.roundtrip_for_tests([false, -Float::INFINITY, Float::NAN, -0.0])
95
+ assert_equal false, got[0]
96
+ assert_equal(-Float::INFINITY, got[1])
97
+ assert_predicate got[2], :nan?
98
+ assert_equal(-Float::INFINITY, 1.0 / got[3])
99
+ end
100
+
101
+ def test_json_roundtrip_is_exact
102
+ obj = JSON.parse('{"i":12345678901234567890123,"f":1688888888.1234,"e":1e-09,"z":-0.0,"s":"\u00e9","n":null,' \
103
+ '"b":true,"a":[1,2.5,"x"],"big":1e400,"o":{"k":{"k2":[]}}}')
104
+ got = ext.roundtrip_for_tests(obj)
105
+ assert_equal obj.keys, got.keys
106
+ obj.keys.each do |k|
107
+ v = obj[k]
108
+ assert_equal v.class, got[k].class, k
109
+ assert(v.is_a?(Float) ? v.eql?(got[k]) : v == got[k], k)
110
+ end
111
+ assert_equal Encoding::UTF_8, got["s"].encoding
112
+ end
113
+
114
+ def test_decimal_is_rubys_own
115
+ ["0.5", "-0.0000", "NaN", "Infinity", "1_000", "1e-9"].each do |s|
116
+ want = BigDecimal(s)
117
+ got = ext.decimal_for_tests(s)
118
+ assert_equal [want.to_s, want.sign], [got.to_s, got.sign], s
119
+ end
120
+ ["", "abc", "true", "[]", "{}"].each do |s|
121
+ want = (BigDecimal(s) rescue $!)
122
+ got = (ext.decimal_for_tests(s) rescue $!)
123
+ assert_equal [want.class, want.message], [got.class, got.message], s.inspect
124
+ end
125
+ end
126
+
127
+ def test_survives_gc_stress_and_compaction
128
+ obj = JSON.parse({ "rows" => (1..2_000).map { |i| { "id" => "O#{i}", "vol" => "0.#{i}", "t" => i + 0.5 } } }.to_json)
129
+ GC.stress = true
130
+ got = ext.roundtrip_for_tests(obj)
131
+ GC.stress = false
132
+ GC.compact if GC.respond_to?(:compact)
133
+ assert_equal obj, got
134
+ ensure
135
+ GC.stress = false
136
+ end
137
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "open3"
5
+
6
+ # Spec §2.2: the platform gems cross-compile with nothing but pure-Rust crypto. The async client
7
+ # lives in the same Cargo workspace, so this proves the extension never pulls it in.
8
+ class Honeymaker::Native::GemIsolationTest < Minitest::Test
9
+ FORBIDDEN = /\A(tokio|tokio-rustls|hyper|hyper-util|rustls|ring|aws-lc-rs|aws-lc-sys|webpki-roots|honeymaker-client) /
10
+
11
+ def test_the_extension_does_not_depend_on_the_async_client_stack
12
+ out, status = Open3.capture2("cargo", "tree", "--locked", "-p", "honeymaker_native", "-e", "normal,build",
13
+ "--prefix", "none", chdir: File.expand_path("../..", __dir__))
14
+ assert status.success?, "cargo tree failed"
15
+ assert_empty out.lines.grep(FORBIDDEN).uniq
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class Honeymaker::Native::LoaderTest < Minitest::Test
6
+ def setup
7
+ Honeymaker::Native.load!
8
+ rescue LoadError => e
9
+ raise if ENV["HONEYMAKER_REQUIRE_NATIVE"]
10
+
11
+ skip "native extension not compiled (#{e.message})"
12
+ end
13
+
14
+ def test_extension_reports_the_gem_version
15
+ assert_equal Honeymaker::VERSION, Honeymaker::Native::Ext.version
16
+ end
17
+
18
+ def test_flag_parsing
19
+ Honeymaker::Native.instance_variable_set(:@enabled, nil)
20
+ ENV["HONEYMAKER_NATIVE"] = " KRAKEN, ,kraken "
21
+ assert_equal %w[kraken], Honeymaker::Native.enabled
22
+ assert Honeymaker::Native.enabled?(:kraken)
23
+ assert Honeymaker::Native.enabled?(" KRAKEN ")
24
+ refute Honeymaker::Native.enabled?("bybit")
25
+ ensure
26
+ ENV.delete("HONEYMAKER_NATIVE")
27
+ Honeymaker::Native.instance_variable_set(:@enabled, nil)
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "tmpdir"
5
+
6
+ class Honeymaker::Native::LockstepTest < Minitest::Test
7
+ def test_cargo_workspace_version_equals_gem_version
8
+ cargo = File.read(File.expand_path("../../Cargo.toml", __dir__))
9
+ assert_equal Honeymaker::VERSION, cargo[/^\[workspace\.package\].*?^version = "([^"]+)"/m, 1]
10
+ end
11
+
12
+ def test_bump_updates_cargo_too
13
+ rakefile = File.expand_path("../../Rakefile", __dir__)
14
+ Dir.mktmpdir do |dir|
15
+ FileUtils.mkdir_p(File.join(dir, "lib/honeymaker"))
16
+ File.write(File.join(dir, "lib/honeymaker/version.rb"), %(module Honeymaker\n VERSION = "1.2.3"\nend\n))
17
+ File.write(File.join(dir, "Cargo.toml"), %([workspace]\nmembers = []\n\n[workspace.package]\nversion = "1.2.3" # c\n))
18
+ out = Dir.chdir(dir) { `HONEYMAKER_RAKEFILE_LIB_ONLY=1 #{RbConfig.ruby} -rrake -e 'load "#{rakefile}"; bump_version(:minor)' 2>&1` }
19
+ assert_includes out, "1.3.0"
20
+ assert_includes File.read(File.join(dir, "Cargo.toml")), %(version = "1.3.0" # c)
21
+ end
22
+ end
23
+ end