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.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
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 {
|
||||
@@ -13,11 +18,29 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
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 items = [
|
||||
{ key: 'pe_ratio', label: 'P/E Ratio', value: data.pe_ratio, format: (v: number) => v.toFixed(2) },
|
||||
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 },
|
||||
@@ -27,10 +50,21 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<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';
|
||||
|
||||
@@ -44,18 +78,30 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.key} className="flex justify-between">
|
||||
<div key={item.key} className="flex items-start justify-between gap-3">
|
||||
<span className="text-gray-400">{item.label}</span>
|
||||
<span className={valueClass}>{display}</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 hover:text-gray-300 transition-colors"
|
||||
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'}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user