diff --git a/atlas-terminal/README.md b/atlas-terminal/README.md index 38f84a9..26d9ae5 100644 --- a/atlas-terminal/README.md +++ b/atlas-terminal/README.md @@ -130,6 +130,8 @@ Credential API: ## Recent Work +- Phase 5 earnings-call delta MVP: FMP transcript pair lookup, deterministic new/faded/emphasis phrase analysis, tone shift scoring, and best-effort AI narrative on the Earnings page +- Phase 4 peer comparison: gateway-backed peer discovery, parallel fundamentals matrix, percentile-colored valuation/quality cells, and backward-compatible `/api/market/peers/{ticker}` responses for overview/report flows - Phase 3 security hardening: AES-GCM envelope encryption, credential tables, credential access audit logs, and `ATLAS_MASTER_KEY` documentation for future KIS/IBKR key storage - v2 refactor foundation: baseline measurements in `docs/baseline-2026-04.md`, CI workflow, pytest smoke tests, and Playwright route smoke tests - Data Gateway scaffold: typed `DataGateway` contract, chained providers, TTL cache wrapper, provider metrics, and a flag-gated `/api/market/quote/{ticker}` migration path via `ATLAS_FLAG_GATEWAY=true` diff --git a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx index 43d5b05..cf4a376 100644 --- a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx +++ b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx @@ -24,7 +24,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) fetch(`/api/financials/${encodeURIComponent(ticker)}/kpi-history`).then((r) => (r.ok ? r.json() : null)), ]).then(([p, k]) => { if (!cancelled) { - setPeerData(p && Array.isArray(p.peers) ? p : null); + setPeerData(p && (Array.isArray(p.matrix) || Array.isArray(p.peers)) ? p : null); setKpiData(k && Array.isArray(k.quarters) ? k : null); } }); diff --git a/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx b/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx index 783ebec..5dc2a0d 100644 --- a/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx +++ b/atlas-terminal/apps/web/src/app/components/overview/PeerComparison.tsx @@ -8,22 +8,43 @@ export interface PeerItem { pb: number | null; ps: number | null; ev_ebitda: number | null; + roic?: number | null; + gross_margin?: number | null; + rev_growth?: number | null; } export interface PeerComparisonData { ticker: string; + primary?: string; sector: string; industry: string; + metrics?: string[]; + matrix?: PeerItem[]; averages: { pe: number | null; pb: number | null; ps: number | null; ev_ebitda: number | null; + roic?: number | null; + gross_margin?: number | null; + rev_growth?: number | null; }; peers: PeerItem[]; } -function formatValue(value: number | null, type: "multiple" | "marketCap" = "multiple"): string { +type MetricKey = "pe" | "ev_ebitda" | "roic" | "gross_margin" | "rev_growth"; + +const METRIC_LABELS: Record = { + pe: "P/E", + ev_ebitda: "EV/EBITDA", + roic: "ROIC", + gross_margin: "Gross Margin", + rev_growth: "Rev Growth", +}; + +const LOWER_IS_BETTER = new Set(["pe", "ev_ebitda"]); + +function formatValue(value: number | null | undefined, type: "multiple" | "marketCap" | "percent" = "multiple"): string { if (value == null) return "—"; if (type === "marketCap") { if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`; @@ -31,22 +52,34 @@ function formatValue(value: number | null, type: "multiple" | "marketCap" = "mul if (value >= 1e6) return `$${(value / 1e6).toFixed(1)}M`; return `$${value.toFixed(0)}`; } - return value.toFixed(2); + if (type === "percent") { + const normalized = Math.abs(value) <= 1 ? value * 100 : value; + return `${normalized.toFixed(1)}%`; + } + return `${value.toFixed(1)}x`; } -function extrema(values: Array) { - const numbers = values.filter((value): value is number => value != null); - return { - min: numbers.length ? Math.min(...numbers) : null, - max: numbers.length ? Math.max(...numbers) : null, - }; +function metricType(metric: MetricKey): "multiple" | "percent" { + return metric === "pe" || metric === "ev_ebitda" ? "multiple" : "percent"; } -function valueClass(value: number | null, min: number | null, max: number | null): string { - if (value == null) return "text-text-muted"; - if (min != null && value === min) return "text-accent-green"; - if (max != null && value === max) return "text-accent-red"; - return "text-text-primary"; +function percentileScore(metric: MetricKey, value: number | null | undefined, rows: PeerItem[]): number | null { + if (value == null) return null; + const values = rows + .map((row) => row[metric]) + .filter((candidate): candidate is number => typeof candidate === "number" && Number.isFinite(candidate)); + if (values.length <= 1) return 50; + const sorted = [...values].sort((a, b) => a - b); + const rank = sorted.filter((candidate) => candidate < value).length / (values.length - 1); + const score = LOWER_IS_BETTER.has(metric) ? (1 - rank) * 100 : rank * 100; + return Math.max(0, Math.min(100, score)); +} + +function metricCellClass(score: number | null, isPrimary: boolean): string { + if (score == null) return isPrimary ? "text-white/60" : "text-text-muted"; + if (score >= 80) return isPrimary ? "bg-brand-gold text-brand-navy" : "bg-brand-gold/15 text-brand-navy"; + if (score <= 20) return isPrimary ? "bg-fin-negative text-white" : "bg-fin-negative/10 text-fin-negative"; + return isPrimary ? "text-white" : "text-text-primary"; } export function PeerComparison({ @@ -56,77 +89,81 @@ export function PeerComparison({ currentTicker: string; data: PeerComparisonData | null; }) { - if (!data || data.peers.length === 0) { + const rows = data?.matrix?.length ? data.matrix : data?.peers ?? []; + + if (!data || rows.length === 0) { return ( -
-

Valuation vs. Peers

+
+

Peer Comparison

Peer comparison data is not available for this ticker.
); } - const peExtrema = extrema(data.peers.map((peer) => peer.pe)); - const pbExtrema = extrema(data.peers.map((peer) => peer.pb)); - const psExtrema = extrema(data.peers.map((peer) => peer.ps)); - const evEbitdaExtrema = extrema(data.peers.map((peer) => peer.ev_ebitda)); + const metrics = ((data.metrics?.length ? data.metrics : ["pe", "ev_ebitda", "roic", "gross_margin", "rev_growth"]) + .filter((metric): metric is MetricKey => metric in METRIC_LABELS)); return ( -
+
-
-

Valuation vs. Peers

+
+

Peer Comparison

{data.industry || data.sector || "Industry peers"}
-
- Avg PE: {formatValue(data.averages.pe)} - Avg PB: {formatValue(data.averages.pb)} - Avg PS: {formatValue(data.averages.ps)} - Avg EV/EBITDA: {formatValue(data.averages.ev_ebitda)} +
+ Avg P/E {formatValue(data.averages.pe)} + Avg EV/EBITDA {formatValue(data.averages.ev_ebitda)} + {data.averages.gross_margin != null && Avg GM {formatValue(data.averages.gross_margin, "percent")}}
- - - - - - - - - - +
TickerCompanyMarket CapP/EP/BP/SEV/EBITDA
+ + + + + + {metrics.map((metric) => ( + + ))} - {data.peers.map((peer) => { + {rows.map((peer) => { const isCurrent = peer.ticker.toUpperCase() === currentTicker.toUpperCase(); return ( - - - - - - - + + {metrics.map((metric) => { + const value = peer[metric]; + const score = percentileScore(metric, value, rows); + return ( + + ); + })} ); })}
TickerCompanyMarket Cap{METRIC_LABELS[metric]}
{peer.ticker}{peer.name}{formatValue(peer.market_cap, "marketCap")} - {formatValue(peer.pe)} + + {peer.ticker} - {formatValue(peer.pb)} - - {formatValue(peer.ps)} - - {formatValue(peer.ev_ebitda)} + {peer.name} + {formatValue(peer.market_cap, "marketCap")} + + {formatValue(value, metricType(metric))} + +
+
+ Gold marks best-in-group percentile; red marks weakest percentile. For valuation multiples, lower is better. +
); } diff --git a/atlas-terminal/apps/web/src/app/earnings/page.tsx b/atlas-terminal/apps/web/src/app/earnings/page.tsx index 6da31b7..9dfbc74 100644 --- a/atlas-terminal/apps/web/src/app/earnings/page.tsx +++ b/atlas-terminal/apps/web/src/app/earnings/page.tsx @@ -31,6 +31,23 @@ interface DeltaData { ai_summary?: string | null; } +interface TranscriptDeltaData { + available: boolean; + message?: string; + current?: { year: number; quarter: number }; + previous?: { year: number; quarter: number }; + new_phrases?: { phrase: string; count: number }[]; + removed_phrases?: { phrase: string; previous_count: number }[]; + emphasis_shift?: { phrase: string; current_count: number; previous_count: number; delta: number }[]; + tone_shift?: { current_score: number; previous_score: number }; + narrative?: { + key_shifts?: string[]; + what_it_means?: string; + questions_to_ask?: string[]; + variant_view?: string; + }; +} + export default function EarningsPage() { const { ticker, initialized } = useTicker(); const [assetType, setAssetType] = useState("equity"); @@ -38,6 +55,7 @@ export default function EarningsPage() { const [calendar, setCalendar] = useState(null); const [quarterly, setQuarterly] = useState([]); const [delta, setDelta] = useState(null); + const [transcriptDelta, setTranscriptDelta] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -49,12 +67,14 @@ export default function EarningsPage() { fetch(`/api/earnings/${ticker}/quarterly`).then((r) => r.ok ? r.json() : null), fetch(`/api/market/overview/${ticker}`).then((r) => r.ok ? r.json() : null), fetch(`/api/earnings/${ticker}/delta`).then((r) => r.ok ? r.json() : null), - ]).then(([h, c, q, o, d]) => { + fetch(`/api/earnings/${ticker}/transcript-delta`).then((r) => r.ok ? r.json() : null), + ]).then(([h, c, q, o, d, td]) => { setHistory(h?.history || []); setCalendar(c); setQuarterly(q?.quarterly || []); setAssetType(o?.asset_type || "equity"); setDelta(d?.available ? d : null); + setTranscriptDelta(td?.available ? td : null); setLoading(false); }).catch(() => setLoading(false)); }, [ticker, initialized]); @@ -102,6 +122,8 @@ export default function EarningsPage() {
+ {transcriptDelta && } + {/* Earnings Delta — What Changed */} {delta && (
@@ -238,3 +260,109 @@ export default function EarningsPage() {
); } + +function TranscriptDeltaPanel({ data }: { data: TranscriptDeltaData }) { + const maxShift = Math.max(1, ...(data.emphasis_shift ?? []).map((row) => Math.abs(row.delta))); + const title = data.current && data.previous + ? `Q${data.current.quarter}'${String(data.current.year).slice(-2)} vs Q${data.previous.quarter}'${String(data.previous.year).slice(-2)}` + : "Transcript Delta"; + + return ( +
+
+
+

Earnings Call Delta — {title}

+

Management-language changes from transcript phrase analysis.

+
+ {data.tone_shift && ( +
+
Tone Shift
+
+ {data.tone_shift.previous_score.toFixed(1)} → {data.tone_shift.current_score.toFixed(1)} +
+
+ )} +
+ +
+ ({ label: row.phrase, value: `${row.count}x` }))} + empty="No new repeated phrases." + /> + ({ label: row.phrase, value: `${row.previous_count}x → 0` }))} + empty="No faded repeated phrases." + /> +
+
Emphasis Shift
+
+ {(data.emphasis_shift ?? []).slice(0, 6).map((row) => ( +
+
+ {row.phrase} + = 0 ? "text-fin-positive font-mono" : "text-fin-negative font-mono"}> + {row.previous_count} → {row.current_count} + +
+
+
= 0 ? "bg-fin-positive" : "bg-fin-negative"}`} + style={{ width: `${Math.max(8, (Math.abs(row.delta) / maxShift) * 100)}%` }} + /> +
+
+ ))} + {(data.emphasis_shift ?? []).length === 0 &&
No major emphasis shift.
} +
+
+
+ + {data.narrative && ( +
+
AI Interpretation
+ {data.narrative.key_shifts && data.narrative.key_shifts.length > 0 && ( +
+ {data.narrative.key_shifts.slice(0, 4).map((shift) => ( + + {shift} + + ))} +
+ )} +

{data.narrative.what_it_means}

+ {data.narrative.variant_view &&

Variant view: {data.narrative.variant_view}

} +
+ )} +
+ ); +} + +function PhraseBox({ + title, + accent, + rows, + empty, +}: { + title: string; + accent: string; + rows: { label: string; value: string }[]; + empty: string; +}) { + return ( +
+
{title}
+
+ {rows.length > 0 ? rows.map((row) => ( +
+ “{row.label}” + {row.value} +
+ )) :
{empty}
} +
+
+ ); +} diff --git a/atlas-terminal/server/core/data_gateway.py b/atlas-terminal/server/core/data_gateway.py index dd38d3e..bd72aca 100644 --- a/atlas-terminal/server/core/data_gateway.py +++ b/atlas-terminal/server/core/data_gateway.py @@ -45,8 +45,12 @@ class Profile: class Fundamentals: symbol: str period: str + name: str | None = None + market_cap: float | None = None revenue: float | None = None + revenue_growth: float | None = None gross_profit: float | None = None + gross_margin: float | None = None operating_income: float | None = None net_income: float | None = None ebitda: float | None = None @@ -55,6 +59,11 @@ class Fundamentals: total_debt: float | None = None cash: float | None = None shares: float | None = None + pe: float | None = None + pb: float | None = None + ps: float | None = None + ev_ebitda: float | None = None + roic: float | None = None source: str = "" raw: dict[str, Any] = field(default_factory=dict) diff --git a/atlas-terminal/server/core/providers/yfinance.py b/atlas-terminal/server/core/providers/yfinance.py index 4ab0533..e8d77aa 100644 --- a/atlas-terminal/server/core/providers/yfinance.py +++ b/atlas-terminal/server/core/providers/yfinance.py @@ -7,6 +7,7 @@ from typing import Any from server.core.data_gateway import Fundamentals, OHLCV, OHLCVBar, Profile, Quote from server.core.providers.base import BaseProvider, ProviderError +from server.utils.peer_universe import peer_symbols_for_profile class YFinanceProvider(BaseProvider): @@ -67,8 +68,12 @@ class YFinanceProvider(BaseProvider): return Fundamentals( symbol=symbol.upper(), period=period, + name=info.get("shortName") or info.get("longName"), + market_cap=info.get("marketCap"), revenue=info.get("totalRevenue"), + revenue_growth=info.get("revenueGrowth"), gross_profit=info.get("grossProfits"), + gross_margin=info.get("grossMargins"), operating_income=info.get("operatingMargins"), net_income=info.get("netIncomeToCommon"), ebitda=info.get("ebitda"), @@ -76,12 +81,31 @@ class YFinanceProvider(BaseProvider): total_debt=info.get("totalDebt"), cash=info.get("totalCash"), shares=info.get("sharesOutstanding"), + pe=info.get("trailingPE") or info.get("forwardPE"), + pb=info.get("priceToBook"), + ps=info.get("priceToSalesTrailing12Months"), + ev_ebitda=info.get("enterpriseToEbitda"), + roic=info.get("returnOnInvestedCapital") or info.get("returnOnCapital"), source=self.name, raw=info, ) return await self._to_thread(fetch) + async def peers(self, symbol: str) -> list[str]: + def fetch() -> list[str]: + normalized = symbol.strip().upper() + info = self._ticker(normalized).info or {} + syms = peer_symbols_for_profile( + normalized, + str(info.get("sector") or ""), + str(info.get("industry") or ""), + cap=6, + ) + return [peer for peer in syms if peer != normalized] + + return await self._to_thread(fetch) + async def history(self, symbol: str, range: str = "1y") -> OHLCV: def fetch() -> OHLCV: hist = self._ticker(symbol).history(period=range) diff --git a/atlas-terminal/server/routers/earnings.py b/atlas-terminal/server/routers/earnings.py index 15b66ea..18d7674 100644 --- a/atlas-terminal/server/routers/earnings.py +++ b/atlas-terminal/server/routers/earnings.py @@ -180,6 +180,55 @@ async def earnings_delta(ticker: str) -> Dict[str, Any]: raise HTTPException(status_code=500, detail=f"Earnings delta failed: {exc}") from exc +@router.get("/{ticker}/transcript-delta", summary="Earnings call transcript phrase delta") +async def earnings_transcript_delta( + ticker: str, + year: Optional[int] = Query(None, ge=1990, le=2035), + quarter: Optional[int] = Query(None, ge=1, le=4), + prev_year: Optional[int] = Query(None, ge=1990, le=2035), + prev_quarter: Optional[int] = Query(None, ge=1, le=4), +) -> Dict[str, Any]: + """Compare management language between two earnings-call transcripts.""" + + from server.services.earnings_transcripts import ( + compute_delta, + default_quarter_pair, + fetch_transcript, + generate_delta_narrative, + ) + from server.services.fmp_client import fmp_is_configured + + if not fmp_is_configured(): + return { + "ticker": ticker.upper(), + "available": False, + "message": "Set FMP_API_KEY for earnings call transcript delta.", + } + + if year is None or quarter is None: + (year, quarter), (default_prev_year, default_prev_quarter) = default_quarter_pair() + prev_year = prev_year or default_prev_year + prev_quarter = prev_quarter or default_prev_quarter + if prev_year is None or prev_quarter is None: + prev_year = year if quarter > 1 else year - 1 + prev_quarter = quarter - 1 if quarter > 1 else 4 + + current = await fetch_transcript(ticker, year, quarter) + previous = await fetch_transcript(ticker, prev_year, prev_quarter) + if current is None or previous is None: + return { + "ticker": ticker.upper(), + "available": False, + "message": "Transcript pair not available for the requested quarters.", + "current": {"year": year, "quarter": quarter}, + "previous": {"year": prev_year, "quarter": prev_quarter}, + } + + delta = compute_delta(current, previous) + delta["narrative"] = await generate_delta_narrative(delta, ticker) + return delta + + @router.get("/{ticker}/quarterly", summary="Quarterly earnings data") async def quarterly_earnings(ticker: str) -> Dict[str, Any]: try: diff --git a/atlas-terminal/server/routers/market_data.py b/atlas-terminal/server/routers/market_data.py index 6a8e0ee..10c9e26 100644 --- a/atlas-terminal/server/routers/market_data.py +++ b/atlas-terminal/server/routers/market_data.py @@ -232,20 +232,28 @@ async def financial_trend(ticker: str): return {"years": [], "revenue": [], "net_income": [], "operating_margin": [], "fcf": []} -@router.get("/peers/{ticker}", summary="Peer valuation multiples (sector bucket)") -async def peer_valuation_multiples(ticker: str): - """P/E, P/B, P/S, EV/EBITDA vs. a small industry peer set (yfinance).""" +@router.get("/peers/{ticker}", summary="Peer valuation multiples and percentile matrix") +async def peer_valuation_multiples( + ticker: str, + metrics: str = Query("pe,ev_ebitda,roic,gross_margin,rev_growth", description="Comma-separated peer metrics"), +): + """Gateway-backed peer comparison with legacy response fields preserved.""" try: - from server.services.peer_comparison_service import build_peer_comparison + from server.services.peer_comparison_service import build_peer_comparison_matrix - return build_peer_comparison(ticker) + metric_list = [m.strip() for m in metrics.split(",") if m.strip()] + return await build_peer_comparison_matrix(ticker, metric_list, get_data_gateway()) except Exception: logger.exception("peers/%s failed", ticker) return { "ticker": ticker.upper(), + "primary": ticker.upper(), "sector": "—", "industry": "—", - "averages": {"pe": None, "pb": None, "ps": None, "ev_ebitda": None}, + "metrics": [m.strip() for m in metrics.split(",") if m.strip()], + "averages": {"pe": None, "pb": None, "ps": None, "ev_ebitda": None, "roic": None, "gross_margin": None, "rev_growth": None}, + "peer_symbols": [], + "matrix": [], "peers": [], } diff --git a/atlas-terminal/server/services/earnings_transcripts.py b/atlas-terminal/server/services/earnings_transcripts.py new file mode 100644 index 0000000..c14a4dc --- /dev/null +++ b/atlas-terminal/server/services/earnings_transcripts.py @@ -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 diff --git a/atlas-terminal/server/services/peer_comparison_service.py b/atlas-terminal/server/services/peer_comparison_service.py index 189a98c..1c3a2a8 100644 --- a/atlas-terminal/server/services/peer_comparison_service.py +++ b/atlas-terminal/server/services/peer_comparison_service.py @@ -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, + } diff --git a/atlas-terminal/server/utils/peer_universe.py b/atlas-terminal/server/utils/peer_universe.py new file mode 100644 index 0000000..c40f366 --- /dev/null +++ b/atlas-terminal/server/utils/peer_universe.py @@ -0,0 +1,65 @@ +"""Peer universe helpers shared by gateway providers and legacy services.""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from server.utils.ticker_utils import 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_peer_bucket(sector: str, industry: str) -> Optional[str]: + text = f"{sector} {industry}".lower() + for bucket, keywords in _BUCKET_KEYWORDS: + if any(keyword in text for keyword in keywords): + return bucket + for bucket_name in SECTORS: + parts = bucket_name.lower().replace("&", " ").split() + if any(part in text for part in parts if len(part) > 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_for_profile(ticker: str, sector: str, industry: str, cap: int = 8) -> List[str]: + t = ticker.upper().strip() + bucket = match_peer_bucket(sector, industry) + syms = list(SECTORS[bucket]) if bucket and bucket in SECTORS else fallback_large_caps(sector) + if t not in syms: + syms = [t] + [symbol for symbol in syms if symbol != t] + + seen: set[str] = set() + out: list[str] = [] + for symbol in syms: + normalized = symbol.upper() + if normalized not in seen: + seen.add(normalized) + out.append(normalized) + if len(out) >= cap: + break + return out + + +def peer_symbols(ticker: str, sector: str, industry: str) -> List[str]: + """Backward-compatible alias used by older services.""" + + return peer_symbols_for_profile(ticker, sector, industry) diff --git a/atlas-terminal/tests/test_earnings_transcripts.py b/atlas-terminal/tests/test_earnings_transcripts.py new file mode 100644 index 0000000..9976463 --- /dev/null +++ b/atlas-terminal/tests/test_earnings_transcripts.py @@ -0,0 +1,43 @@ +"""Tests for earnings transcript delta analysis.""" + +from datetime import date + +from server.services.earnings_transcripts import Transcript, compute_delta, default_quarter_pair, tokenize_and_normalize + + +def test_default_quarter_pair_uses_completed_quarter() -> None: + current, previous = default_quarter_pair(date(2026, 4, 21)) + + assert current == (2026, 1) + assert previous == (2025, 4) + + +def test_tokenize_and_normalize_removes_common_call_words() -> None: + tokens = tokenize_and_normalize("Thank you operator. Sovereign AI demand was strong, strong, strong.") + + assert "thank" not in tokens + assert "operator" not in tokens + assert "sovereign" in tokens + assert tokens.count("strong") == 3 + + +def test_compute_delta_surfaces_new_removed_and_emphasis_phrases() -> None: + previous = Transcript( + ticker="NVDA", + year=2025, + quarter=4, + content="inventory correction inventory correction gaming demand gaming demand data center", + ) + current = Transcript( + ticker="NVDA", + year=2026, + quarter=1, + content="sovereign AI sovereign AI AI infrastructure AI infrastructure data center data center data center", + ) + + delta = compute_delta(current, previous) + + assert delta["available"] is True + assert any(row["phrase"] == "sovereign ai" for row in delta["new_phrases"]) + assert any(row["phrase"] == "inventory correction" for row in delta["removed_phrases"]) + assert any(row["phrase"] == "data center" for row in delta["emphasis_shift"]) diff --git a/atlas-terminal/tests/test_smoke.py b/atlas-terminal/tests/test_smoke.py index f5c09c2..73e9e76 100644 --- a/atlas-terminal/tests/test_smoke.py +++ b/atlas-terminal/tests/test_smoke.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient from server.core.providers.base import DataUnavailable -from server.core.data_gateway import Quote +from server.core.data_gateway import Fundamentals, Profile, Quote from server.main import app @@ -96,3 +96,66 @@ def test_quote_endpoint_gateway_failure_degrades(monkeypatch) -> None: assert response.status_code == 200 assert response.json() == {"ticker": "AAPL", "current_price": None, "change_pct": None} + + +def test_peer_endpoint_returns_gateway_matrix(monkeypatch) -> None: + from server.routers import market_data + + class FakeGateway: + async def profile(self, ticker: str) -> Profile: + return Profile(symbol=ticker.upper(), sector="Technology", industry="Semiconductors", source="fake") + + async def peers(self, ticker: str) -> list[str]: + return ["AMD", "NVDA"] + + async def fundamentals(self, ticker: str, period: str = "ttm") -> Fundamentals: + rows = { + "NVDA": Fundamentals( + symbol="NVDA", + period=period, + name="NVIDIA", + market_cap=3_000_000_000_000, + pe=40.0, + ev_ebitda=32.0, + roic=0.45, + gross_margin=0.72, + revenue_growth=0.6, + source="fake", + ), + "AMD": Fundamentals( + symbol="AMD", + period=period, + name="AMD", + market_cap=250_000_000_000, + pe=35.0, + ev_ebitda=25.0, + roic=0.12, + gross_margin=0.5, + revenue_growth=0.1, + source="fake", + ), + } + return rows[ticker.upper()] + + monkeypatch.setattr(market_data, "get_data_gateway", lambda: FakeGateway()) + + with TestClient(app) as client: + response = client.get("/api/market/peers/NVDA?metrics=pe,ev_ebitda,roic,gross_margin") + + assert response.status_code == 200 + data = response.json() + assert data["primary"] == "NVDA" + assert data["peer_symbols"] == ["AMD"] + assert data["metrics"] == ["pe", "ev_ebitda", "roic", "gross_margin"] + assert [row["ticker"] for row in data["matrix"]] == ["NVDA", "AMD"] + assert data["averages"]["pe"] == 37.5 + + +def test_transcript_delta_degrades_without_fmp_key(monkeypatch) -> None: + monkeypatch.delenv("FMP_API_KEY", raising=False) + + with TestClient(app) as client: + response = client.get("/api/earnings/NVDA/transcript-delta") + + assert response.status_code == 200 + assert response.json()["available"] is False