This commit is contained in:
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cargo build *)",
|
||||||
|
"Bash(curl -s -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36\" \"https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=quoteType\")",
|
||||||
|
"Bash(curl -s -c /tmp/yf_cookies.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://finance.yahoo.com/\" -o /dev/null -w \"%{http_code}\")",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://query2.finance.yahoo.com/v1/test/getcrumb\")",
|
||||||
|
"Bash(curl -v -c /tmp/yf_cookies2.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\" -H \"Accept-Language: en-US,en;q=0.5\" -L \"https://finance.yahoo.com/quote/AAPL/\" -o /dev/null)",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: */*\" -H \"Referer: https://finance.yahoo.com/\" \"https://query2.finance.yahoo.com/v1/test/getcrumb\")",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=quoteType&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(python3 -m json.tool)",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=financialData,summaryDetail,defaultKeyStatistics&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=assetProfile,earningsHistory,incomeStatementHistory,cashflowStatementHistory,balanceSheetHistory,calendarEvents,recommendationTrend&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=incomeStatementHistory&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=earningsHistory&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=cashflowStatementHistory,balanceSheetHistory,calendarEvents&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=financialData,recommendationTrend&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=incomeStatementHistoryQuarterly&crumb=t5.A45X0LQC')",
|
||||||
|
"Bash(cargo doc *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
*.exe
|
||||||
|
*.pdb
|
||||||
|
tickr_update.exe
|
||||||
|
tickr_swap.bat
|
||||||
|
**/.rs.bk
|
||||||
|
.env
|
||||||
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "tickr"
|
||||||
|
version = "0.4.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "tickr"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = { version = "0.27", features = ["default"] }
|
||||||
|
egui = "0.27"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls", "cookies"], default-features = false }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
anyhow = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "5"
|
||||||
|
semver = "1"
|
||||||
|
tray-icon = "0.14"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["ico"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.build-dependencies]
|
||||||
|
winres = "0.1"
|
||||||
497
src/analysis/equity.rs
Normal file
497
src/analysis/equity.rs
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::data::TickerData;
|
||||||
|
use super::{
|
||||||
|
hype, speculation, AnalysisResult, AnalystTrend, AnalystTrendSignal, CategoryScore,
|
||||||
|
Confidence, Decision, RiskLevel,
|
||||||
|
};
|
||||||
|
use crate::report;
|
||||||
|
|
||||||
|
static SECTOR_PE: &[(&str, f64)] = &[
|
||||||
|
("Technology", 28.0),
|
||||||
|
("Healthcare", 22.0),
|
||||||
|
("Financial Services", 14.0),
|
||||||
|
("Consumer Cyclical", 20.0),
|
||||||
|
("Communication Services", 18.0),
|
||||||
|
("Industrials", 20.0),
|
||||||
|
("Energy", 12.0),
|
||||||
|
("Consumer Defensive", 22.0),
|
||||||
|
("Real Estate", 35.0),
|
||||||
|
("Utilities", 18.0),
|
||||||
|
("Basic Materials", 14.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn sector_median_pe(sector: Option<&str>) -> (f64, bool) {
|
||||||
|
match sector {
|
||||||
|
Some(s) => SECTOR_PE
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == s)
|
||||||
|
.map(|(_, pe)| (*pe, false))
|
||||||
|
.unwrap_or((20.0, true)),
|
||||||
|
None => (20.0, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze_equity(data: &TickerData, config: &Config) -> AnalysisResult {
|
||||||
|
let (sector_pe, unknown_sector) = sector_median_pe(data.sector.as_deref());
|
||||||
|
|
||||||
|
let spec = speculation::evaluate(data);
|
||||||
|
let hype = hype::evaluate(data, sector_pe);
|
||||||
|
|
||||||
|
let scores: Vec<CategoryScore> = vec![
|
||||||
|
score_valuation(data, sector_pe),
|
||||||
|
score_revenue(data),
|
||||||
|
score_earnings(data),
|
||||||
|
score_fcf(data),
|
||||||
|
score_debt(data),
|
||||||
|
score_dividend(data),
|
||||||
|
score_analyst(data),
|
||||||
|
score_momentum(data),
|
||||||
|
];
|
||||||
|
|
||||||
|
let raw_weighted: f64 = scores.iter().map(|s| s.score * s.weight).sum();
|
||||||
|
|
||||||
|
let mut modifiers: f64 = 0.0;
|
||||||
|
modifiers -= spec.score_penalty;
|
||||||
|
modifiers -= hype.score_penalty;
|
||||||
|
|
||||||
|
let analyst_trend = compute_analyst_trend(data);
|
||||||
|
if let Some(ref trend) = analyst_trend {
|
||||||
|
modifiers += trend.score_delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// High beta at peak declining EPS
|
||||||
|
let perfect_timing_flag = data.beta.map_or(false, |b| b > 1.8)
|
||||||
|
&& {
|
||||||
|
let near_high = match (data.current_price, data.week_52_high) {
|
||||||
|
(Some(p), Some(h)) if h > 0.0 => (h - p) / h < 0.05,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
near_high
|
||||||
|
}
|
||||||
|
&& {
|
||||||
|
let eps = &data.eps_quarters;
|
||||||
|
eps.len() >= 2 && eps[0].1 < eps[1].1
|
||||||
|
};
|
||||||
|
if perfect_timing_flag {
|
||||||
|
modifiers -= 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_score = raw_weighted + modifiers;
|
||||||
|
|
||||||
|
// Adjust for risk tolerance config
|
||||||
|
let pass_zone_width = if config.preferences.avoid_borderline { 0.1 } else { 0.0 };
|
||||||
|
let buy_threshold = 0.6 + pass_zone_width;
|
||||||
|
let sell_threshold = -0.6 - pass_zone_width;
|
||||||
|
|
||||||
|
let force_pass = spec.force_pass
|
||||||
|
|| hype.force_pass
|
||||||
|
|| data.data_completeness_pct < 0.50;
|
||||||
|
|
||||||
|
let force_pass_reason = if spec.force_pass {
|
||||||
|
Some(format!(
|
||||||
|
"Multiple speculation flags: {}",
|
||||||
|
spec.flags.join(", ")
|
||||||
|
))
|
||||||
|
} else if hype.force_pass {
|
||||||
|
Some("Price peak + analyst divergence combination".to_string())
|
||||||
|
} else if data.data_completeness_pct < 0.50 {
|
||||||
|
Some("Insufficient data to make a reliable assessment".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = if force_pass {
|
||||||
|
Decision::Pass
|
||||||
|
} else if final_score >= buy_threshold {
|
||||||
|
Decision::Buy
|
||||||
|
} else if final_score <= sell_threshold {
|
||||||
|
Decision::Sell
|
||||||
|
} else {
|
||||||
|
Decision::Pass
|
||||||
|
};
|
||||||
|
|
||||||
|
let earnings_within_14 = data.next_earnings_date.map_or(false, |d| {
|
||||||
|
let today = Utc::now().date_naive();
|
||||||
|
(d - today).num_days().abs() <= 14
|
||||||
|
});
|
||||||
|
|
||||||
|
let confidence = compute_confidence(
|
||||||
|
data.data_completeness_pct,
|
||||||
|
final_score,
|
||||||
|
buy_threshold,
|
||||||
|
sell_threshold,
|
||||||
|
earnings_within_14 || unknown_sector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let risk_level = compute_risk(data, &spec.flags);
|
||||||
|
|
||||||
|
let result = AnalysisResult {
|
||||||
|
decision: decision.clone(),
|
||||||
|
confidence: confidence.clone(),
|
||||||
|
risk_level: risk_level.clone(),
|
||||||
|
raw_score: raw_weighted,
|
||||||
|
final_score,
|
||||||
|
force_pass_reason: force_pass_reason.clone(),
|
||||||
|
category_scores: scores.clone(),
|
||||||
|
speculation_flags: spec.flags.clone(),
|
||||||
|
hype_flags: hype.flags.clone(),
|
||||||
|
analyst_trend: analyst_trend.clone(),
|
||||||
|
|
||||||
|
section_verdict: report::equity::verdict(data, &decision, &confidence),
|
||||||
|
section_what_company_does: report::equity::what_company_does(data),
|
||||||
|
section_why_opinion: report::equity::why_opinion(data, &scores, &spec.flags, &hype.flags, &force_pass_reason),
|
||||||
|
section_looks_good: report::equity::looks_good(data, &scores),
|
||||||
|
section_worries: report::equity::worries(data, &scores, &spec.flags, &hype.flags, &analyst_trend),
|
||||||
|
section_dividend: report::equity::dividend_check(data),
|
||||||
|
section_growth: report::equity::growth_check(data),
|
||||||
|
section_valuation: report::equity::valuation_check(data, sector_pe),
|
||||||
|
section_change_mind: report::equity::change_mind(data, &decision, &scores, buy_threshold, sell_threshold),
|
||||||
|
section_study_next: report::equity::study_next(data, &scores, &config.user.name),
|
||||||
|
section_translation: report::equity::translation(data, &decision, &scores, &config.user.name),
|
||||||
|
section_final_answer: decision.label().to_string(),
|
||||||
|
section_final_reason: report::equity::final_reason(data, &decision, &scores, &spec.flags, &hype.flags),
|
||||||
|
};
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_valuation(data: &TickerData, sector_pe: f64) -> CategoryScore {
|
||||||
|
let effective_pe = match (data.pe_ratio, data.forward_pe) {
|
||||||
|
(Some(trailing), Some(forward)) if forward < trailing => (trailing + forward) / 2.0,
|
||||||
|
(Some(trailing), _) => trailing,
|
||||||
|
_ => {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Valuation",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.20,
|
||||||
|
detail: "P/E data unavailable".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ratio = effective_pe / sector_pe;
|
||||||
|
let (score, detail) = if ratio <= 0.7 {
|
||||||
|
(2.0, format!("P/E {:.1} is significantly below sector median {:.0} — undervalued", effective_pe, sector_pe))
|
||||||
|
} else if ratio <= 0.9 {
|
||||||
|
(1.0, format!("P/E {:.1} is modestly below sector median {:.0}", effective_pe, sector_pe))
|
||||||
|
} else if ratio <= 1.2 {
|
||||||
|
(0.0, format!("P/E {:.1} is in line with sector median {:.0}", effective_pe, sector_pe))
|
||||||
|
} else if ratio <= 1.6 {
|
||||||
|
(-1.0, format!("P/E {:.1} is modestly above sector median {:.0} — slightly overvalued", effective_pe, sector_pe))
|
||||||
|
} else {
|
||||||
|
(-2.0, format!("P/E {:.1} is significantly above sector median {:.0} — overvalued", effective_pe, sector_pe))
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Valuation", score, weight: 0.20, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_revenue(data: &TickerData) -> CategoryScore {
|
||||||
|
let quarters = &data.revenue_quarters;
|
||||||
|
if quarters.len() < 2 {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Revenue Trend",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.15,
|
||||||
|
detail: "Insufficient revenue data".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let growing_count = quarters.windows(2).filter(|w| w[0].1 > w[1].1).count();
|
||||||
|
let declining_count = quarters.windows(2).filter(|w| w[0].1 < w[1].1).count();
|
||||||
|
|
||||||
|
let (score, detail) = match (growing_count, declining_count, quarters.len()) {
|
||||||
|
(g, _, n) if g >= 3 && n >= 4 => (2.0, "Revenue growing in 3+ consecutive quarters".to_string()),
|
||||||
|
(g, _, _) if g >= 2 => (1.0, "Revenue growing in 2 consecutive quarters".to_string()),
|
||||||
|
(_, d, n) if d >= 3 && n >= 4 => (-2.0, "Revenue declining in 3+ consecutive quarters".to_string()),
|
||||||
|
(_, d, _) if d >= 2 => (-1.0, "Revenue declining in 2 consecutive quarters".to_string()),
|
||||||
|
_ => (0.0, "Revenue trend mixed or flat".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Revenue Trend", score, weight: 0.15, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_earnings(data: &TickerData) -> CategoryScore {
|
||||||
|
let quarters = &data.eps_quarters;
|
||||||
|
if quarters.len() < 2 {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Earnings Trend",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.15,
|
||||||
|
detail: "Insufficient EPS data".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let growing_count = quarters.windows(2).filter(|w| w[0].1 > w[1].1).count();
|
||||||
|
let declining_count = quarters.windows(2).filter(|w| w[0].1 < w[1].1).count();
|
||||||
|
|
||||||
|
let (score, detail) = match (growing_count, declining_count, quarters.len()) {
|
||||||
|
(g, _, n) if g >= 3 && n >= 4 => (2.0, "EPS growing in 3+ consecutive quarters".to_string()),
|
||||||
|
(g, _, _) if g >= 2 => (1.0, "EPS growing in 2 consecutive quarters".to_string()),
|
||||||
|
(_, d, n) if d >= 3 && n >= 4 => (-2.0, "EPS declining in 3+ consecutive quarters".to_string()),
|
||||||
|
(_, d, _) if d >= 2 => (-1.0, "EPS declining in 2 consecutive quarters".to_string()),
|
||||||
|
_ => (0.0, "EPS trend mixed or flat".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Earnings Trend", score, weight: 0.15, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_fcf(data: &TickerData) -> CategoryScore {
|
||||||
|
let fcf = match data.free_cash_flow_ttm {
|
||||||
|
Some(f) => f,
|
||||||
|
None => {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Free Cash Flow",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.15,
|
||||||
|
detail: "FCF data unavailable".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if fcf < 0.0 {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Free Cash Flow",
|
||||||
|
score: -1.0,
|
||||||
|
weight: 0.15,
|
||||||
|
detail: format!("Negative FCF: {}", format_currency(fcf)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let margin = data.revenue_ttm.filter(|&r| r > 0.0).map(|r| fcf / r * 100.0);
|
||||||
|
|
||||||
|
let (score, detail) = match margin {
|
||||||
|
Some(m) if m > 15.0 => (2.0, format!("Strong FCF of {} with {:.1}% margin", format_currency(fcf), m)),
|
||||||
|
Some(m) if m >= 5.0 => (1.0, format!("Positive FCF of {} with {:.1}% margin", format_currency(fcf), m)),
|
||||||
|
Some(m) => (0.0, format!("FCF positive but thin margin ({:.1}%)", m)),
|
||||||
|
None => (1.0, format!("Positive FCF: {}", format_currency(fcf))),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Free Cash Flow", score, weight: 0.15, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_debt(data: &TickerData) -> CategoryScore {
|
||||||
|
let de = data.debt_to_equity;
|
||||||
|
let cr = data.current_ratio;
|
||||||
|
|
||||||
|
let (score, detail) = match (de, cr) {
|
||||||
|
(Some(d), Some(c)) if d < 0.3 && c > 2.0 => (
|
||||||
|
2.0,
|
||||||
|
format!("Very low D/E of {:.2} and strong current ratio of {:.2}", d, c),
|
||||||
|
),
|
||||||
|
(Some(d), Some(c)) if d < 0.8 && c > 1.5 => (
|
||||||
|
1.0,
|
||||||
|
format!("Healthy D/E of {:.2} and current ratio of {:.2}", d, c),
|
||||||
|
),
|
||||||
|
(Some(d), _) if d > 2.5 => (
|
||||||
|
-2.0,
|
||||||
|
format!("Very high debt load: D/E of {:.2}", d),
|
||||||
|
),
|
||||||
|
(Some(_d), Some(c)) if c < 1.0 => (
|
||||||
|
-2.0,
|
||||||
|
format!("Current ratio of {:.2} — liquidity concern", c),
|
||||||
|
),
|
||||||
|
(Some(d), _) if d > 1.5 => (
|
||||||
|
-1.0,
|
||||||
|
format!("Elevated D/E ratio of {:.2}", d),
|
||||||
|
),
|
||||||
|
(Some(d), _) => (
|
||||||
|
0.0,
|
||||||
|
format!("Moderate D/E ratio of {:.2}", d),
|
||||||
|
),
|
||||||
|
_ => (0.0, "Debt data unavailable".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Debt Health", score, weight: 0.10, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_dividend(data: &TickerData) -> CategoryScore {
|
||||||
|
if data.dividend_cut_flag {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Dividend Safety",
|
||||||
|
score: -2.0,
|
||||||
|
weight: 0.10,
|
||||||
|
detail: "Dividend has been recently cut".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let yield_val = match data.dividend_yield {
|
||||||
|
Some(y) if y > 0.0 => y,
|
||||||
|
_ => {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Dividend Safety",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.10,
|
||||||
|
detail: "No dividend paid — neutral".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let payout = data.payout_ratio.unwrap_or(0.0);
|
||||||
|
let growing = data.dividend_5yr_growth.map_or(false, |g| g > 0.0);
|
||||||
|
|
||||||
|
let (score, detail) = if payout < 0.50 && growing {
|
||||||
|
(2.0, format!("Healthy {:.1}% yield with {:.0}% payout ratio and growing dividend", yield_val * 100.0, payout * 100.0))
|
||||||
|
} else if payout < 0.75 {
|
||||||
|
(1.0, format!("{:.1}% yield with sustainable {:.0}% payout ratio", yield_val * 100.0, payout * 100.0))
|
||||||
|
} else if payout < 0.90 {
|
||||||
|
(-1.0, format!("{:.1}% yield but elevated {:.0}% payout ratio", yield_val * 100.0, payout * 100.0))
|
||||||
|
} else {
|
||||||
|
(-2.0, format!("{:.1}% yield with unsustainable {:.0}% payout ratio", yield_val * 100.0, payout * 100.0))
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Dividend Safety", score, weight: 0.10, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_analyst(data: &TickerData) -> CategoryScore {
|
||||||
|
let snapshot = match &data.analyst_trend_current {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Analyst Sentiment",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.10,
|
||||||
|
detail: "No analyst data available".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ratio = snapshot.bullish_ratio();
|
||||||
|
let total = snapshot.strong_buy + snapshot.buy + snapshot.hold + snapshot.sell + snapshot.strong_sell;
|
||||||
|
|
||||||
|
let (score, detail) = if ratio > 0.75 {
|
||||||
|
(2.0, format!("Strong analyst consensus: {:.0}% bullish ({} analysts)", ratio * 100.0, total))
|
||||||
|
} else if ratio > 0.60 {
|
||||||
|
(1.0, format!("Majority bullish: {:.0}% buy ratings ({} analysts)", ratio * 100.0, total))
|
||||||
|
} else if ratio >= 0.40 {
|
||||||
|
(0.0, format!("Mixed analyst sentiment: {:.0}% bullish ({} analysts)", ratio * 100.0, total))
|
||||||
|
} else if ratio >= 0.20 {
|
||||||
|
(-1.0, format!("Weak analyst sentiment: only {:.0}% bullish ({} analysts)", ratio * 100.0, total))
|
||||||
|
} else {
|
||||||
|
(-2.0, format!("Strongly bearish analyst consensus: only {:.0}% bullish ({} analysts)", ratio * 100.0, total))
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Analyst Sentiment", score, weight: 0.10, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_momentum(data: &TickerData) -> CategoryScore {
|
||||||
|
let m1 = data.price_change_1m_pct;
|
||||||
|
let m3 = data.price_change_3m_pct;
|
||||||
|
|
||||||
|
let (score, detail) = match (m1, m3) {
|
||||||
|
(Some(a), Some(_b)) if a > 30.0 => {
|
||||||
|
(0.0, format!("1M +{:.1}% surge — hype engine evaluates this separately", a))
|
||||||
|
}
|
||||||
|
(Some(a), Some(b)) if a > 0.0 && b > 0.0 => {
|
||||||
|
(1.0, format!("Positive momentum: +{:.1}% (1M), +{:.1}% (3M)", a, b))
|
||||||
|
}
|
||||||
|
(Some(a), Some(b)) if a < 0.0 && b < 0.0 => {
|
||||||
|
(-1.0, format!("Negative momentum: {:.1}% (1M), {:.1}% (3M)", a, b))
|
||||||
|
}
|
||||||
|
(Some(a), Some(b)) => {
|
||||||
|
(0.0, format!("Mixed momentum: {:.1}% (1M), {:.1}% (3M)", a, b))
|
||||||
|
}
|
||||||
|
(Some(a), None) => {
|
||||||
|
if a > 0.0 { (1.0, format!("+{:.1}% over past month", a)) }
|
||||||
|
else { (-1.0, format!("{:.1}% over past month", a)) }
|
||||||
|
}
|
||||||
|
_ => (0.0, "Momentum data unavailable".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Momentum", score, weight: 0.05, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_analyst_trend(data: &TickerData) -> Option<AnalystTrend> {
|
||||||
|
let current = data.analyst_trend_current.as_ref()?;
|
||||||
|
let two_ago = data.analyst_trend_2m_ago.as_ref()?;
|
||||||
|
|
||||||
|
let ratio_now = current.bullish_ratio();
|
||||||
|
let ratio_2m = two_ago.bullish_ratio();
|
||||||
|
let delta = ratio_now - ratio_2m;
|
||||||
|
|
||||||
|
let (signal, score_delta) = if delta >= 0.15 {
|
||||||
|
(AnalystTrendSignal::RapidlyUpgrading, 1.0)
|
||||||
|
} else if delta >= 0.05 {
|
||||||
|
(AnalystTrendSignal::Upgrading, 0.5)
|
||||||
|
} else if delta.abs() < 0.05 {
|
||||||
|
(AnalystTrendSignal::Stable, 0.0)
|
||||||
|
} else if delta <= -0.15 {
|
||||||
|
(AnalystTrendSignal::RapidlyDowngrading, -1.0)
|
||||||
|
} else {
|
||||||
|
(AnalystTrendSignal::Downgrading, -0.5)
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(AnalystTrend { signal, score_delta })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_confidence(
|
||||||
|
completeness: f64,
|
||||||
|
score: f64,
|
||||||
|
buy_thresh: f64,
|
||||||
|
sell_thresh: f64,
|
||||||
|
special_caution: bool,
|
||||||
|
) -> Confidence {
|
||||||
|
let borderline = (score - buy_thresh).abs() < 0.1 || (score - sell_thresh).abs() < 0.1;
|
||||||
|
|
||||||
|
if completeness >= 0.85 && !borderline && !special_caution {
|
||||||
|
Confidence::High
|
||||||
|
} else if completeness >= 0.60 && (!borderline && !special_caution) {
|
||||||
|
Confidence::Medium
|
||||||
|
} else {
|
||||||
|
Confidence::Low
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_risk(data: &TickerData, spec_flags: &[String]) -> RiskLevel {
|
||||||
|
let mut high_triggers = 0u32;
|
||||||
|
|
||||||
|
if data.debt_to_equity.map_or(false, |d| d > 2.0) { high_triggers += 1; }
|
||||||
|
if data.free_cash_flow_ttm.map_or(false, |f| f < 0.0) { high_triggers += 1; }
|
||||||
|
if data.eps_quarters.len() >= 3 {
|
||||||
|
let declining = data.eps_quarters.windows(2).all(|w| w[0].1 < w[1].1);
|
||||||
|
if declining { high_triggers += 1; }
|
||||||
|
}
|
||||||
|
if data.payout_ratio.map_or(false, |p| p > 0.90) { high_triggers += 1; }
|
||||||
|
if let (Some(p), Some(h)) = (data.current_price, data.week_52_high) {
|
||||||
|
if h > 0.0 && (h - p) / h < 0.05 { high_triggers += 1; }
|
||||||
|
}
|
||||||
|
if data.beta.map_or(false, |b| b > 1.8) { high_triggers += 1; }
|
||||||
|
if !spec_flags.is_empty() { high_triggers += 1; }
|
||||||
|
|
||||||
|
if high_triggers >= 2 {
|
||||||
|
return RiskLevel::High;
|
||||||
|
}
|
||||||
|
|
||||||
|
let low_conditions = [
|
||||||
|
data.free_cash_flow_ttm.map_or(false, |f| f > 0.0),
|
||||||
|
data.debt_to_equity.map_or(false, |d| d < 0.5),
|
||||||
|
data.dividend_5yr_growth.map_or(false, |g| g > 0.0),
|
||||||
|
data.analyst_trend_current
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |s| s.bullish_ratio() > 0.6),
|
||||||
|
matches!(
|
||||||
|
(data.current_price, data.week_52_high, data.week_52_low),
|
||||||
|
(Some(p), Some(h), Some(l)) if h > l && p < l + (h - l) * 0.5
|
||||||
|
),
|
||||||
|
data.beta.map_or(false, |b| b < 1.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
if low_conditions.iter().all(|&c| c) {
|
||||||
|
RiskLevel::Low
|
||||||
|
} else {
|
||||||
|
RiskLevel::Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_currency(val: f64) -> String {
|
||||||
|
let abs = val.abs();
|
||||||
|
let sign = if val < 0.0 { "-" } else { "" };
|
||||||
|
if abs >= 1_000_000_000.0 {
|
||||||
|
format!("{sign}${:.2}B", abs / 1_000_000_000.0)
|
||||||
|
} else if abs >= 1_000_000.0 {
|
||||||
|
format!("{sign}${:.1}M", abs / 1_000_000.0)
|
||||||
|
} else {
|
||||||
|
format!("{sign}${:.0}", abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/analysis/etf.rs
Normal file
259
src/analysis/etf.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::data::EtfData;
|
||||||
|
use super::{AnalysisResult, CategoryScore, Confidence, Decision, RiskLevel};
|
||||||
|
use crate::report;
|
||||||
|
|
||||||
|
pub fn analyze_etf(data: &EtfData, config: &Config) -> AnalysisResult {
|
||||||
|
// Auto-PASS triggers
|
||||||
|
let force_pass_reason = if data.is_leveraged || data.is_inverse {
|
||||||
|
Some("Leveraged/inverse ETFs are not suitable for long-term investors".to_string())
|
||||||
|
} else if data.total_assets.map_or(false, |a| a < 50_000_000.0) {
|
||||||
|
Some("AUM under $50M — liquidity risk".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let scores = vec![
|
||||||
|
score_expense(data),
|
||||||
|
score_diversification(data),
|
||||||
|
score_etf_dividend(data),
|
||||||
|
score_etf_performance(data),
|
||||||
|
score_aum(data),
|
||||||
|
score_category(data),
|
||||||
|
];
|
||||||
|
|
||||||
|
let raw_weighted: f64 = scores.iter().map(|s| s.score * s.weight).sum();
|
||||||
|
let final_score = raw_weighted;
|
||||||
|
|
||||||
|
let pass_zone = if config.preferences.avoid_borderline { 0.1 } else { 0.0 };
|
||||||
|
let buy_thresh = 0.6 + pass_zone;
|
||||||
|
let sell_thresh = -0.6 - pass_zone;
|
||||||
|
|
||||||
|
let force_pass = force_pass_reason.is_some()
|
||||||
|
|| data.data_completeness_pct < 0.50;
|
||||||
|
|
||||||
|
let final_force_reason = force_pass_reason.clone().or_else(|| {
|
||||||
|
if data.data_completeness_pct < 0.50 {
|
||||||
|
Some("Insufficient data for reliable assessment".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let decision = if force_pass {
|
||||||
|
Decision::Pass
|
||||||
|
} else if final_score >= buy_thresh {
|
||||||
|
Decision::Buy
|
||||||
|
} else if final_score <= sell_thresh {
|
||||||
|
Decision::Sell
|
||||||
|
} else {
|
||||||
|
Decision::Pass
|
||||||
|
};
|
||||||
|
|
||||||
|
let confidence = if data.data_completeness_pct >= 0.85 {
|
||||||
|
Confidence::High
|
||||||
|
} else if data.data_completeness_pct >= 0.60 {
|
||||||
|
Confidence::Medium
|
||||||
|
} else {
|
||||||
|
Confidence::Low
|
||||||
|
};
|
||||||
|
|
||||||
|
let risk_level = compute_etf_risk(data);
|
||||||
|
|
||||||
|
AnalysisResult {
|
||||||
|
decision: decision.clone(),
|
||||||
|
confidence,
|
||||||
|
risk_level,
|
||||||
|
raw_score: raw_weighted,
|
||||||
|
final_score,
|
||||||
|
force_pass_reason: final_force_reason,
|
||||||
|
category_scores: scores.clone(),
|
||||||
|
speculation_flags: vec![],
|
||||||
|
hype_flags: vec![],
|
||||||
|
analyst_trend: None,
|
||||||
|
section_verdict: report::etf::verdict(data, &decision),
|
||||||
|
section_what_company_does: report::etf::what_fund_does(data),
|
||||||
|
section_why_opinion: report::etf::why_opinion(data, &scores),
|
||||||
|
section_looks_good: report::etf::looks_good(data, &scores),
|
||||||
|
section_worries: report::etf::worries(data, &scores),
|
||||||
|
section_dividend: report::etf::dividend_check(data),
|
||||||
|
section_growth: report::etf::performance_check(data),
|
||||||
|
section_valuation: report::etf::expense_check(data),
|
||||||
|
section_change_mind: report::etf::change_mind(data, &decision),
|
||||||
|
section_study_next: report::etf::study_next(data, &scores, &config.user.name),
|
||||||
|
section_translation: report::etf::translation(data, &decision, &config.user.name),
|
||||||
|
section_final_answer: decision.label().to_string(),
|
||||||
|
section_final_reason: report::etf::final_reason(data, &decision, &scores),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_expense(data: &EtfData) -> CategoryScore {
|
||||||
|
let er = match data.expense_ratio {
|
||||||
|
Some(e) => e,
|
||||||
|
None => {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Expense Ratio",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.25,
|
||||||
|
detail: "Expense ratio unavailable".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pct = er * 100.0;
|
||||||
|
let (score, detail) = if pct < 0.10 {
|
||||||
|
(2.0, format!("Excellent expense ratio of {:.2}%", pct))
|
||||||
|
} else if pct < 0.30 {
|
||||||
|
(1.0, format!("Low expense ratio of {:.2}%", pct))
|
||||||
|
} else if pct < 0.60 {
|
||||||
|
(0.0, format!("Moderate expense ratio of {:.2}%", pct))
|
||||||
|
} else if pct < 1.0 {
|
||||||
|
(-1.0, format!("High expense ratio of {:.2}%", pct))
|
||||||
|
} else {
|
||||||
|
(-2.0, format!("Very high expense ratio of {:.2}% — significant drag on returns", pct))
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Expense Ratio", score, weight: 0.25, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_diversification(data: &EtfData) -> CategoryScore {
|
||||||
|
let conc = match data.top_10_concentration {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
return CategoryScore {
|
||||||
|
name: "Diversification",
|
||||||
|
score: 0.0,
|
||||||
|
weight: 0.20,
|
||||||
|
detail: "Holdings data unavailable".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (score, detail) = if conc < 25.0 {
|
||||||
|
(2.0, format!("Highly diversified — top 10 holdings are only {:.1}% of fund", conc))
|
||||||
|
} else if conc < 40.0 {
|
||||||
|
(1.0, format!("Well diversified — top 10 holdings at {:.1}%", conc))
|
||||||
|
} else if conc < 55.0 {
|
||||||
|
(0.0, format!("Moderate concentration — top 10 holdings at {:.1}%", conc))
|
||||||
|
} else if conc < 70.0 {
|
||||||
|
(-1.0, format!("Concentrated fund — top 10 holdings at {:.1}%", conc))
|
||||||
|
} else {
|
||||||
|
(-2.0, format!("Highly concentrated — top 10 holdings at {:.1}% of fund", conc))
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Diversification", score, weight: 0.20, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_etf_dividend(data: &EtfData) -> CategoryScore {
|
||||||
|
let (score, detail) = match data.dividend_yield {
|
||||||
|
Some(y) if y > 0.0 => {
|
||||||
|
let pct = y * 100.0;
|
||||||
|
if pct > 4.0 {
|
||||||
|
(2.0, format!("Attractive dividend yield of {:.2}%", pct))
|
||||||
|
} else if pct > 2.0 {
|
||||||
|
(1.0, format!("Moderate dividend yield of {:.2}%", pct))
|
||||||
|
} else {
|
||||||
|
(0.0, format!("Low dividend yield of {:.2}%", pct))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (0.0, "No dividend yield data — may be growth-oriented".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Dividend Yield", score, weight: 0.15, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_etf_performance(data: &EtfData) -> CategoryScore {
|
||||||
|
let (score, detail) = match (data.one_year_return, data.three_year_return) {
|
||||||
|
(Some(y1), Some(y3)) => {
|
||||||
|
let avg = (y1 + y3) / 2.0;
|
||||||
|
if avg > 15.0 {
|
||||||
|
(2.0, format!("Strong performance: +{:.1}% (1Y), +{:.1}% (3Y)", y1, y3))
|
||||||
|
} else if avg > 5.0 {
|
||||||
|
(1.0, format!("Positive returns: +{:.1}% (1Y), +{:.1}% (3Y)", y1, y3))
|
||||||
|
} else if avg > 0.0 {
|
||||||
|
(0.0, format!("Modest returns: {:.1}% (1Y), {:.1}% (3Y)", y1, y3))
|
||||||
|
} else {
|
||||||
|
(-1.0, format!("Weak performance: {:.1}% (1Y), {:.1}% (3Y)", y1, y3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(y1), None) => {
|
||||||
|
if y1 > 10.0 { (1.0, format!("1-year return: +{:.1}%", y1)) }
|
||||||
|
else if y1 > 0.0 { (0.0, format!("1-year return: +{:.1}%", y1)) }
|
||||||
|
else { (-1.0, format!("1-year return: {:.1}%", y1)) }
|
||||||
|
}
|
||||||
|
_ => (0.0, "Performance data unavailable".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Performance", score, weight: 0.15, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_aum(data: &EtfData) -> CategoryScore {
|
||||||
|
let (score, detail) = match data.total_assets {
|
||||||
|
Some(a) if a >= 10_000_000_000.0 => (
|
||||||
|
2.0,
|
||||||
|
format!("Large, highly liquid fund: ${:.1}B AUM", a / 1e9),
|
||||||
|
),
|
||||||
|
Some(a) if a >= 1_000_000_000.0 => (
|
||||||
|
1.0,
|
||||||
|
format!("Well-established fund: ${:.1}B AUM", a / 1e9),
|
||||||
|
),
|
||||||
|
Some(a) if a >= 100_000_000.0 => (
|
||||||
|
0.0,
|
||||||
|
format!("Adequate AUM: ${:.0}M", a / 1e6),
|
||||||
|
),
|
||||||
|
Some(a) if a >= 50_000_000.0 => (
|
||||||
|
-1.0,
|
||||||
|
format!("Small fund: ${:.0}M AUM — limited liquidity", a / 1e6),
|
||||||
|
),
|
||||||
|
Some(a) => (
|
||||||
|
-2.0,
|
||||||
|
format!("Very small fund: ${:.0}M AUM — significant liquidity risk", a / 1e6),
|
||||||
|
),
|
||||||
|
None => (0.0, "AUM data unavailable".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "AUM / Liquidity", score, weight: 0.10, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_category(data: &EtfData) -> CategoryScore {
|
||||||
|
let name_lower = data.fund_name.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let cat_lower = data.category.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let combined = format!("{} {}", name_lower, cat_lower);
|
||||||
|
|
||||||
|
let is_broad_market = combined.contains("s&p 500")
|
||||||
|
|| combined.contains("total market")
|
||||||
|
|| combined.contains("broad")
|
||||||
|
|| combined.contains("russell")
|
||||||
|
|| cat_lower.contains("large blend");
|
||||||
|
|
||||||
|
let is_niche = combined.contains("sector")
|
||||||
|
|| combined.contains("thematic")
|
||||||
|
|| cat_lower.contains("technology")
|
||||||
|
|| cat_lower.contains("energy");
|
||||||
|
|
||||||
|
let (score, detail) = if is_broad_market {
|
||||||
|
(2.0, "Broad market index ETF — beginner-friendly, diversified exposure".to_string())
|
||||||
|
} else if is_niche {
|
||||||
|
(-1.0, "Sector/thematic ETF — concentrated risk in specific area".to_string())
|
||||||
|
} else {
|
||||||
|
(0.0, format!("Category: {}", data.category.as_deref().unwrap_or("Unknown")))
|
||||||
|
};
|
||||||
|
|
||||||
|
CategoryScore { name: "Category Fit", score, weight: 0.15, detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_etf_risk(data: &EtfData) -> RiskLevel {
|
||||||
|
if data.is_leveraged || data.is_inverse {
|
||||||
|
return RiskLevel::High;
|
||||||
|
}
|
||||||
|
if data.total_assets.map_or(false, |a| a < 100_000_000.0) {
|
||||||
|
return RiskLevel::High;
|
||||||
|
}
|
||||||
|
if data.expense_ratio.map_or(false, |e| e > 0.006) {
|
||||||
|
return RiskLevel::Medium;
|
||||||
|
}
|
||||||
|
if data.top_10_concentration.map_or(false, |c| c > 60.0) {
|
||||||
|
return RiskLevel::Medium;
|
||||||
|
}
|
||||||
|
RiskLevel::Low
|
||||||
|
}
|
||||||
92
src/analysis/hype.rs
Normal file
92
src/analysis/hype.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use crate::data::TickerData;
|
||||||
|
|
||||||
|
pub struct HypeResult {
|
||||||
|
pub flags: Vec<String>,
|
||||||
|
pub score_penalty: f64,
|
||||||
|
pub force_pass: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(data: &TickerData, sector_median_pe: f64) -> HypeResult {
|
||||||
|
let mut flags = Vec::new();
|
||||||
|
let mut force_pass = false;
|
||||||
|
|
||||||
|
let near_52w_high = match (data.current_price, data.week_52_high) {
|
||||||
|
(Some(p), Some(h)) if h > 0.0 => (h - p) / h < 0.05,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// PRICE_PEAK_NO_EARNINGS
|
||||||
|
let pe_over_2x = data.pe_ratio.map_or(false, |pe| pe > 2.0 * sector_median_pe);
|
||||||
|
if near_52w_high && pe_over_2x {
|
||||||
|
flags.push("Price near peak with weak earnings".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// MOMENTUM_NO_FUNDAMENTALS
|
||||||
|
let momentum_spike = data.price_change_1m_pct.map_or(false, |c| c > 30.0);
|
||||||
|
let eps_flat_or_declining = {
|
||||||
|
let quarters = &data.eps_quarters;
|
||||||
|
if quarters.len() >= 2 {
|
||||||
|
quarters[0].1 <= quarters[1].1
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if momentum_spike && eps_flat_or_declining {
|
||||||
|
flags.push("Price surge with flat/declining earnings".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANALYST_DIVERGENCE
|
||||||
|
let analyst_cautious = data
|
||||||
|
.analyst_consensus
|
||||||
|
.as_deref()
|
||||||
|
.map_or(false, |c| matches!(c, "hold" | "sell" | "underperform" | "underweight"));
|
||||||
|
if analyst_cautious && near_52w_high {
|
||||||
|
flags.push("Analysts cautious despite price surge".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// VALUATION_EXTREME
|
||||||
|
let pe_over_3x = data.pe_ratio.map_or(false, |pe| pe > 3.0 * sector_median_pe);
|
||||||
|
let low_revenue_growth = {
|
||||||
|
let quarters = &data.revenue_quarters;
|
||||||
|
if quarters.len() >= 4 {
|
||||||
|
let recent = quarters[0].1;
|
||||||
|
let year_ago = quarters[3].1;
|
||||||
|
if year_ago > 0.0 {
|
||||||
|
(recent - year_ago) / year_ago * 100.0 < 10.0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if pe_over_3x && low_revenue_growth {
|
||||||
|
flags.push("Extreme valuation with low growth".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// HIGH_BETA_OVERPRICED
|
||||||
|
let high_beta = data.beta.map_or(false, |b| b > 1.8);
|
||||||
|
let pe_over_1_5x = data.pe_ratio.map_or(false, |pe| pe > 1.5 * sector_median_pe);
|
||||||
|
if high_beta && pe_over_1_5x && near_52w_high {
|
||||||
|
flags.push("High volatility stock overpriced near peak".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force pass combo
|
||||||
|
let has_peak_no_earnings = flags
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.contains("Price near peak with weak earnings"));
|
||||||
|
let has_analyst_divergence = flags
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.contains("Analysts cautious"));
|
||||||
|
if has_peak_no_earnings && has_analyst_divergence {
|
||||||
|
force_pass = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let score_penalty = match flags.len() {
|
||||||
|
0 => 0.0,
|
||||||
|
1 => 0.3,
|
||||||
|
_ => 0.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
HypeResult { flags, score_penalty, force_pass }
|
||||||
|
}
|
||||||
138
src/analysis/mod.rs
Normal file
138
src/analysis/mod.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use crate::data::AnalysisInput;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub mod equity;
|
||||||
|
pub mod etf;
|
||||||
|
pub mod speculation;
|
||||||
|
pub mod hype;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
Pass,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decision {
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Decision::Buy => "BUY",
|
||||||
|
Decision::Sell => "SELL",
|
||||||
|
Decision::Pass => "PASS",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn color(&self) -> egui::Color32 {
|
||||||
|
match self {
|
||||||
|
Decision::Buy => egui::Color32::from_rgb(39, 174, 96),
|
||||||
|
Decision::Sell => egui::Color32::from_rgb(231, 76, 60),
|
||||||
|
Decision::Pass => egui::Color32::from_rgb(241, 196, 15),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Confidence {
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Confidence {
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Confidence::High => "High",
|
||||||
|
Confidence::Medium => "Medium",
|
||||||
|
Confidence::Low => "Low",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum RiskLevel {
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RiskLevel {
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
RiskLevel::High => "High",
|
||||||
|
RiskLevel::Medium => "Medium",
|
||||||
|
RiskLevel::Low => "Low",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnalystTrend {
|
||||||
|
pub signal: AnalystTrendSignal,
|
||||||
|
pub score_delta: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum AnalystTrendSignal {
|
||||||
|
RapidlyUpgrading,
|
||||||
|
Upgrading,
|
||||||
|
Stable,
|
||||||
|
Downgrading,
|
||||||
|
RapidlyDowngrading,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalystTrendSignal {
|
||||||
|
pub fn arrow(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AnalystTrendSignal::RapidlyUpgrading => "↑↑",
|
||||||
|
AnalystTrendSignal::Upgrading => "↑",
|
||||||
|
AnalystTrendSignal::Stable => "→",
|
||||||
|
AnalystTrendSignal::Downgrading => "↓",
|
||||||
|
AnalystTrendSignal::RapidlyDowngrading => "↓↓",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CategoryScore {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub score: f64, // -2 to +2
|
||||||
|
pub weight: f64, // 0.0 to 1.0
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnalysisResult {
|
||||||
|
pub decision: Decision,
|
||||||
|
pub confidence: Confidence,
|
||||||
|
pub risk_level: RiskLevel,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub raw_score: f64,
|
||||||
|
pub final_score: f64,
|
||||||
|
pub force_pass_reason: Option<String>,
|
||||||
|
|
||||||
|
pub category_scores: Vec<CategoryScore>,
|
||||||
|
pub speculation_flags: Vec<String>,
|
||||||
|
pub hype_flags: Vec<String>,
|
||||||
|
pub analyst_trend: Option<AnalystTrend>,
|
||||||
|
|
||||||
|
pub section_verdict: String,
|
||||||
|
pub section_what_company_does: String,
|
||||||
|
pub section_why_opinion: String,
|
||||||
|
pub section_looks_good: Vec<String>,
|
||||||
|
pub section_worries: Vec<String>,
|
||||||
|
pub section_dividend: String,
|
||||||
|
pub section_growth: String,
|
||||||
|
pub section_valuation: String,
|
||||||
|
pub section_change_mind: String,
|
||||||
|
pub section_study_next: String,
|
||||||
|
pub section_translation: String,
|
||||||
|
pub section_final_answer: String,
|
||||||
|
pub section_final_reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze(input: &AnalysisInput, config: &Config) -> AnalysisResult {
|
||||||
|
match input {
|
||||||
|
AnalysisInput::Equity(data) => equity::analyze_equity(data, config),
|
||||||
|
AnalysisInput::Etf(data) => etf::analyze_etf(data, config),
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/analysis/speculation.rs
Normal file
61
src/analysis/speculation.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use crate::data::TickerData;
|
||||||
|
|
||||||
|
pub struct SpeculationResult {
|
||||||
|
pub flags: Vec<String>,
|
||||||
|
pub score_penalty: f64,
|
||||||
|
pub force_pass: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(data: &TickerData) -> SpeculationResult {
|
||||||
|
let mut flags = Vec::new();
|
||||||
|
|
||||||
|
if data.revenue_ttm.map_or(true, |r| r <= 0.0) {
|
||||||
|
flags.push("Pre-revenue company".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.market_cap.map_or(false, |mc| mc < 500_000_000.0)
|
||||||
|
&& data.eps_quarters.first().map_or(false, |(_, eps)| *eps < 0.0)
|
||||||
|
{
|
||||||
|
flags.push("Micro-cap with negative earnings".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let price_sales = match (data.current_price, data.market_cap, data.revenue_ttm) {
|
||||||
|
(Some(_p), Some(mc), Some(rev)) if rev > 0.0 => Some(mc / rev),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let net_income_negative = data.eps_quarters.first().map_or(false, |(_, e)| *e < 0.0);
|
||||||
|
if price_sales.map_or(false, |ps| ps > 20.0) && net_income_negative {
|
||||||
|
flags.push("Extreme valuation with negative income".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.eps_quarters.len() < 4 {
|
||||||
|
flags.push("Recent IPO (limited earnings history)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.free_cash_flow_ttm.map_or(false, |fcf| fcf < 0.0) {
|
||||||
|
flags.push("Negative free cash flow".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.current_price.map_or(false, |p| p < 5.0) {
|
||||||
|
flags.push("Penny stock (<$5)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_biotech = data.sector.as_deref() == Some("Healthcare")
|
||||||
|
&& data.industry.as_deref().map_or(false, |i| i.contains("Biotechnology"));
|
||||||
|
if is_biotech
|
||||||
|
&& data.revenue_ttm.map_or(true, |r| r < 10_000_000.0)
|
||||||
|
&& data.free_cash_flow_ttm.map_or(true, |f| f < 0.0)
|
||||||
|
{
|
||||||
|
flags.push("Biotech with no commercial product".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = flags.len();
|
||||||
|
let (score_penalty, force_pass) = match count {
|
||||||
|
0 => (0.0, false),
|
||||||
|
1 => (0.3, false),
|
||||||
|
2 => (0.6, false),
|
||||||
|
_ => (0.6, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
SpeculationResult { flags, score_penalty, force_pass }
|
||||||
|
}
|
||||||
1226
src/app.rs
Normal file
1226
src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/assets/icon.ico
Normal file
BIN
src/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
129
src/config.rs
Normal file
129
src/config.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub user: UserConfig,
|
||||||
|
pub preferences: Preferences,
|
||||||
|
pub cache: CacheConfig,
|
||||||
|
pub output: OutputConfig,
|
||||||
|
pub discord: DiscordConfig,
|
||||||
|
pub watchlist_check: WatchlistCheckConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updater: UpdaterConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UserConfig {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Preferences {
|
||||||
|
pub default_risk_tolerance: String,
|
||||||
|
pub dividend_focus: bool,
|
||||||
|
pub avoid_borderline: bool,
|
||||||
|
pub start_minimized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CacheConfig {
|
||||||
|
pub ttl_minutes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OutputConfig {
|
||||||
|
pub auto_save: bool,
|
||||||
|
pub save_directory: String,
|
||||||
|
pub save_format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DiscordConfig {
|
||||||
|
pub webhook_url: String,
|
||||||
|
pub notify_on_change: bool,
|
||||||
|
pub notify_on_drift: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WatchlistCheckConfig {
|
||||||
|
pub interval_minutes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdaterConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub auto_update: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
user: UserConfig { name: "Investor".to_string() },
|
||||||
|
preferences: Preferences {
|
||||||
|
default_risk_tolerance: "low".to_string(),
|
||||||
|
dividend_focus: false,
|
||||||
|
avoid_borderline: true,
|
||||||
|
start_minimized: false,
|
||||||
|
},
|
||||||
|
cache: CacheConfig { ttl_minutes: 15 },
|
||||||
|
output: OutputConfig {
|
||||||
|
auto_save: false,
|
||||||
|
save_directory: String::new(),
|
||||||
|
save_format: "txt".to_string(),
|
||||||
|
},
|
||||||
|
discord: DiscordConfig {
|
||||||
|
webhook_url: String::new(),
|
||||||
|
notify_on_change: true,
|
||||||
|
notify_on_drift: false,
|
||||||
|
},
|
||||||
|
watchlist_check: WatchlistCheckConfig {
|
||||||
|
interval_minutes: 60,
|
||||||
|
},
|
||||||
|
updater: UpdaterConfig {
|
||||||
|
enabled: true,
|
||||||
|
auto_update: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UpdaterConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { enabled: true, auto_update: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_local_dir().map(|d| d.join("tickr").join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Config {
|
||||||
|
load_inner().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_inner() -> Result<Config> {
|
||||||
|
let path = config_path().ok_or_else(|| anyhow::anyhow!("No config dir"))?;
|
||||||
|
let text = std::fs::read_to_string(&path)?;
|
||||||
|
let config: Config = toml::from_str(&text)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(config: &Config) -> Result<()> {
|
||||||
|
let path = config_path().ok_or_else(|| anyhow::anyhow!("No config dir"))?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let text = toml::to_string_pretty(config)?;
|
||||||
|
std::fs::write(path, text)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_exists() -> Config {
|
||||||
|
let config = load();
|
||||||
|
if let Err(e) = save(&config) {
|
||||||
|
eprintln!("Could not save config: {e}");
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
47
src/data/cache.rs
Normal file
47
src/data/cache.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::AnalysisInput;
|
||||||
|
|
||||||
|
pub struct CacheStore {
|
||||||
|
entries: HashMap<String, AnalysisInput>,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheStore {
|
||||||
|
pub fn new(ttl_minutes: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
ttl: Duration::minutes(ttl_minutes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, symbol: &str) -> Option<&AnalysisInput> {
|
||||||
|
let entry = self.entries.get(&symbol.to_uppercase())?;
|
||||||
|
let age = Utc::now() - entry.fetched_at();
|
||||||
|
if age < self.ttl {
|
||||||
|
Some(entry)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_stale(&self, symbol: &str) -> Option<&AnalysisInput> {
|
||||||
|
self.entries.get(&symbol.to_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, data: AnalysisInput) {
|
||||||
|
self.entries.insert(data.symbol().to_uppercase(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn invalidate(&mut self, symbol: &str) {
|
||||||
|
self.entries.remove(&symbol.to_uppercase());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn age_minutes(&self, symbol: &str) -> Option<i64> {
|
||||||
|
let entry = self.entries.get(&symbol.to_uppercase())?;
|
||||||
|
let age = Utc::now() - entry.fetched_at();
|
||||||
|
Some(age.num_minutes())
|
||||||
|
}
|
||||||
|
}
|
||||||
565
src/data/fetch.rs
Normal file
565
src/data/fetch.rs
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use chrono::Utc;
|
||||||
|
use reqwest::{Client, ClientBuilder};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::{AnalysisInput, AnalystSnapshot, AssetType, EtfData, TickerData};
|
||||||
|
|
||||||
|
const BASE_SUMMARY: &str = "https://query1.finance.yahoo.com/v10/finance/quoteSummary";
|
||||||
|
const BASE_CHART: &str = "https://query1.finance.yahoo.com/v8/finance/chart";
|
||||||
|
const BASE_SEARCH: &str = "https://query1.finance.yahoo.com/v1/finance/search";
|
||||||
|
const CRUMB_URL: &str = "https://query2.finance.yahoo.com/v1/test/getcrumb";
|
||||||
|
|
||||||
|
struct YahooSession {
|
||||||
|
client: Client,
|
||||||
|
crumb: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YahooSession {
|
||||||
|
async fn init(symbol: &str) -> Result<Self> {
|
||||||
|
let client = ClientBuilder::new()
|
||||||
|
.cookie_store(true)
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// Visit a Yahoo Finance quote page to obtain session cookies
|
||||||
|
client
|
||||||
|
.get(format!("https://finance.yahoo.com/quote/{symbol}/"))
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to load Yahoo Finance (cookie init)")?;
|
||||||
|
|
||||||
|
// Fetch crumb using established session cookies
|
||||||
|
let crumb = client
|
||||||
|
.get(CRUMB_URL)
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Referer", "https://finance.yahoo.com/")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch Yahoo Finance crumb")?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.context("Failed to read crumb response")?;
|
||||||
|
|
||||||
|
if crumb.contains("Unauthorized") || crumb.is_empty() {
|
||||||
|
return Err(anyhow!("Failed to authenticate with Yahoo Finance"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { client, crumb })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn summary(&self, symbol: &str, modules: &str) -> Result<Value> {
|
||||||
|
let url = format!("{BASE_SUMMARY}/{symbol}?modules={modules}&crumb={}", self.crumb);
|
||||||
|
self.client
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Network error fetching quoteSummary")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse quoteSummary JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chart(&self, symbol: &str) -> Result<Value> {
|
||||||
|
let url = format!("{BASE_CHART}/{symbol}?interval=1d&range=1y&crumb={}", self.crumb);
|
||||||
|
self.client
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse chart JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str, news_count: u32) -> Result<Value> {
|
||||||
|
let url = format!("{BASE_SEARCH}?q={query}&newsCount={news_count}"esCount=0&crumb={}", self.crumb);
|
||||||
|
self.client
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse search JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public entry point ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn fetch_ticker(symbol: &str) -> Result<AnalysisInput> {
|
||||||
|
let sym = symbol.trim().to_uppercase();
|
||||||
|
let session = YahooSession::init(&sym).await?;
|
||||||
|
|
||||||
|
// Detect asset type first
|
||||||
|
let qt_val = session.summary(&sym, "quoteType").await?;
|
||||||
|
let qt = qt_val["quoteSummary"]["result"][0]["quoteType"]["quoteType"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if qt.is_empty() {
|
||||||
|
return Err(anyhow!("Ticker '{}' not found. Check the symbol and try again.", sym));
|
||||||
|
}
|
||||||
|
|
||||||
|
match qt {
|
||||||
|
"EQUITY" => fetch_equity(&session, &sym).await.map(AnalysisInput::Equity),
|
||||||
|
"ETF" | "MUTUALFUND" => fetch_etf(&session, &sym).await.map(AnalysisInput::Etf),
|
||||||
|
other => Err(anyhow!("Unsupported asset type '{other}'. Only stocks and ETFs are supported.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Equity fetch ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn fetch_equity(session: &YahooSession, symbol: &str) -> Result<TickerData> {
|
||||||
|
let modules = "quoteType,assetProfile,financialData,incomeStatementHistoryQuarterly,\
|
||||||
|
balanceSheetHistory,cashflowStatementHistory,calendarEvents,\
|
||||||
|
earningsHistory,summaryDetail,recommendationTrend,defaultKeyStatistics";
|
||||||
|
|
||||||
|
let (summary_res, chart_res, news_res) = tokio::join!(
|
||||||
|
session.summary(symbol, modules),
|
||||||
|
session.chart(symbol),
|
||||||
|
session.search(symbol, 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
let summary = summary_res.context("Summary fetch failed")?;
|
||||||
|
let chart = chart_res.unwrap_or(Value::Null);
|
||||||
|
let news_val = news_res.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
let r = &summary["quoteSummary"]["result"][0];
|
||||||
|
if r.is_null() {
|
||||||
|
return Err(anyhow!("No data returned for '{symbol}'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_profile = &r["assetProfile"];
|
||||||
|
let financial_data = &r["financialData"];
|
||||||
|
let summary_detail = &r["summaryDetail"];
|
||||||
|
let default_key_stats = &r["defaultKeyStatistics"];
|
||||||
|
let income_quarterly = &r["incomeStatementHistoryQuarterly"]["incomeStatementHistory"];
|
||||||
|
let _cashflow = &r["cashflowStatementHistory"]["cashflowStatements"];
|
||||||
|
let calendar = &r["calendarEvents"];
|
||||||
|
let earnings_hist = &r["earningsHistory"]["history"];
|
||||||
|
let rec_trend = &r["recommendationTrend"]["trend"];
|
||||||
|
|
||||||
|
let company_name = r["quoteType"]["longName"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| r["quoteType"]["shortName"].as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
let business_summary = asset_profile["longBusinessSummary"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| truncate_sentences(s, 2));
|
||||||
|
|
||||||
|
let sector = asset_profile["sector"].as_str().map(str::to_string);
|
||||||
|
let industry = asset_profile["industry"].as_str().map(str::to_string);
|
||||||
|
|
||||||
|
// Price: prefer financialData, fall back to chart meta
|
||||||
|
let chart_meta = &chart["chart"]["result"][0]["meta"];
|
||||||
|
let current_price = financial_data["currentPrice"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| chart_meta["regularMarketPrice"].as_f64());
|
||||||
|
|
||||||
|
let week_52_high = summary_detail["fiftyTwoWeekHigh"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| chart_meta["fiftyTwoWeekHigh"].as_f64());
|
||||||
|
let week_52_low = summary_detail["fiftyTwoWeekLow"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| chart_meta["fiftyTwoWeekLow"].as_f64());
|
||||||
|
|
||||||
|
let market_cap = summary_detail["marketCap"]["raw"].as_f64();
|
||||||
|
let beta = summary_detail["beta"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| default_key_stats["beta"]["raw"].as_f64());
|
||||||
|
|
||||||
|
let pe_ratio = summary_detail["trailingPE"]["raw"].as_f64();
|
||||||
|
let forward_pe = summary_detail["forwardPE"]["raw"].as_f64();
|
||||||
|
|
||||||
|
// Price momentum from chart closes
|
||||||
|
let closes = chart["chart"]["result"][0]["indicators"]["quote"][0]["close"]
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (price_change_1m_pct, price_change_3m_pct) = compute_price_changes(&closes);
|
||||||
|
|
||||||
|
// Quarterly revenue
|
||||||
|
let revenue_quarters = extract_quarterly_revenue(income_quarterly);
|
||||||
|
|
||||||
|
// EPS quarters from earningsHistory
|
||||||
|
let eps_quarters = extract_eps_quarters(earnings_hist);
|
||||||
|
|
||||||
|
// TTM figures from financialData
|
||||||
|
let revenue_ttm = financial_data["totalRevenue"]["raw"].as_f64();
|
||||||
|
let free_cash_flow_ttm = financial_data["freeCashflow"]["raw"].as_f64();
|
||||||
|
|
||||||
|
// debtToEquity from Yahoo is returned as a percentage (e.g. 79.548 means 0.795 ratio)
|
||||||
|
let debt_to_equity = financial_data["debtToEquity"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.map(|v| v / 100.0);
|
||||||
|
let current_ratio = financial_data["currentRatio"]["raw"].as_f64();
|
||||||
|
|
||||||
|
// Dividends — yield is already a fraction (0.0038 = 0.38%)
|
||||||
|
let dividend_yield = summary_detail["dividendYield"]["raw"].as_f64();
|
||||||
|
let payout_ratio = summary_detail["payoutRatio"]["raw"].as_f64();
|
||||||
|
let dividend_5yr_growth = summary_detail["fiveYearAvgDividendYield"]["raw"].as_f64();
|
||||||
|
let dividend_cut_flag = detect_dividend_cut(&summary_detail);
|
||||||
|
|
||||||
|
// Analyst
|
||||||
|
let analyst_consensus = financial_data["recommendationKey"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
let (analyst_trend_current, analyst_trend_1m_ago, analyst_trend_2m_ago) =
|
||||||
|
extract_analyst_trends(rec_trend);
|
||||||
|
|
||||||
|
// Earnings date
|
||||||
|
let next_earnings_date = calendar["earnings"]["earningsDate"][0]["raw"]
|
||||||
|
.as_i64()
|
||||||
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.date_naive()));
|
||||||
|
|
||||||
|
// Headlines
|
||||||
|
let company_headlines = extract_headlines(&news_val, 5);
|
||||||
|
let sector_headlines = if let Some(ref s) = sector {
|
||||||
|
session.search(sector_to_keyword(s), 3).await
|
||||||
|
.map(|v| extract_headlines(&v, 3))
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut data = TickerData {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
company_name,
|
||||||
|
business_summary,
|
||||||
|
sector,
|
||||||
|
industry,
|
||||||
|
asset_type: AssetType::Equity,
|
||||||
|
current_price,
|
||||||
|
price_change_1m_pct,
|
||||||
|
price_change_3m_pct,
|
||||||
|
week_52_high,
|
||||||
|
week_52_low,
|
||||||
|
market_cap,
|
||||||
|
beta,
|
||||||
|
pe_ratio,
|
||||||
|
forward_pe,
|
||||||
|
revenue_quarters,
|
||||||
|
eps_quarters,
|
||||||
|
free_cash_flow_ttm,
|
||||||
|
revenue_ttm,
|
||||||
|
debt_to_equity,
|
||||||
|
current_ratio,
|
||||||
|
dividend_yield,
|
||||||
|
payout_ratio,
|
||||||
|
dividend_5yr_growth,
|
||||||
|
dividend_cut_flag,
|
||||||
|
analyst_consensus,
|
||||||
|
analyst_buy_count: analyst_trend_current.as_ref().map(|s| s.strong_buy + s.buy),
|
||||||
|
analyst_hold_count: analyst_trend_current.as_ref().map(|s| s.hold),
|
||||||
|
analyst_sell_count: analyst_trend_current.as_ref().map(|s| s.sell + s.strong_sell),
|
||||||
|
analyst_trend_current,
|
||||||
|
analyst_trend_1m_ago,
|
||||||
|
analyst_trend_2m_ago,
|
||||||
|
next_earnings_date,
|
||||||
|
company_headlines,
|
||||||
|
sector_headlines,
|
||||||
|
data_completeness_pct: 0.0,
|
||||||
|
fetched_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
data.data_completeness_pct = compute_equity_completeness(&data);
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ETF fetch ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn fetch_etf(session: &YahooSession, symbol: &str) -> Result<EtfData> {
|
||||||
|
let modules = "quoteType,fundProfile,summaryDetail,topHoldings,fundPerformance";
|
||||||
|
|
||||||
|
let (summary_res, chart_res, news_res) = tokio::join!(
|
||||||
|
session.summary(symbol, modules),
|
||||||
|
session.chart(symbol),
|
||||||
|
session.search(symbol, 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
let summary = summary_res.context("ETF summary fetch failed")?;
|
||||||
|
let chart = chart_res.unwrap_or(Value::Null);
|
||||||
|
let news_val = news_res.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
let r = &summary["quoteSummary"]["result"][0];
|
||||||
|
if r.is_null() {
|
||||||
|
return Err(anyhow!("No data returned for ETF '{symbol}'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let fund_profile = &r["fundProfile"];
|
||||||
|
let summary_detail = &r["summaryDetail"];
|
||||||
|
let top_holdings_val = &r["topHoldings"];
|
||||||
|
let fund_perf = &r["fundPerformance"];
|
||||||
|
|
||||||
|
let fund_name = r["quoteType"]["longName"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| r["quoteType"]["shortName"].as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
let category = fund_profile["categoryName"].as_str().map(str::to_string);
|
||||||
|
|
||||||
|
let chart_meta = &chart["chart"]["result"][0]["meta"];
|
||||||
|
let current_price = chart_meta["regularMarketPrice"].as_f64();
|
||||||
|
let week_52_high = summary_detail["fiftyTwoWeekHigh"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| chart_meta["fiftyTwoWeekHigh"].as_f64());
|
||||||
|
let week_52_low = summary_detail["fiftyTwoWeekLow"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| chart_meta["fiftyTwoWeekLow"].as_f64());
|
||||||
|
|
||||||
|
let closes = chart["chart"]["result"][0]["indicators"]["quote"][0]["close"]
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (price_change_1m_pct, _) = compute_price_changes(&closes);
|
||||||
|
|
||||||
|
// Expense ratio: try multiple locations
|
||||||
|
let expense_ratio = summary_detail["annualReportExpenseRatio"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| r["defaultKeyStatistics"]["annualReportExpenseRatio"]["raw"].as_f64())
|
||||||
|
.or_else(|| {
|
||||||
|
fund_profile["feesExpensesInvestment"]["annualReportExpenseRatio"]["raw"].as_f64()
|
||||||
|
});
|
||||||
|
|
||||||
|
let total_assets = summary_detail["totalAssets"]["raw"].as_f64();
|
||||||
|
let nav_price = summary_detail["navPrice"]["raw"].as_f64();
|
||||||
|
// ETF yield: summaryDetail.yield (not dividendYield)
|
||||||
|
let dividend_yield = summary_detail["yield"]["raw"]
|
||||||
|
.as_f64()
|
||||||
|
.or_else(|| summary_detail["dividendYield"]["raw"].as_f64());
|
||||||
|
|
||||||
|
let mut top_holdings_list: Vec<(String, f64)> = top_holdings_val["holdings"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|h| {
|
||||||
|
let name = h["holdingName"].as_str()?;
|
||||||
|
let pct = h["holdingPercent"]["raw"].as_f64()?;
|
||||||
|
Some((name.to_string(), pct * 100.0))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
top_holdings_list.truncate(10);
|
||||||
|
|
||||||
|
let top_10_concentration = if !top_holdings_list.is_empty() {
|
||||||
|
Some(top_holdings_list.iter().map(|(_, w)| w).sum::<f64>())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let trailing = &fund_perf["trailingReturns"];
|
||||||
|
let ytd_return = trailing["ytd"]["raw"].as_f64().map(|v| v * 100.0);
|
||||||
|
let one_year_return = trailing["oneYear"]["raw"].as_f64().map(|v| v * 100.0);
|
||||||
|
let three_year_return = trailing["threeYear"]["raw"].as_f64().map(|v| v * 100.0);
|
||||||
|
|
||||||
|
let name_lower = fund_name.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let is_leveraged = name_lower.contains("2x")
|
||||||
|
|| name_lower.contains("3x")
|
||||||
|
|| name_lower.contains("leveraged")
|
||||||
|
|| name_lower.contains("ultra");
|
||||||
|
let is_inverse = name_lower.contains("inverse")
|
||||||
|
|| name_lower.contains("short")
|
||||||
|
|| name_lower.contains("bear");
|
||||||
|
|
||||||
|
let headlines = extract_headlines(&news_val, 5);
|
||||||
|
|
||||||
|
let mut data = EtfData {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
fund_name,
|
||||||
|
category,
|
||||||
|
asset_type: AssetType::Etf,
|
||||||
|
current_price,
|
||||||
|
price_change_1m_pct,
|
||||||
|
week_52_high,
|
||||||
|
week_52_low,
|
||||||
|
expense_ratio,
|
||||||
|
total_assets,
|
||||||
|
nav_price,
|
||||||
|
dividend_yield,
|
||||||
|
top_holdings: top_holdings_list,
|
||||||
|
top_10_concentration,
|
||||||
|
ytd_return,
|
||||||
|
one_year_return,
|
||||||
|
three_year_return,
|
||||||
|
is_leveraged,
|
||||||
|
is_inverse,
|
||||||
|
headlines,
|
||||||
|
data_completeness_pct: 0.0,
|
||||||
|
fetched_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
data.data_completeness_pct = compute_etf_completeness(&data);
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn truncate_sentences(text: &str, n: usize) -> String {
|
||||||
|
let mut count = 0;
|
||||||
|
let mut end = text.len();
|
||||||
|
for (i, ch) in text.char_indices() {
|
||||||
|
if ch == '.' {
|
||||||
|
count += 1;
|
||||||
|
if count >= n {
|
||||||
|
end = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text[..end].trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_price_changes(closes: &[Value]) -> (Option<f64>, Option<f64>) {
|
||||||
|
let prices: Vec<f64> = closes.iter().filter_map(|v| v.as_f64()).collect();
|
||||||
|
if prices.len() < 2 {
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
let current = *prices.last().unwrap();
|
||||||
|
|
||||||
|
let pct = |ago: usize| -> Option<f64> {
|
||||||
|
let idx = prices.len().saturating_sub(ago);
|
||||||
|
if idx == prices.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let past = prices[idx];
|
||||||
|
if past == 0.0 { None } else { Some((current - past) / past * 100.0) }
|
||||||
|
};
|
||||||
|
|
||||||
|
(pct(22), pct(63))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_quarterly_revenue(income_quarterly: &Value) -> Vec<(String, f64)> {
|
||||||
|
income_quarterly
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|q| {
|
||||||
|
let date = q["endDate"]["fmt"].as_str()?.to_string();
|
||||||
|
let rev = q["totalRevenue"]["raw"].as_f64()?;
|
||||||
|
Some((date, rev))
|
||||||
|
})
|
||||||
|
.take(4)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_eps_quarters(earnings_hist: &Value) -> Vec<(String, f64)> {
|
||||||
|
let mut quarters: Vec<(String, f64)> = earnings_hist
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|q| {
|
||||||
|
let date = q["quarter"]["fmt"].as_str()?.to_string();
|
||||||
|
let eps = q["epsActual"]["raw"].as_f64()?;
|
||||||
|
Some((date, eps))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
// earningsHistory returns oldest-first; reverse so index 0 = most recent
|
||||||
|
quarters.reverse();
|
||||||
|
quarters.truncate(4);
|
||||||
|
quarters
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_dividend_cut(_summary_detail: &Value) -> bool {
|
||||||
|
// Dividend history is not available in quoteSummary; a real implementation
|
||||||
|
// would need the chart endpoint or a separate history call. Until then the
|
||||||
|
// -2.0 penalty in score_dividend for dividend_cut_flag is intentionally
|
||||||
|
// dormant — do not remove that branch.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_analyst_trends(
|
||||||
|
trend: &Value,
|
||||||
|
) -> (Option<AnalystSnapshot>, Option<AnalystSnapshot>, Option<AnalystSnapshot>) {
|
||||||
|
let parse = |period: &str| -> Option<AnalystSnapshot> {
|
||||||
|
trend.as_array()?.iter().find(|t| t["period"].as_str() == Some(period)).map(|t| {
|
||||||
|
// recommendationTrend values are plain integers, not {raw:...}
|
||||||
|
AnalystSnapshot {
|
||||||
|
strong_buy: t["strongBuy"].as_u64().unwrap_or(0) as u32,
|
||||||
|
buy: t["buy"].as_u64().unwrap_or(0) as u32,
|
||||||
|
hold: t["hold"].as_u64().unwrap_or(0) as u32,
|
||||||
|
sell: t["sell"].as_u64().unwrap_or(0) as u32,
|
||||||
|
strong_sell: t["strongSell"].as_u64().unwrap_or(0) as u32,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
(parse("0m"), parse("-1m"), parse("-2m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_headlines(val: &Value, max: usize) -> Vec<String> {
|
||||||
|
val["news"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|n| n["title"].as_str().map(str::to_string))
|
||||||
|
.take(max)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sector_to_keyword(sector: &str) -> &str {
|
||||||
|
match sector {
|
||||||
|
"Technology" => "technology+sector+news",
|
||||||
|
"Healthcare" => "healthcare+sector+news",
|
||||||
|
"Financial Services" => "financial+sector+news",
|
||||||
|
"Energy" => "energy+sector+news",
|
||||||
|
"Consumer Cyclical" => "consumer+retail+sector+news",
|
||||||
|
"Industrials" => "industrial+sector+news",
|
||||||
|
"Communication Services" => "media+telecom+sector+news",
|
||||||
|
"Real Estate" => "real+estate+sector+news",
|
||||||
|
"Utilities" => "utilities+sector+news",
|
||||||
|
"Consumer Defensive" => "consumer+staples+sector+news",
|
||||||
|
"Basic Materials" => "materials+mining+sector+news",
|
||||||
|
_ => "stock+market+news",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_equity_completeness(data: &TickerData) -> f64 {
|
||||||
|
let fields = [
|
||||||
|
data.current_price.is_some(),
|
||||||
|
data.pe_ratio.is_some(),
|
||||||
|
data.market_cap.is_some(),
|
||||||
|
data.week_52_high.is_some(),
|
||||||
|
data.week_52_low.is_some(),
|
||||||
|
!data.revenue_quarters.is_empty(),
|
||||||
|
!data.eps_quarters.is_empty(),
|
||||||
|
data.free_cash_flow_ttm.is_some(),
|
||||||
|
data.debt_to_equity.is_some(),
|
||||||
|
data.current_ratio.is_some(),
|
||||||
|
data.analyst_trend_current.is_some(),
|
||||||
|
data.business_summary.is_some(),
|
||||||
|
data.beta.is_some(),
|
||||||
|
data.revenue_ttm.is_some(),
|
||||||
|
data.price_change_1m_pct.is_some(),
|
||||||
|
data.price_change_3m_pct.is_some(),
|
||||||
|
];
|
||||||
|
fields.iter().filter(|&&f| f).count() as f64 / fields.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_etf_completeness(data: &EtfData) -> f64 {
|
||||||
|
let fields = [
|
||||||
|
data.current_price.is_some(),
|
||||||
|
data.expense_ratio.is_some(),
|
||||||
|
data.total_assets.is_some(),
|
||||||
|
data.dividend_yield.is_some(),
|
||||||
|
!data.top_holdings.is_empty(),
|
||||||
|
data.one_year_return.is_some(),
|
||||||
|
data.three_year_return.is_some(),
|
||||||
|
data.week_52_high.is_some(),
|
||||||
|
data.week_52_low.is_some(),
|
||||||
|
];
|
||||||
|
fields.iter().filter(|&&f| f).count() as f64 / fields.len() as f64
|
||||||
|
}
|
||||||
137
src/data/mod.rs
Normal file
137
src/data/mod.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum AssetType {
|
||||||
|
Equity,
|
||||||
|
Etf,
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnalystSnapshot {
|
||||||
|
pub strong_buy: u32,
|
||||||
|
pub buy: u32,
|
||||||
|
pub hold: u32,
|
||||||
|
pub sell: u32,
|
||||||
|
pub strong_sell: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalystSnapshot {
|
||||||
|
pub fn bullish_ratio(&self) -> f64 {
|
||||||
|
let total = (self.strong_buy + self.buy + self.hold + self.sell + self.strong_sell) as f64;
|
||||||
|
if total == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
(self.strong_buy + self.buy) as f64 / total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct TickerData {
|
||||||
|
pub symbol: String,
|
||||||
|
pub company_name: Option<String>,
|
||||||
|
pub business_summary: Option<String>,
|
||||||
|
pub sector: Option<String>,
|
||||||
|
pub industry: Option<String>,
|
||||||
|
pub asset_type: AssetType,
|
||||||
|
|
||||||
|
pub current_price: Option<f64>,
|
||||||
|
pub price_change_1m_pct: Option<f64>,
|
||||||
|
pub price_change_3m_pct: Option<f64>,
|
||||||
|
pub week_52_high: Option<f64>,
|
||||||
|
pub week_52_low: Option<f64>,
|
||||||
|
pub market_cap: Option<f64>,
|
||||||
|
pub beta: Option<f64>,
|
||||||
|
|
||||||
|
pub pe_ratio: Option<f64>,
|
||||||
|
pub forward_pe: Option<f64>,
|
||||||
|
|
||||||
|
pub revenue_quarters: Vec<(String, f64)>,
|
||||||
|
pub eps_quarters: Vec<(String, f64)>,
|
||||||
|
pub free_cash_flow_ttm: Option<f64>,
|
||||||
|
pub revenue_ttm: Option<f64>,
|
||||||
|
pub debt_to_equity: Option<f64>,
|
||||||
|
pub current_ratio: Option<f64>,
|
||||||
|
|
||||||
|
pub dividend_yield: Option<f64>,
|
||||||
|
pub payout_ratio: Option<f64>,
|
||||||
|
pub dividend_5yr_growth: Option<f64>,
|
||||||
|
pub dividend_cut_flag: bool,
|
||||||
|
|
||||||
|
pub analyst_consensus: Option<String>,
|
||||||
|
pub analyst_buy_count: Option<u32>,
|
||||||
|
pub analyst_hold_count: Option<u32>,
|
||||||
|
pub analyst_sell_count: Option<u32>,
|
||||||
|
pub analyst_trend_current: Option<AnalystSnapshot>,
|
||||||
|
pub analyst_trend_1m_ago: Option<AnalystSnapshot>,
|
||||||
|
pub analyst_trend_2m_ago: Option<AnalystSnapshot>,
|
||||||
|
|
||||||
|
pub next_earnings_date: Option<NaiveDate>,
|
||||||
|
|
||||||
|
pub company_headlines: Vec<String>,
|
||||||
|
pub sector_headlines: Vec<String>,
|
||||||
|
|
||||||
|
pub data_completeness_pct: f64,
|
||||||
|
pub fetched_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct EtfData {
|
||||||
|
pub symbol: String,
|
||||||
|
pub fund_name: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub asset_type: AssetType,
|
||||||
|
|
||||||
|
pub current_price: Option<f64>,
|
||||||
|
pub price_change_1m_pct: Option<f64>,
|
||||||
|
pub week_52_high: Option<f64>,
|
||||||
|
pub week_52_low: Option<f64>,
|
||||||
|
|
||||||
|
pub expense_ratio: Option<f64>,
|
||||||
|
pub total_assets: Option<f64>,
|
||||||
|
pub nav_price: Option<f64>,
|
||||||
|
pub dividend_yield: Option<f64>,
|
||||||
|
|
||||||
|
pub top_holdings: Vec<(String, f64)>,
|
||||||
|
pub top_10_concentration: Option<f64>,
|
||||||
|
|
||||||
|
pub ytd_return: Option<f64>,
|
||||||
|
pub one_year_return: Option<f64>,
|
||||||
|
pub three_year_return: Option<f64>,
|
||||||
|
|
||||||
|
pub is_leveraged: bool,
|
||||||
|
pub is_inverse: bool,
|
||||||
|
|
||||||
|
pub headlines: Vec<String>,
|
||||||
|
|
||||||
|
pub data_completeness_pct: f64,
|
||||||
|
pub fetched_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AnalysisInput {
|
||||||
|
Equity(TickerData),
|
||||||
|
Etf(EtfData),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalysisInput {
|
||||||
|
pub fn symbol(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AnalysisInput::Equity(d) => &d.symbol,
|
||||||
|
AnalysisInput::Etf(d) => &d.symbol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetched_at(&self) -> DateTime<Utc> {
|
||||||
|
match self {
|
||||||
|
AnalysisInput::Equity(d) => d.fetched_at,
|
||||||
|
AnalysisInput::Etf(d) => d.fetched_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod fetch;
|
||||||
|
pub mod cache;
|
||||||
224
src/discord.rs
Normal file
224
src/discord.rs
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::analysis::AnalysisResult;
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
let t: String = s.chars().take(max).collect();
|
||||||
|
if t.len() < s.len() {
|
||||||
|
format!("{}…", t.trim_end())
|
||||||
|
} else {
|
||||||
|
t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn why_field(analysis: &AnalysisResult) -> serde_json::Value {
|
||||||
|
let value = if analysis.section_why_opinion.is_empty() {
|
||||||
|
"Signal detected — see full report for details.".to_string()
|
||||||
|
} else {
|
||||||
|
truncate(&analysis.section_why_opinion, 1024)
|
||||||
|
};
|
||||||
|
json!({"name": "Why", "value": value, "inline": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_signal_embed(
|
||||||
|
webhook_url: &str,
|
||||||
|
ticker: &str,
|
||||||
|
company_name: &str,
|
||||||
|
prev_signal: &str,
|
||||||
|
new_signal: &str,
|
||||||
|
analysis: &AnalysisResult,
|
||||||
|
) -> Result<()> {
|
||||||
|
if webhook_url.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let color: u64 = match new_signal {
|
||||||
|
"BUY" => 5763719,
|
||||||
|
"SELL" => 15548997,
|
||||||
|
"PASS" => 16705372,
|
||||||
|
_ => 10070709,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fields = vec![
|
||||||
|
json!({"name": "Score", "value": format!("{:.2}", analysis.final_score), "inline": true}),
|
||||||
|
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
|
||||||
|
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
|
||||||
|
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(why_field(analysis));
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"embeds": [{
|
||||||
|
"title": format!("{ticker} — {company_name}"),
|
||||||
|
"description": format!("Signal changed: {prev_signal} → {new_signal}"),
|
||||||
|
"color": color,
|
||||||
|
"fields": fields,
|
||||||
|
"footer": { "text": format!("Tickr v{} · Checked {now}", crate::VERSION) }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
|
||||||
|
if resp.status().as_u16() != 204 {
|
||||||
|
anyhow::bail!("Discord webhook returned {}", resp.status());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_drift_embed(
|
||||||
|
webhook_url: &str,
|
||||||
|
ticker: &str,
|
||||||
|
old_score: f64,
|
||||||
|
new_score: f64,
|
||||||
|
signal: &str,
|
||||||
|
analysis: &AnalysisResult,
|
||||||
|
) -> Result<()> {
|
||||||
|
if webhook_url.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction = if new_score > old_score { "↑" } else { "↓" };
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
|
||||||
|
|
||||||
|
let mut fields = vec![
|
||||||
|
json!({"name": "Score", "value": format!("{:.2} → {:.2}", old_score, new_score), "inline": true}),
|
||||||
|
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
|
||||||
|
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
|
||||||
|
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(why_field(analysis));
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"embeds": [{
|
||||||
|
"title": format!("{ticker} — Score Drift {direction}"),
|
||||||
|
"description": format!(
|
||||||
|
"Score moved from **{:.2}** to **{:.2}** (signal still: **{signal}**)",
|
||||||
|
old_score, new_score
|
||||||
|
),
|
||||||
|
"color": 10070709u64,
|
||||||
|
"fields": fields,
|
||||||
|
"footer": { "text": format!("Tickr v{} · Checked {now}", crate::VERSION) }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
|
||||||
|
if resp.status().as_u16() != 204 {
|
||||||
|
anyhow::bail!("Discord webhook returned {}", resp.status());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_added_to_watchlist_embed(
|
||||||
|
webhook_url: &str,
|
||||||
|
ticker: &str,
|
||||||
|
company_name: &str,
|
||||||
|
analysis: &AnalysisResult,
|
||||||
|
) -> Result<()> {
|
||||||
|
if webhook_url.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let signal = analysis.decision.label();
|
||||||
|
let color: u64 = match signal {
|
||||||
|
"BUY" => 5763719,
|
||||||
|
"SELL" => 15548997,
|
||||||
|
"PASS" => 16705372,
|
||||||
|
_ => 10070709,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fields = vec![
|
||||||
|
json!({"name": "Signal", "value": signal, "inline": true}),
|
||||||
|
json!({"name": "Score", "value": format!("{:.2}", analysis.final_score), "inline": true}),
|
||||||
|
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
|
||||||
|
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
|
||||||
|
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(why_field(analysis));
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"embeds": [{
|
||||||
|
"title": format!("{ticker} — {company_name}"),
|
||||||
|
"description": "Added to watchlist",
|
||||||
|
"color": color,
|
||||||
|
"fields": fields,
|
||||||
|
"footer": { "text": format!("Tickr v{} · Added {now}", crate::VERSION) }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
|
||||||
|
if resp.status().as_u16() != 204 {
|
||||||
|
anyhow::bail!("Discord webhook returned {}", resp.status());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post up to 10 ticker snapshots as a single Discord message (Discord's embed-per-message limit).
|
||||||
|
/// The caller is responsible for splitting larger batches and inserting delays between calls.
|
||||||
|
pub async fn send_watchlist_snapshot(
|
||||||
|
webhook_url: &str,
|
||||||
|
entries: &[(String, String, AnalysisResult)],
|
||||||
|
) -> Result<()> {
|
||||||
|
if webhook_url.is_empty() || entries.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
|
||||||
|
|
||||||
|
let embeds: Vec<serde_json::Value> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(ticker, company_name, analysis)| {
|
||||||
|
let signal = analysis.decision.label();
|
||||||
|
let color: u64 = match signal {
|
||||||
|
"BUY" => 5763719,
|
||||||
|
"SELL" => 15548997,
|
||||||
|
"PASS" => 16705372,
|
||||||
|
_ => 10070709,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fields = vec![
|
||||||
|
json!({"name": "Signal", "value": signal, "inline": true}),
|
||||||
|
json!({"name": "Score", "value": format!("{:.2}", analysis.final_score), "inline": true}),
|
||||||
|
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
|
||||||
|
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
|
||||||
|
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(why_field(analysis));
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"title": format!("{ticker} — {company_name}"),
|
||||||
|
"color": color,
|
||||||
|
"fields": fields,
|
||||||
|
"footer": { "text": format!("Tickr v{} · Refreshed {now}", crate::VERSION) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let payload = json!({ "embeds": embeds });
|
||||||
|
|
||||||
|
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
|
||||||
|
if resp.status().as_u16() != 204 {
|
||||||
|
anyhow::bail!("Discord webhook returned {}", resp.status());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
13
src/icon.rs
Normal file
13
src/icon.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const ICO_BYTES: &[u8] = include_bytes!("assets/icon.ico");
|
||||||
|
|
||||||
|
pub fn make_rgba() -> (Vec<u8>, u32) {
|
||||||
|
let img = image::load_from_memory(ICO_BYTES)
|
||||||
|
.expect("Failed to load assets/icon.ico")
|
||||||
|
.resize_exact(32, 32, image::imageops::FilterType::Lanczos3)
|
||||||
|
.to_rgba8();
|
||||||
|
(img.into_raw(), 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn egui_icon_data(rgba: Vec<u8>, size: u32) -> egui::IconData {
|
||||||
|
egui::IconData { rgba, width: size, height: size }
|
||||||
|
}
|
||||||
40
src/main.rs
Normal file
40
src/main.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod analysis;
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod data;
|
||||||
|
mod discord;
|
||||||
|
mod icon;
|
||||||
|
mod report;
|
||||||
|
mod ui;
|
||||||
|
mod updater;
|
||||||
|
mod version;
|
||||||
|
mod watchlist;
|
||||||
|
|
||||||
|
pub use version::VERSION;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let config = config::ensure_exists();
|
||||||
|
|
||||||
|
let (rgba, size) = icon::make_rgba();
|
||||||
|
let icon_data = icon::egui_icon_data(rgba.clone(), size);
|
||||||
|
|
||||||
|
let start_visible = !config.preferences.start_minimized;
|
||||||
|
|
||||||
|
let native_options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_title("Tickr — Stock Analyzer")
|
||||||
|
.with_inner_size([1100.0, 700.0])
|
||||||
|
.with_min_inner_size([800.0, 500.0])
|
||||||
|
.with_visible(start_visible)
|
||||||
|
.with_icon(icon_data),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"Tickr — Stock Analyzer",
|
||||||
|
native_options,
|
||||||
|
Box::new(move |cc| Box::new(app::TickrApp::new(cc, config, rgba, size, start_visible))),
|
||||||
|
)
|
||||||
|
}
|
||||||
324
src/report/equity.rs
Normal file
324
src/report/equity.rs
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
use crate::analysis::{AnalystTrend, AnalystTrendSignal, CategoryScore, Decision, Confidence};
|
||||||
|
use crate::data::TickerData;
|
||||||
|
|
||||||
|
pub fn verdict(data: &TickerData, decision: &Decision, confidence: &Confidence) -> String {
|
||||||
|
let name = data.company_name.as_deref().unwrap_or(&data.symbol);
|
||||||
|
format!(
|
||||||
|
"{} — {} with {} confidence",
|
||||||
|
name,
|
||||||
|
decision.label(),
|
||||||
|
confidence.label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn what_company_does(data: &TickerData) -> String {
|
||||||
|
if let Some(ref summary) = data.business_summary {
|
||||||
|
return summary.clone();
|
||||||
|
}
|
||||||
|
match (&data.company_name, &data.industry, &data.sector) {
|
||||||
|
(Some(name), Some(industry), Some(sector)) => {
|
||||||
|
format!("{name} operates in the {industry} industry within the {sector} sector.")
|
||||||
|
}
|
||||||
|
(Some(name), _, Some(sector)) => {
|
||||||
|
format!("{name} operates in the {sector} sector.")
|
||||||
|
}
|
||||||
|
_ => format!("{} is a publicly traded company.", data.symbol),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn why_opinion(
|
||||||
|
_data: &TickerData,
|
||||||
|
scores: &[CategoryScore],
|
||||||
|
spec_flags: &[String],
|
||||||
|
hype_flags: &[String],
|
||||||
|
force_pass: &Option<String>,
|
||||||
|
) -> String {
|
||||||
|
if let Some(ref reason) = force_pass {
|
||||||
|
return format!("This analysis is forced to PASS because: {reason}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut drivers: Vec<(f64, String)> = scores
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.score.abs() >= 1.0)
|
||||||
|
.map(|s| (s.score * s.weight, s.detail.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
drivers.sort_by(|a, b| b.0.abs().partial_cmp(&a.0.abs()).unwrap());
|
||||||
|
|
||||||
|
let mut lines: Vec<String> = drivers.iter().take(3).map(|(_, d)| d.clone()).collect();
|
||||||
|
|
||||||
|
if !spec_flags.is_empty() {
|
||||||
|
lines.push(format!("Speculation concerns: {}", spec_flags.join("; ")));
|
||||||
|
}
|
||||||
|
if !hype_flags.is_empty() {
|
||||||
|
lines.push(format!("Hype signals: {}", hype_flags.join("; ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
"No single dominant signal — score is borderline.".to_string()
|
||||||
|
} else {
|
||||||
|
lines.join(". ") + "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn looks_good(_data: &TickerData, scores: &[CategoryScore]) -> Vec<String> {
|
||||||
|
scores
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.score > 0.0)
|
||||||
|
.map(|s| s.detail.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn worries(
|
||||||
|
_data: &TickerData,
|
||||||
|
scores: &[CategoryScore],
|
||||||
|
spec_flags: &[String],
|
||||||
|
hype_flags: &[String],
|
||||||
|
analyst_trend: &Option<AnalystTrend>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut items: Vec<String> = scores
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.score < 0.0)
|
||||||
|
.map(|s| s.detail.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for flag in spec_flags {
|
||||||
|
items.push(format!("⚠ {flag}"));
|
||||||
|
}
|
||||||
|
for flag in hype_flags {
|
||||||
|
items.push(format!("⚠ {flag}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(trend) = analyst_trend {
|
||||||
|
if matches!(
|
||||||
|
trend.signal,
|
||||||
|
AnalystTrendSignal::Downgrading | AnalystTrendSignal::RapidlyDowngrading
|
||||||
|
) {
|
||||||
|
items.push("Analysts have been downgrading their outlook over the past 2 months".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dividend_check(data: &TickerData) -> String {
|
||||||
|
if data.dividend_cut_flag {
|
||||||
|
return "⚠ This company recently cut its dividend — a significant negative signal for income investors.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
match data.dividend_yield {
|
||||||
|
Some(y) if y > 0.0 => {
|
||||||
|
let mut parts = vec![format!("Current yield: {:.2}%", y * 100.0)];
|
||||||
|
if let Some(p) = data.payout_ratio {
|
||||||
|
parts.push(format!("payout ratio: {:.0}%", p * 100.0));
|
||||||
|
}
|
||||||
|
if let Some(g) = data.dividend_5yr_growth {
|
||||||
|
if g > 0.0 {
|
||||||
|
parts.push(format!("5-year average yield: {:.2}%", g));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join(", ") + "."
|
||||||
|
}
|
||||||
|
_ => "This company does not currently pay a dividend.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn growth_check(data: &TickerData) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Revenue trend
|
||||||
|
let rev = &data.revenue_quarters;
|
||||||
|
if rev.len() >= 2 {
|
||||||
|
let recent = rev[0].1;
|
||||||
|
let prior = rev[1].1;
|
||||||
|
let qoq = if prior > 0.0 { (recent - prior) / prior * 100.0 } else { 0.0 };
|
||||||
|
lines.push(format!(
|
||||||
|
"Revenue {}: {:.1}% QoQ (most recent quarter)",
|
||||||
|
if qoq >= 0.0 { "up" } else { "down" },
|
||||||
|
qoq.abs()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if rev.len() >= 4 {
|
||||||
|
let recent = rev[0].1;
|
||||||
|
let year_ago = rev[3].1;
|
||||||
|
let yoy = if year_ago > 0.0 { (recent - year_ago) / year_ago * 100.0 } else { 0.0 };
|
||||||
|
lines.push(format!(
|
||||||
|
"Revenue {:.1}% YoY (vs. same quarter last year)",
|
||||||
|
yoy
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// EPS trend
|
||||||
|
let eps = &data.eps_quarters;
|
||||||
|
if eps.len() >= 2 {
|
||||||
|
let recent = eps[0].1;
|
||||||
|
let prior = eps[1].1;
|
||||||
|
lines.push(format!(
|
||||||
|
"EPS: ${:.2} (recent) vs ${:.2} (prior quarter) — {}",
|
||||||
|
recent,
|
||||||
|
prior,
|
||||||
|
if recent >= prior { "improving" } else { "declining" }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
"Insufficient growth data available.".to_string()
|
||||||
|
} else {
|
||||||
|
lines.join(". ") + "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valuation_check(data: &TickerData, sector_pe: f64) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
match data.pe_ratio {
|
||||||
|
Some(pe) => {
|
||||||
|
let ratio = pe / sector_pe;
|
||||||
|
let sector_name = data.sector.as_deref().unwrap_or("this sector");
|
||||||
|
lines.push(format!(
|
||||||
|
"P/E ratio of {:.1}x vs. {} median of {:.0}x ({:.0}% of sector median)",
|
||||||
|
pe, sector_name, sector_pe, ratio * 100.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => lines.push("Trailing P/E not available.".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(fpe) = data.forward_pe {
|
||||||
|
lines.push(format!("Forward P/E: {:.1}x", fpe));
|
||||||
|
}
|
||||||
|
|
||||||
|
match (data.current_price, data.week_52_high, data.week_52_low) {
|
||||||
|
(Some(p), Some(h), Some(l)) if h > l => {
|
||||||
|
let pct_of_range = (p - l) / (h - l) * 100.0;
|
||||||
|
lines.push(format!(
|
||||||
|
"Price ${:.2} is at {:.0}% of its 52-week range (${:.2}–${:.2})",
|
||||||
|
p, pct_of_range, l, h
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
"Valuation data unavailable.".to_string()
|
||||||
|
} else {
|
||||||
|
lines.join(". ") + "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn change_mind(
|
||||||
|
_data: &TickerData,
|
||||||
|
decision: &Decision,
|
||||||
|
scores: &[CategoryScore],
|
||||||
|
_buy_thresh: f64,
|
||||||
|
_sell_thresh: f64,
|
||||||
|
) -> String {
|
||||||
|
match decision {
|
||||||
|
Decision::Buy => {
|
||||||
|
let concerns: Vec<&CategoryScore> = scores.iter().filter(|s| s.score < 0.0).collect();
|
||||||
|
if concerns.is_empty() {
|
||||||
|
"A meaningful deterioration in earnings, revenue, or analyst sentiment would prompt a reassessment.".to_string()
|
||||||
|
} else {
|
||||||
|
let items: Vec<&str> = concerns.iter().map(|c| c.name).collect();
|
||||||
|
format!(
|
||||||
|
"A BUY opinion would be reconsidered if {} continue to weaken.",
|
||||||
|
items.join(" or ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Decision::Sell => {
|
||||||
|
let pos: Vec<&CategoryScore> = scores.iter().filter(|s| s.score > 0.0).collect();
|
||||||
|
if pos.is_empty() {
|
||||||
|
"A return to positive earnings growth and improved valuation would change this assessment.".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"A SELL opinion would change if earnings reversed course and valuation became more reasonable."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Decision::Pass => {
|
||||||
|
"A BUY opinion would require stronger evidence of earnings and revenue growth, \
|
||||||
|
with valuation in line with peers. A SELL would require further deterioration \
|
||||||
|
in fundamentals or rising debt.".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn study_next(_data: &TickerData, scores: &[CategoryScore], name: &str) -> String {
|
||||||
|
let weakest: Vec<&CategoryScore> = {
|
||||||
|
let mut s: Vec<&CategoryScore> = scores.iter().collect();
|
||||||
|
s.sort_by(|a, b| a.score.partial_cmp(&b.score).unwrap());
|
||||||
|
s.into_iter().take(2).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut topics = Vec::new();
|
||||||
|
|
||||||
|
for score in &weakest {
|
||||||
|
match score.name {
|
||||||
|
"Valuation" => topics.push("how to interpret P/E ratios relative to industry benchmarks"),
|
||||||
|
"Free Cash Flow" => topics.push("how free cash flow is calculated and why it matters for sustainability"),
|
||||||
|
"Debt Health" => topics.push("how to read a balance sheet and evaluate debt levels"),
|
||||||
|
"Revenue Trend" => topics.push("how to analyze quarterly revenue trends from earnings reports"),
|
||||||
|
"Earnings Trend" => topics.push("EPS growth and what drives earnings acceleration or decline"),
|
||||||
|
"Dividend Safety" => topics.push("dividend sustainability and payout ratio analysis"),
|
||||||
|
"Analyst Sentiment" => topics.push("how Wall Street analyst ratings work and their limitations"),
|
||||||
|
_ => topics.push("the fundamentals behind this metric"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if topics.is_empty() {
|
||||||
|
format!("{name} might explore how to read annual reports and SEC filings for deeper insight.")
|
||||||
|
} else {
|
||||||
|
format!("{name} should study {} to better understand what drove this analysis.", topics.join(", and "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translation(data: &TickerData, decision: &Decision, scores: &[CategoryScore], name: &str) -> String {
|
||||||
|
let company = data.company_name.as_deref().unwrap_or(&data.symbol);
|
||||||
|
|
||||||
|
let sentiment = match decision {
|
||||||
|
Decision::Buy => format!("the numbers look reasonably good for {company} right now"),
|
||||||
|
Decision::Sell => format!("there are meaningful concerns about {company} that are hard to ignore"),
|
||||||
|
Decision::Pass => format!("the case for {company} is neither clearly strong nor clearly weak"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let top_positive = scores.iter().filter(|s| s.score >= 1.0).next();
|
||||||
|
let top_negative = scores.iter().filter(|s| s.score <= -1.0).next();
|
||||||
|
|
||||||
|
let mut parts = vec![
|
||||||
|
format!("{name}, in plain English: {sentiment}."),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(pos) = top_positive {
|
||||||
|
parts.push(format!("The strongest positive is {}.", pos.detail.to_lowercase()));
|
||||||
|
}
|
||||||
|
if let Some(neg) = top_negative {
|
||||||
|
parts.push(format!("The biggest concern is {}.", neg.detail.to_lowercase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn final_reason(
|
||||||
|
_data: &TickerData,
|
||||||
|
decision: &Decision,
|
||||||
|
scores: &[CategoryScore],
|
||||||
|
spec_flags: &[String],
|
||||||
|
hype_flags: &[String],
|
||||||
|
) -> String {
|
||||||
|
if !spec_flags.is_empty() {
|
||||||
|
return format!("Speculation flags present: {}", spec_flags[0]);
|
||||||
|
}
|
||||||
|
if !hype_flags.is_empty() {
|
||||||
|
return format!("Hype signal detected: {}", hype_flags[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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!("Score near threshold — {} is the most appropriate call", decision.label()),
|
||||||
|
}
|
||||||
|
}
|
||||||
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/report/mod.rs
Normal file
16
src/report/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pub mod equity;
|
||||||
|
pub mod etf;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn format_currency(val: f64) -> String {
|
||||||
|
let abs = val.abs();
|
||||||
|
let sign = if val < 0.0 { "-" } else { "" };
|
||||||
|
if abs >= 1_000_000_000.0 {
|
||||||
|
format!("{sign}${:.2}B", abs / 1_000_000_000.0)
|
||||||
|
} else if abs >= 1_000_000.0 {
|
||||||
|
format!("{sign}${:.1}M", abs / 1_000_000.0)
|
||||||
|
} else {
|
||||||
|
format!("{sign}${:.0}", abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
89
src/ui/analysis_panel.rs
Normal file
89
src/ui/analysis_panel.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use egui::{Color32, RichText, Ui};
|
||||||
|
|
||||||
|
use crate::analysis::AnalysisResult;
|
||||||
|
use super::section_header;
|
||||||
|
|
||||||
|
pub fn render(ui: &mut Ui, result: &AnalysisResult) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new("Final Opinion:").strong());
|
||||||
|
ui.label(
|
||||||
|
RichText::new(result.decision.label())
|
||||||
|
.strong()
|
||||||
|
.size(18.0)
|
||||||
|
.color(result.decision.color()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new("Confidence:").strong());
|
||||||
|
ui.label(result.confidence.label());
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new("Risk Level:").strong());
|
||||||
|
let risk_color = match result.risk_level {
|
||||||
|
crate::analysis::RiskLevel::High => Color32::from_rgb(231, 76, 60),
|
||||||
|
crate::analysis::RiskLevel::Medium => Color32::from_rgb(241, 196, 15),
|
||||||
|
crate::analysis::RiskLevel::Low => Color32::from_rgb(39, 174, 96),
|
||||||
|
};
|
||||||
|
ui.label(RichText::new(result.risk_level.label()).color(risk_color));
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(ref fp) = result.force_pass_reason {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new(format!("⚠ {fp}")).color(Color32::YELLOW).italics());
|
||||||
|
}
|
||||||
|
|
||||||
|
section_header(ui, "One-Sentence Verdict");
|
||||||
|
ui.label(&result.section_verdict);
|
||||||
|
|
||||||
|
section_header(ui, "What This Company Does");
|
||||||
|
ui.label(&result.section_what_company_does);
|
||||||
|
|
||||||
|
section_header(ui, "Why This Is The Opinion");
|
||||||
|
ui.label(&result.section_why_opinion);
|
||||||
|
|
||||||
|
if !result.section_looks_good.is_empty() {
|
||||||
|
section_header(ui, "What Looks Good");
|
||||||
|
for item in &result.section_looks_good {
|
||||||
|
ui.label(format!("✓ {item}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.section_worries.is_empty() {
|
||||||
|
section_header(ui, "What Worries Me");
|
||||||
|
for item in &result.section_worries {
|
||||||
|
ui.label(format!("• {item}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section_header(ui, "Dividend / Income Check");
|
||||||
|
ui.label(&result.section_dividend);
|
||||||
|
|
||||||
|
section_header(ui, "Growth Check");
|
||||||
|
ui.label(&result.section_growth);
|
||||||
|
|
||||||
|
section_header(ui, "Valuation Check");
|
||||||
|
ui.label(&result.section_valuation);
|
||||||
|
|
||||||
|
section_header(ui, "What Would Change My Mind");
|
||||||
|
ui.label(&result.section_change_mind);
|
||||||
|
|
||||||
|
section_header(ui, "What You Should Study Next");
|
||||||
|
ui.label(&result.section_study_next);
|
||||||
|
|
||||||
|
section_header(ui, "Plain English Translation");
|
||||||
|
ui.label(&result.section_translation);
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new("Final Answer:").strong());
|
||||||
|
ui.label(
|
||||||
|
RichText::new(&result.section_final_answer)
|
||||||
|
.strong()
|
||||||
|
.color(result.decision.color()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.label(format!("Reason: {}", result.section_final_reason));
|
||||||
|
}
|
||||||
218
src/ui/data_panel.rs
Normal file
218
src/ui/data_panel.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
use egui::{Color32, RichText, ScrollArea, Ui};
|
||||||
|
|
||||||
|
use crate::analysis::AnalysisResult;
|
||||||
|
use crate::data::AnalysisInput;
|
||||||
|
use super::{format_optional_currency, format_optional_f64, label_value};
|
||||||
|
|
||||||
|
pub fn render(ui: &mut Ui, input: &AnalysisInput, result: &AnalysisResult) {
|
||||||
|
ScrollArea::vertical()
|
||||||
|
.id_source("data_panel_scroll")
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
match input {
|
||||||
|
AnalysisInput::Equity(data) => render_equity(ui, data, result),
|
||||||
|
AnalysisInput::Etf(data) => render_etf(ui, data, result),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_equity(ui: &mut Ui, data: &crate::data::TickerData, result: &AnalysisResult) {
|
||||||
|
ui.label(RichText::new("PRICE").strong().underline());
|
||||||
|
label_value(ui, "Price", &format_optional_f64(data.current_price, 2, ""));
|
||||||
|
label_value(ui, "1M Change", &format_change(data.price_change_1m_pct));
|
||||||
|
label_value(ui, "3M Change", &format_change(data.price_change_3m_pct));
|
||||||
|
|
||||||
|
let range_str = match (data.week_52_low, data.week_52_high) {
|
||||||
|
(Some(l), Some(h)) => format!("${:.2} – ${:.2}", l, h),
|
||||||
|
_ => "N/A".to_string(),
|
||||||
|
};
|
||||||
|
label_value(ui, "52W Range", &range_str);
|
||||||
|
label_value(ui, "Market Cap", &format_optional_currency(data.market_cap));
|
||||||
|
label_value(ui, "Beta", &format_optional_f64(data.beta, 2, ""));
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("VALUATION").strong().underline());
|
||||||
|
label_value(ui, "P/E Ratio", &format_optional_f64(data.pe_ratio, 1, "x"));
|
||||||
|
label_value(ui, "Fwd P/E", &format_optional_f64(data.forward_pe, 1, "x"));
|
||||||
|
|
||||||
|
if let Some(sector) = &data.sector {
|
||||||
|
use crate::analysis::equity::sector_median_pe;
|
||||||
|
let (median, _) = sector_median_pe(Some(sector));
|
||||||
|
label_value(ui, &format!("Sector P/E ({sector})"), &format!("{median:.0}x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("FUNDAMENTALS").strong().underline());
|
||||||
|
label_value(ui, "Revenue (TTM)", &format_optional_currency(data.revenue_ttm));
|
||||||
|
label_value(ui, "FCF (TTM)", &format_optional_currency(data.free_cash_flow_ttm));
|
||||||
|
label_value(ui, "D/E Ratio", &format_optional_f64(data.debt_to_equity, 2, ""));
|
||||||
|
label_value(ui, "Current Ratio", &format_optional_f64(data.current_ratio, 2, ""));
|
||||||
|
|
||||||
|
if !data.revenue_quarters.is_empty() {
|
||||||
|
ui.add_space(2.0);
|
||||||
|
ui.label(RichText::new("Revenue (Quarterly)").italics());
|
||||||
|
for (date, rev) in data.revenue_quarters.iter().take(4) {
|
||||||
|
ui.label(format!(" {date}: {}", format_optional_currency(Some(*rev))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.eps_quarters.is_empty() {
|
||||||
|
ui.add_space(2.0);
|
||||||
|
ui.label(RichText::new("EPS (Quarterly)").italics());
|
||||||
|
for (date, eps) in data.eps_quarters.iter().take(4) {
|
||||||
|
let color = if *eps >= 0.0 { Color32::from_rgb(39, 174, 96) } else { Color32::from_rgb(231, 76, 60) };
|
||||||
|
ui.label(RichText::new(format!(" {date}: ${:.2}", eps)).color(color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("DIVIDENDS").strong().underline());
|
||||||
|
let yield_str = data.dividend_yield
|
||||||
|
.map(|y| format!("{:.2}%", y * 100.0))
|
||||||
|
.unwrap_or_else(|| "None".to_string());
|
||||||
|
label_value(ui, "Div Yield", &yield_str);
|
||||||
|
let payout_str = data.payout_ratio
|
||||||
|
.map(|p| format!("{:.0}%", p * 100.0))
|
||||||
|
.unwrap_or_else(|| "N/A".to_string());
|
||||||
|
label_value(ui, "Payout Ratio", &payout_str);
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("ANALYST").strong().underline());
|
||||||
|
let consensus = data.analyst_consensus.as_deref().unwrap_or("N/A").to_uppercase();
|
||||||
|
if let Some(ref trend) = result.analyst_trend {
|
||||||
|
label_value(ui, "Consensus", &format!("{} {}", consensus, trend.signal.arrow()));
|
||||||
|
} else {
|
||||||
|
label_value(ui, "Consensus", &consensus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref snap) = data.analyst_trend_current {
|
||||||
|
let _total = snap.strong_buy + snap.buy + snap.hold + snap.sell + snap.strong_sell;
|
||||||
|
label_value(ui, "Buy / Hold / Sell", &format!(
|
||||||
|
"{} / {} / {}",
|
||||||
|
snap.strong_buy + snap.buy,
|
||||||
|
snap.hold,
|
||||||
|
snap.sell + snap.strong_sell
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(next) = data.next_earnings_date {
|
||||||
|
let today = chrono::Utc::now().date_naive();
|
||||||
|
let days = (next - today).num_days();
|
||||||
|
let label_str = if days >= 0 {
|
||||||
|
format!("{next} ({days} days)")
|
||||||
|
} else {
|
||||||
|
format!("{next} (past)")
|
||||||
|
};
|
||||||
|
label_value(ui, "Next Earnings", &label_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
if !result.speculation_flags.is_empty() || !result.hype_flags.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("FLAGS").strong().underline());
|
||||||
|
for flag in &result.speculation_flags {
|
||||||
|
ui.label(RichText::new(format!("⚠ {flag}")).color(Color32::YELLOW));
|
||||||
|
}
|
||||||
|
for flag in &result.hype_flags {
|
||||||
|
ui.label(RichText::new(format!("⚠ {flag}")).color(Color32::YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headlines
|
||||||
|
if !data.company_headlines.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("NEWS").strong().underline());
|
||||||
|
for h in &data.company_headlines {
|
||||||
|
ui.label(format!("• {h}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.sector_headlines.is_empty() {
|
||||||
|
ui.add_space(2.0);
|
||||||
|
ui.label(RichText::new("SECTOR NEWS").italics());
|
||||||
|
for h in &data.sector_headlines {
|
||||||
|
ui.label(format!("• {h}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_etf(ui: &mut Ui, data: &crate::data::EtfData, _result: &AnalysisResult) {
|
||||||
|
ui.label(RichText::new("PRICE").strong().underline());
|
||||||
|
label_value(ui, "Price", &format!("${:.2}", data.current_price.unwrap_or(0.0)));
|
||||||
|
label_value(ui, "1M Change", &format_change(data.price_change_1m_pct));
|
||||||
|
|
||||||
|
let range_str = match (data.week_52_low, data.week_52_high) {
|
||||||
|
(Some(l), Some(h)) => format!("${:.2} – ${:.2}", l, h),
|
||||||
|
_ => "N/A".to_string(),
|
||||||
|
};
|
||||||
|
label_value(ui, "52W Range", &range_str);
|
||||||
|
label_value(ui, "NAV", &format_optional_currency(data.nav_price));
|
||||||
|
label_value(ui, "AUM", &format_optional_currency(data.total_assets));
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("FUND INFO").strong().underline());
|
||||||
|
label_value(ui, "Category", data.category.as_deref().unwrap_or("N/A"));
|
||||||
|
let er_str = data.expense_ratio
|
||||||
|
.map(|e| format!("{:.3}%", e * 100.0))
|
||||||
|
.unwrap_or_else(|| "N/A".to_string());
|
||||||
|
label_value(ui, "Expense Ratio", &er_str);
|
||||||
|
let yield_str = data.dividend_yield
|
||||||
|
.map(|y| format!("{:.2}%", y * 100.0))
|
||||||
|
.unwrap_or_else(|| "None".to_string());
|
||||||
|
label_value(ui, "Yield", &yield_str);
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("PERFORMANCE").strong().underline());
|
||||||
|
if let Some(ytd) = data.ytd_return {
|
||||||
|
label_value(ui, "YTD", &format!("{:.1}%", ytd));
|
||||||
|
}
|
||||||
|
if let Some(y1) = data.one_year_return {
|
||||||
|
label_value(ui, "1-Year", &format!("{:.1}%", y1));
|
||||||
|
}
|
||||||
|
if let Some(y3) = data.three_year_return {
|
||||||
|
label_value(ui, "3-Year", &format!("{:.1}%", y3));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.top_holdings.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("TOP HOLDINGS").strong().underline());
|
||||||
|
for (name, pct) in data.top_holdings.iter().take(5) {
|
||||||
|
ui.label(format!(" {name}: {:.1}%", pct));
|
||||||
|
}
|
||||||
|
if let Some(conc) = data.top_10_concentration {
|
||||||
|
ui.label(format!(" Top-10 total: {:.1}%", conc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.is_leveraged || data.is_inverse {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("FLAGS").strong().underline());
|
||||||
|
if data.is_leveraged {
|
||||||
|
ui.label(RichText::new("⚠ Leveraged ETF").color(Color32::YELLOW));
|
||||||
|
}
|
||||||
|
if data.is_inverse {
|
||||||
|
ui.label(RichText::new("⚠ Inverse ETF").color(Color32::YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.headlines.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new("NEWS").strong().underline());
|
||||||
|
for h in &data.headlines {
|
||||||
|
ui.label(format!("• {h}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_change(pct: Option<f64>) -> String {
|
||||||
|
match pct {
|
||||||
|
Some(p) => {
|
||||||
|
if p >= 0.0 {
|
||||||
|
format!("+{:.1}%", p)
|
||||||
|
} else {
|
||||||
|
format!("{:.1}%", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "N/A".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/ui/mod.rs
Normal file
44
src/ui/mod.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
pub mod data_panel;
|
||||||
|
pub mod analysis_panel;
|
||||||
|
|
||||||
|
use egui::{Color32, RichText};
|
||||||
|
|
||||||
|
pub fn label_value(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new(format!("{label}:")).strong());
|
||||||
|
ui.label(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_header(ui: &mut egui::Ui, title: &str) {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.label(RichText::new(title).strong().color(Color32::from_rgb(180, 180, 255)));
|
||||||
|
ui.add_space(2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_optional_f64(val: Option<f64>, decimals: usize, suffix: &str) -> String {
|
||||||
|
match val {
|
||||||
|
Some(v) => format!("{:.prec$}{suffix}", v, prec = decimals),
|
||||||
|
None => "N/A".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_optional_currency(val: Option<f64>) -> String {
|
||||||
|
match val {
|
||||||
|
Some(v) => {
|
||||||
|
let abs = v.abs();
|
||||||
|
let sign = if v < 0.0 { "-" } else { "" };
|
||||||
|
if abs >= 1_000_000_000_000.0 {
|
||||||
|
format!("{sign}${:.2}T", abs / 1e12)
|
||||||
|
} else if abs >= 1_000_000_000.0 {
|
||||||
|
format!("{sign}${:.2}B", abs / 1e9)
|
||||||
|
} else if abs >= 1_000_000.0 {
|
||||||
|
format!("{sign}${:.1}M", abs / 1e6)
|
||||||
|
} else {
|
||||||
|
format!("{sign}${:.2}", abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "N/A".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/updater.rs
Normal file
107
src/updater.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
const GITEA_BASE: &str = "https://gitea.whitlocktech.com";
|
||||||
|
const REPO_OWNER: &str = "whitlocktech";
|
||||||
|
const REPO_NAME: &str = "tickr";
|
||||||
|
const ASSET_NAME: &str = "tickr.exe";
|
||||||
|
|
||||||
|
/// Check whether a newer release exists on Gitea.
|
||||||
|
/// Returns `Some((tag_name, download_url))` when the remote version is strictly
|
||||||
|
/// greater than the running binary, `None` when already up-to-date.
|
||||||
|
pub async fn check_for_update() -> Result<Option<(String, String)>> {
|
||||||
|
let url = format!(
|
||||||
|
"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", format!("tickr/{}", crate::VERSION))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to reach Gitea")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
// 404 = no releases yet; treat as up-to-date rather than an error
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp.json().await.context("Invalid JSON from Gitea")?;
|
||||||
|
|
||||||
|
let tag = body["tag_name"]
|
||||||
|
.as_str()
|
||||||
|
.context("Missing tag_name in release")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let download_url = body["assets"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|assets| {
|
||||||
|
assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a["name"].as_str() == Some(ASSET_NAME))
|
||||||
|
.and_then(|a| a["browser_download_url"].as_str())
|
||||||
|
})
|
||||||
|
.context("Asset not found in release")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Strip leading 'v' and compare semver
|
||||||
|
let remote_str = tag.trim_start_matches('v');
|
||||||
|
let remote_ver = semver::Version::parse(remote_str)
|
||||||
|
.with_context(|| format!("Bad remote version: {remote_str}"))?;
|
||||||
|
let local_ver = semver::Version::parse(crate::VERSION)
|
||||||
|
.with_context(|| format!("Bad local version: {}", crate::VERSION))?;
|
||||||
|
|
||||||
|
if remote_ver > local_ver {
|
||||||
|
Ok(Some((tag, download_url)))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the new binary and schedule a swap on next launch.
|
||||||
|
///
|
||||||
|
/// The download is streamed in chunks to avoid loading the whole binary into
|
||||||
|
/// memory. A small bat script is written alongside it; the bat waits 2 seconds
|
||||||
|
/// for the current process to exit, moves the new exe into place, relaunches
|
||||||
|
/// it, then deletes itself.
|
||||||
|
pub async fn download_and_swap(download_url: &str) -> Result<()> {
|
||||||
|
let exe_path = std::env::current_exe().context("Cannot resolve current exe path")?;
|
||||||
|
let exe_dir = exe_path.parent().context("Cannot resolve exe directory")?;
|
||||||
|
|
||||||
|
let update_path = exe_dir.join("tickr_update.exe");
|
||||||
|
let bat_path = exe_dir.join("tickr_swap.bat");
|
||||||
|
|
||||||
|
// ── Stream download ───────────────────────────────────────────────────
|
||||||
|
let mut resp = reqwest::Client::new()
|
||||||
|
.get(download_url)
|
||||||
|
.header("User-Agent", format!("tickr/{}", crate::VERSION))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Download request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("Download returned HTTP {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = std::fs::File::create(&update_path)
|
||||||
|
.with_context(|| format!("Cannot create {}", update_path.display()))?;
|
||||||
|
|
||||||
|
while let Some(chunk) = resp.chunk().await.context("Download interrupted")? {
|
||||||
|
file.write_all(&chunk).context("Write error during download")?;
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// ── Write swap bat ────────────────────────────────────────────────────
|
||||||
|
let bat = "@echo off\r\ntimeout /t 2 /nobreak >nul\r\nmove /y tickr_update.exe tickr.exe\r\nstart \"\" tickr.exe\r\ndel \"%~f0\"\r\n";
|
||||||
|
std::fs::write(&bat_path, bat)
|
||||||
|
.with_context(|| format!("Cannot write {}", bat_path.display()))?;
|
||||||
|
|
||||||
|
// ── Launch bat detached and exit ──────────────────────────────────────
|
||||||
|
std::process::Command::new("cmd")
|
||||||
|
.args(["/c", "tickr_swap.bat"])
|
||||||
|
.current_dir(exe_dir)
|
||||||
|
.spawn()
|
||||||
|
.context("Failed to launch swap bat")?;
|
||||||
|
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
1
src/version.rs
Normal file
1
src/version.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
66
src/watchlist.rs
Normal file
66
src/watchlist.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WatchlistEntry {
|
||||||
|
pub symbol: String,
|
||||||
|
pub last_signal: String,
|
||||||
|
pub last_score: f64,
|
||||||
|
pub last_checked: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Watchlist {
|
||||||
|
#[serde(default)]
|
||||||
|
pub tickers: Vec<WatchlistEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Watchlist {
|
||||||
|
pub fn contains(&self, symbol: &str) -> bool {
|
||||||
|
self.tickers.iter().any(|e| e.symbol.eq_ignore_ascii_case(symbol))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, entry: WatchlistEntry) {
|
||||||
|
if !self.contains(&entry.symbol) {
|
||||||
|
self.tickers.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, symbol: &str) {
|
||||||
|
self.tickers.retain(|e| !e.symbol.eq_ignore_ascii_case(symbol));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_entry(&mut self, symbol: &str, signal: &str, score: f64) {
|
||||||
|
if let Some(e) = self.tickers.iter_mut().find(|e| e.symbol.eq_ignore_ascii_case(symbol)) {
|
||||||
|
e.last_signal = signal.to_string();
|
||||||
|
e.last_score = score;
|
||||||
|
e.last_checked = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn watchlist_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_local_dir().map(|d| d.join("tickr").join("watchlist.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Watchlist {
|
||||||
|
load_inner().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_inner() -> Result<Watchlist> {
|
||||||
|
let path = watchlist_path().ok_or_else(|| anyhow::anyhow!("No data dir"))?;
|
||||||
|
let text = std::fs::read_to_string(&path)?;
|
||||||
|
Ok(toml::from_str(&text)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(wl: &Watchlist) -> Result<()> {
|
||||||
|
let path = watchlist_path().ok_or_else(|| anyhow::anyhow!("No data dir"))?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let text = toml::to_string_pretty(wl)?;
|
||||||
|
std::fs::write(path, text)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user