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.
- checksums.yaml +7 -0
- data/.gitignore +57 -0
- data/.rspec +3 -0
- data/.rubocop.yml +49 -0
- data/CHANGELOG.md +53 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +526 -0
- data/Rakefile +37 -0
- data/buda_api.gemspec +38 -0
- data/examples/.env.example +11 -0
- data/examples/authenticated_api_example.rb +213 -0
- data/examples/error_handling_example.rb +221 -0
- data/examples/public_api_example.rb +142 -0
- data/examples/trading_bot_example.rb +279 -0
- data/lib/buda_api/authenticated_client.rb +403 -0
- data/lib/buda_api/client.rb +248 -0
- data/lib/buda_api/constants.rb +163 -0
- data/lib/buda_api/errors.rb +60 -0
- data/lib/buda_api/logger.rb +99 -0
- data/lib/buda_api/models.rb +365 -0
- data/lib/buda_api/public_client.rb +231 -0
- data/lib/buda_api/version.rb +5 -0
- data/lib/buda_api.rb +81 -0
- metadata +194 -0
@@ -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
|