mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-09 08:47:44 +00:00
phase 4-5: peer matrix and earnings delta
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<MetricKey, string> = {
|
||||
pe: "P/E",
|
||||
ev_ebitda: "EV/EBITDA",
|
||||
roic: "ROIC",
|
||||
gross_margin: "Gross Margin",
|
||||
rev_growth: "Rev Growth",
|
||||
};
|
||||
|
||||
const LOWER_IS_BETTER = new Set<MetricKey>(["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<number | null>) {
|
||||
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 (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mt-6">
|
||||
<h3 className="text-text-secondary text-sm font-semibold mb-2">Valuation vs. Peers</h3>
|
||||
<div className="atlas-card mt-6 p-5">
|
||||
<h3 className="font-serif text-lg font-bold text-brand-navy mb-2">Peer Comparison</h3>
|
||||
<div className="text-text-muted text-sm">Peer comparison data is not available for this ticker.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-5 mt-6">
|
||||
<div className="atlas-table-shell mt-6">
|
||||
<div className="flex items-end justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-semibold">Valuation vs. Peers</h3>
|
||||
<div className="px-5 pt-5">
|
||||
<h3 className="font-serif text-lg font-bold text-brand-navy">Peer Comparison</h3>
|
||||
<div className="text-text-muted text-xs mt-1">{data.industry || data.sector || "Industry peers"}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted font-mono flex gap-3">
|
||||
<span>Avg PE: {formatValue(data.averages.pe)}</span>
|
||||
<span>Avg PB: {formatValue(data.averages.pb)}</span>
|
||||
<span>Avg PS: {formatValue(data.averages.ps)}</span>
|
||||
<span>Avg EV/EBITDA: {formatValue(data.averages.ev_ebitda)}</span>
|
||||
<div className="hidden px-5 pt-5 text-[11px] text-text-muted font-mono gap-3 lg:flex">
|
||||
<span>Avg P/E {formatValue(data.averages.pe)}</span>
|
||||
<span>Avg EV/EBITDA {formatValue(data.averages.ev_ebitda)}</span>
|
||||
{data.averages.gross_margin != null && <span>Avg GM {formatValue(data.averages.gross_margin, "percent")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border">
|
||||
<th className="py-3 pr-3 font-medium">Ticker</th>
|
||||
<th className="py-3 pr-3 font-medium">Company</th>
|
||||
<th className="py-3 pr-3 font-medium">Market Cap</th>
|
||||
<th className="py-3 pr-3 font-medium">P/E</th>
|
||||
<th className="py-3 pr-3 font-medium">P/B</th>
|
||||
<th className="py-3 pr-3 font-medium">P/S</th>
|
||||
<th className="py-3 font-medium">EV/EBITDA</th>
|
||||
<table className="w-full min-w-[820px] text-sm">
|
||||
<thead className="bg-surface-sunken">
|
||||
<tr className="border-y border-border-strong text-left text-[11px] uppercase tracking-[0.12em] text-brand-navy">
|
||||
<th className="py-3 px-5 font-semibold">Ticker</th>
|
||||
<th className="py-3 pr-3 font-semibold">Company</th>
|
||||
<th className="py-3 pr-3 text-right font-semibold">Market Cap</th>
|
||||
{metrics.map((metric) => (
|
||||
<th key={metric} className="py-3 pr-3 text-right font-semibold">{METRIC_LABELS[metric]}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.peers.map((peer) => {
|
||||
{rows.map((peer) => {
|
||||
const isCurrent = peer.ticker.toUpperCase() === currentTicker.toUpperCase();
|
||||
return (
|
||||
<tr
|
||||
key={peer.ticker}
|
||||
className={`border-b border-border/60 ${isCurrent ? "bg-accent-green/10" : ""}`}
|
||||
className={`border-b border-border/60 ${isCurrent ? "bg-brand-navy text-white" : "hover:bg-surface-sunken"}`}
|
||||
>
|
||||
<td className="py-3 pr-3 font-mono font-semibold text-text-primary">{peer.ticker}</td>
|
||||
<td className="py-3 pr-3 text-text-secondary">{peer.name}</td>
|
||||
<td className="py-3 pr-3 font-mono text-text-primary">{formatValue(peer.market_cap, "marketCap")}</td>
|
||||
<td className={`py-3 pr-3 font-mono ${valueClass(peer.pe, peExtrema.min, peExtrema.max)}`}>
|
||||
{formatValue(peer.pe)}
|
||||
<td className={`py-3 px-5 font-mono font-semibold ${isCurrent ? "text-brand-gold" : "text-brand-navy"}`}>
|
||||
{peer.ticker}
|
||||
</td>
|
||||
<td className={`py-3 pr-3 font-mono ${valueClass(peer.pb, pbExtrema.min, pbExtrema.max)}`}>
|
||||
{formatValue(peer.pb)}
|
||||
</td>
|
||||
<td className={`py-3 pr-3 font-mono ${valueClass(peer.ps, psExtrema.min, psExtrema.max)}`}>
|
||||
{formatValue(peer.ps)}
|
||||
</td>
|
||||
<td className={`py-3 font-mono ${valueClass(peer.ev_ebitda, evEbitdaExtrema.min, evEbitdaExtrema.max)}`}>
|
||||
{formatValue(peer.ev_ebitda)}
|
||||
<td className={`py-3 pr-3 ${isCurrent ? "text-white/80" : "text-text-secondary"}`}>{peer.name}</td>
|
||||
<td className={`py-3 pr-3 font-mono text-right tabular-nums ${isCurrent ? "text-white" : "text-text-primary"}`}>
|
||||
{formatValue(peer.market_cap, "marketCap")}
|
||||
</td>
|
||||
{metrics.map((metric) => {
|
||||
const value = peer[metric];
|
||||
const score = percentileScore(metric, value, rows);
|
||||
return (
|
||||
<td key={metric} className="py-2 pr-3 text-right">
|
||||
<span className={`inline-flex min-w-[76px] justify-end rounded px-2 py-1 font-mono tabular-nums ${metricCellClass(score, isCurrent)}`}>
|
||||
{formatValue(value, metricType(metric))}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-5 py-3 text-[11px] text-text-muted">
|
||||
Gold marks best-in-group percentile; red marks weakest percentile. For valuation multiples, lower is better.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>("equity");
|
||||
@@ -38,6 +55,7 @@ export default function EarningsPage() {
|
||||
const [calendar, setCalendar] = useState<CalendarData | null>(null);
|
||||
const [quarterly, setQuarterly] = useState<QuarterlyData[]>([]);
|
||||
const [delta, setDelta] = useState<DeltaData | null>(null);
|
||||
const [transcriptDelta, setTranscriptDelta] = useState<TranscriptDeltaData | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transcriptDelta && <TranscriptDeltaPanel data={transcriptDelta} />}
|
||||
|
||||
{/* Earnings Delta — What Changed */}
|
||||
{delta && (
|
||||
<div className="bg-bg-card border border-accent-blue/40 rounded-lg p-5 mb-6">
|
||||
@@ -238,3 +260,109 @@ export default function EarningsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-bg-card border border-brand-blue/30 rounded-lg p-5 mb-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 className="font-serif text-xl font-bold text-brand-navy">Earnings Call Delta — {title}</h3>
|
||||
<p className="text-text-muted text-sm mt-1">Management-language changes from transcript phrase analysis.</p>
|
||||
</div>
|
||||
{data.tone_shift && (
|
||||
<div className="rounded border border-border bg-surface-sunken px-3 py-2 text-right">
|
||||
<div className="text-[10px] uppercase tracking-[0.12em] text-text-muted">Tone Shift</div>
|
||||
<div className="font-mono text-sm text-brand-navy">
|
||||
{data.tone_shift.previous_score.toFixed(1)} → {data.tone_shift.current_score.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<PhraseBox
|
||||
title="New Phrases"
|
||||
accent="text-fin-positive"
|
||||
rows={(data.new_phrases ?? []).slice(0, 6).map((row) => ({ label: row.phrase, value: `${row.count}x` }))}
|
||||
empty="No new repeated phrases."
|
||||
/>
|
||||
<PhraseBox
|
||||
title="Faded Phrases"
|
||||
accent="text-fin-negative"
|
||||
rows={(data.removed_phrases ?? []).slice(0, 6).map((row) => ({ label: row.phrase, value: `${row.previous_count}x → 0` }))}
|
||||
empty="No faded repeated phrases."
|
||||
/>
|
||||
<div className="rounded border border-border bg-surface-raised p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-brand-navy font-semibold mb-3">Emphasis Shift</div>
|
||||
<div className="space-y-2">
|
||||
{(data.emphasis_shift ?? []).slice(0, 6).map((row) => (
|
||||
<div key={row.phrase}>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate text-text-secondary">{row.phrase}</span>
|
||||
<span className={row.delta >= 0 ? "text-fin-positive font-mono" : "text-fin-negative font-mono"}>
|
||||
{row.previous_count} → {row.current_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${row.delta >= 0 ? "bg-fin-positive" : "bg-fin-negative"}`}
|
||||
style={{ width: `${Math.max(8, (Math.abs(row.delta) / maxShift) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(data.emphasis_shift ?? []).length === 0 && <div className="text-sm text-text-muted">No major emphasis shift.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.narrative && (
|
||||
<div className="mt-4 border-l-4 border-brand-gold bg-brand-gold/10 p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-brand-navy font-semibold mb-2">AI Interpretation</div>
|
||||
{data.narrative.key_shifts && data.narrative.key_shifts.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{data.narrative.key_shifts.slice(0, 4).map((shift) => (
|
||||
<span key={shift} className="rounded border border-brand-gold/40 bg-white px-2 py-1 text-xs text-brand-navy">
|
||||
{shift}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm leading-relaxed text-text-primary">{data.narrative.what_it_means}</p>
|
||||
{data.narrative.variant_view && <p className="mt-2 text-sm text-text-secondary">Variant view: {data.narrative.variant_view}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhraseBox({
|
||||
title,
|
||||
accent,
|
||||
rows,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
accent: string;
|
||||
rows: { label: string; value: string }[];
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded border border-border bg-surface-raised p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-brand-navy font-semibold mb-3">{title}</div>
|
||||
<div className="space-y-2">
|
||||
{rows.length > 0 ? rows.map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-text-secondary">“{row.label}”</span>
|
||||
<span className={`font-mono ${accent}`}>{row.value}</span>
|
||||
</div>
|
||||
)) : <div className="text-sm text-text-muted">{empty}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user