Source code for metricengine.calculations.ratios

"""
Ratio and percentage calculations.

This module contains calculations for ratios, percentages, and related mathematical operations.
All calculations use the Collection namespace for proper organization.
"""

from __future__ import annotations

from decimal import Decimal
from typing import TypeVar

from ..exceptions import CalculationError
from ..policy import DEFAULT_POLICY, Policy
from ..policy_context import get_policy
from ..registry_collections import Collection
from ..units import Percent, Ratio, Unit
from ..value import FV

U = TypeVar("U", bound=Unit)

ratios = Collection("ratios")

# ── small local helpers ──────────────────────────────────────────────────────


def _resolve_policy(*fvs: FV | None) -> Policy:
    for fv in fvs:
        if isinstance(fv, FV) and fv.policy:
            return fv.policy
    return get_policy() or DEFAULT_POLICY


def _none_with_unit(unit, pol: Policy) -> FV:
    return FV(None, policy=pol, unit=unit)


def _zero_with_unit(unit, pol: Policy) -> FV:
    return FV(Decimal("0"), policy=pol, unit=unit)


def _is_zero(fv: FV) -> bool:
    d = fv.as_decimal()
    return (d is not None) and (d == 0)


def _is_non_positive(fv: FV) -> bool:
    d = fv.as_decimal()
    return (d is not None) and (d <= 0)


def _ratio_with_policy(value: FV, pol: Policy) -> FV[Ratio]:
    """Create a ratio FinancialValue without polluting the policy with percent_style='ratio'."""
    return FV(value._value, policy=pol, unit=Ratio, _is_percentage=False)


# ── calculations ─────────────────────────────────────────────────────────────


[docs] @ratios.calc("ratio", depends_on=("numerator", "denominator")) def ratio( numerator: FV[U], denominator: FV[U], ) -> FV[Ratio]: """ Simple ratio = numerator / denominator. Default behavior: - If denominator == 0 → return None (unless policy.arithmetic_strict, then raise). - None inputs propagate to None. """ pol = _resolve_policy(numerator, denominator) if numerator.is_none() or denominator.is_none(): return _none_with_unit(Ratio, pol) if _is_zero(denominator): if pol.arithmetic_strict: raise CalculationError("Ratio undefined for denominator == 0") return _none_with_unit(Ratio, pol) # FV division handles units (Money/Money -> Ratio, etc.). # We explicitly create a ratio to normalize the unit to Ratio for the public API. return _ratio_with_policy(numerator / denominator, pol)
[docs] @ratios.calc("percentage_of_total", depends_on=("part", "total")) def percentage_of_total( part: FV[U], total: FV[U], ) -> FV[Percent]: """ Percentage of total = (part / total), returned as Percent. Business rule: if total <= 0 → return 0% (not None). """ pol = _resolve_policy(part, total) if part.is_none() or total.is_none(): return _none_with_unit(Percent, pol) # Business rule: non-positive total -> 0% if _is_non_positive(total): return _zero_with_unit(Ratio, pol).as_percentage() return _ratio_with_policy(part / total, pol).as_percentage()
[docs] @ratios.calc("ratio_to_percentage", depends_on=("ratio",)) def ratio_to_percentage(ratio: FV[Ratio]) -> FV[Percent]: """ Convert a ratio (0..1) to percent representation. """ pol = _resolve_policy(ratio) if ratio.is_none(): return _none_with_unit(Percent, pol) return ratio.as_percentage()
[docs] @ratios.calc("percentage_to_ratio", depends_on=("percentage",)) def percentage_to_ratio(percentage: FV[Percent]) -> FV[Ratio]: """ Convert a percent value to a ratio (e.g., 25% -> 0.25). """ pol = _resolve_policy(percentage) if percentage.is_none(): return _none_with_unit(Ratio, pol) return _ratio_with_policy(percentage, pol)
[docs] @ratios.calc("cap_percentage", depends_on=("percentage", "max_percentage")) def cap_percentage( percentage: FV[Percent], max_percentage: FV[Percent], ) -> FV[Percent]: """ Cap a percentage at a maximum value. Returns None if either input is None. """ pol = _resolve_policy(percentage, max_percentage) if percentage.is_none() or max_percentage.is_none(): return _none_with_unit(Percent, pol) # Compare by value; respect policy for any strict-mode differences elsewhere. # Using FV comparisons would also work, but we keep this explicit for clarity. p = percentage.as_decimal() m = max_percentage.as_decimal() if p is None or m is None: return _none_with_unit(Percent, pol) return percentage if p <= m else max_percentage