buda_api 1.0.0

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.
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Advanced trading bot example
5
+ #
6
+ # This example demonstrates a simple trading bot that monitors price changes
7
+ # and can place orders based on basic strategies
8
+
9
+ require_relative "../lib/buda_api"
10
+ require "dotenv/load"
11
+
12
+ class SimpleTradingBot
13
+ def initialize(api_key, api_secret, market_id = "BTC-CLP")
14
+ @client = BudaApi.authenticated_client(
15
+ api_key: api_key,
16
+ api_secret: api_secret
17
+ )
18
+ @public_client = BudaApi.public_client
19
+ @market_id = market_id
20
+ @running = false
21
+ @price_history = []
22
+ @max_history = 20
23
+ end
24
+
25
+ def start
26
+ puts "=== Simple Trading Bot Started ==="
27
+ puts "Market: #{@market_id}"
28
+ puts "Press Ctrl+C to stop"
29
+ puts
30
+
31
+ @running = true
32
+
33
+ # Set up signal handler for graceful shutdown
34
+ trap("INT") do
35
+ puts "\nReceived interrupt signal. Stopping bot..."
36
+ @running = false
37
+ end
38
+
39
+ monitor_loop
40
+ end
41
+
42
+ private
43
+
44
+ def monitor_loop
45
+ while @running
46
+ begin
47
+ # Get current market data
48
+ ticker = @public_client.ticker(@market_id)
49
+ current_price = ticker.last_price.amount
50
+
51
+ # Update price history
52
+ @price_history << {
53
+ price: current_price,
54
+ timestamp: Time.now
55
+ }
56
+
57
+ # Keep only recent history
58
+ @price_history = @price_history.last(@max_history)
59
+
60
+ # Display current status
61
+ display_status(ticker)
62
+
63
+ # Check for trading opportunities
64
+ check_trading_opportunities(current_price)
65
+
66
+ # Wait before next check
67
+ sleep 30 # Check every 30 seconds
68
+
69
+ rescue BudaApi::RateLimitError => e
70
+ puts "Rate limit hit. Waiting 60 seconds..."
71
+ sleep 60
72
+ rescue BudaApi::ApiError => e
73
+ puts "API Error: #{e.message}"
74
+ sleep 10
75
+ rescue => e
76
+ puts "Unexpected error: #{e.message}"
77
+ sleep 10
78
+ end
79
+ end
80
+
81
+ puts "Bot stopped."
82
+ end
83
+
84
+ def display_status(ticker)
85
+ puts "\n=== Market Status at #{Time.now.strftime('%H:%M:%S')} ==="
86
+ puts "Last price: #{ticker.last_price}"
87
+ puts "24h change: #{ticker.price_variation_24h}%"
88
+ puts "Volume: #{ticker.volume}"
89
+ puts "Spread: #{ticker.min_ask.amount - ticker.max_bid.amount}"
90
+
91
+ if @price_history.length > 1
92
+ price_change = @price_history.last[:price] - @price_history.first[:price]
93
+ change_pct = (price_change / @price_history.first[:price] * 100).round(2)
94
+ puts "Recent trend: #{change_pct}% over #{@price_history.length} checks"
95
+ end
96
+
97
+ display_balances
98
+ end
99
+
100
+ def display_balances
101
+ begin
102
+ base_currency = @market_id.split("-").first
103
+ quote_currency = @market_id.split("-").last
104
+
105
+ base_balance = @client.balance(base_currency)
106
+ quote_balance = @client.balance(quote_currency)
107
+
108
+ puts "\nBalances:"
109
+ puts "#{base_currency}: #{base_balance.available_amount} available"
110
+ puts "#{quote_currency}: #{quote_balance.available_amount} available"
111
+ rescue BudaApi::ApiError => e
112
+ puts "Could not fetch balances: #{e.message}"
113
+ end
114
+ end
115
+
116
+ def check_trading_opportunities(current_price)
117
+ return if @price_history.length < 5
118
+
119
+ # Simple moving average strategy
120
+ recent_prices = @price_history.last(5).map { |h| h[:price] }
121
+ sma = recent_prices.sum / recent_prices.length
122
+
123
+ puts "\nStrategy Analysis:"
124
+ puts "Current price: #{current_price}"
125
+ puts "5-period SMA: #{sma.round(2)}"
126
+
127
+ price_above_sma = current_price > sma * 1.02 # 2% above SMA
128
+ price_below_sma = current_price < sma * 0.98 # 2% below SMA
129
+
130
+ if price_above_sma
131
+ puts "🔴 Price significantly above SMA - Consider selling"
132
+ # suggest_sell_order(current_price)
133
+ elsif price_below_sma
134
+ puts "🟢 Price significantly below SMA - Consider buying"
135
+ # suggest_buy_order(current_price)
136
+ else
137
+ puts "📊 Price near SMA - No clear signal"
138
+ end
139
+
140
+ # Check for rapid price changes
141
+ if @price_history.length >= 3
142
+ recent_change = (current_price - @price_history[-3][:price]) / @price_history[-3][:price]
143
+ if recent_change.abs > 0.05 # 5% change in 3 periods
144
+ direction = recent_change > 0 ? "📈 UP" : "📉 DOWN"
145
+ puts "⚠️ Rapid price movement detected: #{direction} #{(recent_change * 100).round(2)}%"
146
+ end
147
+ end
148
+ end
149
+
150
+ def suggest_buy_order(current_price)
151
+ puts "\n💡 Buy Order Suggestion:"
152
+
153
+ # Calculate suggested buy price (slightly below current price)
154
+ suggested_price = current_price * 0.995
155
+ suggested_amount = 0.001 # Small test amount
156
+
157
+ begin
158
+ quotation = @client.quotation(@market_id, "bid_given_size", suggested_amount)
159
+
160
+ puts "Suggested buy order:"
161
+ puts " Amount: #{suggested_amount} BTC"
162
+ puts " Price: #{suggested_price}"
163
+ puts " Estimated cost: #{quotation.quote_balance_change}"
164
+ puts " Fee: #{quotation.fee}"
165
+ puts
166
+ puts "⚠️ This is just a suggestion. Review carefully before placing any orders!"
167
+
168
+ # Uncomment to actually place orders (BE VERY CAREFUL!)
169
+ # place_buy_order(suggested_amount, suggested_price)
170
+
171
+ rescue BudaApi::ApiError => e
172
+ puts "Could not get quotation: #{e.message}"
173
+ end
174
+ end
175
+
176
+ def suggest_sell_order(current_price)
177
+ puts "\n💡 Sell Order Suggestion:"
178
+
179
+ # Calculate suggested sell price (slightly above current price)
180
+ suggested_price = current_price * 1.005
181
+ suggested_amount = 0.001 # Small test amount
182
+
183
+ begin
184
+ quotation = @client.quotation(@market_id, "ask_given_size", suggested_amount)
185
+
186
+ puts "Suggested sell order:"
187
+ puts " Amount: #{suggested_amount} BTC"
188
+ puts " Price: #{suggested_price}"
189
+ puts " Estimated proceeds: #{quotation.quote_balance_change}"
190
+ puts " Fee: #{quotation.fee}"
191
+ puts
192
+ puts "⚠️ This is just a suggestion. Review carefully before placing any orders!"
193
+
194
+ # Uncomment to actually place orders (BE VERY CAREFUL!)
195
+ # place_sell_order(suggested_amount, suggested_price)
196
+
197
+ rescue BudaApi::ApiError => e
198
+ puts "Could not get quotation: #{e.message}"
199
+ end
200
+ end
201
+
202
+ # DANGEROUS: Only uncomment if you want to place actual orders
203
+ def place_buy_order(amount, price)
204
+ puts "🚨 PLACING ACTUAL BUY ORDER 🚨"
205
+
206
+ order = @client.place_order(@market_id, "Bid", "limit", amount, price)
207
+ puts "✅ Buy order placed: ##{order.id}"
208
+
209
+ # Store order ID for potential cancellation
210
+ @active_orders ||= []
211
+ @active_orders << order.id
212
+ end
213
+
214
+ def place_sell_order(amount, price)
215
+ puts "🚨 PLACING ACTUAL SELL ORDER 🚨"
216
+
217
+ order = @client.place_order(@market_id, "Ask", "limit", amount, price)
218
+ puts "✅ Sell order placed: ##{order.id}"
219
+
220
+ # Store order ID for potential cancellation
221
+ @active_orders ||= []
222
+ @active_orders << order.id
223
+ end
224
+
225
+ def cancel_all_orders
226
+ return unless @active_orders&.any?
227
+
228
+ puts "Cancelling all active orders..."
229
+
230
+ @active_orders.each do |order_id|
231
+ begin
232
+ @client.cancel_order(order_id)
233
+ puts "Cancelled order ##{order_id}"
234
+ rescue BudaApi::ApiError => e
235
+ puts "Could not cancel order ##{order_id}: #{e.message}"
236
+ end
237
+ end
238
+
239
+ @active_orders.clear
240
+ end
241
+ end
242
+
243
+ def main
244
+ puts "=== Buda API Ruby SDK - Trading Bot Example ==="
245
+ puts
246
+
247
+ # Load credentials
248
+ api_key = ENV["BUDA_API_KEY"]
249
+ api_secret = ENV["BUDA_API_SECRET"]
250
+
251
+ if api_key.nil? || api_secret.nil?
252
+ puts "ERROR: Please set BUDA_API_KEY and BUDA_API_SECRET environment variables"
253
+ return
254
+ end
255
+
256
+ # Configure for safe testing
257
+ BudaApi.configure do |config|
258
+ config.debug_mode = false # Disable debug to reduce noise
259
+ config.timeout = 30
260
+ end
261
+
262
+ market_id = ARGV[0] || "BTC-CLP"
263
+
264
+ puts "IMPORTANT DISCLAIMER:"
265
+ puts "==================="
266
+ puts "This is a DEMO trading bot for educational purposes only."
267
+ puts "It does NOT place actual orders by default."
268
+ puts "Trading cryptocurrencies involves substantial risk of loss."
269
+ puts "Never risk more than you can afford to lose."
270
+ puts "Always test thoroughly in a staging environment first."
271
+ puts
272
+ puts "Press Enter to continue or Ctrl+C to exit..."
273
+ gets
274
+
275
+ bot = SimpleTradingBot.new(api_key, api_secret, market_id)
276
+ bot.start
277
+ end
278
+
279
+ main if __FILE__ == $0
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BudaApi
4
+ # Authenticated API client for endpoints that require API key authentication
5
+ class AuthenticatedClient < PublicClient
6
+ attr_reader :api_key, :api_secret
7
+
8
+ # Initialize an authenticated client
9
+ #
10
+ # @param api_key [String] your API key
11
+ # @param api_secret [String] your API secret
12
+ # @param options [Hash] additional options
13
+ # @example
14
+ # client = BudaApi::AuthenticatedClient.new(
15
+ # api_key: "your_api_key",
16
+ # api_secret: "your_api_secret",
17
+ # debug_mode: true
18
+ # )
19
+ def initialize(api_key:, api_secret:, **options)
20
+ validate_credentials(api_key, api_secret)
21
+
22
+ @api_key = api_key
23
+ @api_secret = api_secret
24
+
25
+ super(options)
26
+
27
+ BudaApi::Logger.info("Authenticated client initialized")
28
+ end
29
+
30
+ # Get balance for a specific currency
31
+ #
32
+ # @param currency [String] currency code
33
+ # @return [Balance] current balance information
34
+ # @example
35
+ # balance = client.balance("BTC")
36
+ # puts "Available: #{balance.available_amount}"
37
+ # puts "Frozen: #{balance.frozen_amount}"
38
+ def balance(currency)
39
+ validate_required_params({ currency: currency }, [:currency])
40
+ validate_param_values({ currency: currency }, { currency: Currency::ALL })
41
+
42
+ BudaApi::Logger.info("Fetching balance for #{currency}")
43
+
44
+ response = get("balances/#{currency}")
45
+ Balance.new(response["balance"])
46
+ end
47
+
48
+ # Get balance events with pagination
49
+ #
50
+ # @param currencies [Array<String>] list of currencies to filter by
51
+ # @param event_names [Array<String>] list of event types to filter by
52
+ # @param page [Integer, nil] page number
53
+ # @param per_page [Integer, nil] items per page
54
+ # @param relevant [Boolean, nil] filter for relevant events only
55
+ # @return [Hash] balance events with pagination info
56
+ def balance_events(currencies:, event_names:, page: nil, per_page: nil, relevant: nil)
57
+ validate_required_params({
58
+ currencies: currencies,
59
+ event_names: event_names
60
+ }, [:currencies, :event_names])
61
+
62
+ # Validate currency and event parameters
63
+ currencies.each do |currency|
64
+ validate_param_values({ currency: currency }, { currency: Currency::ALL })
65
+ end
66
+
67
+ event_names.each do |event|
68
+ validate_param_values({ event: event }, { event: BalanceEvent::ALL })
69
+ end
70
+
71
+ params = normalize_params({
72
+ "currencies[]" => currencies,
73
+ "event_names[]" => event_names,
74
+ page: page,
75
+ per: per_page,
76
+ relevant: relevant
77
+ })
78
+
79
+ BudaApi::Logger.info("Fetching balance events with params: #{params}")
80
+
81
+ response = get("balance_events", params)
82
+ {
83
+ events: response["balance_events"] || [],
84
+ total_count: response["total_count"]
85
+ }
86
+ end
87
+
88
+ # Place a new order
89
+ #
90
+ # @param market_id [String] market identifier
91
+ # @param order_type [String] "Ask" (sell) or "Bid" (buy)
92
+ # @param price_type [String] "market" or "limit"
93
+ # @param amount [Float] amount to trade
94
+ # @param limit [Float, nil] limit price (required for limit orders)
95
+ # @return [Order] created order
96
+ # @example
97
+ # # Place a limit buy order
98
+ # order = client.place_order("BTC-CLP", "Bid", "limit", 0.001, 50000000)
99
+ #
100
+ # # Place a market sell order
101
+ # order = client.place_order("BTC-CLP", "Ask", "market", 0.001)
102
+ def place_order(market_id, order_type, price_type, amount, limit = nil)
103
+ validate_required_params({
104
+ market_id: market_id,
105
+ order_type: order_type,
106
+ price_type: price_type,
107
+ amount: amount
108
+ }, [:market_id, :order_type, :price_type, :amount])
109
+
110
+ validate_param_values({
111
+ market_id: market_id,
112
+ order_type: order_type,
113
+ price_type: price_type
114
+ }, {
115
+ market_id: Market::ALL,
116
+ order_type: OrderType::ALL,
117
+ price_type: PriceType::ALL
118
+ })
119
+
120
+ if price_type == PriceType::LIMIT && limit.nil?
121
+ raise ValidationError, "Limit price is required for limit orders"
122
+ end
123
+
124
+ order_payload = {
125
+ type: order_type,
126
+ price_type: price_type,
127
+ amount: amount.to_s
128
+ }
129
+ order_payload[:limit] = limit.to_s if limit
130
+
131
+ BudaApi::Logger.info("Placing #{order_type} #{price_type} order for #{amount} on #{market_id}")
132
+
133
+ response = post("markets/#{market_id}/orders", body: order_payload)
134
+ Order.new(response["order"])
135
+ end
136
+
137
+ # Get orders with pagination
138
+ #
139
+ # @param market_id [String] market identifier
140
+ # @param page [Integer, nil] page number
141
+ # @param per_page [Integer, nil] orders per page (max 300)
142
+ # @param state [String, nil] filter by order state
143
+ # @param minimum_exchanged [Float, nil] minimum exchanged amount filter
144
+ # @return [OrderPages] paginated orders
145
+ # @example
146
+ # orders = client.orders("BTC-CLP", page: 1, per_page: 50, state: "traded")
147
+ # puts "Found #{orders.count} orders on page #{orders.meta.current_page}"
148
+ def orders(market_id, page: nil, per_page: nil, state: nil, minimum_exchanged: nil)
149
+ validate_required_params({ market_id: market_id }, [:market_id])
150
+ validate_param_values({ market_id: market_id }, { market_id: Market::ALL })
151
+
152
+ if per_page && per_page > Limits::ORDERS_PER_PAGE
153
+ raise ValidationError, "per_page cannot exceed #{Limits::ORDERS_PER_PAGE}"
154
+ end
155
+
156
+ validate_param_values({ state: state }, { state: OrderState::ALL }) if state
157
+
158
+ params = normalize_params({
159
+ per: per_page,
160
+ page: page,
161
+ state: state,
162
+ minimum_exchanged: minimum_exchanged
163
+ })
164
+
165
+ BudaApi::Logger.info("Fetching orders for #{market_id} with params: #{params}")
166
+
167
+ response = get("markets/#{market_id}/orders", params)
168
+ OrderPages.new(response["orders"] || [], response["meta"])
169
+ end
170
+
171
+ # Get specific order details
172
+ #
173
+ # @param order_id [Integer] order ID
174
+ # @return [Order] order details
175
+ # @example
176
+ # order = client.order_details(123456)
177
+ # puts "Order state: #{order.state}"
178
+ # puts "Filled: #{order.filled_percentage}%"
179
+ def order_details(order_id)
180
+ validate_required_params({ order_id: order_id }, [:order_id])
181
+
182
+ BudaApi::Logger.info("Fetching details for order #{order_id}")
183
+
184
+ response = get("orders/#{order_id}")
185
+ Order.new(response["order"])
186
+ end
187
+
188
+ # Cancel an order
189
+ #
190
+ # @param order_id [Integer] order ID to cancel
191
+ # @return [Order] updated order with canceling state
192
+ # @example
193
+ # cancelled_order = client.cancel_order(123456)
194
+ # puts "Order #{cancelled_order.id} is now #{cancelled_order.state}"
195
+ def cancel_order(order_id)
196
+ validate_required_params({ order_id: order_id }, [:order_id])
197
+
198
+ BudaApi::Logger.info("Cancelling order #{order_id}")
199
+
200
+ response = put("orders/#{order_id}", body: { state: OrderState::CANCELING })
201
+ Order.new(response["order"])
202
+ end
203
+
204
+ # Batch order operations (cancel and/or place multiple orders)
205
+ #
206
+ # @param cancel_orders [Array<Integer>] list of order IDs to cancel
207
+ # @param place_orders [Array<Hash>] list of order specifications to place
208
+ # @return [Hash] batch operation results
209
+ # @example
210
+ # # Cancel some orders and place new ones atomically
211
+ # result = client.batch_orders(
212
+ # cancel_orders: [123, 456],
213
+ # place_orders: [
214
+ # { type: "Bid", price_type: "limit", amount: "0.001", limit: "50000" }
215
+ # ]
216
+ # )
217
+ def batch_orders(cancel_orders: [], place_orders: [])
218
+ diff_operations = []
219
+
220
+ cancel_orders.each do |order_id|
221
+ diff_operations << { mode: "cancel", order_id: order_id }
222
+ end
223
+
224
+ place_orders.each do |order_spec|
225
+ diff_operations << { mode: "place", order: order_spec }
226
+ end
227
+
228
+ if diff_operations.empty?
229
+ raise ValidationError, "At least one cancel or place operation must be specified"
230
+ end
231
+
232
+ BudaApi::Logger.info("Executing batch order operations: #{diff_operations.length} operations")
233
+
234
+ response = post("orders", body: { diff: diff_operations })
235
+ response
236
+ end
237
+
238
+ # Get withdrawals with pagination
239
+ #
240
+ # @param currency [String] currency code
241
+ # @param page [Integer, nil] page number
242
+ # @param per_page [Integer, nil] withdrawals per page
243
+ # @param state [String, nil] filter by withdrawal state
244
+ # @return [Hash] withdrawals with pagination metadata
245
+ # @example
246
+ # withdrawals = client.withdrawals("BTC", page: 1, per_page: 20)
247
+ def withdrawals(currency, page: nil, per_page: nil, state: nil)
248
+ validate_required_params({ currency: currency }, [:currency])
249
+ validate_param_values({ currency: currency }, { currency: Currency::ALL })
250
+
251
+ if per_page && per_page > Limits::TRANSFERS_PER_PAGE
252
+ raise ValidationError, "per_page cannot exceed #{Limits::TRANSFERS_PER_PAGE}"
253
+ end
254
+
255
+ params = normalize_params({
256
+ per: per_page,
257
+ page: page,
258
+ state: state
259
+ })
260
+
261
+ BudaApi::Logger.info("Fetching withdrawals for #{currency} with params: #{params}")
262
+
263
+ response = get("currencies/#{currency}/withdrawals", params)
264
+ {
265
+ withdrawals: (response["withdrawals"] || []).map { |w| Withdrawal.new(w) },
266
+ meta: PaginationMeta.new(response["meta"] || {})
267
+ }
268
+ end
269
+
270
+ # Get deposits with pagination
271
+ #
272
+ # @param currency [String] currency code
273
+ # @param page [Integer, nil] page number
274
+ # @param per_page [Integer, nil] deposits per page
275
+ # @param state [String, nil] filter by deposit state
276
+ # @return [Hash] deposits with pagination metadata
277
+ # @example
278
+ # deposits = client.deposits("BTC", page: 1, per_page: 20)
279
+ def deposits(currency, page: nil, per_page: nil, state: nil)
280
+ validate_required_params({ currency: currency }, [:currency])
281
+ validate_param_values({ currency: currency }, { currency: Currency::ALL })
282
+
283
+ if per_page && per_page > Limits::TRANSFERS_PER_PAGE
284
+ raise ValidationError, "per_page cannot exceed #{Limits::TRANSFERS_PER_PAGE}"
285
+ end
286
+
287
+ params = normalize_params({
288
+ per: per_page,
289
+ page: page,
290
+ state: state
291
+ })
292
+
293
+ BudaApi::Logger.info("Fetching deposits for #{currency} with params: #{params}")
294
+
295
+ response = get("currencies/#{currency}/deposits", params)
296
+ {
297
+ deposits: (response["deposits"] || []).map { |d| Deposit.new(d) },
298
+ meta: PaginationMeta.new(response["meta"] || {})
299
+ }
300
+ end
301
+
302
+ # Create a withdrawal
303
+ #
304
+ # @param currency [String] currency to withdraw
305
+ # @param amount [Float] amount to withdraw
306
+ # @param target_address [String] destination address
307
+ # @param amount_includes_fee [Boolean] whether amount includes the fee
308
+ # @param simulate [Boolean] whether to simulate the withdrawal (not execute)
309
+ # @return [Withdrawal] withdrawal details
310
+ # @example
311
+ # # Simulate a withdrawal first
312
+ # simulation = client.withdrawal("BTC", 0.01, "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", simulate: true)
313
+ # puts "Fee would be: #{simulation.fee}"
314
+ #
315
+ # # Execute the actual withdrawal
316
+ # withdrawal = client.withdrawal("BTC", 0.01, "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
317
+ def withdrawal(currency, amount, target_address, amount_includes_fee: true, simulate: false)
318
+ validate_required_params({
319
+ currency: currency,
320
+ amount: amount,
321
+ target_address: target_address
322
+ }, [:currency, :amount, :target_address])
323
+
324
+ validate_param_values({ currency: currency }, { currency: Currency::ALL })
325
+
326
+ withdrawal_payload = {
327
+ withdrawal_data: {
328
+ target_address: target_address
329
+ },
330
+ amount: amount.to_s,
331
+ currency: currency,
332
+ simulate: simulate,
333
+ amount_includes_fee: amount_includes_fee
334
+ }
335
+
336
+ action = simulate ? "Simulating" : "Creating"
337
+ BudaApi::Logger.info("#{action} withdrawal: #{amount} #{currency} to #{target_address}")
338
+
339
+ response = post("currencies/#{currency}/withdrawals", body: withdrawal_payload)
340
+ Withdrawal.new(response["withdrawal"])
341
+ end
342
+
343
+ # Simulate a withdrawal (without executing)
344
+ #
345
+ # @param currency [String] currency to withdraw
346
+ # @param amount [Float] amount to withdraw
347
+ # @param amount_includes_fee [Boolean] whether amount includes the fee
348
+ # @return [Withdrawal] simulated withdrawal with fee information
349
+ # @example
350
+ # simulation = client.simulate_withdrawal("BTC", 0.01)
351
+ # puts "Withdrawal fee: #{simulation.fee}"
352
+ # puts "You will receive: #{simulation.amount.amount - simulation.fee.amount}"
353
+ def simulate_withdrawal(currency, amount, amount_includes_fee: true)
354
+ withdrawal(currency, amount, nil, amount_includes_fee: amount_includes_fee, simulate: true)
355
+ end
356
+
357
+ private
358
+
359
+ def validate_credentials(api_key, api_secret)
360
+ if api_key.nil? || api_key.empty?
361
+ raise ConfigurationError, "API key is required for authenticated client"
362
+ end
363
+
364
+ if api_secret.nil? || api_secret.empty?
365
+ raise ConfigurationError, "API secret is required for authenticated client"
366
+ end
367
+ end
368
+
369
+ # Add HMAC authentication headers to requests
370
+ def add_authentication_headers(method, path, body, headers)
371
+ nonce = generate_nonce
372
+ message = build_signature_message(method, path, body, nonce)
373
+ signature = generate_signature(message)
374
+
375
+ headers.merge({
376
+ "X-SBTC-APIKEY" => @api_key,
377
+ "X-SBTC-NONCE" => nonce,
378
+ "X-SBTC-SIGNATURE" => signature,
379
+ "Content-Type" => "application/json"
380
+ })
381
+ end
382
+
383
+ def generate_nonce
384
+ (Time.now.to_f * 1000000).to_i.to_s
385
+ end
386
+
387
+ def build_signature_message(method, path, body, nonce)
388
+ components = [method.upcase, path]
389
+
390
+ if body && !body.empty?
391
+ encoded_body = Base64.strict_encode64(body.to_json)
392
+ components << encoded_body
393
+ end
394
+
395
+ components << nonce
396
+ components.join(" ")
397
+ end
398
+
399
+ def generate_signature(message)
400
+ OpenSSL::HMAC.hexdigest("sha384", @api_secret, message)
401
+ end
402
+ end
403
+ end