buda_api 1.0.0 → 1.0.1

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,541 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BudaApi
4
+ module AI
5
+ # Natural language interface for trading operations
6
+ class NaturalLanguageTrader
7
+ TRADING_FUNCTIONS = [
8
+ {
9
+ name: "place_order",
10
+ description: "Place a buy or sell order on the exchange",
11
+ parameters: {
12
+ type: "object",
13
+ properties: {
14
+ market_id: {
15
+ type: "string",
16
+ description: "Trading pair (e.g., BTC-CLP)",
17
+ enum: BudaApi::Constants::Market::ALL
18
+ },
19
+ side: {
20
+ type: "string",
21
+ enum: ["buy", "sell"],
22
+ description: "Whether to buy or sell"
23
+ },
24
+ amount: {
25
+ type: "number",
26
+ description: "Amount to trade (in base currency)"
27
+ },
28
+ price: {
29
+ type: "number",
30
+ description: "Price per unit (optional for market orders)"
31
+ },
32
+ order_type: {
33
+ type: "string",
34
+ enum: ["market", "limit"],
35
+ description: "Order type - market executes immediately, limit waits for price"
36
+ }
37
+ },
38
+ required: ["market_id", "side", "amount"]
39
+ }
40
+ },
41
+ {
42
+ name: "check_balance",
43
+ description: "Check account balance for a specific currency",
44
+ parameters: {
45
+ type: "object",
46
+ properties: {
47
+ currency: {
48
+ type: "string",
49
+ description: "Currency code (e.g., BTC, CLP)",
50
+ enum: BudaApi::Constants::Currency::ALL
51
+ }
52
+ },
53
+ required: ["currency"]
54
+ }
55
+ },
56
+ {
57
+ name: "get_market_data",
58
+ description: "Get current market data including price and order book",
59
+ parameters: {
60
+ type: "object",
61
+ properties: {
62
+ market_id: {
63
+ type: "string",
64
+ description: "Trading pair (e.g., BTC-CLP)",
65
+ enum: BudaApi::Constants::Market::ALL
66
+ }
67
+ },
68
+ required: ["market_id"]
69
+ }
70
+ },
71
+ {
72
+ name: "cancel_order",
73
+ description: "Cancel an existing order",
74
+ parameters: {
75
+ type: "object",
76
+ properties: {
77
+ order_id: {
78
+ type: "integer",
79
+ description: "ID of the order to cancel"
80
+ }
81
+ },
82
+ required: ["order_id"]
83
+ }
84
+ },
85
+ {
86
+ name: "get_order_history",
87
+ description: "Get recent order history for a market",
88
+ parameters: {
89
+ type: "object",
90
+ properties: {
91
+ market_id: {
92
+ type: "string",
93
+ description: "Trading pair (e.g., BTC-CLP)",
94
+ enum: BudaApi::Constants::Market::ALL
95
+ },
96
+ limit: {
97
+ type: "integer",
98
+ description: "Number of orders to retrieve (max 100)",
99
+ maximum: 100
100
+ }
101
+ },
102
+ required: ["market_id"]
103
+ }
104
+ },
105
+ {
106
+ name: "get_quotation",
107
+ description: "Get price quotation for a potential trade",
108
+ parameters: {
109
+ type: "object",
110
+ properties: {
111
+ market_id: {
112
+ type: "string",
113
+ description: "Trading pair (e.g., BTC-CLP)",
114
+ enum: BudaApi::Constants::Market::ALL
115
+ },
116
+ side: {
117
+ type: "string",
118
+ enum: ["buy", "sell"],
119
+ description: "Whether you want to buy or sell"
120
+ },
121
+ amount: {
122
+ type: "number",
123
+ description: "Amount you want to trade"
124
+ }
125
+ },
126
+ required: ["market_id", "side", "amount"]
127
+ }
128
+ }
129
+ ].freeze
130
+
131
+ def initialize(client, llm_provider: :openai)
132
+ @client = client
133
+ @llm = RubyLLM.new(
134
+ provider: llm_provider,
135
+ functions: TRADING_FUNCTIONS
136
+ )
137
+ @conversation_history = []
138
+
139
+ BudaApi::Logger.info("Natural Language Trader initialized")
140
+ end
141
+
142
+ # Execute a natural language trading command
143
+ #
144
+ # @param input [String] natural language command
145
+ # @param confirm_trades [Boolean] whether to confirm before placing orders
146
+ # @return [Hash] execution result
147
+ # @example
148
+ # trader = BudaApi.natural_language_trader(client)
149
+ # result = trader.execute_command("Check my BTC balance")
150
+ # result = trader.execute_command("Buy 0.001 BTC at market price")
151
+ def execute_command(input, confirm_trades: true)
152
+ BudaApi::Logger.info("Processing natural language command: #{input}")
153
+
154
+ # Add to conversation history
155
+ @conversation_history << { role: "user", content: input }
156
+
157
+ begin
158
+ response = @llm.complete(
159
+ messages: build_conversation_messages,
160
+ system_prompt: build_system_prompt,
161
+ max_tokens: 500
162
+ )
163
+
164
+ # Add assistant response to history
165
+ @conversation_history << { role: "assistant", content: response.content }
166
+
167
+ if response.function_call
168
+ result = execute_function_with_confirmation(response.function_call, confirm_trades)
169
+
170
+ # Add function result to conversation
171
+ @conversation_history << {
172
+ role: "function",
173
+ name: response.function_call.name,
174
+ content: result.to_json
175
+ }
176
+
177
+ result
178
+ else
179
+ {
180
+ type: :text_response,
181
+ content: response.content,
182
+ timestamp: Time.now
183
+ }
184
+ end
185
+
186
+ rescue => e
187
+ error_msg = "Failed to process command: #{e.message}"
188
+ BudaApi::Logger.error(error_msg)
189
+
190
+ {
191
+ type: :error,
192
+ error: error_msg,
193
+ timestamp: Time.now
194
+ }
195
+ end
196
+ end
197
+
198
+ # Clear conversation history
199
+ def clear_history
200
+ @conversation_history.clear
201
+ BudaApi::Logger.info("Conversation history cleared")
202
+ end
203
+
204
+ # Get conversation history
205
+ # @return [Array<Hash>] conversation messages
206
+ def conversation_history
207
+ @conversation_history.dup
208
+ end
209
+
210
+ # Process batch commands
211
+ # @param commands [Array<String>] list of natural language commands
212
+ # @param confirm_trades [Boolean] whether to confirm trades
213
+ # @return [Array<Hash>] results for each command
214
+ def execute_batch(commands, confirm_trades: true)
215
+ results = []
216
+
217
+ commands.each_with_index do |command, index|
218
+ BudaApi::Logger.info("Processing batch command #{index + 1}/#{commands.length}")
219
+ result = execute_command(command, confirm_trades: confirm_trades)
220
+ results << result
221
+
222
+ # Add small delay between commands to avoid rate limiting
223
+ sleep(0.5) unless index == commands.length - 1
224
+ end
225
+
226
+ results
227
+ end
228
+
229
+ private
230
+
231
+ def build_system_prompt
232
+ """
233
+ You are a cryptocurrency trading assistant for the Buda exchange in Chile.
234
+
235
+ CAPABILITIES:
236
+ - Check account balances for any supported currency
237
+ - Get real-time market data and prices
238
+ - Place buy and sell orders (market and limit orders)
239
+ - Cancel existing orders
240
+ - Get order history and trading records
241
+ - Provide price quotations for potential trades
242
+
243
+ SUPPORTED MARKETS: #{BudaApi::Constants::Market::ALL.join(', ')}
244
+ SUPPORTED CURRENCIES: #{BudaApi::Constants::Currency::ALL.join(', ')}
245
+
246
+ GUIDELINES:
247
+ 1. Always confirm risky operations like placing orders
248
+ 2. Be precise with numbers and avoid rounding errors
249
+ 3. Explain what each function does before executing
250
+ 4. Provide helpful context about market conditions
251
+ 5. Use Chilean Peso (CLP) as the default quote currency
252
+ 6. Warn about risks and fees when appropriate
253
+
254
+ IMPORTANT SAFETY RULES:
255
+ - Always double-check order parameters
256
+ - Warn about large orders that could impact the market
257
+ - Suggest limit orders for better price control
258
+ - Remind users about trading fees
259
+
260
+ When users ask about prices, trading, or market data, use the appropriate functions.
261
+ Always be helpful, accurate, and prioritize user safety.
262
+ """
263
+ end
264
+
265
+ def build_conversation_messages
266
+ # Keep last 10 messages to maintain context while avoiding token limits
267
+ recent_history = @conversation_history.last(10)
268
+
269
+ messages = []
270
+ recent_history.each do |msg|
271
+ case msg[:role]
272
+ when "user", "assistant"
273
+ messages << { role: msg[:role], content: msg[:content] }
274
+ when "function"
275
+ messages << {
276
+ role: "function",
277
+ name: msg[:name],
278
+ content: msg[:content]
279
+ }
280
+ end
281
+ end
282
+
283
+ messages
284
+ end
285
+
286
+ def execute_function_with_confirmation(function_call, confirm_trades)
287
+ function_name = function_call.name
288
+ arguments = function_call.arguments
289
+
290
+ BudaApi::Logger.info("Executing function: #{function_name} with args: #{arguments}")
291
+
292
+ # Check if this is a trading operation that needs confirmation
293
+ if trading_function?(function_name) && confirm_trades
294
+ confirmation = request_confirmation(function_name, arguments)
295
+ return confirmation unless confirmation[:confirmed]
296
+ end
297
+
298
+ case function_name
299
+ when "place_order"
300
+ place_order_from_params(arguments)
301
+ when "check_balance"
302
+ check_balance_from_params(arguments)
303
+ when "get_market_data"
304
+ get_market_data_from_params(arguments)
305
+ when "cancel_order"
306
+ cancel_order_from_params(arguments)
307
+ when "get_order_history"
308
+ get_order_history_from_params(arguments)
309
+ when "get_quotation"
310
+ get_quotation_from_params(arguments)
311
+ else
312
+ {
313
+ type: :error,
314
+ error: "Unknown function: #{function_name}",
315
+ timestamp: Time.now
316
+ }
317
+ end
318
+ end
319
+
320
+ def trading_function?(function_name)
321
+ %w[place_order cancel_order].include?(function_name)
322
+ end
323
+
324
+ def request_confirmation(function_name, arguments)
325
+ case function_name
326
+ when "place_order"
327
+ {
328
+ type: :confirmation_required,
329
+ message: "⚠️ About to place #{arguments['side']} order: #{arguments['amount']} #{arguments['market_id']} at #{arguments['price'] || 'market price'}. Confirm?",
330
+ function: function_name,
331
+ arguments: arguments,
332
+ confirmed: false,
333
+ timestamp: Time.now
334
+ }
335
+ when "cancel_order"
336
+ {
337
+ type: :confirmation_required,
338
+ message: "⚠️ About to cancel order ##{arguments['order_id']}. Confirm?",
339
+ function: function_name,
340
+ arguments: arguments,
341
+ confirmed: false,
342
+ timestamp: Time.now
343
+ }
344
+ end
345
+ end
346
+
347
+ def place_order_from_params(params)
348
+ market_id = params["market_id"]
349
+ side = params["side"]
350
+ amount = params["amount"].to_f
351
+ price = params["price"]&.to_f
352
+ order_type = params["order_type"] || (price ? "limit" : "market")
353
+
354
+ # Convert side to Buda API format
355
+ buda_order_type = side == "buy" ? "Bid" : "Ask"
356
+ buda_price_type = order_type == "market" ? "market" : "limit"
357
+
358
+ begin
359
+ order = @client.place_order(market_id, buda_order_type, buda_price_type, amount, price)
360
+
361
+ {
362
+ type: :order_placed,
363
+ order_id: order.id,
364
+ market_id: market_id,
365
+ side: side,
366
+ amount: amount,
367
+ price: price,
368
+ order_type: order_type,
369
+ status: order.state,
370
+ message: "✅ Order placed successfully! Order ID: #{order.id}",
371
+ data: order,
372
+ timestamp: Time.now
373
+ }
374
+ rescue BudaApi::ApiError => e
375
+ {
376
+ type: :order_failed,
377
+ error: e.message,
378
+ market_id: market_id,
379
+ side: side,
380
+ amount: amount,
381
+ message: "❌ Failed to place order: #{e.message}",
382
+ timestamp: Time.now
383
+ }
384
+ end
385
+ end
386
+
387
+ def check_balance_from_params(params)
388
+ currency = params["currency"]
389
+
390
+ begin
391
+ balance = @client.balance(currency)
392
+
393
+ {
394
+ type: :balance_info,
395
+ currency: currency,
396
+ total: balance.amount.amount,
397
+ available: balance.available_amount.amount,
398
+ frozen: balance.frozen_amount.amount,
399
+ pending_withdrawals: balance.pending_withdraw_amount.amount,
400
+ message: "💰 #{currency} Balance: #{balance.available_amount} available, #{balance.frozen_amount} frozen",
401
+ data: balance,
402
+ timestamp: Time.now
403
+ }
404
+ rescue BudaApi::ApiError => e
405
+ {
406
+ type: :balance_error,
407
+ error: e.message,
408
+ currency: currency,
409
+ message: "❌ Failed to get balance: #{e.message}",
410
+ timestamp: Time.now
411
+ }
412
+ end
413
+ end
414
+
415
+ def get_market_data_from_params(params)
416
+ market_id = params["market_id"]
417
+
418
+ begin
419
+ ticker = @client.ticker(market_id)
420
+ order_book = @client.order_book(market_id)
421
+
422
+ {
423
+ type: :market_data,
424
+ market_id: market_id,
425
+ price: ticker.last_price.amount,
426
+ change_24h: ticker.price_variation_24h,
427
+ volume: ticker.volume.amount,
428
+ best_ask: order_book.best_ask.price,
429
+ best_bid: order_book.best_bid.price,
430
+ spread: order_book.spread_percentage,
431
+ message: "📊 #{market_id}: #{ticker.last_price} (#{ticker.price_variation_24h > 0 ? '+' : ''}#{ticker.price_variation_24h}%)",
432
+ data: { ticker: ticker, order_book: order_book },
433
+ timestamp: Time.now
434
+ }
435
+ rescue BudaApi::ApiError => e
436
+ {
437
+ type: :market_data_error,
438
+ error: e.message,
439
+ market_id: market_id,
440
+ message: "❌ Failed to get market data: #{e.message}",
441
+ timestamp: Time.now
442
+ }
443
+ end
444
+ end
445
+
446
+ def cancel_order_from_params(params)
447
+ order_id = params["order_id"]
448
+
449
+ begin
450
+ cancelled_order = @client.cancel_order(order_id)
451
+
452
+ {
453
+ type: :order_cancelled,
454
+ order_id: order_id,
455
+ status: cancelled_order.state,
456
+ message: "✅ Order ##{order_id} cancelled successfully",
457
+ data: cancelled_order,
458
+ timestamp: Time.now
459
+ }
460
+ rescue BudaApi::ApiError => e
461
+ {
462
+ type: :cancel_failed,
463
+ error: e.message,
464
+ order_id: order_id,
465
+ message: "❌ Failed to cancel order: #{e.message}",
466
+ timestamp: Time.now
467
+ }
468
+ end
469
+ end
470
+
471
+ def get_order_history_from_params(params)
472
+ market_id = params["market_id"]
473
+ limit = [params["limit"]&.to_i || 10, 100].min
474
+
475
+ begin
476
+ orders_result = @client.orders(market_id, per_page: limit)
477
+
478
+ {
479
+ type: :order_history,
480
+ market_id: market_id,
481
+ orders_count: orders_result.count,
482
+ orders: orders_result.orders.map do |order|
483
+ {
484
+ id: order.id,
485
+ type: order.type,
486
+ amount: order.amount.amount,
487
+ price: order.limit&.amount,
488
+ state: order.state,
489
+ created_at: order.created_at
490
+ }
491
+ end,
492
+ message: "📋 Found #{orders_result.count} orders for #{market_id}",
493
+ data: orders_result,
494
+ timestamp: Time.now
495
+ }
496
+ rescue BudaApi::ApiError => e
497
+ {
498
+ type: :history_error,
499
+ error: e.message,
500
+ market_id: market_id,
501
+ message: "❌ Failed to get order history: #{e.message}",
502
+ timestamp: Time.now
503
+ }
504
+ end
505
+ end
506
+
507
+ def get_quotation_from_params(params)
508
+ market_id = params["market_id"]
509
+ side = params["side"]
510
+ amount = params["amount"].to_f
511
+
512
+ # Convert side to quotation type
513
+ quotation_type = side == "buy" ? "bid_given_size" : "ask_given_size"
514
+
515
+ begin
516
+ quotation = @client.quotation(market_id, quotation_type, amount)
517
+
518
+ {
519
+ type: :quotation,
520
+ market_id: market_id,
521
+ side: side,
522
+ amount: amount,
523
+ estimated_cost: quotation.quote_balance_change.amount,
524
+ fee: quotation.fee.amount,
525
+ message: "💱 To #{side} #{amount} #{market_id.split('-').first}: ~#{quotation.quote_balance_change} (fee: #{quotation.fee})",
526
+ data: quotation,
527
+ timestamp: Time.now
528
+ }
529
+ rescue BudaApi::ApiError => e
530
+ {
531
+ type: :quotation_error,
532
+ error: e.message,
533
+ market_id: market_id,
534
+ message: "❌ Failed to get quotation: #{e.message}",
535
+ timestamp: Time.now
536
+ }
537
+ end
538
+ end
539
+ end
540
+ end
541
+ end