Improve financial statement value formatting
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { formatCurrencyByScale, formatScaledNumber } from './format';
|
||||
import {
|
||||
formatCurrencyByScale,
|
||||
formatFinancialStatementValue,
|
||||
formatScaledNumber
|
||||
} from './format';
|
||||
|
||||
describe('formatScaledNumber', () => {
|
||||
it('keeps values below one thousand unscaled', () => {
|
||||
@@ -45,3 +49,59 @@ describe('formatCurrencyByScale', () => {
|
||||
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) {
|
||||
if (value === null || value === undefined) {
|
||||
return 0;
|
||||
@@ -37,6 +42,37 @@ type FormatScaledCurrencyOptions = {
|
||||
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(
|
||||
value: string | number | null | undefined,
|
||||
options: FormatScaledNumberOptions = {}
|
||||
@@ -99,6 +135,40 @@ export function formatCurrencyByScale(
|
||||
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) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
|
||||
Reference in New Issue
Block a user