from typing import List, Mapping, Optional, Sequence from .insurance_rating_factors_types import AppliedFactor, FactorTable, PremiumAdjustment, RatedPremium from .math_round_div import RoundingMode from .money_amount import Money, assert_same_currency, money def _is_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def _round_quotient(numerator: int, denominator: int, mode: str) -> int: # Both are non-negative here: a base of 0 or more times multipliers of 0 or more. q, r = divmod(numerator, denominator) twice = r * 2 if mode == "down": return q if mode == "up": return q + 1 if twice > 0 else q if mode == "half-up": return q + 1 if twice >= denominator else q if mode == "half-even": return q + 1 if twice > denominator or (twice == denominator and q % 2 == 1) else q raise ValueError('unknown rounding mode "%s"' % (mode,)) def rate_premium( base_premium: Money, tables: Sequence[FactorTable], risk: Mapping[str, str], adjustments: Sequence[PremiumAdjustment], minimum_premium: Optional[Money], maximum_premium: Optional[Money], mode: RoundingMode, ) -> RatedPremium: """Rate a premium: the base times each table's multiplier for the risk, then each loading or discount, with one rounding at the end, then held within the minimum and maximum premiums. Rounding after every factor drifts: 1.01 at 1.005 three times is 1.03 exactly but 1.04 rounded at each step. Python integers are exact at any size, so there is no limit on the number of factors. """ if base_premium.minor < 0: raise ValueError("basePremium must not be negative, received %d" % (base_premium.minor,)) if minimum_premium is not None: assert_same_currency(base_premium, minimum_premium) if maximum_premium is not None: assert_same_currency(base_premium, maximum_premium) if minimum_premium is not None and maximum_premium is not None and minimum_premium.minor > maximum_premium.minor: raise ValueError("minimumPremium must not be more than maximumPremium") factors: List[AppliedFactor] = [] names = set() for table in tables: if table.name in names: raise ValueError('rating factor "%s" appears twice' % (table.name,)) names.add(table.name) if table.name not in risk: raise ValueError('no value for rating factor "%s"' % (table.name,)) key = risk[table.name] found = None for row in table.rows: if row.key == key: if found is not None: raise ValueError('rating factor "%s" has two rows for "%s"' % (table.name, key)) if not _is_int(row.basis_points) or row.basis_points < 0: raise ValueError( "factor basisPoints must be a whole number of 0 or more, received %s" % (row.basis_points,) ) found = row.basis_points if found is None: raise ValueError('rating factor "%s" has no row for "%s"' % (table.name, key)) factors.append(AppliedFactor(name=table.name, basis_points=found)) for name in risk: if name not in names: raise ValueError('risk has a value for "%s", which no table rates' % (name,)) for adjustment in adjustments: bp = adjustment.basis_points if not _is_int(bp) or bp < -10000: raise ValueError("adjustment basisPoints must be a whole number of -10000 or more, received %s" % (bp,)) factors.append(AppliedFactor(name=adjustment.name, basis_points=10000 + bp)) numerator = base_premium.minor denominator = 1 for f in factors: numerator *= f.basis_points denominator *= 10000 c = base_premium.currency calculated = money(_round_quotient(numerator, denominator, mode), c) premium = calculated cap = "none" if minimum_premium is not None and premium.minor < minimum_premium.minor: premium = minimum_premium cap = "minimum" elif maximum_premium is not None and premium.minor > maximum_premium.minor: premium = maximum_premium cap = "maximum" return RatedPremium(factors=factors, calculated=calculated, premium=premium, cap=cap)