honeymaker 0.12.4-aarch64-linux

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 (136) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/native-gems.yml +100 -0
  3. data/.github/workflows/release.yml +36 -0
  4. data/.github/workflows/test.yml +33 -0
  5. data/LICENSE +21 -0
  6. data/README.md +219 -0
  7. data/Rakefile +78 -0
  8. data/crates/honeymaker/tests/vectors/decimal.json +146 -0
  9. data/crates/honeymaker/tests/vectors/kraken_add_order_wire.json +74 -0
  10. data/crates/honeymaker/tests/vectors/kraken_signing.json +3314 -0
  11. data/crates/honeymaker/tests/vectors/kraken_trades_pager.json +1583 -0
  12. data/lib/honeymaker/3.4/honeymaker_native.so +0 -0
  13. data/lib/honeymaker/4.0/honeymaker_native.so +0 -0
  14. data/lib/honeymaker/client.rb +139 -0
  15. data/lib/honeymaker/clients/binance.rb +687 -0
  16. data/lib/honeymaker/clients/binance_us.rb +42 -0
  17. data/lib/honeymaker/clients/bingx.rb +242 -0
  18. data/lib/honeymaker/clients/bitget.rb +318 -0
  19. data/lib/honeymaker/clients/bitrue.rb +245 -0
  20. data/lib/honeymaker/clients/bitvavo.rb +244 -0
  21. data/lib/honeymaker/clients/bybit.rb +308 -0
  22. data/lib/honeymaker/clients/coinbase.rb +326 -0
  23. data/lib/honeymaker/clients/gemini.rb +189 -0
  24. data/lib/honeymaker/clients/hyperliquid.rb +232 -0
  25. data/lib/honeymaker/clients/kraken.rb +379 -0
  26. data/lib/honeymaker/clients/kraken_futures.rb +78 -0
  27. data/lib/honeymaker/clients/kucoin.rb +310 -0
  28. data/lib/honeymaker/clients/mexc.rb +249 -0
  29. data/lib/honeymaker/exchange.rb +129 -0
  30. data/lib/honeymaker/exchanges/binance.rb +55 -0
  31. data/lib/honeymaker/exchanges/binance_us.rb +9 -0
  32. data/lib/honeymaker/exchanges/bingx.rb +56 -0
  33. data/lib/honeymaker/exchanges/bitget.rb +52 -0
  34. data/lib/honeymaker/exchanges/bitrue.rb +53 -0
  35. data/lib/honeymaker/exchanges/bitvavo.rb +65 -0
  36. data/lib/honeymaker/exchanges/bybit.rb +57 -0
  37. data/lib/honeymaker/exchanges/coinbase.rb +69 -0
  38. data/lib/honeymaker/exchanges/gemini.rb +134 -0
  39. data/lib/honeymaker/exchanges/hyperliquid.rb +67 -0
  40. data/lib/honeymaker/exchanges/kraken.rb +122 -0
  41. data/lib/honeymaker/exchanges/kucoin.rb +52 -0
  42. data/lib/honeymaker/exchanges/mexc.rb +56 -0
  43. data/lib/honeymaker/native/kraken.rb +263 -0
  44. data/lib/honeymaker/native.rb +42 -0
  45. data/lib/honeymaker/result.rb +46 -0
  46. data/lib/honeymaker/utils.rb +39 -0
  47. data/lib/honeymaker/version.rb +5 -0
  48. data/lib/honeymaker.rb +97 -0
  49. data/script/parity/kraken_client_live.rb +191 -0
  50. data/script/parity/kraken_live.rb +70 -0
  51. data/script/parity/kraken_transport.rb +604 -0
  52. data/script/parity/resolve.c +49 -0
  53. data/script/release/push_gems.sh +28 -0
  54. data/script/vectors/kraken.rb +50 -0
  55. data/script/vectors/kraken_stage2.rb +146 -0
  56. data/sig/honeymaker.rbs +4 -0
  57. data/test/fixtures/binance_book_ticker.json +7 -0
  58. data/test/fixtures/binance_exchange_info.json +30 -0
  59. data/test/fixtures/bingx_symbols.json +26 -0
  60. data/test/fixtures/bingx_ticker.json +12 -0
  61. data/test/fixtures/bitget_symbols.json +28 -0
  62. data/test/fixtures/bitget_tickers.json +14 -0
  63. data/test/fixtures/bitmart_symbols.json +26 -0
  64. data/test/fixtures/bitmart_ticker.json +12 -0
  65. data/test/fixtures/bitrue_book_ticker.json +7 -0
  66. data/test/fixtures/bitrue_exchange_info.json +34 -0
  67. data/test/fixtures/bitvavo_book_ticker.json +7 -0
  68. data/test/fixtures/bitvavo_markets.json +30 -0
  69. data/test/fixtures/bybit_instruments.json +23 -0
  70. data/test/fixtures/bybit_tickers.json +19 -0
  71. data/test/fixtures/coinbase_product.json +7 -0
  72. data/test/fixtures/coinbase_products.json +24 -0
  73. data/test/fixtures/gemini_pubticker.json +10 -0
  74. data/test/fixtures/gemini_symbol_detail.json +10 -0
  75. data/test/fixtures/gemini_symbols.json +1 -0
  76. data/test/fixtures/hyperliquid_all_mids.json +5 -0
  77. data/test/fixtures/hyperliquid_spot_meta.json +12 -0
  78. data/test/fixtures/kraken_asset_pairs.json +43 -0
  79. data/test/fixtures/kraken_ticker.json +16 -0
  80. data/test/fixtures/kucoin_orderbook_level1.json +10 -0
  81. data/test/fixtures/kucoin_symbols.json +17 -0
  82. data/test/fixtures/mexc_book_ticker.json +7 -0
  83. data/test/fixtures/mexc_exchange_info.json +64 -0
  84. data/test/honeymaker/client_test.rb +90 -0
  85. data/test/honeymaker/clients/binance_client_test.rb +151 -0
  86. data/test/honeymaker/clients/binance_us_client_test.rb +25 -0
  87. data/test/honeymaker/clients/bingx_client_test.rb +106 -0
  88. data/test/honeymaker/clients/bitget_client_test.rb +146 -0
  89. data/test/honeymaker/clients/bitrue_client_test.rb +69 -0
  90. data/test/honeymaker/clients/bitvavo_client_test.rb +156 -0
  91. data/test/honeymaker/clients/bybit_client_test.rb +137 -0
  92. data/test/honeymaker/clients/coinbase_client_test.rb +153 -0
  93. data/test/honeymaker/clients/gemini_client_test.rb +100 -0
  94. data/test/honeymaker/clients/honeymaker_client_registry_test.rb +39 -0
  95. data/test/honeymaker/clients/hyperliquid_client_test.rb +292 -0
  96. data/test/honeymaker/clients/kraken_client_test.rb +255 -0
  97. data/test/honeymaker/clients/kraken_futures_client_test.rb +54 -0
  98. data/test/honeymaker/clients/kucoin_client_test.rb +149 -0
  99. data/test/honeymaker/clients/mexc_client_test.rb +98 -0
  100. data/test/honeymaker/clients/validation_test.rb +167 -0
  101. data/test/honeymaker/error_chain_test.rb +133 -0
  102. data/test/honeymaker/exchange_test.rb +138 -0
  103. data/test/honeymaker/exchanges/binance_test.rb +83 -0
  104. data/test/honeymaker/exchanges/binance_us_test.rb +53 -0
  105. data/test/honeymaker/exchanges/bingx_test.rb +65 -0
  106. data/test/honeymaker/exchanges/bitget_test.rb +65 -0
  107. data/test/honeymaker/exchanges/bitrue_test.rb +65 -0
  108. data/test/honeymaker/exchanges/bitvavo_test.rb +65 -0
  109. data/test/honeymaker/exchanges/bybit_test.rb +55 -0
  110. data/test/honeymaker/exchanges/coinbase_test.rb +106 -0
  111. data/test/honeymaker/exchanges/gemini_catalogue_test.rb +118 -0
  112. data/test/honeymaker/exchanges/gemini_test.rb +184 -0
  113. data/test/honeymaker/exchanges/hyperliquid_test.rb +64 -0
  114. data/test/honeymaker/exchanges/kraken_test.rb +191 -0
  115. data/test/honeymaker/exchanges/kucoin_test.rb +55 -0
  116. data/test/honeymaker/exchanges/mexc_test.rb +97 -0
  117. data/test/honeymaker/honeymaker_test.rb +27 -0
  118. data/test/honeymaker/result_test.rb +42 -0
  119. data/test/honeymaker/utils_test.rb +64 -0
  120. data/test/native/backend_switch_test.rb +53 -0
  121. data/test/native/convert_test.rb +137 -0
  122. data/test/native/gem_isolation_test.rb +17 -0
  123. data/test/native/loader_test.rb +29 -0
  124. data/test/native/lockstep_test.rb +23 -0
  125. data/test/native/signing_smoke_test.rb +28 -0
  126. data/test/parity/kraken_client_parity_test.rb +398 -0
  127. data/test/parity/kraken_exchange_parity_test.rb +172 -0
  128. data/test/release/branch_hardening_test.rb +34 -0
  129. data/test/release/push_gems_test.rb +79 -0
  130. data/test/smoke.rb +24 -0
  131. data/test/support/fixture_helper.rb +13 -0
  132. data/test/support/kraken_signing_smoke.rb +21 -0
  133. data/test/support/parity_helper.rb +62 -0
  134. data/test/support/venue_double.rb +100 -0
  135. data/test/test_helper.rb +9 -0
  136. metadata +254 -0
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honeymaker
4
+ module Clients
5
+ class Bybit < Client
6
+ URL = "https://api.bybit.com"
7
+ RECV_WINDOW = "5000"
8
+ RATE_LIMITS = { default: 100, orders: 200 }.freeze
9
+
10
+ def get_coin_query_info
11
+ get_authenticated("/v5/asset/coin/query-info")
12
+ end
13
+
14
+ def instruments_info(category:, symbol: nil, status: nil, base_coin: nil, limit: nil, cursor: nil)
15
+ get_public("/v5/market/instruments-info", {
16
+ category: category, symbol: symbol, status: status,
17
+ baseCoin: base_coin, limit: limit, cursor: cursor
18
+ })
19
+ end
20
+
21
+ def tickers(category:, symbol: nil, base_coin: nil, exp_date: nil)
22
+ get_public("/v5/market/tickers", {
23
+ category: category, symbol: symbol, baseCoin: base_coin, expDate: exp_date
24
+ })
25
+ end
26
+
27
+ def orderbook(category:, symbol:, limit: nil)
28
+ get_public("/v5/market/orderbook", { category: category, symbol: symbol, limit: limit })
29
+ end
30
+
31
+ def kline(category:, symbol:, interval:, start: nil, end_time: nil, limit: nil)
32
+ get_public("/v5/market/kline", {
33
+ category: category, symbol: symbol, interval: interval,
34
+ start: start, end: end_time, limit: limit
35
+ })
36
+ end
37
+
38
+ def wallet_balance(account_type:, coin: nil)
39
+ get_authenticated("/v5/account/wallet-balance", { accountType: account_type, coin: coin })
40
+ end
41
+
42
+ def get_balances(account_type: "UNIFIED")
43
+ result = wallet_balance(account_type: account_type)
44
+ return result if result.failure?
45
+
46
+ return Result::Failure.new(result.data["retMsg"]) unless result.data["retCode"]&.zero?
47
+
48
+ balances = {}
49
+ accounts = result.data.dig("result", "list") || []
50
+ accounts.each do |account|
51
+ (account["coin"] || []).each do |coin|
52
+ symbol = coin["coin"]
53
+ free = BigDecimal((coin["availableToWithdraw"] || "0").to_s)
54
+ locked = BigDecimal((coin["locked"] || "0").to_s)
55
+ next if free.zero? && locked.zero?
56
+ balances[symbol] = { free: free, locked: locked }
57
+ end
58
+ end
59
+
60
+ Result::Success.new(balances)
61
+ end
62
+
63
+ def get_order(category:, order_id: nil, symbol: nil, order_link_id: nil)
64
+ result = get_authenticated("/v5/order/realtime", {
65
+ category: category, orderId: order_id, symbol: symbol, orderLinkId: order_link_id
66
+ })
67
+ return result if result.failure?
68
+ return Result::Failure.new(result.data["retMsg"]) unless result.data["retCode"]&.zero?
69
+
70
+ order_list = result.data.dig("result", "list") || []
71
+ raw = order_list.first
72
+ return Result::Failure.new("Order not found") unless raw
73
+
74
+ Result::Success.new(normalize_order(raw["orderId"], raw))
75
+ end
76
+
77
+ def create_order(category:, symbol:, side:, order_type:, qty:, price: nil,
78
+ time_in_force: nil, market_unit: nil, order_link_id: nil)
79
+ result = post_authenticated("/v5/order/create", {
80
+ category: category, symbol: symbol, side: side,
81
+ orderType: order_type, qty: qty, price: price,
82
+ timeInForce: time_in_force, marketUnit: market_unit,
83
+ orderLinkId: order_link_id
84
+ })
85
+ return result if result.failure?
86
+ return Result::Failure.new(result.data["retMsg"]) unless result.data["retCode"]&.zero?
87
+
88
+ order_id = result.data.dig("result", "orderId")
89
+ Result::Success.new({ order_id: order_id, raw: result.data })
90
+ end
91
+
92
+ def cancel_order(category:, symbol:, order_id: nil, order_link_id: nil)
93
+ post_authenticated("/v5/order/cancel", {
94
+ category: category, symbol: symbol,
95
+ orderId: order_id, orderLinkId: order_link_id
96
+ })
97
+ end
98
+
99
+ # Closed orders that /v5/order/realtime no longer returns.
100
+ def order_history(category:, symbol: nil, order_id: nil, order_link_id: nil, order_status: nil,
101
+ start_time: nil, end_time: nil, limit: nil, cursor: nil)
102
+ get_authenticated("/v5/order/history", {
103
+ category: category, symbol: symbol, orderId: order_id, orderLinkId: order_link_id,
104
+ orderStatus: order_status, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
105
+ })
106
+ end
107
+
108
+ def transaction_log(account_type: nil, category: nil, currency: nil, type: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
109
+ get_authenticated("/v5/account/transaction-log", {
110
+ accountType: account_type, category: category, currency: currency, type: type,
111
+ startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
112
+ })
113
+ end
114
+
115
+ def execution_list(category:, symbol: nil, order_id: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
116
+ get_authenticated("/v5/execution/list", {
117
+ category: category, symbol: symbol, orderId: order_id,
118
+ startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
119
+ })
120
+ end
121
+
122
+ def deposit_records(coin: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
123
+ get_authenticated("/v5/asset/deposit/query-record", {
124
+ coin: coin, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
125
+ })
126
+ end
127
+
128
+ # Off-chain deposits from another Bybit user; query-record does not include them.
129
+ def internal_deposit_records(tx_id: nil, coin: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
130
+ get_authenticated("/v5/asset/deposit/query-internal-record", {
131
+ txID: tx_id, coin: coin, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
132
+ })
133
+ end
134
+
135
+ def withdraw_records(coin: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil, withdraw_type: nil)
136
+ get_authenticated("/v5/asset/withdraw/query-record", {
137
+ coin: coin, startTime: start_time, endTime: end_time,
138
+ limit: limit, cursor: cursor, withdrawType: withdraw_type
139
+ })
140
+ end
141
+
142
+ def withdraw(coin:, chain:, address:, amount:, tag: nil, force_chain: nil)
143
+ post_authenticated("/v5/asset/withdraw/create", {
144
+ coin: coin, chain: chain, address: address, amount: amount,
145
+ tag: tag, forceChain: force_chain, timestamp: timestamp_ms
146
+ })
147
+ end
148
+
149
+ # --- Margin ---
150
+
151
+ def borrow_history(currency: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
152
+ get_authenticated("/v5/account/borrow-history", {
153
+ currency: currency, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
154
+ })
155
+ end
156
+
157
+ def spot_margin_repay_history(start_time: nil, end_time: nil, coin: nil, limit: nil, cursor: nil)
158
+ get_authenticated("/v5/spot-cross-margin-trade/repay-history", {
159
+ startTime: start_time, endTime: end_time, coin: coin, limit: limit, cursor: cursor
160
+ })
161
+ end
162
+
163
+ # --- Futures ---
164
+
165
+ def delivery_records(category:, symbol: nil, exp_date: nil, limit: nil, cursor: nil)
166
+ get_authenticated("/v5/asset/delivery-record", {
167
+ category: category, symbol: symbol, expDate: exp_date, limit: limit, cursor: cursor
168
+ })
169
+ end
170
+
171
+ def settlement_records(category:, symbol: nil, limit: nil, cursor: nil)
172
+ get_authenticated("/v5/asset/settlement-record", {
173
+ category: category, symbol: symbol, limit: limit, cursor: cursor
174
+ })
175
+ end
176
+
177
+ # --- Earn ---
178
+
179
+ def earn_order_records(order_id: nil, order_type: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
180
+ get_authenticated("/v5/earn/order-records", {
181
+ orderId: order_id, orderType: order_type,
182
+ startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
183
+ })
184
+ end
185
+
186
+ def earn_yield_history(product_id: nil, start_time: nil, end_time: nil, limit: nil, cursor: nil)
187
+ get_authenticated("/v5/earn/yield-history", {
188
+ productId: product_id, startTime: start_time, endTime: end_time, limit: limit, cursor: cursor
189
+ })
190
+ end
191
+
192
+ private
193
+
194
+ def normalize_order(order_id, raw)
195
+ order_type = parse_order_type(raw["orderType"])
196
+ side = raw["side"]&.downcase&.to_sym
197
+ status = parse_order_status(raw["orderStatus"])
198
+
199
+ price = BigDecimal((raw["avgPrice"] || "0").to_s)
200
+ price = BigDecimal((raw["price"] || "0").to_s) if price.zero?
201
+ price = nil if price.zero?
202
+
203
+ amount = BigDecimal((raw["qty"] || "0").to_s)
204
+ amount = nil if amount.zero?
205
+ amount_exec = BigDecimal((raw["cumExecQty"] || "0").to_s)
206
+ quote_amount_exec = BigDecimal((raw["cumExecValue"] || "0").to_s)
207
+
208
+ {
209
+ order_id: order_id, status: status, side: side, order_type: order_type,
210
+ price: price, amount: amount, quote_amount: nil,
211
+ amount_exec: amount_exec, quote_amount_exec: quote_amount_exec, raw: raw
212
+ }
213
+ end
214
+
215
+ def parse_order_type(type)
216
+ case type
217
+ when "Market" then :market
218
+ when "Limit" then :limit
219
+ else :unknown
220
+ end
221
+ end
222
+
223
+ def parse_order_status(status)
224
+ case status
225
+ when "Created", "Untriggered" then :unknown
226
+ when "New", "PartiallyFilled" then :open
227
+ when "Filled" then :closed
228
+ # PartiallyFilledCanceled is final: a spot order cancelled after a partial fill.
229
+ when "Cancelled", "PartiallyFilledCanceled", "Expired", "Deactivated" then :cancelled
230
+ when "Rejected" then :failed
231
+ else :unknown
232
+ end
233
+ end
234
+
235
+ def validate_trading_credentials
236
+ result = wallet_balance(account_type: "UNIFIED")
237
+ return Result::Failure.new("Invalid trading credentials") if result.failure?
238
+ result.data["retCode"]&.zero? ? Result::Success.new(true) : Result::Failure.new("Invalid trading credentials")
239
+ end
240
+
241
+ def validate_read_credentials
242
+ validate_trading_credentials
243
+ end
244
+
245
+ def get_public(path, params = {})
246
+ with_rescue do
247
+ response = connection.get do |req|
248
+ req.url path
249
+ req.headers = unauthenticated_headers
250
+ req.params = params.compact
251
+ end
252
+ response.body
253
+ end
254
+ end
255
+
256
+ def get_authenticated(path, params = {})
257
+ with_rescue do
258
+ params = params.compact
259
+ response = connection.get do |req|
260
+ req.url path
261
+ req.headers = signed_headers("GET", params)
262
+ req.params = params
263
+ end
264
+ response.body
265
+ end
266
+ end
267
+
268
+ def post_authenticated(path, body = {})
269
+ with_rescue do
270
+ body = body.compact
271
+ response = connection.post do |req|
272
+ req.url path
273
+ req.headers = signed_headers("POST", body)
274
+ req.body = body
275
+ end
276
+ response.body
277
+ end
278
+ end
279
+
280
+ def unauthenticated_headers
281
+ { Accept: "application/json", "Content-Type": "application/json" }
282
+ end
283
+
284
+ def signed_headers(method, params_or_body)
285
+ return unauthenticated_headers unless authenticated?
286
+
287
+ ts = timestamp_ms
288
+ payload = if method == "GET"
289
+ query_string = Faraday::Utils.build_query(params_or_body)
290
+ "#{ts}#{@api_key}#{RECV_WINDOW}#{query_string}"
291
+ else
292
+ "#{ts}#{@api_key}#{RECV_WINDOW}#{params_or_body.to_json}"
293
+ end
294
+
295
+ signature = hmac_sha256(@api_secret, payload)
296
+
297
+ {
298
+ "X-BAPI-API-KEY": @api_key,
299
+ "X-BAPI-SIGN": signature,
300
+ "X-BAPI-TIMESTAMP": ts.to_s,
301
+ "X-BAPI-RECV-WINDOW": RECV_WINDOW,
302
+ Accept: "application/json",
303
+ "Content-Type": "application/json"
304
+ }
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+
5
+ module Honeymaker
6
+ module Clients
7
+ class Coinbase < Client
8
+ URL = "https://api.coinbase.com"
9
+ RATE_LIMITS = { default: 100, orders: 100 }.freeze
10
+
11
+ def initialize(api_key: nil, api_secret: nil, proxy: nil, logger: nil)
12
+ super
13
+ @api_secret = @api_secret&.gsub('\n', "\n")
14
+ end
15
+
16
+ def get_order(order_id:)
17
+ result = get("/api/v3/brokerage/orders/historical/#{order_id}")
18
+ return result if result.failure?
19
+
20
+ raw = result.data.is_a?(Hash) && result.data.key?("order") ? result.data["order"] : result.data
21
+ Result::Success.new(normalize_order(order_id, raw))
22
+ end
23
+
24
+ def list_orders(order_ids: nil, product_ids: nil, product_type: nil, order_status: nil,
25
+ time_in_forces: nil, order_types: nil, order_side: nil, start_date: nil,
26
+ end_date: nil, order_placement_source: nil, contract_expiry_type: nil,
27
+ asset_filters: nil, limit: nil, cursor: nil, sort_by: nil)
28
+ with_rescue do
29
+ response = connection.get do |req|
30
+ req.url "/api/v3/brokerage/orders/historical/batch"
31
+ req.headers = auth_headers(req)
32
+ req.params = {
33
+ order_ids: order_ids, product_ids: product_ids, product_type: product_type,
34
+ order_status: order_status, time_in_forces: time_in_forces,
35
+ order_types: order_types, order_side: order_side,
36
+ start_date: start_date, end_date: end_date,
37
+ order_placement_source: order_placement_source,
38
+ contract_expiry_type: contract_expiry_type,
39
+ asset_filters: asset_filters, limit: limit, cursor: cursor, sort_by: sort_by
40
+ }.compact
41
+ req.options.params_encoder = Faraday::FlatParamsEncoder
42
+ end
43
+ response.body
44
+ end
45
+ end
46
+
47
+ def create_order(client_order_id:, product_id:, side:, order_configuration:)
48
+ result = post("/api/v3/brokerage/orders", {
49
+ client_order_id: client_order_id, product_id: product_id,
50
+ side: side, order_configuration: order_configuration
51
+ })
52
+ return result if result.failure?
53
+
54
+ raw = result.data
55
+ if raw["success"] == false
56
+ error_msg = raw.dig("error_response", "message") || "Order creation failed"
57
+ return Result::Failure.new(error_msg)
58
+ end
59
+
60
+ order_id = raw.dig("success_response", "order_id")
61
+ Result::Success.new({ order_id: order_id, raw: raw })
62
+ end
63
+
64
+ def cancel_orders(order_ids:)
65
+ post("/api/v3/brokerage/orders/batch_cancel", { order_ids: order_ids })
66
+ end
67
+
68
+ def list_public_products(limit: nil, offset: nil, product_type: nil, product_ids: nil,
69
+ contract_expiry_type: nil, expiring_contract_status: nil,
70
+ get_tradability_status: nil, get_all_products: nil)
71
+ get("/api/v3/brokerage/market/products", {
72
+ limit: limit, offset: offset, product_type: product_type, product_ids: product_ids,
73
+ contract_expiry_type: contract_expiry_type,
74
+ expiring_contract_status: expiring_contract_status,
75
+ get_tradability_status: get_tradability_status, get_all_products: get_all_products
76
+ })
77
+ end
78
+
79
+ def get_public_product(product_id:, get_tradability_status: nil)
80
+ get("/api/v3/brokerage/market/products/#{product_id}", { get_tradability_status: get_tradability_status })
81
+ end
82
+
83
+ def get_public_product_book(product_id:, limit: nil, aggregation_price_increment: nil)
84
+ with_rescue do
85
+ response = connection.get do |req|
86
+ req.url "/api/v3/brokerage/market/product_book"
87
+ req.headers = auth_headers(req)
88
+ req.params = {
89
+ product_id: product_id, limit: limit,
90
+ aggregation_price_increment: aggregation_price_increment
91
+ }.compact
92
+ req.options.params_encoder = Faraday::FlatParamsEncoder
93
+ end
94
+ response.body
95
+ end
96
+ end
97
+
98
+ def get_public_product_candles(product_id:, start_time:, end_time:, granularity:, limit: nil)
99
+ get("/api/v3/brokerage/market/products/#{product_id}/candles", {
100
+ start: start_time, end: end_time, granularity: granularity, limit: limit
101
+ })
102
+ end
103
+
104
+ def get_api_key_permissions
105
+ get("/api/v3/brokerage/key_permissions")
106
+ end
107
+
108
+ # One page of accounts; pass the response's `cursor` back while `has_next` is true.
109
+ def list_accounts(limit: nil, cursor: nil)
110
+ get("/api/v3/brokerage/accounts", { limit: limit, cursor: cursor })
111
+ end
112
+
113
+ def list_portfolios
114
+ get("/api/v3/brokerage/portfolios")
115
+ end
116
+
117
+ def get_balances(portfolio_uuid: nil)
118
+ unless portfolio_uuid
119
+ portfolios_result = list_portfolios
120
+ return portfolios_result if portfolios_result.failure?
121
+
122
+ portfolio = Array(portfolios_result.data["portfolios"]).first
123
+ return Result::Failure.new("No portfolios found") unless portfolio
124
+ portfolio_uuid = portfolio["uuid"]
125
+ end
126
+
127
+ result = get_portfolio_breakdown(portfolio_uuid: portfolio_uuid)
128
+ return result if result.failure?
129
+
130
+ balances = {}
131
+ positions = result.data.dig("breakdown", "spot_positions") || []
132
+ positions.each do |position|
133
+ symbol = position["asset"]
134
+ next unless symbol
135
+
136
+ free = BigDecimal((position["available_to_trade_crypto"] || "0").to_s)
137
+ total = BigDecimal((position["total_balance_crypto"] || "0").to_s)
138
+ locked = total - free
139
+ next if free.zero? && locked.zero?
140
+ balances[symbol] = { free: free, locked: locked }
141
+ end
142
+
143
+ Result::Success.new(balances)
144
+ end
145
+
146
+ def get_portfolio_breakdown(portfolio_uuid:, currency: nil)
147
+ get("/api/v3/brokerage/portfolios/#{portfolio_uuid}", { currency: currency })
148
+ end
149
+
150
+ def send_money(account_id:, to:, amount:, currency:, idem: nil)
151
+ post("/v2/accounts/#{account_id}/transactions", {
152
+ type: "send", to: to, amount: amount, currency: currency, idem: idem
153
+ })
154
+ end
155
+
156
+ def list_fills(product_id: nil, order_id: nil, start_sequence_timestamp: nil,
157
+ end_sequence_timestamp: nil, limit: nil, cursor: nil)
158
+ with_rescue do
159
+ response = connection.get do |req|
160
+ req.url "/api/v3/brokerage/orders/historical/fills"
161
+ req.headers = auth_headers(req)
162
+ req.params = {
163
+ product_id: product_id, order_id: order_id,
164
+ start_sequence_timestamp: start_sequence_timestamp,
165
+ end_sequence_timestamp: end_sequence_timestamp,
166
+ limit: limit, cursor: cursor
167
+ }.compact
168
+ req.options.params_encoder = Faraday::FlatParamsEncoder
169
+ end
170
+ response.body
171
+ end
172
+ end
173
+
174
+ def list_transactions(account_id:, limit: nil, starting_after: nil)
175
+ get("/v2/accounts/#{account_id}/transactions", { limit: limit, starting_after: starting_after })
176
+ end
177
+
178
+ private
179
+
180
+ def get(path, params = {})
181
+ with_rescue do
182
+ response = connection.get do |req|
183
+ req.url path
184
+ req.headers = auth_headers(req)
185
+ req.params = params.compact
186
+ end
187
+ response.body
188
+ end
189
+ end
190
+
191
+ def post(path, body = {})
192
+ with_rescue do
193
+ response = connection.post do |req|
194
+ req.url path
195
+ req.headers = auth_headers(req)
196
+ req.body = body.compact
197
+ end
198
+ response.body
199
+ end
200
+ end
201
+
202
+ def normalize_order(order_id, raw)
203
+ order_type = parse_order_type(raw["order_type"])
204
+ side = raw["side"]&.downcase&.to_sym
205
+ status = parse_order_status(raw["status"])
206
+
207
+ price = BigDecimal((raw["average_filled_price"] || "0").to_s)
208
+
209
+ case order_type
210
+ when :limit
211
+ config = (raw["order_configuration"] || {})["limit_limit_gtc"] || {}
212
+ amount = config["base_size"] ? BigDecimal(config["base_size"].to_s) : nil
213
+ quote_amount = config["quote_size"] ? BigDecimal(config["quote_size"].to_s) : nil
214
+ price = BigDecimal(config["limit_price"].to_s) if price.zero? && config["limit_price"]
215
+ when :market
216
+ config = (raw["order_configuration"] || {})["market_market_ioc"] || {}
217
+ amount = config["base_size"] ? BigDecimal(config["base_size"].to_s) : nil
218
+ quote_amount = config["quote_size"] ? BigDecimal(config["quote_size"].to_s) : nil
219
+ else
220
+ amount = nil
221
+ quote_amount = nil
222
+ end
223
+
224
+ price = nil if price.zero?
225
+
226
+ amount_exec = BigDecimal((raw["filled_size"] || "0").to_s)
227
+ total_value = BigDecimal((raw["total_value_after_fees"] || "0").to_s)
228
+ outstanding = BigDecimal((raw["outstanding_hold_amount"] || "0").to_s)
229
+ quote_amount_exec = total_value - outstanding
230
+
231
+ {
232
+ order_id: order_id,
233
+ status: status,
234
+ side: side,
235
+ order_type: order_type,
236
+ price: price,
237
+ amount: amount,
238
+ quote_amount: quote_amount,
239
+ amount_exec: amount_exec,
240
+ quote_amount_exec: quote_amount_exec,
241
+ raw: raw
242
+ }
243
+ end
244
+
245
+ def parse_order_type(type)
246
+ case type
247
+ when "MARKET" then :market
248
+ when "LIMIT" then :limit
249
+ else :unknown
250
+ end
251
+ end
252
+
253
+ def parse_order_status(status)
254
+ case status
255
+ when "PENDING", "UNKNOWN_ORDER_STATUS" then :unknown
256
+ when "OPEN" then :open
257
+ when "FILLED" then :closed
258
+ when "CANCELLED", "EXPIRED" then :cancelled
259
+ when "FAILED" then :failed
260
+ else :unknown
261
+ end
262
+ end
263
+
264
+ def validate_trading_credentials
265
+ # List accounts — if it works, the key has trading access
266
+ result = list_accounts
267
+ return Result::Failure.new("Invalid trading credentials") if result.failure?
268
+ Result::Success.new(true)
269
+ end
270
+
271
+ def validate_read_credentials
272
+ result = get_api_key_permissions
273
+ return Result::Failure.new("Invalid read credentials") if result.failure?
274
+ Result::Success.new(true)
275
+ end
276
+
277
+ def auth_headers(req)
278
+ return unauthenticated_headers unless authenticated?
279
+
280
+ timestamp = Time.now.utc.to_i
281
+ method = req.http_method.to_s.upcase
282
+ request_host = URI(URL).host
283
+ request_path = req.path
284
+
285
+ jwt_payload = {
286
+ sub: @api_key,
287
+ iss: "coinbase-cloud",
288
+ nbf: timestamp,
289
+ exp: timestamp + 120,
290
+ uri: "#{method} #{request_host}#{request_path}"
291
+ }
292
+
293
+ signing_key = ecdsa_key? ? ecdsa_signing_key : ed25519_signing_key
294
+ return unauthenticated_headers if signing_key.nil?
295
+
296
+ algorithm = ecdsa_key? ? "ES256" : "EdDSA"
297
+ jwt = JWT.encode(jwt_payload, signing_key, algorithm, { kid: @api_key, nonce: SecureRandom.hex })
298
+
299
+ { Authorization: "Bearer #{jwt}", Accept: "application/json", "Content-Type": "application/json" }
300
+ end
301
+
302
+ def unauthenticated_headers
303
+ { Accept: "application/json", "Content-Type": "application/json" }
304
+ end
305
+
306
+ def ecdsa_key?
307
+ @api_secret&.start_with?("-----BEGIN EC PRIVATE KEY-----")
308
+ end
309
+
310
+ def ecdsa_signing_key
311
+ OpenSSL::PKey::EC.new(@api_secret)
312
+ rescue OpenSSL::PKey::ECError
313
+ nil
314
+ end
315
+
316
+ def ed25519_signing_key
317
+ require "rbnacl"
318
+ decoded_key = Base64.decode64(@api_secret)
319
+ seed = decoded_key[0...32]
320
+ RbNaCl::Signatures::Ed25519::SigningKey.new(seed)
321
+ rescue LoadError, RbNaCl::LengthError
322
+ nil
323
+ end
324
+ end
325
+ end
326
+ end