1use crate::chains::{PricePoint, TokenHolder, VolumePoint};
28use textplots::{Chart, Plot, Shape};
29
30#[derive(Debug, Clone)]
32pub struct ChartConfig {
33 pub width: u32,
35 pub height: u32,
37 pub title: Option<String>,
39 pub show_labels: bool,
41}
42
43impl Default for ChartConfig {
44 fn default() -> Self {
45 Self {
46 width: 60,
47 height: 15,
48 title: None,
49 show_labels: true,
50 }
51 }
52}
53
54pub fn render_price_chart(price_history: &[PricePoint], width: u32, height: u32) -> String {
66 if price_history.is_empty() {
67 return "No price data available".to_string();
68 }
69
70 let mut output = String::new();
71
72 let min_price = price_history
74 .iter()
75 .map(|p| p.price)
76 .fold(f64::INFINITY, f64::min);
77 let max_price = price_history
78 .iter()
79 .map(|p| p.price)
80 .fold(f64::NEG_INFINITY, f64::max);
81
82 let min_time = price_history.iter().map(|p| p.timestamp).min().unwrap_or(0);
84 let max_time = price_history.iter().map(|p| p.timestamp).max().unwrap_or(0);
85
86 let points: Vec<(f32, f32)> = price_history
88 .iter()
89 .map(|p| {
90 let x = (p.timestamp - min_time) as f32;
91 let y = p.price as f32;
92 (x, y)
93 })
94 .collect();
95
96 if points.is_empty() {
97 return "No price data available".to_string();
98 }
99
100 let x_max = (max_time - min_time) as f32;
102 let x_min = 0.0_f32;
103
104 let chart_str = Chart::new(width, height, x_min, x_max)
106 .lineplot(&Shape::Lines(&points))
107 .to_string();
108
109 output.push_str(&format!("Price (${:.4} - ${:.4})\n", min_price, max_price));
111 output.push_str(&chart_str);
112
113 let time_range_hours = (max_time - min_time) as f64 / 3600.0;
115 if time_range_hours <= 24.0 {
116 output.push_str(&format!(
117 " {:>width$}\n",
118 format!("{:.0}h ago -> now", time_range_hours),
119 width = width as usize - 5
120 ));
121 } else {
122 let days = time_range_hours / 24.0;
123 output.push_str(&format!(
124 " {:>width$}\n",
125 format!("{:.0}d ago -> now", days),
126 width = width as usize - 5
127 ));
128 }
129
130 output
131}
132
133pub fn render_volume_chart(volume_history: &[VolumePoint], width: u32, height: u32) -> String {
145 if volume_history.is_empty() {
146 return "No volume data available".to_string();
147 }
148
149 let mut output = String::new();
150
151 let max_volume = volume_history
153 .iter()
154 .map(|v| v.volume)
155 .fold(f64::NEG_INFINITY, f64::max);
156
157 let total_volume: f64 = volume_history.iter().map(|v| v.volume).sum();
158
159 let max_vol_formatted = format_large_number(max_volume);
161 let total_vol_formatted = format_large_number(total_volume);
162
163 output.push_str(&format!(
164 "Volume (max: ${}, total: ${})\n",
165 max_vol_formatted, total_vol_formatted
166 ));
167
168 let min_time = volume_history
170 .iter()
171 .map(|v| v.timestamp)
172 .min()
173 .unwrap_or(0);
174 let max_time = volume_history
175 .iter()
176 .map(|v| v.timestamp)
177 .max()
178 .unwrap_or(0);
179
180 let points: Vec<(f32, f32)> = volume_history
182 .iter()
183 .map(|v| {
184 let x = (v.timestamp - min_time) as f32;
185 let y = v.volume as f32;
186 (x, y)
187 })
188 .collect();
189
190 let x_max = (max_time - min_time) as f32;
191 let x_min = 0.0_f32;
192
193 let chart_str = Chart::new(width, height, x_min, x_max)
195 .lineplot(&Shape::Bars(&points))
196 .to_string();
197
198 output.push_str(&chart_str);
199
200 output
201}
202
203pub fn render_holder_distribution(holders: &[TokenHolder]) -> String {
216 if holders.is_empty() {
217 return "No holder data available".to_string();
218 }
219
220 let mut output = String::new();
221 output.push_str("Top Holders\n");
222 output.push_str(&"=".repeat(50));
223 output.push('\n');
224
225 let max_bar_width = 20;
226
227 for holder in holders.iter().take(10) {
228 let addr_display = truncate_address(&holder.address);
230
231 let bar_width = ((holder.percentage / 100.0) * max_bar_width as f64).round() as usize;
233 let bar_width = bar_width.min(max_bar_width);
234
235 let filled = "█".repeat(bar_width);
236 let empty = "░".repeat(max_bar_width - bar_width);
237
238 output.push_str(&format!(
239 "{:>2}. {} {:>6.2}% {}{}\n",
240 holder.rank, addr_display, holder.percentage, filled, empty
241 ));
242 }
243
244 if holders.len() >= 10 {
246 let top_10_total: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
247 output.push_str(&"-".repeat(50));
248 output.push('\n');
249 output.push_str(&format!("Top 10 control: {:.1}% of supply\n", top_10_total));
250 }
251
252 output
253}
254
255pub fn render_analytics_dashboard(
269 price_history: &[PricePoint],
270 volume_history: &[VolumePoint],
271 holders: &[TokenHolder],
272 token_symbol: &str,
273 chain: &str,
274) -> String {
275 let mut output = String::new();
276
277 output.push_str(&format!("Token Analytics: {} on {}\n", token_symbol, chain));
279 output.push_str(&"=".repeat(60));
280 output.push_str("\n\n");
281
282 if !price_history.is_empty() {
284 output.push_str(&render_price_chart(price_history, 60, 12));
285 output.push('\n');
286 }
287
288 if !volume_history.is_empty() {
290 output.push_str(&render_volume_chart(volume_history, 60, 8));
291 output.push('\n');
292 }
293
294 if !holders.is_empty() {
296 output.push_str(&render_holder_distribution(holders));
297 }
298
299 output
300}
301
302fn truncate_address(address: &str) -> String {
304 if address.len() <= 13 {
305 address.to_string()
306 } else {
307 format!("{}...{}", &address[..6], &address[address.len() - 4..])
308 }
309}
310
311fn format_large_number(value: f64) -> String {
313 if value >= 1_000_000_000.0 {
314 format!("{:.2}B", value / 1_000_000_000.0)
315 } else if value >= 1_000_000.0 {
316 format!("{:.2}M", value / 1_000_000.0)
317 } else if value >= 1_000.0 {
318 format!("{:.2}K", value / 1_000.0)
319 } else {
320 format!("{:.2}", value)
321 }
322}
323
324#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn test_render_price_chart_empty() {
334 let result = render_price_chart(&[], 60, 10);
335 assert!(result.contains("No price data"));
336 }
337
338 #[test]
339 fn test_render_price_chart_with_data() {
340 let history = vec![
341 PricePoint {
342 timestamp: 0,
343 price: 100.0,
344 },
345 PricePoint {
346 timestamp: 3600,
347 price: 105.0,
348 },
349 PricePoint {
350 timestamp: 7200,
351 price: 102.0,
352 },
353 ];
354
355 let result = render_price_chart(&history, 60, 10);
356 assert!(!result.is_empty());
357 assert!(result.contains("Price"));
358 }
359
360 #[test]
361 fn test_render_volume_chart_empty() {
362 let result = render_volume_chart(&[], 60, 10);
363 assert!(result.contains("No volume data"));
364 }
365
366 #[test]
367 fn test_render_volume_chart_with_data() {
368 let history = vec![
369 VolumePoint {
370 timestamp: 0,
371 volume: 1000000.0,
372 },
373 VolumePoint {
374 timestamp: 3600,
375 volume: 1500000.0,
376 },
377 ];
378
379 let result = render_volume_chart(&history, 60, 10);
380 assert!(!result.is_empty());
381 assert!(result.contains("Volume"));
382 }
383
384 #[test]
385 fn test_render_holder_distribution_empty() {
386 let result = render_holder_distribution(&[]);
387 assert!(result.contains("No holder data"));
388 }
389
390 #[test]
391 fn test_render_holder_distribution_with_data() {
392 let holders = vec![
393 TokenHolder {
394 address: "0x1234567890123456789012345678901234567890".to_string(),
395 balance: "1000000".to_string(),
396 formatted_balance: "1M".to_string(),
397 percentage: 25.0,
398 rank: 1,
399 },
400 TokenHolder {
401 address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd".to_string(),
402 balance: "500000".to_string(),
403 formatted_balance: "500K".to_string(),
404 percentage: 12.5,
405 rank: 2,
406 },
407 ];
408
409 let result = render_holder_distribution(&holders);
410 assert!(result.contains("Top Holders"));
411 assert!(result.contains("25.00%"));
412 assert!(result.contains("12.50%"));
413 assert!(result.contains("█")); }
415
416 #[test]
417 fn test_truncate_address() {
418 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
419 let truncated = truncate_address(addr);
420 assert_eq!(truncated, "0x742d...b3c2");
421
422 let short = "0x123";
424 assert_eq!(truncate_address(short), "0x123");
425 }
426
427 #[test]
428 fn test_format_large_number() {
429 assert_eq!(format_large_number(1500.0), "1.50K");
430 assert_eq!(format_large_number(1500000.0), "1.50M");
431 assert_eq!(format_large_number(1500000000.0), "1.50B");
432 assert_eq!(format_large_number(500.0), "500.00");
433 }
434
435 #[test]
436 fn test_chart_config_default() {
437 let config = ChartConfig::default();
438 assert_eq!(config.width, 60);
439 assert_eq!(config.height, 15);
440 assert!(config.show_labels);
441 }
442
443 #[test]
444 fn test_render_analytics_dashboard() {
445 let prices = vec![PricePoint {
446 timestamp: 0,
447 price: 1.0,
448 }];
449 let volumes = vec![VolumePoint {
450 timestamp: 0,
451 volume: 1000.0,
452 }];
453 let holders = vec![TokenHolder {
454 address: "0x1234567890123456789012345678901234567890".to_string(),
455 balance: "1000".to_string(),
456 formatted_balance: "1K".to_string(),
457 percentage: 50.0,
458 rank: 1,
459 }];
460
461 let result = render_analytics_dashboard(&prices, &volumes, &holders, "TEST", "ethereum");
462 assert!(result.contains("Token Analytics: TEST on ethereum"));
463 }
464
465 #[test]
466 fn test_price_chart_multiday_range() {
467 let prices: Vec<PricePoint> = (0..50)
469 .map(|i| PricePoint {
470 timestamp: i * 7200, price: 1.0 + (i as f64) * 0.01,
472 })
473 .collect();
474 let chart = render_price_chart(&prices, 60, 15);
475 assert!(chart.contains("d ago -> now"));
476 }
477
478 #[test]
479 fn test_holder_distribution_with_10_holders() {
480 let holders: Vec<TokenHolder> = (1..=12)
482 .map(|i| TokenHolder {
483 address: format!("0x{:040}", i),
484 balance: format!("{}", 1000 - i * 50),
485 formatted_balance: format!("{}K", (1000 - i * 50) / 1000),
486 percentage: 10.0 - (i as f64) * 0.5,
487 rank: i as u32,
488 })
489 .collect();
490 let chart = render_holder_distribution(&holders);
491 assert!(chart.contains("Top 10 control:"));
492 assert!(chart.contains("% of supply"));
493 }
494}