mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-26 00:38:04 +00:00
phase 4-5: peer matrix and earnings delta
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""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
|
||||
@@ -2,68 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yfinance as yf
|
||||
|
||||
from server.utils.ticker_utils import SECTORS
|
||||
from server.core.data_gateway import DataGateway, Fundamentals
|
||||
from server.core.providers.base import DataUnavailable
|
||||
from server.utils.peer_universe import peer_symbols_for_profile
|
||||
from server.utils.safe_float import _safe_float
|
||||
|
||||
# Extra keyword → bucket name (must match keys in SECTORS)
|
||||
_BUCKET_KEYWORDS: List[Tuple[str, List[str]]] = [
|
||||
("Semiconductors & Hardware", ["semiconductor", "semiconductors", "semi ", "hardware"]),
|
||||
("Software & Cloud", ["software", "cloud", "saas", "internet content"]),
|
||||
("Consumer Retail", ["retail", "consumer", "restaurant", "specialty retail"]),
|
||||
("Financial Services", ["financial", "bank", "insurance", "capital market"]),
|
||||
("Healthcare", ["health", "drug", "biotech", "medical"]),
|
||||
]
|
||||
|
||||
|
||||
def _match_bucket(sector: str, industry: str) -> Optional[str]:
|
||||
text = f"{sector} {industry}".lower()
|
||||
for bucket, kws in _BUCKET_KEYWORDS:
|
||||
if any(kw in text for kw in kws):
|
||||
return bucket
|
||||
for bucket_name in SECTORS:
|
||||
parts = bucket_name.lower().replace("&", " ").split()
|
||||
if any(p in text for p in parts if len(p) > 3):
|
||||
return bucket_name
|
||||
return None
|
||||
|
||||
|
||||
def _fallback_large_caps(sector: str) -> List[str]:
|
||||
s = (sector or "").lower()
|
||||
if "technology" in s or "tech" in s:
|
||||
return ["MSFT", "AAPL", "GOOGL", "META", "NVDA"]
|
||||
if "financial" in s or "financials" in s:
|
||||
return ["JPM", "BAC", "GS", "MS", "V"]
|
||||
if "health" in s:
|
||||
return ["UNH", "JNJ", "LLY", "ABBV", "MRK"]
|
||||
if "consumer" in s:
|
||||
return ["AMZN", "WMT", "HD", "MCD", "SBUX"]
|
||||
return ["MSFT", "AAPL", "GOOGL", "AMZN", "JPM"]
|
||||
|
||||
|
||||
def _peer_symbols(ticker: str, sector: str, industry: str) -> List[str]:
|
||||
t = ticker.upper().strip()
|
||||
bucket = _match_bucket(sector, industry)
|
||||
if bucket and bucket in SECTORS:
|
||||
syms = list(SECTORS[bucket])
|
||||
else:
|
||||
syms = _fallback_large_caps(sector)
|
||||
if t not in syms:
|
||||
syms = [t] + [x for x in syms if x != t]
|
||||
# unique preserve order, cap 8
|
||||
seen: set[str] = set()
|
||||
out: List[str] = []
|
||||
for s in syms:
|
||||
u = s.upper()
|
||||
if u not in seen:
|
||||
seen.add(u)
|
||||
out.append(u)
|
||||
if len(out) >= 8:
|
||||
break
|
||||
return out
|
||||
return peer_symbols_for_profile(ticker, sector, industry, cap=8)
|
||||
|
||||
|
||||
def _peer_row(sym: str) -> Dict[str, Any]:
|
||||
@@ -76,6 +27,9 @@ def _peer_row(sym: str) -> Dict[str, Any]:
|
||||
"pb": _safe_float(info.get("priceToBook")),
|
||||
"ps": _safe_float(info.get("priceToSalesTrailing12Months")),
|
||||
"ev_ebitda": _safe_float(info.get("enterpriseToEbitda")),
|
||||
"roic": _safe_float(info.get("returnOnInvestedCapital") or info.get("returnOnCapital")),
|
||||
"gross_margin": _safe_float(info.get("grossMargins")),
|
||||
"rev_growth": _safe_float(info.get("revenueGrowth")),
|
||||
}
|
||||
|
||||
|
||||
@@ -101,15 +55,122 @@ def build_peer_comparison(ticker: str) -> Dict[str, Any]:
|
||||
pbs = [p["pb"] for p in peers]
|
||||
pss = [p["ps"] for p in peers]
|
||||
evs = [p["ev_ebitda"] for p in peers]
|
||||
roics = [p["roic"] for p in peers]
|
||||
gross_margins = [p["gross_margin"] for p in peers]
|
||||
rev_growths = [p["rev_growth"] for p in peers]
|
||||
return {
|
||||
"ticker": t,
|
||||
"primary": t,
|
||||
"sector": sector or "—",
|
||||
"industry": industry or "—",
|
||||
"metrics": ["pe", "pb", "ps", "ev_ebitda", "roic", "gross_margin", "rev_growth"],
|
||||
"averages": {
|
||||
"pe": _avg(pes),
|
||||
"pb": _avg(pbs),
|
||||
"ps": _avg(pss),
|
||||
"ev_ebitda": _avg(evs),
|
||||
"roic": _avg(roics),
|
||||
"gross_margin": _avg(gross_margins),
|
||||
"rev_growth": _avg(rev_growths),
|
||||
},
|
||||
"peer_symbols": syms[1:],
|
||||
"matrix": peers,
|
||||
"peers": peers,
|
||||
}
|
||||
|
||||
|
||||
def _metric_from_fundamentals(fundamentals: Fundamentals, metric: str) -> Optional[float]:
|
||||
raw = fundamentals.raw or {}
|
||||
if metric == "pe":
|
||||
return _safe_float(fundamentals.pe or raw.get("trailingPE") or raw.get("forwardPE"))
|
||||
if metric == "pb":
|
||||
return _safe_float(fundamentals.pb or raw.get("priceToBook"))
|
||||
if metric == "ps":
|
||||
return _safe_float(fundamentals.ps or raw.get("priceToSalesTrailing12Months"))
|
||||
if metric == "ev_ebitda":
|
||||
return _safe_float(fundamentals.ev_ebitda or raw.get("enterpriseToEbitda"))
|
||||
if metric == "roic":
|
||||
return _safe_float(fundamentals.roic or raw.get("returnOnInvestedCapital") or raw.get("returnOnCapital"))
|
||||
if metric == "gross_margin":
|
||||
return _safe_float(fundamentals.gross_margin or raw.get("grossMargins"))
|
||||
if metric in {"rev_growth", "revenue_growth"}:
|
||||
return _safe_float(fundamentals.revenue_growth or raw.get("revenueGrowth"))
|
||||
return None
|
||||
|
||||
|
||||
def _matrix_row(symbol: str, fundamentals: Fundamentals, metrics: list[str]) -> Dict[str, Any]:
|
||||
raw = fundamentals.raw or {}
|
||||
row: Dict[str, Any] = {
|
||||
"ticker": symbol.upper(),
|
||||
"name": str(fundamentals.name or raw.get("shortName") or raw.get("longName") or symbol.upper())[:80],
|
||||
"market_cap": _safe_float(fundamentals.market_cap or raw.get("marketCap")),
|
||||
"source": fundamentals.source,
|
||||
}
|
||||
for metric in metrics:
|
||||
row[metric] = _metric_from_fundamentals(fundamentals, metric)
|
||||
# Keep report/overview legacy fields available even when callers request a
|
||||
# smaller metric set.
|
||||
for metric in ["pe", "pb", "ps", "ev_ebitda", "roic", "gross_margin", "rev_growth"]:
|
||||
row.setdefault(metric, _metric_from_fundamentals(fundamentals, metric))
|
||||
return row
|
||||
|
||||
|
||||
async def build_peer_comparison_matrix(
|
||||
ticker: str,
|
||||
metrics: list[str],
|
||||
gateway: DataGateway,
|
||||
max_peers: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a gateway-backed peer matrix with bounded parallel fundamentals fetches."""
|
||||
|
||||
primary = ticker.upper().strip()
|
||||
requested_metrics = [m.strip().lower() for m in metrics if m.strip()]
|
||||
if not requested_metrics:
|
||||
requested_metrics = ["pe", "ev_ebitda", "roic", "gross_margin"]
|
||||
|
||||
try:
|
||||
profile, peer_symbols = await asyncio.gather(
|
||||
gateway.profile(primary),
|
||||
gateway.peers(primary),
|
||||
)
|
||||
except DataUnavailable:
|
||||
legacy = await asyncio.to_thread(build_peer_comparison, primary)
|
||||
legacy["metrics"] = requested_metrics
|
||||
return legacy
|
||||
|
||||
targets = [primary] + [symbol.upper() for symbol in peer_symbols if symbol.upper() != primary][:max_peers]
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def fetch_one(symbol: str) -> Fundamentals | Exception:
|
||||
async with semaphore:
|
||||
try:
|
||||
return await gateway.fundamentals(symbol, period="ttm")
|
||||
except Exception as exc:
|
||||
return exc
|
||||
|
||||
results = await asyncio.gather(*(fetch_one(symbol) for symbol in targets))
|
||||
matrix: list[Dict[str, Any]] = []
|
||||
for symbol, result in zip(targets, results):
|
||||
if isinstance(result, Fundamentals):
|
||||
matrix.append(_matrix_row(symbol, result, requested_metrics))
|
||||
|
||||
if not matrix:
|
||||
legacy = await asyncio.to_thread(build_peer_comparison, primary)
|
||||
legacy["metrics"] = requested_metrics
|
||||
return legacy
|
||||
|
||||
averages = {
|
||||
metric: _avg([_safe_float(row.get(metric)) for row in matrix])
|
||||
for metric in ["pe", "pb", "ps", "ev_ebitda", "roic", "gross_margin", "rev_growth"]
|
||||
}
|
||||
return {
|
||||
"ticker": primary,
|
||||
"primary": primary,
|
||||
"sector": profile.sector or "—",
|
||||
"industry": profile.industry or "—",
|
||||
"metrics": requested_metrics,
|
||||
"peer_symbols": targets[1:],
|
||||
"averages": averages,
|
||||
"matrix": matrix,
|
||||
"peers": matrix,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user