Source code for ratingmodels.constraints
r"""Constraints applied to indicated rates before they become rate actions.
Indicated rates are rarely charged as-is. Common adjustments:
* **Caps / floors** on the rate change to limit renewal shock.
* **Banding** -- snapping small changes to zero, or to discrete steps.
* **Rounding** to a filed precision.
* **Corridors** -- limiting how far a rate may move over successive renewals.
Every function is elementwise under the vectorization contract: pass whole
columns of current and indicated rates and get a column of constrained rates
back, with the pandas index preserved.
"""
from __future__ import annotations
import numpy as np
from ._utils import (
Numeric,
first_series,
match_index,
maybe_float,
require_positive,
)
[docs]
def cap_change(
change: Numeric, cap: Numeric | None = None, floor: Numeric | None = None
) -> Numeric:
"""Clip a proportional rate change to ``[floor, cap]`` (either may be None).
``cap`` and ``floor`` may themselves be vectors for per-row limits.
"""
out = np.asarray(change, dtype=float)
if cap is not None:
out = np.minimum(out, np.asarray(cap, dtype=float))
if floor is not None:
out = np.maximum(out, np.asarray(floor, dtype=float))
template = first_series(change, cap, floor)
if out.ndim:
return match_index(out, template) if template is not None else out
return float(out[()])
[docs]
def apply_cap(
current_rate: Numeric,
indicated_rate: Numeric,
cap: Numeric | None = None,
floor: Numeric | None = None,
) -> Numeric:
"""Return the charged rate after capping the implied change, elementwise."""
current_rate = require_positive(current_rate, "current_rate")
change = indicated_rate / current_rate - 1.0
return maybe_float(current_rate * (1.0 + cap_change(change, cap, floor)))
[docs]
def band(change: Numeric, deadband: float = 0.0, step: float | None = None) -> Numeric:
"""Snap a change to zero within ``deadband``; optionally to ``step`` grid."""
arr = np.asarray(change, dtype=float)
out = np.where(np.abs(arr) <= deadband, 0.0, arr)
if step is not None and step > 0:
out = np.round(out / step) * step
return maybe_float(match_index(out, change) if out.ndim else out[()])
[docs]
def round_rate(rate: Numeric, ndigits: int = 2) -> Numeric:
"""Round a rate to a filed precision (default cents), elementwise."""
return maybe_float(np.round(rate, ndigits))
[docs]
def corridor(
current_rate: Numeric,
indicated_rate: Numeric,
max_up: float,
max_down: float,
) -> Numeric:
"""Limit a single renewal move to ``[-max_down, +max_up]`` proportionally."""
return apply_cap(current_rate, indicated_rate, cap=max_up, floor=-abs(max_down))