use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use vader_sentiment::SentimentIntensityAnalyzer;
const THRESHOLD: f64 = 0.05;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SentimentLabel {
Bullish,
Neutral,
Bearish,
}
impl SentimentLabel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Bullish => "Bullish",
Self::Neutral => "Neutral",
Self::Bearish => "Bearish",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))]
#[non_exhaustive]
pub struct Sentiment {
pub label: SentimentLabel,
pub score: f64,
pub confidence: f64,
}
impl Sentiment {
pub fn from_compound(score: f64) -> Self {
let label = if score >= THRESHOLD {
SentimentLabel::Bullish
} else if score <= -THRESHOLD {
SentimentLabel::Bearish
} else {
SentimentLabel::Neutral
};
Self {
label,
score,
confidence: score.abs().clamp(0.0, 1.0),
}
}
pub fn neutral() -> Self {
Self {
label: SentimentLabel::Neutral,
score: 0.0,
confidence: 0.0,
}
}
}
fn analyzer() -> &'static SentimentIntensityAnalyzer<'static> {
static ANALYZER: OnceLock<SentimentIntensityAnalyzer<'static>> = OnceLock::new();
ANALYZER.get_or_init(SentimentIntensityAnalyzer::new)
}
pub fn analyze(text: &str) -> Sentiment {
if text.trim().is_empty() {
return Sentiment::neutral();
}
let scores = analyzer().polarity_scores(text);
let compound = scores.get("compound").copied().unwrap_or(0.0);
Sentiment::from_compound(compound)
}
pub(crate) fn aggregate(scores: &[f64]) -> Option<Sentiment> {
if scores.is_empty() {
return None;
}
let mean = scores.iter().sum::<f64>() / scores.len() as f64;
Some(Sentiment::from_compound(mean))
}
pub(crate) fn aggregate_weighted(texts: &[&str]) -> Option<Sentiment> {
let mut weighted_sum = 0.0;
let mut total_weight = 0.0;
for text in texts {
let weight = text.trim().len() as f64;
if weight == 0.0 {
continue;
}
weighted_sum += analyze(text).score * weight;
total_weight += weight;
}
if total_weight == 0.0 {
return None;
}
Some(Sentiment::from_compound(weighted_sum / total_weight))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bullish_headline() {
let s = analyze("Apple stock surges to record high on blockbuster earnings");
assert_eq!(s.label, SentimentLabel::Bullish);
assert!(s.score > 0.0);
}
#[test]
fn bearish_headline() {
let s = analyze("Recession fears mount as inflation disappoints investors");
assert_eq!(s.label, SentimentLabel::Bearish);
assert!(s.score < 0.0);
}
#[test]
fn neutral_empty() {
let s = analyze(" ");
assert_eq!(s.label, SentimentLabel::Neutral);
assert_eq!(s.score, 0.0);
assert_eq!(s.confidence, 0.0);
}
#[test]
fn from_compound_thresholds() {
assert_eq!(
Sentiment::from_compound(0.05).label,
SentimentLabel::Bullish
);
assert_eq!(
Sentiment::from_compound(-0.05).label,
SentimentLabel::Bearish
);
assert_eq!(Sentiment::from_compound(0.0).label, SentimentLabel::Neutral);
assert_eq!(
Sentiment::from_compound(0.04).label,
SentimentLabel::Neutral
);
}
#[test]
fn aggregate_mean() {
let agg = aggregate(&[0.8, 0.6, -0.2]).unwrap();
assert!((agg.score - 0.4).abs() < 1e-9);
assert_eq!(agg.label, SentimentLabel::Bullish);
assert!(aggregate(&[]).is_none());
}
#[test]
fn confidence_is_magnitude() {
assert!((Sentiment::from_compound(-0.8).confidence - 0.8).abs() < 1e-9);
}
}