#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
mod catalyst;
mod lexicon;
mod phrases;
use std::collections::HashMap;
use vader_sentimental::SentimentIntensityAnalyzer;
pub use catalyst::Catalyst;
const ALPHA: f64 = 15.0;
const NEGATION_SCALAR: f64 = 0.74;
const BOOST: f64 = 0.293;
const FIN_WEIGHT: f64 = 0.65;
const CATALYST_BONUS: f64 = 0.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Signal {
StronglyBullish,
Bullish,
Neutral,
Bearish,
StronglyBearish,
}
#[derive(Debug, Clone)]
pub struct Trigger {
pub term: String,
pub valence: f64,
}
#[derive(Debug, Clone)]
pub struct FinancialSentiment {
pub compound: f64,
pub positive: f64,
pub negative: f64,
pub neutral: f64,
pub signal: Signal,
pub catalyst: Option<Catalyst>,
pub triggers: Vec<Trigger>,
}
pub struct FinVader {
base: SentimentIntensityAnalyzer<'static>,
words: HashMap<&'static str, f64>,
}
impl Default for FinVader {
fn default() -> Self {
Self::new()
}
}
impl FinVader {
pub fn new() -> Self {
Self {
base: SentimentIntensityAnalyzer::new(),
words: lexicon::WORDS.iter().copied().collect(),
}
}
pub fn analyze(&self, text: &str) -> FinancialSentiment {
let masked = mask_for_base(text);
let base = self.base.polarity_scores(&masked);
let mut norm = normalize(text);
let catalyst = catalyst::detect(&norm);
let mut triggers = Vec::new();
let mut fin_sum = phrase_pass(&mut norm, &mut triggers);
let tokens: Vec<&str> = norm.split_whitespace().collect();
let mut consumed = vec![false; tokens.len()];
fin_sum += gap_pass(&tokens, &mut consumed, &mut triggers);
fin_sum += self.word_pass(&tokens, &consumed, &mut triggers);
let mut compound = if triggers.is_empty() {
base.compound
} else {
let fin_compound = fin_sum / (fin_sum * fin_sum + ALPHA).sqrt();
(1.0 - FIN_WEIGHT) * base.compound + FIN_WEIGHT * fin_compound
};
if let Some(c) = &catalyst {
compound += if c.bullish {
CATALYST_BONUS
} else {
-CATALYST_BONUS
};
}
let compound = compound.clamp(-1.0, 1.0);
FinancialSentiment {
compound,
positive: base.pos,
negative: base.neg,
neutral: base.neu,
signal: signal_for(compound),
catalyst,
triggers,
}
}
fn word_pass(&self, tokens: &[&str], consumed: &[bool], triggers: &mut Vec<Trigger>) -> f64 {
let mut sum = 0.0;
let mut i = 0;
while i < tokens.len() {
if consumed[i] {
i += 1;
continue;
}
let tok = tokens[i];
if (tok == "up" || tok == "down")
&& let Some(pct) = tokens.get(i + 1).and_then(|t| parse_pct(t))
{
let mag = (pct / 25.0).min(3.0);
let v = if tok == "up" { mag } else { -mag };
sum += v;
triggers.push(Trigger {
term: format!("{tok} {pct}%"),
valence: v,
});
i += 2;
} else {
if let Some(&valence) = self.words.get(tok) {
let v = adjust_for_context(valence, &tokens[..i]);
sum += v;
triggers.push(Trigger {
term: tok.to_string(),
valence: v,
});
}
i += 1;
}
}
sum
}
}
fn signal_for(compound: f64) -> Signal {
match compound {
c if c >= 0.5 => Signal::StronglyBullish,
c if c >= 0.15 => Signal::Bullish,
c if c <= -0.5 => Signal::StronglyBearish,
c if c <= -0.15 => Signal::Bearish,
_ => Signal::Neutral,
}
}
fn normalize(text: &str) -> String {
let mut s = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\u{2019}' => s.push('\''), c if c.is_alphanumeric() => {
for lc in c.to_lowercase() {
s.push(lc);
}
}
'-' | '&' | '%' | '$' | '\'' => s.push(ch),
_ => s.push(' '),
}
}
let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
format!(" {collapsed} ")
}
fn mask_for_base(text: &str) -> String {
text.split_whitespace()
.map(|word| {
let clean: String = word
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.flat_map(char::to_lowercase)
.collect();
if lexicon::NEUTRAL_OVERRIDES.contains(&clean.as_str()) {
"item"
} else {
word
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn phrase_pass(norm: &mut String, triggers: &mut Vec<Trigger>) -> f64 {
let mut sum = 0.0;
for &(phrase, valence) in phrases::PHRASES {
let needle = format!(" {phrase} ");
while let Some(pos) = norm.find(&needle) {
let end = pos + needle.len();
let before: Vec<&str> = norm[..pos].split_whitespace().collect();
let mut v = adjust_for_context(valence, &before);
let mut after = norm[end..].split_whitespace();
if after.next() == Some("by")
&& let Some(pct) = after.next().and_then(parse_pct)
&& pct >= 10.0
{
v *= 1.25;
}
sum += v;
triggers.push(Trigger {
term: phrase.to_string(),
valence: v,
});
norm.replace_range(pos..end, " ");
}
}
sum
}
fn gap_pass(tokens: &[&str], consumed: &mut [bool], triggers: &mut Vec<Trigger>) -> f64 {
const MAX_GAP: usize = 2;
let mut sum = 0.0;
for i in 0..tokens.len() {
if consumed[i] {
continue;
}
for &(lead, tail, valence) in phrases::GAP_PHRASES {
if tokens[i] != lead {
continue;
}
let hi = (i + 2 + MAX_GAP).min(tokens.len());
let Some(j) = (i + 1..hi).find(|&j| !consumed[j] && tokens[j] == tail) else {
continue;
};
let mut v = adjust_for_context(valence, &tokens[..i]);
if tokens.get(j + 1) == Some(&"by")
&& let Some(pct) = tokens.get(j + 2).and_then(|t| parse_pct(t))
&& pct >= 10.0
{
v *= 1.25;
}
sum += v;
triggers.push(Trigger {
term: format!("{lead} … {tail}"),
valence: v,
});
consumed[i] = true;
consumed[j] = true;
break;
}
}
sum
}
fn adjust_for_context(valence: f64, preceding: &[&str]) -> f64 {
let window = &preceding[preceding.len().saturating_sub(3)..];
if window.iter().any(|t| lexicon::NEGATIONS.contains(t)) {
return -valence * NEGATION_SCALAR;
}
if let Some(last) = window.last() {
if lexicon::BOOSTERS_UP.contains(last) {
return valence + BOOST * valence.signum();
}
if lexicon::BOOSTERS_DOWN.contains(last) {
return valence - BOOST * valence.signum();
}
}
valence
}
fn parse_pct(token: &str) -> Option<f64> {
token.strip_suffix('%')?.parse().ok()
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn strongly_bullish_on_beat_and_raise() {
let fv = FinVader::new();
let s = fv.analyze("Acme beats Q3 expectations and raises full-year guidance");
assert!(s.compound > 0.5, "compound was {}", s.compound);
assert_eq!(s.signal, Signal::StronglyBullish);
assert!(!s.triggers.is_empty());
}
#[test]
fn negation_flips_phrase() {
let fv = FinVader::new();
let s = fv.analyze("Acme failed to beat expectations this quarter");
assert!(s.compound < 0.0, "compound was {}", s.compound);
}
#[test]
fn gross_margin_is_not_negative() {
let fv = FinVader::new();
let s = fv.analyze("Gross margin expanded 300 basis points to 58%");
assert!(s.compound > 0.2, "compound was {}", s.compound);
}
#[test]
fn fda_approval_is_bullish_catalyst_despite_cancer() {
let fv = FinVader::new();
let s = fv.analyze("FDA approves Acme's new cancer drug");
assert!(s.compound > 0.2, "compound was {}", s.compound);
let cat = s.catalyst.expect("catalyst expected");
assert!(cat.bullish);
}
#[test]
fn bankruptcy_is_bearish_catalyst() {
let fv = FinVader::new();
let s = fv.analyze("Acme files for chapter 11 bankruptcy protection");
assert!(s.compound < -0.5, "compound was {}", s.compound);
let cat = s.catalyst.expect("catalyst expected");
assert!(!cat.bullish);
}
#[test]
fn plain_scheduling_news_is_neutral() {
let fv = FinVader::new();
let s = fv.analyze("Acme to report third quarter results on Thursday");
assert_eq!(s.signal, Signal::Neutral, "compound was {}", s.compound);
}
#[test]
fn magnitude_amplifier_applies() {
let fv = FinVader::new();
let small = fv.analyze("Acme beat estimates by 2%");
let large = fv.analyze("Acme beat estimates by 40%");
assert!(large.compound > small.compound);
}
#[test]
fn magnitude_amplifier_applies_to_exact_phrases() {
let fv = FinVader::new();
let plain = fv.analyze("Q4 revenue fell short");
let amplified = fv.analyze("Q4 revenue fell short by 25%");
assert!(amplified.compound < plain.compound);
}
#[test]
fn down_percent_is_bearish() {
let fv = FinVader::new();
let s = fv.analyze("Shares down 30% since January");
assert!(s.compound < -0.15, "compound was {}", s.compound);
}
#[test]
fn boosters_amplify_and_dampeners_soften() {
let fv = FinVader::new();
let boosted = fv.analyze("Acme sharply missed estimates");
let plain = fv.analyze("Acme missed estimates");
let dampened = fv.analyze("Acme slightly missed estimates");
assert!(boosted.compound < plain.compound);
assert!(dampened.compound > plain.compound);
}
#[test]
fn curly_apostrophe_is_normalized() {
let fv = FinVader::new();
let s = fv.analyze("Acme\u{2019}s margin expansion continues");
assert!(s.compound > 0.15, "compound was {}", s.compound);
}
#[test]
fn gap_phrase_by_without_pct_does_not_amplify() {
let fv = FinVader::new();
let s = fv.analyze("Acme beats expectations by");
let plain = fv.analyze("Acme beats expectations");
assert!(
(s.compound - plain.compound).abs() < 0.05,
"gap 'by' without pct changed compound too much: {} vs {}",
s.compound,
plain.compound
);
}
#[test]
fn default_matches_new() {
let s = FinVader::default().analyze("Acme beats expectations");
assert!(s.compound > 0.0);
}
}