This commit is contained in:
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