from decimal import Decimal, ROUND_HALF_UP from pydantic import BaseModel, Field, field_validator CURRENCY_SYMBOLS = { "RUB": "₽", "USD": "$", "EUR": "€", "GBP": "£", "CNY": "¥", "JPY": "¥", "KZT": "₸", "UAH": "₴", } # ISO 4217 minor units per currency (https://en.wikipedia.org/wiki/ISO_4217). # Keys are uppercase 3-letter codes, values are the number of decimal digits used # when DISPLAYING the currency. Internal arithmetic keeps full Decimal precision # (do not modify — see T8 decision `internal_precision`). # # - 0 decimals: JPY (Japanese yen) # - 2 decimals: most currencies (RUB, USD, EUR, GBP, CNY, KZT, UAH, ...) # - 3 decimals: BHD (Bahraini dinar), KWD (Kuwaiti dinar), OMR (Omani rial) # # Unknown currencies fall back to `DEFAULT_CURRENCY_DECIMALS` (2). CURRENCY_DECIMALS: dict[str, int] = { "JPY": 0, "RUB": 2, "USD": 2, "EUR": 2, "GBP": 2, "CNY": 2, "KZT": 2, "UAH": 2, "BHD": 3, "KWD": 3, "OMR": 3, } DEFAULT_CURRENCY_DECIMALS = 2 class ExchangeRate(BaseModel): from_currency: str = "USD" to_currency: str = "RUB" rate: Decimal = Field(default=Decimal("80")) @field_validator("rate") @classmethod def _rate_positive(cls, v: Decimal) -> Decimal: if v <= 0: raise ValueError("rate must be positive") return v DEFAULT_RATES: list[ExchangeRate] = [ ExchangeRate(from_currency="USD", to_currency="RUB", rate=Decimal("80")), ] class CurrencyError(Exception): pass class CurrencyConverter: def __init__(self, rates: list[ExchangeRate] | None = None): self._rates: dict[tuple[str, str], Decimal] = {} if rates: for r in rates: self.set_rate(r.from_currency, r.to_currency, r.rate) def set_rate( self, from_currency: str, to_currency: str, rate: Decimal | float | int | str, ) -> None: d = Decimal(str(rate)) if not isinstance(rate, Decimal) else rate if d <= 0: raise CurrencyError(f"Rate must be positive: {d}") self._rates[(from_currency, to_currency)] = d self._rates[(to_currency, from_currency)] = Decimal("1") / d def get_rate(self, from_currency: str, to_currency: str) -> Decimal: if from_currency == to_currency: return Decimal("1") try: return self._rates[(from_currency, to_currency)] except KeyError: raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}") def convert( self, amount: Decimal | float | int | str, from_currency: str, to_currency: str, ) -> Decimal: a = Decimal(str(amount)) if not isinstance(amount, Decimal) else amount if from_currency == to_currency: return a rate = self.get_rate(from_currency, to_currency) return (a * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) def get_symbol(self, currency: str) -> str: return CURRENCY_SYMBOLS.get(currency, currency) @staticmethod def format_money( amount: Decimal | float | int | str, currency: str, ) -> str: """Format `amount` for display using ISO 4217 decimal digits for `currency`. Rounding: ROUND_HALF_UP (0.5 -> 1) before display — internal arithmetic in Decimal is unchanged. Known symbols are rendered as a tight prefix (e.g. "$1,234.57"); unknown codes are rendered as a spaced prefix ("BHD 1.234") so the layout is consistent regardless of symbol availability. Negative values render with the sign before the symbol ("-₽1,200.00"). """ cur = currency.upper() decimals = CURRENCY_DECIMALS.get(cur, DEFAULT_CURRENCY_DECIMALS) a = Decimal(str(amount)) if not isinstance(amount, Decimal) else amount quantizer = Decimal("1") if decimals == 0 else Decimal(1).scaleb(-decimals) quantized = a.quantize(quantizer, rounding=ROUND_HALF_UP) sign = "-" if quantized < 0 else "" abs_quantized = -quantized if quantized < 0 else quantized num_str = f"{abs_quantized:,.{decimals}f}" if cur in CURRENCY_SYMBOLS: return f"{sign}{CURRENCY_SYMBOLS[cur]}{num_str}" return f"{sign}{cur} {num_str}" @classmethod def with_defaults(cls) -> "CurrencyConverter": return cls(DEFAULT_RATES)