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 { formatPercent, formatLargeNumber } from '../../lib/format';
|
||||||
|
import {
|
||||||
|
fundamentalScore,
|
||||||
|
metricStatus,
|
||||||
|
overallFundamentalStatus,
|
||||||
|
} from '../../lib/fundamentals';
|
||||||
import type { FundamentalResponse } from '../../lib/types';
|
import type { FundamentalResponse } from '../../lib/types';
|
||||||
|
|
||||||
interface FundamentalsPanelProps {
|
interface FundamentalsPanelProps {
|
||||||
@@ -13,11 +18,29 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
market_cap: 'Market Cap',
|
market_cap: 'Market Cap',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MetricKey = 'pe_ratio' | 'revenue_growth' | 'earnings_surprise' | 'market_cap';
|
||||||
|
|
||||||
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||||
const [expanded, setExpanded] = useState<boolean>(false);
|
const [expanded, setExpanded] = useState<boolean>(false);
|
||||||
|
|
||||||
const items = [
|
const score = useMemo(
|
||||||
{ key: 'pe_ratio', label: 'P/E Ratio', value: data.pe_ratio, format: (v: number) => v.toFixed(2) },
|
() =>
|
||||||
|
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: 'revenue_growth', label: 'Revenue Growth', value: data.revenue_growth, format: formatPercent },
|
||||||
{ key: 'earnings_surprise', label: 'Earnings Surprise', value: data.earnings_surprise, 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 },
|
{ key: 'market_cap', label: 'Market Cap', value: data.market_cap, format: formatLargeNumber },
|
||||||
@@ -27,10 +50,21 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass p-5">
|
<div className="glass p-5">
|
||||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
|
<div className="mb-3 flex items-baseline justify-between gap-2">
|
||||||
<div className="space-y-2.5 text-sm">
|
<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) => {
|
{items.map((item) => {
|
||||||
const reason = data.unavailable_fields?.[item.key];
|
const reason = data.unavailable_fields?.[item.key];
|
||||||
|
const status = item.value !== null ? metricStatus(item.key, item.value) : null;
|
||||||
let display: React.ReactNode;
|
let display: React.ReactNode;
|
||||||
let valueClass = 'text-gray-200';
|
let valueClass = 'text-gray-200';
|
||||||
|
|
||||||
@@ -44,18 +78,30 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((prev) => !prev)}
|
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-expanded={expanded}
|
||||||
aria-label={expanded ? 'Collapse details' : 'Expand details'}
|
aria-label={expanded ? 'Collapse details' : 'Expand details'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Fundamental dimension readouts for the Fundamentals tab.
|
||||||
|
*
|
||||||
|
* Scoring mirrors app/services/scoring_service.py _compute_fundamental_score:
|
||||||
|
* equal-weighted average of available P/E, revenue growth, and earnings
|
||||||
|
* surprise (need ≥2 metrics). Market cap is display-only, not scored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FundamentalMetrics {
|
||||||
|
pe_ratio: number | null;
|
||||||
|
revenue_growth: number | null;
|
||||||
|
earnings_surprise: number | null;
|
||||||
|
market_cap?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusRead {
|
||||||
|
text: string;
|
||||||
|
tone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v: number, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
|
/** P/E sub-score: lower is better. PE 15 → 100, 30 → 50, 45 → 0. */
|
||||||
|
export function peSubScore(pe: number): number {
|
||||||
|
return clamp(100 - (pe - 15) * (100 / 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revenue growth sub-score: 0% → 50, +20% → 100, −20% → 0. */
|
||||||
|
export function revenueGrowthSubScore(growthPct: number): number {
|
||||||
|
return clamp(50 + growthPct * 2.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Earnings surprise sub-score: 0% → 50, +10% → 100, −10% → 0. */
|
||||||
|
export function earningsSurpriseSubScore(surprisePct: number): number {
|
||||||
|
return clamp(50 + surprisePct * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall fundamental score, or null when fewer than 2 scored metrics.
|
||||||
|
* Matches backend MIN_METRICS = 2.
|
||||||
|
*/
|
||||||
|
export function fundamentalScore(m: FundamentalMetrics): number | null {
|
||||||
|
const parts: number[] = [];
|
||||||
|
if (m.pe_ratio != null && m.pe_ratio > 0) parts.push(peSubScore(m.pe_ratio));
|
||||||
|
if (m.revenue_growth != null) parts.push(revenueGrowthSubScore(m.revenue_growth));
|
||||||
|
if (m.earnings_surprise != null) parts.push(earningsSurpriseSubScore(m.earnings_surprise));
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return parts.reduce((a, b) => a + b, 0) / parts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overallFundamentalStatus(score: number | null): StatusRead {
|
||||||
|
if (score == null) {
|
||||||
|
return { text: 'incomplete data', tone: 'text-amber-300' };
|
||||||
|
}
|
||||||
|
if (score >= 70) return { text: 'strong fundamentals', tone: 'text-emerald-300' };
|
||||||
|
if (score >= 55) return { text: 'healthy', tone: 'text-emerald-300' };
|
||||||
|
if (score >= 45) return { text: 'mixed / average', tone: 'text-gray-400' };
|
||||||
|
if (score >= 30) return { text: 'soft', tone: 'text-amber-300' };
|
||||||
|
return { text: 'weak fundamentals', tone: 'text-red-300' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peStatus(pe: number | null): StatusRead | null {
|
||||||
|
if (pe == null || !(pe > 0)) return null;
|
||||||
|
if (pe <= 15) return { text: 'cheap / attractive', tone: 'text-emerald-300' };
|
||||||
|
if (pe <= 25) return { text: 'fair', tone: 'text-gray-400' };
|
||||||
|
if (pe <= 35) return { text: 'expensive', tone: 'text-amber-300' };
|
||||||
|
return { text: 'rich', tone: 'text-red-300' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revenueGrowthStatus(growthPct: number | null): StatusRead | null {
|
||||||
|
if (growthPct == null) return null;
|
||||||
|
if (growthPct >= 20) return { text: 'strong growth', tone: 'text-emerald-300' };
|
||||||
|
if (growthPct >= 5) return { text: 'solid growth', tone: 'text-emerald-300' };
|
||||||
|
if (growthPct >= -5) return { text: 'flat', tone: 'text-gray-400' };
|
||||||
|
if (growthPct >= -20) return { text: 'contracting', tone: 'text-amber-300' };
|
||||||
|
return { text: 'deep contraction', tone: 'text-red-300' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function earningsSurpriseStatus(surprisePct: number | null): StatusRead | null {
|
||||||
|
if (surprisePct == null) return null;
|
||||||
|
if (surprisePct >= 10) return { text: 'beat (large)', tone: 'text-emerald-300' };
|
||||||
|
if (surprisePct >= 2) return { text: 'beat', tone: 'text-emerald-300' };
|
||||||
|
if (surprisePct >= -2) return { text: 'in line', tone: 'text-gray-400' };
|
||||||
|
if (surprisePct >= -10) return { text: 'miss', tone: 'text-amber-300' };
|
||||||
|
return { text: 'miss (large)', tone: 'text-red-300' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Size band only — not good/bad, not part of the score. */
|
||||||
|
export function marketCapStatus(marketCap: number | null): StatusRead | null {
|
||||||
|
if (marketCap == null || !(marketCap > 0)) return null;
|
||||||
|
if (marketCap >= 200e9) return { text: 'mega cap', tone: 'text-gray-400' };
|
||||||
|
if (marketCap >= 10e9) return { text: 'large cap', tone: 'text-gray-400' };
|
||||||
|
if (marketCap >= 2e9) return { text: 'mid cap', tone: 'text-gray-400' };
|
||||||
|
if (marketCap >= 300e6) return { text: 'small cap', tone: 'text-gray-400' };
|
||||||
|
return { text: 'micro cap', tone: 'text-gray-400' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metricStatus(
|
||||||
|
key: 'pe_ratio' | 'revenue_growth' | 'earnings_surprise' | 'market_cap',
|
||||||
|
value: number | null,
|
||||||
|
): StatusRead | null {
|
||||||
|
switch (key) {
|
||||||
|
case 'pe_ratio':
|
||||||
|
return peStatus(value);
|
||||||
|
case 'revenue_growth':
|
||||||
|
return revenueGrowthStatus(value);
|
||||||
|
case 'earnings_surprise':
|
||||||
|
return earningsSurpriseStatus(value);
|
||||||
|
case 'market_cap':
|
||||||
|
return marketCapStatus(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user