const DECIMAL_PART_COUNT = 2; const DEFAULT_MINOR_UNITS = 2; type FormatMoneyOptions = { withRoundedCents?: boolean; withSign?: boolean; withZeroCents?: boolean; }; const portfolioCategoryPresentation = { BOND: { color: "grape.6", label: "portfolio.bonds" }, CASH: { color: "green.6", label: "portfolio.structure.cash" }, OTHER: { color: "orange.6", label: "portfolio.structure.other" }, STOCK: { color: "blue.6", label: "portfolio.stocks" }, } as const; type Money = { currency: string; value: string }; const dateFormatter = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", timeZone: "UTC", year: "numeric", }); // ISO 4217 minor units (JPY 0, KWD 3); unknown codes fall back to 2. function currencyMinorUnits(currency: string): number { try { return new Intl.NumberFormat("en", { currency, style: "currency" }) .resolvedOptions().maximumFractionDigits ?? DEFAULT_MINOR_UNITS; } catch { return DEFAULT_MINOR_UNITS; } } function formatDate(value: string): string { return dateFormatter.format(new Date(value)); } function formatDecimal(value: string): string { const [integer = "", fraction] = value.split(".", DECIMAL_PART_COUNT); const sign = integer.startsWith("-") ? "-" : ""; const digits = sign ? integer.slice(1) : integer; const groupSize = 3; const groups: string[] = []; for (let end = digits.length; end > 0; end -= groupSize) { groups.push(digits.slice(Math.max(0, end - groupSize), end)); } const grouped = groups.toReversed().join(" "); const suffix = fraction ? `,${fraction}` : ""; return `${sign}${grouped}${suffix}`; } function formatMoney( money: Money | null | undefined, { withRoundedCents = false, withSign = false, withZeroCents = false, }: FormatMoneyOptions = {}, ): string { if (!money) return "—"; const minorUnits = currencyMinorUnits(money.currency); const value = withRoundedCents ? roundDecimal(money.value, minorUnits) : money.value; const [integerPart = "", fractionPart = ""] = value.split( ".", DECIMAL_PART_COUNT, ); const normalizedFraction = withRoundedCents || withZeroCents ? fractionPart.padEnd(minorUnits, "0") : fractionPart; const decimal = normalizedFraction ? `${integerPart}.${normalizedFraction}` : integerPart; const currency = money.currency === "RUB" ? "₽" : money.currency; const formatted = `${formatDecimal(decimal)} ${currency}`; if (!withSign) return formatted; if (isZeroDecimal(value)) return value.startsWith("-") ? formatted.slice(1) : formatted; return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`; } function isZeroDecimal(value: string) { const unsigned = value.startsWith("-") ? value.slice(1) : value; const [integer = "", fraction = ""] = unsigned.split( ".", DECIMAL_PART_COUNT, ); return integer.replaceAll("0", "") === "" && fraction.replaceAll("0", "") === ""; } function roundDecimal(value: string, places: number): string { const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT); const digits = integer.startsWith("-") ? integer.slice(1) : integer; const paddedFraction = fraction.padEnd(places + 1, "0"); const units = `${digits}${paddedFraction.slice(0, places)}`; const roundingDigit = paddedFraction.at(places) ?? "0"; const rounded = (roundingDigit >= "5" ? BigInt(units) + 1n : BigInt(units)) .toString() .padStart(places + 1, "0"); // A value rounded to zero has no sign: "-0.004" → "0.00". const sign = integer.startsWith("-") && /[1-9]/.test(rounded) ? "-" : ""; const roundedInteger = rounded.slice(0, rounded.length - places); const roundedFraction = rounded.slice(rounded.length - places); return roundedFraction ? `${sign}${roundedInteger}.${roundedFraction}` : `${sign}${roundedInteger}`; } // Без timeZone Intl берёт часовой пояс браузера пользователя. const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short", }); function formatSnapshotTime(value: string): string { return snapshotFormatter.format(new Date(value)); } const logoSize = 160; const chartFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }); function formatChartValue(value: number) { return chartFormatter.format(value); } function instrumentLogoUrl(logoName?: null | string): null | string { const stem = logoName?.trim().replace(/\.png$/i, ""); return stem ? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png` : null; } export { formatChartValue, formatDate, formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl, portfolioCategoryPresentation, };