Files
signal-platform/frontend/src/lib/fundamentals.ts
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

113 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}