192 lines
5.2 KiB
TypeScript
192 lines
5.2 KiB
TypeScript
import type {
|
|
FinancialSurfaceKind,
|
|
FinancialUnit
|
|
} from './types';
|
|
|
|
export function asNumber(value: string | number | null | undefined) {
|
|
if (value === null || value === undefined) {
|
|
return 0;
|
|
}
|
|
|
|
const parsed = typeof value === 'number' ? value : Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
type NumberScale = {
|
|
divisor: number;
|
|
suffix: string;
|
|
};
|
|
|
|
export type NumberScaleUnit = 'thousands' | 'millions' | 'billions';
|
|
|
|
const NUMBER_SCALES: NumberScale[] = [
|
|
{ divisor: 1, suffix: '' },
|
|
{ divisor: 1_000, suffix: 'K' },
|
|
{ divisor: 1_000_000, suffix: 'M' },
|
|
{ divisor: 1_000_000_000, suffix: 'B' }
|
|
];
|
|
|
|
const NUMBER_SCALE_UNITS: Record<NumberScaleUnit, NumberScale> = {
|
|
thousands: { divisor: 1_000, suffix: 'K' },
|
|
millions: { divisor: 1_000_000, suffix: 'M' },
|
|
billions: { divisor: 1_000_000_000, suffix: 'B' }
|
|
};
|
|
|
|
type FormatScaledNumberOptions = {
|
|
minimumFractionDigits?: number;
|
|
maximumFractionDigits?: number;
|
|
};
|
|
|
|
type FormatScaledCurrencyOptions = {
|
|
minimumFractionDigits?: 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(
|
|
value: string | number | null | undefined,
|
|
options: FormatScaledNumberOptions = {}
|
|
) {
|
|
const {
|
|
minimumFractionDigits = 0,
|
|
maximumFractionDigits = 1
|
|
} = options;
|
|
|
|
const numeric = asNumber(value);
|
|
const absolute = Math.abs(numeric);
|
|
|
|
let scaleIndex = 0;
|
|
if (absolute >= 1_000_000_000) {
|
|
scaleIndex = 3;
|
|
} else if (absolute >= 1_000_000) {
|
|
scaleIndex = 2;
|
|
} else if (absolute >= 1_000) {
|
|
scaleIndex = 1;
|
|
}
|
|
|
|
let scaledAbsolute = absolute / NUMBER_SCALES[scaleIndex].divisor;
|
|
|
|
if (
|
|
Number(scaledAbsolute.toFixed(maximumFractionDigits)) >= 1_000
|
|
&& scaleIndex < NUMBER_SCALES.length - 1
|
|
) {
|
|
scaleIndex += 1;
|
|
scaledAbsolute = absolute / NUMBER_SCALES[scaleIndex].divisor;
|
|
}
|
|
|
|
const scaled = numeric < 0 ? -scaledAbsolute : scaledAbsolute;
|
|
const formatted = new Intl.NumberFormat('en-US', {
|
|
minimumFractionDigits,
|
|
maximumFractionDigits
|
|
}).format(scaled);
|
|
|
|
return `${formatted}${NUMBER_SCALES[scaleIndex].suffix}`;
|
|
}
|
|
|
|
export function formatCurrencyByScale(
|
|
value: string | number | null | undefined,
|
|
scale: NumberScaleUnit,
|
|
options: FormatScaledCurrencyOptions = {}
|
|
) {
|
|
const {
|
|
minimumFractionDigits = 0,
|
|
maximumFractionDigits = 1
|
|
} = options;
|
|
const { divisor, suffix } = NUMBER_SCALE_UNITS[scale];
|
|
const scaled = asNumber(value) / divisor;
|
|
|
|
const formatted = new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
minimumFractionDigits,
|
|
maximumFractionDigits
|
|
}).format(scaled);
|
|
|
|
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',
|
|
currency: 'USD',
|
|
maximumFractionDigits: 2
|
|
}).format(asNumber(value));
|
|
}
|
|
|
|
export function formatPercent(value: string | number | null | undefined) {
|
|
return `${asNumber(value).toFixed(2)}%`;
|
|
}
|
|
|
|
export function formatCompactCurrency(value: string | number | null | undefined) {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
notation: 'compact',
|
|
maximumFractionDigits: 2
|
|
}).format(asNumber(value));
|
|
}
|