Improve financial statement value formatting

This commit is contained in:
2026-03-09 18:58:15 -04:00
parent fae8c54121
commit fa2de3e259
3 changed files with 306 additions and 88 deletions

View File

@@ -37,6 +37,7 @@ import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import { queueFilingSync } from '@/lib/api'; import { queueFilingSync } from '@/lib/api';
import { import {
formatCurrencyByScale, formatCurrencyByScale,
formatFinancialStatementValue,
formatPercent, formatPercent,
type NumberScaleUnit type NumberScaleUnit
} from '@/lib/format'; } from '@/lib/format';
@@ -121,32 +122,62 @@ function rowValue(row: DisplayRow, periodId: string) {
return row.values[periodId] ?? null; return row.values[periodId] ?? null;
} }
function formatMetricValue(value: number | null, unit: FinancialUnit, scale: NumberScaleUnit) { function isStatementSurfaceKind(surfaceKind: FinancialSurfaceKind) {
if (value === null) { return surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement';
return 'n/a'; }
function formatMetricValue(input: {
value: number | null;
unit: FinancialUnit;
scale: NumberScaleUnit;
rowKey?: string | null;
surfaceKind: FinancialSurfaceKind;
isPercentChange?: boolean;
isCommonSize?: boolean;
}) {
if (input.value === null) {
return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a';
} }
switch (unit) { if (isStatementSurfaceKind(input.surfaceKind)) {
return formatFinancialStatementValue({
value: input.value,
unit: input.unit,
scale: input.scale,
rowKey: input.rowKey,
surfaceKind: input.surfaceKind,
isPercentChange: input.isPercentChange,
isCommonSize: input.isCommonSize
});
}
switch (input.unit) {
case 'currency': case 'currency':
return formatCurrencyByScale(value, scale); return formatCurrencyByScale(input.value, input.scale);
case 'percent': case 'percent':
return formatPercent(value * 100); return formatPercent(input.value * 100);
case 'shares': case 'shares':
case 'count': case 'count':
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value); return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(input.value);
case 'ratio': case 'ratio':
return `${value.toFixed(2)}x`; return `${input.value.toFixed(2)}x`;
default: default:
return String(value); return String(input.value);
} }
} }
function chartTickFormatter(value: number, unit: FinancialUnit, scale: NumberScaleUnit) { function chartTickFormatter(
value: number,
unit: FinancialUnit,
scale: NumberScaleUnit,
surfaceKind: FinancialSurfaceKind,
rowKey?: string | null
) {
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) {
return 'n/a'; return isStatementSurfaceKind(surfaceKind) ? '—' : 'n/a';
} }
return formatMetricValue(value, unit, scale); return formatMetricValue({ value, unit, scale, surfaceKind, rowKey });
} }
function buildDisplayValue(input: { function buildDisplayValue(input: {
@@ -165,10 +196,17 @@ function buildDisplayValue(input: {
if (input.showPercentChange && input.previousPeriodId) { if (input.showPercentChange && input.previousPeriodId) {
const previous = rowValue(input.row, input.previousPeriodId); const previous = rowValue(input.row, input.previousPeriodId);
if (current === null || previous === null || previous === 0) { if (current === null || previous === null || previous === 0) {
return 'n/a'; return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a';
} }
return formatPercent(((current - previous) / previous) * 100); return formatMetricValue({
value: (current - previous) / previous,
unit: 'percent',
scale: input.scale,
rowKey: input.row.key,
surfaceKind: input.surfaceKind,
isPercentChange: true
});
} }
if (input.showCommonSize) { if (input.showCommonSize) {
@@ -177,21 +215,34 @@ function buildDisplayValue(input: {
} }
if (input.displayMode === 'faithful') { if (input.displayMode === 'faithful') {
return 'n/a'; return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a';
} }
const denominator = input.commonSizeRow ? rowValue(input.commonSizeRow, input.periodId) : null; const denominator = input.commonSizeRow ? rowValue(input.commonSizeRow, input.periodId) : null;
if (current === null || denominator === null || denominator === 0) { if (current === null || denominator === null || denominator === 0) {
return 'n/a'; return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a';
} }
return formatPercent((current / denominator) * 100); return formatMetricValue({
value: current / denominator,
unit: 'percent',
scale: input.scale,
rowKey: input.row.key,
surfaceKind: input.surfaceKind,
isCommonSize: true
});
} }
const unit = isTaxonomyRow(input.row) const unit = isTaxonomyRow(input.row)
? 'currency' ? 'currency'
: input.row.unit; : input.row.unit;
return formatMetricValue(current, unit, input.scale); return formatMetricValue({
value: current,
unit,
scale: input.scale,
rowKey: input.row.key,
surfaceKind: input.surfaceKind
});
} }
function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsResponse['categories']) { function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsResponse['categories']) {
@@ -694,13 +745,25 @@ function FinancialsPageContent() {
<YAxis <YAxis
stroke={CHART_MUTED} stroke={CHART_MUTED}
fontSize={12} fontSize={12}
tickFormatter={(value: number) => chartTickFormatter(value, trendSeries[0]?.unit ?? 'currency', valueScale)} tickFormatter={(value: number) => chartTickFormatter(
value,
trendSeries[0]?.unit ?? 'currency',
valueScale,
surfaceKind,
trendSeries[0]?.key ?? null
)}
/> />
<Tooltip <Tooltip
formatter={(value: unknown, _name, entry) => { formatter={(value: unknown, _name, entry) => {
const numeric = Number(value); const numeric = Number(value);
const unit = trendSeries.find((series) => series.key === entry.dataKey)?.unit ?? 'currency'; const series = trendSeries.find((candidate) => candidate.key === entry.dataKey);
return formatMetricValue(Number.isFinite(numeric) ? numeric : null, unit, valueScale); return formatMetricValue({
value: Number.isFinite(numeric) ? numeric : null,
unit: series?.unit ?? 'currency',
scale: valueScale,
rowKey: series?.key ?? null,
surfaceKind
});
}} }}
contentStyle={{ contentStyle={{
backgroundColor: CHART_TOOLTIP_BG, backgroundColor: CHART_TOOLTIP_BG,
@@ -736,70 +799,77 @@ function FinancialsPageContent() {
) : periods.length === 0 || filteredRows.length === 0 ? ( ) : periods.length === 0 || filteredRows.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No rows available for the selected filters yet.</p> <p className="text-sm text-[color:var(--terminal-muted)]">No rows available for the selected filters yet.</p>
) : ( ) : (
<div className="data-table-wrap"> <div className="space-y-3">
<table className="data-table min-w-[980px]"> {isStatementSurfaceKind(surfaceKind) ? (
<thead> <p className="text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
<tr> USD · {FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? valueScale}
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th> </p>
{periods.map((period) => ( ) : null}
<th key={period.id}> <div className="data-table-wrap">
<div className="flex flex-col gap-1"> <table className="data-table min-w-[980px]">
<span>{formatLongDate(period.periodEnd ?? period.filingDate)}</span> <thead>
<span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">{period.filingType} · {period.periodLabel}</span> <tr>
</div> <th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
</th> {periods.map((period) => (
))} <th key={period.id}>
</tr> <div className="flex flex-col gap-1">
</thead> <span>{formatLongDate(period.periodEnd ?? period.filingDate)}</span>
<tbody> <span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">{period.filingType} · {period.periodLabel}</span>
{groupedRows.map((group) => ( </div>
<Fragment key={group.label ?? 'ungrouped'}> </th>
{group.label ? (
<tr className="bg-[color:var(--panel-soft)]">
<td colSpan={periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">{group.label}</td>
</tr>
) : null}
{group.rows.map((row) => (
<tr
key={row.key}
className={selectedRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
onClick={() => setSelectedRowKey(row.key)}
>
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span style={{ paddingLeft: `${isTaxonomyRow(row) ? Math.min(row.depth, 10) * 12 : 0}px` }}>{row.label}</span>
{'hasDimensions' in row && row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
</div>
{isDerivedRow(row) && row.formulaKey ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Formula: {row.formulaKey}</span>
) : null}
{isKpiRow(row) ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Provenance: {row.provenanceType}</span>
) : null}
</div>
</td>
{periods.map((period, index) => (
<td key={`${row.key}-${period.id}`}>
{buildDisplayValue({
row,
periodId: period.id,
previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null,
commonSizeRow,
displayMode,
showPercentChange,
showCommonSize,
scale: valueScale,
surfaceKind
})}
</td>
))}
</tr>
))} ))}
</Fragment> </tr>
))} </thead>
</tbody> <tbody>
</table> {groupedRows.map((group) => (
<Fragment key={group.label ?? 'ungrouped'}>
{group.label ? (
<tr className="bg-[color:var(--panel-soft)]">
<td colSpan={periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">{group.label}</td>
</tr>
) : null}
{group.rows.map((row) => (
<tr
key={row.key}
className={selectedRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
onClick={() => setSelectedRowKey(row.key)}
>
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span style={{ paddingLeft: `${isTaxonomyRow(row) ? Math.min(row.depth, 10) * 12 : 0}px` }}>{row.label}</span>
{'hasDimensions' in row && row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
</div>
{isDerivedRow(row) && row.formulaKey ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Formula: {row.formulaKey}</span>
) : null}
{isKpiRow(row) ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Provenance: {row.provenanceType}</span>
) : null}
</div>
</td>
{periods.map((period, index) => (
<td key={`${row.key}-${period.id}`}>
{buildDisplayValue({
row,
periodId: period.id,
previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null,
commonSizeRow,
displayMode,
showPercentChange,
showCommonSize,
scale: valueScale,
surfaceKind
})}
</td>
))}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
</div> </div>
)} )}
</Panel> </Panel>
@@ -870,7 +940,13 @@ function FinancialsPageContent() {
<td>{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td> <td>{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
<td>{row.axis}</td> <td>{row.axis}</td>
<td>{row.member}</td> <td>{row.member}</td>
<td>{formatMetricValue(row.value, 'currency', valueScale)}</td> <td>{formatMetricValue({
value: row.value,
unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit,
scale: valueScale,
rowKey: selectedRow.key,
surfaceKind
})}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -905,8 +981,20 @@ function FinancialsPageContent() {
{financials.metrics.validation?.checks.map((check) => ( {financials.metrics.validation?.checks.map((check) => (
<tr key={check.metricKey}> <tr key={check.metricKey}>
<td>{check.metricKey}</td> <td>{check.metricKey}</td>
<td>{formatMetricValue(check.taxonomyValue, 'currency', valueScale)}</td> <td>{formatMetricValue({
<td>{formatMetricValue(check.llmValue, 'currency', valueScale)}</td> value: check.taxonomyValue,
unit: 'currency',
scale: valueScale,
rowKey: check.metricKey,
surfaceKind
})}</td>
<td>{formatMetricValue({
value: check.llmValue,
unit: 'currency',
scale: valueScale,
rowKey: check.metricKey,
surfaceKind
})}</td>
<td>{check.status}</td> <td>{check.status}</td>
<td>{check.evidencePages.join(', ') || 'n/a'}</td> <td>{check.evidencePages.join(', ') || 'n/a'}</td>
</tr> </tr>

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { formatCurrencyByScale, formatScaledNumber } from './format'; import {
formatCurrencyByScale,
formatFinancialStatementValue,
formatScaledNumber
} from './format';
describe('formatScaledNumber', () => { describe('formatScaledNumber', () => {
it('keeps values below one thousand unscaled', () => { it('keeps values below one thousand unscaled', () => {
@@ -45,3 +49,59 @@ describe('formatCurrencyByScale', () => {
expect(formatCurrencyByScale(-2_500_000, 'millions')).toBe('-$2.5M'); expect(formatCurrencyByScale(-2_500_000, 'millions')).toBe('-$2.5M');
}); });
}); });
describe('formatFinancialStatementValue', () => {
it('renders null statement values as em dashes', () => {
expect(formatFinancialStatementValue({
value: null,
unit: 'currency',
scale: 'millions',
rowKey: 'revenue',
surfaceKind: 'income_statement'
})).toBe('—');
});
it('formats scaled currency values without a currency symbol', () => {
expect(formatFinancialStatementValue({
value: 15_940_899_000,
unit: 'currency',
scale: 'millions',
rowKey: 'revenue',
surfaceKind: 'income_statement'
})).toBe('15,940.9');
expect(formatFinancialStatementValue({
value: 1_100_000_000,
unit: 'currency',
scale: 'millions',
rowKey: 'long_term_debt_issued',
surfaceKind: 'cash_flow_statement'
})).toBe('1,100');
});
it('keeps extra precision for per-share rows', () => {
expect(formatFinancialStatementValue({
value: 14.64,
unit: 'currency',
scale: 'millions',
rowKey: 'diluted_eps',
surfaceKind: 'income_statement'
})).toBe('14.64');
});
it('formats statement percents and ratios with trimmed precision', () => {
expect(formatFinancialStatementValue({
value: 0.233,
unit: 'percent',
scale: 'millions',
rowKey: 'effective_tax_rate',
surfaceKind: 'income_statement'
})).toBe('23.3%');
expect(formatFinancialStatementValue({
value: 1.5,
unit: 'ratio',
scale: 'millions',
rowKey: 'debt_to_equity',
surfaceKind: 'ratios'
})).toBe('1.5x');
});
});

View File

@@ -1,3 +1,8 @@
import type {
FinancialSurfaceKind,
FinancialUnit
} from './types';
export function asNumber(value: string | number | null | undefined) { export function asNumber(value: string | number | null | undefined) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return 0; return 0;
@@ -37,6 +42,37 @@ type FormatScaledCurrencyOptions = {
maximumFractionDigits?: number; maximumFractionDigits?: number;
}; };
type FormatFinancialStatementValueInput = {
value: string | number | null | undefined;
unit: FinancialUnit;
scale: NumberScaleUnit;
rowKey?: string | null;
surfaceKind?: FinancialSurfaceKind | null;
isPercentChange?: boolean;
isCommonSize?: boolean;
};
function isPerShareRowKey(rowKey: string | null | undefined) {
if (!rowKey) {
return false;
}
return rowKey.includes('eps') || rowKey.includes('per_share');
}
function formatPlainNumber(
value: number,
options: {
minimumFractionDigits?: number;
maximumFractionDigits?: number;
} = {}
) {
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: options.minimumFractionDigits ?? 0,
maximumFractionDigits: options.maximumFractionDigits ?? 1
}).format(value);
}
export function formatScaledNumber( export function formatScaledNumber(
value: string | number | null | undefined, value: string | number | null | undefined,
options: FormatScaledNumberOptions = {} options: FormatScaledNumberOptions = {}
@@ -99,6 +135,40 @@ export function formatCurrencyByScale(
return `${formatted}${suffix}`; return `${formatted}${suffix}`;
} }
export function formatFinancialStatementValue(input: FormatFinancialStatementValueInput) {
if (input.value === null || input.value === undefined) {
return '—';
}
const numeric = typeof input.value === 'number' ? input.value : Number(input.value);
if (!Number.isFinite(numeric)) {
return '—';
}
if (input.isPercentChange || input.isCommonSize || input.unit === 'percent') {
return `${formatPlainNumber(numeric * 100, { minimumFractionDigits: 0, maximumFractionDigits: 1 })}%`;
}
if (input.unit === 'ratio') {
return `${formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}x`;
}
if (input.unit === 'currency' && isPerShareRowKey(input.rowKey)) {
return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
if (input.unit === 'currency') {
const scaled = numeric / NUMBER_SCALE_UNITS[input.scale].divisor;
return formatPlainNumber(scaled, { minimumFractionDigits: 0, maximumFractionDigits: 1 });
}
if (input.unit === 'shares' || input.unit === 'count') {
return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
export function formatCurrency(value: string | number | null | undefined) { export function formatCurrency(value: string | number | null | undefined) {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',