Skip to main content

portfolio_analyzer/
sentiment.rs

1//! # Sentiment: what people are saying, as one number
2//!
3//! SD reads price.  Sentiment reads *people*: the analysts who cover a
4//! company and the headlines written about it.  It is folded into portfolio
5//! selection as a modest tie-breaker, not as a driver, for one honest
6//! reason: there is no free *historical* archive of headlines or analyst
7//! revisions, so unlike SD it cannot be backtested here.  Small weight, clear
8//! label, easy to switch off (`SENTIMENT_WEIGHT=0`).
9//!
10//! Four signals, each squeezed into `[-1, 1]`:
11//!
12//! | Signal | Source | Idea |
13//! | --- | --- | --- |
14//! | Analyst tilt | Nasdaq consensus (buy / hold / sell counts) | (buys − sells) ÷ analysts |
15//! | Revision momentum | Nasdaq monthly consensus history | is the buy share *rising* over 3 months? |
16//! | Target upside | Nasdaq mean price target | tanh(upside ÷ 25%); targets run optimistic, so lightly weighted |
17//! | Headline tone | Yahoo Finance RSS (recent headlines) | finance word lists: "beats", "upgrade" vs "miss", "probe" |
18//!
19//! ```text
20//!   raw   = 0.35·tilt + 0.25·revision + 0.15·upside + 0.25·news
21//!   score = 0.5 + 0.5 · raw · confidence      (confidence grows with coverage)
22//! ```
23//!
24//! A stock nobody covers lands at 0.5, neutral, rather than being punished
25//! or rewarded for silence.
26
27use 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    /// Final score in `[0, 1]`; 0.5 is neutral.
38    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    /// `[0, 1]`: how much evidence there was.
46    pub confidence: f64,
47    pub fetched: String,
48}
49
50/// Small finance-specific lexicons, in the spirit of Loughran & McDonald
51/// (2011): words that carry tone *in financial news*, which is not the same
52/// as everyday tone ("liability" is neutral in a balance sheet).
53const 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
66/// Tone of one piece of text in `[-1, 1]`, or `None` if it has no tone words.
67pub 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}&region=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
117/// Fetch and score sentiment for a US-listed `symbol` at `price`.
118pub 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    /// Combine the parts that exist, re-weighting over what is available,
162    /// and shrink toward neutral when evidence is thin.
163    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
185/// A day-scoped cache so re-runs on the same day do not refetch.
186pub 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}