v0.4.0
Some checks failed
Build and Release / build (push) Has been cancelled

This commit is contained in:
2026-05-17 01:23:37 -05:00
parent e60b6af388
commit bb14b125a2
27 changed files with 4517 additions and 0 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(cargo build *)",
"Bash(curl -s -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36\" \"https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=quoteType\")",
"Bash(curl -s -c /tmp/yf_cookies.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://finance.yahoo.com/\" -o /dev/null -w \"%{http_code}\")",
"Bash(curl -s -b /tmp/yf_cookies.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://query2.finance.yahoo.com/v1/test/getcrumb\")",
"Bash(curl -v -c /tmp/yf_cookies2.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\" -H \"Accept-Language: en-US,en;q=0.5\" -L \"https://finance.yahoo.com/quote/AAPL/\" -o /dev/null)",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: */*\" -H \"Referer: https://finance.yahoo.com/\" \"https://query2.finance.yahoo.com/v1/test/getcrumb\")",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=quoteType&crumb=t5.A45X0LQC')",
"Bash(python3 -m json.tool)",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=financialData,summaryDetail,defaultKeyStatistics&crumb=t5.A45X0LQC')",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=assetProfile,earningsHistory,incomeStatementHistory,cashflowStatementHistory,balanceSheetHistory,calendarEvents,recommendationTrend&crumb=t5.A45X0LQC')",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=incomeStatementHistory&crumb=t5.A45X0LQC')",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=earningsHistory&crumb=t5.A45X0LQC')",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=cashflowStatementHistory,balanceSheetHistory,calendarEvents&crumb=t5.A45X0LQC')",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=financialData,recommendationTrend&crumb=t5.A45X0LQC')",
"Bash(curl -s -b /tmp/yf_cookies2.txt -A 'Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36' 'https://query1.finance.yahoo.com/v10/finance/quoteSummary/AAPL?modules=incomeStatementHistoryQuarterly&crumb=t5.A45X0LQC')",
"Bash(cargo doc *)"
]
}
}

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/target
Cargo.lock
*.exe
*.pdb
tickr_update.exe
tickr_swap.bat
**/.rs.bk
.env

32
Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "tickr"
version = "0.4.0"
edition = "2021"
[[bin]]
name = "tickr"
path = "src/main.rs"
[dependencies]
eframe = { version = "0.27", features = ["default"] }
egui = "0.27"
reqwest = { version = "0.12", features = ["json", "rustls-tls", "cookies"], default-features = false }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1"
toml = "0.8"
dirs = "5"
semver = "1"
tray-icon = "0.14"
image = { version = "0.25", default-features = false, features = ["ico"] }
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
[target.'cfg(windows)'.build-dependencies]
winres = "0.1"

497
src/analysis/equity.rs Normal file
View File

@@ -0,0 +1,497 @@
use chrono::Utc;
use crate::config::Config;
use crate::data::TickerData;
use super::{
hype, speculation, AnalysisResult, AnalystTrend, AnalystTrendSignal, CategoryScore,
Confidence, Decision, RiskLevel,
};
use crate::report;
static SECTOR_PE: &[(&str, f64)] = &[
("Technology", 28.0),
("Healthcare", 22.0),
("Financial Services", 14.0),
("Consumer Cyclical", 20.0),
("Communication Services", 18.0),
("Industrials", 20.0),
("Energy", 12.0),
("Consumer Defensive", 22.0),
("Real Estate", 35.0),
("Utilities", 18.0),
("Basic Materials", 14.0),
];
pub fn sector_median_pe(sector: Option<&str>) -> (f64, bool) {
match sector {
Some(s) => SECTOR_PE
.iter()
.find(|(name, _)| *name == s)
.map(|(_, pe)| (*pe, false))
.unwrap_or((20.0, true)),
None => (20.0, true),
}
}
pub fn analyze_equity(data: &TickerData, config: &Config) -> AnalysisResult {
let (sector_pe, unknown_sector) = sector_median_pe(data.sector.as_deref());
let spec = speculation::evaluate(data);
let hype = hype::evaluate(data, sector_pe);
let scores: Vec<CategoryScore> = vec![
score_valuation(data, sector_pe),
score_revenue(data),
score_earnings(data),
score_fcf(data),
score_debt(data),
score_dividend(data),
score_analyst(data),
score_momentum(data),
];
let raw_weighted: f64 = scores.iter().map(|s| s.score * s.weight).sum();
let mut modifiers: f64 = 0.0;
modifiers -= spec.score_penalty;
modifiers -= hype.score_penalty;
let analyst_trend = compute_analyst_trend(data);
if let Some(ref trend) = analyst_trend {
modifiers += trend.score_delta;
}
// High beta at peak declining EPS
let perfect_timing_flag = data.beta.map_or(false, |b| b > 1.8)
&& {
let near_high = match (data.current_price, data.week_52_high) {
(Some(p), Some(h)) if h > 0.0 => (h - p) / h < 0.05,
_ => false,
};
near_high
}
&& {
let eps = &data.eps_quarters;
eps.len() >= 2 && eps[0].1 < eps[1].1
};
if perfect_timing_flag {
modifiers -= 0.3;
}
let final_score = raw_weighted + modifiers;
// Adjust for risk tolerance config
let pass_zone_width = if config.preferences.avoid_borderline { 0.1 } else { 0.0 };
let buy_threshold = 0.6 + pass_zone_width;
let sell_threshold = -0.6 - pass_zone_width;
let force_pass = spec.force_pass
|| hype.force_pass
|| data.data_completeness_pct < 0.50;
let force_pass_reason = if spec.force_pass {
Some(format!(
"Multiple speculation flags: {}",
spec.flags.join(", ")
))
} else if hype.force_pass {
Some("Price peak + analyst divergence combination".to_string())
} else if data.data_completeness_pct < 0.50 {
Some("Insufficient data to make a reliable assessment".to_string())
} else {
None
};
let decision = if force_pass {
Decision::Pass
} else if final_score >= buy_threshold {
Decision::Buy
} else if final_score <= sell_threshold {
Decision::Sell
} else {
Decision::Pass
};
let earnings_within_14 = data.next_earnings_date.map_or(false, |d| {
let today = Utc::now().date_naive();
(d - today).num_days().abs() <= 14
});
let confidence = compute_confidence(
data.data_completeness_pct,
final_score,
buy_threshold,
sell_threshold,
earnings_within_14 || unknown_sector,
);
let risk_level = compute_risk(data, &spec.flags);
let result = AnalysisResult {
decision: decision.clone(),
confidence: confidence.clone(),
risk_level: risk_level.clone(),
raw_score: raw_weighted,
final_score,
force_pass_reason: force_pass_reason.clone(),
category_scores: scores.clone(),
speculation_flags: spec.flags.clone(),
hype_flags: hype.flags.clone(),
analyst_trend: analyst_trend.clone(),
section_verdict: report::equity::verdict(data, &decision, &confidence),
section_what_company_does: report::equity::what_company_does(data),
section_why_opinion: report::equity::why_opinion(data, &scores, &spec.flags, &hype.flags, &force_pass_reason),
section_looks_good: report::equity::looks_good(data, &scores),
section_worries: report::equity::worries(data, &scores, &spec.flags, &hype.flags, &analyst_trend),
section_dividend: report::equity::dividend_check(data),
section_growth: report::equity::growth_check(data),
section_valuation: report::equity::valuation_check(data, sector_pe),
section_change_mind: report::equity::change_mind(data, &decision, &scores, buy_threshold, sell_threshold),
section_study_next: report::equity::study_next(data, &scores, &config.user.name),
section_translation: report::equity::translation(data, &decision, &scores, &config.user.name),
section_final_answer: decision.label().to_string(),
section_final_reason: report::equity::final_reason(data, &decision, &scores, &spec.flags, &hype.flags),
};
result
}
fn score_valuation(data: &TickerData, sector_pe: f64) -> CategoryScore {
let effective_pe = match (data.pe_ratio, data.forward_pe) {
(Some(trailing), Some(forward)) if forward < trailing => (trailing + forward) / 2.0,
(Some(trailing), _) => trailing,
_ => {
return CategoryScore {
name: "Valuation",
score: 0.0,
weight: 0.20,
detail: "P/E data unavailable".to_string(),
};
}
};
let ratio = effective_pe / sector_pe;
let (score, detail) = if ratio <= 0.7 {
(2.0, format!("P/E {:.1} is significantly below sector median {:.0} — undervalued", effective_pe, sector_pe))
} else if ratio <= 0.9 {
(1.0, format!("P/E {:.1} is modestly below sector median {:.0}", effective_pe, sector_pe))
} else if ratio <= 1.2 {
(0.0, format!("P/E {:.1} is in line with sector median {:.0}", effective_pe, sector_pe))
} else if ratio <= 1.6 {
(-1.0, format!("P/E {:.1} is modestly above sector median {:.0} — slightly overvalued", effective_pe, sector_pe))
} else {
(-2.0, format!("P/E {:.1} is significantly above sector median {:.0} — overvalued", effective_pe, sector_pe))
};
CategoryScore { name: "Valuation", score, weight: 0.20, detail }
}
fn score_revenue(data: &TickerData) -> CategoryScore {
let quarters = &data.revenue_quarters;
if quarters.len() < 2 {
return CategoryScore {
name: "Revenue Trend",
score: 0.0,
weight: 0.15,
detail: "Insufficient revenue data".to_string(),
};
}
let growing_count = quarters.windows(2).filter(|w| w[0].1 > w[1].1).count();
let declining_count = quarters.windows(2).filter(|w| w[0].1 < w[1].1).count();
let (score, detail) = match (growing_count, declining_count, quarters.len()) {
(g, _, n) if g >= 3 && n >= 4 => (2.0, "Revenue growing in 3+ consecutive quarters".to_string()),
(g, _, _) if g >= 2 => (1.0, "Revenue growing in 2 consecutive quarters".to_string()),
(_, d, n) if d >= 3 && n >= 4 => (-2.0, "Revenue declining in 3+ consecutive quarters".to_string()),
(_, d, _) if d >= 2 => (-1.0, "Revenue declining in 2 consecutive quarters".to_string()),
_ => (0.0, "Revenue trend mixed or flat".to_string()),
};
CategoryScore { name: "Revenue Trend", score, weight: 0.15, detail }
}
fn score_earnings(data: &TickerData) -> CategoryScore {
let quarters = &data.eps_quarters;
if quarters.len() < 2 {
return CategoryScore {
name: "Earnings Trend",
score: 0.0,
weight: 0.15,
detail: "Insufficient EPS data".to_string(),
};
}
let growing_count = quarters.windows(2).filter(|w| w[0].1 > w[1].1).count();
let declining_count = quarters.windows(2).filter(|w| w[0].1 < w[1].1).count();
let (score, detail) = match (growing_count, declining_count, quarters.len()) {
(g, _, n) if g >= 3 && n >= 4 => (2.0, "EPS growing in 3+ consecutive quarters".to_string()),
(g, _, _) if g >= 2 => (1.0, "EPS growing in 2 consecutive quarters".to_string()),
(_, d, n) if d >= 3 && n >= 4 => (-2.0, "EPS declining in 3+ consecutive quarters".to_string()),
(_, d, _) if d >= 2 => (-1.0, "EPS declining in 2 consecutive quarters".to_string()),
_ => (0.0, "EPS trend mixed or flat".to_string()),
};
CategoryScore { name: "Earnings Trend", score, weight: 0.15, detail }
}
fn score_fcf(data: &TickerData) -> CategoryScore {
let fcf = match data.free_cash_flow_ttm {
Some(f) => f,
None => {
return CategoryScore {
name: "Free Cash Flow",
score: 0.0,
weight: 0.15,
detail: "FCF data unavailable".to_string(),
};
}
};
if fcf < 0.0 {
return CategoryScore {
name: "Free Cash Flow",
score: -1.0,
weight: 0.15,
detail: format!("Negative FCF: {}", format_currency(fcf)),
};
}
let margin = data.revenue_ttm.filter(|&r| r > 0.0).map(|r| fcf / r * 100.0);
let (score, detail) = match margin {
Some(m) if m > 15.0 => (2.0, format!("Strong FCF of {} with {:.1}% margin", format_currency(fcf), m)),
Some(m) if m >= 5.0 => (1.0, format!("Positive FCF of {} with {:.1}% margin", format_currency(fcf), m)),
Some(m) => (0.0, format!("FCF positive but thin margin ({:.1}%)", m)),
None => (1.0, format!("Positive FCF: {}", format_currency(fcf))),
};
CategoryScore { name: "Free Cash Flow", score, weight: 0.15, detail }
}
fn score_debt(data: &TickerData) -> CategoryScore {
let de = data.debt_to_equity;
let cr = data.current_ratio;
let (score, detail) = match (de, cr) {
(Some(d), Some(c)) if d < 0.3 && c > 2.0 => (
2.0,
format!("Very low D/E of {:.2} and strong current ratio of {:.2}", d, c),
),
(Some(d), Some(c)) if d < 0.8 && c > 1.5 => (
1.0,
format!("Healthy D/E of {:.2} and current ratio of {:.2}", d, c),
),
(Some(d), _) if d > 2.5 => (
-2.0,
format!("Very high debt load: D/E of {:.2}", d),
),
(Some(_d), Some(c)) if c < 1.0 => (
-2.0,
format!("Current ratio of {:.2} — liquidity concern", c),
),
(Some(d), _) if d > 1.5 => (
-1.0,
format!("Elevated D/E ratio of {:.2}", d),
),
(Some(d), _) => (
0.0,
format!("Moderate D/E ratio of {:.2}", d),
),
_ => (0.0, "Debt data unavailable".to_string()),
};
CategoryScore { name: "Debt Health", score, weight: 0.10, detail }
}
fn score_dividend(data: &TickerData) -> CategoryScore {
if data.dividend_cut_flag {
return CategoryScore {
name: "Dividend Safety",
score: -2.0,
weight: 0.10,
detail: "Dividend has been recently cut".to_string(),
};
}
let yield_val = match data.dividend_yield {
Some(y) if y > 0.0 => y,
_ => {
return CategoryScore {
name: "Dividend Safety",
score: 0.0,
weight: 0.10,
detail: "No dividend paid — neutral".to_string(),
};
}
};
let payout = data.payout_ratio.unwrap_or(0.0);
let growing = data.dividend_5yr_growth.map_or(false, |g| g > 0.0);
let (score, detail) = if payout < 0.50 && growing {
(2.0, format!("Healthy {:.1}% yield with {:.0}% payout ratio and growing dividend", yield_val * 100.0, payout * 100.0))
} else if payout < 0.75 {
(1.0, format!("{:.1}% yield with sustainable {:.0}% payout ratio", yield_val * 100.0, payout * 100.0))
} else if payout < 0.90 {
(-1.0, format!("{:.1}% yield but elevated {:.0}% payout ratio", yield_val * 100.0, payout * 100.0))
} else {
(-2.0, format!("{:.1}% yield with unsustainable {:.0}% payout ratio", yield_val * 100.0, payout * 100.0))
};
CategoryScore { name: "Dividend Safety", score, weight: 0.10, detail }
}
fn score_analyst(data: &TickerData) -> CategoryScore {
let snapshot = match &data.analyst_trend_current {
Some(s) => s,
None => {
return CategoryScore {
name: "Analyst Sentiment",
score: 0.0,
weight: 0.10,
detail: "No analyst data available".to_string(),
};
}
};
let ratio = snapshot.bullish_ratio();
let total = snapshot.strong_buy + snapshot.buy + snapshot.hold + snapshot.sell + snapshot.strong_sell;
let (score, detail) = if ratio > 0.75 {
(2.0, format!("Strong analyst consensus: {:.0}% bullish ({} analysts)", ratio * 100.0, total))
} else if ratio > 0.60 {
(1.0, format!("Majority bullish: {:.0}% buy ratings ({} analysts)", ratio * 100.0, total))
} else if ratio >= 0.40 {
(0.0, format!("Mixed analyst sentiment: {:.0}% bullish ({} analysts)", ratio * 100.0, total))
} else if ratio >= 0.20 {
(-1.0, format!("Weak analyst sentiment: only {:.0}% bullish ({} analysts)", ratio * 100.0, total))
} else {
(-2.0, format!("Strongly bearish analyst consensus: only {:.0}% bullish ({} analysts)", ratio * 100.0, total))
};
CategoryScore { name: "Analyst Sentiment", score, weight: 0.10, detail }
}
fn score_momentum(data: &TickerData) -> CategoryScore {
let m1 = data.price_change_1m_pct;
let m3 = data.price_change_3m_pct;
let (score, detail) = match (m1, m3) {
(Some(a), Some(_b)) if a > 30.0 => {
(0.0, format!("1M +{:.1}% surge — hype engine evaluates this separately", a))
}
(Some(a), Some(b)) if a > 0.0 && b > 0.0 => {
(1.0, format!("Positive momentum: +{:.1}% (1M), +{:.1}% (3M)", a, b))
}
(Some(a), Some(b)) if a < 0.0 && b < 0.0 => {
(-1.0, format!("Negative momentum: {:.1}% (1M), {:.1}% (3M)", a, b))
}
(Some(a), Some(b)) => {
(0.0, format!("Mixed momentum: {:.1}% (1M), {:.1}% (3M)", a, b))
}
(Some(a), None) => {
if a > 0.0 { (1.0, format!("+{:.1}% over past month", a)) }
else { (-1.0, format!("{:.1}% over past month", a)) }
}
_ => (0.0, "Momentum data unavailable".to_string()),
};
CategoryScore { name: "Momentum", score, weight: 0.05, detail }
}
fn compute_analyst_trend(data: &TickerData) -> Option<AnalystTrend> {
let current = data.analyst_trend_current.as_ref()?;
let two_ago = data.analyst_trend_2m_ago.as_ref()?;
let ratio_now = current.bullish_ratio();
let ratio_2m = two_ago.bullish_ratio();
let delta = ratio_now - ratio_2m;
let (signal, score_delta) = if delta >= 0.15 {
(AnalystTrendSignal::RapidlyUpgrading, 1.0)
} else if delta >= 0.05 {
(AnalystTrendSignal::Upgrading, 0.5)
} else if delta.abs() < 0.05 {
(AnalystTrendSignal::Stable, 0.0)
} else if delta <= -0.15 {
(AnalystTrendSignal::RapidlyDowngrading, -1.0)
} else {
(AnalystTrendSignal::Downgrading, -0.5)
};
Some(AnalystTrend { signal, score_delta })
}
fn compute_confidence(
completeness: f64,
score: f64,
buy_thresh: f64,
sell_thresh: f64,
special_caution: bool,
) -> Confidence {
let borderline = (score - buy_thresh).abs() < 0.1 || (score - sell_thresh).abs() < 0.1;
if completeness >= 0.85 && !borderline && !special_caution {
Confidence::High
} else if completeness >= 0.60 && (!borderline && !special_caution) {
Confidence::Medium
} else {
Confidence::Low
}
}
fn compute_risk(data: &TickerData, spec_flags: &[String]) -> RiskLevel {
let mut high_triggers = 0u32;
if data.debt_to_equity.map_or(false, |d| d > 2.0) { high_triggers += 1; }
if data.free_cash_flow_ttm.map_or(false, |f| f < 0.0) { high_triggers += 1; }
if data.eps_quarters.len() >= 3 {
let declining = data.eps_quarters.windows(2).all(|w| w[0].1 < w[1].1);
if declining { high_triggers += 1; }
}
if data.payout_ratio.map_or(false, |p| p > 0.90) { high_triggers += 1; }
if let (Some(p), Some(h)) = (data.current_price, data.week_52_high) {
if h > 0.0 && (h - p) / h < 0.05 { high_triggers += 1; }
}
if data.beta.map_or(false, |b| b > 1.8) { high_triggers += 1; }
if !spec_flags.is_empty() { high_triggers += 1; }
if high_triggers >= 2 {
return RiskLevel::High;
}
let low_conditions = [
data.free_cash_flow_ttm.map_or(false, |f| f > 0.0),
data.debt_to_equity.map_or(false, |d| d < 0.5),
data.dividend_5yr_growth.map_or(false, |g| g > 0.0),
data.analyst_trend_current
.as_ref()
.map_or(false, |s| s.bullish_ratio() > 0.6),
matches!(
(data.current_price, data.week_52_high, data.week_52_low),
(Some(p), Some(h), Some(l)) if h > l && p < l + (h - l) * 0.5
),
data.beta.map_or(false, |b| b < 1.0),
];
if low_conditions.iter().all(|&c| c) {
RiskLevel::Low
} else {
RiskLevel::Medium
}
}
fn format_currency(val: f64) -> String {
let abs = val.abs();
let sign = if val < 0.0 { "-" } else { "" };
if abs >= 1_000_000_000.0 {
format!("{sign}${:.2}B", abs / 1_000_000_000.0)
} else if abs >= 1_000_000.0 {
format!("{sign}${:.1}M", abs / 1_000_000.0)
} else {
format!("{sign}${:.0}", abs)
}
}

259
src/analysis/etf.rs Normal file
View File

@@ -0,0 +1,259 @@
use crate::config::Config;
use crate::data::EtfData;
use super::{AnalysisResult, CategoryScore, Confidence, Decision, RiskLevel};
use crate::report;
pub fn analyze_etf(data: &EtfData, config: &Config) -> AnalysisResult {
// Auto-PASS triggers
let force_pass_reason = if data.is_leveraged || data.is_inverse {
Some("Leveraged/inverse ETFs are not suitable for long-term investors".to_string())
} else if data.total_assets.map_or(false, |a| a < 50_000_000.0) {
Some("AUM under $50M — liquidity risk".to_string())
} else {
None
};
let scores = vec![
score_expense(data),
score_diversification(data),
score_etf_dividend(data),
score_etf_performance(data),
score_aum(data),
score_category(data),
];
let raw_weighted: f64 = scores.iter().map(|s| s.score * s.weight).sum();
let final_score = raw_weighted;
let pass_zone = if config.preferences.avoid_borderline { 0.1 } else { 0.0 };
let buy_thresh = 0.6 + pass_zone;
let sell_thresh = -0.6 - pass_zone;
let force_pass = force_pass_reason.is_some()
|| data.data_completeness_pct < 0.50;
let final_force_reason = force_pass_reason.clone().or_else(|| {
if data.data_completeness_pct < 0.50 {
Some("Insufficient data for reliable assessment".to_string())
} else {
None
}
});
let decision = if force_pass {
Decision::Pass
} else if final_score >= buy_thresh {
Decision::Buy
} else if final_score <= sell_thresh {
Decision::Sell
} else {
Decision::Pass
};
let confidence = if data.data_completeness_pct >= 0.85 {
Confidence::High
} else if data.data_completeness_pct >= 0.60 {
Confidence::Medium
} else {
Confidence::Low
};
let risk_level = compute_etf_risk(data);
AnalysisResult {
decision: decision.clone(),
confidence,
risk_level,
raw_score: raw_weighted,
final_score,
force_pass_reason: final_force_reason,
category_scores: scores.clone(),
speculation_flags: vec![],
hype_flags: vec![],
analyst_trend: None,
section_verdict: report::etf::verdict(data, &decision),
section_what_company_does: report::etf::what_fund_does(data),
section_why_opinion: report::etf::why_opinion(data, &scores),
section_looks_good: report::etf::looks_good(data, &scores),
section_worries: report::etf::worries(data, &scores),
section_dividend: report::etf::dividend_check(data),
section_growth: report::etf::performance_check(data),
section_valuation: report::etf::expense_check(data),
section_change_mind: report::etf::change_mind(data, &decision),
section_study_next: report::etf::study_next(data, &scores, &config.user.name),
section_translation: report::etf::translation(data, &decision, &config.user.name),
section_final_answer: decision.label().to_string(),
section_final_reason: report::etf::final_reason(data, &decision, &scores),
}
}
fn score_expense(data: &EtfData) -> CategoryScore {
let er = match data.expense_ratio {
Some(e) => e,
None => {
return CategoryScore {
name: "Expense Ratio",
score: 0.0,
weight: 0.25,
detail: "Expense ratio unavailable".to_string(),
};
}
};
let pct = er * 100.0;
let (score, detail) = if pct < 0.10 {
(2.0, format!("Excellent expense ratio of {:.2}%", pct))
} else if pct < 0.30 {
(1.0, format!("Low expense ratio of {:.2}%", pct))
} else if pct < 0.60 {
(0.0, format!("Moderate expense ratio of {:.2}%", pct))
} else if pct < 1.0 {
(-1.0, format!("High expense ratio of {:.2}%", pct))
} else {
(-2.0, format!("Very high expense ratio of {:.2}% — significant drag on returns", pct))
};
CategoryScore { name: "Expense Ratio", score, weight: 0.25, detail }
}
fn score_diversification(data: &EtfData) -> CategoryScore {
let conc = match data.top_10_concentration {
Some(c) => c,
None => {
return CategoryScore {
name: "Diversification",
score: 0.0,
weight: 0.20,
detail: "Holdings data unavailable".to_string(),
};
}
};
let (score, detail) = if conc < 25.0 {
(2.0, format!("Highly diversified — top 10 holdings are only {:.1}% of fund", conc))
} else if conc < 40.0 {
(1.0, format!("Well diversified — top 10 holdings at {:.1}%", conc))
} else if conc < 55.0 {
(0.0, format!("Moderate concentration — top 10 holdings at {:.1}%", conc))
} else if conc < 70.0 {
(-1.0, format!("Concentrated fund — top 10 holdings at {:.1}%", conc))
} else {
(-2.0, format!("Highly concentrated — top 10 holdings at {:.1}% of fund", conc))
};
CategoryScore { name: "Diversification", score, weight: 0.20, detail }
}
fn score_etf_dividend(data: &EtfData) -> CategoryScore {
let (score, detail) = match data.dividend_yield {
Some(y) if y > 0.0 => {
let pct = y * 100.0;
if pct > 4.0 {
(2.0, format!("Attractive dividend yield of {:.2}%", pct))
} else if pct > 2.0 {
(1.0, format!("Moderate dividend yield of {:.2}%", pct))
} else {
(0.0, format!("Low dividend yield of {:.2}%", pct))
}
}
_ => (0.0, "No dividend yield data — may be growth-oriented".to_string()),
};
CategoryScore { name: "Dividend Yield", score, weight: 0.15, detail }
}
fn score_etf_performance(data: &EtfData) -> CategoryScore {
let (score, detail) = match (data.one_year_return, data.three_year_return) {
(Some(y1), Some(y3)) => {
let avg = (y1 + y3) / 2.0;
if avg > 15.0 {
(2.0, format!("Strong performance: +{:.1}% (1Y), +{:.1}% (3Y)", y1, y3))
} else if avg > 5.0 {
(1.0, format!("Positive returns: +{:.1}% (1Y), +{:.1}% (3Y)", y1, y3))
} else if avg > 0.0 {
(0.0, format!("Modest returns: {:.1}% (1Y), {:.1}% (3Y)", y1, y3))
} else {
(-1.0, format!("Weak performance: {:.1}% (1Y), {:.1}% (3Y)", y1, y3))
}
}
(Some(y1), None) => {
if y1 > 10.0 { (1.0, format!("1-year return: +{:.1}%", y1)) }
else if y1 > 0.0 { (0.0, format!("1-year return: +{:.1}%", y1)) }
else { (-1.0, format!("1-year return: {:.1}%", y1)) }
}
_ => (0.0, "Performance data unavailable".to_string()),
};
CategoryScore { name: "Performance", score, weight: 0.15, detail }
}
fn score_aum(data: &EtfData) -> CategoryScore {
let (score, detail) = match data.total_assets {
Some(a) if a >= 10_000_000_000.0 => (
2.0,
format!("Large, highly liquid fund: ${:.1}B AUM", a / 1e9),
),
Some(a) if a >= 1_000_000_000.0 => (
1.0,
format!("Well-established fund: ${:.1}B AUM", a / 1e9),
),
Some(a) if a >= 100_000_000.0 => (
0.0,
format!("Adequate AUM: ${:.0}M", a / 1e6),
),
Some(a) if a >= 50_000_000.0 => (
-1.0,
format!("Small fund: ${:.0}M AUM — limited liquidity", a / 1e6),
),
Some(a) => (
-2.0,
format!("Very small fund: ${:.0}M AUM — significant liquidity risk", a / 1e6),
),
None => (0.0, "AUM data unavailable".to_string()),
};
CategoryScore { name: "AUM / Liquidity", score, weight: 0.10, detail }
}
fn score_category(data: &EtfData) -> CategoryScore {
let name_lower = data.fund_name.as_deref().unwrap_or("").to_lowercase();
let cat_lower = data.category.as_deref().unwrap_or("").to_lowercase();
let combined = format!("{} {}", name_lower, cat_lower);
let is_broad_market = combined.contains("s&p 500")
|| combined.contains("total market")
|| combined.contains("broad")
|| combined.contains("russell")
|| cat_lower.contains("large blend");
let is_niche = combined.contains("sector")
|| combined.contains("thematic")
|| cat_lower.contains("technology")
|| cat_lower.contains("energy");
let (score, detail) = if is_broad_market {
(2.0, "Broad market index ETF — beginner-friendly, diversified exposure".to_string())
} else if is_niche {
(-1.0, "Sector/thematic ETF — concentrated risk in specific area".to_string())
} else {
(0.0, format!("Category: {}", data.category.as_deref().unwrap_or("Unknown")))
};
CategoryScore { name: "Category Fit", score, weight: 0.15, detail }
}
fn compute_etf_risk(data: &EtfData) -> RiskLevel {
if data.is_leveraged || data.is_inverse {
return RiskLevel::High;
}
if data.total_assets.map_or(false, |a| a < 100_000_000.0) {
return RiskLevel::High;
}
if data.expense_ratio.map_or(false, |e| e > 0.006) {
return RiskLevel::Medium;
}
if data.top_10_concentration.map_or(false, |c| c > 60.0) {
return RiskLevel::Medium;
}
RiskLevel::Low
}

92
src/analysis/hype.rs Normal file
View File

@@ -0,0 +1,92 @@
use crate::data::TickerData;
pub struct HypeResult {
pub flags: Vec<String>,
pub score_penalty: f64,
pub force_pass: bool,
}
pub fn evaluate(data: &TickerData, sector_median_pe: f64) -> HypeResult {
let mut flags = Vec::new();
let mut force_pass = false;
let near_52w_high = match (data.current_price, data.week_52_high) {
(Some(p), Some(h)) if h > 0.0 => (h - p) / h < 0.05,
_ => false,
};
// PRICE_PEAK_NO_EARNINGS
let pe_over_2x = data.pe_ratio.map_or(false, |pe| pe > 2.0 * sector_median_pe);
if near_52w_high && pe_over_2x {
flags.push("Price near peak with weak earnings".to_string());
}
// MOMENTUM_NO_FUNDAMENTALS
let momentum_spike = data.price_change_1m_pct.map_or(false, |c| c > 30.0);
let eps_flat_or_declining = {
let quarters = &data.eps_quarters;
if quarters.len() >= 2 {
quarters[0].1 <= quarters[1].1
} else {
false
}
};
if momentum_spike && eps_flat_or_declining {
flags.push("Price surge with flat/declining earnings".to_string());
}
// ANALYST_DIVERGENCE
let analyst_cautious = data
.analyst_consensus
.as_deref()
.map_or(false, |c| matches!(c, "hold" | "sell" | "underperform" | "underweight"));
if analyst_cautious && near_52w_high {
flags.push("Analysts cautious despite price surge".to_string());
}
// VALUATION_EXTREME
let pe_over_3x = data.pe_ratio.map_or(false, |pe| pe > 3.0 * sector_median_pe);
let low_revenue_growth = {
let quarters = &data.revenue_quarters;
if quarters.len() >= 4 {
let recent = quarters[0].1;
let year_ago = quarters[3].1;
if year_ago > 0.0 {
(recent - year_ago) / year_ago * 100.0 < 10.0
} else {
false
}
} else {
false
}
};
if pe_over_3x && low_revenue_growth {
flags.push("Extreme valuation with low growth".to_string());
}
// HIGH_BETA_OVERPRICED
let high_beta = data.beta.map_or(false, |b| b > 1.8);
let pe_over_1_5x = data.pe_ratio.map_or(false, |pe| pe > 1.5 * sector_median_pe);
if high_beta && pe_over_1_5x && near_52w_high {
flags.push("High volatility stock overpriced near peak".to_string());
}
// Force pass combo
let has_peak_no_earnings = flags
.iter()
.any(|f| f.contains("Price near peak with weak earnings"));
let has_analyst_divergence = flags
.iter()
.any(|f| f.contains("Analysts cautious"));
if has_peak_no_earnings && has_analyst_divergence {
force_pass = true;
}
let score_penalty = match flags.len() {
0 => 0.0,
1 => 0.3,
_ => 0.6,
};
HypeResult { flags, score_penalty, force_pass }
}

138
src/analysis/mod.rs Normal file
View File

@@ -0,0 +1,138 @@
use crate::data::AnalysisInput;
use crate::config::Config;
pub mod equity;
pub mod etf;
pub mod speculation;
pub mod hype;
#[derive(Debug, Clone, PartialEq)]
pub enum Decision {
Buy,
Sell,
Pass,
}
impl Decision {
pub fn label(&self) -> &str {
match self {
Decision::Buy => "BUY",
Decision::Sell => "SELL",
Decision::Pass => "PASS",
}
}
pub fn color(&self) -> egui::Color32 {
match self {
Decision::Buy => egui::Color32::from_rgb(39, 174, 96),
Decision::Sell => egui::Color32::from_rgb(231, 76, 60),
Decision::Pass => egui::Color32::from_rgb(241, 196, 15),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Confidence {
High,
Medium,
Low,
}
impl Confidence {
pub fn label(&self) -> &str {
match self {
Confidence::High => "High",
Confidence::Medium => "Medium",
Confidence::Low => "Low",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RiskLevel {
High,
Medium,
Low,
}
impl RiskLevel {
pub fn label(&self) -> &str {
match self {
RiskLevel::High => "High",
RiskLevel::Medium => "Medium",
RiskLevel::Low => "Low",
}
}
}
#[derive(Debug, Clone)]
pub struct AnalystTrend {
pub signal: AnalystTrendSignal,
pub score_delta: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AnalystTrendSignal {
RapidlyUpgrading,
Upgrading,
Stable,
Downgrading,
RapidlyDowngrading,
}
impl AnalystTrendSignal {
pub fn arrow(&self) -> &str {
match self {
AnalystTrendSignal::RapidlyUpgrading => "↑↑",
AnalystTrendSignal::Upgrading => "",
AnalystTrendSignal::Stable => "",
AnalystTrendSignal::Downgrading => "",
AnalystTrendSignal::RapidlyDowngrading => "↓↓",
}
}
}
#[derive(Debug, Clone)]
pub struct CategoryScore {
pub name: &'static str,
pub score: f64, // -2 to +2
pub weight: f64, // 0.0 to 1.0
pub detail: String,
}
#[derive(Debug, Clone)]
pub struct AnalysisResult {
pub decision: Decision,
pub confidence: Confidence,
pub risk_level: RiskLevel,
#[allow(dead_code)]
pub raw_score: f64,
pub final_score: f64,
pub force_pass_reason: Option<String>,
pub category_scores: Vec<CategoryScore>,
pub speculation_flags: Vec<String>,
pub hype_flags: Vec<String>,
pub analyst_trend: Option<AnalystTrend>,
pub section_verdict: String,
pub section_what_company_does: String,
pub section_why_opinion: String,
pub section_looks_good: Vec<String>,
pub section_worries: Vec<String>,
pub section_dividend: String,
pub section_growth: String,
pub section_valuation: String,
pub section_change_mind: String,
pub section_study_next: String,
pub section_translation: String,
pub section_final_answer: String,
pub section_final_reason: String,
}
pub fn analyze(input: &AnalysisInput, config: &Config) -> AnalysisResult {
match input {
AnalysisInput::Equity(data) => equity::analyze_equity(data, config),
AnalysisInput::Etf(data) => etf::analyze_etf(data, config),
}
}

View File

@@ -0,0 +1,61 @@
use crate::data::TickerData;
pub struct SpeculationResult {
pub flags: Vec<String>,
pub score_penalty: f64,
pub force_pass: bool,
}
pub fn evaluate(data: &TickerData) -> SpeculationResult {
let mut flags = Vec::new();
if data.revenue_ttm.map_or(true, |r| r <= 0.0) {
flags.push("Pre-revenue company".to_string());
}
if data.market_cap.map_or(false, |mc| mc < 500_000_000.0)
&& data.eps_quarters.first().map_or(false, |(_, eps)| *eps < 0.0)
{
flags.push("Micro-cap with negative earnings".to_string());
}
let price_sales = match (data.current_price, data.market_cap, data.revenue_ttm) {
(Some(_p), Some(mc), Some(rev)) if rev > 0.0 => Some(mc / rev),
_ => None,
};
let net_income_negative = data.eps_quarters.first().map_or(false, |(_, e)| *e < 0.0);
if price_sales.map_or(false, |ps| ps > 20.0) && net_income_negative {
flags.push("Extreme valuation with negative income".to_string());
}
if data.eps_quarters.len() < 4 {
flags.push("Recent IPO (limited earnings history)".to_string());
}
if data.free_cash_flow_ttm.map_or(false, |fcf| fcf < 0.0) {
flags.push("Negative free cash flow".to_string());
}
if data.current_price.map_or(false, |p| p < 5.0) {
flags.push("Penny stock (<$5)".to_string());
}
let is_biotech = data.sector.as_deref() == Some("Healthcare")
&& data.industry.as_deref().map_or(false, |i| i.contains("Biotechnology"));
if is_biotech
&& data.revenue_ttm.map_or(true, |r| r < 10_000_000.0)
&& data.free_cash_flow_ttm.map_or(true, |f| f < 0.0)
{
flags.push("Biotech with no commercial product".to_string());
}
let count = flags.len();
let (score_penalty, force_pass) = match count {
0 => (0.0, false),
1 => (0.3, false),
2 => (0.6, false),
_ => (0.6, true),
};
SpeculationResult { flags, score_penalty, force_pass }
}

1226
src/app.rs Normal file

File diff suppressed because it is too large Load Diff

BIN
src/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

129
src/config.rs Normal file
View File

@@ -0,0 +1,129 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub user: UserConfig,
pub preferences: Preferences,
pub cache: CacheConfig,
pub output: OutputConfig,
pub discord: DiscordConfig,
pub watchlist_check: WatchlistCheckConfig,
#[serde(default)]
pub updater: UpdaterConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserConfig {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Preferences {
pub default_risk_tolerance: String,
pub dividend_focus: bool,
pub avoid_borderline: bool,
pub start_minimized: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
pub ttl_minutes: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
pub auto_save: bool,
pub save_directory: String,
pub save_format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscordConfig {
pub webhook_url: String,
pub notify_on_change: bool,
pub notify_on_drift: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchlistCheckConfig {
pub interval_minutes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdaterConfig {
pub enabled: bool,
pub auto_update: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
user: UserConfig { name: "Investor".to_string() },
preferences: Preferences {
default_risk_tolerance: "low".to_string(),
dividend_focus: false,
avoid_borderline: true,
start_minimized: false,
},
cache: CacheConfig { ttl_minutes: 15 },
output: OutputConfig {
auto_save: false,
save_directory: String::new(),
save_format: "txt".to_string(),
},
discord: DiscordConfig {
webhook_url: String::new(),
notify_on_change: true,
notify_on_drift: false,
},
watchlist_check: WatchlistCheckConfig {
interval_minutes: 60,
},
updater: UpdaterConfig {
enabled: true,
auto_update: false,
},
}
}
}
impl Default for UpdaterConfig {
fn default() -> Self {
Self { enabled: true, auto_update: false }
}
}
pub fn config_path() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join("tickr").join("config.toml"))
}
pub fn load() -> Config {
load_inner().unwrap_or_default()
}
fn load_inner() -> Result<Config> {
let path = config_path().ok_or_else(|| anyhow::anyhow!("No config dir"))?;
let text = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&text)?;
Ok(config)
}
pub fn save(config: &Config) -> Result<()> {
let path = config_path().ok_or_else(|| anyhow::anyhow!("No config dir"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(config)?;
std::fs::write(path, text)?;
Ok(())
}
pub fn ensure_exists() -> Config {
let config = load();
if let Err(e) = save(&config) {
eprintln!("Could not save config: {e}");
}
config
}

47
src/data/cache.rs Normal file
View File

@@ -0,0 +1,47 @@
use chrono::{Duration, Utc};
use std::collections::HashMap;
use super::AnalysisInput;
pub struct CacheStore {
entries: HashMap<String, AnalysisInput>,
pub ttl: Duration,
}
impl CacheStore {
pub fn new(ttl_minutes: i64) -> Self {
Self {
entries: HashMap::new(),
ttl: Duration::minutes(ttl_minutes),
}
}
pub fn get(&self, symbol: &str) -> Option<&AnalysisInput> {
let entry = self.entries.get(&symbol.to_uppercase())?;
let age = Utc::now() - entry.fetched_at();
if age < self.ttl {
Some(entry)
} else {
None
}
}
pub fn get_stale(&self, symbol: &str) -> Option<&AnalysisInput> {
self.entries.get(&symbol.to_uppercase())
}
pub fn insert(&mut self, data: AnalysisInput) {
self.entries.insert(data.symbol().to_uppercase(), data);
}
#[allow(dead_code)]
pub fn invalidate(&mut self, symbol: &str) {
self.entries.remove(&symbol.to_uppercase());
}
pub fn age_minutes(&self, symbol: &str) -> Option<i64> {
let entry = self.entries.get(&symbol.to_uppercase())?;
let age = Utc::now() - entry.fetched_at();
Some(age.num_minutes())
}
}

565
src/data/fetch.rs Normal file
View File

@@ -0,0 +1,565 @@
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use reqwest::{Client, ClientBuilder};
use serde_json::Value;
use super::{AnalysisInput, AnalystSnapshot, AssetType, EtfData, TickerData};
const BASE_SUMMARY: &str = "https://query1.finance.yahoo.com/v10/finance/quoteSummary";
const BASE_CHART: &str = "https://query1.finance.yahoo.com/v8/finance/chart";
const BASE_SEARCH: &str = "https://query1.finance.yahoo.com/v1/finance/search";
const CRUMB_URL: &str = "https://query2.finance.yahoo.com/v1/test/getcrumb";
struct YahooSession {
client: Client,
crumb: String,
}
impl YahooSession {
async fn init(symbol: &str) -> Result<Self> {
let client = ClientBuilder::new()
.cookie_store(true)
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.timeout(std::time::Duration::from_secs(15))
.build()?;
// Visit a Yahoo Finance quote page to obtain session cookies
client
.get(format!("https://finance.yahoo.com/quote/{symbol}/"))
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.send()
.await
.context("Failed to load Yahoo Finance (cookie init)")?;
// Fetch crumb using established session cookies
let crumb = client
.get(CRUMB_URL)
.header("Accept", "*/*")
.header("Referer", "https://finance.yahoo.com/")
.send()
.await
.context("Failed to fetch Yahoo Finance crumb")?
.text()
.await
.context("Failed to read crumb response")?;
if crumb.contains("Unauthorized") || crumb.is_empty() {
return Err(anyhow!("Failed to authenticate with Yahoo Finance"));
}
Ok(Self { client, crumb })
}
async fn summary(&self, symbol: &str, modules: &str) -> Result<Value> {
let url = format!("{BASE_SUMMARY}/{symbol}?modules={modules}&crumb={}", self.crumb);
self.client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.context("Network error fetching quoteSummary")?
.json()
.await
.context("Failed to parse quoteSummary JSON")
}
async fn chart(&self, symbol: &str) -> Result<Value> {
let url = format!("{BASE_CHART}/{symbol}?interval=1d&range=1y&crumb={}", self.crumb);
self.client
.get(&url)
.header("Accept", "application/json")
.send()
.await?
.json()
.await
.context("Failed to parse chart JSON")
}
async fn search(&self, query: &str, news_count: u32) -> Result<Value> {
let url = format!("{BASE_SEARCH}?q={query}&newsCount={news_count}&quotesCount=0&crumb={}", self.crumb);
self.client
.get(&url)
.header("Accept", "application/json")
.send()
.await?
.json()
.await
.context("Failed to parse search JSON")
}
}
// ─── Public entry point ────────────────────────────────────────────────────
pub async fn fetch_ticker(symbol: &str) -> Result<AnalysisInput> {
let sym = symbol.trim().to_uppercase();
let session = YahooSession::init(&sym).await?;
// Detect asset type first
let qt_val = session.summary(&sym, "quoteType").await?;
let qt = qt_val["quoteSummary"]["result"][0]["quoteType"]["quoteType"]
.as_str()
.unwrap_or("");
if qt.is_empty() {
return Err(anyhow!("Ticker '{}' not found. Check the symbol and try again.", sym));
}
match qt {
"EQUITY" => fetch_equity(&session, &sym).await.map(AnalysisInput::Equity),
"ETF" | "MUTUALFUND" => fetch_etf(&session, &sym).await.map(AnalysisInput::Etf),
other => Err(anyhow!("Unsupported asset type '{other}'. Only stocks and ETFs are supported.")),
}
}
// ─── Equity fetch ──────────────────────────────────────────────────────────
async fn fetch_equity(session: &YahooSession, symbol: &str) -> Result<TickerData> {
let modules = "quoteType,assetProfile,financialData,incomeStatementHistoryQuarterly,\
balanceSheetHistory,cashflowStatementHistory,calendarEvents,\
earningsHistory,summaryDetail,recommendationTrend,defaultKeyStatistics";
let (summary_res, chart_res, news_res) = tokio::join!(
session.summary(symbol, modules),
session.chart(symbol),
session.search(symbol, 5),
);
let summary = summary_res.context("Summary fetch failed")?;
let chart = chart_res.unwrap_or(Value::Null);
let news_val = news_res.unwrap_or(Value::Null);
let r = &summary["quoteSummary"]["result"][0];
if r.is_null() {
return Err(anyhow!("No data returned for '{symbol}'"));
}
let asset_profile = &r["assetProfile"];
let financial_data = &r["financialData"];
let summary_detail = &r["summaryDetail"];
let default_key_stats = &r["defaultKeyStatistics"];
let income_quarterly = &r["incomeStatementHistoryQuarterly"]["incomeStatementHistory"];
let _cashflow = &r["cashflowStatementHistory"]["cashflowStatements"];
let calendar = &r["calendarEvents"];
let earnings_hist = &r["earningsHistory"]["history"];
let rec_trend = &r["recommendationTrend"]["trend"];
let company_name = r["quoteType"]["longName"]
.as_str()
.or_else(|| r["quoteType"]["shortName"].as_str())
.map(str::to_string);
let business_summary = asset_profile["longBusinessSummary"]
.as_str()
.map(|s| truncate_sentences(s, 2));
let sector = asset_profile["sector"].as_str().map(str::to_string);
let industry = asset_profile["industry"].as_str().map(str::to_string);
// Price: prefer financialData, fall back to chart meta
let chart_meta = &chart["chart"]["result"][0]["meta"];
let current_price = financial_data["currentPrice"]["raw"]
.as_f64()
.or_else(|| chart_meta["regularMarketPrice"].as_f64());
let week_52_high = summary_detail["fiftyTwoWeekHigh"]["raw"]
.as_f64()
.or_else(|| chart_meta["fiftyTwoWeekHigh"].as_f64());
let week_52_low = summary_detail["fiftyTwoWeekLow"]["raw"]
.as_f64()
.or_else(|| chart_meta["fiftyTwoWeekLow"].as_f64());
let market_cap = summary_detail["marketCap"]["raw"].as_f64();
let beta = summary_detail["beta"]["raw"]
.as_f64()
.or_else(|| default_key_stats["beta"]["raw"].as_f64());
let pe_ratio = summary_detail["trailingPE"]["raw"].as_f64();
let forward_pe = summary_detail["forwardPE"]["raw"].as_f64();
// Price momentum from chart closes
let closes = chart["chart"]["result"][0]["indicators"]["quote"][0]["close"]
.as_array()
.cloned()
.unwrap_or_default();
let (price_change_1m_pct, price_change_3m_pct) = compute_price_changes(&closes);
// Quarterly revenue
let revenue_quarters = extract_quarterly_revenue(income_quarterly);
// EPS quarters from earningsHistory
let eps_quarters = extract_eps_quarters(earnings_hist);
// TTM figures from financialData
let revenue_ttm = financial_data["totalRevenue"]["raw"].as_f64();
let free_cash_flow_ttm = financial_data["freeCashflow"]["raw"].as_f64();
// debtToEquity from Yahoo is returned as a percentage (e.g. 79.548 means 0.795 ratio)
let debt_to_equity = financial_data["debtToEquity"]["raw"]
.as_f64()
.map(|v| v / 100.0);
let current_ratio = financial_data["currentRatio"]["raw"].as_f64();
// Dividends — yield is already a fraction (0.0038 = 0.38%)
let dividend_yield = summary_detail["dividendYield"]["raw"].as_f64();
let payout_ratio = summary_detail["payoutRatio"]["raw"].as_f64();
let dividend_5yr_growth = summary_detail["fiveYearAvgDividendYield"]["raw"].as_f64();
let dividend_cut_flag = detect_dividend_cut(&summary_detail);
// Analyst
let analyst_consensus = financial_data["recommendationKey"]
.as_str()
.map(str::to_string);
let (analyst_trend_current, analyst_trend_1m_ago, analyst_trend_2m_ago) =
extract_analyst_trends(rec_trend);
// Earnings date
let next_earnings_date = calendar["earnings"]["earningsDate"][0]["raw"]
.as_i64()
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.date_naive()));
// Headlines
let company_headlines = extract_headlines(&news_val, 5);
let sector_headlines = if let Some(ref s) = sector {
session.search(sector_to_keyword(s), 3).await
.map(|v| extract_headlines(&v, 3))
.unwrap_or_default()
} else {
vec![]
};
let mut data = TickerData {
symbol: symbol.to_string(),
company_name,
business_summary,
sector,
industry,
asset_type: AssetType::Equity,
current_price,
price_change_1m_pct,
price_change_3m_pct,
week_52_high,
week_52_low,
market_cap,
beta,
pe_ratio,
forward_pe,
revenue_quarters,
eps_quarters,
free_cash_flow_ttm,
revenue_ttm,
debt_to_equity,
current_ratio,
dividend_yield,
payout_ratio,
dividend_5yr_growth,
dividend_cut_flag,
analyst_consensus,
analyst_buy_count: analyst_trend_current.as_ref().map(|s| s.strong_buy + s.buy),
analyst_hold_count: analyst_trend_current.as_ref().map(|s| s.hold),
analyst_sell_count: analyst_trend_current.as_ref().map(|s| s.sell + s.strong_sell),
analyst_trend_current,
analyst_trend_1m_ago,
analyst_trend_2m_ago,
next_earnings_date,
company_headlines,
sector_headlines,
data_completeness_pct: 0.0,
fetched_at: Utc::now(),
};
data.data_completeness_pct = compute_equity_completeness(&data);
Ok(data)
}
// ─── ETF fetch ─────────────────────────────────────────────────────────────
async fn fetch_etf(session: &YahooSession, symbol: &str) -> Result<EtfData> {
let modules = "quoteType,fundProfile,summaryDetail,topHoldings,fundPerformance";
let (summary_res, chart_res, news_res) = tokio::join!(
session.summary(symbol, modules),
session.chart(symbol),
session.search(symbol, 5),
);
let summary = summary_res.context("ETF summary fetch failed")?;
let chart = chart_res.unwrap_or(Value::Null);
let news_val = news_res.unwrap_or(Value::Null);
let r = &summary["quoteSummary"]["result"][0];
if r.is_null() {
return Err(anyhow!("No data returned for ETF '{symbol}'"));
}
let fund_profile = &r["fundProfile"];
let summary_detail = &r["summaryDetail"];
let top_holdings_val = &r["topHoldings"];
let fund_perf = &r["fundPerformance"];
let fund_name = r["quoteType"]["longName"]
.as_str()
.or_else(|| r["quoteType"]["shortName"].as_str())
.map(str::to_string);
let category = fund_profile["categoryName"].as_str().map(str::to_string);
let chart_meta = &chart["chart"]["result"][0]["meta"];
let current_price = chart_meta["regularMarketPrice"].as_f64();
let week_52_high = summary_detail["fiftyTwoWeekHigh"]["raw"]
.as_f64()
.or_else(|| chart_meta["fiftyTwoWeekHigh"].as_f64());
let week_52_low = summary_detail["fiftyTwoWeekLow"]["raw"]
.as_f64()
.or_else(|| chart_meta["fiftyTwoWeekLow"].as_f64());
let closes = chart["chart"]["result"][0]["indicators"]["quote"][0]["close"]
.as_array()
.cloned()
.unwrap_or_default();
let (price_change_1m_pct, _) = compute_price_changes(&closes);
// Expense ratio: try multiple locations
let expense_ratio = summary_detail["annualReportExpenseRatio"]["raw"]
.as_f64()
.or_else(|| r["defaultKeyStatistics"]["annualReportExpenseRatio"]["raw"].as_f64())
.or_else(|| {
fund_profile["feesExpensesInvestment"]["annualReportExpenseRatio"]["raw"].as_f64()
});
let total_assets = summary_detail["totalAssets"]["raw"].as_f64();
let nav_price = summary_detail["navPrice"]["raw"].as_f64();
// ETF yield: summaryDetail.yield (not dividendYield)
let dividend_yield = summary_detail["yield"]["raw"]
.as_f64()
.or_else(|| summary_detail["dividendYield"]["raw"].as_f64());
let mut top_holdings_list: Vec<(String, f64)> = top_holdings_val["holdings"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|h| {
let name = h["holdingName"].as_str()?;
let pct = h["holdingPercent"]["raw"].as_f64()?;
Some((name.to_string(), pct * 100.0))
})
.collect()
})
.unwrap_or_default();
top_holdings_list.truncate(10);
let top_10_concentration = if !top_holdings_list.is_empty() {
Some(top_holdings_list.iter().map(|(_, w)| w).sum::<f64>())
} else {
None
};
let trailing = &fund_perf["trailingReturns"];
let ytd_return = trailing["ytd"]["raw"].as_f64().map(|v| v * 100.0);
let one_year_return = trailing["oneYear"]["raw"].as_f64().map(|v| v * 100.0);
let three_year_return = trailing["threeYear"]["raw"].as_f64().map(|v| v * 100.0);
let name_lower = fund_name.as_deref().unwrap_or("").to_lowercase();
let is_leveraged = name_lower.contains("2x")
|| name_lower.contains("3x")
|| name_lower.contains("leveraged")
|| name_lower.contains("ultra");
let is_inverse = name_lower.contains("inverse")
|| name_lower.contains("short")
|| name_lower.contains("bear");
let headlines = extract_headlines(&news_val, 5);
let mut data = EtfData {
symbol: symbol.to_string(),
fund_name,
category,
asset_type: AssetType::Etf,
current_price,
price_change_1m_pct,
week_52_high,
week_52_low,
expense_ratio,
total_assets,
nav_price,
dividend_yield,
top_holdings: top_holdings_list,
top_10_concentration,
ytd_return,
one_year_return,
three_year_return,
is_leveraged,
is_inverse,
headlines,
data_completeness_pct: 0.0,
fetched_at: Utc::now(),
};
data.data_completeness_pct = compute_etf_completeness(&data);
Ok(data)
}
// ─── Helpers ───────────────────────────────────────────────────────────────
fn truncate_sentences(text: &str, n: usize) -> String {
let mut count = 0;
let mut end = text.len();
for (i, ch) in text.char_indices() {
if ch == '.' {
count += 1;
if count >= n {
end = i + 1;
break;
}
}
}
text[..end].trim().to_string()
}
fn compute_price_changes(closes: &[Value]) -> (Option<f64>, Option<f64>) {
let prices: Vec<f64> = closes.iter().filter_map(|v| v.as_f64()).collect();
if prices.len() < 2 {
return (None, None);
}
let current = *prices.last().unwrap();
let pct = |ago: usize| -> Option<f64> {
let idx = prices.len().saturating_sub(ago);
if idx == prices.len() {
return None;
}
let past = prices[idx];
if past == 0.0 { None } else { Some((current - past) / past * 100.0) }
};
(pct(22), pct(63))
}
fn extract_quarterly_revenue(income_quarterly: &Value) -> Vec<(String, f64)> {
income_quarterly
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|q| {
let date = q["endDate"]["fmt"].as_str()?.to_string();
let rev = q["totalRevenue"]["raw"].as_f64()?;
Some((date, rev))
})
.take(4)
.collect()
})
.unwrap_or_default()
}
fn extract_eps_quarters(earnings_hist: &Value) -> Vec<(String, f64)> {
let mut quarters: Vec<(String, f64)> = earnings_hist
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|q| {
let date = q["quarter"]["fmt"].as_str()?.to_string();
let eps = q["epsActual"]["raw"].as_f64()?;
Some((date, eps))
})
.collect()
})
.unwrap_or_default();
// earningsHistory returns oldest-first; reverse so index 0 = most recent
quarters.reverse();
quarters.truncate(4);
quarters
}
fn detect_dividend_cut(_summary_detail: &Value) -> bool {
// Dividend history is not available in quoteSummary; a real implementation
// would need the chart endpoint or a separate history call. Until then the
// -2.0 penalty in score_dividend for dividend_cut_flag is intentionally
// dormant — do not remove that branch.
false
}
fn extract_analyst_trends(
trend: &Value,
) -> (Option<AnalystSnapshot>, Option<AnalystSnapshot>, Option<AnalystSnapshot>) {
let parse = |period: &str| -> Option<AnalystSnapshot> {
trend.as_array()?.iter().find(|t| t["period"].as_str() == Some(period)).map(|t| {
// recommendationTrend values are plain integers, not {raw:...}
AnalystSnapshot {
strong_buy: t["strongBuy"].as_u64().unwrap_or(0) as u32,
buy: t["buy"].as_u64().unwrap_or(0) as u32,
hold: t["hold"].as_u64().unwrap_or(0) as u32,
sell: t["sell"].as_u64().unwrap_or(0) as u32,
strong_sell: t["strongSell"].as_u64().unwrap_or(0) as u32,
}
})
};
(parse("0m"), parse("-1m"), parse("-2m"))
}
fn extract_headlines(val: &Value, max: usize) -> Vec<String> {
val["news"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|n| n["title"].as_str().map(str::to_string))
.take(max)
.collect()
})
.unwrap_or_default()
}
fn sector_to_keyword(sector: &str) -> &str {
match sector {
"Technology" => "technology+sector+news",
"Healthcare" => "healthcare+sector+news",
"Financial Services" => "financial+sector+news",
"Energy" => "energy+sector+news",
"Consumer Cyclical" => "consumer+retail+sector+news",
"Industrials" => "industrial+sector+news",
"Communication Services" => "media+telecom+sector+news",
"Real Estate" => "real+estate+sector+news",
"Utilities" => "utilities+sector+news",
"Consumer Defensive" => "consumer+staples+sector+news",
"Basic Materials" => "materials+mining+sector+news",
_ => "stock+market+news",
}
}
fn compute_equity_completeness(data: &TickerData) -> f64 {
let fields = [
data.current_price.is_some(),
data.pe_ratio.is_some(),
data.market_cap.is_some(),
data.week_52_high.is_some(),
data.week_52_low.is_some(),
!data.revenue_quarters.is_empty(),
!data.eps_quarters.is_empty(),
data.free_cash_flow_ttm.is_some(),
data.debt_to_equity.is_some(),
data.current_ratio.is_some(),
data.analyst_trend_current.is_some(),
data.business_summary.is_some(),
data.beta.is_some(),
data.revenue_ttm.is_some(),
data.price_change_1m_pct.is_some(),
data.price_change_3m_pct.is_some(),
];
fields.iter().filter(|&&f| f).count() as f64 / fields.len() as f64
}
fn compute_etf_completeness(data: &EtfData) -> f64 {
let fields = [
data.current_price.is_some(),
data.expense_ratio.is_some(),
data.total_assets.is_some(),
data.dividend_yield.is_some(),
!data.top_holdings.is_empty(),
data.one_year_return.is_some(),
data.three_year_return.is_some(),
data.week_52_high.is_some(),
data.week_52_low.is_some(),
];
fields.iter().filter(|&&f| f).count() as f64 / fields.len() as f64
}

137
src/data/mod.rs Normal file
View File

@@ -0,0 +1,137 @@
use chrono::{DateTime, NaiveDate, Utc};
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum AssetType {
Equity,
Etf,
Unknown(String),
}
#[derive(Debug, Clone)]
pub struct AnalystSnapshot {
pub strong_buy: u32,
pub buy: u32,
pub hold: u32,
pub sell: u32,
pub strong_sell: u32,
}
impl AnalystSnapshot {
pub fn bullish_ratio(&self) -> f64 {
let total = (self.strong_buy + self.buy + self.hold + self.sell + self.strong_sell) as f64;
if total == 0.0 {
return 0.0;
}
(self.strong_buy + self.buy) as f64 / total
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct TickerData {
pub symbol: String,
pub company_name: Option<String>,
pub business_summary: Option<String>,
pub sector: Option<String>,
pub industry: Option<String>,
pub asset_type: AssetType,
pub current_price: Option<f64>,
pub price_change_1m_pct: Option<f64>,
pub price_change_3m_pct: Option<f64>,
pub week_52_high: Option<f64>,
pub week_52_low: Option<f64>,
pub market_cap: Option<f64>,
pub beta: Option<f64>,
pub pe_ratio: Option<f64>,
pub forward_pe: Option<f64>,
pub revenue_quarters: Vec<(String, f64)>,
pub eps_quarters: Vec<(String, f64)>,
pub free_cash_flow_ttm: Option<f64>,
pub revenue_ttm: Option<f64>,
pub debt_to_equity: Option<f64>,
pub current_ratio: Option<f64>,
pub dividend_yield: Option<f64>,
pub payout_ratio: Option<f64>,
pub dividend_5yr_growth: Option<f64>,
pub dividend_cut_flag: bool,
pub analyst_consensus: Option<String>,
pub analyst_buy_count: Option<u32>,
pub analyst_hold_count: Option<u32>,
pub analyst_sell_count: Option<u32>,
pub analyst_trend_current: Option<AnalystSnapshot>,
pub analyst_trend_1m_ago: Option<AnalystSnapshot>,
pub analyst_trend_2m_ago: Option<AnalystSnapshot>,
pub next_earnings_date: Option<NaiveDate>,
pub company_headlines: Vec<String>,
pub sector_headlines: Vec<String>,
pub data_completeness_pct: f64,
pub fetched_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EtfData {
pub symbol: String,
pub fund_name: Option<String>,
pub category: Option<String>,
pub asset_type: AssetType,
pub current_price: Option<f64>,
pub price_change_1m_pct: Option<f64>,
pub week_52_high: Option<f64>,
pub week_52_low: Option<f64>,
pub expense_ratio: Option<f64>,
pub total_assets: Option<f64>,
pub nav_price: Option<f64>,
pub dividend_yield: Option<f64>,
pub top_holdings: Vec<(String, f64)>,
pub top_10_concentration: Option<f64>,
pub ytd_return: Option<f64>,
pub one_year_return: Option<f64>,
pub three_year_return: Option<f64>,
pub is_leveraged: bool,
pub is_inverse: bool,
pub headlines: Vec<String>,
pub data_completeness_pct: f64,
pub fetched_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub enum AnalysisInput {
Equity(TickerData),
Etf(EtfData),
}
impl AnalysisInput {
pub fn symbol(&self) -> &str {
match self {
AnalysisInput::Equity(d) => &d.symbol,
AnalysisInput::Etf(d) => &d.symbol,
}
}
pub fn fetched_at(&self) -> DateTime<Utc> {
match self {
AnalysisInput::Equity(d) => d.fetched_at,
AnalysisInput::Etf(d) => d.fetched_at,
}
}
}
pub mod fetch;
pub mod cache;

224
src/discord.rs Normal file
View File

@@ -0,0 +1,224 @@
use anyhow::Result;
use reqwest::Client;
use serde_json::json;
use crate::analysis::AnalysisResult;
fn truncate(s: &str, max: usize) -> String {
let t: String = s.chars().take(max).collect();
if t.len() < s.len() {
format!("{}", t.trim_end())
} else {
t
}
}
fn why_field(analysis: &AnalysisResult) -> serde_json::Value {
let value = if analysis.section_why_opinion.is_empty() {
"Signal detected — see full report for details.".to_string()
} else {
truncate(&analysis.section_why_opinion, 1024)
};
json!({"name": "Why", "value": value, "inline": false})
}
pub async fn send_signal_embed(
webhook_url: &str,
ticker: &str,
company_name: &str,
prev_signal: &str,
new_signal: &str,
analysis: &AnalysisResult,
) -> Result<()> {
if webhook_url.is_empty() {
return Ok(());
}
let color: u64 = match new_signal {
"BUY" => 5763719,
"SELL" => 15548997,
"PASS" => 16705372,
_ => 10070709,
};
let mut fields = vec![
json!({"name": "Score", "value": format!("{:.2}", analysis.final_score), "inline": true}),
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
];
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
}
fields.push(why_field(analysis));
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
let payload = json!({
"embeds": [{
"title": format!("{ticker} — {company_name}"),
"description": format!("Signal changed: {prev_signal} → {new_signal}"),
"color": color,
"fields": fields,
"footer": { "text": format!("Tickr v{} · Checked {now}", crate::VERSION) }
}]
});
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
if resp.status().as_u16() != 204 {
anyhow::bail!("Discord webhook returned {}", resp.status());
}
Ok(())
}
pub async fn send_drift_embed(
webhook_url: &str,
ticker: &str,
old_score: f64,
new_score: f64,
signal: &str,
analysis: &AnalysisResult,
) -> Result<()> {
if webhook_url.is_empty() {
return Ok(());
}
let direction = if new_score > old_score { "" } else { "" };
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
let mut fields = vec![
json!({"name": "Score", "value": format!("{:.2} → {:.2}", old_score, new_score), "inline": true}),
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
];
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
}
fields.push(why_field(analysis));
let payload = json!({
"embeds": [{
"title": format!("{ticker} — Score Drift {direction}"),
"description": format!(
"Score moved from **{:.2}** to **{:.2}** (signal still: **{signal}**)",
old_score, new_score
),
"color": 10070709u64,
"fields": fields,
"footer": { "text": format!("Tickr v{} · Checked {now}", crate::VERSION) }
}]
});
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
if resp.status().as_u16() != 204 {
anyhow::bail!("Discord webhook returned {}", resp.status());
}
Ok(())
}
pub async fn send_added_to_watchlist_embed(
webhook_url: &str,
ticker: &str,
company_name: &str,
analysis: &AnalysisResult,
) -> Result<()> {
if webhook_url.is_empty() {
return Ok(());
}
let signal = analysis.decision.label();
let color: u64 = match signal {
"BUY" => 5763719,
"SELL" => 15548997,
"PASS" => 16705372,
_ => 10070709,
};
let mut fields = vec![
json!({"name": "Signal", "value": signal, "inline": true}),
json!({"name": "Score", "value": format!("{:.2}", analysis.final_score), "inline": true}),
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
];
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
}
fields.push(why_field(analysis));
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
let payload = json!({
"embeds": [{
"title": format!("{ticker} — {company_name}"),
"description": "Added to watchlist",
"color": color,
"fields": fields,
"footer": { "text": format!("Tickr v{} · Added {now}", crate::VERSION) }
}]
});
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
if resp.status().as_u16() != 204 {
anyhow::bail!("Discord webhook returned {}", resp.status());
}
Ok(())
}
/// Post up to 10 ticker snapshots as a single Discord message (Discord's embed-per-message limit).
/// The caller is responsible for splitting larger batches and inserting delays between calls.
pub async fn send_watchlist_snapshot(
webhook_url: &str,
entries: &[(String, String, AnalysisResult)],
) -> Result<()> {
if webhook_url.is_empty() || entries.is_empty() {
return Ok(());
}
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
let embeds: Vec<serde_json::Value> = entries
.iter()
.map(|(ticker, company_name, analysis)| {
let signal = analysis.decision.label();
let color: u64 = match signal {
"BUY" => 5763719,
"SELL" => 15548997,
"PASS" => 16705372,
_ => 10070709,
};
let mut fields = vec![
json!({"name": "Signal", "value": signal, "inline": true}),
json!({"name": "Score", "value": format!("{:.2}", analysis.final_score), "inline": true}),
json!({"name": "Confidence", "value": analysis.confidence.label(), "inline": true}),
json!({"name": "Risk Level", "value": analysis.risk_level.label(), "inline": true}),
];
for s in analysis.category_scores.iter().filter(|s| s.score.abs() >= 1.0).take(3) {
fields.push(json!({"name": s.name, "value": truncate(&s.detail, 1024), "inline": true}));
}
fields.push(why_field(analysis));
json!({
"title": format!("{ticker} — {company_name}"),
"color": color,
"fields": fields,
"footer": { "text": format!("Tickr v{} · Refreshed {now}", crate::VERSION) }
})
})
.collect();
let payload = json!({ "embeds": embeds });
let resp = Client::new().post(webhook_url).json(&payload).send().await?;
if resp.status().as_u16() != 204 {
anyhow::bail!("Discord webhook returned {}", resp.status());
}
Ok(())
}

13
src/icon.rs Normal file
View File

@@ -0,0 +1,13 @@
const ICO_BYTES: &[u8] = include_bytes!("assets/icon.ico");
pub fn make_rgba() -> (Vec<u8>, u32) {
let img = image::load_from_memory(ICO_BYTES)
.expect("Failed to load assets/icon.ico")
.resize_exact(32, 32, image::imageops::FilterType::Lanczos3)
.to_rgba8();
(img.into_raw(), 32)
}
pub fn egui_icon_data(rgba: Vec<u8>, size: u32) -> egui::IconData {
egui::IconData { rgba, width: size, height: size }
}

40
src/main.rs Normal file
View File

@@ -0,0 +1,40 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod analysis;
mod app;
mod config;
mod data;
mod discord;
mod icon;
mod report;
mod ui;
mod updater;
mod version;
mod watchlist;
pub use version::VERSION;
fn main() -> eframe::Result<()> {
let config = config::ensure_exists();
let (rgba, size) = icon::make_rgba();
let icon_data = icon::egui_icon_data(rgba.clone(), size);
let start_visible = !config.preferences.start_minimized;
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Tickr — Stock Analyzer")
.with_inner_size([1100.0, 700.0])
.with_min_inner_size([800.0, 500.0])
.with_visible(start_visible)
.with_icon(icon_data),
..Default::default()
};
eframe::run_native(
"Tickr — Stock Analyzer",
native_options,
Box::new(move |cc| Box::new(app::TickrApp::new(cc, config, rgba, size, start_visible))),
)
}

324
src/report/equity.rs Normal file
View File

@@ -0,0 +1,324 @@
use crate::analysis::{AnalystTrend, AnalystTrendSignal, CategoryScore, Decision, Confidence};
use crate::data::TickerData;
pub fn verdict(data: &TickerData, decision: &Decision, confidence: &Confidence) -> String {
let name = data.company_name.as_deref().unwrap_or(&data.symbol);
format!(
"{}{} with {} confidence",
name,
decision.label(),
confidence.label()
)
}
pub fn what_company_does(data: &TickerData) -> String {
if let Some(ref summary) = data.business_summary {
return summary.clone();
}
match (&data.company_name, &data.industry, &data.sector) {
(Some(name), Some(industry), Some(sector)) => {
format!("{name} operates in the {industry} industry within the {sector} sector.")
}
(Some(name), _, Some(sector)) => {
format!("{name} operates in the {sector} sector.")
}
_ => format!("{} is a publicly traded company.", data.symbol),
}
}
pub fn why_opinion(
_data: &TickerData,
scores: &[CategoryScore],
spec_flags: &[String],
hype_flags: &[String],
force_pass: &Option<String>,
) -> String {
if let Some(ref reason) = force_pass {
return format!("This analysis is forced to PASS because: {reason}.");
}
let mut drivers: Vec<(f64, String)> = scores
.iter()
.filter(|s| s.score.abs() >= 1.0)
.map(|s| (s.score * s.weight, s.detail.clone()))
.collect();
drivers.sort_by(|a, b| b.0.abs().partial_cmp(&a.0.abs()).unwrap());
let mut lines: Vec<String> = drivers.iter().take(3).map(|(_, d)| d.clone()).collect();
if !spec_flags.is_empty() {
lines.push(format!("Speculation concerns: {}", spec_flags.join("; ")));
}
if !hype_flags.is_empty() {
lines.push(format!("Hype signals: {}", hype_flags.join("; ")));
}
if lines.is_empty() {
"No single dominant signal — score is borderline.".to_string()
} else {
lines.join(". ") + "."
}
}
pub fn looks_good(_data: &TickerData, scores: &[CategoryScore]) -> Vec<String> {
scores
.iter()
.filter(|s| s.score > 0.0)
.map(|s| s.detail.clone())
.collect()
}
pub fn worries(
_data: &TickerData,
scores: &[CategoryScore],
spec_flags: &[String],
hype_flags: &[String],
analyst_trend: &Option<AnalystTrend>,
) -> Vec<String> {
let mut items: Vec<String> = scores
.iter()
.filter(|s| s.score < 0.0)
.map(|s| s.detail.clone())
.collect();
for flag in spec_flags {
items.push(format!("{flag}"));
}
for flag in hype_flags {
items.push(format!("{flag}"));
}
if let Some(trend) = analyst_trend {
if matches!(
trend.signal,
AnalystTrendSignal::Downgrading | AnalystTrendSignal::RapidlyDowngrading
) {
items.push("Analysts have been downgrading their outlook over the past 2 months".to_string());
}
}
items
}
pub fn dividend_check(data: &TickerData) -> String {
if data.dividend_cut_flag {
return "⚠ This company recently cut its dividend — a significant negative signal for income investors.".to_string();
}
match data.dividend_yield {
Some(y) if y > 0.0 => {
let mut parts = vec![format!("Current yield: {:.2}%", y * 100.0)];
if let Some(p) = data.payout_ratio {
parts.push(format!("payout ratio: {:.0}%", p * 100.0));
}
if let Some(g) = data.dividend_5yr_growth {
if g > 0.0 {
parts.push(format!("5-year average yield: {:.2}%", g));
}
}
parts.join(", ") + "."
}
_ => "This company does not currently pay a dividend.".to_string(),
}
}
pub fn growth_check(data: &TickerData) -> String {
let mut lines = Vec::new();
// Revenue trend
let rev = &data.revenue_quarters;
if rev.len() >= 2 {
let recent = rev[0].1;
let prior = rev[1].1;
let qoq = if prior > 0.0 { (recent - prior) / prior * 100.0 } else { 0.0 };
lines.push(format!(
"Revenue {}: {:.1}% QoQ (most recent quarter)",
if qoq >= 0.0 { "up" } else { "down" },
qoq.abs()
));
}
if rev.len() >= 4 {
let recent = rev[0].1;
let year_ago = rev[3].1;
let yoy = if year_ago > 0.0 { (recent - year_ago) / year_ago * 100.0 } else { 0.0 };
lines.push(format!(
"Revenue {:.1}% YoY (vs. same quarter last year)",
yoy
));
}
// EPS trend
let eps = &data.eps_quarters;
if eps.len() >= 2 {
let recent = eps[0].1;
let prior = eps[1].1;
lines.push(format!(
"EPS: ${:.2} (recent) vs ${:.2} (prior quarter) — {}",
recent,
prior,
if recent >= prior { "improving" } else { "declining" }
));
}
if lines.is_empty() {
"Insufficient growth data available.".to_string()
} else {
lines.join(". ") + "."
}
}
pub fn valuation_check(data: &TickerData, sector_pe: f64) -> String {
let mut lines = Vec::new();
match data.pe_ratio {
Some(pe) => {
let ratio = pe / sector_pe;
let sector_name = data.sector.as_deref().unwrap_or("this sector");
lines.push(format!(
"P/E ratio of {:.1}x vs. {} median of {:.0}x ({:.0}% of sector median)",
pe, sector_name, sector_pe, ratio * 100.0
));
}
None => lines.push("Trailing P/E not available.".to_string()),
}
if let Some(fpe) = data.forward_pe {
lines.push(format!("Forward P/E: {:.1}x", fpe));
}
match (data.current_price, data.week_52_high, data.week_52_low) {
(Some(p), Some(h), Some(l)) if h > l => {
let pct_of_range = (p - l) / (h - l) * 100.0;
lines.push(format!(
"Price ${:.2} is at {:.0}% of its 52-week range (${:.2}${:.2})",
p, pct_of_range, l, h
));
}
_ => {}
}
if lines.is_empty() {
"Valuation data unavailable.".to_string()
} else {
lines.join(". ") + "."
}
}
pub fn change_mind(
_data: &TickerData,
decision: &Decision,
scores: &[CategoryScore],
_buy_thresh: f64,
_sell_thresh: f64,
) -> String {
match decision {
Decision::Buy => {
let concerns: Vec<&CategoryScore> = scores.iter().filter(|s| s.score < 0.0).collect();
if concerns.is_empty() {
"A meaningful deterioration in earnings, revenue, or analyst sentiment would prompt a reassessment.".to_string()
} else {
let items: Vec<&str> = concerns.iter().map(|c| c.name).collect();
format!(
"A BUY opinion would be reconsidered if {} continue to weaken.",
items.join(" or ")
)
}
}
Decision::Sell => {
let pos: Vec<&CategoryScore> = scores.iter().filter(|s| s.score > 0.0).collect();
if pos.is_empty() {
"A return to positive earnings growth and improved valuation would change this assessment.".to_string()
} else {
format!(
"A SELL opinion would change if earnings reversed course and valuation became more reasonable."
)
}
}
Decision::Pass => {
"A BUY opinion would require stronger evidence of earnings and revenue growth, \
with valuation in line with peers. A SELL would require further deterioration \
in fundamentals or rising debt.".to_string()
}
}
}
pub fn study_next(_data: &TickerData, scores: &[CategoryScore], name: &str) -> String {
let weakest: Vec<&CategoryScore> = {
let mut s: Vec<&CategoryScore> = scores.iter().collect();
s.sort_by(|a, b| a.score.partial_cmp(&b.score).unwrap());
s.into_iter().take(2).collect()
};
let mut topics = Vec::new();
for score in &weakest {
match score.name {
"Valuation" => topics.push("how to interpret P/E ratios relative to industry benchmarks"),
"Free Cash Flow" => topics.push("how free cash flow is calculated and why it matters for sustainability"),
"Debt Health" => topics.push("how to read a balance sheet and evaluate debt levels"),
"Revenue Trend" => topics.push("how to analyze quarterly revenue trends from earnings reports"),
"Earnings Trend" => topics.push("EPS growth and what drives earnings acceleration or decline"),
"Dividend Safety" => topics.push("dividend sustainability and payout ratio analysis"),
"Analyst Sentiment" => topics.push("how Wall Street analyst ratings work and their limitations"),
_ => topics.push("the fundamentals behind this metric"),
}
}
if topics.is_empty() {
format!("{name} might explore how to read annual reports and SEC filings for deeper insight.")
} else {
format!("{name} should study {} to better understand what drove this analysis.", topics.join(", and "))
}
}
pub fn translation(data: &TickerData, decision: &Decision, scores: &[CategoryScore], name: &str) -> String {
let company = data.company_name.as_deref().unwrap_or(&data.symbol);
let sentiment = match decision {
Decision::Buy => format!("the numbers look reasonably good for {company} right now"),
Decision::Sell => format!("there are meaningful concerns about {company} that are hard to ignore"),
Decision::Pass => format!("the case for {company} is neither clearly strong nor clearly weak"),
};
let top_positive = scores.iter().filter(|s| s.score >= 1.0).next();
let top_negative = scores.iter().filter(|s| s.score <= -1.0).next();
let mut parts = vec![
format!("{name}, in plain English: {sentiment}."),
];
if let Some(pos) = top_positive {
parts.push(format!("The strongest positive is {}.", pos.detail.to_lowercase()));
}
if let Some(neg) = top_negative {
parts.push(format!("The biggest concern is {}.", neg.detail.to_lowercase()));
}
parts.join(" ")
}
pub fn final_reason(
_data: &TickerData,
decision: &Decision,
scores: &[CategoryScore],
spec_flags: &[String],
hype_flags: &[String],
) -> String {
if !spec_flags.is_empty() {
return format!("Speculation flags present: {}", spec_flags[0]);
}
if !hype_flags.is_empty() {
return format!("Hype signal detected: {}", hype_flags[0]);
}
let top = scores
.iter()
.max_by(|a, b| (a.score * a.weight).abs().partial_cmp(&(b.score * b.weight).abs()).unwrap());
match top {
Some(s) => s.detail.clone(),
None => format!("Score near threshold — {} is the most appropriate call", decision.label()),
}
}

160
src/report/etf.rs Normal file
View File

@@ -0,0 +1,160 @@
use crate::analysis::{CategoryScore, Decision};
use crate::data::EtfData;
pub fn verdict(data: &EtfData, decision: &Decision) -> String {
let name = data.fund_name.as_deref().unwrap_or(&data.symbol);
format!("{}{}", name, decision.label())
}
pub fn what_fund_does(data: &EtfData) -> String {
let name = data.fund_name.as_deref().unwrap_or(&data.symbol);
let category = data.category.as_deref().unwrap_or("diversified");
if let Some(aum) = data.total_assets {
format!(
"{name} is a {category} ETF with ${:.1}B in assets under management.",
aum / 1e9
)
} else {
format!("{name} is a {category} ETF.")
}
}
pub fn why_opinion(_data: &EtfData, scores: &[CategoryScore]) -> String {
let mut drivers: Vec<(f64, &str)> = scores
.iter()
.filter(|s| s.score.abs() >= 1.0)
.map(|s| (s.score * s.weight, s.detail.as_str()))
.collect();
drivers.sort_by(|a, b| b.0.abs().partial_cmp(&a.0.abs()).unwrap());
let lines: Vec<String> = drivers.iter().take(3).map(|(_, d)| d.to_string()).collect();
if lines.is_empty() {
"No single dominant factor — score is borderline.".to_string()
} else {
lines.join(". ") + "."
}
}
pub fn looks_good(_data: &EtfData, scores: &[CategoryScore]) -> Vec<String> {
scores.iter().filter(|s| s.score > 0.0).map(|s| s.detail.clone()).collect()
}
pub fn worries(data: &EtfData, scores: &[CategoryScore]) -> Vec<String> {
let mut items: Vec<String> = scores
.iter()
.filter(|s| s.score < 0.0)
.map(|s| s.detail.clone())
.collect();
if data.is_leveraged {
items.push("⚠ Leveraged ETF — not suitable for long-term holding".to_string());
}
if data.is_inverse {
items.push("⚠ Inverse ETF — designed for short-term hedging only".to_string());
}
items
}
pub fn dividend_check(data: &EtfData) -> String {
match data.dividend_yield {
Some(y) if y > 0.0 => format!("Distribution yield: {:.2}%", y * 100.0),
_ => "This ETF does not currently distribute dividends or yield data is unavailable.".to_string(),
}
}
pub fn performance_check(data: &EtfData) -> String {
let mut parts = Vec::new();
if let Some(ytd) = data.ytd_return {
parts.push(format!("YTD: {:.1}%", ytd));
}
if let Some(y1) = data.one_year_return {
parts.push(format!("1-year: {:.1}%", y1));
}
if let Some(y3) = data.three_year_return {
parts.push(format!("3-year: {:.1}%", y3));
}
if parts.is_empty() {
"Performance data unavailable.".to_string()
} else {
parts.join(", ") + "."
}
}
pub fn expense_check(data: &EtfData) -> String {
match data.expense_ratio {
Some(er) => {
let pct = er * 100.0;
let context = if pct < 0.10 {
"well below the industry average — very cost-efficient"
} else if pct < 0.30 {
"below the industry average — cost-efficient"
} else if pct < 0.60 {
"near the industry average"
} else {
"above average — this fee drag compounds significantly over time"
};
format!("Annual expense ratio: {:.2}% — {}", pct, context)
}
None => "Expense ratio data unavailable.".to_string(),
}
}
pub fn change_mind(_data: &EtfData, decision: &Decision) -> String {
match decision {
Decision::Buy => {
"A BUY opinion would be reconsidered if the expense ratio increases, \
AUM drops significantly, or the fund strategy shifts to a more concentrated/leveraged approach.".to_string()
}
Decision::Pass | Decision::Sell => {
"A more favorable opinion would require a lower expense ratio, \
broader diversification, or improved trailing returns relative to category peers.".to_string()
}
}
}
pub fn study_next(_data: &EtfData, scores: &[CategoryScore], name: &str) -> String {
let weakest = scores.iter().min_by(|a, b| a.score.partial_cmp(&b.score).unwrap());
match weakest.map(|s| s.name) {
Some("Expense Ratio") => format!("{name} should study how expense ratios compound over time and why even small differences matter significantly over a 20-year horizon."),
Some("Diversification") => format!("{name} should explore the difference between concentrated sector ETFs and broad market index funds."),
Some("AUM / Liquidity") => format!("{name} should learn about ETF liquidity risk and why fund size matters for buying and selling shares efficiently."),
Some("Performance") => format!("{name} should study how to compare ETF returns against a benchmark index and what tracking error means."),
_ => format!("{name} should explore how to compare ETFs across the same category using expense ratios, AUM, and trailing returns."),
}
}
pub fn translation(data: &EtfData, decision: &Decision, name: &str) -> String {
let fund = data.fund_name.as_deref().unwrap_or(&data.symbol);
match decision {
Decision::Buy => format!(
"{name}, in plain English: {fund} appears to be a solid, cost-effective choice. \
The expense ratio is reasonable and the fund offers good diversification."
),
Decision::Pass => format!(
"{name}, in plain English: {fund} is neither clearly great nor clearly bad. \
There are some concerns worth noting, but nothing that makes it an obvious avoid."
),
Decision::Sell => format!(
"{name}, in plain English: {fund} has some meaningful drawbacks — \
likely a high expense ratio, concentrated holdings, or poor recent performance."
),
}
}
pub fn final_reason(data: &EtfData, decision: &Decision, scores: &[CategoryScore]) -> String {
if data.is_leveraged || data.is_inverse {
return "Leveraged/inverse ETF structure makes it unsuitable for long-term investors".to_string();
}
let top = scores.iter().max_by(|a, b| {
(a.score * a.weight).abs().partial_cmp(&(b.score * b.weight).abs()).unwrap()
});
match top {
Some(s) => s.detail.clone(),
None => format!("{} based on overall fund characteristics", decision.label()),
}
}

16
src/report/mod.rs Normal file
View File

@@ -0,0 +1,16 @@
pub mod equity;
pub mod etf;
#[allow(dead_code)]
pub fn format_currency(val: f64) -> String {
let abs = val.abs();
let sign = if val < 0.0 { "-" } else { "" };
if abs >= 1_000_000_000.0 {
format!("{sign}${:.2}B", abs / 1_000_000_000.0)
} else if abs >= 1_000_000.0 {
format!("{sign}${:.1}M", abs / 1_000_000.0)
} else {
format!("{sign}${:.0}", abs)
}
}

89
src/ui/analysis_panel.rs Normal file
View File

@@ -0,0 +1,89 @@
use egui::{Color32, RichText, Ui};
use crate::analysis::AnalysisResult;
use super::section_header;
pub fn render(ui: &mut Ui, result: &AnalysisResult) {
ui.horizontal(|ui| {
ui.label(RichText::new("Final Opinion:").strong());
ui.label(
RichText::new(result.decision.label())
.strong()
.size(18.0)
.color(result.decision.color()),
);
});
ui.horizontal(|ui| {
ui.label(RichText::new("Confidence:").strong());
ui.label(result.confidence.label());
});
ui.horizontal(|ui| {
ui.label(RichText::new("Risk Level:").strong());
let risk_color = match result.risk_level {
crate::analysis::RiskLevel::High => Color32::from_rgb(231, 76, 60),
crate::analysis::RiskLevel::Medium => Color32::from_rgb(241, 196, 15),
crate::analysis::RiskLevel::Low => Color32::from_rgb(39, 174, 96),
};
ui.label(RichText::new(result.risk_level.label()).color(risk_color));
});
if let Some(ref fp) = result.force_pass_reason {
ui.add_space(4.0);
ui.label(RichText::new(format!("{fp}")).color(Color32::YELLOW).italics());
}
section_header(ui, "One-Sentence Verdict");
ui.label(&result.section_verdict);
section_header(ui, "What This Company Does");
ui.label(&result.section_what_company_does);
section_header(ui, "Why This Is The Opinion");
ui.label(&result.section_why_opinion);
if !result.section_looks_good.is_empty() {
section_header(ui, "What Looks Good");
for item in &result.section_looks_good {
ui.label(format!("{item}"));
}
}
if !result.section_worries.is_empty() {
section_header(ui, "What Worries Me");
for item in &result.section_worries {
ui.label(format!("{item}"));
}
}
section_header(ui, "Dividend / Income Check");
ui.label(&result.section_dividend);
section_header(ui, "Growth Check");
ui.label(&result.section_growth);
section_header(ui, "Valuation Check");
ui.label(&result.section_valuation);
section_header(ui, "What Would Change My Mind");
ui.label(&result.section_change_mind);
section_header(ui, "What You Should Study Next");
ui.label(&result.section_study_next);
section_header(ui, "Plain English Translation");
ui.label(&result.section_translation);
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
ui.label(RichText::new("Final Answer:").strong());
ui.label(
RichText::new(&result.section_final_answer)
.strong()
.color(result.decision.color()),
);
});
ui.label(format!("Reason: {}", result.section_final_reason));
}

218
src/ui/data_panel.rs Normal file
View File

@@ -0,0 +1,218 @@
use egui::{Color32, RichText, ScrollArea, Ui};
use crate::analysis::AnalysisResult;
use crate::data::AnalysisInput;
use super::{format_optional_currency, format_optional_f64, label_value};
pub fn render(ui: &mut Ui, input: &AnalysisInput, result: &AnalysisResult) {
ScrollArea::vertical()
.id_source("data_panel_scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
match input {
AnalysisInput::Equity(data) => render_equity(ui, data, result),
AnalysisInput::Etf(data) => render_etf(ui, data, result),
}
});
}
fn render_equity(ui: &mut Ui, data: &crate::data::TickerData, result: &AnalysisResult) {
ui.label(RichText::new("PRICE").strong().underline());
label_value(ui, "Price", &format_optional_f64(data.current_price, 2, ""));
label_value(ui, "1M Change", &format_change(data.price_change_1m_pct));
label_value(ui, "3M Change", &format_change(data.price_change_3m_pct));
let range_str = match (data.week_52_low, data.week_52_high) {
(Some(l), Some(h)) => format!("${:.2} ${:.2}", l, h),
_ => "N/A".to_string(),
};
label_value(ui, "52W Range", &range_str);
label_value(ui, "Market Cap", &format_optional_currency(data.market_cap));
label_value(ui, "Beta", &format_optional_f64(data.beta, 2, ""));
ui.add_space(4.0);
ui.label(RichText::new("VALUATION").strong().underline());
label_value(ui, "P/E Ratio", &format_optional_f64(data.pe_ratio, 1, "x"));
label_value(ui, "Fwd P/E", &format_optional_f64(data.forward_pe, 1, "x"));
if let Some(sector) = &data.sector {
use crate::analysis::equity::sector_median_pe;
let (median, _) = sector_median_pe(Some(sector));
label_value(ui, &format!("Sector P/E ({sector})"), &format!("{median:.0}x"));
}
ui.add_space(4.0);
ui.label(RichText::new("FUNDAMENTALS").strong().underline());
label_value(ui, "Revenue (TTM)", &format_optional_currency(data.revenue_ttm));
label_value(ui, "FCF (TTM)", &format_optional_currency(data.free_cash_flow_ttm));
label_value(ui, "D/E Ratio", &format_optional_f64(data.debt_to_equity, 2, ""));
label_value(ui, "Current Ratio", &format_optional_f64(data.current_ratio, 2, ""));
if !data.revenue_quarters.is_empty() {
ui.add_space(2.0);
ui.label(RichText::new("Revenue (Quarterly)").italics());
for (date, rev) in data.revenue_quarters.iter().take(4) {
ui.label(format!(" {date}: {}", format_optional_currency(Some(*rev))));
}
}
if !data.eps_quarters.is_empty() {
ui.add_space(2.0);
ui.label(RichText::new("EPS (Quarterly)").italics());
for (date, eps) in data.eps_quarters.iter().take(4) {
let color = if *eps >= 0.0 { Color32::from_rgb(39, 174, 96) } else { Color32::from_rgb(231, 76, 60) };
ui.label(RichText::new(format!(" {date}: ${:.2}", eps)).color(color));
}
}
ui.add_space(4.0);
ui.label(RichText::new("DIVIDENDS").strong().underline());
let yield_str = data.dividend_yield
.map(|y| format!("{:.2}%", y * 100.0))
.unwrap_or_else(|| "None".to_string());
label_value(ui, "Div Yield", &yield_str);
let payout_str = data.payout_ratio
.map(|p| format!("{:.0}%", p * 100.0))
.unwrap_or_else(|| "N/A".to_string());
label_value(ui, "Payout Ratio", &payout_str);
ui.add_space(4.0);
ui.label(RichText::new("ANALYST").strong().underline());
let consensus = data.analyst_consensus.as_deref().unwrap_or("N/A").to_uppercase();
if let Some(ref trend) = result.analyst_trend {
label_value(ui, "Consensus", &format!("{} {}", consensus, trend.signal.arrow()));
} else {
label_value(ui, "Consensus", &consensus);
}
if let Some(ref snap) = data.analyst_trend_current {
let _total = snap.strong_buy + snap.buy + snap.hold + snap.sell + snap.strong_sell;
label_value(ui, "Buy / Hold / Sell", &format!(
"{} / {} / {}",
snap.strong_buy + snap.buy,
snap.hold,
snap.sell + snap.strong_sell
));
}
if let Some(next) = data.next_earnings_date {
let today = chrono::Utc::now().date_naive();
let days = (next - today).num_days();
let label_str = if days >= 0 {
format!("{next} ({days} days)")
} else {
format!("{next} (past)")
};
label_value(ui, "Next Earnings", &label_str);
}
// Flags
if !result.speculation_flags.is_empty() || !result.hype_flags.is_empty() {
ui.add_space(4.0);
ui.label(RichText::new("FLAGS").strong().underline());
for flag in &result.speculation_flags {
ui.label(RichText::new(format!("{flag}")).color(Color32::YELLOW));
}
for flag in &result.hype_flags {
ui.label(RichText::new(format!("{flag}")).color(Color32::YELLOW));
}
}
// Headlines
if !data.company_headlines.is_empty() {
ui.add_space(4.0);
ui.label(RichText::new("NEWS").strong().underline());
for h in &data.company_headlines {
ui.label(format!("{h}"));
}
}
if !data.sector_headlines.is_empty() {
ui.add_space(2.0);
ui.label(RichText::new("SECTOR NEWS").italics());
for h in &data.sector_headlines {
ui.label(format!("{h}"));
}
}
}
fn render_etf(ui: &mut Ui, data: &crate::data::EtfData, _result: &AnalysisResult) {
ui.label(RichText::new("PRICE").strong().underline());
label_value(ui, "Price", &format!("${:.2}", data.current_price.unwrap_or(0.0)));
label_value(ui, "1M Change", &format_change(data.price_change_1m_pct));
let range_str = match (data.week_52_low, data.week_52_high) {
(Some(l), Some(h)) => format!("${:.2} ${:.2}", l, h),
_ => "N/A".to_string(),
};
label_value(ui, "52W Range", &range_str);
label_value(ui, "NAV", &format_optional_currency(data.nav_price));
label_value(ui, "AUM", &format_optional_currency(data.total_assets));
ui.add_space(4.0);
ui.label(RichText::new("FUND INFO").strong().underline());
label_value(ui, "Category", data.category.as_deref().unwrap_or("N/A"));
let er_str = data.expense_ratio
.map(|e| format!("{:.3}%", e * 100.0))
.unwrap_or_else(|| "N/A".to_string());
label_value(ui, "Expense Ratio", &er_str);
let yield_str = data.dividend_yield
.map(|y| format!("{:.2}%", y * 100.0))
.unwrap_or_else(|| "None".to_string());
label_value(ui, "Yield", &yield_str);
ui.add_space(4.0);
ui.label(RichText::new("PERFORMANCE").strong().underline());
if let Some(ytd) = data.ytd_return {
label_value(ui, "YTD", &format!("{:.1}%", ytd));
}
if let Some(y1) = data.one_year_return {
label_value(ui, "1-Year", &format!("{:.1}%", y1));
}
if let Some(y3) = data.three_year_return {
label_value(ui, "3-Year", &format!("{:.1}%", y3));
}
if !data.top_holdings.is_empty() {
ui.add_space(4.0);
ui.label(RichText::new("TOP HOLDINGS").strong().underline());
for (name, pct) in data.top_holdings.iter().take(5) {
ui.label(format!(" {name}: {:.1}%", pct));
}
if let Some(conc) = data.top_10_concentration {
ui.label(format!(" Top-10 total: {:.1}%", conc));
}
}
if data.is_leveraged || data.is_inverse {
ui.add_space(4.0);
ui.label(RichText::new("FLAGS").strong().underline());
if data.is_leveraged {
ui.label(RichText::new("⚠ Leveraged ETF").color(Color32::YELLOW));
}
if data.is_inverse {
ui.label(RichText::new("⚠ Inverse ETF").color(Color32::YELLOW));
}
}
if !data.headlines.is_empty() {
ui.add_space(4.0);
ui.label(RichText::new("NEWS").strong().underline());
for h in &data.headlines {
ui.label(format!("{h}"));
}
}
}
fn format_change(pct: Option<f64>) -> String {
match pct {
Some(p) => {
if p >= 0.0 {
format!("+{:.1}%", p)
} else {
format!("{:.1}%", p)
}
}
None => "N/A".to_string(),
}
}

44
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,44 @@
pub mod data_panel;
pub mod analysis_panel;
use egui::{Color32, RichText};
pub fn label_value(ui: &mut egui::Ui, label: &str, value: &str) {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{label}:")).strong());
ui.label(value);
});
}
pub fn section_header(ui: &mut egui::Ui, title: &str) {
ui.add_space(4.0);
ui.separator();
ui.label(RichText::new(title).strong().color(Color32::from_rgb(180, 180, 255)));
ui.add_space(2.0);
}
pub fn format_optional_f64(val: Option<f64>, decimals: usize, suffix: &str) -> String {
match val {
Some(v) => format!("{:.prec$}{suffix}", v, prec = decimals),
None => "N/A".to_string(),
}
}
pub fn format_optional_currency(val: Option<f64>) -> String {
match val {
Some(v) => {
let abs = v.abs();
let sign = if v < 0.0 { "-" } else { "" };
if abs >= 1_000_000_000_000.0 {
format!("{sign}${:.2}T", abs / 1e12)
} else if abs >= 1_000_000_000.0 {
format!("{sign}${:.2}B", abs / 1e9)
} else if abs >= 1_000_000.0 {
format!("{sign}${:.1}M", abs / 1e6)
} else {
format!("{sign}${:.2}", abs)
}
}
None => "N/A".to_string(),
}
}

107
src/updater.rs Normal file
View File

@@ -0,0 +1,107 @@
use anyhow::{Context, Result};
use std::io::Write;
const GITEA_BASE: &str = "https://gitea.whitlocktech.com";
const REPO_OWNER: &str = "whitlocktech";
const REPO_NAME: &str = "tickr";
const ASSET_NAME: &str = "tickr.exe";
/// Check whether a newer release exists on Gitea.
/// Returns `Some((tag_name, download_url))` when the remote version is strictly
/// greater than the running binary, `None` when already up-to-date.
pub async fn check_for_update() -> Result<Option<(String, String)>> {
let url = format!(
"{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"
);
let resp = reqwest::Client::new()
.get(&url)
.header("User-Agent", format!("tickr/{}", crate::VERSION))
.send()
.await
.context("Failed to reach Gitea")?;
if !resp.status().is_success() {
// 404 = no releases yet; treat as up-to-date rather than an error
return Ok(None);
}
let body: serde_json::Value = resp.json().await.context("Invalid JSON from Gitea")?;
let tag = body["tag_name"]
.as_str()
.context("Missing tag_name in release")?
.to_string();
let download_url = body["assets"]
.as_array()
.and_then(|assets| {
assets
.iter()
.find(|a| a["name"].as_str() == Some(ASSET_NAME))
.and_then(|a| a["browser_download_url"].as_str())
})
.context("Asset not found in release")?
.to_string();
// Strip leading 'v' and compare semver
let remote_str = tag.trim_start_matches('v');
let remote_ver = semver::Version::parse(remote_str)
.with_context(|| format!("Bad remote version: {remote_str}"))?;
let local_ver = semver::Version::parse(crate::VERSION)
.with_context(|| format!("Bad local version: {}", crate::VERSION))?;
if remote_ver > local_ver {
Ok(Some((tag, download_url)))
} else {
Ok(None)
}
}
/// Download the new binary and schedule a swap on next launch.
///
/// The download is streamed in chunks to avoid loading the whole binary into
/// memory. A small bat script is written alongside it; the bat waits 2 seconds
/// for the current process to exit, moves the new exe into place, relaunches
/// it, then deletes itself.
pub async fn download_and_swap(download_url: &str) -> Result<()> {
let exe_path = std::env::current_exe().context("Cannot resolve current exe path")?;
let exe_dir = exe_path.parent().context("Cannot resolve exe directory")?;
let update_path = exe_dir.join("tickr_update.exe");
let bat_path = exe_dir.join("tickr_swap.bat");
// ── Stream download ───────────────────────────────────────────────────
let mut resp = reqwest::Client::new()
.get(download_url)
.header("User-Agent", format!("tickr/{}", crate::VERSION))
.send()
.await
.context("Download request failed")?;
if !resp.status().is_success() {
anyhow::bail!("Download returned HTTP {}", resp.status());
}
let mut file = std::fs::File::create(&update_path)
.with_context(|| format!("Cannot create {}", update_path.display()))?;
while let Some(chunk) = resp.chunk().await.context("Download interrupted")? {
file.write_all(&chunk).context("Write error during download")?;
}
drop(file);
// ── Write swap bat ────────────────────────────────────────────────────
let bat = "@echo off\r\ntimeout /t 2 /nobreak >nul\r\nmove /y tickr_update.exe tickr.exe\r\nstart \"\" tickr.exe\r\ndel \"%~f0\"\r\n";
std::fs::write(&bat_path, bat)
.with_context(|| format!("Cannot write {}", bat_path.display()))?;
// ── Launch bat detached and exit ──────────────────────────────────────
std::process::Command::new("cmd")
.args(["/c", "tickr_swap.bat"])
.current_dir(exe_dir)
.spawn()
.context("Failed to launch swap bat")?;
std::process::exit(0);
}

1
src/version.rs Normal file
View File

@@ -0,0 +1 @@
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

66
src/watchlist.rs Normal file
View File

@@ -0,0 +1,66 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchlistEntry {
pub symbol: String,
pub last_signal: String,
pub last_score: f64,
pub last_checked: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Watchlist {
#[serde(default)]
pub tickers: Vec<WatchlistEntry>,
}
impl Watchlist {
pub fn contains(&self, symbol: &str) -> bool {
self.tickers.iter().any(|e| e.symbol.eq_ignore_ascii_case(symbol))
}
pub fn add(&mut self, entry: WatchlistEntry) {
if !self.contains(&entry.symbol) {
self.tickers.push(entry);
}
}
pub fn remove(&mut self, symbol: &str) {
self.tickers.retain(|e| !e.symbol.eq_ignore_ascii_case(symbol));
}
pub fn update_entry(&mut self, symbol: &str, signal: &str, score: f64) {
if let Some(e) = self.tickers.iter_mut().find(|e| e.symbol.eq_ignore_ascii_case(symbol)) {
e.last_signal = signal.to_string();
e.last_score = score;
e.last_checked = Utc::now();
}
}
}
pub fn watchlist_path() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join("tickr").join("watchlist.toml"))
}
pub fn load() -> Watchlist {
load_inner().unwrap_or_default()
}
fn load_inner() -> Result<Watchlist> {
let path = watchlist_path().ok_or_else(|| anyhow::anyhow!("No data dir"))?;
let text = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&text)?)
}
pub fn save(wl: &Watchlist) -> Result<()> {
let path = watchlist_path().ok_or_else(|| anyhow::anyhow!("No data dir"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(wl)?;
std::fs::write(path, text)?;
Ok(())
}