161 lines
6.2 KiB
Rust
161 lines
6.2 KiB
Rust
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<String> = 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<String> {
|
|
scores.iter().filter(|s| s.score > 0.0).map(|s| s.detail.clone()).collect()
|
|
}
|
|
|
|
pub fn worries(data: &EtfData, scores: &[CategoryScore]) -> Vec<String> {
|
|
let mut items: Vec<String> = 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()),
|
|
}
|
|
}
|