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.
- checksums.yaml +4 -4
- data/README.md +101 -4
- data/buda_api.gemspec +4 -1
- data/examples/ai/README.md +314 -0
- data/examples/ai/anomaly_detection_example.rb +412 -0
- data/examples/ai/natural_language_trading.rb +369 -0
- data/examples/ai/report_generation_example.rb +605 -0
- data/examples/ai/risk_management_example.rb +300 -0
- data/examples/ai/trading_assistant_example.rb +295 -0
- data/lib/buda_api/ai/anomaly_detector.rb +787 -0
- data/lib/buda_api/ai/natural_language_trader.rb +541 -0
- data/lib/buda_api/ai/report_generator.rb +1054 -0
- data/lib/buda_api/ai/risk_manager.rb +789 -0
- data/lib/buda_api/ai/trading_assistant.rb +404 -0
- data/lib/buda_api/version.rb +1 -1
- data/lib/buda_api.rb +37 -0
- metadata +32 -1
@@ -0,0 +1,1054 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BudaApi
|
4
|
+
module AI
|
5
|
+
# AI-powered report generation for trading analysis
|
6
|
+
class ReportGenerator
|
7
|
+
REPORT_TYPES = %w[
|
8
|
+
portfolio_summary
|
9
|
+
trading_performance
|
10
|
+
market_analysis
|
11
|
+
risk_assessment
|
12
|
+
profit_loss
|
13
|
+
tax_report
|
14
|
+
custom
|
15
|
+
].freeze
|
16
|
+
|
17
|
+
EXPORT_FORMATS = %w[text markdown html json csv].freeze
|
18
|
+
|
19
|
+
def initialize(client, llm_provider: :openai)
|
20
|
+
@client = client
|
21
|
+
@llm = RubyLLM.new(
|
22
|
+
provider: llm_provider,
|
23
|
+
system_prompt: build_report_system_prompt
|
24
|
+
)
|
25
|
+
|
26
|
+
BudaApi::Logger.info("Report Generator initialized")
|
27
|
+
end
|
28
|
+
|
29
|
+
# Generate comprehensive portfolio summary report
|
30
|
+
#
|
31
|
+
# @param options [Hash] report options
|
32
|
+
# @option options [String] :format export format (text, markdown, html, json, csv)
|
33
|
+
# @option options [Boolean] :include_charts include visual elements
|
34
|
+
# @option options [Date] :start_date analysis start date
|
35
|
+
# @option options [Date] :end_date analysis end date
|
36
|
+
# @return [Hash] generated report
|
37
|
+
def generate_portfolio_summary(options = {})
|
38
|
+
format = options[:format] || "markdown"
|
39
|
+
include_ai = options[:include_ai] != false
|
40
|
+
|
41
|
+
BudaApi::Logger.info("Generating portfolio summary report")
|
42
|
+
|
43
|
+
begin
|
44
|
+
# Gather portfolio data
|
45
|
+
balances_result = @client.balances
|
46
|
+
portfolios = extract_portfolio_data(balances_result)
|
47
|
+
|
48
|
+
return empty_portfolio_report(format) if portfolios.empty?
|
49
|
+
|
50
|
+
# Get market data
|
51
|
+
market_data = fetch_portfolio_market_data(portfolios)
|
52
|
+
|
53
|
+
# Calculate metrics
|
54
|
+
portfolio_metrics = calculate_portfolio_metrics(portfolios, market_data)
|
55
|
+
|
56
|
+
# Generate base report
|
57
|
+
report_data = {
|
58
|
+
type: :portfolio_summary,
|
59
|
+
generated_at: Time.now,
|
60
|
+
portfolio: portfolios,
|
61
|
+
market_data: market_data,
|
62
|
+
metrics: portfolio_metrics,
|
63
|
+
summary: {
|
64
|
+
total_value: portfolio_metrics[:total_value_clp],
|
65
|
+
asset_count: portfolios.length,
|
66
|
+
top_holding: portfolio_metrics[:top_holding],
|
67
|
+
total_change_24h: portfolio_metrics[:total_change_24h]
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
# Add AI analysis if requested
|
72
|
+
if include_ai
|
73
|
+
report_data[:ai_analysis] = generate_ai_portfolio_analysis(report_data)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Format the report
|
77
|
+
formatted_report = format_report(report_data, format)
|
78
|
+
|
79
|
+
{
|
80
|
+
type: :portfolio_summary_report,
|
81
|
+
format: format,
|
82
|
+
data: report_data,
|
83
|
+
formatted_content: formatted_report,
|
84
|
+
timestamp: Time.now
|
85
|
+
}
|
86
|
+
|
87
|
+
rescue => e
|
88
|
+
error_msg = "Portfolio summary generation failed: #{e.message}"
|
89
|
+
BudaApi::Logger.error(error_msg)
|
90
|
+
|
91
|
+
{
|
92
|
+
type: :report_error,
|
93
|
+
error: error_msg,
|
94
|
+
timestamp: Time.now
|
95
|
+
}
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Generate trading performance report
|
100
|
+
#
|
101
|
+
# @param market_id [String] specific market or 'all'
|
102
|
+
# @param options [Hash] report options
|
103
|
+
# @return [Hash] trading performance report
|
104
|
+
def generate_trading_performance(market_id = 'all', options = {})
|
105
|
+
format = options[:format] || "markdown"
|
106
|
+
limit = options[:limit] || 50
|
107
|
+
include_ai = options[:include_ai] != false
|
108
|
+
|
109
|
+
BudaApi::Logger.info("Generating trading performance report for #{market_id}")
|
110
|
+
|
111
|
+
begin
|
112
|
+
# Get trading history
|
113
|
+
trading_data = fetch_trading_history(market_id, limit)
|
114
|
+
|
115
|
+
return empty_trading_report(format) if trading_data.empty?
|
116
|
+
|
117
|
+
# Calculate performance metrics
|
118
|
+
performance_metrics = calculate_performance_metrics(trading_data)
|
119
|
+
|
120
|
+
# Generate report data
|
121
|
+
report_data = {
|
122
|
+
type: :trading_performance,
|
123
|
+
generated_at: Time.now,
|
124
|
+
market_id: market_id,
|
125
|
+
period: {
|
126
|
+
trades_analyzed: trading_data.length,
|
127
|
+
date_range: get_date_range(trading_data)
|
128
|
+
},
|
129
|
+
trades: trading_data,
|
130
|
+
performance: performance_metrics
|
131
|
+
}
|
132
|
+
|
133
|
+
# Add AI insights
|
134
|
+
if include_ai
|
135
|
+
report_data[:ai_insights] = generate_ai_trading_insights(report_data)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Format report
|
139
|
+
formatted_report = format_report(report_data, format)
|
140
|
+
|
141
|
+
{
|
142
|
+
type: :trading_performance_report,
|
143
|
+
format: format,
|
144
|
+
data: report_data,
|
145
|
+
formatted_content: formatted_report,
|
146
|
+
timestamp: Time.now
|
147
|
+
}
|
148
|
+
|
149
|
+
rescue => e
|
150
|
+
error_msg = "Trading performance report failed: #{e.message}"
|
151
|
+
BudaApi::Logger.error(error_msg)
|
152
|
+
|
153
|
+
{
|
154
|
+
type: :report_error,
|
155
|
+
error: error_msg,
|
156
|
+
timestamp: Time.now
|
157
|
+
}
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Generate market analysis report
|
162
|
+
#
|
163
|
+
# @param markets [Array<String>] markets to analyze
|
164
|
+
# @param options [Hash] report options
|
165
|
+
# @return [Hash] market analysis report
|
166
|
+
def generate_market_analysis(markets = nil, options = {})
|
167
|
+
markets ||= BudaApi::Constants::Market::MAJOR
|
168
|
+
format = options[:format] || "markdown"
|
169
|
+
include_ai = options[:include_ai] != false
|
170
|
+
|
171
|
+
BudaApi::Logger.info("Generating market analysis report for #{markets.length} markets")
|
172
|
+
|
173
|
+
begin
|
174
|
+
# Gather market data
|
175
|
+
market_analysis = {}
|
176
|
+
|
177
|
+
markets.each do |market_id|
|
178
|
+
market_analysis[market_id] = analyze_single_market(market_id)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Calculate cross-market metrics
|
182
|
+
market_metrics = calculate_market_metrics(market_analysis)
|
183
|
+
|
184
|
+
# Generate report data
|
185
|
+
report_data = {
|
186
|
+
type: :market_analysis,
|
187
|
+
generated_at: Time.now,
|
188
|
+
markets_analyzed: markets.length,
|
189
|
+
markets: market_analysis,
|
190
|
+
metrics: market_metrics,
|
191
|
+
summary: generate_market_summary(market_analysis, market_metrics)
|
192
|
+
}
|
193
|
+
|
194
|
+
# Add AI market insights
|
195
|
+
if include_ai
|
196
|
+
report_data[:ai_insights] = generate_ai_market_insights(report_data)
|
197
|
+
end
|
198
|
+
|
199
|
+
# Format report
|
200
|
+
formatted_report = format_report(report_data, format)
|
201
|
+
|
202
|
+
{
|
203
|
+
type: :market_analysis_report,
|
204
|
+
format: format,
|
205
|
+
data: report_data,
|
206
|
+
formatted_content: formatted_report,
|
207
|
+
timestamp: Time.now
|
208
|
+
}
|
209
|
+
|
210
|
+
rescue => e
|
211
|
+
error_msg = "Market analysis report failed: #{e.message}"
|
212
|
+
BudaApi::Logger.error(error_msg)
|
213
|
+
|
214
|
+
{
|
215
|
+
type: :report_error,
|
216
|
+
error: error_msg,
|
217
|
+
timestamp: Time.now
|
218
|
+
}
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Generate custom report with AI assistance
|
223
|
+
#
|
224
|
+
# @param prompt [String] custom report requirements
|
225
|
+
# @param data_sources [Array<Symbol>] data to include (:portfolio, :trades, :market)
|
226
|
+
# @param options [Hash] report options
|
227
|
+
# @return [Hash] custom report
|
228
|
+
def generate_custom_report(prompt, data_sources = [:portfolio], options = {})
|
229
|
+
format = options[:format] || "markdown"
|
230
|
+
|
231
|
+
BudaApi::Logger.info("Generating custom report: #{prompt}")
|
232
|
+
|
233
|
+
begin
|
234
|
+
# Gather requested data
|
235
|
+
gathered_data = {}
|
236
|
+
|
237
|
+
if data_sources.include?(:portfolio)
|
238
|
+
balances_result = @client.balances
|
239
|
+
gathered_data[:portfolio] = extract_portfolio_data(balances_result)
|
240
|
+
end
|
241
|
+
|
242
|
+
if data_sources.include?(:trades)
|
243
|
+
gathered_data[:trades] = fetch_recent_trades_all_markets
|
244
|
+
end
|
245
|
+
|
246
|
+
if data_sources.include?(:market)
|
247
|
+
gathered_data[:market] = fetch_market_overview
|
248
|
+
end
|
249
|
+
|
250
|
+
# Generate AI report
|
251
|
+
ai_report = generate_ai_custom_report(prompt, gathered_data)
|
252
|
+
|
253
|
+
# Structure report data
|
254
|
+
report_data = {
|
255
|
+
type: :custom_report,
|
256
|
+
generated_at: Time.now,
|
257
|
+
prompt: prompt,
|
258
|
+
data_sources: data_sources,
|
259
|
+
data: gathered_data,
|
260
|
+
ai_analysis: ai_report
|
261
|
+
}
|
262
|
+
|
263
|
+
# Format report
|
264
|
+
formatted_report = format_report(report_data, format)
|
265
|
+
|
266
|
+
{
|
267
|
+
type: :custom_report,
|
268
|
+
format: format,
|
269
|
+
data: report_data,
|
270
|
+
formatted_content: formatted_report,
|
271
|
+
timestamp: Time.now
|
272
|
+
}
|
273
|
+
|
274
|
+
rescue => e
|
275
|
+
error_msg = "Custom report generation failed: #{e.message}"
|
276
|
+
BudaApi::Logger.error(error_msg)
|
277
|
+
|
278
|
+
{
|
279
|
+
type: :report_error,
|
280
|
+
error: error_msg,
|
281
|
+
timestamp: Time.now
|
282
|
+
}
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# Export report to file
|
287
|
+
#
|
288
|
+
# @param report [Hash] generated report
|
289
|
+
# @param filename [String] output filename
|
290
|
+
# @return [Hash] export result
|
291
|
+
def export_report(report, filename = nil)
|
292
|
+
filename ||= generate_filename(report)
|
293
|
+
|
294
|
+
begin
|
295
|
+
case report[:format]
|
296
|
+
when "json"
|
297
|
+
write_json_report(report, filename)
|
298
|
+
when "csv"
|
299
|
+
write_csv_report(report, filename)
|
300
|
+
when "html"
|
301
|
+
write_html_report(report, filename)
|
302
|
+
else
|
303
|
+
write_text_report(report, filename)
|
304
|
+
end
|
305
|
+
|
306
|
+
{
|
307
|
+
type: :export_success,
|
308
|
+
filename: filename,
|
309
|
+
format: report[:format],
|
310
|
+
size: File.size(filename),
|
311
|
+
timestamp: Time.now
|
312
|
+
}
|
313
|
+
|
314
|
+
rescue => e
|
315
|
+
error_msg = "Report export failed: #{e.message}"
|
316
|
+
BudaApi::Logger.error(error_msg)
|
317
|
+
|
318
|
+
{
|
319
|
+
type: :export_error,
|
320
|
+
error: error_msg,
|
321
|
+
timestamp: Time.now
|
322
|
+
}
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
private
|
327
|
+
|
328
|
+
def build_report_system_prompt
|
329
|
+
"""
|
330
|
+
You are an expert cryptocurrency trading analyst and report writer.
|
331
|
+
|
332
|
+
Your expertise includes:
|
333
|
+
- Portfolio performance analysis
|
334
|
+
- Market trend identification
|
335
|
+
- Risk assessment and recommendations
|
336
|
+
- Profit/loss analysis
|
337
|
+
- Trading strategy evaluation
|
338
|
+
- Chilean cryptocurrency market knowledge
|
339
|
+
|
340
|
+
When generating reports:
|
341
|
+
1. Use clear, professional language
|
342
|
+
2. Provide specific, data-driven insights
|
343
|
+
3. Include actionable recommendations
|
344
|
+
4. Highlight both opportunities and risks
|
345
|
+
5. Consider Chilean market conditions and regulations
|
346
|
+
6. Use appropriate financial terminology
|
347
|
+
7. Structure information logically with headings and bullet points
|
348
|
+
|
349
|
+
Always base conclusions on the provided data and clearly state assumptions.
|
350
|
+
"""
|
351
|
+
end
|
352
|
+
|
353
|
+
def extract_portfolio_data(balances_result)
|
354
|
+
balances_result.balances.select do |balance|
|
355
|
+
balance.amount.amount > 0.0001
|
356
|
+
end.map do |balance|
|
357
|
+
{
|
358
|
+
currency: balance.currency,
|
359
|
+
amount: balance.amount.amount,
|
360
|
+
available: balance.available_amount.amount,
|
361
|
+
frozen: balance.frozen_amount.amount
|
362
|
+
}
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
def fetch_portfolio_market_data(portfolios)
|
367
|
+
market_data = {}
|
368
|
+
|
369
|
+
portfolios.each do |holding|
|
370
|
+
currency = holding[:currency]
|
371
|
+
next if currency == "CLP"
|
372
|
+
|
373
|
+
market_id = "#{currency}-CLP"
|
374
|
+
begin
|
375
|
+
ticker = @client.ticker(market_id)
|
376
|
+
market_data[currency] = {
|
377
|
+
market_id: market_id,
|
378
|
+
price: ticker.last_price.amount,
|
379
|
+
change_24h: ticker.price_variation_24h,
|
380
|
+
volume: ticker.volume.amount,
|
381
|
+
min_24h: ticker.min_24h.amount,
|
382
|
+
max_24h: ticker.max_24h.amount
|
383
|
+
}
|
384
|
+
rescue => e
|
385
|
+
BudaApi::Logger.warn("Could not fetch market data for #{market_id}: #{e.message}")
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
market_data
|
390
|
+
end
|
391
|
+
|
392
|
+
def calculate_portfolio_metrics(portfolios, market_data)
|
393
|
+
total_value_clp = 0.0
|
394
|
+
total_change_24h = 0.0
|
395
|
+
top_holding = { currency: nil, value: 0.0, percentage: 0.0 }
|
396
|
+
|
397
|
+
# Calculate values and changes
|
398
|
+
portfolios.each do |holding|
|
399
|
+
currency = holding[:currency]
|
400
|
+
|
401
|
+
if currency == "CLP"
|
402
|
+
value_clp = holding[:amount]
|
403
|
+
change_24h_clp = 0.0
|
404
|
+
elsif market_data[currency]
|
405
|
+
value_clp = holding[:amount] * market_data[currency][:price]
|
406
|
+
change_24h_clp = value_clp * (market_data[currency][:change_24h] / 100.0)
|
407
|
+
else
|
408
|
+
value_clp = 0.0
|
409
|
+
change_24h_clp = 0.0
|
410
|
+
end
|
411
|
+
|
412
|
+
total_value_clp += value_clp
|
413
|
+
total_change_24h += change_24h_clp
|
414
|
+
|
415
|
+
# Track top holding
|
416
|
+
if value_clp > top_holding[:value]
|
417
|
+
top_holding = {
|
418
|
+
currency: currency,
|
419
|
+
value: value_clp,
|
420
|
+
amount: holding[:amount]
|
421
|
+
}
|
422
|
+
end
|
423
|
+
end
|
424
|
+
|
425
|
+
# Calculate percentages
|
426
|
+
if total_value_clp > 0
|
427
|
+
top_holding[:percentage] = (top_holding[:value] / total_value_clp) * 100
|
428
|
+
total_change_24h_percent = (total_change_24h / total_value_clp) * 100
|
429
|
+
else
|
430
|
+
total_change_24h_percent = 0.0
|
431
|
+
end
|
432
|
+
|
433
|
+
{
|
434
|
+
total_value_clp: total_value_clp,
|
435
|
+
total_change_24h: total_change_24h,
|
436
|
+
total_change_24h_percent: total_change_24h_percent,
|
437
|
+
top_holding: top_holding,
|
438
|
+
asset_allocation: calculate_asset_allocation(portfolios, market_data, total_value_clp)
|
439
|
+
}
|
440
|
+
end
|
441
|
+
|
442
|
+
def calculate_asset_allocation(portfolios, market_data, total_value)
|
443
|
+
return {} if total_value <= 0
|
444
|
+
|
445
|
+
allocation = {}
|
446
|
+
|
447
|
+
portfolios.each do |holding|
|
448
|
+
currency = holding[:currency]
|
449
|
+
|
450
|
+
if currency == "CLP"
|
451
|
+
value = holding[:amount]
|
452
|
+
elsif market_data[currency]
|
453
|
+
value = holding[:amount] * market_data[currency][:price]
|
454
|
+
else
|
455
|
+
value = 0.0
|
456
|
+
end
|
457
|
+
|
458
|
+
percentage = (value / total_value) * 100
|
459
|
+
allocation[currency] = {
|
460
|
+
amount: holding[:amount],
|
461
|
+
value_clp: value,
|
462
|
+
percentage: percentage
|
463
|
+
}
|
464
|
+
end
|
465
|
+
|
466
|
+
allocation.sort_by { |_, data| -data[:percentage] }.to_h
|
467
|
+
end
|
468
|
+
|
469
|
+
def fetch_trading_history(market_id, limit)
|
470
|
+
trades = []
|
471
|
+
|
472
|
+
if market_id == 'all'
|
473
|
+
# Get trades from all available markets
|
474
|
+
BudaApi::Constants::Market::MAJOR.each do |market|
|
475
|
+
begin
|
476
|
+
market_trades = @client.orders(market, per_page: [limit / 4, 10].max)
|
477
|
+
trades.concat(extract_trade_data(market_trades.orders, market))
|
478
|
+
rescue => e
|
479
|
+
BudaApi::Logger.warn("Could not fetch trades for #{market}: #{e.message}")
|
480
|
+
end
|
481
|
+
end
|
482
|
+
else
|
483
|
+
begin
|
484
|
+
orders_result = @client.orders(market_id, per_page: limit)
|
485
|
+
trades = extract_trade_data(orders_result.orders, market_id)
|
486
|
+
rescue => e
|
487
|
+
BudaApi::Logger.warn("Could not fetch trades for #{market_id}: #{e.message}")
|
488
|
+
end
|
489
|
+
end
|
490
|
+
|
491
|
+
trades.sort_by { |trade| trade[:created_at] }.reverse
|
492
|
+
end
|
493
|
+
|
494
|
+
def extract_trade_data(orders, market_id)
|
495
|
+
orders.select { |order| order.state == "traded" }.map do |order|
|
496
|
+
{
|
497
|
+
id: order.id,
|
498
|
+
market_id: market_id,
|
499
|
+
side: order.type.downcase,
|
500
|
+
amount: order.amount.amount,
|
501
|
+
price: order.limit&.amount || order.price&.amount,
|
502
|
+
total: order.total_exchanged&.amount,
|
503
|
+
fee: order.fee&.amount,
|
504
|
+
created_at: order.created_at,
|
505
|
+
state: order.state
|
506
|
+
}
|
507
|
+
end
|
508
|
+
end
|
509
|
+
|
510
|
+
def calculate_performance_metrics(trades)
|
511
|
+
return empty_performance_metrics if trades.empty?
|
512
|
+
|
513
|
+
total_trades = trades.length
|
514
|
+
buy_trades = trades.select { |t| t[:side] == "bid" }
|
515
|
+
sell_trades = trades.select { |t| t[:side] == "ask" }
|
516
|
+
|
517
|
+
total_volume = trades.sum { |t| t[:total] || 0 }
|
518
|
+
total_fees = trades.sum { |t| t[:fee] || 0 }
|
519
|
+
|
520
|
+
# Calculate average trade sizes
|
521
|
+
avg_trade_size = total_volume / total_trades if total_trades > 0
|
522
|
+
|
523
|
+
# Calculate win rate (simplified)
|
524
|
+
profitable_trades = estimate_profitable_trades(trades)
|
525
|
+
win_rate = total_trades > 0 ? (profitable_trades / total_trades.to_f) * 100 : 0
|
526
|
+
|
527
|
+
{
|
528
|
+
total_trades: total_trades,
|
529
|
+
buy_trades: buy_trades.length,
|
530
|
+
sell_trades: sell_trades.length,
|
531
|
+
total_volume: total_volume,
|
532
|
+
total_fees: total_fees,
|
533
|
+
avg_trade_size: avg_trade_size,
|
534
|
+
win_rate: win_rate,
|
535
|
+
trading_frequency: calculate_trading_frequency(trades),
|
536
|
+
most_traded_market: find_most_traded_market(trades)
|
537
|
+
}
|
538
|
+
end
|
539
|
+
|
540
|
+
def estimate_profitable_trades(trades)
|
541
|
+
# Simplified profitability estimation
|
542
|
+
# In a real implementation, this would track buy/sell pairs
|
543
|
+
profitable = 0
|
544
|
+
|
545
|
+
trades.each_with_index do |trade, index|
|
546
|
+
next if index == 0
|
547
|
+
|
548
|
+
prev_trade = trades[index - 1]
|
549
|
+
if trade[:market_id] == prev_trade[:market_id]
|
550
|
+
# Simple check: if price increased between buy and sell
|
551
|
+
if prev_trade[:side] == "bid" && trade[:side] == "ask"
|
552
|
+
profitable += 1 if trade[:price] > prev_trade[:price]
|
553
|
+
end
|
554
|
+
end
|
555
|
+
end
|
556
|
+
|
557
|
+
profitable
|
558
|
+
end
|
559
|
+
|
560
|
+
def calculate_trading_frequency(trades)
|
561
|
+
return 0 if trades.length < 2
|
562
|
+
|
563
|
+
first_trade = trades.last[:created_at]
|
564
|
+
last_trade = trades.first[:created_at]
|
565
|
+
|
566
|
+
days_span = (Time.parse(last_trade) - Time.parse(first_trade)) / (24 * 60 * 60)
|
567
|
+
return 0 if days_span <= 0
|
568
|
+
|
569
|
+
trades.length / days_span.to_f
|
570
|
+
end
|
571
|
+
|
572
|
+
def find_most_traded_market(trades)
|
573
|
+
market_counts = trades.group_by { |t| t[:market_id] }.transform_values(&:length)
|
574
|
+
market_counts.max_by { |_, count| count }&.first || "N/A"
|
575
|
+
end
|
576
|
+
|
577
|
+
def analyze_single_market(market_id)
|
578
|
+
begin
|
579
|
+
ticker = @client.ticker(market_id)
|
580
|
+
order_book = @client.order_book(market_id)
|
581
|
+
|
582
|
+
{
|
583
|
+
market_id: market_id,
|
584
|
+
price: ticker.last_price.amount,
|
585
|
+
change_24h: ticker.price_variation_24h,
|
586
|
+
volume: ticker.volume.amount,
|
587
|
+
min_24h: ticker.min_24h.amount,
|
588
|
+
max_24h: ticker.max_24h.amount,
|
589
|
+
spread: calculate_spread(order_book),
|
590
|
+
order_book_depth: analyze_order_book_depth(order_book)
|
591
|
+
}
|
592
|
+
rescue => e
|
593
|
+
BudaApi::Logger.warn("Could not analyze market #{market_id}: #{e.message}")
|
594
|
+
{
|
595
|
+
market_id: market_id,
|
596
|
+
error: e.message
|
597
|
+
}
|
598
|
+
end
|
599
|
+
end
|
600
|
+
|
601
|
+
def calculate_spread(order_book)
|
602
|
+
return 0 if order_book.asks.empty? || order_book.bids.empty?
|
603
|
+
|
604
|
+
best_ask = order_book.asks.first.price
|
605
|
+
best_bid = order_book.bids.first.price
|
606
|
+
|
607
|
+
((best_ask - best_bid) / best_ask * 100).round(4)
|
608
|
+
end
|
609
|
+
|
610
|
+
def analyze_order_book_depth(order_book)
|
611
|
+
{
|
612
|
+
ask_levels: order_book.asks.length,
|
613
|
+
bid_levels: order_book.bids.length,
|
614
|
+
ask_volume: order_book.asks.sum(&:amount),
|
615
|
+
bid_volume: order_book.bids.sum(&:amount)
|
616
|
+
}
|
617
|
+
end
|
618
|
+
|
619
|
+
def calculate_market_metrics(market_analysis)
|
620
|
+
markets_with_data = market_analysis.select { |_, data| !data.key?(:error) }
|
621
|
+
|
622
|
+
return empty_market_metrics if markets_with_data.empty?
|
623
|
+
|
624
|
+
total_volume = markets_with_data.sum { |_, data| data[:volume] }
|
625
|
+
avg_change = markets_with_data.sum { |_, data| data[:change_24h] } / markets_with_data.length
|
626
|
+
|
627
|
+
# Find best and worst performers
|
628
|
+
sorted_by_change = markets_with_data.sort_by { |_, data| data[:change_24h] }
|
629
|
+
best_performer = sorted_by_change.last
|
630
|
+
worst_performer = sorted_by_change.first
|
631
|
+
|
632
|
+
{
|
633
|
+
total_volume: total_volume,
|
634
|
+
average_change_24h: avg_change,
|
635
|
+
markets_analyzed: markets_with_data.length,
|
636
|
+
best_performer: {
|
637
|
+
market: best_performer[0],
|
638
|
+
change: best_performer[1][:change_24h]
|
639
|
+
},
|
640
|
+
worst_performer: {
|
641
|
+
market: worst_performer[0],
|
642
|
+
change: worst_performer[1][:change_24h]
|
643
|
+
}
|
644
|
+
}
|
645
|
+
end
|
646
|
+
|
647
|
+
def generate_market_summary(market_analysis, metrics)
|
648
|
+
markets_up = market_analysis.count { |_, data| !data.key?(:error) && data[:change_24h] > 0 }
|
649
|
+
markets_down = market_analysis.count { |_, data| !data.key?(:error) && data[:change_24h] < 0 }
|
650
|
+
|
651
|
+
{
|
652
|
+
markets_up: markets_up,
|
653
|
+
markets_down: markets_down,
|
654
|
+
markets_flat: market_analysis.length - markets_up - markets_down,
|
655
|
+
market_sentiment: determine_market_sentiment(metrics[:average_change_24h])
|
656
|
+
}
|
657
|
+
end
|
658
|
+
|
659
|
+
def determine_market_sentiment(avg_change)
|
660
|
+
case avg_change
|
661
|
+
when 5.. then "Very Bullish"
|
662
|
+
when 2..5 then "Bullish"
|
663
|
+
when -2..2 then "Neutral"
|
664
|
+
when -5..-2 then "Bearish"
|
665
|
+
else "Very Bearish"
|
666
|
+
end
|
667
|
+
end
|
668
|
+
|
669
|
+
def format_report(report_data, format)
|
670
|
+
case format
|
671
|
+
when "json"
|
672
|
+
JSON.pretty_generate(report_data)
|
673
|
+
when "csv"
|
674
|
+
format_csv_report(report_data)
|
675
|
+
when "html"
|
676
|
+
format_html_report(report_data)
|
677
|
+
when "markdown"
|
678
|
+
format_markdown_report(report_data)
|
679
|
+
else
|
680
|
+
format_text_report(report_data)
|
681
|
+
end
|
682
|
+
end
|
683
|
+
|
684
|
+
def format_markdown_report(report_data)
|
685
|
+
case report_data[:type]
|
686
|
+
when :portfolio_summary
|
687
|
+
format_portfolio_markdown(report_data)
|
688
|
+
when :trading_performance
|
689
|
+
format_trading_markdown(report_data)
|
690
|
+
when :market_analysis
|
691
|
+
format_market_markdown(report_data)
|
692
|
+
when :custom_report
|
693
|
+
format_custom_markdown(report_data)
|
694
|
+
else
|
695
|
+
"# Report\n\n" + JSON.pretty_generate(report_data)
|
696
|
+
end
|
697
|
+
end
|
698
|
+
|
699
|
+
def format_portfolio_markdown(data)
|
700
|
+
"""
|
701
|
+
# Portfolio Summary Report
|
702
|
+
*Generated: #{data[:generated_at]}*
|
703
|
+
|
704
|
+
## Overview
|
705
|
+
- **Total Portfolio Value:** #{data[:metrics][:total_value_clp].round(2)} CLP
|
706
|
+
- **Number of Assets:** #{data[:summary][:asset_count]}
|
707
|
+
- **24h Change:** #{data[:metrics][:total_change_24h_percent].round(2)}% (#{data[:metrics][:total_change_24h].round(2)} CLP)
|
708
|
+
|
709
|
+
## Top Holdings
|
710
|
+
#{format_asset_allocation_markdown(data[:metrics][:asset_allocation])}
|
711
|
+
|
712
|
+
## Market Performance
|
713
|
+
#{format_market_performance_markdown(data[:market_data])}
|
714
|
+
|
715
|
+
#{data[:ai_analysis] ? "## AI Analysis\n#{data[:ai_analysis][:content]}" : ""}
|
716
|
+
"""
|
717
|
+
end
|
718
|
+
|
719
|
+
def format_asset_allocation_markdown(allocation)
|
720
|
+
lines = ["| Asset | Amount | Value (CLP) | Allocation |"]
|
721
|
+
lines << "|-------|---------|-------------|------------|"]
|
722
|
+
|
723
|
+
allocation.each do |currency, data|
|
724
|
+
lines << "| #{currency} | #{data[:amount].round(8)} | #{data[:value_clp].round(2)} | #{data[:percentage].round(1)}% |"
|
725
|
+
end
|
726
|
+
|
727
|
+
lines.join("\n")
|
728
|
+
end
|
729
|
+
|
730
|
+
def format_market_performance_markdown(market_data)
|
731
|
+
return "No market data available" if market_data.empty?
|
732
|
+
|
733
|
+
lines = ["| Market | Price | 24h Change | Volume |"]
|
734
|
+
lines << "|--------|-------|------------|--------|"]
|
735
|
+
|
736
|
+
market_data.each do |currency, data|
|
737
|
+
change_symbol = data[:change_24h] >= 0 ? "+" : ""
|
738
|
+
lines << "| #{data[:market_id]} | #{data[:price]} | #{change_symbol}#{data[:change_24h].round(2)}% | #{data[:volume].round(2)} |"
|
739
|
+
end
|
740
|
+
|
741
|
+
lines.join("\n")
|
742
|
+
end
|
743
|
+
|
744
|
+
def empty_portfolio_report(format)
|
745
|
+
{
|
746
|
+
type: :empty_portfolio,
|
747
|
+
message: "No portfolio holdings found",
|
748
|
+
timestamp: Time.now
|
749
|
+
}
|
750
|
+
end
|
751
|
+
|
752
|
+
def empty_trading_report(format)
|
753
|
+
{
|
754
|
+
type: :empty_trading,
|
755
|
+
message: "No trading history found",
|
756
|
+
timestamp: Time.now
|
757
|
+
}
|
758
|
+
end
|
759
|
+
|
760
|
+
def empty_performance_metrics
|
761
|
+
{
|
762
|
+
total_trades: 0,
|
763
|
+
buy_trades: 0,
|
764
|
+
sell_trades: 0,
|
765
|
+
total_volume: 0.0,
|
766
|
+
total_fees: 0.0,
|
767
|
+
avg_trade_size: 0.0,
|
768
|
+
win_rate: 0.0,
|
769
|
+
trading_frequency: 0.0,
|
770
|
+
most_traded_market: "N/A"
|
771
|
+
}
|
772
|
+
end
|
773
|
+
|
774
|
+
def empty_market_metrics
|
775
|
+
{
|
776
|
+
total_volume: 0.0,
|
777
|
+
average_change_24h: 0.0,
|
778
|
+
markets_analyzed: 0,
|
779
|
+
best_performer: { market: "N/A", change: 0.0 },
|
780
|
+
worst_performer: { market: "N/A", change: 0.0 }
|
781
|
+
}
|
782
|
+
end
|
783
|
+
|
784
|
+
# AI integration methods
|
785
|
+
def generate_ai_portfolio_analysis(report_data)
|
786
|
+
return nil unless defined?(RubyLLM)
|
787
|
+
|
788
|
+
prompt = build_portfolio_analysis_prompt(report_data)
|
789
|
+
|
790
|
+
begin
|
791
|
+
response = @llm.complete(
|
792
|
+
messages: [{ role: "user", content: prompt }],
|
793
|
+
max_tokens: 400
|
794
|
+
)
|
795
|
+
|
796
|
+
{
|
797
|
+
content: response.content,
|
798
|
+
generated_at: Time.now
|
799
|
+
}
|
800
|
+
rescue => e
|
801
|
+
BudaApi::Logger.error("AI portfolio analysis failed: #{e.message}")
|
802
|
+
nil
|
803
|
+
end
|
804
|
+
end
|
805
|
+
|
806
|
+
def build_portfolio_analysis_prompt(report_data)
|
807
|
+
"""
|
808
|
+
Analyze this cryptocurrency portfolio and provide insights:
|
809
|
+
|
810
|
+
Portfolio Value: #{report_data[:metrics][:total_value_clp].round(2)} CLP
|
811
|
+
24h Change: #{report_data[:metrics][:total_change_24h_percent].round(2)}%
|
812
|
+
Assets: #{report_data[:summary][:asset_count]}
|
813
|
+
Top Holding: #{report_data[:metrics][:top_holding][:currency]} (#{report_data[:metrics][:top_holding][:percentage].round(1)}%)
|
814
|
+
|
815
|
+
Asset Allocation:
|
816
|
+
#{report_data[:metrics][:asset_allocation].map { |currency, data| "- #{currency}: #{data[:percentage].round(1)}%" }.join("\n")}
|
817
|
+
|
818
|
+
Provide a brief analysis covering:
|
819
|
+
1. Portfolio diversification assessment
|
820
|
+
2. Risk factors and opportunities
|
821
|
+
3. Specific recommendations for Chilean crypto investors
|
822
|
+
|
823
|
+
Keep it concise and actionable.
|
824
|
+
"""
|
825
|
+
end
|
826
|
+
|
827
|
+
def generate_filename(report)
|
828
|
+
type = report[:type].to_s.gsub('_', '-')
|
829
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
830
|
+
extension = get_file_extension(report[:format])
|
831
|
+
|
832
|
+
"buda-#{type}-#{timestamp}.#{extension}"
|
833
|
+
end
|
834
|
+
|
835
|
+
def get_file_extension(format)
|
836
|
+
case format
|
837
|
+
when "json" then "json"
|
838
|
+
when "csv" then "csv"
|
839
|
+
when "html" then "html"
|
840
|
+
when "markdown" then "md"
|
841
|
+
else "txt"
|
842
|
+
end
|
843
|
+
end
|
844
|
+
|
845
|
+
def write_text_report(report, filename)
|
846
|
+
File.write(filename, report[:formatted_content])
|
847
|
+
end
|
848
|
+
|
849
|
+
def write_json_report(report, filename)
|
850
|
+
File.write(filename, JSON.pretty_generate(report[:data]))
|
851
|
+
end
|
852
|
+
|
853
|
+
def write_csv_report(report, filename)
|
854
|
+
# Basic CSV export - would need enhancement for complex reports
|
855
|
+
content = "Type,Generated At,Summary\n"
|
856
|
+
content += "#{report[:type]},#{report[:data][:generated_at]},#{report[:data].inspect}"
|
857
|
+
|
858
|
+
File.write(filename, content)
|
859
|
+
end
|
860
|
+
|
861
|
+
def write_html_report(report, filename)
|
862
|
+
html = """
|
863
|
+
<!DOCTYPE html>
|
864
|
+
<html>
|
865
|
+
<head>
|
866
|
+
<title>Buda API Report</title>
|
867
|
+
<style>
|
868
|
+
body { font-family: Arial, sans-serif; margin: 20px; }
|
869
|
+
table { border-collapse: collapse; width: 100%; }
|
870
|
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
871
|
+
th { background-color: #f2f2f2; }
|
872
|
+
.positive { color: green; }
|
873
|
+
.negative { color: red; }
|
874
|
+
</style>
|
875
|
+
</head>
|
876
|
+
<body>
|
877
|
+
<h1>Buda API Report</h1>
|
878
|
+
<pre>#{report[:formatted_content]}</pre>
|
879
|
+
</body>
|
880
|
+
</html>
|
881
|
+
"""
|
882
|
+
|
883
|
+
File.write(filename, html)
|
884
|
+
end
|
885
|
+
|
886
|
+
# Additional helper methods for other report types...
|
887
|
+
def fetch_recent_trades_all_markets
|
888
|
+
# Implementation for fetching recent trades across all markets
|
889
|
+
[]
|
890
|
+
end
|
891
|
+
|
892
|
+
def fetch_market_overview
|
893
|
+
# Implementation for fetching general market overview
|
894
|
+
{}
|
895
|
+
end
|
896
|
+
|
897
|
+
def generate_ai_custom_report(prompt, data)
|
898
|
+
return nil unless defined?(RubyLLM)
|
899
|
+
|
900
|
+
data_summary = summarize_data_for_ai(data)
|
901
|
+
full_prompt = "#{prompt}\n\nAvailable data:\n#{data_summary}"
|
902
|
+
|
903
|
+
begin
|
904
|
+
response = @llm.complete(
|
905
|
+
messages: [{ role: "user", content: full_prompt }],
|
906
|
+
max_tokens: 800
|
907
|
+
)
|
908
|
+
|
909
|
+
{
|
910
|
+
content: response.content,
|
911
|
+
generated_at: Time.now
|
912
|
+
}
|
913
|
+
rescue => e
|
914
|
+
BudaApi::Logger.error("AI custom report failed: #{e.message}")
|
915
|
+
{ content: "AI analysis unavailable", generated_at: Time.now }
|
916
|
+
end
|
917
|
+
end
|
918
|
+
|
919
|
+
def summarize_data_for_ai(data)
|
920
|
+
summary = []
|
921
|
+
|
922
|
+
if data[:portfolio]
|
923
|
+
summary << "Portfolio: #{data[:portfolio].length} assets"
|
924
|
+
end
|
925
|
+
|
926
|
+
if data[:trades]
|
927
|
+
summary << "Trades: #{data[:trades].length} recent trades"
|
928
|
+
end
|
929
|
+
|
930
|
+
if data[:market]
|
931
|
+
summary << "Market: Current market overview available"
|
932
|
+
end
|
933
|
+
|
934
|
+
summary.join(", ")
|
935
|
+
end
|
936
|
+
|
937
|
+
# Placeholder methods for additional report formatting
|
938
|
+
def format_trading_markdown(data)
|
939
|
+
"# Trading Performance Report\n\n*Generated: #{data[:generated_at]}*\n\n" +
|
940
|
+
JSON.pretty_generate(data[:performance])
|
941
|
+
end
|
942
|
+
|
943
|
+
def format_market_markdown(data)
|
944
|
+
"# Market Analysis Report\n\n*Generated: #{data[:generated_at]}*\n\n" +
|
945
|
+
JSON.pretty_generate(data[:summary])
|
946
|
+
end
|
947
|
+
|
948
|
+
def format_custom_markdown(data)
|
949
|
+
content = "# Custom Report\n\n*Generated: #{data[:generated_at]}*\n\n"
|
950
|
+
content += "**Request:** #{data[:prompt]}\n\n"
|
951
|
+
content += data[:ai_analysis] ? data[:ai_analysis][:content] : "No analysis available"
|
952
|
+
content
|
953
|
+
end
|
954
|
+
|
955
|
+
def format_text_report(data)
|
956
|
+
"Buda API Report\nGenerated: #{data[:generated_at]}\n\n#{JSON.pretty_generate(data)}"
|
957
|
+
end
|
958
|
+
|
959
|
+
def format_csv_report(data)
|
960
|
+
"Type,Timestamp,Data\n#{data[:type]},#{data[:generated_at]},#{data.inspect}"
|
961
|
+
end
|
962
|
+
|
963
|
+
def format_html_report(data)
|
964
|
+
"<html><body><h1>Buda API Report</h1><pre>#{JSON.pretty_generate(data)}</pre></body></html>"
|
965
|
+
end
|
966
|
+
|
967
|
+
def get_date_range(trades)
|
968
|
+
return { start: nil, end: nil } if trades.empty?
|
969
|
+
|
970
|
+
dates = trades.map { |t| Time.parse(t[:created_at]) }
|
971
|
+
{
|
972
|
+
start: dates.min,
|
973
|
+
end: dates.max
|
974
|
+
}
|
975
|
+
end
|
976
|
+
|
977
|
+
def generate_ai_trading_insights(report_data)
|
978
|
+
return nil unless defined?(RubyLLM)
|
979
|
+
|
980
|
+
prompt = build_trading_insights_prompt(report_data)
|
981
|
+
|
982
|
+
begin
|
983
|
+
response = @llm.complete(
|
984
|
+
messages: [{ role: "user", content: prompt }],
|
985
|
+
max_tokens: 300
|
986
|
+
)
|
987
|
+
|
988
|
+
{
|
989
|
+
content: response.content,
|
990
|
+
generated_at: Time.now
|
991
|
+
}
|
992
|
+
rescue => e
|
993
|
+
BudaApi::Logger.error("AI trading insights failed: #{e.message}")
|
994
|
+
nil
|
995
|
+
end
|
996
|
+
end
|
997
|
+
|
998
|
+
def build_trading_insights_prompt(report_data)
|
999
|
+
"""
|
1000
|
+
Analyze this trading performance data:
|
1001
|
+
|
1002
|
+
Total Trades: #{report_data[:performance][:total_trades]}
|
1003
|
+
Win Rate: #{report_data[:performance][:win_rate].round(1)}%
|
1004
|
+
Total Volume: #{report_data[:performance][:total_volume].round(2)} CLP
|
1005
|
+
Total Fees: #{report_data[:performance][:total_fees].round(2)} CLP
|
1006
|
+
Most Traded: #{report_data[:performance][:most_traded_market]}
|
1007
|
+
|
1008
|
+
Provide brief insights on:
|
1009
|
+
1. Trading performance strengths/weaknesses
|
1010
|
+
2. Fee optimization opportunities
|
1011
|
+
3. Strategy improvement recommendations
|
1012
|
+
"""
|
1013
|
+
end
|
1014
|
+
|
1015
|
+
def generate_ai_market_insights(report_data)
|
1016
|
+
return nil unless defined?(RubyLLM)
|
1017
|
+
|
1018
|
+
prompt = build_market_insights_prompt(report_data)
|
1019
|
+
|
1020
|
+
begin
|
1021
|
+
response = @llm.complete(
|
1022
|
+
messages: [{ role: "user", content: prompt }],
|
1023
|
+
max_tokens: 300
|
1024
|
+
)
|
1025
|
+
|
1026
|
+
{
|
1027
|
+
content: response.content,
|
1028
|
+
generated_at: Time.now
|
1029
|
+
}
|
1030
|
+
rescue => e
|
1031
|
+
BudaApi::Logger.error("AI market insights failed: #{e.message}")
|
1032
|
+
nil
|
1033
|
+
end
|
1034
|
+
end
|
1035
|
+
|
1036
|
+
def build_market_insights_prompt(report_data)
|
1037
|
+
"""
|
1038
|
+
Analyze this market data:
|
1039
|
+
|
1040
|
+
Markets Analyzed: #{report_data[:markets_analyzed]}
|
1041
|
+
Average Change: #{report_data[:metrics][:average_change_24h].round(2)}%
|
1042
|
+
Best Performer: #{report_data[:metrics][:best_performer][:market]} (+#{report_data[:metrics][:best_performer][:change].round(2)}%)
|
1043
|
+
Worst Performer: #{report_data[:metrics][:worst_performer][:market]} (#{report_data[:metrics][:worst_performer][:change].round(2)}%)
|
1044
|
+
Market Sentiment: #{report_data[:summary][:market_sentiment]}
|
1045
|
+
|
1046
|
+
Provide brief market analysis covering:
|
1047
|
+
1. Current market trends
|
1048
|
+
2. Trading opportunities
|
1049
|
+
3. Risk factors to consider
|
1050
|
+
"""
|
1051
|
+
end
|
1052
|
+
end
|
1053
|
+
end
|
1054
|
+
end
|