Skip to main content

scope/display/
report.rs

1//! # Markdown Report Generator
2//!
3//! This module generates comprehensive markdown reports for token analytics.
4//! Reports include full, non-truncated addresses and all available data.
5//!
6//! ## Features
7//!
8//! - Executive summary with key metrics
9//! - Price and volume analysis
10//! - Complete holder distribution with full addresses
11//! - Concentration metrics and risk indicators
12//! - Data source links for verification
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use scope::display::report::{generate_report, save_report};
18//! use scope::chains::TokenAnalytics;
19//!
20//! // Assuming you have TokenAnalytics data
21//! // let analytics = ...;
22//! // let report = generate_report(&analytics);
23//! // save_report(&report, "report.md").unwrap();
24//! ```
25
26use crate::chains::TokenAnalytics;
27use crate::error::Result;
28use chrono::{DateTime, Utc};
29use std::path::Path;
30
31// ============================================================================
32// Block explorer base URLs
33// ============================================================================
34
35/// Etherscan base URL for Ethereum token pages.
36const ETHERSCAN_TOKEN_BASE: &str = "https://etherscan.io/token";
37/// PolygonScan base URL for Polygon token pages.
38const POLYGONSCAN_TOKEN_BASE: &str = "https://polygonscan.com/token";
39/// Arbiscan base URL for Arbitrum token pages.
40const ARBISCAN_TOKEN_BASE: &str = "https://arbiscan.io/token";
41/// Optimistic Etherscan base URL for Optimism token pages.
42const OPTIMISM_TOKEN_BASE: &str = "https://optimistic.etherscan.io/token";
43/// BaseScan base URL for Base token pages.
44const BASESCAN_TOKEN_BASE: &str = "https://basescan.org/token";
45/// BscScan base URL for BSC token pages.
46const BSCSCAN_TOKEN_BASE: &str = "https://bscscan.com/token";
47/// Solscan base URL for Solana token pages.
48const SOLSCAN_TOKEN_BASE: &str = "https://solscan.io/token";
49
50/// DexScreener base URL for token pair pages.
51const DEXSCREENER_BASE: &str = "https://dexscreener.com";
52/// GeckoTerminal base URL for pool pages.
53const GECKOTERMINAL_BASE: &str = "https://www.geckoterminal.com";
54
55/// Generates a comprehensive markdown report from token analytics.
56///
57/// # Arguments
58///
59/// * `analytics` - The token analytics data to include in the report
60///
61/// # Returns
62///
63/// Returns a formatted markdown string.
64///
65/// # Note
66///
67/// All addresses in the report are non-truncated for analysis and verification purposes.
68pub fn generate_report(analytics: &TokenAnalytics) -> String {
69    let mut report = String::new();
70
71    // Header
72    report.push_str(&generate_header(analytics));
73    report.push_str("\n---\n\n");
74
75    // Executive Summary
76    report.push_str(&generate_executive_summary(analytics));
77    report.push_str("\n---\n\n");
78
79    // Price Analysis with chart
80    report.push_str(&generate_price_analysis(analytics));
81    report.push_str(&generate_price_chart(analytics));
82    report.push_str("\n---\n\n");
83
84    // Volume Analysis with chart
85    report.push_str(&generate_volume_analysis(analytics));
86    report.push_str(&generate_volume_chart(analytics));
87    report.push_str("\n---\n\n");
88
89    // Liquidity Analysis with DEX chart
90    report.push_str(&generate_liquidity_analysis(analytics));
91    report.push_str(&generate_liquidity_chart(analytics));
92    report.push_str("\n---\n\n");
93
94    // Top Holders (with full addresses)
95    report.push_str(&generate_holder_section(analytics));
96    report.push_str("\n---\n\n");
97
98    // Concentration Analysis with pie chart
99    report.push_str(&generate_concentration_analysis(analytics));
100    report.push_str(&generate_concentration_chart(analytics));
101    report.push_str("\n---\n\n");
102
103    // Token Information (socials, websites)
104    report.push_str(&generate_token_info_section(analytics));
105    report.push_str("\n---\n\n");
106
107    // Security Analysis
108    report.push_str(&generate_security_analysis(analytics));
109    report.push_str("\n---\n\n");
110
111    // Risk Score
112    report.push_str(&generate_risk_score_section(analytics));
113    report.push_str("\n---\n\n");
114
115    // Risk Indicators
116    report.push_str(&generate_risk_indicators(analytics));
117    report.push_str("\n---\n\n");
118
119    // Data Sources
120    report.push_str(&generate_data_sources(analytics));
121
122    report
123}
124
125/// Generates the report header.
126fn generate_header(analytics: &TokenAnalytics) -> String {
127    let timestamp = DateTime::<Utc>::from_timestamp(analytics.fetched_at, 0)
128        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
129        .unwrap_or_else(|| "Unknown".to_string());
130
131    let mut header = String::new();
132    header.push_str(&format!(
133        "# Token Analysis Report: {}\n\n",
134        analytics.token.symbol
135    ));
136    header.push_str(&format!("**Token Name:** {}  \n", analytics.token.name));
137    header.push_str(&format!("**Chain:** {}  \n", capitalize(&analytics.chain)));
138    header.push_str(&format!("**Generated:** {}  \n", timestamp));
139    header.push_str(&format!(
140        "**Contract:** `{}`\n",
141        analytics.token.contract_address
142    ));
143
144    header
145}
146
147/// Generates the executive summary section.
148fn generate_executive_summary(analytics: &TokenAnalytics) -> String {
149    let mut summary = String::new();
150    summary.push_str("## Executive Summary\n\n");
151    summary.push_str("| Metric | Value |\n");
152    summary.push_str("|--------|-------|\n");
153    summary.push_str(&format!("| Price | ${:.6} |\n", analytics.price_usd));
154    summary.push_str(&format!(
155        "| 24h Change | {:+.2}% |\n",
156        analytics.price_change_24h
157    ));
158    summary.push_str(&format!(
159        "| 7d Change | {:+.2}% |\n",
160        analytics.price_change_7d
161    ));
162    summary.push_str(&format!(
163        "| 24h Volume | {} |\n",
164        format_usd(analytics.volume_24h)
165    ));
166    summary.push_str(&format!(
167        "| 7d Volume | {} |\n",
168        format_usd(analytics.volume_7d)
169    ));
170    summary.push_str(&format!(
171        "| Liquidity | {} |\n",
172        format_usd(analytics.liquidity_usd)
173    ));
174
175    if let Some(mc) = analytics.market_cap {
176        summary.push_str(&format!("| Market Cap | {} |\n", format_usd(mc)));
177    }
178
179    if let Some(fdv) = analytics.fdv {
180        summary.push_str(&format!(
181            "| Fully Diluted Valuation | {} |\n",
182            format_usd(fdv)
183        ));
184    }
185
186    summary.push_str(&format!(
187        "| Total Holders | {} |\n",
188        format_number(analytics.total_holders as f64)
189    ));
190
191    if let Some(ref supply) = analytics.total_supply {
192        summary.push_str(&format!("| Total Supply | {} |\n", supply));
193    }
194
195    if let Some(ref circ) = analytics.circulating_supply {
196        summary.push_str(&format!("| Circulating Supply | {} |\n", circ));
197    }
198
199    summary
200}
201
202/// Generates the price analysis section.
203fn generate_price_analysis(analytics: &TokenAnalytics) -> String {
204    let mut section = String::new();
205    section.push_str("## Price Analysis\n\n");
206
207    section.push_str(&format!(
208        "**Current Price:** ${:.6}\n\n",
209        analytics.price_usd
210    ));
211
212    // Price changes
213    section.push_str("### Price Changes\n\n");
214    section.push_str("| Period | Change |\n");
215    section.push_str("|--------|--------|\n");
216    section.push_str(&format!(
217        "| 24 Hours | {:+.2}% |\n",
218        analytics.price_change_24h
219    ));
220    section.push_str(&format!(
221        "| 7 Days | {:+.2}% |\n",
222        analytics.price_change_7d
223    ));
224
225    // Price history stats if available
226    if !analytics.price_history.is_empty() {
227        let prices: Vec<f64> = analytics.price_history.iter().map(|p| p.price).collect();
228        let min_price = prices.iter().cloned().fold(f64::INFINITY, f64::min);
229        let max_price = prices.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
230        let avg_price: f64 = prices.iter().sum::<f64>() / prices.len() as f64;
231
232        section.push_str("\n### Price Range (Period)\n\n");
233        section.push_str("| Stat | Value |\n");
234        section.push_str("|------|-------|\n");
235        section.push_str(&format!("| High | ${:.6} |\n", max_price));
236        section.push_str(&format!("| Low | ${:.6} |\n", min_price));
237        section.push_str(&format!("| Average | ${:.6} |\n", avg_price));
238    }
239
240    section
241}
242
243/// Generates the volume analysis section.
244fn generate_volume_analysis(analytics: &TokenAnalytics) -> String {
245    let mut section = String::new();
246    section.push_str("## Volume Analysis\n\n");
247
248    section.push_str("| Period | Volume |\n");
249    section.push_str("|--------|--------|\n");
250    section.push_str(&format!(
251        "| 24 Hours | {} |\n",
252        format_usd(analytics.volume_24h)
253    ));
254    section.push_str(&format!(
255        "| 7 Days | {} |\n",
256        format_usd(analytics.volume_7d)
257    ));
258
259    // Volume to liquidity ratio (indicator of trading activity)
260    if analytics.liquidity_usd > 0.0 {
261        let vol_to_liq = analytics.volume_24h / analytics.liquidity_usd;
262        section.push_str(&format!(
263            "\n**Volume/Liquidity Ratio (24h):** {:.2}x\n",
264            vol_to_liq
265        ));
266
267        if vol_to_liq > 5.0 {
268            section.push_str(
269                "\n> ⚠️ High volume relative to liquidity may indicate unusual trading activity.\n",
270            );
271        }
272    }
273
274    section
275}
276
277/// Generates the liquidity analysis section.
278fn generate_liquidity_analysis(analytics: &TokenAnalytics) -> String {
279    let mut section = String::new();
280    section.push_str("## Liquidity Analysis\n\n");
281
282    section.push_str(&format!(
283        "**Total Liquidity:** {}\n\n",
284        format_usd(analytics.liquidity_usd)
285    ));
286
287    if !analytics.dex_pairs.is_empty() {
288        section.push_str("### Trading Pairs\n\n");
289        section.push_str("| DEX | Pair | Liquidity | 24h Volume | Price |\n");
290        section.push_str("|-----|------|-----------|------------|-------|\n");
291
292        for pair in analytics.dex_pairs.iter().take(10) {
293            section.push_str(&format!(
294                "| {} | {}/{} | {} | {} | ${:.6} |\n",
295                pair.dex_name,
296                pair.base_token,
297                pair.quote_token,
298                format_usd(pair.liquidity_usd),
299                format_usd(pair.volume_24h),
300                pair.price_usd
301            ));
302        }
303    }
304
305    section
306}
307
308/// Generates the holder section with FULL addresses.
309fn generate_holder_section(analytics: &TokenAnalytics) -> String {
310    let mut section = String::new();
311    section.push_str("## Top Holders\n\n");
312
313    if analytics.holders.is_empty() {
314        section.push_str("*No holder data available*\n");
315        return section;
316    }
317
318    section.push_str("| Rank | Address | Balance | % of Supply |\n");
319    section.push_str("|------|---------|---------|-------------|\n");
320
321    for holder in &analytics.holders {
322        // IMPORTANT: Full addresses, not truncated
323        section.push_str(&format!(
324            "| {} | `{}` | {} | {:.2}% |\n",
325            holder.rank,
326            holder.address, // Full address
327            holder.formatted_balance,
328            holder.percentage
329        ));
330    }
331
332    section
333}
334
335/// Generates the concentration analysis section.
336fn generate_concentration_analysis(analytics: &TokenAnalytics) -> String {
337    let mut section = String::new();
338    section.push_str("## Concentration Analysis\n\n");
339
340    // Calculate concentration metrics from holder data
341    let top_10_pct: f64 = analytics
342        .holders
343        .iter()
344        .take(10)
345        .map(|h| h.percentage)
346        .sum();
347
348    let top_50_pct: f64 = analytics
349        .holders
350        .iter()
351        .take(50)
352        .map(|h| h.percentage)
353        .sum();
354
355    let top_100_pct: f64 = analytics
356        .holders
357        .iter()
358        .take(100)
359        .map(|h| h.percentage)
360        .sum();
361
362    // Use stored values if available, otherwise use calculated
363    let top_10 = analytics.top_10_concentration.unwrap_or(top_10_pct);
364    let top_50 = analytics.top_50_concentration.unwrap_or(top_50_pct);
365    let top_100 = analytics.top_100_concentration.unwrap_or(top_100_pct);
366
367    section.push_str(&format!(
368        "- **Top 10 holders control:** {:.1}% of supply\n",
369        top_10
370    ));
371    section.push_str(&format!(
372        "- **Top 50 holders control:** {:.1}% of supply\n",
373        top_50
374    ));
375    section.push_str(&format!(
376        "- **Top 100 holders control:** {:.1}% of supply\n",
377        top_100
378    ));
379
380    // Add interpretation
381    section.push_str("\n### Interpretation\n\n");
382
383    if top_10 > 80.0 {
384        section.push_str("- 🔴 **Very High Concentration:** Top 10 holders control over 80% of supply. This indicates significant centralization risk.\n");
385    } else if top_10 > 50.0 {
386        section.push_str("- 🟠 **High Concentration:** Top 10 holders control over 50% of supply. Moderate centralization risk.\n");
387    } else if top_10 > 25.0 {
388        section.push_str("- 🟡 **Moderate Concentration:** Top 10 holders control 25-50% of supply. Typical for many tokens.\n");
389    } else {
390        section.push_str("- 🟢 **Low Concentration:** Top 10 holders control less than 25% of supply. Well-distributed ownership.\n");
391    }
392
393    section
394}
395
396/// Generates the token information section with socials, websites, and DexScreener link.
397fn generate_token_info_section(analytics: &TokenAnalytics) -> String {
398    let mut section = String::new();
399    section.push_str("## Token Information\n\n");
400
401    // Display image URL if available
402    if let Some(ref image_url) = analytics.image_url {
403        section.push_str(&format!("**Token Logo:** [View Image]({})\n\n", image_url));
404    }
405
406    // Display websites
407    if !analytics.websites.is_empty() {
408        section.push_str("### Websites\n\n");
409        for website in &analytics.websites {
410            section.push_str(&format!("- [{}]({})\n", website, website));
411        }
412        section.push('\n');
413    }
414
415    // Display social links
416    if !analytics.socials.is_empty() {
417        section.push_str("### Social Media\n\n");
418        for social in &analytics.socials {
419            let icon = match social.platform.to_lowercase().as_str() {
420                "twitter" | "x" => "🐦",
421                "telegram" => "📱",
422                "discord" => "💬",
423                "medium" => "📝",
424                "github" => "💻",
425                "reddit" => "🔴",
426                "youtube" => "📺",
427                "facebook" => "📘",
428                "instagram" => "📷",
429                _ => "🔗",
430            };
431            section.push_str(&format!(
432                "- {} **{}**: [{}]({})\n",
433                icon,
434                capitalize(&social.platform),
435                social.url,
436                social.url
437            ));
438        }
439        section.push('\n');
440    }
441
442    // Display DexScreener link
443    if let Some(ref dexscreener_url) = analytics.dexscreener_url {
444        section.push_str("### Trading Links\n\n");
445        section.push_str(&format!(
446            "- 📊 **DexScreener:** [View on DexScreener]({})\n",
447            dexscreener_url
448        ));
449        section.push('\n');
450    }
451
452    // If no metadata available, note it
453    if analytics.image_url.is_none()
454        && analytics.websites.is_empty()
455        && analytics.socials.is_empty()
456        && analytics.dexscreener_url.is_none()
457    {
458        section.push_str("*No additional token metadata available*\n");
459    }
460
461    section
462}
463
464/// Generates the security analysis section with honeypot detection and token age.
465fn generate_security_analysis(analytics: &TokenAnalytics) -> String {
466    let mut section = String::new();
467    section.push_str("## Security Analysis\n\n");
468
469    // Build the security checks table
470    section.push_str("| Check | Status | Details |\n");
471    section.push_str("|-------|--------|--------|\n");
472
473    // Honeypot Risk Analysis (buy/sell ratio)
474    let buys = analytics.total_buys_24h;
475    let sells = analytics.total_sells_24h;
476    let (honeypot_status, honeypot_details) = if buys == 0 && sells == 0 {
477        ("⚪ UNKNOWN", "No transaction data available".to_string())
478    } else if sells == 0 && buys > 0 {
479        (
480            "🔴 HIGH",
481            format!("{} buys / 0 sells - Possible honeypot!", buys),
482        )
483    } else {
484        let ratio = if sells > 0 {
485            buys as f64 / sells as f64
486        } else {
487            f64::INFINITY
488        };
489        if ratio > 10.0 {
490            (
491                "🔴 HIGH",
492                format!(
493                    "{} buys / {} sells (ratio: {:.2}) - Suspicious activity!",
494                    buys, sells, ratio
495                ),
496            )
497        } else if ratio > 3.0 {
498            (
499                "🟠 MEDIUM",
500                format!(
501                    "{} buys / {} sells (ratio: {:.2}) - Elevated risk",
502                    buys, sells, ratio
503                ),
504            )
505        } else {
506            (
507                "🟢 LOW",
508                format!(
509                    "{} buys / {} sells (ratio: {:.2}) - Normal activity",
510                    buys, sells, ratio
511                ),
512            )
513        }
514    };
515    section.push_str(&format!(
516        "| Honeypot Risk | {} | {} |\n",
517        honeypot_status, honeypot_details
518    ));
519
520    // Token Age Analysis
521    let (age_status, age_details) = match analytics.token_age_hours {
522        Some(hours) if hours < 24.0 => (
523            "🔴 HIGH RISK",
524            format!("Created {:.1} hours ago - Very new token!", hours),
525        ),
526        Some(hours) if hours < 48.0 => (
527            "🟠 MEDIUM",
528            format!("Created {:.1} hours ago - New token", hours),
529        ),
530        Some(hours) if hours < 168.0 => {
531            // 7 days
532            let days = hours / 24.0;
533            (
534                "🟡 CAUTION",
535                format!("Created {:.1} days ago - Relatively new", days),
536            )
537        }
538        Some(hours) => {
539            let days = hours / 24.0;
540            if days > 365.0 {
541                let years = days / 365.0;
542                ("🟢 ESTABLISHED", format!("Created {:.1} years ago", years))
543            } else if days > 30.0 {
544                let months = days / 30.0;
545                (
546                    "🟢 ESTABLISHED",
547                    format!("Created {:.1} months ago", months),
548                )
549            } else {
550                ("🟢 MODERATE", format!("Created {:.1} days ago", days))
551            }
552        }
553        None => ("⚪ UNKNOWN", "Token age data not available".to_string()),
554    };
555    section.push_str(&format!(
556        "| Token Age | {} | {} |\n",
557        age_status, age_details
558    ));
559
560    // Whale Concentration Risk
561    let top_holder_pct = analytics
562        .holders
563        .first()
564        .map(|h| h.percentage)
565        .unwrap_or(0.0);
566    let (whale_status, whale_details) = if top_holder_pct > 50.0 {
567        (
568            "🔴 HIGH",
569            format!(
570                "Largest holder owns {:.1}% - Extreme concentration!",
571                top_holder_pct
572            ),
573        )
574    } else if top_holder_pct > 25.0 {
575        (
576            "🟠 MEDIUM",
577            format!(
578                "Largest holder owns {:.1}% - High concentration",
579                top_holder_pct
580            ),
581        )
582    } else if top_holder_pct > 10.0 {
583        (
584            "🟡 MODERATE",
585            format!("Largest holder owns {:.1}%", top_holder_pct),
586        )
587    } else if top_holder_pct > 0.0 {
588        (
589            "🟢 LOW",
590            format!(
591                "Largest holder owns {:.1}% - Well distributed",
592                top_holder_pct
593            ),
594        )
595    } else {
596        ("⚪ UNKNOWN", "Holder data not available".to_string())
597    };
598    section.push_str(&format!(
599        "| Whale Risk | {} | {} |\n",
600        whale_status, whale_details
601    ));
602
603    // Social Presence
604    let (social_status, social_details) =
605        if analytics.socials.is_empty() && analytics.websites.is_empty() {
606            (
607                "🟠 NONE",
608                "No verified social links or websites".to_string(),
609            )
610        } else {
611            let social_count = analytics.socials.len();
612            let website_count = analytics.websites.len();
613            (
614                "🟢 PRESENT",
615                format!("{} social links, {} websites", social_count, website_count),
616            )
617        };
618    section.push_str(&format!(
619        "| Social Presence | {} | {} |\n",
620        social_status, social_details
621    ));
622
623    section.push('\n');
624
625    // Add Mermaid charts for visualization
626    if analytics.total_buys_24h > 0 || analytics.total_sells_24h > 0 {
627        // Buy/Sell Distribution Pie Chart
628        section.push_str(&generate_buysell_chart(analytics));
629        section.push('\n');
630
631        // Transaction Activity Bar Chart
632        section.push_str(&generate_txn_activity_chart(analytics));
633        section.push('\n');
634    }
635
636    // Add recent activity summary
637    if analytics.total_buys_1h > 0 || analytics.total_sells_1h > 0 {
638        section.push_str("### Recent Activity\n\n");
639        section.push_str(&format!(
640            "- **1h:** {} buys, {} sells\n",
641            analytics.total_buys_1h, analytics.total_sells_1h
642        ));
643        section.push_str(&format!(
644            "- **6h:** {} buys, {} sells\n",
645            analytics.total_buys_6h, analytics.total_sells_6h
646        ));
647        section.push_str(&format!(
648            "- **24h:** {} buys, {} sells\n",
649            analytics.total_buys_24h, analytics.total_sells_24h
650        ));
651        section.push('\n');
652    }
653
654    section
655}
656
657/// Generates a Mermaid pie chart showing buy vs sell transaction distribution.
658fn generate_buysell_chart(analytics: &TokenAnalytics) -> String {
659    let buys = analytics.total_buys_24h;
660    let sells = analytics.total_sells_24h;
661
662    if buys == 0 && sells == 0 {
663        return String::new();
664    }
665
666    let mut chart = String::new();
667    chart.push_str("### 24h Transaction Distribution\n\n");
668    chart.push_str("```mermaid\n");
669    chart.push_str("pie showData\n");
670    chart.push_str("    title \"24h Buy vs Sell Transactions\"\n");
671    chart.push_str(&format!("    \"Buys\" : {}\n", buys));
672    chart.push_str(&format!("    \"Sells\" : {}\n", sells));
673    chart.push_str("```\n");
674
675    chart
676}
677
678/// Generates a Mermaid bar chart showing transaction activity across time periods.
679fn generate_txn_activity_chart(analytics: &TokenAnalytics) -> String {
680    // Only generate if we have data
681    if analytics.total_buys_24h == 0
682        && analytics.total_sells_24h == 0
683        && analytics.total_buys_6h == 0
684        && analytics.total_sells_6h == 0
685        && analytics.total_buys_1h == 0
686        && analytics.total_sells_1h == 0
687    {
688        return String::new();
689    }
690
691    let mut chart = String::new();
692    chart.push_str("### Transaction Activity by Period\n\n");
693
694    // Use a table format for clearer multi-series data since xychart-beta
695    // doesn't support multiple named bar series well
696    chart.push_str("| Period | Buys | Sells | Ratio |\n");
697    chart.push_str("|--------|------|-------|-------|\n");
698
699    let periods = [
700        ("1h", analytics.total_buys_1h, analytics.total_sells_1h),
701        ("6h", analytics.total_buys_6h, analytics.total_sells_6h),
702        ("24h", analytics.total_buys_24h, analytics.total_sells_24h),
703    ];
704
705    for (period, buys, sells) in periods {
706        let ratio = if sells > 0 {
707            format!("{:.2}", buys as f64 / sells as f64)
708        } else if buys > 0 {
709            "∞".to_string()
710        } else {
711            "-".to_string()
712        };
713        chart.push_str(&format!(
714            "| {} | {} | {} | {} |\n",
715            period, buys, sells, ratio
716        ));
717    }
718
719    chart.push('\n');
720
721    // Add a simple bar chart for visual representation of 24h activity
722    chart.push_str("```mermaid\n");
723    chart.push_str("xychart-beta\n");
724    chart.push_str("    title \"24h Transaction Volume\"\n");
725    chart.push_str("    x-axis [\"Buys\", \"Sells\"]\n");
726    chart.push_str("    y-axis \"Count\"\n");
727    chart.push_str(&format!(
728        "    bar [{}, {}]\n",
729        analytics.total_buys_24h, analytics.total_sells_24h
730    ));
731    chart.push_str("```\n");
732
733    chart
734}
735
736/// Risk factors used to calculate the overall risk score.
737struct RiskFactors {
738    /// Honeypot risk (0-10, higher is riskier)
739    honeypot: u8,
740    /// Token age risk (0-10, higher is riskier for newer tokens)
741    age: u8,
742    /// Liquidity risk (0-10, higher is riskier for low liquidity)
743    liquidity: u8,
744    /// Holder concentration risk (0-10, higher is riskier)
745    concentration: u8,
746    /// Social presence risk (0-10, higher is riskier for no presence)
747    social: u8,
748}
749
750impl RiskFactors {
751    /// Calculate risk factors from token analytics.
752    fn from_analytics(analytics: &TokenAnalytics) -> Self {
753        // Honeypot risk based on buy/sell ratio
754        let honeypot = if analytics.total_buys_24h == 0 && analytics.total_sells_24h == 0 {
755            5 // Unknown, moderate risk
756        } else if analytics.total_sells_24h == 0 && analytics.total_buys_24h > 0 {
757            10 // Maximum risk
758        } else {
759            let ratio = analytics.total_buys_24h as f64 / analytics.total_sells_24h.max(1) as f64;
760            if ratio > 10.0 {
761                9
762            } else if ratio > 5.0 {
763                7
764            } else if ratio > 3.0 {
765                5
766            } else if ratio > 2.0 {
767                3
768            } else {
769                1
770            }
771        };
772
773        // Age risk based on token age
774        let age = match analytics.token_age_hours {
775            Some(hours) if hours < 24.0 => 10,
776            Some(hours) if hours < 48.0 => 8,
777            Some(hours) if hours < 168.0 => 6,  // 7 days
778            Some(hours) if hours < 720.0 => 4,  // 30 days
779            Some(hours) if hours < 2160.0 => 2, // 90 days
780            Some(_) => 1,
781            None => 5, // Unknown
782        };
783
784        // Liquidity risk
785        let liquidity = if analytics.liquidity_usd < 10_000.0 {
786            10
787        } else if analytics.liquidity_usd < 50_000.0 {
788            8
789        } else if analytics.liquidity_usd < 100_000.0 {
790            6
791        } else if analytics.liquidity_usd < 500_000.0 {
792            4
793        } else if analytics.liquidity_usd < 1_000_000.0 {
794            2
795        } else {
796            1
797        };
798
799        // Concentration risk based on top holder percentage
800        let top_holder_pct = analytics
801            .holders
802            .first()
803            .map(|h| h.percentage)
804            .unwrap_or(0.0);
805        let concentration = if top_holder_pct > 50.0 {
806            10
807        } else if top_holder_pct > 30.0 {
808            8
809        } else if top_holder_pct > 20.0 {
810            6
811        } else if top_holder_pct > 10.0 {
812            4
813        } else if top_holder_pct > 5.0 {
814            2
815        } else {
816            1
817        };
818
819        // Social presence risk
820        let social = if analytics.socials.is_empty() && analytics.websites.is_empty() {
821            8
822        } else if analytics.socials.is_empty() || analytics.websites.is_empty() {
823            4
824        } else if analytics.socials.len() >= 2 && !analytics.websites.is_empty() {
825            1
826        } else {
827            2
828        };
829
830        RiskFactors {
831            honeypot,
832            age,
833            liquidity,
834            concentration,
835            social,
836        }
837    }
838
839    /// Calculate the overall risk score (1-10).
840    fn overall_score(&self) -> u8 {
841        // Weighted average with honeypot and concentration being most important
842        let weighted = (self.honeypot as u16 * 3
843            + self.age as u16 * 2
844            + self.liquidity as u16 * 2
845            + self.concentration as u16 * 3
846            + self.social as u16)
847            / 11;
848        weighted.clamp(1, 10) as u8
849    }
850
851    /// Get risk level label.
852    fn risk_level(&self) -> &'static str {
853        match self.overall_score() {
854            1..=3 => "LOW",
855            4..=6 => "MEDIUM",
856            7..=8 => "HIGH",
857            _ => "CRITICAL",
858        }
859    }
860
861    /// Get risk level color/emoji.
862    fn risk_emoji(&self) -> &'static str {
863        match self.overall_score() {
864            1..=3 => "🟢",
865            4..=6 => "🟡",
866            7..=8 => "🟠",
867            _ => "🔴",
868        }
869    }
870}
871
872/// Generates the risk score section with breakdown pie chart.
873fn generate_risk_score_section(analytics: &TokenAnalytics) -> String {
874    let mut section = String::new();
875    section.push_str("## Risk Score\n\n");
876
877    let factors = RiskFactors::from_analytics(analytics);
878    let overall = factors.overall_score();
879    let level = factors.risk_level();
880    let emoji = factors.risk_emoji();
881
882    // Overall risk score display
883    section.push_str(&format!(
884        "### Overall Risk: {} {}/10 ({})\n\n",
885        emoji, overall, level
886    ));
887
888    // Factor breakdown table
889    section.push_str("| Factor | Score | Assessment |\n");
890    section.push_str("|--------|-------|------------|\n");
891    section.push_str(&format!(
892        "| Honeypot Risk | {}/10 | {} |\n",
893        factors.honeypot,
894        risk_assessment(factors.honeypot)
895    ));
896    section.push_str(&format!(
897        "| Token Age | {}/10 | {} |\n",
898        factors.age,
899        risk_assessment(factors.age)
900    ));
901    section.push_str(&format!(
902        "| Liquidity | {}/10 | {} |\n",
903        factors.liquidity,
904        risk_assessment(factors.liquidity)
905    ));
906    section.push_str(&format!(
907        "| Concentration | {}/10 | {} |\n",
908        factors.concentration,
909        risk_assessment(factors.concentration)
910    ));
911    section.push_str(&format!(
912        "| Social Presence | {}/10 | {} |\n",
913        factors.social,
914        risk_assessment(factors.social)
915    ));
916    section.push('\n');
917
918    // Risk breakdown pie chart
919    section.push_str(&generate_risk_breakdown_chart(&factors));
920
921    section
922}
923
924/// Get a risk assessment label for a given score.
925fn risk_assessment(score: u8) -> &'static str {
926    match score {
927        0..=2 => "Low Risk",
928        3..=4 => "Moderate",
929        5..=6 => "Elevated",
930        7..=8 => "High Risk",
931        _ => "Critical",
932    }
933}
934
935/// Generates a Mermaid pie chart showing risk factor breakdown.
936fn generate_risk_breakdown_chart(factors: &RiskFactors) -> String {
937    let mut chart = String::new();
938    chart.push_str("### Risk Factor Breakdown\n\n");
939    chart.push_str("```mermaid\n");
940    chart.push_str("pie showData\n");
941    chart.push_str("    title \"Risk Factor Contribution\"\n");
942    chart.push_str(&format!("    \"Honeypot\" : {}\n", factors.honeypot));
943    chart.push_str(&format!("    \"Token Age\" : {}\n", factors.age));
944    chart.push_str(&format!("    \"Liquidity\" : {}\n", factors.liquidity));
945    chart.push_str(&format!(
946        "    \"Concentration\" : {}\n",
947        factors.concentration
948    ));
949    chart.push_str(&format!("    \"Social\" : {}\n", factors.social));
950    chart.push_str("```\n");
951
952    chart
953}
954
955fn generate_risk_indicators(analytics: &TokenAnalytics) -> String {
956    let mut section = String::new();
957    section.push_str("## Risk Indicators\n\n");
958
959    let mut risks = Vec::new();
960    let mut positives = Vec::new();
961
962    // Concentration risk
963    let top_10_pct: f64 = analytics
964        .holders
965        .iter()
966        .take(10)
967        .map(|h| h.percentage)
968        .sum();
969
970    if top_10_pct > 80.0 {
971        risks
972            .push("🔴 **Extreme whale concentration** - Top 10 holders control over 80% of supply");
973    } else if top_10_pct > 50.0 {
974        risks.push("🟠 **High whale concentration** - Top 10 holders control over 50% of supply");
975    } else {
976        positives.push("🟢 **Reasonable distribution** - No extreme concentration in top holders");
977    }
978
979    // Liquidity risk
980    if analytics.liquidity_usd < 10_000.0 {
981        risks.push("🔴 **Very low liquidity** - High slippage risk for trades");
982    } else if analytics.liquidity_usd < 100_000.0 {
983        risks.push("🟠 **Low liquidity** - Moderate slippage risk for larger trades");
984    } else if analytics.liquidity_usd > 1_000_000.0 {
985        positives.push("🟢 **Good liquidity** - Sufficient depth for most trades");
986    }
987
988    // Volume risk
989    if analytics.volume_24h < 1_000.0 {
990        risks
991            .push("🟠 **Very low trading volume** - May indicate low interest or liquidity issues");
992    } else if analytics.volume_24h > 100_000.0 {
993        positives.push("🟢 **Active trading** - Healthy trading volume");
994    }
995
996    // Price volatility
997    if analytics.price_change_24h.abs() > 20.0 {
998        risks.push("🟠 **High price volatility** - Price moved over 20% in 24 hours");
999    }
1000
1001    if !risks.is_empty() {
1002        section.push_str("### Risk Factors\n\n");
1003        for risk in &risks {
1004            section.push_str(&format!("- {}\n", risk));
1005        }
1006        section.push('\n');
1007    }
1008
1009    if !positives.is_empty() {
1010        section.push_str("### Positive Indicators\n\n");
1011        for positive in &positives {
1012            section.push_str(&format!("- {}\n", positive));
1013        }
1014    }
1015
1016    if risks.is_empty() && positives.is_empty() {
1017        section.push_str("*Insufficient data for risk assessment*\n");
1018    }
1019
1020    section
1021}
1022
1023/// Generates the data sources section.
1024fn generate_data_sources(analytics: &TokenAnalytics) -> String {
1025    let mut section = String::new();
1026    section.push_str("## Data Sources\n\n");
1027
1028    let chain = &analytics.chain.to_lowercase();
1029    let address = &analytics.token.contract_address;
1030
1031    // Explorer links based on chain
1032    let explorer_url = match chain.as_str() {
1033        "ethereum" => format!("{}/{}", ETHERSCAN_TOKEN_BASE, address),
1034        "polygon" => format!("{}/{}", POLYGONSCAN_TOKEN_BASE, address),
1035        "arbitrum" => format!("{}/{}", ARBISCAN_TOKEN_BASE, address),
1036        "optimism" => format!("{}/{}", OPTIMISM_TOKEN_BASE, address),
1037        "base" => format!("{}/{}", BASESCAN_TOKEN_BASE, address),
1038        "bsc" => format!("{}/{}", BSCSCAN_TOKEN_BASE, address),
1039        "solana" => format!("{}/{}", SOLSCAN_TOKEN_BASE, address),
1040        _ => format!("{}/{}", ETHERSCAN_TOKEN_BASE, address),
1041    };
1042
1043    section.push_str(&format!(
1044        "- [Block Explorer ({})]({})\n",
1045        capitalize(chain),
1046        explorer_url
1047    ));
1048
1049    section.push_str(&format!(
1050        "- [DexScreener]({}/{}/{})\n",
1051        DEXSCREENER_BASE, chain, address
1052    ));
1053
1054    section.push_str(&format!(
1055        "- [GeckoTerminal]({}/{}/pools/{})\n",
1056        GECKOTERMINAL_BASE, chain, address
1057    ));
1058
1059    section.push_str("\n---\n\n");
1060    section.push_str("*This report was generated automatically. Always verify data from primary sources before making decisions.*\n");
1061
1062    section
1063}
1064
1065/// Saves a report to a file.
1066///
1067/// # Arguments
1068///
1069/// * `report` - The markdown report content
1070/// * `path` - The file path to save to
1071///
1072/// # Returns
1073///
1074/// Returns `Ok(())` on success, or an error if the file cannot be written.
1075pub fn save_report(report: &str, path: impl AsRef<Path>) -> Result<()> {
1076    std::fs::write(path.as_ref(), report).map_err(|e| {
1077        crate::error::ScopeError::Io(format!(
1078            "Failed to write report to {}: {}",
1079            path.as_ref().display(),
1080            e
1081        ))
1082    })
1083}
1084
1085/// Formats a USD value with appropriate suffixes.
1086fn format_usd(value: f64) -> String {
1087    if value >= 1_000_000_000.0 {
1088        format!("${:.2}B", value / 1_000_000_000.0)
1089    } else if value >= 1_000_000.0 {
1090        format!("${:.2}M", value / 1_000_000.0)
1091    } else if value >= 1_000.0 {
1092        format!("${:.0}K", value / 1_000.0)
1093    } else {
1094        format!("${:.2}", value)
1095    }
1096}
1097
1098/// Formats a number with commas.
1099fn format_number(value: f64) -> String {
1100    if value >= 1_000_000.0 {
1101        format!("{:.2}M", value / 1_000_000.0)
1102    } else if value >= 1_000.0 {
1103        format!("{:.0}K", value / 1_000.0)
1104    } else {
1105        format!("{:.0}", value)
1106    }
1107}
1108
1109/// Capitalizes the first letter of a string.
1110fn capitalize(s: &str) -> String {
1111    let mut chars = s.chars();
1112    match chars.next() {
1113        None => String::new(),
1114        Some(first) => first.to_uppercase().chain(chars).collect(),
1115    }
1116}
1117
1118// ============================================================================
1119// Mermaid Chart Generation
1120// ============================================================================
1121
1122/// Generates a Mermaid line chart for price history.
1123fn generate_price_chart(analytics: &TokenAnalytics) -> String {
1124    // Generate price change comparison chart for multiple timeframes
1125    let mut chart = String::new();
1126
1127    // Only generate if we have meaningful data
1128    if analytics.price_change_1h == 0.0
1129        && analytics.price_change_6h == 0.0
1130        && analytics.price_change_24h == 0.0
1131        && analytics.price_change_7d == 0.0
1132    {
1133        // Fall back to price history chart if no change data
1134        if analytics.price_history.len() >= 2 {
1135            return generate_price_history_chart(analytics);
1136        }
1137        return String::new();
1138    }
1139
1140    chart.push_str("\n### Price Changes by Period\n\n");
1141    chart.push_str("```mermaid\n");
1142    chart.push_str("%%{init: {'theme': 'base'}}%%\n");
1143    chart.push_str("xychart-beta\n");
1144    chart.push_str("    title \"Price Change Comparison (%)\"\n");
1145    chart.push_str("    x-axis [\"1h\", \"6h\", \"24h\", \"7d\"]\n");
1146    chart.push_str("    y-axis \"Change %\"\n");
1147    chart.push_str(&format!(
1148        "    bar [{:.2}, {:.2}, {:.2}, {:.2}]\n",
1149        analytics.price_change_1h,
1150        analytics.price_change_6h,
1151        analytics.price_change_24h,
1152        analytics.price_change_7d
1153    ));
1154    chart.push_str("```\n");
1155
1156    // Also add the price history chart if available
1157    if analytics.price_history.len() >= 2 {
1158        chart.push_str(&generate_price_history_chart(analytics));
1159    }
1160
1161    chart
1162}
1163
1164/// Generates a price history line chart from historical data points.
1165fn generate_price_history_chart(analytics: &TokenAnalytics) -> String {
1166    if analytics.price_history.len() < 2 {
1167        return String::new();
1168    }
1169
1170    let mut chart = String::new();
1171    chart.push_str("\n### Price History\n\n");
1172    chart.push_str("```mermaid\n");
1173    chart.push_str("xychart-beta\n");
1174    chart.push_str("    title \"Price Over Time\"\n");
1175    chart.push_str("    x-axis [");
1176
1177    // Sample up to 12 data points for readability
1178    let step = (analytics.price_history.len() / 12).max(1);
1179    let sampled: Vec<_> = analytics
1180        .price_history
1181        .iter()
1182        .step_by(step)
1183        .take(12)
1184        .collect();
1185
1186    // Generate x-axis labels (time offsets)
1187    let labels: Vec<String> = sampled
1188        .iter()
1189        .enumerate()
1190        .map(|(i, _)| format!("\"{}\"", i + 1))
1191        .collect();
1192    chart.push_str(&labels.join(", "));
1193    chart.push_str("]\n");
1194
1195    // Generate y-axis with price data
1196    let prices: Vec<String> = sampled.iter().map(|p| format!("{:.6}", p.price)).collect();
1197    chart.push_str("    y-axis \"Price (USD)\"\n");
1198    chart.push_str("    line [");
1199    chart.push_str(&prices.join(", "));
1200    chart.push_str("]\n");
1201    chart.push_str("```\n");
1202
1203    chart
1204}
1205
1206/// Generates a Mermaid bar chart for volume history.
1207fn generate_volume_chart(analytics: &TokenAnalytics) -> String {
1208    if analytics.volume_history.len() < 2 {
1209        return String::new();
1210    }
1211
1212    let mut chart = String::new();
1213    chart.push_str("\n### Volume Chart\n\n");
1214    chart.push_str("```mermaid\n");
1215    chart.push_str("xychart-beta\n");
1216    chart.push_str("    title \"Trading Volume Over Time\"\n");
1217    chart.push_str("    x-axis [");
1218
1219    // Sample up to 12 data points for readability
1220    let step = (analytics.volume_history.len() / 12).max(1);
1221    let sampled: Vec<_> = analytics
1222        .volume_history
1223        .iter()
1224        .step_by(step)
1225        .take(12)
1226        .collect();
1227
1228    // Generate x-axis labels
1229    let labels: Vec<String> = sampled
1230        .iter()
1231        .enumerate()
1232        .map(|(i, _)| format!("\"{}\"", i + 1))
1233        .collect();
1234    chart.push_str(&labels.join(", "));
1235    chart.push_str("]\n");
1236
1237    // Generate y-axis with volume data
1238    let volumes: Vec<String> = sampled.iter().map(|v| format!("{:.0}", v.volume)).collect();
1239    chart.push_str("    y-axis \"Volume (USD)\"\n");
1240    chart.push_str("    bar [");
1241    chart.push_str(&volumes.join(", "));
1242    chart.push_str("]\n");
1243    chart.push_str("```\n");
1244
1245    chart
1246}
1247
1248/// Generates a Mermaid pie chart for liquidity distribution across DEXes.
1249fn generate_liquidity_chart(analytics: &TokenAnalytics) -> String {
1250    if analytics.dex_pairs.is_empty() {
1251        return String::new();
1252    }
1253
1254    // Only show chart if there are multiple DEXes
1255    if analytics.dex_pairs.len() < 2 {
1256        return String::new();
1257    }
1258
1259    let mut chart = String::new();
1260    chart.push_str("\n### Liquidity Distribution by DEX\n\n");
1261    chart.push_str("```mermaid\n");
1262    chart.push_str("pie showData\n");
1263    chart.push_str("    title Liquidity by DEX\n");
1264
1265    // Aggregate liquidity by DEX name
1266    let mut dex_liquidity: std::collections::HashMap<String, f64> =
1267        std::collections::HashMap::new();
1268    for pair in &analytics.dex_pairs {
1269        *dex_liquidity.entry(pair.dex_name.clone()).or_insert(0.0) += pair.liquidity_usd;
1270    }
1271
1272    // Sort by liquidity descending and take top 6
1273    let mut sorted: Vec<_> = dex_liquidity.into_iter().collect();
1274    sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1275
1276    for (dex, liquidity) in sorted.iter().take(6) {
1277        // Mermaid pie values need to be positive integers or percentages
1278        let value = (liquidity / 1_000_000.0).max(0.01); // Convert to millions
1279        chart.push_str(&format!("    \"{}\" : {:.2}\n", dex, value));
1280    }
1281
1282    chart.push_str("```\n");
1283
1284    chart
1285}
1286
1287/// Generates a Mermaid pie chart for holder concentration.
1288fn generate_concentration_chart(analytics: &TokenAnalytics) -> String {
1289    // Calculate concentration from holder data or use stored values
1290    let top_10_pct: f64 = analytics.top_10_concentration.unwrap_or_else(|| {
1291        analytics
1292            .holders
1293            .iter()
1294            .take(10)
1295            .map(|h| h.percentage)
1296            .sum()
1297    });
1298
1299    // Only show chart if we have meaningful concentration data
1300    if top_10_pct <= 0.0 || analytics.holders.is_empty() {
1301        return String::new();
1302    }
1303
1304    let remaining = (100.0 - top_10_pct).max(0.0);
1305
1306    let mut chart = String::new();
1307    chart.push_str("\n### Holder Concentration Chart\n\n");
1308    chart.push_str("```mermaid\n");
1309    chart.push_str("pie showData\n");
1310    chart.push_str("    title Token Holder Distribution\n");
1311    chart.push_str(&format!("    \"Top 10 Holders\" : {:.1}\n", top_10_pct));
1312    chart.push_str(&format!("    \"Other Holders\" : {:.1}\n", remaining));
1313
1314    // Add top 50 if different enough from top 10
1315    let top_50_pct = analytics.top_50_concentration.unwrap_or_else(|| {
1316        analytics
1317            .holders
1318            .iter()
1319            .take(50)
1320            .map(|h| h.percentage)
1321            .sum()
1322    });
1323
1324    if top_50_pct > top_10_pct + 5.0 {
1325        // Show breakdown: Top 10 vs 11-50 vs Rest
1326        let between_10_50 = top_50_pct - top_10_pct;
1327        let rest = (100.0 - top_50_pct).max(0.0);
1328
1329        // Regenerate with 3 segments
1330        chart.clear();
1331        chart.push_str("\n### Holder Concentration Chart\n\n");
1332        chart.push_str("```mermaid\n");
1333        chart.push_str("pie showData\n");
1334        chart.push_str("    title Token Holder Distribution\n");
1335        chart.push_str(&format!("    \"Top 10\" : {:.1}\n", top_10_pct));
1336        chart.push_str(&format!("    \"Rank 11-50\" : {:.1}\n", between_10_50));
1337        chart.push_str(&format!("    \"Others\" : {:.1}\n", rest));
1338    }
1339
1340    chart.push_str("```\n");
1341
1342    chart
1343}
1344
1345// ============================================================================
1346// Unit Tests
1347// ============================================================================
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352    use crate::chains::{DexPair, Token, TokenHolder, TokenSocial};
1353
1354    fn create_test_analytics() -> TokenAnalytics {
1355        TokenAnalytics {
1356            token: Token {
1357                contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1358                symbol: "USDC".to_string(),
1359                name: "USD Coin".to_string(),
1360                decimals: 6,
1361            },
1362            chain: "ethereum".to_string(),
1363            holders: vec![
1364                TokenHolder {
1365                    address: "0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c".to_string(),
1366                    balance: "1250000000000000".to_string(),
1367                    formatted_balance: "1.25B".to_string(),
1368                    percentage: 12.5,
1369                    rank: 1,
1370                },
1371                TokenHolder {
1372                    address: "0x8894E0a0c962CB723c1976a4421c95949bE2a912".to_string(),
1373                    balance: "820000000000000".to_string(),
1374                    formatted_balance: "820M".to_string(),
1375                    percentage: 8.2,
1376                    rank: 2,
1377                },
1378            ],
1379            total_holders: 1234567,
1380            volume_24h: 1234567890.0,
1381            volume_7d: 8641975230.0,
1382            price_usd: 1.0002,
1383            price_change_24h: 0.01,
1384            price_change_7d: -0.05,
1385            liquidity_usd: 500000000.0,
1386            market_cap: Some(32500000000.0),
1387            fdv: Some(40000000000.0),
1388            total_supply: Some("40,000,000,000".to_string()),
1389            circulating_supply: Some("32,500,000,000".to_string()),
1390            price_history: vec![],
1391            volume_history: vec![],
1392            holder_history: vec![],
1393            dex_pairs: vec![DexPair {
1394                dex_name: "Uniswap V3".to_string(),
1395                pair_address: "0x1234".to_string(),
1396                base_token: "USDC".to_string(),
1397                quote_token: "ETH".to_string(),
1398                price_usd: 1.0002,
1399                volume_24h: 500000000.0,
1400                liquidity_usd: 250000000.0,
1401                price_change_24h: 0.01,
1402                buys_24h: 1234,
1403                sells_24h: 1189,
1404                buys_6h: 234,
1405                sells_6h: 220,
1406                buys_1h: 45,
1407                sells_1h: 42,
1408                pair_created_at: Some(1700000000 - 86400 * 30), // 30 days ago
1409                url: Some("https://dexscreener.com/ethereum/0x1234".to_string()),
1410            }],
1411            fetched_at: 1700000000,
1412            top_10_concentration: Some(45.2),
1413            top_50_concentration: Some(67.8),
1414            top_100_concentration: Some(78.5),
1415            price_change_6h: 0.5,
1416            price_change_1h: -0.1,
1417            total_buys_24h: 1234,
1418            total_sells_24h: 1189,
1419            total_buys_6h: 234,
1420            total_sells_6h: 220,
1421            total_buys_1h: 45,
1422            total_sells_1h: 42,
1423            token_age_hours: Some(720.0), // 30 days
1424            image_url: Some("https://example.com/usdc.png".to_string()),
1425            websites: vec!["https://www.circle.com/usdc".to_string()],
1426            socials: vec![TokenSocial {
1427                platform: "twitter".to_string(),
1428                url: "https://twitter.com/USDC".to_string(),
1429            }],
1430            dexscreener_url: Some("https://dexscreener.com/ethereum/0x1234".to_string()),
1431        }
1432    }
1433
1434    #[test]
1435    fn test_generate_report() {
1436        let analytics = create_test_analytics();
1437        let report = generate_report(&analytics);
1438
1439        // Check header
1440        assert!(report.contains("# Token Analysis Report: USDC"));
1441        assert!(report.contains("**Chain:** Ethereum"));
1442        assert!(report.contains("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"));
1443
1444        // Check that addresses are NOT truncated
1445        assert!(report.contains("0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c"));
1446        assert!(report.contains("0x8894E0a0c962CB723c1976a4421c95949bE2a912"));
1447
1448        // Check sections exist
1449        assert!(report.contains("## Executive Summary"));
1450        assert!(report.contains("## Top Holders"));
1451        assert!(report.contains("## Concentration Analysis"));
1452        assert!(report.contains("## Data Sources"));
1453    }
1454
1455    #[test]
1456    fn test_format_usd() {
1457        assert_eq!(format_usd(1500000000.0), "$1.50B");
1458        assert_eq!(format_usd(1500000.0), "$1.50M");
1459        assert_eq!(format_usd(1500.0), "$2K"); // 1500 / 1000 = 1.5, rounded to 2K
1460        assert_eq!(format_usd(15.5), "$15.50");
1461    }
1462
1463    #[test]
1464    fn test_capitalize() {
1465        assert_eq!(capitalize("ethereum"), "Ethereum");
1466        assert_eq!(capitalize("bsc"), "Bsc");
1467        assert_eq!(capitalize(""), "");
1468    }
1469
1470    #[test]
1471    fn test_full_addresses_not_truncated() {
1472        let analytics = create_test_analytics();
1473        let section = generate_holder_section(&analytics);
1474
1475        // Verify full addresses are present
1476        assert!(section.contains("0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c"));
1477        assert!(section.contains("0x8894E0a0c962CB723c1976a4421c95949bE2a912"));
1478
1479        // Verify truncated format is NOT used
1480        assert!(!section.contains("..."));
1481    }
1482
1483    #[test]
1484    fn test_concentration_analysis() {
1485        let analytics = create_test_analytics();
1486        let section = generate_concentration_analysis(&analytics);
1487
1488        assert!(section.contains("45.1%") || section.contains("45.2%"));
1489        assert!(section.contains("Top 10 holders"));
1490    }
1491
1492    #[test]
1493    fn test_security_analysis_section() {
1494        let analytics = create_test_analytics();
1495        let section = generate_security_analysis(&analytics);
1496
1497        // Check section header
1498        assert!(section.contains("## Security Analysis"));
1499
1500        // Check for security checks table
1501        assert!(section.contains("Honeypot Risk"));
1502        assert!(section.contains("Token Age"));
1503        assert!(section.contains("Whale Risk"));
1504        assert!(section.contains("Social Presence"));
1505
1506        // Check for buy/sell data
1507        assert!(section.contains("1234"));
1508        assert!(section.contains("1189"));
1509    }
1510
1511    #[test]
1512    fn test_security_analysis_honeypot_detection() {
1513        let mut analytics = create_test_analytics();
1514
1515        // Test high honeypot risk (many buys, few sells)
1516        analytics.total_buys_24h = 1000;
1517        analytics.total_sells_24h = 10;
1518        let section = generate_security_analysis(&analytics);
1519        assert!(section.contains("HIGH") || section.contains("Suspicious"));
1520
1521        // Test normal activity
1522        analytics.total_buys_24h = 100;
1523        analytics.total_sells_24h = 95;
1524        let section = generate_security_analysis(&analytics);
1525        assert!(section.contains("LOW") || section.contains("Normal"));
1526    }
1527
1528    #[test]
1529    fn test_token_info_section() {
1530        let analytics = create_test_analytics();
1531        let section = generate_token_info_section(&analytics);
1532
1533        // Check section header
1534        assert!(section.contains("## Token Information"));
1535
1536        // Check for social links
1537        assert!(section.contains("Twitter") || section.contains("twitter"));
1538        assert!(section.contains("https://twitter.com/USDC"));
1539
1540        // Check for website
1541        assert!(section.contains("circle.com"));
1542
1543        // Check for DexScreener link
1544        assert!(section.contains("DexScreener"));
1545    }
1546
1547    #[test]
1548    fn test_risk_score_calculation() {
1549        let analytics = create_test_analytics();
1550        let factors = RiskFactors::from_analytics(&analytics);
1551
1552        // Verify factors are in valid range
1553        assert!(factors.honeypot <= 10);
1554        assert!(factors.age <= 10);
1555        assert!(factors.liquidity <= 10);
1556        assert!(factors.concentration <= 10);
1557        assert!(factors.social <= 10);
1558
1559        // Verify overall score is in valid range
1560        let overall = factors.overall_score();
1561        assert!((1..=10).contains(&overall));
1562    }
1563
1564    #[test]
1565    fn test_risk_score_section() {
1566        let analytics = create_test_analytics();
1567        let section = generate_risk_score_section(&analytics);
1568
1569        // Check section header
1570        assert!(section.contains("## Risk Score"));
1571
1572        // Check for overall risk display
1573        assert!(section.contains("Overall Risk:"));
1574        assert!(section.contains("/10"));
1575
1576        // Check for factor breakdown table
1577        assert!(section.contains("Honeypot Risk"));
1578        assert!(section.contains("Token Age"));
1579        assert!(section.contains("Liquidity"));
1580        assert!(section.contains("Concentration"));
1581        assert!(section.contains("Social Presence"));
1582
1583        // Check for Mermaid chart
1584        assert!(section.contains("```mermaid"));
1585        assert!(section.contains("pie showData"));
1586    }
1587
1588    #[test]
1589    fn test_buysell_chart() {
1590        let analytics = create_test_analytics();
1591        let chart = generate_buysell_chart(&analytics);
1592
1593        // Check for Mermaid syntax
1594        assert!(chart.contains("```mermaid"));
1595        assert!(chart.contains("pie showData"));
1596        assert!(chart.contains("Buys"));
1597        assert!(chart.contains("Sells"));
1598    }
1599
1600    #[test]
1601    fn test_txn_activity_chart() {
1602        let analytics = create_test_analytics();
1603        let chart = generate_txn_activity_chart(&analytics);
1604
1605        // Check for Mermaid syntax
1606        assert!(chart.contains("```mermaid"));
1607        assert!(chart.contains("xychart-beta"));
1608        assert!(chart.contains("1h") || chart.contains("6h") || chart.contains("24h"));
1609    }
1610
1611    #[test]
1612    fn test_price_change_chart() {
1613        let analytics = create_test_analytics();
1614        let chart = generate_price_chart(&analytics);
1615
1616        // Check for Mermaid syntax
1617        assert!(chart.contains("```mermaid"));
1618        assert!(chart.contains("Price Change"));
1619    }
1620
1621    #[test]
1622    fn test_new_report_sections_included() {
1623        let analytics = create_test_analytics();
1624        let report = generate_report(&analytics);
1625
1626        // Check new sections are included in the report
1627        assert!(report.contains("## Token Information"));
1628        assert!(report.contains("## Security Analysis"));
1629        assert!(report.contains("## Risk Score"));
1630    }
1631
1632    // ========================================================================
1633    // Edge case tests
1634    // ========================================================================
1635
1636    #[test]
1637    fn test_generate_report_no_holders() {
1638        let mut analytics = create_test_analytics();
1639        analytics.holders = vec![];
1640        analytics.total_holders = 0;
1641        analytics.top_10_concentration = None;
1642        analytics.top_50_concentration = None;
1643        analytics.top_100_concentration = None;
1644        let report = generate_report(&analytics);
1645        assert!(report.contains("No holder data available"));
1646    }
1647
1648    #[test]
1649    fn test_generate_report_no_market_cap() {
1650        let mut analytics = create_test_analytics();
1651        analytics.market_cap = None;
1652        analytics.fdv = None;
1653        analytics.total_supply = None;
1654        analytics.circulating_supply = None;
1655        let report = generate_report(&analytics);
1656        assert!(!report.contains("Market Cap | $"));
1657        assert!(!report.contains("Fully Diluted Valuation | $"));
1658    }
1659
1660    #[test]
1661    fn test_generate_report_no_dex_pairs() {
1662        let mut analytics = create_test_analytics();
1663        analytics.dex_pairs = vec![];
1664        analytics.liquidity_usd = 0.0;
1665        let report = generate_report(&analytics);
1666        // Should still generate without errors
1667        assert!(report.contains("## Liquidity Analysis"));
1668    }
1669
1670    #[test]
1671    fn test_generate_report_no_social_no_website() {
1672        let mut analytics = create_test_analytics();
1673        analytics.socials = vec![];
1674        analytics.websites = vec![];
1675        analytics.image_url = None;
1676        analytics.dexscreener_url = None;
1677        let section = generate_token_info_section(&analytics);
1678        assert!(section.contains("No additional token metadata available"));
1679    }
1680
1681    #[test]
1682    fn test_security_analysis_zero_transactions() {
1683        let mut analytics = create_test_analytics();
1684        analytics.total_buys_24h = 0;
1685        analytics.total_sells_24h = 0;
1686        analytics.total_buys_6h = 0;
1687        analytics.total_sells_6h = 0;
1688        analytics.total_buys_1h = 0;
1689        analytics.total_sells_1h = 0;
1690        let section = generate_security_analysis(&analytics);
1691        assert!(section.contains("UNKNOWN") || section.contains("No transaction data"));
1692    }
1693
1694    #[test]
1695    fn test_security_analysis_only_buys() {
1696        let mut analytics = create_test_analytics();
1697        analytics.total_buys_24h = 100;
1698        analytics.total_sells_24h = 0;
1699        let section = generate_security_analysis(&analytics);
1700        assert!(section.contains("Possible honeypot") || section.contains("HIGH"));
1701    }
1702
1703    #[test]
1704    fn test_security_analysis_token_age_very_new() {
1705        let mut analytics = create_test_analytics();
1706        analytics.token_age_hours = Some(6.0);
1707        let section = generate_security_analysis(&analytics);
1708        assert!(section.contains("Very new token") || section.contains("HIGH RISK"));
1709    }
1710
1711    #[test]
1712    fn test_security_analysis_token_age_unknown() {
1713        let mut analytics = create_test_analytics();
1714        analytics.token_age_hours = None;
1715        let section = generate_security_analysis(&analytics);
1716        assert!(section.contains("not available") || section.contains("UNKNOWN"));
1717    }
1718
1719    #[test]
1720    fn test_security_analysis_whale_risk_extreme() {
1721        let mut analytics = create_test_analytics();
1722        analytics.holders = vec![TokenHolder {
1723            address: "0xwhale".to_string(),
1724            balance: "9000000".to_string(),
1725            formatted_balance: "9M".to_string(),
1726            percentage: 60.0,
1727            rank: 1,
1728        }];
1729        let section = generate_security_analysis(&analytics);
1730        assert!(section.contains("HIGH") || section.contains("Extreme concentration"));
1731    }
1732
1733    #[test]
1734    fn test_security_analysis_no_holders() {
1735        let mut analytics = create_test_analytics();
1736        analytics.holders = vec![];
1737        let section = generate_security_analysis(&analytics);
1738        assert!(section.contains("Whale Risk"));
1739        assert!(section.contains("UNKNOWN") || section.contains("not available"));
1740    }
1741
1742    #[test]
1743    fn test_risk_factors_high_risk_token() {
1744        let mut analytics = create_test_analytics();
1745        analytics.total_buys_24h = 1000;
1746        analytics.total_sells_24h = 0; // Honeypot risk = 10
1747        analytics.token_age_hours = Some(12.0); // Very new = 10
1748        analytics.liquidity_usd = 5_000.0; // Very low = 10
1749        analytics.holders = vec![TokenHolder {
1750            address: "0x1".to_string(),
1751            balance: "1000".to_string(),
1752            formatted_balance: "1K".to_string(),
1753            percentage: 80.0, // Very concentrated = 10
1754            rank: 1,
1755        }];
1756        analytics.socials = vec![]; // No socials = 8
1757        analytics.websites = vec![];
1758
1759        let factors = RiskFactors::from_analytics(&analytics);
1760        assert_eq!(factors.honeypot, 10);
1761        assert_eq!(factors.age, 10);
1762        assert_eq!(factors.liquidity, 10);
1763        assert_eq!(factors.concentration, 10);
1764        assert_eq!(factors.social, 8);
1765        assert!(factors.overall_score() >= 8);
1766        assert!(factors.risk_level() == "HIGH" || factors.risk_level() == "CRITICAL");
1767        assert!(factors.risk_emoji() == "🟠" || factors.risk_emoji() == "🔴");
1768    }
1769
1770    #[test]
1771    fn test_risk_factors_low_risk_token() {
1772        let mut analytics = create_test_analytics();
1773        analytics.total_buys_24h = 100;
1774        analytics.total_sells_24h = 95; // Normal ratio
1775        analytics.token_age_hours = Some(10_000.0); // Very established
1776        analytics.liquidity_usd = 50_000_000.0; // Very high
1777        analytics.holders = vec![TokenHolder {
1778            address: "0x1".to_string(),
1779            balance: "1000".to_string(),
1780            formatted_balance: "1K".to_string(),
1781            percentage: 3.0, // Well distributed
1782            rank: 1,
1783        }];
1784        analytics.socials = vec![
1785            TokenSocial {
1786                platform: "twitter".to_string(),
1787                url: "https://twitter.com/test".to_string(),
1788            },
1789            TokenSocial {
1790                platform: "telegram".to_string(),
1791                url: "https://t.me/test".to_string(),
1792            },
1793        ];
1794        analytics.websites = vec!["https://example.com".to_string()];
1795
1796        let factors = RiskFactors::from_analytics(&analytics);
1797        assert!(factors.overall_score() <= 3);
1798        assert_eq!(factors.risk_level(), "LOW");
1799        assert_eq!(factors.risk_emoji(), "🟢");
1800    }
1801
1802    #[test]
1803    fn test_risk_assessment_labels() {
1804        assert_eq!(risk_assessment(0), "Low Risk");
1805        assert_eq!(risk_assessment(1), "Low Risk");
1806        assert_eq!(risk_assessment(3), "Moderate");
1807        assert_eq!(risk_assessment(5), "Elevated");
1808        assert_eq!(risk_assessment(7), "High Risk");
1809        assert_eq!(risk_assessment(9), "Critical");
1810        assert_eq!(risk_assessment(10), "Critical");
1811    }
1812
1813    #[test]
1814    fn test_format_usd_edge_cases() {
1815        assert_eq!(format_usd(0.0), "$0.00");
1816        assert_eq!(format_usd(0.50), "$0.50");
1817        assert_eq!(format_usd(999.0), "$999.00");
1818    }
1819
1820    #[test]
1821    fn test_format_number_edge_cases() {
1822        assert_eq!(format_number(0.0), "0");
1823        assert_eq!(format_number(500.0), "500");
1824        assert_eq!(format_number(1500.0), "2K");
1825        assert_eq!(format_number(1_500_000.0), "1.50M");
1826    }
1827
1828    #[test]
1829    fn test_capitalize_edge_cases() {
1830        assert_eq!(capitalize("a"), "A");
1831        assert_eq!(capitalize("ABC"), "ABC");
1832    }
1833
1834    #[test]
1835    fn test_data_sources_different_chains() {
1836        let chains = vec![
1837            ("ethereum", "etherscan.io"),
1838            ("polygon", "polygonscan.com"),
1839            ("arbitrum", "arbiscan.io"),
1840            ("optimism", "optimistic.etherscan.io"),
1841            ("base", "basescan.org"),
1842            ("bsc", "bscscan.com"),
1843            ("solana", "solscan.io"),
1844        ];
1845
1846        for (chain, expected_domain) in chains {
1847            let mut analytics = create_test_analytics();
1848            analytics.chain = chain.to_string();
1849            let section = generate_data_sources(&analytics);
1850            assert!(
1851                section.contains(expected_domain),
1852                "Chain {} should link to {}",
1853                chain,
1854                expected_domain
1855            );
1856        }
1857    }
1858
1859    #[test]
1860    fn test_buysell_chart_empty() {
1861        let mut analytics = create_test_analytics();
1862        analytics.total_buys_24h = 0;
1863        analytics.total_sells_24h = 0;
1864        let chart = generate_buysell_chart(&analytics);
1865        assert!(chart.is_empty());
1866    }
1867
1868    #[test]
1869    fn test_txn_activity_chart_empty() {
1870        let mut analytics = create_test_analytics();
1871        analytics.total_buys_24h = 0;
1872        analytics.total_sells_24h = 0;
1873        analytics.total_buys_6h = 0;
1874        analytics.total_sells_6h = 0;
1875        analytics.total_buys_1h = 0;
1876        analytics.total_sells_1h = 0;
1877        let chart = generate_txn_activity_chart(&analytics);
1878        assert!(chart.is_empty());
1879    }
1880
1881    #[test]
1882    fn test_volume_chart_empty() {
1883        let analytics = create_test_analytics();
1884        // analytics has empty volume_history by default
1885        let chart = generate_volume_chart(&analytics);
1886        assert!(chart.is_empty());
1887    }
1888
1889    #[test]
1890    fn test_liquidity_chart_single_pair() {
1891        let analytics = create_test_analytics();
1892        // Only 1 DEX pair → no chart generated
1893        assert_eq!(analytics.dex_pairs.len(), 1);
1894        let chart = generate_liquidity_chart(&analytics);
1895        assert!(chart.is_empty());
1896    }
1897
1898    #[test]
1899    fn test_liquidity_chart_multiple_pairs() {
1900        let mut analytics = create_test_analytics();
1901        analytics.dex_pairs.push(DexPair {
1902            dex_name: "SushiSwap".to_string(),
1903            pair_address: "0x5678".to_string(),
1904            base_token: "USDC".to_string(),
1905            quote_token: "DAI".to_string(),
1906            price_usd: 1.0,
1907            volume_24h: 100_000.0,
1908            liquidity_usd: 5_000_000.0,
1909            price_change_24h: 0.0,
1910            buys_24h: 50,
1911            sells_24h: 50,
1912            buys_6h: 10,
1913            sells_6h: 10,
1914            buys_1h: 2,
1915            sells_1h: 2,
1916            pair_created_at: None,
1917            url: None,
1918        });
1919        let chart = generate_liquidity_chart(&analytics);
1920        assert!(chart.contains("mermaid"));
1921        assert!(chart.contains("Uniswap V3"));
1922        assert!(chart.contains("SushiSwap"));
1923    }
1924
1925    #[test]
1926    fn test_concentration_chart_no_holders() {
1927        let mut analytics = create_test_analytics();
1928        analytics.holders = vec![];
1929        analytics.top_10_concentration = Some(0.0);
1930        let chart = generate_concentration_chart(&analytics);
1931        assert!(chart.is_empty());
1932    }
1933
1934    #[test]
1935    fn test_concentration_analysis_ranges() {
1936        // Very high concentration
1937        let mut analytics = create_test_analytics();
1938        analytics.top_10_concentration = Some(85.0);
1939        let section = generate_concentration_analysis(&analytics);
1940        assert!(section.contains("Very High Concentration"));
1941
1942        // High concentration
1943        analytics.top_10_concentration = Some(55.0);
1944        let section = generate_concentration_analysis(&analytics);
1945        assert!(section.contains("High Concentration"));
1946
1947        // Low concentration
1948        analytics.top_10_concentration = Some(15.0);
1949        let section = generate_concentration_analysis(&analytics);
1950        assert!(section.contains("Low Concentration"));
1951    }
1952
1953    #[test]
1954    fn test_risk_indicators_low_liquidity() {
1955        let mut analytics = create_test_analytics();
1956        analytics.liquidity_usd = 5_000.0;
1957        let section = generate_risk_indicators(&analytics);
1958        assert!(section.contains("Very low liquidity"));
1959    }
1960
1961    #[test]
1962    fn test_risk_indicators_high_volatility() {
1963        let mut analytics = create_test_analytics();
1964        analytics.price_change_24h = 25.0;
1965        let section = generate_risk_indicators(&analytics);
1966        assert!(section.contains("High price volatility"));
1967    }
1968
1969    #[test]
1970    fn test_risk_indicators_healthy_token() {
1971        let mut analytics = create_test_analytics();
1972        analytics.holders = vec![TokenHolder {
1973            address: "0x1".to_string(),
1974            balance: "100".to_string(),
1975            formatted_balance: "100".to_string(),
1976            percentage: 5.0,
1977            rank: 1,
1978        }];
1979        analytics.liquidity_usd = 10_000_000.0;
1980        analytics.volume_24h = 500_000.0;
1981        analytics.price_change_24h = 2.0;
1982        let section = generate_risk_indicators(&analytics);
1983        assert!(section.contains("Reasonable distribution"));
1984        assert!(section.contains("Good liquidity"));
1985        assert!(section.contains("Active trading"));
1986    }
1987
1988    #[test]
1989    fn test_risk_indicators_empty() {
1990        let mut analytics = create_test_analytics();
1991        analytics.holders = vec![];
1992        analytics.liquidity_usd = 500_000.0;
1993        analytics.volume_24h = 50_000.0;
1994        analytics.price_change_24h = 5.0;
1995        let section = generate_risk_indicators(&analytics);
1996        // With no holders, calculation uses empty iter → 0%, which is "reasonable"
1997        assert!(section.contains("Reasonable distribution"));
1998    }
1999
2000    #[test]
2001    fn test_save_report() {
2002        let tmp = std::env::temp_dir().join("bcc_test_report.md");
2003        let result = save_report("# Test Report\n\nContent here.", &tmp);
2004        assert!(result.is_ok());
2005        let content = std::fs::read_to_string(&tmp).unwrap();
2006        assert!(content.contains("# Test Report"));
2007        let _ = std::fs::remove_file(&tmp);
2008    }
2009
2010    #[test]
2011    fn test_save_report_invalid_path() {
2012        let result = save_report("content", "/nonexistent/directory/report.md");
2013        assert!(result.is_err());
2014    }
2015
2016    #[test]
2017    fn test_volume_analysis_high_vol_to_liq() {
2018        let mut analytics = create_test_analytics();
2019        analytics.volume_24h = 100_000_000.0;
2020        analytics.liquidity_usd = 10_000_000.0; // ratio = 10
2021        let section = generate_volume_analysis(&analytics);
2022        assert!(section.contains("unusual trading activity"));
2023    }
2024
2025    #[test]
2026    fn test_price_analysis_with_history() {
2027        use crate::chains::PricePoint;
2028        let mut analytics = create_test_analytics();
2029        analytics.price_history = vec![
2030            PricePoint {
2031                timestamp: 1700000000,
2032                price: 1.0,
2033            },
2034            PricePoint {
2035                timestamp: 1700003600,
2036                price: 1.5,
2037            },
2038            PricePoint {
2039                timestamp: 1700007200,
2040                price: 0.8,
2041            },
2042        ];
2043        let section = generate_price_analysis(&analytics);
2044        assert!(section.contains("Price Range"));
2045        assert!(section.contains("High"));
2046        assert!(section.contains("Low"));
2047        assert!(section.contains("Average"));
2048    }
2049
2050    #[test]
2051    fn test_social_platform_icons() {
2052        let mut analytics = create_test_analytics();
2053        analytics.socials = vec![
2054            TokenSocial {
2055                platform: "twitter".to_string(),
2056                url: "https://twitter.com/test".to_string(),
2057            },
2058            TokenSocial {
2059                platform: "telegram".to_string(),
2060                url: "https://t.me/test".to_string(),
2061            },
2062            TokenSocial {
2063                platform: "discord".to_string(),
2064                url: "https://discord.gg/test".to_string(),
2065            },
2066            TokenSocial {
2067                platform: "github".to_string(),
2068                url: "https://github.com/test".to_string(),
2069            },
2070            TokenSocial {
2071                platform: "unknown".to_string(),
2072                url: "https://example.com".to_string(),
2073            },
2074        ];
2075        let section = generate_token_info_section(&analytics);
2076        assert!(section.contains("🐦")); // twitter
2077        assert!(section.contains("📱")); // telegram
2078        assert!(section.contains("💬")); // discord
2079        assert!(section.contains("💻")); // github
2080        assert!(section.contains("🔗")); // unknown
2081    }
2082
2083    #[test]
2084    fn test_security_analysis_token_age_ranges() {
2085        let mut analytics = create_test_analytics();
2086
2087        // Very new (< 24h)
2088        analytics.token_age_hours = Some(6.0);
2089        let section = generate_security_analysis(&analytics);
2090        assert!(section.contains("HIGH RISK"));
2091
2092        // New (24-48h)
2093        analytics.token_age_hours = Some(36.0);
2094        let section = generate_security_analysis(&analytics);
2095        assert!(section.contains("MEDIUM"));
2096
2097        // Relatively new (< 7d)
2098        analytics.token_age_hours = Some(120.0);
2099        let section = generate_security_analysis(&analytics);
2100        assert!(section.contains("CAUTION"));
2101
2102        // Established (> 1 year)
2103        analytics.token_age_hours = Some(10_000.0);
2104        let section = generate_security_analysis(&analytics);
2105        assert!(section.contains("ESTABLISHED"));
2106    }
2107
2108    #[test]
2109    fn test_price_history_chart_with_data() {
2110        use crate::chains::PricePoint;
2111        let mut analytics = create_test_analytics();
2112        analytics.price_history = (0..20)
2113            .map(|i| PricePoint {
2114                timestamp: 1700000000 + i * 3600,
2115                price: 1.0 + (i as f64) * 0.01,
2116            })
2117            .collect();
2118        let chart = generate_price_history_chart(&analytics);
2119        assert!(chart.contains("Price History"));
2120        assert!(chart.contains("mermaid"));
2121        assert!(chart.contains("xychart-beta"));
2122        assert!(chart.contains("line ["));
2123    }
2124
2125    #[test]
2126    fn test_price_chart_with_changes_and_history() {
2127        use crate::chains::PricePoint;
2128        let mut analytics = create_test_analytics();
2129        analytics.price_change_1h = 1.5;
2130        analytics.price_change_6h = -2.3;
2131        analytics.price_change_24h = 5.0;
2132        analytics.price_change_7d = -10.0;
2133        analytics.price_history = (0..5)
2134            .map(|i| PricePoint {
2135                timestamp: 1700000000 + i * 3600,
2136                price: 1.0 + (i as f64) * 0.1,
2137            })
2138            .collect();
2139        let chart = generate_price_chart(&analytics);
2140        assert!(chart.contains("Price Changes by Period"));
2141        assert!(chart.contains("Price History")); // Also includes history chart
2142    }
2143
2144    #[test]
2145    fn test_price_chart_zero_changes_with_history() {
2146        use crate::chains::PricePoint;
2147        let mut analytics = create_test_analytics();
2148        analytics.price_change_1h = 0.0;
2149        analytics.price_change_6h = 0.0;
2150        analytics.price_change_24h = 0.0;
2151        analytics.price_change_7d = 0.0;
2152        analytics.price_history = vec![
2153            PricePoint {
2154                timestamp: 1700000000,
2155                price: 1.0,
2156            },
2157            PricePoint {
2158                timestamp: 1700003600,
2159                price: 1.5,
2160            },
2161        ];
2162        let chart = generate_price_chart(&analytics);
2163        assert!(chart.contains("Price History")); // Falls back to history chart
2164    }
2165
2166    #[test]
2167    fn test_volume_chart_with_data() {
2168        use crate::chains::VolumePoint;
2169        let mut analytics = create_test_analytics();
2170        analytics.volume_history = (0..10)
2171            .map(|i| VolumePoint {
2172                timestamp: 1700000000 + i * 3600,
2173                volume: 100_000.0 + (i as f64) * 50_000.0,
2174            })
2175            .collect();
2176        let chart = generate_volume_chart(&analytics);
2177        assert!(chart.contains("Volume Chart"));
2178        assert!(chart.contains("mermaid"));
2179        assert!(chart.contains("bar ["));
2180    }
2181
2182    #[test]
2183    fn test_concentration_chart_three_segments() {
2184        let mut analytics = create_test_analytics();
2185        // Set top_10 = 30%, top_50 = 60% (difference > 5%), triggers 3-segment chart
2186        analytics.top_10_concentration = Some(30.0);
2187        analytics.top_50_concentration = Some(60.0);
2188        let chart = generate_concentration_chart(&analytics);
2189        assert!(chart.contains("Top 10"));
2190        assert!(chart.contains("Rank 11-50"));
2191        assert!(chart.contains("Others"));
2192    }
2193
2194    #[test]
2195    fn test_risk_indicators_very_low_liquidity() {
2196        let mut analytics = create_test_analytics();
2197        analytics.liquidity_usd = 5_000.0;
2198        analytics.volume_24h = 500.0;
2199        let section = generate_risk_indicators(&analytics);
2200        assert!(section.contains("Very low liquidity"));
2201        assert!(section.contains("Very low trading volume"));
2202    }
2203
2204    #[test]
2205    fn test_risk_indicators_moderate_liquidity() {
2206        let mut analytics = create_test_analytics();
2207        analytics.liquidity_usd = 50_000.0;
2208        let section = generate_risk_indicators(&analytics);
2209        assert!(section.contains("Low liquidity"));
2210    }
2211
2212    #[test]
2213    fn test_risk_indicators_extreme_concentration() {
2214        let mut analytics = create_test_analytics();
2215        analytics.holders = vec![TokenHolder {
2216            address: "0xwhale".to_string(),
2217            balance: "900000000".to_string(),
2218            formatted_balance: "900M".to_string(),
2219            percentage: 90.0,
2220            rank: 1,
2221        }];
2222        let section = generate_risk_indicators(&analytics);
2223        assert!(section.contains("Extreme whale concentration"));
2224    }
2225
2226    #[test]
2227    fn test_risk_indicators_no_data() {
2228        let mut analytics = create_test_analytics();
2229        analytics.holders = vec![];
2230        analytics.liquidity_usd = 500_000.0; // between 100k and 1M, no risk or positive
2231        analytics.volume_24h = 50_000.0; // between 1k and 100k, no risk or positive
2232        analytics.price_change_24h = 5.0; // less than 20%, no risk
2233        let section = generate_risk_indicators(&analytics);
2234        // Should have insufficient data or at least no risk factors
2235        assert!(section.contains("Risk Indicators"));
2236    }
2237
2238    #[test]
2239    fn test_holder_section_with_data() {
2240        let analytics = create_test_analytics();
2241        let section = generate_holder_section(&analytics);
2242        assert!(section.contains("Top Holders"));
2243        assert!(section.contains("0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c")); // Full address
2244        assert!(section.contains("12.50%"));
2245    }
2246
2247    #[test]
2248    fn test_risk_breakdown_chart() {
2249        let analytics = create_test_analytics();
2250        let section = generate_risk_score_section(&analytics);
2251        assert!(section.contains("Risk Score"));
2252        assert!(section.contains("Risk Factor Breakdown"));
2253        assert!(section.contains("Honeypot"));
2254        assert!(section.contains("Token Age"));
2255    }
2256
2257    #[test]
2258    fn test_data_sources_section() {
2259        let analytics = create_test_analytics();
2260        let section = generate_data_sources(&analytics);
2261        assert!(section.contains("Data Sources"));
2262        assert!(section.contains("ethereum"));
2263    }
2264
2265    #[test]
2266    fn test_volume_analysis_section() {
2267        let analytics = create_test_analytics();
2268        let section = generate_volume_analysis(&analytics);
2269        assert!(section.contains("Volume Analysis"));
2270    }
2271
2272    #[test]
2273    fn test_liquidity_analysis_section() {
2274        let analytics = create_test_analytics();
2275        let section = generate_liquidity_analysis(&analytics);
2276        assert!(section.contains("Liquidity Analysis"));
2277    }
2278
2279    #[test]
2280    fn test_security_analysis_medium_buy_sell_ratio() {
2281        let mut analytics = create_test_analytics();
2282        // ratio = 5.0 → MEDIUM risk
2283        analytics.total_buys_24h = 100;
2284        analytics.total_sells_24h = 20;
2285        let section = generate_security_analysis(&analytics);
2286        assert!(section.contains("MEDIUM") || section.contains("Elevated"));
2287    }
2288
2289    #[test]
2290    fn test_security_analysis_token_age_months() {
2291        let mut analytics = create_test_analytics();
2292        // 2000 hours ≈ 83 days ≈ 2.8 months (> 30 days, < 365 days)
2293        analytics.token_age_hours = Some(2000.0);
2294        let section = generate_security_analysis(&analytics);
2295        assert!(section.contains("ESTABLISHED") || section.contains("months"));
2296    }
2297
2298    #[test]
2299    fn test_security_analysis_whale_risk_medium() {
2300        let mut analytics = create_test_analytics();
2301        analytics.holders = vec![TokenHolder {
2302            address: "0xwhale".to_string(),
2303            balance: "3000000".to_string(),
2304            formatted_balance: "3M".to_string(),
2305            percentage: 30.0, // > 25%, <= 50%
2306            rank: 1,
2307        }];
2308        let section = generate_security_analysis(&analytics);
2309        assert!(section.contains("MEDIUM") || section.contains("High concentration"));
2310    }
2311
2312    #[test]
2313    fn test_security_analysis_whale_risk_low() {
2314        let mut analytics = create_test_analytics();
2315        analytics.holders = vec![TokenHolder {
2316            address: "0xholder".to_string(),
2317            balance: "500000".to_string(),
2318            formatted_balance: "500K".to_string(),
2319            percentage: 5.0, // > 0%, <= 10%
2320            rank: 1,
2321        }];
2322        let section = generate_security_analysis(&analytics);
2323        assert!(section.contains("LOW") || section.contains("Well distributed"));
2324    }
2325
2326    #[test]
2327    fn test_security_analysis_token_age_days_format() {
2328        let mut analytics = create_test_analytics();
2329        // 480 hours = 20 days (< 30 days, uses "days ago" format)
2330        analytics.token_age_hours = Some(480.0);
2331        let section = generate_security_analysis(&analytics);
2332        assert!(section.contains("days ago") || section.contains("MODERATE"));
2333    }
2334
2335    #[test]
2336    fn test_security_buysell_zero_buys_zero_sells_in_period() {
2337        let mut analytics = create_test_analytics();
2338        // 24h has data, but 1h and 6h have zero
2339        analytics.total_buys_1h = 0;
2340        analytics.total_sells_1h = 0;
2341        analytics.total_buys_6h = 0;
2342        analytics.total_sells_6h = 0;
2343        analytics.total_buys_24h = 100;
2344        analytics.total_sells_24h = 80;
2345        let section = generate_security_analysis(&analytics);
2346        assert!(section.contains("-") || section.contains("100")); // "-" for 0/0 ratio
2347    }
2348
2349    #[test]
2350    fn test_risk_factors_various_honeypot_ratios() {
2351        let mut analytics = create_test_analytics();
2352
2353        // ratio > 10 → honeypot = 9
2354        analytics.total_buys_24h = 110;
2355        analytics.total_sells_24h = 10;
2356        let factors = RiskFactors::from_analytics(&analytics);
2357        assert_eq!(factors.honeypot, 9);
2358
2359        // ratio > 5, <= 10 → honeypot = 7
2360        analytics.total_buys_24h = 60;
2361        analytics.total_sells_24h = 10;
2362        let factors = RiskFactors::from_analytics(&analytics);
2363        assert_eq!(factors.honeypot, 7);
2364
2365        // ratio > 3, <= 5 → honeypot = 5
2366        analytics.total_buys_24h = 40;
2367        analytics.total_sells_24h = 10;
2368        let factors = RiskFactors::from_analytics(&analytics);
2369        assert_eq!(factors.honeypot, 5);
2370
2371        // ratio > 2, <= 3 → honeypot = 3
2372        analytics.total_buys_24h = 25;
2373        analytics.total_sells_24h = 10;
2374        let factors = RiskFactors::from_analytics(&analytics);
2375        assert_eq!(factors.honeypot, 3);
2376
2377        // ratio <= 2 → honeypot = 1
2378        analytics.total_buys_24h = 15;
2379        analytics.total_sells_24h = 10;
2380        let factors = RiskFactors::from_analytics(&analytics);
2381        assert_eq!(factors.honeypot, 1);
2382    }
2383
2384    #[test]
2385    fn test_risk_factors_various_age_thresholds() {
2386        let mut analytics = create_test_analytics();
2387
2388        // < 48h → 8
2389        analytics.token_age_hours = Some(36.0);
2390        let factors = RiskFactors::from_analytics(&analytics);
2391        assert_eq!(factors.age, 8);
2392
2393        // < 168h (7d) → 6
2394        analytics.token_age_hours = Some(120.0);
2395        let factors = RiskFactors::from_analytics(&analytics);
2396        assert_eq!(factors.age, 6);
2397
2398        // < 720h (30d) → 4
2399        analytics.token_age_hours = Some(500.0);
2400        let factors = RiskFactors::from_analytics(&analytics);
2401        assert_eq!(factors.age, 4);
2402
2403        // < 2160h (90d) → 2
2404        analytics.token_age_hours = Some(1500.0);
2405        let factors = RiskFactors::from_analytics(&analytics);
2406        assert_eq!(factors.age, 2);
2407    }
2408
2409    #[test]
2410    fn test_risk_factors_various_liquidity_thresholds() {
2411        let mut analytics = create_test_analytics();
2412
2413        // 50K-100K → 6
2414        analytics.liquidity_usd = 75_000.0;
2415        let factors = RiskFactors::from_analytics(&analytics);
2416        assert_eq!(factors.liquidity, 6);
2417
2418        // 100K-500K → 4
2419        analytics.liquidity_usd = 200_000.0;
2420        let factors = RiskFactors::from_analytics(&analytics);
2421        assert_eq!(factors.liquidity, 4);
2422
2423        // 500K-1M → 2
2424        analytics.liquidity_usd = 750_000.0;
2425        let factors = RiskFactors::from_analytics(&analytics);
2426        assert_eq!(factors.liquidity, 2);
2427
2428        // > 1M → 1
2429        analytics.liquidity_usd = 2_000_000.0;
2430        let factors = RiskFactors::from_analytics(&analytics);
2431        assert_eq!(factors.liquidity, 1);
2432    }
2433
2434    #[test]
2435    fn test_risk_factors_various_concentration_thresholds() {
2436        let mut analytics = create_test_analytics();
2437
2438        // 30-50% → 8
2439        analytics.holders = vec![TokenHolder {
2440            address: "0x1".to_string(),
2441            balance: "1".to_string(),
2442            formatted_balance: "1".to_string(),
2443            percentage: 35.0,
2444            rank: 1,
2445        }];
2446        let factors = RiskFactors::from_analytics(&analytics);
2447        assert_eq!(factors.concentration, 8);
2448
2449        // 20-30% → 6
2450        analytics.holders[0].percentage = 25.0;
2451        let factors = RiskFactors::from_analytics(&analytics);
2452        assert_eq!(factors.concentration, 6);
2453
2454        // 10-20% → 4
2455        analytics.holders[0].percentage = 15.0;
2456        let factors = RiskFactors::from_analytics(&analytics);
2457        assert_eq!(factors.concentration, 4);
2458
2459        // 5-10% → 2
2460        analytics.holders[0].percentage = 7.0;
2461        let factors = RiskFactors::from_analytics(&analytics);
2462        assert_eq!(factors.concentration, 2);
2463
2464        // < 5% → 1
2465        analytics.holders[0].percentage = 3.0;
2466        let factors = RiskFactors::from_analytics(&analytics);
2467        assert_eq!(factors.concentration, 1);
2468    }
2469
2470    #[test]
2471    fn test_risk_factors_social_one_social() {
2472        let mut analytics = create_test_analytics();
2473        analytics.socials = vec![TokenSocial {
2474            platform: "twitter".to_string(),
2475            url: "https://twitter.com/test".to_string(),
2476        }];
2477        analytics.websites = vec![];
2478        let factors = RiskFactors::from_analytics(&analytics);
2479        // 1 social = moderate social presence
2480        assert!(factors.social <= 5);
2481    }
2482
2483    #[test]
2484    fn test_format_number_large_values() {
2485        assert_eq!(format_number(1_500_000_000.0), "1500.00M");
2486        assert_eq!(format_number(500_000.0), "500K");
2487        assert_eq!(format_number(42.0), "42");
2488    }
2489}