Docs/dolt plan clarifications #1

Merged
dennisthiessen merged 34 commits from docs/dolt-plan-clarifications into main 2026-07-23 13:27:08 +02:00
2 changed files with 289 additions and 126 deletions
Showing only changes of commit 9172c1a699 - Show all commits
@@ -1,157 +1,249 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { formatPercent, formatLargeNumber } from '../../lib/format'; import type {
import { EarningsRecent,
fundamentalScore, FundamentalResponse,
metricStatus, MetricItem,
overallFundamentalStatus, MetricIndustry,
} from '../../lib/fundamentals'; } from '../../lib/types';
import type { FundamentalResponse } from '../../lib/types';
interface FundamentalsPanelProps { interface FundamentalsPanelProps {
data: FundamentalResponse; data: FundamentalResponse;
} }
const FIELD_LABELS: Record<string, string> = { /** Positive / neutral / negative styling, always paired with the read text. */
pe_ratio: 'P/E Ratio', type Tone = 'up' | 'flat' | 'down';
revenue_growth: 'Revenue Growth',
earnings_surprise: 'Earnings Surprise', const TONE_TEXT: Record<Tone, string> = {
market_cap: 'Market Cap', up: 'text-emerald-400',
flat: 'text-gray-400',
down: 'text-rose-400',
};
const TONE_CELL: Record<Tone, string> = {
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',
]);
export function FundamentalsPanel({ data }: FundamentalsPanelProps) { function readTone(read: string | null | undefined): Tone {
const [expanded, setExpanded] = useState<boolean>(false); if (!read) return 'flat';
if (POSITIVE_READS.has(read)) return 'up';
const score = useMemo( if (NEGATIVE_READS.has(read)) return 'down';
() => if (read.includes('dilution')) return 'down';
fundamentalScore({ return 'flat';
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 },
];
const unavailableEntries = Object.entries(data.unavailable_fields ?? {});
return (
<div className="glass p-5">
<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';
if (item.value !== null) {
display = item.format(item.value);
} else if (reason) {
display = reason;
valueClass = 'text-amber-400';
} else {
display = '—';
} }
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 metrics = useMemo(
() => Object.fromEntries((data.metrics ?? []).map((m) => [m.key, m])),
[data.metrics],
) as Record<string, MetricItem | undefined>;
const reads = data.reads?.by_key ?? {};
const val = data.valuation;
const earnings = data.earnings;
const hasAny = (data.metrics ?? []).some((m) => m.value != null) || !!val || !!earnings?.next
|| (earnings?.recent?.length ?? 0) > 0;
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 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 ( return (
<div key={item.key} className="flex items-start justify-between gap-3"> <section className="glass p-5" aria-label="Fundamentals">
<span className="text-gray-400">{item.label}</span> <div className="mb-1 flex items-baseline justify-between gap-3">
<div className="min-w-0 text-right"> <h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
<div className={`num ${valueClass}`}>{display}</div> {data.reads?.header && (
{status && ( <p className="truncate text-right text-[11.5px] text-gray-400">{data.reads.header}</p>
<div className={`mt-0.5 text-[11.5px] font-medium ${status.tone}`}>{status.text}</div>
)} )}
</div> </div>
{!hasAny ? (
<p className="mt-3 text-sm text-gray-500">No fundamentals reported yet.</p>
) : (
<>
<EarningsStrip earnings={earnings} />
{/* Quarter tape — the panel's signature device */}
<div className="mt-4">
<div className="mb-1.5 grid grid-cols-[minmax(0,1fr)_auto] items-baseline">
<span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Quarter tape</span>
<span className="text-[10px] uppercase tracking-widest text-gray-600">Latest · read</span>
</div> </div>
<div className="space-y-1.5">
{tapeRows.map((row) => (
<TapeRow key={row.key} label={row.label} metric={metrics[row.key]}
read={reads[row.key]} fmt={row.fmt} />
))}
</div>
</div>
{/* Balance & valuation — restrained peer strips */}
<div className="mt-4">
<span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Balance &amp; valuation</span>
<div className="mt-1.5 space-y-2">
{valuationRows.map((row) => (
<ValuationRow key={row.label} {...row} read={reads[row.readKey]} />
))}
</div>
{val?.price_date && (
<p className="mt-2 text-[10px] text-gray-600">
Valuation at {new Date(val.price_date).toLocaleDateString()} close · market cap {money(val.market_cap_est)} est.
</p>
)}
</div>
</>
)}
</section>
);
}
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 (
<div className="grid grid-cols-[minmax(0,7rem)_1fr_auto] items-center gap-3">
<span className="truncate text-sm text-gray-400">{label}</span>
<div className="flex justify-end gap-1" role="img"
aria-label={`${label}: ${history.map((h) => fmt(h.value)).join(', ')}`}>
{history.length === 0 && <span className="num text-xs text-gray-600"></span>}
{history.map((h, i) => {
const latest = i === history.length - 1;
return (
<span key={i}
className={`num rounded px-1.5 py-0.5 text-[11px] ring-1 ${latest ? TONE_CELL[tone] : 'bg-white/5 text-gray-400 ring-white/5'}`}>
{fmt(h.value)}
</span>
); );
})} })}
</div> </div>
<span className={`w-24 text-right text-[11.5px] font-medium ${TONE_TEXT[tone]}`}>{read ?? '—'}</span>
<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 transition-colors hover:text-gray-300"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse details' : 'Expand details'}
>
<svg
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="mt-3 space-y-3 border-t border-white/10 pt-3">
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Data Source</span>
<span className="text-gray-300">FMP</span>
</div>
{data.fetched_at && (
<div className="flex justify-between">
<span className="text-gray-500">Fetched</span>
<span className="text-gray-300">{new Date(data.fetched_at).toLocaleString()}</span>
</div> </div>
);
}
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 (
<div className="grid grid-cols-[minmax(0,9rem)_auto_1fr] items-center gap-3">
<span className="truncate text-sm text-gray-400">{label}</span>
<span className="num text-sm text-gray-100">{fmt(value)}</span>
<div className="flex items-center justify-end gap-3 text-right">
{industry ? (
<>
<PercentileStrip industry={industry} tone={tone} />
<span className={`text-[11.5px] font-medium ${TONE_TEXT[tone]}`}>{read ?? 'in line'}</span>
</>
) : (
<span className="text-[11px] text-gray-600">peers n/a</span>
)} )}
</div> </div>
{unavailableEntries.length > 0 && (
<div>
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Unavailable Fields</span>
<ul className="mt-1 space-y-1">
{unavailableEntries.map(([field, reason]) => (
<li key={field} className="flex justify-between text-sm">
<span className="text-gray-400">{FIELD_LABELS[field] ?? field}</span>
<span className="text-amber-400">{reason}</span>
</li>
))}
</ul>
</div> </div>
)} );
</div> }
)}
{!expanded && data.fetched_at && ( function PercentileStrip({ industry, tone }: { industry: MetricIndustry; tone: Tone }) {
<p className="mt-2 text-xs text-gray-500"> const p = Math.max(0, Math.min(100, industry.favorable_percentile));
Updated {new Date(data.fetched_at).toLocaleDateString()} const bar = tone === 'up' ? 'bg-emerald-400' : tone === 'down' ? 'bg-rose-400' : 'bg-gray-400';
</p> return (
<span className="hidden items-center gap-1.5 sm:flex"
title={`${industry.label}: median ${industry.median}, ${industry.peer_count} peers`}>
<span className="relative h-1 w-16 rounded-full bg-white/10">
<span className="absolute inset-y-0 left-0 rounded-full" style={{ width: `${p}%` }}>
<span className={`block h-full w-full rounded-full ${bar}`} />
</span>
<span className="absolute inset-y-[-2px] left-1/2 w-px bg-white/30" aria-hidden />
</span>
</span>
);
}
function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] }) {
const next = earnings?.next;
const recent = earnings?.recent ?? [];
return (
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
<span className="text-sm text-gray-300">
<span className="text-gray-500">Next earnings </span>
{next ? (
<>
{new Date(next.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
{' · '}
<span className="uppercase">{next.session === 'unknown' ? 'TBD' : next.session}</span>
<span className="text-gray-500"> · in {next.days_until}d</span>
</>
) : (
<span className="text-gray-500">no date</span>
)}
</span>
{recent.length > 0 && (
<span className="flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-widest text-gray-600">Last {recent.length}</span>
{recent.slice().reverse().map((e, i) => <EarningsBar key={i} e={e} />)}
</span>
)} )}
</div> </div>
); );
} }
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 (
<span
className={`num rounded px-1 text-[10px] ring-1 ${TONE_CELL[tone]}`}
title={`${e.announce_date}: ${label}${e.surprise_pct != null ? ` ${e.surprise_pct > 0 ? '+' : ''}${e.surprise_pct}%` : ''}`}
aria-label={`${e.announce_date} ${label}`}
>
{arrow}
</span>
);
}
+71
View File
@@ -703,8 +703,74 @@ export interface SentimentResponse {
} }
// Fundamentals // 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<string, string | null>;
}
export interface FundamentalResponse { export interface FundamentalResponse {
symbol: string; symbol: string;
// legacy fields (unchanged)
pe_ratio: number | null; pe_ratio: number | null;
revenue_growth: number | null; revenue_growth: number | null;
earnings_surprise: number | null; earnings_surprise: number | null;
@@ -712,6 +778,11 @@ export interface FundamentalResponse {
next_earnings_date: string | null; next_earnings_date: string | null;
fetched_at: string | null; fetched_at: string | null;
unavailable_fields: Record<string, string>; unavailable_fields: Record<string, string>;
// additive v1 (SEC/Dolt-derived) — present, with null/empty when unavailable
earnings: EarningsObject | null;
metrics: MetricItem[] | null;
valuation: Valuation | null;
reads: FundamentalsReads | null;
} }
// Indicators // Indicators