From bb14b125a2ebf68a5ab5102bffd064c8146ad615 Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 17 May 2026 01:23:37 -0500 Subject: [PATCH] v0.4.0 --- .cargo/config.toml | 2 + .claude/settings.local.json | 22 + .gitignore | 8 + Cargo.toml | 32 + src/analysis/equity.rs | 497 ++++++++++++++ src/analysis/etf.rs | 259 ++++++++ src/analysis/hype.rs | 92 +++ src/analysis/mod.rs | 138 ++++ src/analysis/speculation.rs | 61 ++ src/app.rs | 1226 +++++++++++++++++++++++++++++++++++ src/assets/icon.ico | Bin 0 -> 182811 bytes src/config.rs | 129 ++++ src/data/cache.rs | 47 ++ src/data/fetch.rs | 565 ++++++++++++++++ src/data/mod.rs | 137 ++++ src/discord.rs | 224 +++++++ src/icon.rs | 13 + src/main.rs | 40 ++ src/report/equity.rs | 324 +++++++++ src/report/etf.rs | 160 +++++ src/report/mod.rs | 16 + src/ui/analysis_panel.rs | 89 +++ src/ui/data_panel.rs | 218 +++++++ src/ui/mod.rs | 44 ++ src/updater.rs | 107 +++ src/version.rs | 1 + src/watchlist.rs | 66 ++ 27 files changed, 4517 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/analysis/equity.rs create mode 100644 src/analysis/etf.rs create mode 100644 src/analysis/hype.rs create mode 100644 src/analysis/mod.rs create mode 100644 src/analysis/speculation.rs create mode 100644 src/app.rs create mode 100644 src/assets/icon.ico create mode 100644 src/config.rs create mode 100644 src/data/cache.rs create mode 100644 src/data/fetch.rs create mode 100644 src/data/mod.rs create mode 100644 src/discord.rs create mode 100644 src/icon.rs create mode 100644 src/main.rs create mode 100644 src/report/equity.rs create mode 100644 src/report/etf.rs create mode 100644 src/report/mod.rs create mode 100644 src/ui/analysis_panel.rs create mode 100644 src/ui/data_panel.rs create mode 100644 src/ui/mod.rs create mode 100644 src/updater.rs create mode 100644 src/version.rs create mode 100644 src/watchlist.rs 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 0000000000000000000000000000000000000000..1104b8af43a63dd2cfefc1d7d4ca29f7b2a9a29c GIT binary patch literal 182811 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 literal 0 HcmV?d00001 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(()) +}