Source code for moneyx.rounding

# moneyx/rounding.py
"""
Rounding utilities for Money objects.

This module provides functionality for rounding decimal values according to
different rounding strategies.

Available rounding modes:
    - HALF_UP: Classic rounding. If the fractional part is >= 0.5, rounds up.
    - HALF_DOWN: If the fractional part is > 0.5, rounds up; if it's exactly 0.5,
      rounds down.
    - BANKERS (HALF_EVEN): Banker's rounding. Rounds to the nearest even number
      when exactly 0.5.
    - HALF_ODD: Rounds to the nearest odd number when exactly 0.5.
    - DOWN: Always truncates (towards zero).
    - UP: Always rounds away from zero.
    - CEILING: Always rounds towards positive infinity.
    - FLOOR: Always rounds towards negative infinity.
    - HALF_TOWARDS_ZERO: If exactly 0.5, rounds towards zero.
    - HALF_AWAY_FROM_ZERO: If exactly 0.5, rounds away from zero.
"""

from decimal import (
    ROUND_CEILING,
    ROUND_DOWN,
    ROUND_FLOOR,
    ROUND_HALF_DOWN,
    ROUND_HALF_EVEN,
    ROUND_HALF_UP,
    ROUND_UP,
    Decimal,
)
from typing import Literal

# Define RoundingModeStr type for mypy
RoundingModeStr = Literal[
    "HALF_UP",
    "HALF_DOWN",
    "BANKERS",
    "DOWN",
    "UP",
    "CEILING",
    "FLOOR",
    "HALF_ODD",
    "HALF_TOWARDS_ZERO",
    "HALF_AWAY_FROM_ZERO",
]


[docs] class RoundingMode: """ Constants for various rounding modes. """ # Standard modes HALF_UP: RoundingModeStr = "HALF_UP" # Classic rounding (if fractional part >= 0.5, # rounds up) HALF_DOWN: RoundingModeStr = ( "HALF_DOWN" # If fractional part > 0.5, rounds up, otherwise down ) BANKERS: RoundingModeStr = "BANKERS" # Banker's rounding (round half to even) HALF_EVEN: RoundingModeStr = "BANKERS" # Alias for BANKERS DOWN: RoundingModeStr = "DOWN" # Always rounds towards zero (truncation) UP: RoundingModeStr = "UP" # Always rounds away from zero CEILING: RoundingModeStr = "CEILING" # Always rounds towards positive infinity FLOOR: RoundingModeStr = "FLOOR" # Always rounds towards negative infinity # Extended modes HALF_ODD: RoundingModeStr = "HALF_ODD" # Round half to odd HALF_TOWARDS_ZERO: RoundingModeStr = ( "HALF_TOWARDS_ZERO" # If exactly 0.5, round towards zero ) HALF_AWAY_FROM_ZERO: RoundingModeStr = ( "HALF_AWAY_FROM_ZERO" # If exactly 0.5, round away from zero )
# Mapping of rounding modes to decimal module rounding constants # Used for modes that have direct Decimal equivalents _DECIMAL_ROUNDING_MAP = { RoundingMode.HALF_UP: ROUND_HALF_UP, RoundingMode.HALF_DOWN: ROUND_HALF_DOWN, RoundingMode.BANKERS: ROUND_HALF_EVEN, RoundingMode.DOWN: ROUND_DOWN, RoundingMode.UP: ROUND_UP, RoundingMode.CEILING: ROUND_CEILING, RoundingMode.FLOOR: ROUND_FLOOR, } def _apply_half_odd(value: Decimal, places: int) -> Decimal: """ Apply half-odd rounding. If the value is exactly half, round to the nearest odd number. Otherwise, round like HALF_UP. Args: value: The Decimal to round places: Number of decimal places to round to Returns: Rounded Decimal value """ # Shift decimal point to focus on the digit after the target place shifted = value * Decimal(10) ** places # Get the integer and fractional parts int_part = int(shifted) frac_part = shifted - int_part # Check if we're exactly at 0.5 if abs(frac_part) == Decimal("0.5"): # Round to nearest odd number if int_part % 2 == 0: # If even return Decimal(int_part + (1 if value >= 0 else -1)) / Decimal(10) ** places else: # Already odd return Decimal(int_part) / Decimal(10) ** places else: # For non-half cases, use HALF_UP return value.quantize(Decimal("0.1") ** places, rounding=ROUND_HALF_UP) def _apply_half_towards_zero(value: Decimal, places: int) -> Decimal: """ Apply half-towards-zero rounding. If the value is exactly half, round towards zero. Otherwise, round like HALF_UP. Args: value: The Decimal to round places: Number of decimal places to round to Returns: Rounded Decimal value """ # Shift decimal point to focus on the digit after the target place shifted = value * Decimal(10) ** places # Get the integer and fractional parts int_part = int(shifted) frac_part = shifted - int_part # Check if we're exactly at 0.5 if abs(frac_part) == Decimal("0.5"): # Round towards zero return Decimal(int_part) / Decimal(10) ** places else: # For non-half cases, use HALF_UP return value.quantize(Decimal("0.1") ** places, rounding=ROUND_HALF_UP) def _apply_half_away_from_zero(value: Decimal, places: int) -> Decimal: """ Apply half-away-from-zero rounding. If the value is exactly half, round away from zero. Otherwise, round like HALF_UP. Args: value: The Decimal to round places: Number of decimal places to round to Returns: Rounded Decimal value """ # Shift decimal point to focus on the digit after the target place shifted = value * Decimal(10) ** places # Get the integer and fractional parts int_part = int(shifted) frac_part = shifted - int_part # Check if we're exactly at 0.5 if abs(frac_part) == Decimal("0.5"): # Round away from zero return Decimal(int_part + (1 if value >= 0 else -1)) / Decimal(10) ** places else: # For non-half cases, use HALF_UP return value.quantize(Decimal("0.1") ** places, rounding=ROUND_HALF_UP) def apply_rounding(value: Decimal, mode: RoundingModeStr, places: int) -> Decimal: """ Round a Decimal value according to the specified rounding mode. Args: value: The Decimal value to round mode: The rounding mode to use, one of the RoundingMode constants places: Number of decimal places to round to Returns: Rounded Decimal value Raises: ValueError: If the rounding mode is not recognized Examples: apply_rounding(Decimal('2.5'), RoundingMode.HALF_UP, 0) -> Decimal('3') apply_rounding(Decimal('2.5'), RoundingMode.BANKERS, 0) -> Decimal('2') apply_rounding(Decimal('-2.5'), RoundingMode.HALF_TOWARDS_ZERO, 0) -> Decimal('-2') apply_rounding(Decimal('1.234'), RoundingMode.UP, 2) -> Decimal('1.24') """ # If the value is already correctly rounded, just return it exponent = value.as_tuple().exponent # Handle special cases with Decimal exponents if isinstance(exponent, int) and ( exponent == -places or (exponent >= 0 and places == 0) ): return value # Handle the extended rounding modes if mode == RoundingMode.HALF_ODD: return _apply_half_odd(value, places) elif mode == RoundingMode.HALF_TOWARDS_ZERO: return _apply_half_towards_zero(value, places) elif mode == RoundingMode.HALF_AWAY_FROM_ZERO: return _apply_half_away_from_zero(value, places) # Handle the standard modes that map directly to Decimal rounding if mode in _DECIMAL_ROUNDING_MAP: return value.quantize( Decimal("0.1") ** places, rounding=_DECIMAL_ROUNDING_MAP[mode], ) # If we get here, the mode is not recognized raise ValueError(f"Unknown rounding mode: {mode}")