"""Policy configuration for Metric Engine calculations."""
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Callable, Literal, Optional
[docs]
def default_quantizer_factory(decimal_places: int) -> Decimal:
"""Exact, fast quantizer for given dp: e.g., dp=2 -> Decimal('0.01')."""
return Decimal(1).scaleb(-decimal_places)
PercentDisplay = Literal["ratio", "percent"]
# Keep imports at module scope so tests can patch
from .units import Unit
[docs]
@dataclass(frozen=True)
class DisplayPolicy:
"""
Immutable configuration for locale-aware formatting.
This policy controls how financial values are formatted for display,
including currency symbols, number formatting, and locale-specific
conventions.
"""
# Locale/Currency
locale: str = "en_ZA" # BCP-47 or ICU id; e.g., "en_US", "fr_FR", "en_ZA"
currency: str = "ZAR" # ISO 4217 code (fallback if FV.unit is not Money)
currency_style: str = "standard" # "standard" | "accounting"
# Number formatting
use_grouping: bool = True
min_int: Optional[int] = None
min_frac: Optional[int] = None
max_frac: Optional[int] = None
compact: Optional[str] = None # None | "short" | "long" (e.g. 1.2K / 1.2 thousand)
# Percent formatting
percent_scale: str = "ratio" # "ratio" (0.15 -> 15%) | "unit" (15 -> 15%)
percent_style: str = (
"standard" # reserved: "standard" | "accounting" (same parens rules)
)
# Sign display
negative_parens: bool = (
False # force (1.23) instead of -1.23 even outside accounting
)
# Fallbacks
fallback_locale: str = "en_US" # used if locale invalid/unavailable
[docs]
@dataclass(frozen=True)
class Policy:
"""
Immutable configuration for financial calculations and formatting.
"""
decimal_places: int = 2
rounding: str = ROUND_HALF_UP
none_text: str = "—"
# Display preference for percents
percent_display: PercentDisplay = "percent"
cap_percentage_at: Optional[Decimal] = field(
default_factory=lambda: Decimal("99999.99")
)
# Legacy alias, kept for compatibility
percent_style: str = "percent"
quantizer_factory: Callable[[int], Decimal] = field(
default=default_quantizer_factory
)
# Behavior toggles
negative_sales_is_none: bool = True
compare_none_as_minus_infinity: bool = False
arithmetic_strict: bool = False
# Formatting options
thousands_sep: bool = True
currency_symbol: Optional[str] = None
currency_position: Literal["prefix", "suffix"] = "prefix"
negative_parentheses: bool = False
locale: Optional[str] = None
# Display policy for advanced formatting
display: Optional[DisplayPolicy] = None
def __post_init__(self):
if self.decimal_places < 0:
raise ValueError("decimal_places must be non-negative")
if self.cap_percentage_at is not None and self.cap_percentage_at < 0:
raise ValueError("cap_percentage_at must be non-negative")
if self.currency_symbol is not None and not self.currency_symbol.strip():
raise ValueError("currency_symbol must be non-empty or None")
# ---------- Helpers ----------
[docs]
def quantize(self, d: Decimal) -> Decimal:
"""
Quantize according to policy.
Supports arbitrary step sizes (e.g., 0.5) by rounding to nearest step.
"""
q = self.quantizer_factory(self.decimal_places)
# If quantizer is a power of ten, use normal quantize
expected_power_ten = Decimal(1).scaleb(-self.decimal_places)
if q == expected_power_ten or q.normalize() == expected_power_ten.normalize():
return d.quantize(q, rounding=self.rounding)
# For non power-of-ten quantizers:
# - Use step rounding only for coarse resolutions (dp <= 1), e.g., 0.5 at 1dp
# - Otherwise, fall back to standard dp quantization
try:
if self.decimal_places <= 1:
steps = (d / q).to_integral_value(rounding=self.rounding)
return (steps * q).quantize(q, rounding=self.rounding)
else:
ten_quant = expected_power_ten
return d.quantize(ten_quant, rounding=self.rounding)
except (InvalidOperation, ZeroDivisionError):
return d # fallback
# Default policy instance
DEFAULT_POLICY = Policy()