Files
signal-platform/frontend/src/components/ticker/FundamentalsPanel.tsx
T
dennisthiessen ac0bcf9012
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m13s
Deploy / deploy (push) Successful in 39s
Explain fundamentals score with good/bad status labels.
Add overall and per-metric reads for P/E, growth, and surprise using the same scoring rules as the backend, plus a short how-it-works note.
2026-07-14 12:02:33 +02:00

158 lines
5.7 KiB
TypeScript

import { useMemo, useState } from 'react';
import { formatPercent, formatLargeNumber } from '../../lib/format';
import {
fundamentalScore,
metricStatus,
overallFundamentalStatus,
} from '../../lib/fundamentals';
import type { FundamentalResponse } from '../../lib/types';
interface FundamentalsPanelProps {
data: FundamentalResponse;
}
const FIELD_LABELS: Record<string, string> = {
pe_ratio: 'P/E Ratio',
revenue_growth: 'Revenue Growth',
earnings_surprise: 'Earnings Surprise',
market_cap: 'Market Cap',
};
type MetricKey = 'pe_ratio' | 'revenue_growth' | 'earnings_surprise' | 'market_cap';
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const [expanded, setExpanded] = useState<boolean>(false);
const score = useMemo(
() =>
fundamentalScore({
pe_ratio: data.pe_ratio,
revenue_growth: data.revenue_growth,
earnings_surprise: data.earnings_surprise,
}),
[data.pe_ratio, data.revenue_growth, data.earnings_surprise],
);
const overall = overallFundamentalStatus(score);
const items: {
key: MetricKey;
label: string;
value: number | null;
format: (v: number) => string;
}[] = [
{ key: 'pe_ratio', label: 'P/E Ratio', value: data.pe_ratio, format: (v) => v.toFixed(2) },
{ key: 'revenue_growth', label: 'Revenue Growth', value: data.revenue_growth, format: formatPercent },
{ key: 'earnings_surprise', label: 'Earnings Surprise', value: data.earnings_surprise, format: formatPercent },
{ key: 'market_cap', label: 'Market Cap', value: data.market_cap, format: formatLargeNumber },
];
const unavailableEntries = Object.entries(data.unavailable_fields ?? {});
return (
<div className="glass p-5">
<div className="mb-3 flex items-baseline justify-between gap-2">
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
{score != null && (
<span className="num text-[10px] text-gray-600" title="Equal average of available P/E, growth, and surprise (need 2+)">
score {score.toFixed(0)}
</span>
)}
</div>
<p className={`text-sm font-semibold ${overall.tone}`}>{overall.text}</p>
<div className="mt-3 space-y-3 text-sm">
{items.map((item) => {
const reason = data.unavailable_fields?.[item.key];
const status = item.value !== null ? metricStatus(item.key, item.value) : null;
let display: React.ReactNode;
let valueClass = 'text-gray-200';
if (item.value !== null) {
display = item.format(item.value);
} else if (reason) {
display = reason;
valueClass = 'text-amber-400';
} else {
display = '—';
}
return (
<div key={item.key} className="flex items-start justify-between gap-3">
<span className="text-gray-400">{item.label}</span>
<div className="min-w-0 text-right">
<div className={`num ${valueClass}`}>{display}</div>
{status && (
<div className={`mt-0.5 text-[11.5px] font-medium ${status.tone}`}>{status.text}</div>
)}
</div>
</div>
);
})}
</div>
<p className="mt-4 text-[11px] leading-relaxed text-gray-500">
Score = average of available P/E, revenue growth, and earnings surprise (need 2+).
{' '}P/E: lower scores higher (15 best, 45 worst).
{' '}Growth / surprise: 0% is neutral; stronger positives lift the score.
{' '}Market cap is size context only not scored.
</p>
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-300"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse details' : 'Expand details'}
>
<svg
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="mt-3 space-y-3 border-t border-white/10 pt-3">
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Data Source</span>
<span className="text-gray-300">FMP</span>
</div>
{data.fetched_at && (
<div className="flex justify-between">
<span className="text-gray-500">Fetched</span>
<span className="text-gray-300">{new Date(data.fetched_at).toLocaleString()}</span>
</div>
)}
</div>
{unavailableEntries.length > 0 && (
<div>
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Unavailable Fields</span>
<ul className="mt-1 space-y-1">
{unavailableEntries.map(([field, reason]) => (
<li key={field} className="flex justify-between text-sm">
<span className="text-gray-400">{FIELD_LABELS[field] ?? field}</span>
<span className="text-amber-400">{reason}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{!expanded && data.fetched_at && (
<p className="mt-2 text-xs text-gray-500">
Updated {new Date(data.fetched_at).toLocaleDateString()}
</p>
)}
</div>
);
}