finance_query/models/sentiment/
score.rs1use serde::{Deserialize, Serialize};
4use std::sync::OnceLock;
5use vader_sentiment::SentimentIntensityAnalyzer;
6
7const THRESHOLD: f64 = 0.05;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[non_exhaustive]
13pub enum SentimentLabel {
14 Bullish,
16 Neutral,
18 Bearish,
20}
21
22impl SentimentLabel {
23 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37#[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))]
38#[non_exhaustive]
39pub struct Sentiment {
40 pub label: SentimentLabel,
42 pub score: f64,
44 pub confidence: f64,
46}
47
48impl Sentiment {
49 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 pub fn neutral() -> Self {
67 Self {
68 label: SentimentLabel::Neutral,
69 score: 0.0,
70 confidence: 0.0,
71 }
72 }
73}
74
75fn analyzer() -> &'static SentimentIntensityAnalyzer<'static> {
77 static ANALYZER: OnceLock<SentimentIntensityAnalyzer<'static>> = OnceLock::new();
78 ANALYZER.get_or_init(SentimentIntensityAnalyzer::new)
79}
80
81pub 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
94pub(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
105pub(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}