1use anyhow::Result;
28use reqwest::blocking::Client;
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31use std::path::{Path, PathBuf};
32use std::time::Duration;
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct Sentiment {
36 pub symbol: String,
37 pub score: f64,
39 pub analyst_tilt: Option<f64>,
40 pub revision: Option<f64>,
41 pub target_upside: Option<f64>,
42 pub news_tone: Option<f64>,
43 pub analysts: u32,
44 pub headlines: u32,
45 pub confidence: f64,
47 pub fetched: String,
48}
49
50const POSITIVE: &[&str] = &[
54 "beat", "beats", "record", "surge", "surges", "soar", "soars", "rally", "rallies", "upgrade",
55 "upgraded", "outperform", "raises", "raised", "strong", "growth", "profit", "gains", "jumps",
56 "bullish", "buy", "approval", "approved", "wins", "expands", "tops", "boost", "optimistic",
57 "rebound", "accelerates", "exceeds", "breakthrough", "dividend",
58];
59const NEGATIVE: &[&str] = &[
60 "miss", "misses", "plunge", "plunges", "falls", "drop", "drops", "slump", "downgrade",
61 "downgraded", "underperform", "cuts", "cut", "weak", "loss", "losses", "lawsuit", "probe",
62 "investigation", "recall", "bearish", "sell", "warns", "warning", "fraud", "layoffs", "slows",
63 "decline", "declines", "tumbles", "halts", "bankruptcy", "default", "fine", "fined",
64];
65
66pub fn text_tone(text: &str) -> Option<(f64, u32)> {
68 let (mut pos, mut neg) = (0u32, 0u32);
69 for w in text.split(|c: char| !c.is_ascii_alphanumeric()).map(|w| w.to_ascii_lowercase()) {
70 if POSITIVE.contains(&w.as_str()) {
71 pos += 1;
72 } else if NEGATIVE.contains(&w.as_str()) {
73 neg += 1;
74 }
75 }
76 (pos + neg > 0).then(|| ((pos as f64 - neg as f64) / (pos + neg) as f64, pos + neg))
77}
78
79fn get_json(client: &Client, url: &str) -> Option<Value> {
80 client
81 .get(url)
82 .header("User-Agent", "Mozilla/5.0")
83 .header("Accept", "application/json")
84 .timeout(Duration::from_secs(12))
85 .send()
86 .ok()?
87 .json()
88 .ok()
89}
90
91fn headlines(client: &Client, symbol: &str) -> Vec<String> {
92 let url = format!("https://feeds.finance.yahoo.com/rss/2.0/headline?s={symbol}®ion=US&lang=en-US");
93 let Some(body) = client
94 .get(&url)
95 .header("User-Agent", "Mozilla/5.0")
96 .timeout(Duration::from_secs(12))
97 .send()
98 .ok()
99 .and_then(|r| r.text().ok())
100 else {
101 return vec![];
102 };
103 body.split("<item>")
104 .skip(1)
105 .filter_map(|item| {
106 let grab = |tag: &str| {
107 let a = item.find(&format!("<{tag}>"))? + tag.len() + 2;
108 let b = item[a..].find(&format!("</{tag}>"))? + a;
109 Some(item[a..b].to_string())
110 };
111 Some(format!("{} {}", grab("title")?, grab("description").unwrap_or_default()))
112 })
113 .take(20)
114 .collect()
115}
116
117pub fn fetch(client: &Client, symbol: &str, price: f64) -> Sentiment {
119 let mut s = Sentiment { symbol: symbol.to_string(), fetched: chrono::Utc::now().date_naive().to_string(), ..Default::default() };
120 let url = format!("https://api.nasdaq.com/api/analyst/{symbol}/targetprice");
121 if let Some(d) = get_json(client, &url).map(|v| v["data"].clone()).filter(|d| !d.is_null()) {
122 let o = &d["consensusOverview"];
123 let n = |v: &Value| v.as_f64().unwrap_or(0.0);
124 let (b, h, sl) = (n(&o["buy"]), n(&o["hold"]), n(&o["sell"]));
125 let total = b + h + sl;
126 if total > 0.0 {
127 s.analysts = total as u32;
128 s.analyst_tilt = Some((b - sl) / total);
129 }
130 if let Some(t) = o["priceTarget"].as_f64().filter(|t| *t > 0.0 && price > 0.0) {
131 s.target_upside = Some((t / price - 1.0).clamp(-0.9, 3.0));
132 }
133 if let Some(hist) = d["historicalConsensus"].as_array().filter(|h| h.len() >= 4) {
134 let share = |e: &Value| {
135 let z = &e["z"];
136 let (b, h, sl) = (n(&z["buy"]), n(&z["hold"]), n(&z["sell"]));
137 if b + h + sl > 0.0 { Some(b / (b + h + sl)) } else { None }
138 };
139 if let (Some(now), Some(then)) = (share(&hist[hist.len() - 1]), share(&hist[hist.len() - 4])) {
140 s.revision = Some(((now - then) * 3.0).clamp(-1.0, 1.0));
141 }
142 }
143 }
144 let lines = headlines(client, symbol);
145 let (mut sum, mut weight) = (0.0, 0.0);
146 for line in &lines {
147 if let Some((tone, words)) = text_tone(line) {
148 sum += tone * words as f64;
149 weight += words as f64;
150 s.headlines += 1;
151 }
152 }
153 if weight > 0.0 {
154 s.news_tone = Some(sum / weight);
155 }
156 s.finish();
157 s
158}
159
160impl Sentiment {
161 pub fn finish(&mut self) {
164 let parts = [
165 (self.analyst_tilt, 0.35),
166 (self.revision, 0.25),
167 (self.target_upside.map(|u| (u / 0.25).tanh()), 0.15),
168 (self.news_tone, 0.25),
169 ];
170 let (mut raw, mut w) = (0.0, 0.0);
171 for (v, wt) in parts {
172 if let Some(v) = v.filter(|v| v.is_finite()) {
173 raw += wt * v;
174 w += wt;
175 }
176 }
177 let raw = if w > 0.0 { raw / w } else { 0.0 };
178 let analyst_conf = self.analysts as f64 / (self.analysts as f64 + 5.0);
179 let news_conf = self.headlines as f64 / (self.headlines as f64 + 4.0);
180 self.confidence = (0.65 * analyst_conf + 0.35 * news_conf).clamp(0.0, 1.0);
181 self.score = (0.5 + 0.5 * raw * self.confidence).clamp(0.0, 1.0);
182 }
183}
184
185pub struct Cache {
187 path: PathBuf,
188 pub entries: std::collections::HashMap<String, Sentiment>,
189}
190
191impl Cache {
192 pub fn open(path: &Path) -> Self {
193 let today = chrono::Utc::now().date_naive().to_string();
194 let entries = std::fs::read_to_string(path)
195 .ok()
196 .and_then(|t| serde_json::from_str::<std::collections::HashMap<String, Sentiment>>(&t).ok())
197 .unwrap_or_default()
198 .into_iter()
199 .filter(|(_, s)| s.fetched == today)
200 .collect();
201 Self { path: path.to_path_buf(), entries }
202 }
203 pub fn save(&self) -> Result<()> {
204 std::fs::write(&self.path, serde_json::to_string(&self.entries)?)?;
205 Ok(())
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn tone_reads_finance_words() {
215 assert!(text_tone("Apple beats estimates, raises dividend").unwrap().0 > 0.9);
216 assert!(text_tone("Regulators open probe after earnings miss").unwrap().0 < -0.9);
217 assert!(text_tone("Company to present at conference").is_none());
218 }
219
220 #[test]
221 fn silence_is_neutral() {
222 let mut s = Sentiment::default();
223 s.finish();
224 assert_eq!(s.score, 0.5);
225 }
226
227 #[test]
228 fn thin_coverage_is_shrunk() {
229 let mut thin = Sentiment { analyst_tilt: Some(1.0), analysts: 1, ..Default::default() };
230 let mut deep = Sentiment { analyst_tilt: Some(1.0), analysts: 40, ..Default::default() };
231 thin.finish();
232 deep.finish();
233 assert!(deep.score > thin.score && thin.score > 0.5);
234 }
235}