This commit is contained in:
160
src/report/etf.rs
Normal file
160
src/report/etf.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
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()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user