Files
Neon-Desk/lib/format.ts

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));
}