Skip to main content

finance_query/models/sentiment/
score.rs

1//! Offline lexicon-based sentiment scoring (VADER)
2
3use serde::{Deserialize, Serialize};
4use std::sync::OnceLock;
5use vader_sentiment::SentimentIntensityAnalyzer;
6
7/// Standard VADER decision threshold on the compound score.
8const THRESHOLD: f64 = 0.05;
9
10/// Directional sentiment classification for a piece of text.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[non_exhaustive]
13pub enum SentimentLabel {
14    /// Net positive tone (`compound >= 0.05`).
15    Bullish,
16    /// Tone within the neutral band (`-0.05 < compound < 0.05`).
17    Neutral,
18    /// Net negative tone (`compound <= -0.05`).
19    Bearish,
20}
21
22impl SentimentLabel {
23    /// Human-readable label.
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            Self::Bullish => "Bullish",
27            Self::Neutral => "Neutral",
28            Self::Bearish => "Bearish",
29        }
30    }
31}
32
33/// Sentiment score for a news article or transcript segment.
34///
35/// Only present when the `sentiment` feature is enabled.
36#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37#[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))]
38#[non_exhaustive]
39pub struct Sentiment {
40    /// Directional classification.
41    pub label: SentimentLabel,
42    /// Compound score: -1.0 (most bearish) to +1.0 (most bullish).
43    pub score: f64,
44    /// Confidence: 0.0 to 1.0 (magnitude of the compound score).
45    pub confidence: f64,
46}
47
48impl Sentiment {
49    /// Build a [`Sentiment`] from a VADER compound score in `[-1.0, 1.0]`.
50    pub fn from_compound(score: f64) -> Self {
51        let label = if score >= THRESHOLD {
52            SentimentLabel::Bullish
53        } else if score <= -THRESHOLD {
54            SentimentLabel::Bearish
55        } else {
56            SentimentLabel::Neutral
57        };
58        Self {
59            label,
60            score,
61            confidence: score.abs().clamp(0.0, 1.0),
62        }
63    }
64
65    /// A neutral, zero-confidence score (used as the empty aggregate).
66    pub fn neutral() -> Self {
67        Self {
68            label: SentimentLabel::Neutral,
69            score: 0.0,
70            confidence: 0.0,
71        }
72    }
73}
74
75/// Single shared analyzer — `new()` rebinds the lexicon refs, so reuse it.
76fn analyzer() -> &'static SentimentIntensityAnalyzer<'static> {
77    static ANALYZER: OnceLock<SentimentIntensityAnalyzer<'static>> = OnceLock::new();
78    ANALYZER.get_or_init(SentimentIntensityAnalyzer::new)
79}
80
81/// Score a single piece of text.
82///
83/// Lexicon lookup is O(tokens) — typically well under a millisecond per
84/// headline. Empty/whitespace text scores [`Sentiment::neutral`].
85pub fn analyze(text: &str) -> Sentiment {
86    if text.trim().is_empty() {
87        return Sentiment::neutral();
88    }
89    let scores = analyzer().polarity_scores(text);
90    let compound = scores.get("compound").copied().unwrap_or(0.0);
91    Sentiment::from_compound(compound)
92}
93
94/// Aggregate compound scores into a single [`Sentiment`] (simple mean).
95///
96/// Returns `None` when there is nothing to aggregate.
97pub(crate) fn aggregate(scores: &[f64]) -> Option<Sentiment> {
98    if scores.is_empty() {
99        return None;
100    }
101    let mean = scores.iter().sum::<f64>() / scores.len() as f64;
102    Some(Sentiment::from_compound(mean))
103}
104
105/// Aggregate texts weighted by length (longer segments count more).
106///
107/// Used for transcript-level scoring where paragraphs differ widely in length.
108pub(crate) fn aggregate_weighted(texts: &[&str]) -> Option<Sentiment> {
109    let mut weighted_sum = 0.0;
110    let mut total_weight = 0.0;
111    for text in texts {
112        let weight = text.trim().len() as f64;
113        if weight == 0.0 {
114            continue;
115        }
116        weighted_sum += analyze(text).score * weight;
117        total_weight += weight;
118    }
119    if total_weight == 0.0 {
120        return None;
121    }
122    Some(Sentiment::from_compound(weighted_sum / total_weight))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn bullish_headline() {
131        let s = analyze("Apple stock surges to record high on blockbuster earnings");
132        assert_eq!(s.label, SentimentLabel::Bullish);
133        assert!(s.score > 0.0);
134    }
135
136    #[test]
137    fn bearish_headline() {
138        let s = analyze("Recession fears mount as inflation disappoints investors");
139        assert_eq!(s.label, SentimentLabel::Bearish);
140        assert!(s.score < 0.0);
141    }
142
143    #[test]
144    fn neutral_empty() {
145        let s = analyze("   ");
146        assert_eq!(s.label, SentimentLabel::Neutral);
147        assert_eq!(s.score, 0.0);
148        assert_eq!(s.confidence, 0.0);
149    }
150
151    #[test]
152    fn from_compound_thresholds() {
153        assert_eq!(
154            Sentiment::from_compound(0.05).label,
155            SentimentLabel::Bullish
156        );
157        assert_eq!(
158            Sentiment::from_compound(-0.05).label,
159            SentimentLabel::Bearish
160        );
161        assert_eq!(Sentiment::from_compound(0.0).label, SentimentLabel::Neutral);
162        assert_eq!(
163            Sentiment::from_compound(0.04).label,
164            SentimentLabel::Neutral
165        );
166    }
167
168    #[test]
169    fn aggregate_mean() {
170        let agg = aggregate(&[0.8, 0.6, -0.2]).unwrap();
171        assert!((agg.score - 0.4).abs() < 1e-9);
172        assert_eq!(agg.label, SentimentLabel::Bullish);
173        assert!(aggregate(&[]).is_none());
174    }
175
176    #[test]
177    fn confidence_is_magnitude() {
178        assert!((Sentiment::from_compound(-0.8).confidence - 0.8).abs() < 1e-9);
179    }
180}