diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ac2b23f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0b6a3bc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,22 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build *)", + "Bash(curl -s -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36\" \"https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=quoteType\")", + "Bash(curl -s -c /tmp/yf_cookies.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://finance.yahoo.com/\" -o /dev/null -w \"%{http_code}\")", + "Bash(curl -s -b /tmp/yf_cookies.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://query2.finance.yahoo.com/v1/test/getcrumb\")", + "Bash(curl -v -c /tmp/yf_cookies2.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\" -H \"Accept-Language: en-US,en;q=0.5\" -L \"https://finance.yahoo.com/quote/AAPL/\" -o /dev/null)", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: */*\" -H \"Referer: https://finance.yahoo.com/\" \"https://query2.finance.yahoo.com/v1/test/getcrumb\")", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=quoteType&crumb=t5.A45X0LQC')", + "Bash(python3 -m json.tool)", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=financialData,summaryDetail,defaultKeyStatistics&crumb=t5.A45X0LQC')", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=assetProfile,earningsHistory,incomeStatementHistory,cashflowStatementHistory,balanceSheetHistory,calendarEvents,recommendationTrend&crumb=t5.A45X0LQC')", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=incomeStatementHistory&crumb=t5.A45X0LQC')", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=earningsHistory&crumb=t5.A45X0LQC')", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=cashflowStatementHistory,balanceSheetHistory,calendarEvents&crumb=t5.A45X0LQC')", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=financialData,recommendationTrend&crumb=t5.A45X0LQC')", + "Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=incomeStatementHistoryQuarterly&crumb=t5.A45X0LQC')", + "Bash(cargo doc *)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba7b223 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target +Cargo.lock +*.exe +*.pdb +tickr_update.exe +tickr_swap.bat +**/.rs.bk +.env diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..59ca869 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "tickr" +version = "0.4.0" +edition = "2021" + +[[bin]] +name = "tickr" +path = "src/main.rs" + +[dependencies] +eframe = { version = "0.27", features = ["default"] } +egui = "0.27" +reqwest = { version = "0.12", features = ["json", "rustls-tls", "cookies"], default-features = false } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1" +toml = "0.8" +dirs = "5" +semver = "1" +tray-icon = "0.14" +image = { version = "0.25", default-features = false, features = ["ico"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true + +[target.'cfg(windows)'.build-dependencies] +winres = "0.1" diff --git a/src/analysis/equity.rs b/src/analysis/equity.rs new file mode 100644 index 0000000..bef4903 --- /dev/null +++ b/src/analysis/equity.rs @@ -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 = 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 { + 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) + } +} diff --git a/src/analysis/etf.rs b/src/analysis/etf.rs new file mode 100644 index 0000000..088999e --- /dev/null +++ b/src/analysis/etf.rs @@ -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 +} diff --git a/src/analysis/hype.rs b/src/analysis/hype.rs new file mode 100644 index 0000000..7c931c6 --- /dev/null +++ b/src/analysis/hype.rs @@ -0,0 +1,92 @@ +use crate::data::TickerData; + +pub struct HypeResult { + pub flags: Vec, + 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 } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs new file mode 100644 index 0000000..2e0806a --- /dev/null +++ b/src/analysis/mod.rs @@ -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, + + pub category_scores: Vec, + pub speculation_flags: Vec, + pub hype_flags: Vec, + pub analyst_trend: Option, + + pub section_verdict: String, + pub section_what_company_does: String, + pub section_why_opinion: String, + pub section_looks_good: Vec, + pub section_worries: Vec, + 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), + } +} diff --git a/src/analysis/speculation.rs b/src/analysis/speculation.rs new file mode 100644 index 0000000..980290c --- /dev/null +++ b/src/analysis/speculation.rs @@ -0,0 +1,61 @@ +use crate::data::TickerData; + +pub struct SpeculationResult { + pub flags: Vec, + 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 } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e974daa --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1226 @@ +use chrono::Utc; +use egui::{Color32, Context, RichText, TextEdit}; +use std::sync::mpsc; + +use crate::analysis::{self, AnalysisResult}; +use crate::config::Config; +use crate::data::cache::CacheStore; +use crate::data::{self, AnalysisInput}; +use crate::ui::{analysis_panel, data_panel}; +use crate::watchlist::{self, Watchlist, WatchlistEntry}; + +// ─── Background task message types ──────────────────────────────────────── + +enum BgCmd { + UpdateWatchlist(Vec), + RunNow, + CheckUpdate, + Quit, +} + +enum UpdateCheckResult { + Available(String, String), // tag, download_url + UpToDate, +} + +enum BgCheckResult { + Entry { + symbol: String, + input: AnalysisInput, + result: AnalysisResult, + prev_signal: String, + prev_score: f64, + }, + CycleDone, +} + +// ─── App state ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +enum FetchState { + Idle, + Loading, + Done, + Error(String), +} + +pub struct TickrApp { + // Analysis + ticker_input: String, + current_analysis: Option<(AnalysisInput, AnalysisResult)>, + fetch_state: FetchState, + cache: CacheStore, + config: Config, + status_message: String, + save_status: Option, + + // Async fetch channel + fetch_tx: mpsc::Sender>, + fetch_rx: mpsc::Receiver>, + tokio_rt: tokio::runtime::Runtime, + + // UI state + show_settings: bool, + show_watchlist_panel: bool, + + // Watchlist + watchlist: Watchlist, + bg_cmd_tx: Option>, + bg_result_rx: mpsc::Receiver, + snapshot_batch: Vec<(String, String, AnalysisResult)>, // (ticker, company, result) — accumulated per cycle + + // Updater + update_available: Option<(String, String)>, // (tag, download_url) + update_in_progress: bool, + update_result_rx: mpsc::Receiver, + + // Tray + #[allow(dead_code)] + tray_icon: Option, + tray_open_id: Option, + tray_check_id: Option, + tray_quit_id: Option, + window_visible: bool, + hide_to_tray: bool, + should_quit: bool, +} + +impl TickrApp { + pub fn new( + cc: &eframe::CreationContext<'_>, + config: Config, + icon_rgba: Vec, + icon_size: u32, + start_visible: bool, + ) -> Self { + let ctx = &cc.egui_ctx; + let mut visuals = egui::Visuals::dark(); + visuals.override_text_color = Some(Color32::from_rgb(220, 220, 220)); + ctx.set_visuals(visuals); + + let (fetch_tx, fetch_rx) = mpsc::channel(); + let (bg_result_tx, bg_result_rx) = mpsc::channel::(); + let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); + + let watchlist = watchlist::load(); + + // Set up tray + let (tray_icon, tray_open_id, tray_check_id, tray_quit_id) = + setup_tray(icon_rgba.clone(), icon_size, watchlist.tickers.len()); + + // Spawn background check task + let (bg_cmd_tx, bg_cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (update_result_tx, update_result_rx) = mpsc::channel::(); + let initial_entries = watchlist.tickers.clone(); + let bg_config = config.clone(); + rt.spawn(background_task(bg_cmd_rx, bg_result_tx, update_result_tx, initial_entries, bg_config)); + + Self { + ticker_input: String::new(), + current_analysis: None, + fetch_state: FetchState::Idle, + cache: CacheStore::new(config.cache.ttl_minutes), + status_message: "Ready".to_string(), + save_status: None, + fetch_tx, + fetch_rx, + tokio_rt: rt, + show_settings: false, + show_watchlist_panel: false, + watchlist, + bg_cmd_tx: Some(bg_cmd_tx), + bg_result_rx, + snapshot_batch: Vec::new(), + update_available: None, + update_in_progress: false, + update_result_rx, + tray_icon, + tray_open_id, + tray_check_id, + tray_quit_id, + window_visible: start_visible, + hide_to_tray: false, + should_quit: false, + config, + } + } + + // ─── Fetch ──────────────────────────────────────────────────────────── + + fn trigger_fetch(&mut self, symbol: &str, bypass_cache: bool) { + let sym = symbol.trim().to_uppercase(); + if sym.is_empty() { + return; + } + + if !bypass_cache { + if let Some(cached) = self.cache.get(&sym) { + let age = self.cache.age_minutes(&sym).unwrap_or(0); + let result = analysis::analyze(cached, &self.config); + self.current_analysis = Some((cached.clone(), result)); + self.fetch_state = FetchState::Done; + self.status_message = format!("Using cached data (fetched {age} min ago)"); + return; + } + } + + self.fetch_state = FetchState::Loading; + self.status_message = format!("Fetching data for {sym}…"); + let tx = self.fetch_tx.clone(); + self.tokio_rt.spawn(async move { + let result = data::fetch::fetch_ticker(&sym).await; + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + } + + fn poll_fetch_result(&mut self) { + if let Ok(result) = self.fetch_rx.try_recv() { + match result { + Ok(input) => { + let sym = input.symbol().to_string(); + self.cache.insert(input.clone()); + let analysis = analysis::analyze(&input, &self.config); + self.current_analysis = Some((input, analysis)); + self.fetch_state = FetchState::Done; + self.status_message = format!("Analysis complete for {sym}"); + } + Err(e) => { + let sym = self.ticker_input.trim().to_uppercase(); + if let Some(stale) = self.cache.get_stale(&sym) { + let result = analysis::analyze(stale, &self.config); + self.current_analysis = Some((stale.clone(), result)); + self.fetch_state = + FetchState::Error(format!("Network error — showing stale data")); + self.status_message = "Network error — using stale cache".to_string(); + } else { + self.fetch_state = FetchState::Error(e.clone()); + self.status_message = format!("Error: {e}"); + } + } + } + } + } + + // ─── Background check ───────────────────────────────────────────────── + + fn poll_bg_results(&mut self) { + while let Ok(res) = self.bg_result_rx.try_recv() { + match res { + BgCheckResult::CycleDone => { + self.send_watchlist_refresh_batch(); + } + BgCheckResult::Entry { symbol, input, result, prev_signal, prev_score } => { + let new_signal = result.decision.label().to_string(); + let new_score = result.final_score; + let signal_changed = new_signal != prev_signal; + let score_drift = (new_score - prev_score).abs(); + + // Update cache with fresh data + self.cache.insert(input.clone()); + + // Update watchlist entry + self.watchlist.update_entry(&symbol, &new_signal, new_score); + let _ = watchlist::save(&self.watchlist); + + // Resolve company name (needed for both Discord and snapshot) + let company = match &input { + AnalysisInput::Equity(d) => { + d.company_name.clone().unwrap_or_else(|| symbol.clone()) + } + AnalysisInput::Etf(d) => { + d.fund_name.clone().unwrap_or_else(|| symbol.clone()) + } + }; + + // Accumulate for end-of-cycle snapshot (fires unconditionally when CycleDone arrives) + self.snapshot_batch.push((symbol.clone(), company.clone(), result.clone())); + + // Existing per-ticker notifications (gated by user flags) + let url = self.config.discord.webhook_url.clone(); + if !url.is_empty() { + if signal_changed && self.config.discord.notify_on_change { + let url2 = url.clone(); + let sym = symbol.clone(); + let prev = prev_signal.clone(); + let new_s = new_signal.clone(); + let analysis = result.clone(); + let co = company.clone(); + self.tokio_rt.spawn(async move { + if let Err(e) = + crate::discord::send_signal_embed(&url2, &sym, &co, &prev, &new_s, &analysis) + .await + { + eprintln!("Discord error: {e}"); + } + }); + } + + if !signal_changed && score_drift >= 0.2 && self.config.discord.notify_on_drift { + let sym = symbol.clone(); + let old_s = prev_score; + let analysis = result.clone(); + let sig_copy = new_signal.clone(); + self.tokio_rt.spawn(async move { + if let Err(e) = crate::discord::send_drift_embed( + &url, &sym, old_s, new_score, &sig_copy, &analysis, + ) + .await + { + eprintln!("Discord error: {e}"); + } + }); + } + } + + // If currently displayed ticker was refreshed, update the panel + if self.ticker_input.trim().to_uppercase() == symbol.to_uppercase() { + let a = analysis::analyze(&input, &self.config); + self.current_analysis = Some((input, a)); + } + + self.status_message = format!("Watchlist: {} updated ({})", symbol, new_signal); + } + } + } + } + + fn send_watchlist_refresh_batch(&mut self) { + if self.config.discord.webhook_url.is_empty() || self.snapshot_batch.is_empty() { + self.snapshot_batch.clear(); + return; + } + let url = self.config.discord.webhook_url.clone(); + let batch = std::mem::take(&mut self.snapshot_batch); + + self.tokio_rt.spawn(async move { + // Split into groups of ≤10 (Discord's embed-per-message limit) + let chunks: Vec> = + batch.chunks(10).map(|c| c.to_vec()).collect(); + for (i, chunk) in chunks.into_iter().enumerate() { + if i > 0 { + // Rate-limit protection: 30 s between groups + tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + } + if let Err(e) = crate::discord::send_watchlist_snapshot(&url, &chunk).await { + eprintln!("Discord snapshot error: {e}"); + } + } + }); + } + + fn send_watchlist_to_bg(&self) { + if let Some(ref tx) = self.bg_cmd_tx { + let _ = tx.send(BgCmd::UpdateWatchlist(self.watchlist.tickers.clone())); + } + } + + fn trigger_bg_check_now(&self) { + if let Some(ref tx) = self.bg_cmd_tx { + let _ = tx.send(BgCmd::RunNow); + } + } + + fn trigger_update_check(&self) { + if let Some(ref tx) = self.bg_cmd_tx { + let _ = tx.send(BgCmd::CheckUpdate); + } + } + + fn poll_update_results(&mut self) { + while let Ok(result) = self.update_result_rx.try_recv() { + match result { + UpdateCheckResult::Available(tag, url) => { + if self.config.updater.auto_update { + self.update_in_progress = true; + let url2 = url.clone(); + self.tokio_rt.spawn(async move { + if let Err(e) = crate::updater::download_and_swap(&url2).await { + eprintln!("Auto-update failed: {e}"); + } + }); + } else { + self.update_available = Some((tag.clone(), url)); + self.status_message = format!("Update available: {tag}"); + } + } + UpdateCheckResult::UpToDate => { + if self.update_available.is_none() { + self.status_message = format!("Tickr v{} — Up to date", crate::VERSION); + } + } + } + } + } + + fn update_tray_tooltip(&self) { + if let Some(ref tray) = self.tray_icon { + let n = self.watchlist.tickers.len(); + let tip = format!("Tickr — {n} ticker{} watched", if n == 1 { "" } else { "s" }); + let _ = tray.set_tooltip(Some(tip)); + } + } + + // ─── Watchlist helpers ──────────────────────────────────────────────── + + fn add_to_watchlist(&mut self) { + let Some((input, result)) = &self.current_analysis else { + return; + }; + let sym = input.symbol().to_uppercase(); + if self.watchlist.contains(&sym) { + return; + } + + let company = match input { + crate::data::AnalysisInput::Equity(d) => { + d.company_name.clone().unwrap_or_else(|| sym.clone()) + } + crate::data::AnalysisInput::Etf(d) => { + d.fund_name.clone().unwrap_or_else(|| sym.clone()) + } + }; + + let entry = WatchlistEntry { + symbol: sym.clone(), + last_signal: result.decision.label().to_string(), + last_score: result.final_score, + last_checked: Utc::now(), + }; + self.watchlist.add(entry); + let _ = watchlist::save(&self.watchlist); + self.send_watchlist_to_bg(); + self.update_tray_tooltip(); + + if !self.config.discord.webhook_url.is_empty() { + let url = self.config.discord.webhook_url.clone(); + let analysis = result.clone(); + self.tokio_rt.spawn(async move { + if let Err(e) = + crate::discord::send_added_to_watchlist_embed(&url, &sym, &company, &analysis) + .await + { + eprintln!("Discord error: {e}"); + } + }); + } + } + + fn remove_from_watchlist(&mut self, symbol: &str) { + self.watchlist.remove(symbol); + let _ = watchlist::save(&self.watchlist); + self.send_watchlist_to_bg(); + self.update_tray_tooltip(); + } + + fn current_ticker_watched(&self) -> bool { + let sym = self.ticker_input.trim().to_uppercase(); + !sym.is_empty() && self.watchlist.contains(&sym) + } + + fn can_watch(&self) -> bool { + let sym = self.ticker_input.trim().to_uppercase(); + self.current_analysis + .as_ref() + .map_or(false, |(input, _)| input.symbol().to_uppercase() == sym) + } + + // ─── Window / tray helpers ──────────────────────────────────────────── + + fn show_window(&mut self, ctx: &Context) { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + self.window_visible = true; + } + + // ─── Save report ────────────────────────────────────────────────────── + + fn save_report(&mut self) { + let Some((input, result)) = &self.current_analysis else { + return; + }; + let sym = input.symbol(); + let date = Utc::now().format("%Y-%m-%d"); + let ext = &self.config.output.save_format; + let filename = format!("{sym}_{date}.{ext}"); + + let save_dir = if self.config.output.save_directory.is_empty() { + dirs::desktop_dir().unwrap_or_else(|| std::path::PathBuf::from(".")) + } else { + std::path::PathBuf::from(&self.config.output.save_directory) + }; + + let path = save_dir.join(&filename); + match std::fs::write(&path, build_report_text(input, result)) { + Ok(()) => { + self.save_status = Some(format!("Saved: {}", path.display())); + let _ = std::process::Command::new("explorer").arg(save_dir).spawn(); + } + Err(e) => { + self.save_status = Some(format!("Save failed: {e}")); + } + } + } + + // ─── UI renderers ───────────────────────────────────────────────────── + + fn render_toolbar(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label(RichText::new("Ticker:").strong()); + let resp = ui.add( + TextEdit::singleline(&mut self.ticker_input) + .desired_width(120.0) + .hint_text("e.g. AAPL"), + ); + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let sym = self.ticker_input.clone(); + self.trigger_fetch(&sym, false); + } + + let loading = self.fetch_state == FetchState::Loading; + + if ui.add_enabled(!loading, egui::Button::new("Analyze")).clicked() { + let sym = self.ticker_input.clone(); + self.trigger_fetch(&sym, false); + } + + // Watch button + let watched = self.current_ticker_watched(); + let can_watch = self.can_watch(); + let watch_label = if watched { "✓ Watching" } else { "+ Watch" }; + let watch_btn = ui.add_enabled(can_watch, egui::Button::new(watch_label)); + if watch_btn.clicked() { + if watched { + let sym = self.ticker_input.trim().to_uppercase(); + self.remove_from_watchlist(&sym); + } else { + self.add_to_watchlist(); + } + } + if watch_btn.hovered() && watched { + egui::show_tooltip_text(ui.ctx(), egui::Id::new("watch_tip"), "Click to remove from watchlist"); + } + + if ui.add_enabled(!loading, egui::Button::new("⟳ Refresh")).clicked() { + let sym = self.ticker_input.clone(); + self.trigger_fetch(&sym, true); + } + + if loading { + ui.spinner(); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("⚙ Settings").clicked() { + self.show_settings = !self.show_settings; + } + let wl_label = if self.show_watchlist_panel { + "▼ Watchlist" + } else { + "▲ Watchlist" + }; + if ui.button(wl_label).clicked() { + self.show_watchlist_panel = !self.show_watchlist_panel; + } + }); + }); + } + + fn render_status_bar(&self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let color = match &self.fetch_state { + FetchState::Error(_) => Color32::from_rgb(231, 76, 60), + FetchState::Loading => Color32::from_rgb(241, 196, 15), + FetchState::Done => Color32::from_rgb(39, 174, 96), + FetchState::Idle => Color32::GRAY, + }; + ui.label(RichText::new(format!("Status: {}", self.status_message)).color(color)); + + if let Some(ref msg) = self.save_status { + ui.separator(); + ui.label(RichText::new(msg).color(Color32::from_rgb(100, 200, 255))); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new("v0.3.0").color(Color32::GRAY)); + }); + }); + } + + fn render_watchlist_panel(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label(RichText::new("WATCHLIST").strong()); + ui.separator(); + if ui.button("Run Check Now").clicked() { + self.trigger_bg_check_now(); + self.status_message = "Watchlist check triggered…".to_string(); + } + }); + + if self.watchlist.tickers.is_empty() { + ui.label( + RichText::new("No tickers watched. Analyze a ticker and click [+ Watch] to add it.") + .color(Color32::GRAY) + .italics(), + ); + return; + } + + let now = Utc::now(); + let to_remove: std::cell::RefCell> = std::cell::RefCell::new(None); + let to_load: std::cell::RefCell> = std::cell::RefCell::new(None); + + egui::Grid::new("watchlist_grid") + .striped(true) + .min_col_width(80.0) + .show(ui, |ui| { + // Header + ui.label(RichText::new("Symbol").strong()); + ui.label(RichText::new("Signal").strong()); + ui.label(RichText::new("Score").strong()); + ui.label(RichText::new("Last Checked").strong()); + ui.label(""); // remove button column + ui.end_row(); + + for entry in &self.watchlist.tickers { + let sym = entry.symbol.clone(); + let btn = ui.selectable_label(false, RichText::new(&sym).strong()); + if btn.clicked() { + *to_load.borrow_mut() = Some(sym.clone()); + } + btn.on_hover_text("Click to load analysis"); + + // Signal with color + let sig_color = match entry.last_signal.as_str() { + "BUY" => Color32::from_rgb(39, 174, 96), + "SELL" => Color32::from_rgb(231, 76, 60), + _ => Color32::from_rgb(241, 196, 15), + }; + ui.label(RichText::new(&entry.last_signal).color(sig_color).strong()); + + // Score + ui.label(format!("{:.2}", entry.last_score)); + + // Age + let mins = (now - entry.last_checked).num_minutes(); + let age = if mins < 2 { + "just now".to_string() + } else if mins < 60 { + format!("{mins} min ago") + } else { + format!("{:.0} hr ago", mins as f64 / 60.0) + }; + ui.label(age); + + // Remove button + if ui + .small_button(RichText::new("✕").color(Color32::from_rgb(180, 60, 60))) + .clicked() + { + *to_remove.borrow_mut() = Some(sym.clone()); + } + + ui.end_row(); + } + }); + + if let Some(sym) = to_load.into_inner() { + self.ticker_input = sym.clone(); + self.trigger_fetch(&sym, false); + } + if let Some(sym) = to_remove.into_inner() { + self.remove_from_watchlist(&sym); + } + } + + fn render_settings_window(&mut self, ctx: &Context) { + let mut open = self.show_settings; + egui::Window::new("Settings") + .open(&mut open) + .resizable(true) + .min_width(380.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + // ── User ────────────────────────────────────────── + ui.heading("User"); + ui.horizontal(|ui| { + ui.label("Name:"); + ui.text_edit_singleline(&mut self.config.user.name); + }); + + ui.add_space(8.0); + + // ── Preferences ─────────────────────────────────── + ui.heading("Analysis Preferences"); + ui.horizontal(|ui| { + ui.label("Risk tolerance:"); + egui::ComboBox::from_id_source("risk_tol") + .selected_text(&self.config.preferences.default_risk_tolerance) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.config.preferences.default_risk_tolerance, + "low".into(), + "Low", + ); + ui.selectable_value( + &mut self.config.preferences.default_risk_tolerance, + "medium".into(), + "Medium", + ); + ui.selectable_value( + &mut self.config.preferences.default_risk_tolerance, + "high".into(), + "High", + ); + }); + }); + ui.checkbox( + &mut self.config.preferences.dividend_focus, + "Prioritize dividend analysis", + ); + ui.checkbox( + &mut self.config.preferences.avoid_borderline, + "Widen PASS zone (avoid borderline calls)", + ); + ui.checkbox( + &mut self.config.preferences.start_minimized, + "Start minimized to system tray", + ); + + ui.add_space(8.0); + + // ── Cache ───────────────────────────────────────── + ui.heading("Cache"); + ui.horizontal(|ui| { + ui.label("Cache TTL (minutes):"); + ui.add(egui::DragValue::new(&mut self.config.cache.ttl_minutes) + .speed(1) + .clamp_range(1..=120)); + }); + + ui.add_space(8.0); + + // ── Output ──────────────────────────────────────── + ui.heading("Report Output"); + ui.checkbox(&mut self.config.output.auto_save, "Auto-save report after analysis"); + ui.horizontal(|ui| { + ui.label("Format:"); + egui::ComboBox::from_id_source("save_fmt") + .selected_text(&self.config.output.save_format) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.config.output.save_format, + "txt".into(), + "Plain text (.txt)", + ); + ui.selectable_value( + &mut self.config.output.save_format, + "md".into(), + "Markdown (.md)", + ); + }); + }); + ui.horizontal(|ui| { + ui.label("Save directory:"); + ui.text_edit_singleline(&mut self.config.output.save_directory) + .on_hover_text("Leave blank to use Desktop"); + }); + + ui.add_space(8.0); + + // ── Watchlist ───────────────────────────────────── + ui.heading("Watchlist"); + ui.horizontal(|ui| { + ui.label("Check interval (minutes):"); + ui.add(egui::DragValue::new( + &mut self.config.watchlist_check.interval_minutes, + ) + .speed(1) + .clamp_range(5..=1440)); + }); + ui.label( + RichText::new("⚠ Interval change takes effect on next app restart.") + .color(Color32::GRAY) + .small(), + ); + + ui.add_space(8.0); + + // ── Discord ─────────────────────────────────────── + ui.heading("Discord Notifications"); + ui.horizontal(|ui| { + ui.label("Webhook URL:"); + ui.add( + TextEdit::singleline(&mut self.config.discord.webhook_url) + .desired_width(240.0) + .hint_text("https://discord.com/api/webhooks/…"), + ); + }); + ui.checkbox( + &mut self.config.discord.notify_on_change, + "Notify on signal change (BUY/SELL/PASS flip)", + ); + ui.checkbox( + &mut self.config.discord.notify_on_drift, + "Notify on score drift ≥ 0.2 (without signal flip)", + ); + + if !self.config.discord.webhook_url.is_empty() { + if ui.button("🔔 Send test notification").clicked() { + let url = self.config.discord.webhook_url.clone(); + self.tokio_rt.spawn(async move { + let _ = crate::discord::send_signal_embed( + &url, + "TEST", + "Tickr Test", + "PASS", + "BUY", + // We need a dummy AnalysisResult — just log the error + &make_dummy_result(), + ) + .await; + }); + self.status_message = "Test notification sent.".to_string(); + } + } + + ui.add_space(8.0); + + // ── Updates ─────────────────────────────────────── + ui.heading("Updates"); + let auto_changed = ui.checkbox( + &mut self.config.updater.auto_update, + "Auto-update when new version is available", + ).changed(); + if auto_changed { + let _ = crate::config::save(&self.config); + } + + ui.horizontal(|ui| { + let checking = self.update_in_progress; + if ui.add_enabled(!checking, egui::Button::new("🔍 Check Now")).clicked() { + self.trigger_update_check(); + } + + if self.update_in_progress { + ui.spinner(); + ui.label(RichText::new("Downloading update…").color(Color32::from_rgb(241, 196, 15))); + } else if let Some((ref tag, ref url)) = self.update_available.clone() { + ui.label( + RichText::new(format!("Update available: {tag}")) + .color(Color32::from_rgb(39, 174, 96)) + .strong(), + ); + if ui.button("⬇ Apply Update").clicked() { + self.update_in_progress = true; + let url2 = url.clone(); + self.tokio_rt.spawn(async move { + if let Err(e) = crate::updater::download_and_swap(&url2).await { + eprintln!("Update failed: {e}"); + } + }); + } + } else { + ui.label( + RichText::new(format!("Tickr v{} — Up to date", crate::VERSION)) + .color(Color32::GRAY), + ); + } + }); + + ui.add_space(12.0); + + // ── Save button ─────────────────────────────────── + if ui.button("💾 Save Settings").clicked() { + if let Err(e) = crate::config::save(&self.config) { + self.status_message = format!("Failed to save settings: {e}"); + } else { + self.cache = CacheStore::new(self.config.cache.ttl_minutes); + self.status_message = "Settings saved.".to_string(); + } + } + }); + }); + + if !open { + self.show_settings = false; + } + } + + fn render_earnings_warning(&self, ui: &mut egui::Ui) { + if let Some((AnalysisInput::Equity(data), _)) = &self.current_analysis { + if let Some(next) = data.next_earnings_date { + let today = Utc::now().date_naive(); + let days = (next - today).num_days(); + if days >= 0 && days <= 7 { + let frame = egui::Frame::none() + .fill(Color32::from_rgb(80, 60, 0)) + .inner_margin(6.0); + frame.show(ui, |ui| { + ui.label( + RichText::new(format!( + "⚠ Earnings in {days} day{} — higher uncertainty", + if days == 1 { "" } else { "s" } + )) + .color(Color32::YELLOW), + ); + }); + } + } + } + } + + fn poll_tray_events(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { + use tray_icon::TrayIconEvent; + + while let Ok(event) = TrayIconEvent::receiver().try_recv() { + match event { + TrayIconEvent::Click { .. } => self.show_window(ctx), + _ => {} + } + } + + use tray_icon::menu::MenuEvent; + while let Ok(event) = MenuEvent::receiver().try_recv() { + if self.tray_open_id.as_ref() == Some(&event.id) { + self.show_window(ctx); + } else if self.tray_check_id.as_ref() == Some(&event.id) { + self.trigger_bg_check_now(); + self.status_message = "Watchlist check triggered…".to_string(); + } else if self.tray_quit_id.as_ref() == Some(&event.id) { + self.should_quit = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } +} + +// ─── eframe::App impl ───────────────────────────────────────────────────── + +impl eframe::App for TickrApp { + fn update(&mut self, ctx: &Context, frame: &mut eframe::Frame) { + // Handle window close button + if ctx.input(|i| i.viewport().close_requested()) { + if self.should_quit { + if let Some(ref tx) = self.bg_cmd_tx { + let _ = tx.send(BgCmd::Quit); + } + // Let it close (do not cancel) + } else { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.hide_to_tray = true; + } + } + + self.poll_fetch_result(); + self.poll_bg_results(); + self.poll_update_results(); + self.poll_tray_events(ctx, frame); + + // Apply pending hide-to-tray + if self.hide_to_tray { + self.hide_to_tray = false; + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + self.window_visible = false; + } + + // Keep event loop alive for tray responsiveness when window is hidden + if !self.window_visible { + ctx.request_repaint_after(std::time::Duration::from_millis(300)); + return; // Don't render UI when hidden + } + + if self.fetch_state == FetchState::Loading { + ctx.request_repaint(); + } + + if self.show_settings { + self.render_settings_window(ctx); + } + + egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { + ui.add_space(4.0); + self.render_toolbar(ui); + ui.add_space(4.0); + self.render_earnings_warning(ui); + }); + + if self.show_watchlist_panel { + egui::TopBottomPanel::bottom("watchlist_panel") + .resizable(true) + .default_height(160.0) + .show(ctx, |ui| { + self.render_watchlist_panel(ui); + }); + } + + egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { + ui.add_space(2.0); + self.render_status_bar(ui); + ui.add_space(2.0); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + if let Some((input, result)) = &self.current_analysis.clone() { + let available = ui.available_size(); + let panel_width = available.x * 0.35; + + ui.columns(2, |cols| { + cols[0].set_width(panel_width); + data_panel::render(&mut cols[0], input, result); + + cols[1].vertical(|ui| { + let save_h = 30.0; + let analysis_h = available.y - save_h - 8.0; + egui::ScrollArea::vertical() + .id_source("analysis_outer_scroll") + .max_height(analysis_h) + .show(ui, |ui| { + analysis_panel::render(ui, result); + }); + ui.add_space(4.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("💾 Save Report").clicked() { + self.save_report(); + } + }); + }); + }); + } else { + ui.centered_and_justified(|ui| { + ui.label( + RichText::new("Enter a ticker symbol above and click Analyze") + .color(Color32::GRAY) + .size(16.0), + ); + }); + } + }); + } +} + +// ─── Background task ────────────────────────────────────────────────────── + +async fn background_task( + mut cmd_rx: tokio::sync::mpsc::UnboundedReceiver, + result_tx: mpsc::Sender, + update_tx: mpsc::Sender, + initial_entries: Vec, + config: Config, +) { + let watchlist_secs = config.watchlist_check.interval_minutes * 60; + let mut watchlist_interval = + tokio::time::interval(tokio::time::Duration::from_secs(watchlist_secs)); + watchlist_interval.tick().await; // skip immediate first tick + + let mut update_interval = + tokio::time::interval(tokio::time::Duration::from_secs(60 * 60)); + update_interval.tick().await; // skip immediate first tick + + let mut entries = initial_entries; + + loop { + tokio::select! { + _ = watchlist_interval.tick() => { + run_watchlist_check(&entries, &result_tx, &config).await; + } + _ = update_interval.tick() => { + if config.updater.enabled { + run_update_check(&update_tx).await; + } + } + Some(cmd) = cmd_rx.recv() => { + match cmd { + BgCmd::UpdateWatchlist(e) => entries = e, + BgCmd::RunNow => run_watchlist_check(&entries, &result_tx, &config).await, + BgCmd::CheckUpdate => run_update_check(&update_tx).await, + BgCmd::Quit => break, + } + } + } + } +} + +async fn run_update_check(update_tx: &mpsc::Sender) { + match crate::updater::check_for_update().await { + Ok(Some((tag, url))) => { let _ = update_tx.send(UpdateCheckResult::Available(tag, url)); } + Ok(None) => { let _ = update_tx.send(UpdateCheckResult::UpToDate); } + Err(e) => eprintln!("Update check failed: {e}"), + } +} + +async fn run_watchlist_check( + entries: &[WatchlistEntry], + result_tx: &mpsc::Sender, + config: &Config, +) { + for entry in entries { + match data::fetch::fetch_ticker(&entry.symbol).await { + Ok(input) => { + let result = analysis::analyze(&input, config); + let _ = result_tx.send(BgCheckResult::Entry { + symbol: entry.symbol.clone(), + input, + result, + prev_signal: entry.last_signal.clone(), + prev_score: entry.last_score, + }); + } + Err(e) => { + eprintln!("Watchlist check failed for {}: {e}", entry.symbol); + } + } + } + // Signal that the full cycle is complete so the UI thread can fire the batch snapshot + let _ = result_tx.send(BgCheckResult::CycleDone); +} + +// ─── Tray setup ─────────────────────────────────────────────────────────── + +fn setup_tray( + rgba: Vec, + size: u32, + watched_count: usize, +) -> ( + Option, + Option, + Option, + Option, +) { + use tray_icon::menu::{Menu, MenuItem, PredefinedMenuItem}; + use tray_icon::TrayIconBuilder; + + let icon = match tray_icon::Icon::from_rgba(rgba, size, size) { + Ok(i) => i, + Err(e) => { + eprintln!("Could not create tray icon: {e}"); + return (None, None, None, None); + } + }; + + let open_item = MenuItem::new("Open Tickr", true, None); + let sep1 = PredefinedMenuItem::separator(); + let check_item = MenuItem::new("Run Check Now", true, None); + let sep2 = PredefinedMenuItem::separator(); + let quit_item = MenuItem::new("Quit", true, None); + + let open_id = open_item.id().clone(); + let check_id = check_item.id().clone(); + let quit_id = quit_item.id().clone(); + + let menu = Menu::new(); + let _ = menu.append_items(&[ + &open_item, + &sep1, + &check_item, + &sep2, + &quit_item, + ]); + + let n = watched_count; + let tooltip = format!("Tickr — {n} ticker{} watched", if n == 1 { "" } else { "s" }); + + match TrayIconBuilder::new() + .with_icon(icon) + .with_tooltip(tooltip) + .with_menu(Box::new(menu)) + .build() + { + Ok(tray) => (Some(tray), Some(open_id), Some(check_id), Some(quit_id)), + Err(e) => { + eprintln!("Could not create system tray: {e}"); + (None, None, None, None) + } + } +} + +// ─── Report builder ─────────────────────────────────────────────────────── + +fn build_report_text(input: &AnalysisInput, result: &AnalysisResult) -> String { + use std::fmt::Write; + let mut out = String::new(); + + let sym = input.symbol(); + let date = Utc::now().format("%Y-%m-%d"); + let fetched = input.fetched_at().format("%Y-%m-%d %H:%M UTC"); + + let _ = writeln!(out, "TICKR ANALYSIS REPORT"); + let _ = writeln!(out, "Ticker: {sym}"); + let _ = writeln!(out, "Date: {date}"); + let _ = writeln!(out, "Data fetched: {fetched}"); + let _ = writeln!(out, "{}", "=".repeat(60)); + let _ = writeln!(out); + let _ = writeln!(out, "FINAL OPINION: {}", result.decision.label()); + let _ = writeln!(out, "Confidence: {}", result.confidence.label()); + let _ = writeln!(out, "Risk Level: {}", result.risk_level.label()); + let _ = writeln!(out); + + for (title, content) in &[ + ("ONE-SENTENCE VERDICT", result.section_verdict.as_str()), + ("WHAT THIS COMPANY DOES", result.section_what_company_does.as_str()), + ("WHY THIS IS THE OPINION", result.section_why_opinion.as_str()), + ("DIVIDEND / INCOME CHECK", result.section_dividend.as_str()), + ("GROWTH CHECK", result.section_growth.as_str()), + ("VALUATION CHECK", result.section_valuation.as_str()), + ("WHAT WOULD CHANGE MY MIND", result.section_change_mind.as_str()), + ("WHAT YOU SHOULD STUDY NEXT", result.section_study_next.as_str()), + ("PLAIN ENGLISH TRANSLATION", result.section_translation.as_str()), + ] { + let _ = writeln!(out, "{}", "-".repeat(40)); + let _ = writeln!(out, "{title}"); + let _ = writeln!(out, "{content}"); + let _ = writeln!(out); + } + + if !result.section_looks_good.is_empty() { + let _ = writeln!(out, "{}", "-".repeat(40)); + let _ = writeln!(out, "WHAT LOOKS GOOD"); + for item in &result.section_looks_good { + let _ = writeln!(out, " ✓ {item}"); + } + let _ = writeln!(out); + } + if !result.section_worries.is_empty() { + let _ = writeln!(out, "{}", "-".repeat(40)); + let _ = writeln!(out, "WHAT WORRIES ME"); + for item in &result.section_worries { + let _ = writeln!(out, " • {item}"); + } + let _ = writeln!(out); + } + + let _ = writeln!(out, "{}", "=".repeat(60)); + let _ = writeln!(out, "FINAL ANSWER: {}", result.section_final_answer); + let _ = writeln!(out, "REASON: {}", result.section_final_reason); + out +} + +// ─── Dummy result for test Discord notification ──────────────────────────── + +fn make_dummy_result() -> AnalysisResult { + use crate::analysis::{Confidence, Decision, RiskLevel}; + AnalysisResult { + decision: Decision::Buy, + confidence: Confidence::Medium, + risk_level: RiskLevel::Medium, + raw_score: 0.7, + final_score: 0.7, + force_pass_reason: None, + category_scores: vec![], + speculation_flags: vec![], + hype_flags: vec![], + analyst_trend: None, + section_verdict: "Test notification from Tickr".to_string(), + section_what_company_does: String::new(), + section_why_opinion: "This is a test Discord notification.".to_string(), + section_looks_good: vec![], + section_worries: vec![], + section_dividend: String::new(), + section_growth: String::new(), + section_valuation: String::new(), + section_change_mind: String::new(), + section_study_next: String::new(), + section_translation: String::new(), + section_final_answer: "BUY".to_string(), + section_final_reason: "Test".to_string(), + } +} diff --git a/src/assets/icon.ico b/src/assets/icon.ico new file mode 100644 index 0000000..1104b8a Binary files /dev/null and b/src/assets/icon.ico differ diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7bc1346 --- /dev/null +++ b/src/config.rs @@ -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 { + 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 { + 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 +} diff --git a/src/data/cache.rs b/src/data/cache.rs new file mode 100644 index 0000000..0eaaeab --- /dev/null +++ b/src/data/cache.rs @@ -0,0 +1,47 @@ +use chrono::{Duration, Utc}; +use std::collections::HashMap; + +use super::AnalysisInput; + +pub struct CacheStore { + entries: HashMap, + 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 { + let entry = self.entries.get(&symbol.to_uppercase())?; + let age = Utc::now() - entry.fetched_at(); + Some(age.num_minutes()) + } +} diff --git a/src/data/fetch.rs b/src/data/fetch.rs new file mode 100644 index 0000000..2c736b4 --- /dev/null +++ b/src/data/fetch.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::()) + } 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, Option) { + let prices: Vec = 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 { + 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, Option, Option) { + let parse = |period: &str| -> Option { + 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 { + 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 +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..ae744a4 --- /dev/null +++ b/src/data/mod.rs @@ -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, + pub business_summary: Option, + pub sector: Option, + pub industry: Option, + pub asset_type: AssetType, + + pub current_price: Option, + pub price_change_1m_pct: Option, + pub price_change_3m_pct: Option, + pub week_52_high: Option, + pub week_52_low: Option, + pub market_cap: Option, + pub beta: Option, + + pub pe_ratio: Option, + pub forward_pe: Option, + + pub revenue_quarters: Vec<(String, f64)>, + pub eps_quarters: Vec<(String, f64)>, + pub free_cash_flow_ttm: Option, + pub revenue_ttm: Option, + pub debt_to_equity: Option, + pub current_ratio: Option, + + pub dividend_yield: Option, + pub payout_ratio: Option, + pub dividend_5yr_growth: Option, + pub dividend_cut_flag: bool, + + pub analyst_consensus: Option, + pub analyst_buy_count: Option, + pub analyst_hold_count: Option, + pub analyst_sell_count: Option, + pub analyst_trend_current: Option, + pub analyst_trend_1m_ago: Option, + pub analyst_trend_2m_ago: Option, + + pub next_earnings_date: Option, + + pub company_headlines: Vec, + pub sector_headlines: Vec, + + pub data_completeness_pct: f64, + pub fetched_at: DateTime, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct EtfData { + pub symbol: String, + pub fund_name: Option, + pub category: Option, + pub asset_type: AssetType, + + pub current_price: Option, + pub price_change_1m_pct: Option, + pub week_52_high: Option, + pub week_52_low: Option, + + pub expense_ratio: Option, + pub total_assets: Option, + pub nav_price: Option, + pub dividend_yield: Option, + + pub top_holdings: Vec<(String, f64)>, + pub top_10_concentration: Option, + + pub ytd_return: Option, + pub one_year_return: Option, + pub three_year_return: Option, + + pub is_leveraged: bool, + pub is_inverse: bool, + + pub headlines: Vec, + + pub data_completeness_pct: f64, + pub fetched_at: DateTime, +} + +#[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 { + match self { + AnalysisInput::Equity(d) => d.fetched_at, + AnalysisInput::Etf(d) => d.fetched_at, + } + } +} + +pub mod fetch; +pub mod cache; diff --git a/src/discord.rs b/src/discord.rs new file mode 100644 index 0000000..d9e1957 --- /dev/null +++ b/src/discord.rs @@ -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 = 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(()) +} diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..52ef0b4 --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,13 @@ +const ICO_BYTES: &[u8] = include_bytes!("assets/icon.ico"); + +pub fn make_rgba() -> (Vec, 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, size: u32) -> egui::IconData { + egui::IconData { rgba, width: size, height: size } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..840489a --- /dev/null +++ b/src/main.rs @@ -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))), + ) +} diff --git a/src/report/equity.rs b/src/report/equity.rs new file mode 100644 index 0000000..e56d9d0 --- /dev/null +++ b/src/report/equity.rs @@ -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 { + 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 = 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 { + 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, +) -> Vec { + let mut items: Vec = 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()), + } +} diff --git a/src/report/etf.rs b/src/report/etf.rs new file mode 100644 index 0000000..6920cf3 --- /dev/null +++ b/src/report/etf.rs @@ -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 = 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 { + scores.iter().filter(|s| s.score > 0.0).map(|s| s.detail.clone()).collect() +} + +pub fn worries(data: &EtfData, scores: &[CategoryScore]) -> Vec { + let mut items: Vec = 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()), + } +} diff --git a/src/report/mod.rs b/src/report/mod.rs new file mode 100644 index 0000000..4a206a7 --- /dev/null +++ b/src/report/mod.rs @@ -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) + } +} + diff --git a/src/ui/analysis_panel.rs b/src/ui/analysis_panel.rs new file mode 100644 index 0000000..9476122 --- /dev/null +++ b/src/ui/analysis_panel.rs @@ -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)); +} diff --git a/src/ui/data_panel.rs b/src/ui/data_panel.rs new file mode 100644 index 0000000..8a832db --- /dev/null +++ b/src/ui/data_panel.rs @@ -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) -> String { + match pct { + Some(p) => { + if p >= 0.0 { + format!("+{:.1}%", p) + } else { + format!("{:.1}%", p) + } + } + None => "N/A".to_string(), + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..bec1848 --- /dev/null +++ b/src/ui/mod.rs @@ -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, 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) -> 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(), + } +} diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..648310b --- /dev/null +++ b/src/updater.rs @@ -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> { + 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); +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..9d8f529 --- /dev/null +++ b/src/version.rs @@ -0,0 +1 @@ +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/watchlist.rs b/src/watchlist.rs new file mode 100644 index 0000000..9e5337f --- /dev/null +++ b/src/watchlist.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Watchlist { + #[serde(default)] + pub tickers: Vec, +} + +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 { + 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 { + 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(()) +}