mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-26 08:48:05 +00:00
phase 4-5: peer matrix and earnings delta
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user