Files
tickr/src/report/etf.rs
whitlocktech bb14b125a2
Some checks failed
Build and Release / build (push) Has been cancelled
v0.4.0
2026-05-17 01:23:37 -05:00

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()),
}
}