readthrough / analysis /theme_sentiment.py
Viney's picture
Claude Opus 5 (1M context)
feat: the heatmap says what management was actually naming
1bc4598
Raw History Blame Contribute Delete
17.3 kB
"""Quarter-by-quarter management sentiment by investor-relevant theme.
The analyser is deliberately deterministic. It reads management speech from
stored earnings-call transcripts, isolates sentences that concern one of five
fixed themes, and measures three separate things:
* sentiment: positive minus negative/uncertain language, scaled -100..+100;
* attention: theme-bearing sentences per 10,000 management words;
* evidence: the number of theme-bearing sentences behind the score.
Only aggregates leave this module. The source sentences are never included in
the returned object, which lets the dashboard explain a change without showing
the original transcript text.
"""
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass, field
from typing import Iterable
from analysis import theme_terms
from analysis.transcript_parse import parse_call
from storage.sections_db import get_recent_transcripts
THEMES: tuple[dict, ...] = (
{
"key": "products",
"label": "Products",
"patterns": (
r"\bproducts?\b", r"\bplatforms?\b", r"\bservices?\b",
r"\bportfolio\b", r"\broadmap\b", r"\blaunch(?:es|ed|ing)?\b",
r"\badoption\b", r"\binnovation\b", r"\bfeatures?\b",
),
},
{
"key": "geography",
"label": "Geography",
"patterns": (
r"\bgeograph(?:y|ic|ies)\b", r"\bregions?\b", r"\bcountries\b",
r"\bamericas?\b", r"\beurope\b", r"\bemea\b", r"\bapac\b",
r"\basia\b", r"\bchina\b", r"\bjapan\b", r"\bindia\b",
r"\binternational\b", r"\bdomestic\b", r"\boverseas\b",
),
},
{
"key": "geopolitics",
"label": "Geopolitics",
"patterns": (
r"\bgeopolitic\w*\b", r"\bexport controls?\b", r"\bsanctions?\b",
r"\btariffs?\b", r"\btrade restrictions?\b", r"\btrade war\b",
r"\bregulat(?:ion|ory|ors?)\b", r"\bgovernment\w*\b",
r"\bconflicts?\b", r"\bwar\b", r"\bembargo\b",
),
},
{
"key": "growth",
"label": "Growth",
"patterns": (
r"\bgrowth\b", r"\brevenue\b", r"\bdemand\b", r"\bbookings?\b",
r"\bbacklog\b", r"\borders?\b", r"\bexpansion\b",
r"\bmarket share\b", r"\bvolume\b", r"\bunits?\b",
),
},
{
"key": "margin",
"label": "Margin",
"patterns": (
r"\bmargins?\b", r"\bprofitab\w*\b", r"\bgross profit\b",
r"\boperating leverage\b", r"\bcosts?\b", r"\bexpenses?\b",
r"\bopex\b", r"\bmix\b", r"\befficienc\w*\b",
),
},
)
# Stems are intentional: "accelerating" and "accelerated" should be the same
# signal. The list is compact enough to be auditable and sector-neutral.
_POSITIVE_STEMS = (
"accelerat", "ahead", "benefit", "confident", "expand", "exceed",
"growth", "improv", "momentum", "opportun", "outperform", "record",
"resilien", "robust", "strong", "strength", "successful", "upside",
)
_NEGATIVE_STEMS = (
"challeng", "constrain", "declin", "deteriorat", "disappoint", "downturn",
"headwind", "pressure", "risk", "shortage", "slow", "soft", "uncertain",
"weak", "worsen", "volatile", "delay", "loss",
)
_HEDGE_PHRASES = (
"i think", "we believe", "too early", "hard to say", "it depends",
"we'll see", "roughly", "somewhat", "a bit", "remains to be seen",
)
_NEGATIONS = frozenset({"not", "no", "never", "neither", "without"})
EMERGING_TOPICS: tuple[tuple[str, tuple[str, ...]], ...] = (
("AI", (r"\bartificial intelligence\b", r"\bgenerative ai\b", r"\bai\b")),
("Pricing", (r"\bpricing\b", r"\bprice increases?\b", r"\bdiscount\w*\b")),
("China", (r"\bchina\b", r"\bchinese\b")),
("Export controls", (r"\bexport controls?\b", r"\bexport restrictions?\b")),
("Tariffs", (r"\btariffs?\b", r"\bimport duties\b")),
("Capacity", (r"\bcapacity\b", r"\bcapacity constraints?\b")),
("Supply chain", (r"\bsupply chain\b", r"\bsupply constraints?\b")),
("Inventory", (r"\binventor(?:y|ies)\b",)),
("Competition", (r"\bcompetit(?:ion|ive|ors?)\b", r"\bmarket share\b")),
("Capital expenditure", (r"\bcapital expenditures?\b", r"\bcapex\b")),
("Currency", (r"\bforeign exchange\b", r"\bcurrency\b", r"\bfx\b")),
("Restructuring", (r"\brestructur\w*\b", r"\bworkforce reduction\b")),
)
GEOGRAPHY_PATTERNS: tuple[str, ...] = next(
theme["patterns"] for theme in THEMES if theme["key"] == "geography"
)
@dataclass(frozen=True)
class Vocabulary:
"""The company's own names, and the ones that carry no information.
``filed`` maps a theme key to the segment names the issuer reports revenue
under. They are added to that theme's patterns, which is the point: a
sector-neutral lexicon matches the word "products" and never the iPhone, so
without this the Products row for Apple is scored on generic vocabulary
alone. Because each name comes from a filing, the enrichment stays sourced.
``exclude`` holds the issuer's own name and the speakers'. Both are said
constantly and neither is a subject anyone chose to raise.
"""
filed: dict = field(default_factory=dict)
exclude: frozenset = frozenset()
@property
def filed_terms(self) -> frozenset:
return frozenset(
term.lower() for terms in self.filed.values() for term in terms
)
def _label_theme(label: str) -> str:
"""Route one filed segment label to the theme it belongs to.
Business-segment axes are not all alike: Apple reports Americas, Europe and
Greater China where NVIDIA reports Compute and Graphics. The existing
geography patterns already know a region when they see one, so they decide
rather than a second list that could drift away from the first.
"""
return "geography" if _matches_any(label, GEOGRAPHY_PATTERNS) else "products"
def build_vocabulary(
company_name: str,
ticker: str,
labels: list,
transcripts: list,
speakers: object = (),
) -> Vocabulary:
"""Filed names the calls actually use, plus the names to ignore.
A label is only kept when it is spoken. XBRL labels arrive machine-shaped
-- "Microsoft Three Six Five Commercial Products And Cloud Services" -- and
adding "Three" as a product name would inflate the theme with a word that
means nothing. Requiring the corpus to contain the term closes that door
without a hand-written exception list.
"""
exclude = theme_terms.build_exclusions(company_name, ticker, speakers)
spoken = theme_terms.named_terms(" ".join(transcripts), exclude)
lowered = {term.lower(): count for term, count in spoken.items()}
filed: dict = {}
for axis, label in labels:
theme_key = _label_theme(str(label))
for term in theme_terms.label_terms(label):
if lowered.get(term.lower(), 0) >= theme_terms.MIN_TOTAL_MENTIONS:
filed.setdefault(theme_key, set()).add(term)
return Vocabulary(
filed={key: tuple(sorted(values)) for key, values in filed.items()},
exclude=exclude,
)
def _themes_with(vocabulary: Vocabulary) -> tuple:
"""THEMES, each carrying the filed names routed to it."""
if not vocabulary.filed:
return THEMES
enriched = []
for theme in THEMES:
extra = vocabulary.filed.get(theme["key"], ())
patterns = theme["patterns"] + tuple(
r"\b" + re.escape(term) + r"\b" for term in extra
)
enriched.append({**theme, "patterns": patterns, "filed": extra})
return tuple(enriched)
def _sentences(text: str) -> list[str]:
return [
part.strip()
for part in re.split(r"(?<=[.!?])\s+|\n+", str(text or ""))
if len(part.split()) >= 4
]
def _matches_any(text: str, patterns: Iterable[str]) -> bool:
return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns)
def _stem_hits(tokens: list[str], stems: tuple[str, ...]) -> tuple[int, int]:
"""Return (ordinary hits, negated hits) for *stems* in a token sequence."""
hits = negated = 0
for index, token in enumerate(tokens):
if not any(token.startswith(stem) for stem in stems):
continue
window = tokens[max(0, index - 3):index]
if any(word in _NEGATIONS for word in window):
negated += 1
else:
hits += 1
return hits, negated
def score_sentence(sentence: str) -> tuple[float, int, int, int]:
"""Return valence plus auditable positive/negative/uncertainty counts."""
lower = sentence.lower()
tokens = re.findall(r"[a-z']+", lower)
pos, negated_pos = _stem_hits(tokens, _POSITIVE_STEMS)
neg, negated_neg = _stem_hits(tokens, _NEGATIVE_STEMS)
hedges = sum(lower.count(phrase) for phrase in _HEDGE_PHRASES)
positive = pos + negated_neg
negative = neg + negated_pos
denominator = max(1.0, positive + negative + hedges * 0.5)
valence = (positive - negative - hedges * 0.35) / denominator
return max(-1.0, min(1.0, valence)), positive, negative, hedges
def _theme_period(text: str, patterns: tuple[str, ...]) -> dict:
relevant = [sentence for sentence in _sentences(text) if _matches_any(sentence, patterns)]
words = max(1, len(text.split()))
scored = [score_sentence(sentence) for sentence in relevant]
pos = sum(item[1] for item in scored)
neg = sum(item[2] for item in scored)
hedges = sum(item[3] for item in scored)
# One isolated sentence is too fragile to call sentiment, but its attention
# count is still useful. Missing sentiment is preferable to false precision.
score = None
if len(relevant) >= 2:
score = round(100 * sum(item[0] for item in scored) / len(relevant))
evidence = len(relevant)
if evidence >= 8 and pos + neg + hedges >= 4:
confidence = "HIGH"
elif evidence >= 3:
confidence = "MEDIUM"
elif evidence:
confidence = "LOW"
else:
confidence = "NONE"
return {
"score": score,
"attention_per_10k": round(evidence / words * 10_000, 1),
"evidence_sentences": evidence,
"positive_signals": pos,
"negative_signals": neg,
"uncertainty_signals": hedges,
"confidence": confidence,
}
def _topic_series(texts: list[str], patterns: tuple[str, ...]) -> tuple[list[int], list[float]]:
counts: list[int] = []
rates: list[float] = []
for text in texts:
count = sum(len(re.findall(pattern, text, re.IGNORECASE)) for pattern in patterns)
counts.append(count)
rates.append(round(count / max(1, len(text.split())) * 10_000, 1))
return counts, rates
def _emerging_topics(periods: list[str], texts: list[str]) -> list[dict]:
if len(texts) < 2:
return []
found: list[dict] = []
for label, patterns in EMERGING_TOPICS:
counts, rates = _topic_series(texts, patterns)
latest_count, prior_count = counts[-1], counts[-2]
latest_rate, prior_rate = rates[-1], rates[-2]
recent_prior_counts = counts[max(0, len(counts) - 3):-1]
prior_avg = sum(rates[:-1]) / max(1, len(rates) - 1)
status = ""
if latest_count >= 2 and prior_count == 0 and sum(recent_prior_counts) == 0:
status = "NEW"
elif (
latest_count >= 3
and latest_rate >= 8
and latest_rate >= max(prior_avg * 2.0, prior_rate + 5)
):
status = "SURGING"
if not status:
continue
found.append({
"theme": label,
"status": status,
"period": periods[-1],
"mentions": latest_count,
"attention_per_10k": latest_rate,
"prior_attention_per_10k": prior_rate,
"change_per_10k": round(latest_rate - prior_rate, 1),
})
return sorted(found, key=lambda item: (-item["change_per_10k"], item["theme"]))[:6]
def analyse_transcripts(
transcripts: list[tuple[str, str]],
vocabulary: "Vocabulary | None" = None,
) -> dict:
"""Analyse chronological ``(period, transcript)`` pairs. Pure and testable.
*vocabulary* is optional so the analyser stays callable from a test with
nothing but text. Supplied, it both sharpens the scores -- the issuer's
own segment names join the theme patterns -- and lets the term breakdown
mark which names carry a filing behind them.
"""
usable = [(period, text) for period, text in transcripts if str(text or "").strip()]
if len(usable) < 2:
return {}
vocabulary = vocabulary or Vocabulary()
periods: list[str] = []
management_texts: list[str] = []
calls: list[theme_terms.Call] = []
for period, raw_text in usable:
parsed = parse_call(period, raw_text)
management = parsed.management_text or parsed.prepared_text or raw_text
if not management.strip():
continue
periods.append(period)
management_texts.append(management)
calls.append(theme_terms.Call(
period=period,
prepared_text=parsed.prepared_text or management,
answers_text=parsed.answers_text,
))
if len(periods) < 2:
return {}
themes = _themes_with(vocabulary)
breakdown = theme_terms.analyse(
calls,
themes,
score_sentence,
exclude=vocabulary.exclude,
filed=vocabulary.filed_terms,
)
theme_rows: list[dict] = []
for theme in themes:
values = [_theme_period(text, theme["patterns"]) for text in management_texts]
latest_score = values[-1]["score"]
previous_score = values[-2]["score"]
change = (
latest_score - previous_score
if latest_score is not None and previous_score is not None
else None
)
theme_rows.append({
"key": theme["key"],
"label": theme["label"],
"periods": values,
"latest_score": latest_score,
"change": change,
"latest_attention_per_10k": values[-1]["attention_per_10k"],
"latest_evidence_sentences": values[-1]["evidence_sentences"],
"confidence": values[-1]["confidence"],
"terms": breakdown.get(theme["key"], []),
})
measurable = [row for row in theme_rows if row["change"] is not None]
biggest_improvement = max(measurable, key=lambda row: row["change"], default=None)
biggest_deterioration = min(measurable, key=lambda row: row["change"], default=None)
return {
"method": "deterministic_lexicon_v2",
"filed_vocabulary": sorted(vocabulary.filed_terms),
"periods": periods,
"themes": theme_rows,
"biggest_improvement": (
{"theme": biggest_improvement["label"], "change": biggest_improvement["change"]}
if biggest_improvement and biggest_improvement["change"] > 0 else None
),
"biggest_deterioration": (
{"theme": biggest_deterioration["label"], "change": biggest_deterioration["change"]}
if biggest_deterioration and biggest_deterioration["change"] < 0 else None
),
"emerging_themes": _emerging_topics(periods, management_texts),
}
def _stored_vocabulary(ticker: str, transcripts: list) -> Vocabulary:
"""Build the vocabulary from the databases, degrading to an empty one.
Every source here is an enhancement: a missing segments table or an
unresolved LFS pointer must cost the section its product names, never the
section itself.
"""
company_name = ''
labels: list = []
try:
from storage import metrics_db
rows = metrics_db.get_all_metrics(ticker) or []
company_name = next(
(str(row.get('company_name') or '') for row in rows if row.get('company_name')),
'',
)
except Exception:
pass
try:
labels = _filed_labels(ticker)
except Exception:
labels = []
speakers: list = []
for period, text in transcripts:
speakers.extend(parse_call(period, text).speakers)
return build_vocabulary(
company_name, ticker, labels, [text for _period, text in transcripts], speakers
)
def _filed_labels(ticker: str) -> list:
"""Every distinct (axis, label) the issuer has reported a segment under."""
import sqlite3
from storage.segments_db import DB_PATH
if not DB_PATH.exists():
return []
with sqlite3.connect(DB_PATH) as conn:
return [
(axis, label)
for axis, label in conn.execute(
"SELECT DISTINCT axis, label FROM segments WHERE ticker=?",
(ticker.upper(),),
)
if label
]
def compute(ticker: str, n: int = 6) -> dict:
"""Load and analyse the latest calls for *ticker*. Never raises."""
try:
transcripts = get_recent_transcripts(ticker.upper(), n=n)
return analyse_transcripts(
transcripts, _stored_vocabulary(ticker.upper(), transcripts)
)
except Exception as exc:
import sys
print(f"[theme_sentiment] Error computing sentiment for {ticker}: {exc}", file=sys.stderr)
return {}