mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-17 04:18:08 +00:00
174 lines
6.5 KiB
Python
174 lines
6.5 KiB
Python
"""Earnings-call transcript delta analysis.
|
|
|
|
This intentionally starts lightweight: deterministic phrase deltas are computed
|
|
locally, and the LLM narrative is best-effort so the feature still works without
|
|
an AI key.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections import Counter
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from server.services.fmp_client import fetch_earning_call_transcript, fmp_is_configured
|
|
|
|
_STOPWORDS = {
|
|
"about", "after", "again", "also", "and", "are", "because", "been", "but", "can", "could",
|
|
"did", "does", "for", "from", "have", "into", "just", "like", "more", "our", "out", "over",
|
|
"said", "should", "that", "the", "their", "then", "there", "these", "they", "this", "those",
|
|
"through", "was", "were", "what", "when", "where", "which", "while", "will", "with", "would",
|
|
"you", "your", "we", "us", "quarter", "year", "thank", "thanks", "operator", "question",
|
|
}
|
|
|
|
_POSITIVE = {"growth", "accelerate", "strong", "record", "improve", "expansion", "demand", "margin", "profitable"}
|
|
_NEGATIVE = {"decline", "pressure", "risk", "weak", "slower", "headwind", "inventory", "cost", "uncertain"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Transcript:
|
|
ticker: str
|
|
year: int
|
|
quarter: int
|
|
content: str
|
|
source: str = "fmp"
|
|
|
|
|
|
def default_quarter_pair(today: date | None = None) -> tuple[tuple[int, int], tuple[int, int]]:
|
|
"""Return a reasonable current/previous quarter pair for transcript lookup."""
|
|
|
|
d = today or date.today()
|
|
current_q = ((d.month - 1) // 3) + 1
|
|
latest_q = current_q - 1
|
|
latest_year = d.year
|
|
if latest_q == 0:
|
|
latest_q = 4
|
|
latest_year -= 1
|
|
prev_q = latest_q - 1
|
|
prev_year = latest_year
|
|
if prev_q == 0:
|
|
prev_q = 4
|
|
prev_year -= 1
|
|
return (latest_year, latest_q), (prev_year, prev_q)
|
|
|
|
|
|
def _extract_content(row: Dict[str, Any]) -> str:
|
|
for key in ("content", "transcript", "text"):
|
|
value = row.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return ""
|
|
|
|
|
|
async def fetch_transcript(ticker: str, year: int, quarter: int) -> Optional[Transcript]:
|
|
if not fmp_is_configured():
|
|
return None
|
|
rows = await fetch_earning_call_transcript(ticker, year, quarter)
|
|
if not rows:
|
|
return None
|
|
content = _extract_content(rows[0])
|
|
if not content:
|
|
return None
|
|
return Transcript(ticker=ticker.upper(), year=year, quarter=quarter, content=content)
|
|
|
|
|
|
def tokenize_and_normalize(text: str) -> list[str]:
|
|
words = re.findall(r"[a-zA-Z][a-zA-Z\-']{1,}", text.lower())
|
|
normalized = [word.strip("-'") for word in words]
|
|
return [word for word in normalized if (len(word) > 2 or word == "ai") and word not in _STOPWORDS]
|
|
|
|
|
|
def _phrase_counts(text: str) -> Counter[str]:
|
|
tokens = tokenize_and_normalize(text)
|
|
phrases: Counter[str] = Counter(tokens)
|
|
for size in (2, 3):
|
|
for idx in range(0, max(0, len(tokens) - size + 1)):
|
|
phrase = " ".join(tokens[idx : idx + size])
|
|
phrases[phrase] += 1
|
|
return phrases
|
|
|
|
|
|
def _sentiment_score(counts: Counter[str]) -> float:
|
|
total = sum(counts.values()) or 1
|
|
pos = sum(counts[word] for word in _POSITIVE)
|
|
neg = sum(counts[word] for word in _NEGATIVE)
|
|
return round((pos - neg) / total * 100, 2)
|
|
|
|
|
|
def _top_new(curr: Counter[str], prev: Counter[str], limit: int = 10) -> list[dict[str, Any]]:
|
|
rows = [
|
|
{"phrase": phrase, "count": count}
|
|
for phrase, count in curr.items()
|
|
if count >= 2 and prev.get(phrase, 0) == 0 and " " in phrase
|
|
]
|
|
return sorted(rows, key=lambda row: row["count"], reverse=True)[:limit]
|
|
|
|
|
|
def _top_removed(curr: Counter[str], prev: Counter[str], limit: int = 10) -> list[dict[str, Any]]:
|
|
rows = [
|
|
{"phrase": phrase, "previous_count": count}
|
|
for phrase, count in prev.items()
|
|
if count >= 2 and curr.get(phrase, 0) == 0 and " " in phrase
|
|
]
|
|
return sorted(rows, key=lambda row: row["previous_count"], reverse=True)[:limit]
|
|
|
|
|
|
def _emphasis_shift(curr: Counter[str], prev: Counter[str], limit: int = 12) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for phrase in set(curr) | set(prev):
|
|
if " " not in phrase:
|
|
continue
|
|
curr_count = curr.get(phrase, 0)
|
|
prev_count = prev.get(phrase, 0)
|
|
delta = curr_count - prev_count
|
|
if abs(delta) < 2:
|
|
continue
|
|
rows.append({"phrase": phrase, "current_count": curr_count, "previous_count": prev_count, "delta": delta})
|
|
return sorted(rows, key=lambda row: abs(row["delta"]), reverse=True)[:limit]
|
|
|
|
|
|
def compute_delta(curr: Transcript, prev: Transcript) -> dict[str, Any]:
|
|
curr_counts = _phrase_counts(curr.content)
|
|
prev_counts = _phrase_counts(prev.content)
|
|
return {
|
|
"ticker": curr.ticker,
|
|
"available": True,
|
|
"current": {"year": curr.year, "quarter": curr.quarter},
|
|
"previous": {"year": prev.year, "quarter": prev.quarter},
|
|
"new_phrases": _top_new(curr_counts, prev_counts),
|
|
"removed_phrases": _top_removed(curr_counts, prev_counts),
|
|
"emphasis_shift": _emphasis_shift(curr_counts, prev_counts),
|
|
"tone_shift": {
|
|
"current_score": _sentiment_score(curr_counts),
|
|
"previous_score": _sentiment_score(prev_counts),
|
|
},
|
|
}
|
|
|
|
|
|
async def generate_delta_narrative(delta: dict[str, Any], ticker: str) -> dict[str, Any]:
|
|
fallback = {
|
|
"key_shifts": [row["phrase"] for row in delta.get("emphasis_shift", [])[:3]],
|
|
"what_it_means": "Transcript language changed, but AI narrative is unavailable. Review the phrase deltas for direction.",
|
|
"questions_to_ask": ["Which new phrases are one-off comments versus strategy?", "Are margin or capex terms increasing?"],
|
|
"variant_view": "Use phrase shifts as a prompt for deeper research, not as standalone evidence.",
|
|
}
|
|
try:
|
|
from server.services.gemini_service import generate_text
|
|
|
|
prompt = (
|
|
f"Analyze {ticker.upper()} earnings call transcript delta. Return concise JSON with keys "
|
|
"key_shifts, what_it_means, questions_to_ask, variant_view. Data:\n"
|
|
f"{delta}"
|
|
)
|
|
text = await generate_text(prompt)
|
|
import json
|
|
|
|
parsed = json.loads(text.strip().removeprefix("```json").removesuffix("```").strip())
|
|
if isinstance(parsed, dict):
|
|
return {**fallback, **parsed}
|
|
except Exception:
|
|
return fallback
|
|
return fallback
|