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(&report_footer());
1060
1061    section
1062}
1063
1064/// Generates a standard report footer with version and timestamp.
1065/// Used across all report types for audit trail and versioning.
1066pub fn report_footer() -> String {
1067    format!(
1068        "\n---\n\n*Report generated by Scope v{} at {} UTC. Always verify data from primary sources.*",
1069        crate::VERSION,
1070        Utc::now().format("%Y-%m-%d %H:%M:%S")
1071    )
1072}
1073
1074/// Saves a report to a file.
1075///
1076/// # Arguments
1077///
1078/// * `report` - The markdown report content
1079/// * `path` - The file path to save to
1080///
1081/// # Returns
1082///
1083/// Returns `Ok(())` on success, or an error if the file cannot be written.
1084pub fn save_report(report: &str, path: impl AsRef<Path>) -> Result<()> {
1085    std::fs::write(path.as_ref(), report).map_err(|e| {
1086        crate::error::ScopeError::Io(format!(
1087            "Failed to write report to {}: {}",
1088            path.as_ref().display(),
1089            e
1090        ))
1091    })
1092}
1093
1094/// Formats a USD value with appropriate suffixes.
1095fn format_usd(value: f64) -> String {
1096    if value >= 1_000_000_000.0 {
1097        format!("${:.2}B", value / 1_000_000_000.0)
1098    } else if value >= 1_000_000.0 {
1099        format!("${:.2}M", value / 1_000_000.0)
1100    } else if value >= 1_000.0 {
1101        format!("${:.0}K", value / 1_000.0)
1102    } else {
1103        format!("${:.2}", value)
1104    }
1105}
1106
1107/// Formats a number with commas.
1108fn format_number(value: f64) -> String {
1109    if value >= 1_000_000.0 {
1110        format!("{:.2}M", value / 1_000_000.0)
1111    } else if value >= 1_000.0 {
1112        format!("{:.0}K", value / 1_000.0)
1113    } else {
1114        format!("{:.0}", value)
1115    }
1116}
1117
1118/// Capitalizes the first letter of a string.
1119fn capitalize(s: &str) -> String {
1120    let mut chars = s.chars();
1121    match chars.next() {
1122        None => String::new(),
1123        Some(first) => first.to_uppercase().chain(chars).collect(),
1124    }
1125}
1126
1127// ============================================================================
1128// Mermaid Chart Generation
1129// ============================================================================
1130
1131/// Generates a Mermaid line chart for price history.
1132fn generate_price_chart(analytics: &TokenAnalytics) -> String {
1133    // Generate price change comparison chart for multiple timeframes
1134    let mut chart = String::new();
1135
1136    // Only generate if we have meaningful data
1137    if analytics.price_change_1h == 0.0
1138        && analytics.price_change_6h == 0.0
1139        && analytics.price_change_24h == 0.0
1140        && analytics.price_change_7d == 0.0
1141    {
1142        // Fall back to price history chart if no change data
1143        if analytics.price_history.len() >= 2 {
1144            return generate_price_history_chart(analytics);
1145        }
1146        return String::new();
1147    }
1148
1149    chart.push_str("\n### Price Changes by Period\n\n");
1150    chart.push_str("```mermaid\n");
1151    chart.push_str("%%{init: {'theme': 'base'}}%%\n");
1152    chart.push_str("xychart-beta\n");
1153    chart.push_str("    title \"Price Change Comparison (%)\"\n");
1154    chart.push_str("    x-axis [\"1h\", \"6h\", \"24h\", \"7d\"]\n");
1155    chart.push_str("    y-axis \"Change %\"\n");
1156    chart.push_str(&format!(
1157        "    bar [{:.2}, {:.2}, {:.2}, {:.2}]\n",
1158        analytics.price_change_1h,
1159        analytics.price_change_6h,
1160        analytics.price_change_24h,
1161        analytics.price_change_7d
1162    ));
1163    chart.push_str("```\n");
1164
1165    // Also add the price history chart if available
1166    if analytics.price_history.len() >= 2 {
1167        chart.push_str(&generate_price_history_chart(analytics));
1168    }
1169
1170    chart
1171}
1172
1173/// Generates a price history line chart from historical data points.
1174fn generate_price_history_chart(analytics: &TokenAnalytics) -> String {
1175    if analytics.price_history.len() < 2 {
1176        return String::new();
1177    }
1178
1179    let mut chart = String::new();
1180    chart.push_str("\n### Price History\n\n");
1181    chart.push_str("```mermaid\n");
1182    chart.push_str("xychart-beta\n");
1183    chart.push_str("    title \"Price Over Time\"\n");
1184    chart.push_str("    x-axis [");
1185
1186    // Sample up to 12 data points for readability
1187    let step = (analytics.price_history.len() / 12).max(1);
1188    let sampled: Vec<_> = analytics
1189        .price_history
1190        .iter()
1191        .step_by(step)
1192        .take(12)
1193        .collect();
1194
1195    // Generate x-axis labels (time offsets)
1196    let labels: Vec<String> = sampled
1197        .iter()
1198        .enumerate()
1199        .map(|(i, _)| format!("\"{}\"", i + 1))
1200        .collect();
1201    chart.push_str(&labels.join(", "));
1202    chart.push_str("]\n");
1203
1204    // Generate y-axis with price data
1205    let prices: Vec<String> = sampled.iter().map(|p| format!("{:.6}", p.price)).collect();
1206    chart.push_str("    y-axis \"Price (USD)\"\n");
1207    chart.push_str("    line [");
1208    chart.push_str(&prices.join(", "));
1209    chart.push_str("]\n");
1210    chart.push_str("```\n");
1211
1212    chart
1213}
1214
1215/// Generates a Mermaid bar chart for volume history.
1216fn generate_volume_chart(analytics: &TokenAnalytics) -> String {
1217    if analytics.volume_history.len() < 2 {
1218        return String::new();
1219    }
1220
1221    let mut chart = String::new();
1222    chart.push_str("\n### Volume Chart\n\n");
1223    chart.push_str("```mermaid\n");
1224    chart.push_str("xychart-beta\n");
1225    chart.push_str("    title \"Trading Volume Over Time\"\n");
1226    chart.push_str("    x-axis [");
1227
1228    // Sample up to 12 data points for readability
1229    let step = (analytics.volume_history.len() / 12).max(1);
1230    let sampled: Vec<_> = analytics
1231        .volume_history
1232        .iter()
1233        .step_by(step)
1234        .take(12)
1235        .collect();
1236
1237    // Generate x-axis labels
1238    let labels: Vec<String> = sampled
1239        .iter()
1240        .enumerate()
1241        .map(|(i, _)| format!("\"{}\"", i + 1))
1242        .collect();
1243    chart.push_str(&labels.join(", "));
1244    chart.push_str("]\n");
1245
1246    // Generate y-axis with volume data
1247    let volumes: Vec<String> = sampled.iter().map(|v| format!("{:.0}", v.volume)).collect();
1248    chart.push_str("    y-axis \"Volume (USD)\"\n");
1249    chart.push_str("    bar [");
1250    chart.push_str(&volumes.join(", "));
1251    chart.push_str("]\n");
1252    chart.push_str("```\n");
1253
1254    chart
1255}
1256
1257/// Generates a Mermaid pie chart for liquidity distribution across DEXes.
1258fn generate_liquidity_chart(analytics: &TokenAnalytics) -> String {
1259    if analytics.dex_pairs.is_empty() {
1260        return String::new();
1261    }
1262
1263    // Only show chart if there are multiple DEXes
1264    if analytics.dex_pairs.len() < 2 {
1265        return String::new();
1266    }
1267
1268    let mut chart = String::new();
1269    chart.push_str("\n### Liquidity Distribution by DEX\n\n");
1270    chart.push_str("```mermaid\n");
1271    chart.push_str("pie showData\n");
1272    chart.push_str("    title Liquidity by DEX\n");
1273
1274    // Aggregate liquidity by DEX name
1275    let mut dex_liquidity: std::collections::HashMap<String, f64> =
1276        std::collections::HashMap::new();
1277    for pair in &analytics.dex_pairs {
1278        *dex_liquidity.entry(pair.dex_name.clone()).or_insert(0.0) += pair.liquidity_usd;
1279    }
1280
1281    // Sort by liquidity descending and take top 6
1282    let mut sorted: Vec<_> = dex_liquidity.into_iter().collect();
1283    sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1284
1285    for (dex, liquidity) in sorted.iter().take(6) {
1286        // Mermaid pie values need to be positive integers or percentages
1287        let value = (liquidity / 1_000_000.0).max(0.01); // Convert to millions
1288        chart.push_str(&format!("    \"{}\" : {:.2}\n", dex, value));
1289    }
1290
1291    chart.push_str("```\n");
1292
1293    chart
1294}
1295
1296/// Generates a Mermaid pie chart for holder concentration.
1297fn generate_concentration_chart(analytics: &TokenAnalytics) -> String {
1298    // Calculate concentration from holder data or use stored values
1299    let top_10_pct: f64 = analytics.top_10_concentration.unwrap_or_else(|| {
1300        analytics
1301            .holders
1302            .iter()
1303            .take(10)
1304            .map(|h| h.percentage)
1305            .sum()
1306    });
1307
1308    // Only show chart if we have meaningful concentration data
1309    if top_10_pct <= 0.0 || analytics.holders.is_empty() {
1310        return String::new();
1311    }
1312
1313    let remaining = (100.0 - top_10_pct).max(0.0);
1314
1315    let mut chart = String::new();
1316    chart.push_str("\n### Holder Concentration Chart\n\n");
1317    chart.push_str("```mermaid\n");
1318    chart.push_str("pie showData\n");
1319    chart.push_str("    title Token Holder Distribution\n");
1320    chart.push_str(&format!("    \"Top 10 Holders\" : {:.1}\n", top_10_pct));
1321    chart.push_str(&format!("    \"Other Holders\" : {:.1}\n", remaining));
1322
1323    // Add top 50 if different enough from top 10
1324    let top_50_pct = analytics.top_50_concentration.unwrap_or_else(|| {
1325        analytics
1326            .holders
1327            .iter()
1328            .take(50)
1329            .map(|h| h.percentage)
1330            .sum()
1331    });
1332
1333    if top_50_pct > top_10_pct + 5.0 {
1334        // Show breakdown: Top 10 vs 11-50 vs Rest
1335        let between_10_50 = top_50_pct - top_10_pct;
1336        let rest = (100.0 - top_50_pct).max(0.0);
1337
1338        // Regenerate with 3 segments
1339        chart.clear();
1340        chart.push_str("\n### Holder Concentration Chart\n\n");
1341        chart.push_str("```mermaid\n");
1342        chart.push_str("pie showData\n");
1343        chart.push_str("    title Token Holder Distribution\n");
1344        chart.push_str(&format!("    \"Top 10\" : {:.1}\n", top_10_pct));
1345        chart.push_str(&format!("    \"Rank 11-50\" : {:.1}\n", between_10_50));
1346        chart.push_str(&format!("    \"Others\" : {:.1}\n", rest));
1347    }
1348
1349    chart.push_str("```\n");
1350
1351    chart
1352}
1353
1354// ============================================================================
1355// Unit Tests
1356// ============================================================================
1357
1358#[cfg(test)]
1359mod tests {
1360    use super::*;
1361    use crate::chains::{DexPair, Token, TokenHolder, TokenSocial};
1362
1363    fn create_test_analytics() -> TokenAnalytics {
1364        TokenAnalytics {
1365            token: Token {
1366                contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1367                symbol: "USDC".to_string(),
1368                name: "USD Coin".to_string(),
1369                decimals: 6,
1370            },
1371            chain: "ethereum".to_string(),
1372            holders: vec![
1373                TokenHolder {
1374                    address: "0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c".to_string(),
1375                    balance: "1250000000000000".to_string(),
1376                    formatted_balance: "1.25B".to_string(),
1377                    percentage: 12.5,
1378                    rank: 1,
1379                },
1380                TokenHolder {
1381                    address: "0x8894E0a0c962CB723c1976a4421c95949bE2a912".to_string(),
1382                    balance: "820000000000000".to_string(),
1383                    formatted_balance: "820M".to_string(),
1384                    percentage: 8.2,
1385                    rank: 2,
1386                },
1387            ],
1388            total_holders: 1234567,
1389            volume_24h: 1234567890.0,
1390            volume_7d: 8641975230.0,
1391            price_usd: 1.0002,
1392            price_change_24h: 0.01,
1393            price_change_7d: -0.05,
1394            liquidity_usd: 500000000.0,
1395            market_cap: Some(32500000000.0),
1396            fdv: Some(40000000000.0),
1397            total_supply: Some("40,000,000,000".to_string()),
1398            circulating_supply: Some("32,500,000,000".to_string()),
1399            price_history: vec![],
1400            volume_history: vec![],
1401            holder_history: vec![],
1402            dex_pairs: vec![DexPair {
1403                dex_name: "Uniswap V3".to_string(),
1404                pair_address: "0x1234".to_string(),
1405                base_token: "USDC".to_string(),
1406                quote_token: "ETH".to_string(),
1407                price_usd: 1.0002,
1408                volume_24h: 500000000.0,
1409                liquidity_usd: 250000000.0,
1410                price_change_24h: 0.01,
1411                buys_24h: 1234,
1412                sells_24h: 1189,
1413                buys_6h: 234,
1414                sells_6h: 220,
1415                buys_1h: 45,
1416                sells_1h: 42,
1417                pair_created_at: Some(1700000000 - 86400 * 30), // 30 days ago
1418                url: Some("https://dexscreener.com/ethereum/0x1234".to_string()),
1419            }],
1420            fetched_at: 1700000000,
1421            top_10_concentration: Some(45.2),
1422            top_50_concentration: Some(67.8),
1423            top_100_concentration: Some(78.5),
1424            price_change_6h: 0.5,
1425            price_change_1h: -0.1,
1426            total_buys_24h: 1234,
1427            total_sells_24h: 1189,
1428            total_buys_6h: 234,
1429            total_sells_6h: 220,
1430            total_buys_1h: 45,
1431            total_sells_1h: 42,
1432            token_age_hours: Some(720.0), // 30 days
1433            image_url: Some("https://example.com/usdc.png".to_string()),
1434            websites: vec!["https://www.circle.com/usdc".to_string()],
1435            socials: vec![TokenSocial {
1436                platform: "twitter".to_string(),
1437                url: "https://twitter.com/USDC".to_string(),
1438            }],
1439            dexscreener_url: Some("https://dexscreener.com/ethereum/0x1234".to_string()),
1440        }
1441    }
1442
1443    #[test]
1444    fn test_generate_report() {
1445        let analytics = create_test_analytics();
1446        let report = generate_report(&analytics);
1447
1448        // Check header
1449        assert!(report.contains("# Token Analysis Report: USDC"));
1450        assert!(report.contains("**Chain:** Ethereum"));
1451        assert!(report.contains("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"));
1452
1453        // Check that addresses are NOT truncated
1454        assert!(report.contains("0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c"));
1455        assert!(report.contains("0x8894E0a0c962CB723c1976a4421c95949bE2a912"));
1456
1457        // Check sections exist
1458        assert!(report.contains("## Executive Summary"));
1459        assert!(report.contains("## Top Holders"));
1460        assert!(report.contains("## Concentration Analysis"));
1461        assert!(report.contains("## Data Sources"));
1462    }
1463
1464    #[test]
1465    fn test_format_usd() {
1466        assert_eq!(format_usd(1500000000.0), "$1.50B");
1467        assert_eq!(format_usd(1500000.0), "$1.50M");
1468        assert_eq!(format_usd(1500.0), "$2K"); // 1500 / 1000 = 1.5, rounded to 2K
1469        assert_eq!(format_usd(15.5), "$15.50");
1470    }
1471
1472    #[test]
1473    fn test_capitalize() {
1474        assert_eq!(capitalize("ethereum"), "Ethereum");
1475        assert_eq!(capitalize("bsc"), "Bsc");
1476        assert_eq!(capitalize(""), "");
1477    }
1478
1479    #[test]
1480    fn test_full_addresses_not_truncated() {
1481        let analytics = create_test_analytics();
1482        let section = generate_holder_section(&analytics);
1483
1484        // Verify full addresses are present
1485        assert!(section.contains("0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c"));
1486        assert!(section.contains("0x8894E0a0c962CB723c1976a4421c95949bE2a912"));
1487
1488        // Verify truncated format is NOT used
1489        assert!(!section.contains("..."));
1490    }
1491
1492    #[test]
1493    fn test_concentration_analysis() {
1494        let analytics = create_test_analytics();
1495        let section = generate_concentration_analysis(&analytics);
1496
1497        assert!(section.contains("45.1%") || section.contains("45.2%"));
1498        assert!(section.contains("Top 10 holders"));
1499    }
1500
1501    #[test]
1502    fn test_security_analysis_section() {
1503        let analytics = create_test_analytics();
1504        let section = generate_security_analysis(&analytics);
1505
1506        // Check section header
1507        assert!(section.contains("## Security Analysis"));
1508
1509        // Check for security checks table
1510        assert!(section.contains("Honeypot Risk"));
1511        assert!(section.contains("Token Age"));
1512        assert!(section.contains("Whale Risk"));
1513        assert!(section.contains("Social Presence"));
1514
1515        // Check for buy/sell data
1516        assert!(section.contains("1234"));
1517        assert!(section.contains("1189"));
1518    }
1519
1520    #[test]
1521    fn test_security_analysis_honeypot_detection() {
1522        let mut analytics = create_test_analytics();
1523
1524        // Test high honeypot risk (many buys, few sells)
1525        analytics.total_buys_24h = 1000;
1526        analytics.total_sells_24h = 10;
1527        let section = generate_security_analysis(&analytics);
1528        assert!(section.contains("HIGH") || section.contains("Suspicious"));
1529
1530        // Test normal activity
1531        analytics.total_buys_24h = 100;
1532        analytics.total_sells_24h = 95;
1533        let section = generate_security_analysis(&analytics);
1534        assert!(section.contains("LOW") || section.contains("Normal"));
1535    }
1536
1537    #[test]
1538    fn test_token_info_section() {
1539        let analytics = create_test_analytics();
1540        let section = generate_token_info_section(&analytics);
1541
1542        // Check section header
1543        assert!(section.contains("## Token Information"));
1544
1545        // Check for social links
1546        assert!(section.contains("Twitter") || section.contains("twitter"));
1547        assert!(section.contains("https://twitter.com/USDC"));
1548
1549        // Check for website
1550        assert!(section.contains("circle.com"));
1551
1552        // Check for DexScreener link
1553        assert!(section.contains("DexScreener"));
1554    }
1555
1556    #[test]
1557    fn test_risk_score_calculation() {
1558        let analytics = create_test_analytics();
1559        let factors = RiskFactors::from_analytics(&analytics);
1560
1561        // Verify factors are in valid range
1562        assert!(factors.honeypot <= 10);
1563        assert!(factors.age <= 10);
1564        assert!(factors.liquidity <= 10);
1565        assert!(factors.concentration <= 10);
1566        assert!(factors.social <= 10);
1567
1568        // Verify overall score is in valid range
1569        let overall = factors.overall_score();
1570        assert!((1..=10).contains(&overall));
1571    }
1572
1573    #[test]
1574    fn test_risk_score_section() {
1575        let analytics = create_test_analytics();
1576        let section = generate_risk_score_section(&analytics);
1577
1578        // Check section header
1579        assert!(section.contains("## Risk Score"));
1580
1581        // Check for overall risk display
1582        assert!(section.contains("Overall Risk:"));
1583        assert!(section.contains("/10"));
1584
1585        // Check for factor breakdown table
1586        assert!(section.contains("Honeypot Risk"));
1587        assert!(section.contains("Token Age"));
1588        assert!(section.contains("Liquidity"));
1589        assert!(section.contains("Concentration"));
1590        assert!(section.contains("Social Presence"));
1591
1592        // Check for Mermaid chart
1593        assert!(section.contains("```mermaid"));
1594        assert!(section.contains("pie showData"));
1595    }
1596
1597    #[test]
1598    fn test_buysell_chart() {
1599        let analytics = create_test_analytics();
1600        let chart = generate_buysell_chart(&analytics);
1601
1602        // Check for Mermaid syntax
1603        assert!(chart.contains("```mermaid"));
1604        assert!(chart.contains("pie showData"));
1605        assert!(chart.contains("Buys"));
1606        assert!(chart.contains("Sells"));
1607    }
1608
1609    #[test]
1610    fn test_txn_activity_chart() {
1611        let analytics = create_test_analytics();
1612        let chart = generate_txn_activity_chart(&analytics);
1613
1614        // Check for Mermaid syntax
1615        assert!(chart.contains("```mermaid"));
1616        assert!(chart.contains("xychart-beta"));
1617        assert!(chart.contains("1h") || chart.contains("6h") || chart.contains("24h"));
1618    }
1619
1620    #[test]
1621    fn test_price_change_chart() {
1622        let analytics = create_test_analytics();
1623        let chart = generate_price_chart(&analytics);
1624
1625        // Check for Mermaid syntax
1626        assert!(chart.contains("```mermaid"));
1627        assert!(chart.contains("Price Change"));
1628    }
1629
1630    #[test]
1631    fn test_new_report_sections_included() {
1632        let analytics = create_test_analytics();
1633        let report = generate_report(&analytics);
1634
1635        // Check new sections are included in the report
1636        assert!(report.contains("## Token Information"));
1637        assert!(report.contains("## Security Analysis"));
1638        assert!(report.contains("## Risk Score"));
1639    }
1640
1641    // ========================================================================
1642    // Edge case tests
1643    // ========================================================================
1644
1645    #[test]
1646    fn test_generate_report_no_holders() {
1647        let mut analytics = create_test_analytics();
1648        analytics.holders = vec![];
1649        analytics.total_holders = 0;
1650        analytics.top_10_concentration = None;
1651        analytics.top_50_concentration = None;
1652        analytics.top_100_concentration = None;
1653        let report = generate_report(&analytics);
1654        assert!(report.contains("No holder data available"));
1655    }
1656
1657    #[test]
1658    fn test_generate_report_no_market_cap() {
1659        let mut analytics = create_test_analytics();
1660        analytics.market_cap = None;
1661        analytics.fdv = None;
1662        analytics.total_supply = None;
1663        analytics.circulating_supply = None;
1664        let report = generate_report(&analytics);
1665        assert!(!report.contains("Market Cap | $"));
1666        assert!(!report.contains("Fully Diluted Valuation | $"));
1667    }
1668
1669    #[test]
1670    fn test_generate_report_no_dex_pairs() {
1671        let mut analytics = create_test_analytics();
1672        analytics.dex_pairs = vec![];
1673        analytics.liquidity_usd = 0.0;
1674        let report = generate_report(&analytics);
1675        // Should still generate without errors
1676        assert!(report.contains("## Liquidity Analysis"));
1677    }
1678
1679    #[test]
1680    fn test_generate_report_no_social_no_website() {
1681        let mut analytics = create_test_analytics();
1682        analytics.socials = vec![];
1683        analytics.websites = vec![];
1684        analytics.image_url = None;
1685        analytics.dexscreener_url = None;
1686        let section = generate_token_info_section(&analytics);
1687        assert!(section.contains("No additional token metadata available"));
1688    }
1689
1690    #[test]
1691    fn test_security_analysis_zero_transactions() {
1692        let mut analytics = create_test_analytics();
1693        analytics.total_buys_24h = 0;
1694        analytics.total_sells_24h = 0;
1695        analytics.total_buys_6h = 0;
1696        analytics.total_sells_6h = 0;
1697        analytics.total_buys_1h = 0;
1698        analytics.total_sells_1h = 0;
1699        let section = generate_security_analysis(&analytics);
1700        assert!(section.contains("UNKNOWN") || section.contains("No transaction data"));
1701    }
1702
1703    #[test]
1704    fn test_security_analysis_only_buys() {
1705        let mut analytics = create_test_analytics();
1706        analytics.total_buys_24h = 100;
1707        analytics.total_sells_24h = 0;
1708        let section = generate_security_analysis(&analytics);
1709        assert!(section.contains("Possible honeypot") || section.contains("HIGH"));
1710    }
1711
1712    #[test]
1713    fn test_security_analysis_token_age_very_new() {
1714        let mut analytics = create_test_analytics();
1715        analytics.token_age_hours = Some(6.0);
1716        let section = generate_security_analysis(&analytics);
1717        assert!(section.contains("Very new token") || section.contains("HIGH RISK"));
1718    }
1719
1720    #[test]
1721    fn test_security_analysis_token_age_unknown() {
1722        let mut analytics = create_test_analytics();
1723        analytics.token_age_hours = None;
1724        let section = generate_security_analysis(&analytics);
1725        assert!(section.contains("not available") || section.contains("UNKNOWN"));
1726    }
1727
1728    #[test]
1729    fn test_security_analysis_whale_risk_extreme() {
1730        let mut analytics = create_test_analytics();
1731        analytics.holders = vec![TokenHolder {
1732            address: "0xwhale".to_string(),
1733            balance: "9000000".to_string(),
1734            formatted_balance: "9M".to_string(),
1735            percentage: 60.0,
1736            rank: 1,
1737        }];
1738        let section = generate_security_analysis(&analytics);
1739        assert!(section.contains("HIGH") || section.contains("Extreme concentration"));
1740    }
1741
1742    #[test]
1743    fn test_security_analysis_no_holders() {
1744        let mut analytics = create_test_analytics();
1745        analytics.holders = vec![];
1746        let section = generate_security_analysis(&analytics);
1747        assert!(section.contains("Whale Risk"));
1748        assert!(section.contains("UNKNOWN") || section.contains("not available"));
1749    }
1750
1751    #[test]
1752    fn test_risk_factors_high_risk_token() {
1753        let mut analytics = create_test_analytics();
1754        analytics.total_buys_24h = 1000;
1755        analytics.total_sells_24h = 0; // Honeypot risk = 10
1756        analytics.token_age_hours = Some(12.0); // Very new = 10
1757        analytics.liquidity_usd = 5_000.0; // Very low = 10
1758        analytics.holders = vec![TokenHolder {
1759            address: "0x1".to_string(),
1760            balance: "1000".to_string(),
1761            formatted_balance: "1K".to_string(),
1762            percentage: 80.0, // Very concentrated = 10
1763            rank: 1,
1764        }];
1765        analytics.socials = vec![]; // No socials = 8
1766        analytics.websites = vec![];
1767
1768        let factors = RiskFactors::from_analytics(&analytics);
1769        assert_eq!(factors.honeypot, 10);
1770        assert_eq!(factors.age, 10);
1771        assert_eq!(factors.liquidity, 10);
1772        assert_eq!(factors.concentration, 10);
1773        assert_eq!(factors.social, 8);
1774        assert!(factors.overall_score() >= 8);
1775        assert!(factors.risk_level() == "HIGH" || factors.risk_level() == "CRITICAL");
1776        assert!(factors.risk_emoji() == "🟠" || factors.risk_emoji() == "🔴");
1777    }
1778
1779    #[test]
1780    fn test_risk_factors_low_risk_token() {
1781        let mut analytics = create_test_analytics();
1782        analytics.total_buys_24h = 100;
1783        analytics.total_sells_24h = 95; // Normal ratio
1784        analytics.token_age_hours = Some(10_000.0); // Very established
1785        analytics.liquidity_usd = 50_000_000.0; // Very high
1786        analytics.holders = vec![TokenHolder {
1787            address: "0x1".to_string(),
1788            balance: "1000".to_string(),
1789            formatted_balance: "1K".to_string(),
1790            percentage: 3.0, // Well distributed
1791            rank: 1,
1792        }];
1793        analytics.socials = vec![
1794            TokenSocial {
1795                platform: "twitter".to_string(),
1796                url: "https://twitter.com/test".to_string(),
1797            },
1798            TokenSocial {
1799                platform: "telegram".to_string(),
1800                url: "https://t.me/test".to_string(),
1801            },
1802        ];
1803        analytics.websites = vec!["https://example.com".to_string()];
1804
1805        let factors = RiskFactors::from_analytics(&analytics);
1806        assert!(factors.overall_score() <= 3);
1807        assert_eq!(factors.risk_level(), "LOW");
1808        assert_eq!(factors.risk_emoji(), "🟢");
1809    }
1810
1811    #[test]
1812    fn test_risk_assessment_labels() {
1813        assert_eq!(risk_assessment(0), "Low Risk");
1814        assert_eq!(risk_assessment(1), "Low Risk");
1815        assert_eq!(risk_assessment(3), "Moderate");
1816        assert_eq!(risk_assessment(5), "Elevated");
1817        assert_eq!(risk_assessment(7), "High Risk");
1818        assert_eq!(risk_assessment(9), "Critical");
1819        assert_eq!(risk_assessment(10), "Critical");
1820    }
1821
1822    #[test]
1823    fn test_format_usd_edge_cases() {
1824        assert_eq!(format_usd(0.0), "$0.00");
1825        assert_eq!(format_usd(0.50), "$0.50");
1826        assert_eq!(format_usd(999.0), "$999.00");
1827    }
1828
1829    #[test]
1830    fn test_format_number_edge_cases() {
1831        assert_eq!(format_number(0.0), "0");
1832        assert_eq!(format_number(500.0), "500");
1833        assert_eq!(format_number(1500.0), "2K");
1834        assert_eq!(format_number(1_500_000.0), "1.50M");
1835    }
1836
1837    #[test]
1838    fn test_capitalize_edge_cases() {
1839        assert_eq!(capitalize("a"), "A");
1840        assert_eq!(capitalize("ABC"), "ABC");
1841    }
1842
1843    #[test]
1844    fn test_data_sources_different_chains() {
1845        let chains = vec![
1846            ("ethereum", "etherscan.io"),
1847            ("polygon", "polygonscan.com"),
1848            ("arbitrum", "arbiscan.io"),
1849            ("optimism", "optimistic.etherscan.io"),
1850            ("base", "basescan.org"),
1851            ("bsc", "bscscan.com"),
1852            ("solana", "solscan.io"),
1853        ];
1854
1855        for (chain, expected_domain) in chains {
1856            let mut analytics = create_test_analytics();
1857            analytics.chain = chain.to_string();
1858            let section = generate_data_sources(&analytics);
1859            assert!(
1860                section.contains(expected_domain),
1861                "Chain {} should link to {}",
1862                chain,
1863                expected_domain
1864            );
1865        }
1866    }
1867
1868    #[test]
1869    fn test_buysell_chart_empty() {
1870        let mut analytics = create_test_analytics();
1871        analytics.total_buys_24h = 0;
1872        analytics.total_sells_24h = 0;
1873        let chart = generate_buysell_chart(&analytics);
1874        assert!(chart.is_empty());
1875    }
1876
1877    #[test]
1878    fn test_txn_activity_chart_empty() {
1879        let mut analytics = create_test_analytics();
1880        analytics.total_buys_24h = 0;
1881        analytics.total_sells_24h = 0;
1882        analytics.total_buys_6h = 0;
1883        analytics.total_sells_6h = 0;
1884        analytics.total_buys_1h = 0;
1885        analytics.total_sells_1h = 0;
1886        let chart = generate_txn_activity_chart(&analytics);
1887        assert!(chart.is_empty());
1888    }
1889
1890    #[test]
1891    fn test_volume_chart_empty() {
1892        let analytics = create_test_analytics();
1893        // analytics has empty volume_history by default
1894        let chart = generate_volume_chart(&analytics);
1895        assert!(chart.is_empty());
1896    }
1897
1898    #[test]
1899    fn test_liquidity_chart_single_pair() {
1900        let analytics = create_test_analytics();
1901        // Only 1 DEX pair → no chart generated
1902        assert_eq!(analytics.dex_pairs.len(), 1);
1903        let chart = generate_liquidity_chart(&analytics);
1904        assert!(chart.is_empty());
1905    }
1906
1907    #[test]
1908    fn test_liquidity_chart_multiple_pairs() {
1909        let mut analytics = create_test_analytics();
1910        analytics.dex_pairs.push(DexPair {
1911            dex_name: "SushiSwap".to_string(),
1912            pair_address: "0x5678".to_string(),
1913            base_token: "USDC".to_string(),
1914            quote_token: "DAI".to_string(),
1915            price_usd: 1.0,
1916            volume_24h: 100_000.0,
1917            liquidity_usd: 5_000_000.0,
1918            price_change_24h: 0.0,
1919            buys_24h: 50,
1920            sells_24h: 50,
1921            buys_6h: 10,
1922            sells_6h: 10,
1923            buys_1h: 2,
1924            sells_1h: 2,
1925            pair_created_at: None,
1926            url: None,
1927        });
1928        let chart = generate_liquidity_chart(&analytics);
1929        assert!(chart.contains("mermaid"));
1930        assert!(chart.contains("Uniswap V3"));
1931        assert!(chart.contains("SushiSwap"));
1932    }
1933
1934    #[test]
1935    fn test_concentration_chart_no_holders() {
1936        let mut analytics = create_test_analytics();
1937        analytics.holders = vec![];
1938        analytics.top_10_concentration = Some(0.0);
1939        let chart = generate_concentration_chart(&analytics);
1940        assert!(chart.is_empty());
1941    }
1942
1943    #[test]
1944    fn test_concentration_analysis_ranges() {
1945        // Very high concentration
1946        let mut analytics = create_test_analytics();
1947        analytics.top_10_concentration = Some(85.0);
1948        let section = generate_concentration_analysis(&analytics);
1949        assert!(section.contains("Very High Concentration"));
1950
1951        // High concentration
1952        analytics.top_10_concentration = Some(55.0);
1953        let section = generate_concentration_analysis(&analytics);
1954        assert!(section.contains("High Concentration"));
1955
1956        // Low concentration
1957        analytics.top_10_concentration = Some(15.0);
1958        let section = generate_concentration_analysis(&analytics);
1959        assert!(section.contains("Low Concentration"));
1960    }
1961
1962    #[test]
1963    fn test_risk_indicators_low_liquidity() {
1964        let mut analytics = create_test_analytics();
1965        analytics.liquidity_usd = 5_000.0;
1966        let section = generate_risk_indicators(&analytics);
1967        assert!(section.contains("Very low liquidity"));
1968    }
1969
1970    #[test]
1971    fn test_risk_indicators_high_volatility() {
1972        let mut analytics = create_test_analytics();
1973        analytics.price_change_24h = 25.0;
1974        let section = generate_risk_indicators(&analytics);
1975        assert!(section.contains("High price volatility"));
1976    }
1977
1978    #[test]
1979    fn test_risk_indicators_healthy_token() {
1980        let mut analytics = create_test_analytics();
1981        analytics.holders = vec![TokenHolder {
1982            address: "0x1".to_string(),
1983            balance: "100".to_string(),
1984            formatted_balance: "100".to_string(),
1985            percentage: 5.0,
1986            rank: 1,
1987        }];
1988        analytics.liquidity_usd = 10_000_000.0;
1989        analytics.volume_24h = 500_000.0;
1990        analytics.price_change_24h = 2.0;
1991        let section = generate_risk_indicators(&analytics);
1992        assert!(section.contains("Reasonable distribution"));
1993        assert!(section.contains("Good liquidity"));
1994        assert!(section.contains("Active trading"));
1995    }
1996
1997    #[test]
1998    fn test_risk_indicators_empty() {
1999        let mut analytics = create_test_analytics();
2000        analytics.holders = vec![];
2001        analytics.liquidity_usd = 500_000.0;
2002        analytics.volume_24h = 50_000.0;
2003        analytics.price_change_24h = 5.0;
2004        let section = generate_risk_indicators(&analytics);
2005        // With no holders, calculation uses empty iter → 0%, which is "reasonable"
2006        assert!(section.contains("Reasonable distribution"));
2007    }
2008
2009    #[test]
2010    fn test_report_footer() {
2011        let footer = report_footer();
2012        assert!(footer.contains("Scope"));
2013        assert!(footer.contains("UTC"));
2014        assert!(footer.contains("verify"));
2015    }
2016
2017    #[test]
2018    fn test_save_report() {
2019        let tmp = std::env::temp_dir().join("bcc_test_report.md");
2020        let result = save_report("# Test Report\n\nContent here.", &tmp);
2021        assert!(result.is_ok());
2022        let content = std::fs::read_to_string(&tmp).unwrap();
2023        assert!(content.contains("# Test Report"));
2024        let _ = std::fs::remove_file(&tmp);
2025    }
2026
2027    #[test]
2028    fn test_save_report_invalid_path() {
2029        let result = save_report("content", "/nonexistent/directory/report.md");
2030        assert!(result.is_err());
2031    }
2032
2033    #[test]
2034    fn test_volume_analysis_high_vol_to_liq() {
2035        let mut analytics = create_test_analytics();
2036        analytics.volume_24h = 100_000_000.0;
2037        analytics.liquidity_usd = 10_000_000.0; // ratio = 10
2038        let section = generate_volume_analysis(&analytics);
2039        assert!(section.contains("unusual trading activity"));
2040    }
2041
2042    #[test]
2043    fn test_price_analysis_with_history() {
2044        use crate::chains::PricePoint;
2045        let mut analytics = create_test_analytics();
2046        analytics.price_history = vec![
2047            PricePoint {
2048                timestamp: 1700000000,
2049                price: 1.0,
2050            },
2051            PricePoint {
2052                timestamp: 1700003600,
2053                price: 1.5,
2054            },
2055            PricePoint {
2056                timestamp: 1700007200,
2057                price: 0.8,
2058            },
2059        ];
2060        let section = generate_price_analysis(&analytics);
2061        assert!(section.contains("Price Range"));
2062        assert!(section.contains("High"));
2063        assert!(section.contains("Low"));
2064        assert!(section.contains("Average"));
2065    }
2066
2067    #[test]
2068    fn test_social_platform_icons() {
2069        let mut analytics = create_test_analytics();
2070        analytics.socials = vec![
2071            TokenSocial {
2072                platform: "twitter".to_string(),
2073                url: "https://twitter.com/test".to_string(),
2074            },
2075            TokenSocial {
2076                platform: "telegram".to_string(),
2077                url: "https://t.me/test".to_string(),
2078            },
2079            TokenSocial {
2080                platform: "discord".to_string(),
2081                url: "https://discord.gg/test".to_string(),
2082            },
2083            TokenSocial {
2084                platform: "github".to_string(),
2085                url: "https://github.com/test".to_string(),
2086            },
2087            TokenSocial {
2088                platform: "unknown".to_string(),
2089                url: "https://example.com".to_string(),
2090            },
2091        ];
2092        let section = generate_token_info_section(&analytics);
2093        assert!(section.contains("🐦")); // twitter
2094        assert!(section.contains("📱")); // telegram
2095        assert!(section.contains("💬")); // discord
2096        assert!(section.contains("💻")); // github
2097        assert!(section.contains("🔗")); // unknown
2098    }
2099
2100    #[test]
2101    fn test_security_analysis_token_age_ranges() {
2102        let mut analytics = create_test_analytics();
2103
2104        // Very new (< 24h)
2105        analytics.token_age_hours = Some(6.0);
2106        let section = generate_security_analysis(&analytics);
2107        assert!(section.contains("HIGH RISK"));
2108
2109        // New (24-48h)
2110        analytics.token_age_hours = Some(36.0);
2111        let section = generate_security_analysis(&analytics);
2112        assert!(section.contains("MEDIUM"));
2113
2114        // Relatively new (< 7d)
2115        analytics.token_age_hours = Some(120.0);
2116        let section = generate_security_analysis(&analytics);
2117        assert!(section.contains("CAUTION"));
2118
2119        // Established (> 1 year)
2120        analytics.token_age_hours = Some(10_000.0);
2121        let section = generate_security_analysis(&analytics);
2122        assert!(section.contains("ESTABLISHED"));
2123    }
2124
2125    #[test]
2126    fn test_price_history_chart_with_data() {
2127        use crate::chains::PricePoint;
2128        let mut analytics = create_test_analytics();
2129        analytics.price_history = (0..20)
2130            .map(|i| PricePoint {
2131                timestamp: 1700000000 + i * 3600,
2132                price: 1.0 + (i as f64) * 0.01,
2133            })
2134            .collect();
2135        let chart = generate_price_history_chart(&analytics);
2136        assert!(chart.contains("Price History"));
2137        assert!(chart.contains("mermaid"));
2138        assert!(chart.contains("xychart-beta"));
2139        assert!(chart.contains("line ["));
2140    }
2141
2142    #[test]
2143    fn test_price_chart_with_changes_and_history() {
2144        use crate::chains::PricePoint;
2145        let mut analytics = create_test_analytics();
2146        analytics.price_change_1h = 1.5;
2147        analytics.price_change_6h = -2.3;
2148        analytics.price_change_24h = 5.0;
2149        analytics.price_change_7d = -10.0;
2150        analytics.price_history = (0..5)
2151            .map(|i| PricePoint {
2152                timestamp: 1700000000 + i * 3600,
2153                price: 1.0 + (i as f64) * 0.1,
2154            })
2155            .collect();
2156        let chart = generate_price_chart(&analytics);
2157        assert!(chart.contains("Price Changes by Period"));
2158        assert!(chart.contains("Price History")); // Also includes history chart
2159    }
2160
2161    #[test]
2162    fn test_price_chart_zero_changes_with_history() {
2163        use crate::chains::PricePoint;
2164        let mut analytics = create_test_analytics();
2165        analytics.price_change_1h = 0.0;
2166        analytics.price_change_6h = 0.0;
2167        analytics.price_change_24h = 0.0;
2168        analytics.price_change_7d = 0.0;
2169        analytics.price_history = vec![
2170            PricePoint {
2171                timestamp: 1700000000,
2172                price: 1.0,
2173            },
2174            PricePoint {
2175                timestamp: 1700003600,
2176                price: 1.5,
2177            },
2178        ];
2179        let chart = generate_price_chart(&analytics);
2180        assert!(chart.contains("Price History")); // Falls back to history chart
2181    }
2182
2183    #[test]
2184    fn test_volume_chart_with_data() {
2185        use crate::chains::VolumePoint;
2186        let mut analytics = create_test_analytics();
2187        analytics.volume_history = (0..10)
2188            .map(|i| VolumePoint {
2189                timestamp: 1700000000 + i * 3600,
2190                volume: 100_000.0 + (i as f64) * 50_000.0,
2191            })
2192            .collect();
2193        let chart = generate_volume_chart(&analytics);
2194        assert!(chart.contains("Volume Chart"));
2195        assert!(chart.contains("mermaid"));
2196        assert!(chart.contains("bar ["));
2197    }
2198
2199    #[test]
2200    fn test_concentration_chart_three_segments() {
2201        let mut analytics = create_test_analytics();
2202        // Set top_10 = 30%, top_50 = 60% (difference > 5%), triggers 3-segment chart
2203        analytics.top_10_concentration = Some(30.0);
2204        analytics.top_50_concentration = Some(60.0);
2205        let chart = generate_concentration_chart(&analytics);
2206        assert!(chart.contains("Top 10"));
2207        assert!(chart.contains("Rank 11-50"));
2208        assert!(chart.contains("Others"));
2209    }
2210
2211    #[test]
2212    fn test_risk_indicators_very_low_liquidity() {
2213        let mut analytics = create_test_analytics();
2214        analytics.liquidity_usd = 5_000.0;
2215        analytics.volume_24h = 500.0;
2216        let section = generate_risk_indicators(&analytics);
2217        assert!(section.contains("Very low liquidity"));
2218        assert!(section.contains("Very low trading volume"));
2219    }
2220
2221    #[test]
2222    fn test_risk_indicators_moderate_liquidity() {
2223        let mut analytics = create_test_analytics();
2224        analytics.liquidity_usd = 50_000.0;
2225        let section = generate_risk_indicators(&analytics);
2226        assert!(section.contains("Low liquidity"));
2227    }
2228
2229    #[test]
2230    fn test_risk_indicators_extreme_concentration() {
2231        let mut analytics = create_test_analytics();
2232        analytics.holders = vec![TokenHolder {
2233            address: "0xwhale".to_string(),
2234            balance: "900000000".to_string(),
2235            formatted_balance: "900M".to_string(),
2236            percentage: 90.0,
2237            rank: 1,
2238        }];
2239        let section = generate_risk_indicators(&analytics);
2240        assert!(section.contains("Extreme whale concentration"));
2241    }
2242
2243    #[test]
2244    fn test_risk_indicators_no_data() {
2245        let mut analytics = create_test_analytics();
2246        analytics.holders = vec![];
2247        analytics.liquidity_usd = 500_000.0; // between 100k and 1M, no risk or positive
2248        analytics.volume_24h = 50_000.0; // between 1k and 100k, no risk or positive
2249        analytics.price_change_24h = 5.0; // less than 20%, no risk
2250        let section = generate_risk_indicators(&analytics);
2251        // Should have insufficient data or at least no risk factors
2252        assert!(section.contains("Risk Indicators"));
2253    }
2254
2255    #[test]
2256    fn test_holder_section_with_data() {
2257        let analytics = create_test_analytics();
2258        let section = generate_holder_section(&analytics);
2259        assert!(section.contains("Top Holders"));
2260        assert!(section.contains("0x55FE002e15bA7591a5E5Ce68a6D3c6E1593d3d8c")); // Full address
2261        assert!(section.contains("12.50%"));
2262    }
2263
2264    #[test]
2265    fn test_risk_breakdown_chart() {
2266        let analytics = create_test_analytics();
2267        let section = generate_risk_score_section(&analytics);
2268        assert!(section.contains("Risk Score"));
2269        assert!(section.contains("Risk Factor Breakdown"));
2270        assert!(section.contains("Honeypot"));
2271        assert!(section.contains("Token Age"));
2272    }
2273
2274    #[test]
2275    fn test_data_sources_section() {
2276        let analytics = create_test_analytics();
2277        let section = generate_data_sources(&analytics);
2278        assert!(section.contains("Data Sources"));
2279        assert!(section.contains("ethereum"));
2280    }
2281
2282    #[test]
2283    fn test_volume_analysis_section() {
2284        let analytics = create_test_analytics();
2285        let section = generate_volume_analysis(&analytics);
2286        assert!(section.contains("Volume Analysis"));
2287    }
2288
2289    #[test]
2290    fn test_liquidity_analysis_section() {
2291        let analytics = create_test_analytics();
2292        let section = generate_liquidity_analysis(&analytics);
2293        assert!(section.contains("Liquidity Analysis"));
2294    }
2295
2296    #[test]
2297    fn test_security_analysis_medium_buy_sell_ratio() {
2298        let mut analytics = create_test_analytics();
2299        // ratio = 5.0 → MEDIUM risk
2300        analytics.total_buys_24h = 100;
2301        analytics.total_sells_24h = 20;
2302        let section = generate_security_analysis(&analytics);
2303        assert!(section.contains("MEDIUM") || section.contains("Elevated"));
2304    }
2305
2306    #[test]
2307    fn test_security_analysis_token_age_months() {
2308        let mut analytics = create_test_analytics();
2309        // 2000 hours ≈ 83 days ≈ 2.8 months (> 30 days, < 365 days)
2310        analytics.token_age_hours = Some(2000.0);
2311        let section = generate_security_analysis(&analytics);
2312        assert!(section.contains("ESTABLISHED") || section.contains("months"));
2313    }
2314
2315    #[test]
2316    fn test_security_analysis_whale_risk_medium() {
2317        let mut analytics = create_test_analytics();
2318        analytics.holders = vec![TokenHolder {
2319            address: "0xwhale".to_string(),
2320            balance: "3000000".to_string(),
2321            formatted_balance: "3M".to_string(),
2322            percentage: 30.0, // > 25%, <= 50%
2323            rank: 1,
2324        }];
2325        let section = generate_security_analysis(&analytics);
2326        assert!(section.contains("MEDIUM") || section.contains("High concentration"));
2327    }
2328
2329    #[test]
2330    fn test_security_analysis_whale_risk_low() {
2331        let mut analytics = create_test_analytics();
2332        analytics.holders = vec![TokenHolder {
2333            address: "0xholder".to_string(),
2334            balance: "500000".to_string(),
2335            formatted_balance: "500K".to_string(),
2336            percentage: 5.0, // > 0%, <= 10%
2337            rank: 1,
2338        }];
2339        let section = generate_security_analysis(&analytics);
2340        assert!(section.contains("LOW") || section.contains("Well distributed"));
2341    }
2342
2343    #[test]
2344    fn test_security_analysis_token_age_days_format() {
2345        let mut analytics = create_test_analytics();
2346        // 480 hours = 20 days (< 30 days, uses "days ago" format)
2347        analytics.token_age_hours = Some(480.0);
2348        let section = generate_security_analysis(&analytics);
2349        assert!(section.contains("days ago") || section.contains("MODERATE"));
2350    }
2351
2352    #[test]
2353    fn test_security_buysell_zero_buys_zero_sells_in_period() {
2354        let mut analytics = create_test_analytics();
2355        // 24h has data, but 1h and 6h have zero
2356        analytics.total_buys_1h = 0;
2357        analytics.total_sells_1h = 0;
2358        analytics.total_buys_6h = 0;
2359        analytics.total_sells_6h = 0;
2360        analytics.total_buys_24h = 100;
2361        analytics.total_sells_24h = 80;
2362        let section = generate_security_analysis(&analytics);
2363        assert!(section.contains("-") || section.contains("100")); // "-" for 0/0 ratio
2364    }
2365
2366    #[test]
2367    fn test_risk_factors_various_honeypot_ratios() {
2368        let mut analytics = create_test_analytics();
2369
2370        // ratio > 10 → honeypot = 9
2371        analytics.total_buys_24h = 110;
2372        analytics.total_sells_24h = 10;
2373        let factors = RiskFactors::from_analytics(&analytics);
2374        assert_eq!(factors.honeypot, 9);
2375
2376        // ratio > 5, <= 10 → honeypot = 7
2377        analytics.total_buys_24h = 60;
2378        analytics.total_sells_24h = 10;
2379        let factors = RiskFactors::from_analytics(&analytics);
2380        assert_eq!(factors.honeypot, 7);
2381
2382        // ratio > 3, <= 5 → honeypot = 5
2383        analytics.total_buys_24h = 40;
2384        analytics.total_sells_24h = 10;
2385        let factors = RiskFactors::from_analytics(&analytics);
2386        assert_eq!(factors.honeypot, 5);
2387
2388        // ratio > 2, <= 3 → honeypot = 3
2389        analytics.total_buys_24h = 25;
2390        analytics.total_sells_24h = 10;
2391        let factors = RiskFactors::from_analytics(&analytics);
2392        assert_eq!(factors.honeypot, 3);
2393
2394        // ratio <= 2 → honeypot = 1
2395        analytics.total_buys_24h = 15;
2396        analytics.total_sells_24h = 10;
2397        let factors = RiskFactors::from_analytics(&analytics);
2398        assert_eq!(factors.honeypot, 1);
2399    }
2400
2401    #[test]
2402    fn test_risk_factors_various_age_thresholds() {
2403        let mut analytics = create_test_analytics();
2404
2405        // < 48h → 8
2406        analytics.token_age_hours = Some(36.0);
2407        let factors = RiskFactors::from_analytics(&analytics);
2408        assert_eq!(factors.age, 8);
2409
2410        // < 168h (7d) → 6
2411        analytics.token_age_hours = Some(120.0);
2412        let factors = RiskFactors::from_analytics(&analytics);
2413        assert_eq!(factors.age, 6);
2414
2415        // < 720h (30d) → 4
2416        analytics.token_age_hours = Some(500.0);
2417        let factors = RiskFactors::from_analytics(&analytics);
2418        assert_eq!(factors.age, 4);
2419
2420        // < 2160h (90d) → 2
2421        analytics.token_age_hours = Some(1500.0);
2422        let factors = RiskFactors::from_analytics(&analytics);
2423        assert_eq!(factors.age, 2);
2424    }
2425
2426    #[test]
2427    fn test_risk_factors_various_liquidity_thresholds() {
2428        let mut analytics = create_test_analytics();
2429
2430        // 50K-100K → 6
2431        analytics.liquidity_usd = 75_000.0;
2432        let factors = RiskFactors::from_analytics(&analytics);
2433        assert_eq!(factors.liquidity, 6);
2434
2435        // 100K-500K → 4
2436        analytics.liquidity_usd = 200_000.0;
2437        let factors = RiskFactors::from_analytics(&analytics);
2438        assert_eq!(factors.liquidity, 4);
2439
2440        // 500K-1M → 2
2441        analytics.liquidity_usd = 750_000.0;
2442        let factors = RiskFactors::from_analytics(&analytics);
2443        assert_eq!(factors.liquidity, 2);
2444
2445        // > 1M → 1
2446        analytics.liquidity_usd = 2_000_000.0;
2447        let factors = RiskFactors::from_analytics(&analytics);
2448        assert_eq!(factors.liquidity, 1);
2449    }
2450
2451    #[test]
2452    fn test_risk_factors_various_concentration_thresholds() {
2453        let mut analytics = create_test_analytics();
2454
2455        // 30-50% → 8
2456        analytics.holders = vec![TokenHolder {
2457            address: "0x1".to_string(),
2458            balance: "1".to_string(),
2459            formatted_balance: "1".to_string(),
2460            percentage: 35.0,
2461            rank: 1,
2462        }];
2463        let factors = RiskFactors::from_analytics(&analytics);
2464        assert_eq!(factors.concentration, 8);
2465
2466        // 20-30% → 6
2467        analytics.holders[0].percentage = 25.0;
2468        let factors = RiskFactors::from_analytics(&analytics);
2469        assert_eq!(factors.concentration, 6);
2470
2471        // 10-20% → 4
2472        analytics.holders[0].percentage = 15.0;
2473        let factors = RiskFactors::from_analytics(&analytics);
2474        assert_eq!(factors.concentration, 4);
2475
2476        // 5-10% → 2
2477        analytics.holders[0].percentage = 7.0;
2478        let factors = RiskFactors::from_analytics(&analytics);
2479        assert_eq!(factors.concentration, 2);
2480
2481        // < 5% → 1
2482        analytics.holders[0].percentage = 3.0;
2483        let factors = RiskFactors::from_analytics(&analytics);
2484        assert_eq!(factors.concentration, 1);
2485    }
2486
2487    #[test]
2488    fn test_risk_factors_social_one_social() {
2489        let mut analytics = create_test_analytics();
2490        analytics.socials = vec![TokenSocial {
2491            platform: "twitter".to_string(),
2492            url: "https://twitter.com/test".to_string(),
2493        }];
2494        analytics.websites = vec![];
2495        let factors = RiskFactors::from_analytics(&analytics);
2496        // 1 social = moderate social presence
2497        assert!(factors.social <= 5);
2498    }
2499
2500    #[test]
2501    fn test_format_number_large_values() {
2502        assert_eq!(format_number(1_500_000_000.0), "1500.00M");
2503        assert_eq!(format_number(500_000.0), "500K");
2504        assert_eq!(format_number(42.0), "42");
2505    }
2506}