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