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 = crate::display::format_usd(max_volume);
161 let total_vol_formatted = crate::display::format_usd(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 = super::text::abbreviate(&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
302#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn test_render_price_chart_empty() {
312 let result = render_price_chart(&[], 60, 10);
313 assert!(result.contains("No price data"));
314 }
315
316 #[test]
317 fn test_render_price_chart_with_data() {
318 let history = vec![
319 PricePoint {
320 timestamp: 0,
321 price: 100.0,
322 },
323 PricePoint {
324 timestamp: 3600,
325 price: 105.0,
326 },
327 PricePoint {
328 timestamp: 7200,
329 price: 102.0,
330 },
331 ];
332
333 let result = render_price_chart(&history, 60, 10);
334 assert!(!result.is_empty());
335 assert!(result.contains("Price"));
336 }
337
338 #[test]
339 fn test_render_volume_chart_empty() {
340 let result = render_volume_chart(&[], 60, 10);
341 assert!(result.contains("No volume data"));
342 }
343
344 #[test]
345 fn test_render_volume_chart_with_data() {
346 let history = vec![
347 VolumePoint {
348 timestamp: 0,
349 volume: 1000000.0,
350 },
351 VolumePoint {
352 timestamp: 3600,
353 volume: 1500000.0,
354 },
355 ];
356
357 let result = render_volume_chart(&history, 60, 10);
358 assert!(!result.is_empty());
359 assert!(result.contains("Volume"));
360 }
361
362 #[test]
363 fn test_render_holder_distribution_empty() {
364 let result = render_holder_distribution(&[]);
365 assert!(result.contains("No holder data"));
366 }
367
368 #[test]
369 fn test_render_holder_distribution_with_data() {
370 let holders = vec![
371 TokenHolder {
372 address: "0x1234567890123456789012345678901234567890".to_string(),
373 balance: "1000000".to_string(),
374 formatted_balance: "1M".to_string(),
375 percentage: 25.0,
376 rank: 1,
377 },
378 TokenHolder {
379 address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd".to_string(),
380 balance: "500000".to_string(),
381 formatted_balance: "500K".to_string(),
382 percentage: 12.5,
383 rank: 2,
384 },
385 ];
386
387 let result = render_holder_distribution(&holders);
388 assert!(result.contains("Top Holders"));
389 assert!(result.contains("25.00%"));
390 assert!(result.contains("12.50%"));
391 assert!(result.contains("█")); }
393
394 #[test]
395 fn test_format_large_number() {
396 assert_eq!(crate::display::format_large_number(1500.0), "1.50K");
397 assert_eq!(crate::display::format_large_number(1500000.0), "1.50M");
398 assert_eq!(crate::display::format_large_number(1500000000.0), "1.50B");
399 assert_eq!(crate::display::format_large_number(500.0), "500.00");
400 }
401
402 #[test]
403 fn test_chart_config_default() {
404 let config = ChartConfig::default();
405 assert_eq!(config.width, 60);
406 assert_eq!(config.height, 15);
407 assert!(config.show_labels);
408 }
409
410 #[test]
411 fn test_render_analytics_dashboard() {
412 let prices = vec![PricePoint {
413 timestamp: 0,
414 price: 1.0,
415 }];
416 let volumes = vec![VolumePoint {
417 timestamp: 0,
418 volume: 1000.0,
419 }];
420 let holders = vec![TokenHolder {
421 address: "0x1234567890123456789012345678901234567890".to_string(),
422 balance: "1000".to_string(),
423 formatted_balance: "1K".to_string(),
424 percentage: 50.0,
425 rank: 1,
426 }];
427
428 let result = render_analytics_dashboard(&prices, &volumes, &holders, "TEST", "ethereum");
429 assert!(result.contains("Token Analytics: TEST on ethereum"));
430 }
431
432 #[test]
433 fn test_price_chart_multiday_range() {
434 let prices: Vec<PricePoint> = (0..50)
436 .map(|i| PricePoint {
437 timestamp: i * 7200, price: 1.0 + (i as f64) * 0.01,
439 })
440 .collect();
441 let chart = render_price_chart(&prices, 60, 15);
442 assert!(chart.contains("d ago -> now"));
443 }
444
445 #[test]
446 fn test_holder_distribution_with_10_holders() {
447 let holders: Vec<TokenHolder> = (1..=12)
449 .map(|i| TokenHolder {
450 address: format!("0x{:040}", i),
451 balance: format!("{}", 1000 - i * 50),
452 formatted_balance: format!("{}K", (1000 - i * 50) / 1000),
453 percentage: 10.0 - (i as f64) * 0.5,
454 rank: i as u32,
455 })
456 .collect();
457 let chart = render_holder_distribution(&holders);
458 assert!(chart.contains("Top 10 control:"));
459 assert!(chart.contains("% of supply"));
460 }
461}