Improve financial statement value formatting
This commit is contained in:
@@ -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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (unit) {
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +799,12 @@ 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="space-y-3">
|
||||||
|
{isStatementSurfaceKind(surfaceKind) ? (
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
||||||
|
USD · {FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? valueScale}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="data-table-wrap">
|
<div className="data-table-wrap">
|
||||||
<table className="data-table min-w-[980px]">
|
<table className="data-table min-w-[980px]">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -801,6 +870,7 @@ function FinancialsPageContent() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user