"""Loughran-McDonald text sentiment features for SEC filings. Computes word-frequency-based sentiment scores using the Loughran-McDonald financial sentiment dictionary. Features: - lm_positive_pct: fraction of words in positive word list - lm_negative_pct: fraction of words in negative word list - lm_net_sentiment: (positive - negative) / total_words - lm_uncertainty_pct: fraction of words in uncertainty word list """ from __future__ import annotations import csv import re from functools import lru_cache from pathlib import Path from typing import Any from libs.common.logging import get_logger logger = get_logger(__name__) _DICT_PATH = Path("data/dictionaries/lm_master_dictionary.csv") # Fallback minimal word lists if the full dictionary is not available. # These are the most common Loughran-McDonald words by category. _FALLBACK_POSITIVE = { "achieve", "achieved", "achieves", "achieving", "advantage", "advantages", "benefit", "beneficial", "benefits", "better", "boost", "boosted", "create", "created", "creative", "effective", "efficiency", "enable", "enabled", "enhance", "enhanced", "exceed", "exceeded", "exceeding", "excellent", "favorable", "gain", "gained", "gains", "good", "great", "greater", "grew", "growth", "highest", "improve", "improved", "improvement", "improvements", "improving", "increase", "increased", "increases", "increasing", "innovative", "leading", "opportunity", "opportunities", "optimal", "outperform", "outperformed", "positive", "profitability", "profitable", "progress", "prosper", "record", "reward", "rewarding", "strong", "stronger", "strongest", "succeed", "succeeded", "success", "successful", "superior", "surpass", "surpassed", "upturn", "win", "winning", } _FALLBACK_NEGATIVE = { "abandon", "abandoned", "adversarial", "adverse", "adversely", "challenge", "challenged", "challenges", "challenging", "close", "closed", "closing", "concern", "concerned", "concerns", "decline", "declined", "declines", "declining", "default", "defaults", "deficit", "deficient", "delay", "delayed", "delays", "deteriorate", "deteriorated", "deteriorating", "difficult", "difficulties", "difficulty", "diminish", "disappointed", "disappointing", "discontinue", "disruption", "doubt", "downturn", "downturns", "drop", "dropped", "drops", "fail", "failed", "failing", "failure", "failures", "fell", "impair", "impaired", "impairment", "impairments", "inability", "inadequate", "investigation", "investigations", "lawsuit", "lawsuits", "liability", "liabilities", "liquidate", "liquidation", "litigation", "loss", "losses", "lost", "negative", "negatively", "penalty", "penalties", "problem", "problems", "recession", "recessions", "restructure", "restructured", "restructuring", "risk", "risks", "risky", "shortfall", "shutdown", "slump", "slowdown", "suffer", "suffered", "suffering", "suspend", "suspended", "terminate", "terminated", "termination", "threat", "threaten", "uncertain", "unfavorable", "unforeseen", "unprofitable", "volatile", "volatility", "weakness", "weaknesses", "worsen", "worsened", "worsening", "writedown", "writeoff", } _FALLBACK_UNCERTAINTY = { "almost", "ambiguity", "ambiguous", "approximate", "approximately", "assume", "assumed", "assumes", "assuming", "assumption", "assumptions", "believe", "believed", "believes", "cautious", "conceivable", "conditional", "could", "depend", "dependent", "depending", "depends", "doubt", "doubtful", "estimate", "estimated", "estimates", "estimating", "estimation", "expect", "expected", "expecting", "expects", "fluctuate", "fluctuated", "fluctuates", "fluctuating", "fluctuation", "fluctuations", "indefinite", "indefinitely", "indicate", "indicated", "indicates", "indication", "likely", "may", "maybe", "might", "nearly", "pending", "perhaps", "possible", "possibly", "potential", "potentially", "predict", "predicted", "prediction", "predicting", "preliminary", "presumably", "probable", "probably", "project", "projected", "projecting", "projection", "projections", "risk", "risky", "roughly", "seem", "seemed", "seems", "sometimes", "somewhat", "suggest", "suggested", "suggesting", "suggests", "suppose", "tend", "tended", "tends", "uncertain", "uncertainty", "unclear", "undetermined", "unlikely", "unpredictable", "unsettled", "unsure", "variable", "variability", } _WORD_RE = re.compile(r"[a-z]+", re.IGNORECASE) @lru_cache(maxsize=1) def _load_dictionary() -> tuple[set[str], set[str], set[str]]: """Load Loughran-McDonald dictionary. Returns (positive, negative, uncertainty) word sets.""" if not _DICT_PATH.exists(): logger.info("lm_dict_using_fallback", path=str(_DICT_PATH)) return _FALLBACK_POSITIVE, _FALLBACK_NEGATIVE, _FALLBACK_UNCERTAINTY positive: set[str] = set() negative: set[str] = set() uncertainty: set[str] = set() try: with open(_DICT_PATH, encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: word = row.get("Word", row.get("word", "")).lower().strip() if not word: continue # LM dictionary uses non-zero year values to indicate category membership if _is_positive_value(row): positive.add(word) if _is_negative_value(row): negative.add(word) if _is_uncertainty_value(row): uncertainty.add(word) logger.info( "lm_dict_loaded", positive=len(positive), negative=len(negative), uncertainty=len(uncertainty), ) except Exception as exc: logger.warning("lm_dict_load_failed", error=str(exc)) return _FALLBACK_POSITIVE, _FALLBACK_NEGATIVE, _FALLBACK_UNCERTAINTY # Fall back if dictionary seems empty if not positive and not negative: return _FALLBACK_POSITIVE, _FALLBACK_NEGATIVE, _FALLBACK_UNCERTAINTY return positive, negative, uncertainty def _is_positive_value(row: dict[str, str]) -> bool: val = row.get("Positive", row.get("positive", "0")) try: return int(val) != 0 except (ValueError, TypeError): return False def _is_negative_value(row: dict[str, str]) -> bool: val = row.get("Negative", row.get("negative", "0")) try: return int(val) != 0 except (ValueError, TypeError): return False def _is_uncertainty_value(row: dict[str, str]) -> bool: val = row.get("Uncertainty", row.get("uncertainty", "0")) try: return int(val) != 0 except (ValueError, TypeError): return False def compute_text_features(text: str) -> dict[str, Any]: """Compute Loughran-McDonald text sentiment features from filing text. Args: text: Raw or normalized filing/exhibit text. Returns: Dict with keys: lm_positive_pct, lm_negative_pct, lm_net_sentiment, lm_uncertainty_pct, lm_word_count. """ positive_words, negative_words, uncertainty_words = _load_dictionary() words = _WORD_RE.findall(text.lower()) total = len(words) if total == 0: return { "lm_positive_pct": 0.0, "lm_negative_pct": 0.0, "lm_net_sentiment": 0.0, "lm_uncertainty_pct": 0.0, "lm_word_count": 0, } pos_count = sum(1 for w in words if w in positive_words) neg_count = sum(1 for w in words if w in negative_words) unc_count = sum(1 for w in words if w in uncertainty_words) return { "lm_positive_pct": round(pos_count / total, 6), "lm_negative_pct": round(neg_count / total, 6), "lm_net_sentiment": round((pos_count - neg_count) / total, 6), "lm_uncertainty_pct": round(unc_count / total, 6), "lm_word_count": total, }