From 9172c1a6992211fe793c5f6b33b69de20792f6e4 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Wed, 22 Jul 2026 22:05:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20A4=20=E2=80=94=20Fundamentals?= =?UTF-8?q?Panel=20v1=20(quarter=20tape=20+=20earnings=20+=20peers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes FundamentalsPanel to consume the additive API v1, within the app's existing dark-glass language. - types.ts updated to the exact v1 shape (metrics/earnings/valuation/reads + legacy fields preserved). - The quarter tape is the single distinctive device: per-metric 4-cell tape (revenue/EPS growth, operating + FCF margin, share count) with the latest cell toned by the deterministic read; color is always paired with the read text. - Restrained peer strips for Net debt/EBITDA, P/E, FCF yield: value + a polarity-aware percentile bar with a median marker + the read; hidden ("peers n/a") when industry is null (< 5 peers). - Earnings: next date/session/countdown + last-N beat/miss arrows (▲/▼/·) with text aria-labels; explicit "no date" state. - Explicit n/a, insufficient-peer, and no-earnings states; header shows the deterministic sentence. Removed the hard-coded "FMP" source label. Frontend tsc -b passes; backend suite 778 passed. Co-Authored-By: Claude Opus 4.8 --- .../components/ticker/FundamentalsPanel.tsx | 344 +++++++++++------- frontend/src/lib/types.ts | 71 ++++ 2 files changed, 289 insertions(+), 126 deletions(-) diff --git a/frontend/src/components/ticker/FundamentalsPanel.tsx b/frontend/src/components/ticker/FundamentalsPanel.tsx index cf9aa91..7c24c4c 100644 --- a/frontend/src/components/ticker/FundamentalsPanel.tsx +++ b/frontend/src/components/ticker/FundamentalsPanel.tsx @@ -1,157 +1,249 @@ -import { useMemo, useState } from 'react'; -import { formatPercent, formatLargeNumber } from '../../lib/format'; -import { - fundamentalScore, - metricStatus, - overallFundamentalStatus, -} from '../../lib/fundamentals'; -import type { FundamentalResponse } from '../../lib/types'; +import { useMemo } from 'react'; +import type { + EarningsRecent, + FundamentalResponse, + MetricItem, + MetricIndustry, +} from '../../lib/types'; interface FundamentalsPanelProps { data: FundamentalResponse; } -const FIELD_LABELS: Record = { - pe_ratio: 'P/E Ratio', - revenue_growth: 'Revenue Growth', - earnings_surprise: 'Earnings Surprise', - market_cap: 'Market Cap', +/** Positive / neutral / negative styling, always paired with the read text. */ +type Tone = 'up' | 'flat' | 'down'; + +const TONE_TEXT: Record = { + up: 'text-emerald-400', + flat: 'text-gray-400', + down: 'text-rose-400', +}; +const TONE_CELL: Record = { + up: 'bg-emerald-400/10 text-emerald-200 ring-emerald-400/20', + flat: 'bg-white/5 text-gray-200 ring-white/10', + down: 'bg-rose-400/10 text-rose-200 ring-rose-400/20', }; -type MetricKey = 'pe_ratio' | 'revenue_growth' | 'earnings_surprise' | 'market_cap'; +const POSITIVE_READS = new Set([ + 'accelerating', 'improving', 'above peers', 'buying back', 'attractively valued', + 'conservative leverage', +]); +const NEGATIVE_READS = new Set([ + 'decelerating', 'deteriorating', 'priced above peers', 'elevated leverage', + 'below peers', +]); + +function readTone(read: string | null | undefined): Tone { + if (!read) return 'flat'; + if (POSITIVE_READS.has(read)) return 'up'; + if (NEGATIVE_READS.has(read)) return 'down'; + if (read.includes('dilution')) return 'down'; + return 'flat'; +} + +function pct(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return 'n/a'; + return `${Math.round(v * 10) / 10}%`; +} +function mult(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return 'n/a'; + return `${v.toFixed(1)}×`; +} +function money(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return 'n/a'; + const abs = Math.abs(v); + if (abs >= 1e12) return `$${(v / 1e12).toFixed(1)}T`; + if (abs >= 1e9) return `$${(v / 1e9).toFixed(1)}B`; + if (abs >= 1e6) return `$${(v / 1e6).toFixed(1)}M`; + return `$${v.toFixed(0)}`; +} export function FundamentalsPanel({ data }: FundamentalsPanelProps) { - const [expanded, setExpanded] = useState(false); + const metrics = useMemo( + () => Object.fromEntries((data.metrics ?? []).map((m) => [m.key, m])), + [data.metrics], + ) as Record; + const reads = data.reads?.by_key ?? {}; + const val = data.valuation; + const earnings = data.earnings; - 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 hasAny = (data.metrics ?? []).some((m) => m.value != null) || !!val || !!earnings?.next + || (earnings?.recent?.length ?? 0) > 0; - 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 tapeRows: { key: string; label: string; fmt: (v: number | null) => string }[] = [ + { key: 'revenue_growth_yoy', label: 'Revenue growth', fmt: pct }, + { key: 'eps_growth_yoy', label: 'EPS growth', fmt: pct }, + { key: 'operating_margin', label: 'Operating margin', fmt: pct }, + { key: 'fcf_margin', label: 'FCF margin', fmt: pct }, + { key: 'share_count_change_yoy', label: 'Share count', fmt: pct }, ]; - const unavailableEntries = Object.entries(data.unavailable_fields ?? {}); + const valuationRows: { + label: string; value: number | null; industry: MetricIndustry | null; + readKey: string; fmt: (v: number | null) => string; + }[] = [ + { label: 'Net debt / EBITDA', value: metrics.net_debt_to_ebitda?.value ?? null, + industry: metrics.net_debt_to_ebitda?.industry ?? null, readKey: 'net_debt_to_ebitda', fmt: mult }, + { label: 'P/E', value: val?.pe ?? null, industry: val?.pe_industry ?? null, readKey: 'pe', fmt: mult }, + { label: 'FCF yield', value: val?.fcf_yield ?? null, industry: val?.fcf_yield_industry ?? null, + readKey: 'fcf_yield', fmt: pct }, + ]; return ( -
-
+
+

Fundamentals

- {score != null && ( - - score {score.toFixed(0)} - + {data.reads?.header && ( +

{data.reads.header}

)}
-

{overall.text}

+ {!hasAny ? ( +

No fundamentals reported yet.

+ ) : ( + <> + -
- {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 ( -
- {item.label} -
-
{display}
- {status && ( -
{status.text}
- )} -
+ {/* Quarter tape — the panel's signature device */} +
+
+ Quarter tape + Latest · read
+
+ {tapeRows.map((row) => ( + + ))} +
+
+ + {/* Balance & valuation — restrained peer strips */} +
+ Balance & valuation +
+ {valuationRows.map((row) => ( + + ))} +
+ {val?.price_date && ( +

+ Valuation at {new Date(val.price_date).toLocaleDateString()} close · market cap {money(val.market_cap_est)} est. +

+ )} +
+ + )} +
+ ); +} + +function TapeRow({ label, metric, read, fmt }: { + label: string; metric: MetricItem | undefined; read: string | null | undefined; + fmt: (v: number | null) => string; +}) { + const history = metric?.history ?? []; + const tone = readTone(read); + return ( +
+ {label} +
fmt(h.value)).join(', ')}`}> + {history.length === 0 && } + {history.map((h, i) => { + const latest = i === history.length - 1; + return ( + + {fmt(h.value)} + ); })}
+ {read ?? '—'} +
+ ); +} -

- 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. -

+function ValuationRow({ label, value, industry, read, fmt }: { + label: string; value: number | null; industry: MetricIndustry | null; + read: string | null | undefined; fmt: (v: number | null) => string; +}) { + const tone = readTone(read); + return ( +
+ {label} + {fmt(value)} +
+ {industry ? ( + <> + + {read ?? 'in line'} + + ) : ( + peers n/a + )} +
+
+ ); +} - +function PercentileStrip({ industry, tone }: { industry: MetricIndustry; tone: Tone }) { + const p = Math.max(0, Math.min(100, industry.favorable_percentile)); + const bar = tone === 'up' ? 'bg-emerald-400' : tone === 'down' ? 'bg-rose-400' : 'bg-gray-400'; + return ( + + + + + + + + + ); +} - {expanded && ( -
-
-
- Data Source - FMP -
- {data.fetched_at && ( -
- Fetched - {new Date(data.fetched_at).toLocaleString()} -
- )} -
- - {unavailableEntries.length > 0 && ( -
- Unavailable Fields -
    - {unavailableEntries.map(([field, reason]) => ( -
  • - {FIELD_LABELS[field] ?? field} - {reason} -
  • - ))} -
-
- )} -
- )} - - {!expanded && data.fetched_at && ( -

- Updated {new Date(data.fetched_at).toLocaleDateString()} -

+function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] }) { + const next = earnings?.next; + const recent = earnings?.recent ?? []; + return ( +
+ + Next earnings + {next ? ( + <> + {new Date(next.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + {' · '} + {next.session === 'unknown' ? 'TBD' : next.session} + · in {next.days_until}d + + ) : ( + no date + )} + + {recent.length > 0 && ( + + Last {recent.length} + {recent.slice().reverse().map((e, i) => )} + )}
); } + +function EarningsBar({ e }: { e: EarningsRecent }) { + const beat = e.eps_actual != null && e.eps_estimate != null ? e.eps_actual - e.eps_estimate : null; + const tone: Tone = beat == null ? 'flat' : beat > 0 ? 'up' : beat < 0 ? 'down' : 'flat'; + const label = beat == null ? 'n/a' : beat > 0 ? 'beat' : beat < 0 ? 'miss' : 'in line'; + const arrow = beat == null ? '·' : beat > 0 ? '▲' : beat < 0 ? '▼' : '–'; + return ( + 0 ? '+' : ''}${e.surprise_pct}%` : ''}`} + aria-label={`${e.announce_date} ${label}`} + > + {arrow} + + ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e562220..2f5af55 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -703,8 +703,74 @@ export interface SentimentResponse { } // Fundamentals +export interface MetricIndustry { + label: string; + median: number; + favorable_percentile: number; // 0-100, polarity-aware (higher = more favorable) + peer_count: number; +} + +export interface MetricHistoryPoint { + period_end: string | null; // YYYY-MM-DD + value: number | null; +} + +export type MetricKey = + | 'revenue_growth_yoy' + | 'eps_growth_yoy' + | 'operating_margin' + | 'fcf_margin' + | 'net_debt' + | 'net_debt_to_ebitda' + | 'share_count_change_yoy'; + +export interface MetricItem { + key: MetricKey; + value: number | null; + history: MetricHistoryPoint[]; + industry: MetricIndustry | null; + period_end: string | null; + filed_date: string | null; + source: string; // 'sec' | 'legacy_api' +} + +export interface EarningsNext { + date: string; + session: string; // bmo | amc | unknown + days_until: number; +} + +export interface EarningsRecent { + announce_date: string; + period_end: string | null; + eps_estimate: number | null; + eps_actual: number | null; + surprise_pct: number | null; +} + +export interface EarningsObject { + next: EarningsNext | null; + recent: EarningsRecent[]; +} + +export interface Valuation { + pe: number | null; + fcf_yield: number | null; + market_cap_est: number | null; + pe_industry: MetricIndustry | null; + fcf_yield_industry: MetricIndustry | null; + price_date: string | null; +} + +export interface FundamentalsReads { + header: string | null; + // fixed map over every metric key plus 'pe' and 'fcf_yield'; null when unavailable + by_key: Record; +} + export interface FundamentalResponse { symbol: string; + // legacy fields (unchanged) pe_ratio: number | null; revenue_growth: number | null; earnings_surprise: number | null; @@ -712,6 +778,11 @@ export interface FundamentalResponse { next_earnings_date: string | null; fetched_at: string | null; unavailable_fields: Record; + // additive v1 (SEC/Dolt-derived) — present, with null/empty when unavailable + earnings: EarningsObject | null; + metrics: MetricItem[] | null; + valuation: Valuation | null; + reads: FundamentalsReads | null; } // Indicators