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