diff --git a/docs/dolt-integration-plan.md b/docs/dolt-integration-plan.md index 8fbbcd0..50dff31 100644 --- a/docs/dolt-integration-plan.md +++ b/docs/dolt-integration-plan.md @@ -109,12 +109,12 @@ reviewed separately if B begins. share count — both consumers (est. market cap, YoY dilution) want a point-in-time value. **Nothing derived is frozen into a row:** discrete quarters (10-Q YTD deltas, Q4 = FY − - Q1..Q3), TTM, YoY and the quarter-tape series are all computed **at read time** by + Q1..Q3), TTM, YoY and the four-period metric histories are all computed **at read time** by picking the newest valid accepted_at snapshot for *each* required period — so non-calendar fiscal years resolve correctly and a later amendment to a prior quarter is reflected automatically without ever storing a stale derived quarter. Readers pick the newest valid accepted_at per period; history powers the UI - quarter tape. + reference comparisons and deterministic reads. - Keep `fundamental_data` (`app/models/fundamental.py`) as the latest-value compat cache, repopulated by the daily SEC job — but only after the phase-A5 parity gate. @@ -336,37 +336,41 @@ changes. ## UI — `frontend/src/components/ticker/FundamentalsPanel.tsx` -One distinctive visual device — the **quarter tape** — in an otherwise restrained +One distinctive visual device — the **Reference Rails** — in an otherwise restrained panel. Preserve the app's dark glass styling and numeric typography. ``` -Fundamentals Quality improving · valuation rich -Next earnings Aug 3 · AMC Last 4: beat beat miss beat +Fundamentals +Growth accelerating · margins improving · valuation priced above peers +Next earnings Aug 3 · AMC EPS surprises ▂ ▅ ▃ ▆ -Quarter tape Q−3 Q−2 Q−1 Latest Read -Revenue growth 8% 11% 15% 18% accelerating -Operating margin 19% 20% 20% 22% improving -FCF margin 12% 10% 14% 16% above own average -Share count change 1.8% dilution +Operating trend less favorable ← ref → more favorable +Revenue growth 18% +───────────────│━━━━● +3pp vs prior · accelerating +Share count YoY −1.7% +───────────────│━━━━● buying back -Balance & valuation -Net debt / EBITDA 1.4× Industry median 2.1× healthy leverage -P/E 29.2× Industry median 23.5× priced above peers -FCF yield 3.8% Industry median 3.1% above peers +Valuation & balance less favorable ← median → more favorable +P/E 29.2× +────●━━━━━━━━━━│──── priced above peers · median 23.5× · 12 peers ``` -- Growth, margins, share count: latest four periods as a compact four-cell tape - (or sparkline) plus a deterministic text read (rules below). -- P/E, FCF yield, leverage: horizontal industry-percentile strip with a median - marker. Hidden entirely when `industry` is null (< 5 peer issuers). -- Earnings: four bars around a zero baseline — green beats, red misses, gray +- Growth and margins: horizontal rails compare the latest value with the prior + quarter or prior-period average; share-count YoY compares with zero. The rail + is normalized so right is always more favorable, including buybacks. +- P/E, FCF yield and leverage: horizontal favorable-percentile rails with a + peer-median marker. No decorative rail when `industry` is null (< 5 peers). +- Every row keeps the exact value and one deterministic comparison caption; + missing values render `n/a`, and insufficient peers render `peers n/a`. +- Earnings: four bars around a shared zero baseline — cyan beats, coral misses, gray unavailable — plus next date and BMO/AMC session countdown. -- Accessibility: color always paired with text or arrows; neutral/ambiguous stays - gray; green/red only when a read is genuinely favorable/adverse. -- Remove the hard-coded "FMP" source label — provenance is per metric. +- Accessibility: color is always paired with text; neutral/ambiguous stays gray; + rails and earnings bars expose complete ARIA descriptions. +- Remove the hard-coded "FMP" source label; surface filing and price-date + provenance in the footer. **Deterministic reads — one shared rule set.** Implement as a single function with -named constants; the tape reads and the header sentence use identical outputs. No +named constants; the metric reads and the header sentence use identical outputs. No LLM, no new composite score. Defaults (tunable constants, not scattered literals): - A series read requires ≥ 3 periods; otherwise show "—" and no read. diff --git a/frontend/src/components/ticker/FundamentalsPanel.tsx b/frontend/src/components/ticker/FundamentalsPanel.tsx index 3129f1f..712e65d 100644 --- a/frontend/src/components/ticker/FundamentalsPanel.tsx +++ b/frontend/src/components/ticker/FundamentalsPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, type ReactNode } from 'react'; import type { EarningsRecent, FundamentalResponse, @@ -13,7 +13,7 @@ interface FundamentalsPanelProps { /** Favorable / neutral / adverse — always paired with the read text. */ type Tone = 'good' | 'flat' | 'bad'; -// Horizon tokens (native to the rest of the product). +// Horizon tokens. const HZ = { text: '#EDEEF3', muted: '#9AA0B0', @@ -57,6 +57,22 @@ function money(v: number | null | undefined): string { function signedPp(v: number): string { return `${v >= 0 ? '+' : ''}${Math.round(v * 10) / 10}pp`; } +function capitalize(s: string): string { + return s.length ? s[0].toUpperCase() + s.slice(1) : s; +} +function finiteOrNull(v: number | null | undefined): number | null { + return v != null && Number.isFinite(v) ? v : null; +} +function latestHistory(metric: MetricItem | undefined): number[] { + const run: number[] = []; + const history = metric?.history ?? []; + for (let i = history.length - 1; i >= 0; i -= 1) { + const value = finiteOrNull(history[i].value); + if (value == null) break; + run.unshift(value); + } + return run; +} /** Parse YYYY-MM-DD as a LOCAL calendar date (no UTC off-by-one west of UTC). */ function parseLocalDate(s: string): Date { @@ -80,16 +96,14 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { const hasAny = (data.metrics ?? []).some((m) => m.value != null) || !!val || !!earnings?.next || (earnings?.recent?.length ?? 0) > 0; - // Operating trend: value vs a truthful reference (prior quarter / prior avg / zero). const trendRows: { key: string; label: string; kind: 'growth' | 'margin' | 'share' }[] = [ { key: 'revenue_growth_yoy', label: 'Revenue growth', kind: 'growth' }, { key: 'eps_growth_yoy', label: 'EPS growth', kind: 'growth' }, { key: 'operating_margin', label: 'Operating margin', kind: 'margin' }, { key: 'fcf_margin', label: 'FCF margin', kind: 'margin' }, - { key: 'share_count_change_yoy', label: 'Share count', kind: 'share' }, + { key: 'share_count_change_yoy', label: 'Share count YoY', kind: 'share' }, ]; - // Valuation & balance: favorable percentile vs peer median. const valueRows: { label: string; value: number | null; industry: MetricIndustry | null; readKey: string; fmt: (v: number | null) => string; @@ -103,23 +117,25 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { return (
-
-

Fundamentals

- {data.reads?.header && ( -

{data.reads.header}

- )} -
+

Fundamentals

+ {data.reads?.header ? ( +

+ {capitalize(data.reads.header)} +

+ ) : !hasAny ? ( +

+ No fundamentals reported yet. +

+ ) : null} - {!hasAny ? ( -

No fundamentals reported yet.

- ) : ( + {hasAny && ( <>
- Operating trend -
+ +
{trendRows.map((r) => ( @@ -127,8 +143,8 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
- Valuation & balance -
+ +
{valueRows.map((r) => ( ))} @@ -144,147 +160,158 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { ); } -function SectionLabel({ children }: { children: React.ReactNode }) { - return {children}; +function SectionHead({ label, axis }: { label: string; axis: string }) { + return ( +
+ {label} + {axis} +
+ ); } -// ---- operating-trend row: value vs reference, shown as a delta rail ---------- +function Bullet({ label, value, rail, comparison }: { + label: string; value: ReactNode; rail: ReactNode | null; comparison: ReactNode; +}) { + return ( +
+
+ {label} + {value} +
+ {rail &&
{rail}
} +
+ {comparison} +
+
+ ); +} + +// ---- operating-trend row (delta vs reference, favorable = right) ------------ function TrendRow({ label, kind, metric, read }: { label: string; kind: 'growth' | 'margin' | 'share'; metric: MetricItem | undefined; read: string | null | undefined; }) { const tone = readTone(read); - const value = metric?.value ?? null; - const history = (metric?.history ?? []).map((h) => h.value).filter((v): v is number => v != null); + const value = finiteOrNull(metric?.value); + const history = latestHistory(metric); - // reference + neutral band per the backend's deterministic rules let ref: number | null = null; - let refLabel = ''; + let refWord = ''; let halfRange = 8; let neutral = 2; + let favSign = 1; // +1: higher is favorable; -1: lower is favorable if (kind === 'growth') { ref = history.length >= 2 ? history[history.length - 2] : null; - refLabel = ref != null ? `prior ${pct(ref)}` : ''; - halfRange = 8; neutral = 2; + refWord = 'prior'; halfRange = 8; neutral = 2; favSign = 1; } else if (kind === 'margin') { const prior = history.slice(0, -1); ref = prior.length ? prior.reduce((a, b) => a + b, 0) / prior.length : null; - refLabel = ref != null ? `avg ${pct(ref)}` : ''; - halfRange = 4; neutral = 1; + refWord = 'avg'; halfRange = 4; neutral = 1; favSign = 1; } else { - ref = 0; refLabel = 'vs zero'; halfRange = 5; neutral = 1; + ref = 0; refWord = ''; halfRange = 5; neutral = 1; favSign = -1; // buyback (negative) is favorable } const delta = value != null && ref != null ? value - ref : null; - return ( -
-
- {label} - {pct(value)} -
-
{read ?? '—'}
- {delta != null ? ( -
- {refLabel} - - {signedPp(delta)} -
- ) : ( - value == null &&
n/a
+ const comparison = value == null ? ( + n/a + ) : delta == null ? ( + history n/a + ) : ( + + {kind !== 'share' && ( + {signedPp(delta)} vs {refWord} · )} -
+ {read ?? '—'} + ); + + const rail = delta != null + ? + : null; + + return ; } -/** A comparison rail centered on a reference line (not a progress bar): a dot at - * the current delta with a bar connecting it back to the reference. */ -function DeltaRail({ delta, halfRange, neutral, tone, ariaLabel }: { - delta: number; halfRange: number; neutral: number; tone: Tone; ariaLabel: string; +/** Comparison rail centered on a reference line (not a progress bar). favOffset > 0 + * is favorable and moves the dot RIGHT for every metric. */ +function DeltaRail({ favOffset, halfRange, neutral, tone, ariaLabel }: { + favOffset: number; halfRange: number; neutral: number; tone: Tone; ariaLabel: string; }) { - const clamped = Math.max(-halfRange, Math.min(halfRange, delta)); - const pos = 50 + (clamped / halfRange) * 50; // % + const clamped = Math.max(-halfRange, Math.min(halfRange, favOffset)); + const pos = 50 + (clamped / halfRange) * 50; const barLeft = Math.min(50, pos); const barWidth = Math.abs(pos - 50); const bandHalf = (neutral / halfRange) * 50; - const color = toneColor(tone); return ( - - - + + ); } -// ---- valuation/balance row: favorable percentile vs peer median ------------- +// ---- valuation/balance row (favorable percentile vs median) ---------------- function ValueRow({ 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)} -
- {value == null ? ( -
n/a
- ) : industry ? ( - <> -
{read ?? 'in line'}
-
- 0 - - 100 -
-
- median {fmt(industry.median)} · {industry.peer_count} peers -
- - ) : ( -
peers n/a
- )} -
+ const safeValue = finiteOrNull(value); + const rail = safeValue == null || !industry + ? null + : ; + const comparison = safeValue == null ? ( + n/a + ) : industry ? ( + + {read ?? 'in line'} · median {fmt(industry.median)} · {industry.peer_count} peers + + ) : ( + peers n/a ); + return ; } -/** Percentile rail 0-100 with the peer median fixed at 50; right = more favorable - * (the percentile is already polarity-aware). */ +/** 0-100 favorable-percentile rail with the peer median fixed at 50; right = more favorable. */ function PercentileRail({ percentile, tone, ariaLabel }: { percentile: number; tone: Tone; ariaLabel: string; }) { const p = Math.max(0, Math.min(100, percentile)); const barLeft = Math.min(50, p); const barWidth = Math.abs(p - 50); - const color = toneColor(tone); return ( - - - + + ); } -// ---- earnings + provenance (unchanged behavior) ---------------------------- +function Dot({ pos, tone }: { pos: number; tone: Tone }) { + return ( + + ); +} + +// ---- earnings + provenance ------------------------------------------------- function Provenance({ provenance, priceDate, marketCap }: { provenance: MetricItem | null; priceDate: string | null; marketCap: number | null; }) { if (!provenance?.period_end && !priceDate) return null; return ( -

+

{provenance?.period_end && ( <>SEC filings · latest {shortDate(provenance.period_end)} {provenance.filed_date && <> (filed {shortDate(provenance.filed_date)})} @@ -301,7 +328,7 @@ function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] const recent = earnings?.recent ?? []; const when = next ? (next.days_until === 0 ? 'today' : `${next.days_until}d`) : null; return ( -

+
Next earnings {next ? ( @@ -315,26 +342,56 @@ function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] no date )} - {recent.length > 0 && ( - - Last {recent.length} - {recent.slice().reverse().map((e, i) => )} - - )} + {recent.length > 0 && }
); } -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 ? 'good' : beat < 0 ? 'bad' : 'flat'; - const label = beat == null ? 'n/a' : beat > 0 ? 'beat' : beat < 0 ? 'miss' : 'in line'; - const arrow = beat == null ? '·' : beat > 0 ? '▲' : beat < 0 ? '▼' : '–'; +/** Four tiny diverging bars around a zero baseline: beat above (cyan), miss below + * (coral), height ~ |surprise %|. Reads as a beat/miss history at a glance. */ +function SurpriseSpark({ recent }: { recent: EarningsRecent[] }) { + const ordered = recent.slice().reverse(); + const description = ordered.map((e) => { + const surprise = e.surprise_pct; + const amount = surprise != null ? ` ${surprise > 0 ? '+' : ''}${surprise}%` : ''; + return `${e.announce_date} ${surpriseLabel(e)}${amount}`; + }).join(', '); return ( - 0 ? '+' : ''}${e.surprise_pct}%` : ''}`} - aria-label={`${e.announce_date} ${label}`}> - {arrow} + + + EPS surprises + + + + {ordered.map((e, i) => )} + + + ); +} + +function surpriseLabel(e: EarningsRecent): string { + const beat = e.eps_actual != null && e.eps_estimate != null ? e.eps_actual - e.eps_estimate : null; + return beat == null ? 'n/a' : beat > 0 ? 'beat' : beat < 0 ? 'miss' : 'in line'; +} + +function SurpriseBar({ 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 ? 'good' : beat < 0 ? 'bad' : 'flat'; + const s = e.surprise_pct; + const mag = s != null ? Math.min(Math.abs(s), 15) / 15 : 0; // cap at 15% + const h = beat == null ? 2 : 3 + mag * 9; // px + const up = (beat ?? 0) >= 0; + return ( + 0 ? '+' : ''}${s}%` : ''}`}> + ); } diff --git a/frontend/src/dev/harness.tsx b/frontend/src/dev/harness.tsx index e50d40c..0b60783 100644 --- a/frontend/src/dev/harness.tsx +++ b/frontend/src/dev/harness.tsx @@ -11,6 +11,17 @@ function h(period: string, value: number | null) { } const P = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-28']; +function dateFromToday(days: number): string { + const date = new Date(); + date.setHours(12, 0, 0, 0); + date.setDate(date.getDate() + days); + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + ].join('-'); +} + function metric(key: string, value: number | null, hist: (number | null)[], industry: MetricItem['industry'] = null): MetricItem { return { @@ -30,7 +41,7 @@ const legacy = { const full: FundamentalResponse = { symbol: 'AAPL', ...legacy, earnings: { - next: { date: '2026-08-03', session: 'amc', days_until: 12 }, + next: { date: dateFromToday(12), session: 'amc', days_until: 12 }, recent: [ { announce_date: '2025-08-01', period_end: '2025-06-30', eps_estimate: 1.4, eps_actual: 1.6, surprise_pct: 14.3 }, { announce_date: '2025-11-01', period_end: '2025-09-30', eps_estimate: 1.7, eps_actual: 1.9, surprise_pct: 11.8 }, @@ -44,7 +55,7 @@ const full: FundamentalResponse = { metric('operating_margin', 32, [30, 31, 31, 32], ind(22, 88)), metric('fcf_margin', 28, [24, 25, 27, 28], ind(18, 80)), metric('net_debt', 16.2e9, [46e9, 44e9, 24e9, 16.2e9], null), - metric('net_debt_to_ebitda', 1.4, [0.3, 0.3, 0.2, 0.1], ind(2.1, 68)), + metric('net_debt_to_ebitda', 1.4, [1.9, 1.7, 1.5, 1.4], ind(2.1, 68)), metric('share_count_change_yoy', -1.7, [-2.4, -2.2, -2.3, -1.7], null), ], valuation: { @@ -64,7 +75,7 @@ const full: FundamentalResponse = { const partial: FundamentalResponse = { symbol: 'NEWCO', ...legacy, - earnings: { next: { date: '2026-08-03', session: 'unknown', days_until: 0 }, recent: [] }, + earnings: { next: { date: dateFromToday(0), session: 'unknown', days_until: 0 }, recent: [] }, metrics: [ metric('revenue_growth_yoy', 12, [null, 8, 10, 12], null), metric('eps_growth_yoy', null, [null, null, null, null], null),