use crate::analysis::{CategoryScore, Decision}; use crate::data::EtfData; pub fn verdict(data: &EtfData, decision: &Decision) -> String { let name = data.fund_name.as_deref().unwrap_or(&data.symbol); format!("{} — {}", name, decision.label()) } pub fn what_fund_does(data: &EtfData) -> String { let name = data.fund_name.as_deref().unwrap_or(&data.symbol); let category = data.category.as_deref().unwrap_or("diversified"); if let Some(aum) = data.total_assets { format!( "{name} is a {category} ETF with ${:.1}B in assets under management.", aum / 1e9 ) } else { format!("{name} is a {category} ETF.") } } pub fn why_opinion(_data: &EtfData, scores: &[CategoryScore]) -> String { let mut drivers: Vec<(f64, &str)> = scores .iter() .filter(|s| s.score.abs() >= 1.0) .map(|s| (s.score * s.weight, s.detail.as_str())) .collect(); drivers.sort_by(|a, b| b.0.abs().partial_cmp(&a.0.abs()).unwrap()); let lines: Vec = drivers.iter().take(3).map(|(_, d)| d.to_string()).collect(); if lines.is_empty() { "No single dominant factor — score is borderline.".to_string() } else { lines.join(". ") + "." } } pub fn looks_good(_data: &EtfData, scores: &[CategoryScore]) -> Vec { scores.iter().filter(|s| s.score > 0.0).map(|s| s.detail.clone()).collect() } pub fn worries(data: &EtfData, scores: &[CategoryScore]) -> Vec { let mut items: Vec = scores .iter() .filter(|s| s.score < 0.0) .map(|s| s.detail.clone()) .collect(); if data.is_leveraged { items.push("⚠ Leveraged ETF — not suitable for long-term holding".to_string()); } if data.is_inverse { items.push("⚠ Inverse ETF — designed for short-term hedging only".to_string()); } items } pub fn dividend_check(data: &EtfData) -> String { match data.dividend_yield { Some(y) if y > 0.0 => format!("Distribution yield: {:.2}%", y * 100.0), _ => "This ETF does not currently distribute dividends or yield data is unavailable.".to_string(), } } pub fn performance_check(data: &EtfData) -> String { let mut parts = Vec::new(); if let Some(ytd) = data.ytd_return { parts.push(format!("YTD: {:.1}%", ytd)); } if let Some(y1) = data.one_year_return { parts.push(format!("1-year: {:.1}%", y1)); } if let Some(y3) = data.three_year_return { parts.push(format!("3-year: {:.1}%", y3)); } if parts.is_empty() { "Performance data unavailable.".to_string() } else { parts.join(", ") + "." } } pub fn expense_check(data: &EtfData) -> String { match data.expense_ratio { Some(er) => { let pct = er * 100.0; let context = if pct < 0.10 { "well below the industry average — very cost-efficient" } else if pct < 0.30 { "below the industry average — cost-efficient" } else if pct < 0.60 { "near the industry average" } else { "above average — this fee drag compounds significantly over time" }; format!("Annual expense ratio: {:.2}% — {}", pct, context) } None => "Expense ratio data unavailable.".to_string(), } } pub fn change_mind(_data: &EtfData, decision: &Decision) -> String { match decision { Decision::Buy => { "A BUY opinion would be reconsidered if the expense ratio increases, \ AUM drops significantly, or the fund strategy shifts to a more concentrated/leveraged approach.".to_string() } Decision::Pass | Decision::Sell => { "A more favorable opinion would require a lower expense ratio, \ broader diversification, or improved trailing returns relative to category peers.".to_string() } } } pub fn study_next(_data: &EtfData, scores: &[CategoryScore], name: &str) -> String { let weakest = scores.iter().min_by(|a, b| a.score.partial_cmp(&b.score).unwrap()); match weakest.map(|s| s.name) { Some("Expense Ratio") => format!("{name} should study how expense ratios compound over time and why even small differences matter significantly over a 20-year horizon."), Some("Diversification") => format!("{name} should explore the difference between concentrated sector ETFs and broad market index funds."), Some("AUM / Liquidity") => format!("{name} should learn about ETF liquidity risk and why fund size matters for buying and selling shares efficiently."), Some("Performance") => format!("{name} should study how to compare ETF returns against a benchmark index and what tracking error means."), _ => format!("{name} should explore how to compare ETFs across the same category using expense ratios, AUM, and trailing returns."), } } pub fn translation(data: &EtfData, decision: &Decision, name: &str) -> String { let fund = data.fund_name.as_deref().unwrap_or(&data.symbol); match decision { Decision::Buy => format!( "{name}, in plain English: {fund} appears to be a solid, cost-effective choice. \ The expense ratio is reasonable and the fund offers good diversification." ), Decision::Pass => format!( "{name}, in plain English: {fund} is neither clearly great nor clearly bad. \ There are some concerns worth noting, but nothing that makes it an obvious avoid." ), Decision::Sell => format!( "{name}, in plain English: {fund} has some meaningful drawbacks — \ likely a high expense ratio, concentrated holdings, or poor recent performance." ), } } pub fn final_reason(data: &EtfData, decision: &Decision, scores: &[CategoryScore]) -> String { if data.is_leveraged || data.is_inverse { return "Leveraged/inverse ETF structure makes it unsuitable for long-term investors".to_string(); } let top = scores.iter().max_by(|a, b| { (a.score * a.weight).abs().partial_cmp(&(b.score * b.weight).abs()).unwrap() }); match top { Some(s) => s.detail.clone(), None => format!("{} based on overall fund characteristics", decision.label()), } }