ratingmodels 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ r"""ratingmodels -- actuarial pricing and rate-indication tools.
2
+
3
+ A small, dependency-light toolkit for the group rating workflow: credibility,
4
+ trend, manual and experience rate construction, credibility blending, rate
5
+ indication, rate-change decomposition, GLM relativity estimation, and renewal
6
+ constraints. Part of the OpenActuarial ecosystem.
7
+
8
+ Quick start
9
+ -----------
10
+ >>> import ratingmodels as rm
11
+ >>> exp = rm.ExperienceRate(
12
+ ... incurred_claims=4_200_000, exposure=96_000,
13
+ ... trend_annual=0.075, trend_years=1.5,
14
+ ... pooled_excess=350_000, pooling_charge_pmpm=4.0,
15
+ ... target_loss_ratio=0.85,
16
+ ... )
17
+ >>> man = rm.ManualRate(base_pmpm=480, factors={"area": 1.05, "industry": 0.97})
18
+ >>> z = rm.limited_fluctuation_credibility(n=96_000, n_full=120_000)
19
+ >>> ind = rm.RateIndication(
20
+ ... experience_claims_pmpm=exp.claims_pmpm(),
21
+ ... manual_claims_pmpm=man.claims_pmpm(),
22
+ ... credibility=z, current_rate=560, target_loss_ratio=0.85,
23
+ ... trend_total_factor=exp.trend_factor(),
24
+ ... )
25
+ >>> round(ind.indicated_rate_change(), 4) # doctest: +SKIP
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from .base_rate import (
30
+ BaseRateResult,
31
+ average_relativity,
32
+ base_rate_from_experience,
33
+ off_balance_factor,
34
+ rebalance_base_rate,
35
+ )
36
+ from .blend import blend
37
+ from .buildup import (
38
+ BuildUp,
39
+ BuildUpResult,
40
+ Step,
41
+ add,
42
+ checkpoint,
43
+ combine_streams,
44
+ evaluate,
45
+ multiply,
46
+ participation_blend,
47
+ segment_multiply,
48
+ start,
49
+ )
50
+ from .constraints import apply_cap, band, cap_change, corridor, round_rate
51
+ from .credibility import (
52
+ BuhlmannStraubResult,
53
+ buhlmann_credibility,
54
+ buhlmann_straub,
55
+ full_credibility_standard,
56
+ limited_fluctuation_credibility,
57
+ )
58
+ from .decomposition import RateChangeDecomposition, decompose_rate_change
59
+ from .experience_rate import (
60
+ ExperienceRate,
61
+ expected_excess_charge,
62
+ pool_claims,
63
+ )
64
+ from .indication import RateIndication
65
+ from .loading import (
66
+ RetentionLoad,
67
+ gross_rate,
68
+ permissible_loss_ratio,
69
+ )
70
+ from .manual_rate import (
71
+ ManualRate,
72
+ aggregate_demographic_factor,
73
+ manual_pmpm,
74
+ )
75
+ from .relativity import (
76
+ FactorTable,
77
+ GLMRelativities,
78
+ one_way_relativities,
79
+ )
80
+ from .renewal import RenewalAction, member_level_renewal, renew
81
+ from .trend import (
82
+ apply_trend,
83
+ combine_trend,
84
+ period_midpoint,
85
+ split_total_trend,
86
+ trend_factor,
87
+ trend_factor_between,
88
+ years_between,
89
+ )
90
+
91
+ __version__ = "0.1.0"
92
+
93
+ __all__ = [
94
+ "__version__",
95
+ # credibility
96
+ "full_credibility_standard",
97
+ "limited_fluctuation_credibility",
98
+ "buhlmann_credibility",
99
+ "buhlmann_straub",
100
+ "BuhlmannStraubResult",
101
+ # trend
102
+ "trend_factor",
103
+ "trend_factor_between",
104
+ "apply_trend",
105
+ "combine_trend",
106
+ "split_total_trend",
107
+ "period_midpoint",
108
+ "years_between",
109
+ # relativity
110
+ "FactorTable",
111
+ "one_way_relativities",
112
+ "GLMRelativities",
113
+ # manual / experience
114
+ "ManualRate",
115
+ "manual_pmpm",
116
+ "aggregate_demographic_factor",
117
+ "ExperienceRate",
118
+ "pool_claims",
119
+ "expected_excess_charge",
120
+ # base rate / off-balance
121
+ "base_rate_from_experience",
122
+ "BaseRateResult",
123
+ "average_relativity",
124
+ "off_balance_factor",
125
+ "rebalance_base_rate",
126
+ # build-up engine
127
+ "Step",
128
+ "start",
129
+ "multiply",
130
+ "add",
131
+ "segment_multiply",
132
+ "checkpoint",
133
+ "evaluate",
134
+ "BuildUp",
135
+ "BuildUpResult",
136
+ "participation_blend",
137
+ "combine_streams",
138
+ # retention / loading
139
+ "RetentionLoad",
140
+ "gross_rate",
141
+ "permissible_loss_ratio",
142
+ # blend / indication
143
+ "blend",
144
+ "RateIndication",
145
+ # decomposition
146
+ "decompose_rate_change",
147
+ "RateChangeDecomposition",
148
+ # constraints / renewal
149
+ "cap_change",
150
+ "apply_cap",
151
+ "band",
152
+ "round_rate",
153
+ "corridor",
154
+ "renew",
155
+ "RenewalAction",
156
+ "member_level_renewal",
157
+ ]
ratingmodels/_utils.py ADDED
@@ -0,0 +1,53 @@
1
+ """Internal helpers: validation and small numeric utilities."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Iterable
5
+
6
+ import numpy as np
7
+
8
+
9
+ def as_float_array(x, name: str = "value") -> np.ndarray:
10
+ """Coerce to a 1-D float array, raising a clear error on bad input."""
11
+ arr = np.asarray(x, dtype=float)
12
+ if arr.ndim == 0:
13
+ arr = arr.reshape(1)
14
+ if arr.ndim != 1:
15
+ raise ValueError(f"{name} must be scalar or 1-D, got shape {arr.shape}")
16
+ if not np.all(np.isfinite(arr)):
17
+ raise ValueError(f"{name} contains non-finite values")
18
+ return arr
19
+
20
+
21
+ def require_positive(x: float, name: str) -> float:
22
+ x = float(x)
23
+ if not np.isfinite(x) or x <= 0:
24
+ raise ValueError(f"{name} must be a positive finite number, got {x!r}")
25
+ return x
26
+
27
+
28
+ def require_nonnegative(x: float, name: str) -> float:
29
+ x = float(x)
30
+ if not np.isfinite(x) or x < 0:
31
+ raise ValueError(f"{name} must be non-negative and finite, got {x!r}")
32
+ return x
33
+
34
+
35
+ def require_unit_interval(x: float, name: str, *, closed: bool = True) -> float:
36
+ x = float(x)
37
+ lo_ok = (x >= 0) if closed else (x > 0)
38
+ hi_ok = (x <= 1) if closed else (x < 1)
39
+ if not (np.isfinite(x) and lo_ok and hi_ok):
40
+ bound = "[0, 1]" if closed else "(0, 1)"
41
+ raise ValueError(f"{name} must lie in {bound}, got {x!r}")
42
+ return x
43
+
44
+
45
+ def product(values: Iterable[float]) -> float:
46
+ """Numerically stable product via logs when all positive, else direct."""
47
+ vals = list(values)
48
+ if not vals:
49
+ return 1.0
50
+ arr = np.asarray(vals, dtype=float)
51
+ if np.all(arr > 0):
52
+ return float(np.exp(np.sum(np.log(arr))))
53
+ return float(np.prod(arr))
@@ -0,0 +1,151 @@
1
+ r"""Base-rate construction from book experience, with off-balancing.
2
+
3
+ The base rate is the cost level for the reference cell (all relativities equal
4
+ 1). It is backed out from the book so that base times relativities reproduces
5
+ the book's losses. For risks :math:`i` with exposure :math:`e_i`, relativity
6
+ :math:`r_i = \prod_k f_{ki}`, and trended/developed loss :math:`L_i`:
7
+
8
+ .. math::
9
+ \bar r = \frac{\sum_i e_i r_i}{\sum_i e_i}, \qquad
10
+ B = \frac{\sum_i L_i}{\sum_i e_i r_i} = \frac{\bar L}{\bar r}.
11
+
12
+ By construction :math:`\sum_i e_i\, B r_i = \sum_i L_i`. When relativities are
13
+ revised, the average relativity moves and the overall premium level drifts
14
+ unless the base is **off-balanced**. Moving from average :math:`\bar r_0` to
15
+ :math:`\bar r_1`, with an intended overall change :math:`\Delta`:
16
+
17
+ .. math::
18
+ B_1 = B_0 \, \frac{\bar r_0}{\bar r_1} \, (1 + \Delta),
19
+
20
+ where :math:`\bar r_0 / \bar r_1` is the off-balance correction that holds the
21
+ book level neutral.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from typing import Sequence
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from ._utils import product, require_positive
32
+
33
+
34
+ @dataclass
35
+ class BaseRateResult:
36
+ """Result of :func:`base_rate_from_experience`."""
37
+
38
+ base_loss_cost: float
39
+ average_relativity: float
40
+ average_loss_cost: float
41
+ total_exposure: float
42
+
43
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
44
+ return (
45
+ f"BaseRateResult(base_loss_cost={self.base_loss_cost:.4f}, "
46
+ f"average_relativity={self.average_relativity:.4f}, "
47
+ f"average_loss_cost={self.average_loss_cost:.4f})"
48
+ )
49
+
50
+
51
+ def _relativity_vector(
52
+ data: pd.DataFrame,
53
+ relativity: str | None,
54
+ factor_cols: Sequence[str] | None,
55
+ ) -> np.ndarray:
56
+ if relativity is not None:
57
+ rel = data[relativity].to_numpy(dtype=float)
58
+ elif factor_cols:
59
+ rel = np.array(
60
+ [product(row[c] for c in factor_cols) for _, row in data.iterrows()],
61
+ dtype=float,
62
+ )
63
+ else:
64
+ raise ValueError("provide either `relativity` or `factor_cols`")
65
+ if np.any(rel <= 0):
66
+ raise ValueError("relativities must be positive")
67
+ return rel
68
+
69
+
70
+ def average_relativity(
71
+ data: pd.DataFrame,
72
+ exposure: str,
73
+ relativity: str | None = None,
74
+ factor_cols: Sequence[str] | None = None,
75
+ ) -> float:
76
+ r"""Exposure-weighted average relativity :math:`\bar r = \sum e_i r_i / \sum e_i`.
77
+
78
+ Supply relativities either as a single ``relativity`` column or as
79
+ ``factor_cols`` (per-row factors that are multiplied together).
80
+ """
81
+ e = data[exposure].to_numpy(dtype=float)
82
+ if np.any(e <= 0):
83
+ raise ValueError("exposures must be positive")
84
+ rel = _relativity_vector(data, relativity, factor_cols)
85
+ return float(np.sum(e * rel) / np.sum(e))
86
+
87
+
88
+ def base_rate_from_experience(
89
+ data: pd.DataFrame,
90
+ exposure: str,
91
+ loss: str,
92
+ relativity: str | None = None,
93
+ factor_cols: Sequence[str] | None = None,
94
+ ) -> BaseRateResult:
95
+ r"""Indicated base loss cost from book experience (off-balance method).
96
+
97
+ Returns :math:`B = \sum_i L_i / \sum_i e_i r_i` together with the average
98
+ relativity and average loss cost. Gross ``base_loss_cost`` to a charged base
99
+ rate with a :class:`ratingmodels.RetentionLoad`.
100
+
101
+ Parameters
102
+ ----------
103
+ data : DataFrame
104
+ One row per risk or rating cell.
105
+ exposure, loss : str
106
+ Column names for exposure (e.g. member-months) and trended/developed
107
+ loss.
108
+ relativity : str, optional
109
+ Column of precomputed relativities :math:`r_i`.
110
+ factor_cols : sequence of str, optional
111
+ Columns of individual rating factors to multiply into :math:`r_i`
112
+ (used when ``relativity`` is not supplied).
113
+ """
114
+ e = data[exposure].to_numpy(dtype=float)
115
+ if np.any(e <= 0):
116
+ raise ValueError("exposures must be positive")
117
+ losses = data[loss].to_numpy(dtype=float)
118
+ rel = _relativity_vector(data, relativity, factor_cols)
119
+
120
+ total_exposure = float(np.sum(e))
121
+ exposure_weighted_rel = float(np.sum(e * rel)) # = sum(e_i r_i)
122
+ base = float(np.sum(losses) / exposure_weighted_rel)
123
+ return BaseRateResult(
124
+ base_loss_cost=base,
125
+ average_relativity=exposure_weighted_rel / total_exposure,
126
+ average_loss_cost=float(np.sum(losses) / total_exposure),
127
+ total_exposure=total_exposure,
128
+ )
129
+
130
+
131
+ def off_balance_factor(current_avg_relativity: float, new_avg_relativity: float) -> float:
132
+ r"""Off-balance correction :math:`\bar r_0 / \bar r_1` from revising relativities."""
133
+ require_positive(current_avg_relativity, "current_avg_relativity")
134
+ require_positive(new_avg_relativity, "new_avg_relativity")
135
+ return current_avg_relativity / new_avg_relativity
136
+
137
+
138
+ def rebalance_base_rate(
139
+ current_base: float,
140
+ current_avg_relativity: float,
141
+ new_avg_relativity: float,
142
+ overall_change: float = 0.0,
143
+ ) -> float:
144
+ r"""Off-balanced new base rate :math:`B_1 = B_0 (\bar r_0/\bar r_1)(1+\Delta)`.
145
+
146
+ Holds the overall premium level neutral when relativities change, then
147
+ applies the intended overall rate change ``overall_change`` (:math:`\Delta`).
148
+ """
149
+ require_positive(current_base, "current_base")
150
+ factor = off_balance_factor(current_avg_relativity, new_avg_relativity)
151
+ return current_base * factor * (1.0 + overall_change)
ratingmodels/blend.py ADDED
@@ -0,0 +1,20 @@
1
+ r"""Credibility blending of experience and manual quantities.
2
+
3
+ .. math::
4
+ \text{blended} = Z \cdot \text{experience} + (1 - Z)\cdot \text{manual}.
5
+
6
+ This is the atomic credibility-weighting operation; it delegates to
7
+ :func:`actuarialpy.credibility_weighted_estimate` so the primitive lives in one
8
+ place across the ecosystem.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import actuarialpy as ap
13
+
14
+ from ._utils import require_unit_interval
15
+
16
+
17
+ def blend(experience: float, manual: float, credibility: float) -> float:
18
+ r""":math:`Z \cdot \text{experience} + (1-Z)\cdot \text{manual}`."""
19
+ z = require_unit_interval(credibility, "credibility")
20
+ return ap.credibility_weighted_estimate(experience, manual, z)
@@ -0,0 +1,253 @@
1
+ r"""Ordered rate build-up with an audit trail.
2
+
3
+ Every group rate is assembled the same way: start from a base claim cost and
4
+ apply an ordered sequence of operations -- multiply by a relativity, add or
5
+ subtract a dollar amount (a copay credit, a per-member fee), apply a factor to
6
+ only a segment of the cost -- recording labeled subtotals along the way, then
7
+ combine streams (in-/out-of-network by participation, medical + drug). This
8
+ module provides that grammar; it ships **no factor values**. The numbers are
9
+ yours (filed tables, state amounts, vendor fees); the engine just applies them
10
+ and produces a reconciling, auditable breakdown.
11
+
12
+ Operations
13
+ ----------
14
+ * ``start(label, value)`` -- set the running total.
15
+ * ``multiply(label, factor)`` -- ``running *= factor`` (a relativity / trend).
16
+ * ``add(label, amount)`` -- ``running += amount`` (copay credit < 0, fee > 0).
17
+ * ``segment_multiply(label, factor, weight)`` -- apply ``factor`` to a fraction
18
+ ``weight`` of the running total:
19
+ :math:`\text{running} \leftarrow \text{running}\,(1 - w + w f)`.
20
+ * ``checkpoint(label)`` -- record a labeled subtotal; total unchanged.
21
+
22
+ Combining streams
23
+ -----------------
24
+ * :func:`participation_blend` -- :math:`\text{par}\,p + \text{nonpar}\,(1-p)`.
25
+ * :func:`combine_streams` -- additive combine (e.g. medical + drug).
26
+
27
+ Both return a :class:`BuildUpResult`, so intermediate results carry their own
28
+ breakdown and can be fed into credibility, trend, and retention.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass, field
33
+ from typing import Mapping, Sequence, Union
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+
38
+ _BREAKDOWN_COLUMNS = ["step", "operation", "label", "operand", "running_total"]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Step:
43
+ """A single build-up operation. ``operand`` is the factor (multiply /
44
+ segment), amount (add), or value (start); ``weight`` is used by
45
+ ``segment_multiply`` only."""
46
+
47
+ op: str
48
+ label: str
49
+ operand: float = 1.0
50
+ weight: float = 1.0
51
+
52
+
53
+ def start(label: str, value: float) -> Step:
54
+ """Set the running total to ``value`` (normally the first step)."""
55
+ return Step("start", label, float(value))
56
+
57
+
58
+ def multiply(label: str, factor: float) -> Step:
59
+ """Multiply the running total by ``factor`` (a relativity or trend)."""
60
+ return Step("multiply", label, float(factor))
61
+
62
+
63
+ def add(label: str, amount: float) -> Step:
64
+ """Add ``amount`` to the running total (negative for a copay credit)."""
65
+ return Step("add", label, float(amount))
66
+
67
+
68
+ def segment_multiply(label: str, factor: float, weight: float) -> Step:
69
+ r"""Apply ``factor`` to a fraction ``weight`` of the running total.
70
+
71
+ :math:`\text{running} \leftarrow \text{running}\,(1 - w + w f)`.
72
+ """
73
+ if not 0.0 <= weight <= 1.0:
74
+ raise ValueError("weight must lie in [0, 1]")
75
+ return Step("segment_multiply", label, float(factor), float(weight))
76
+
77
+
78
+ def checkpoint(label: str) -> Step:
79
+ """Record a labeled subtotal without changing the running total."""
80
+ return Step("checkpoint", label, float("nan"))
81
+
82
+
83
+ @dataclass
84
+ class BuildUpResult:
85
+ """Result of evaluating a build-up.
86
+
87
+ Attributes
88
+ ----------
89
+ value : float
90
+ Final running total.
91
+ breakdown : pandas.DataFrame
92
+ One row per step: ``step, operation, label, operand, running_total``.
93
+ For ``segment_multiply`` the ``operand`` shown is the *effective*
94
+ factor :math:`(1 - w + w f)`, so the column reconciles by multiplication.
95
+ subtotals : dict
96
+ Ordered mapping of checkpoint label -> running total at that point.
97
+ steps : list[Step]
98
+ The raw steps (nominal factor and weight preserved).
99
+ """
100
+
101
+ value: float
102
+ breakdown: pd.DataFrame
103
+ subtotals: dict
104
+ steps: list = field(default_factory=list, repr=False)
105
+
106
+ def subtotal(self, label: str) -> float:
107
+ """Running total recorded at the named checkpoint."""
108
+ if label not in self.subtotals:
109
+ raise KeyError(f"no checkpoint labeled {label!r}")
110
+ return self.subtotals[label]
111
+
112
+ def to_frame(self) -> pd.DataFrame:
113
+ return self.breakdown
114
+
115
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
116
+ return f"BuildUpResult(value={self.value:.4f}, steps={len(self.steps)})"
117
+
118
+
119
+ def evaluate(steps: Sequence[Step]) -> BuildUpResult:
120
+ """Run an ordered sequence of :class:`Step` and return a :class:`BuildUpResult`.
121
+
122
+ The running total starts at 0; a leading :func:`start` sets the base.
123
+ """
124
+ running = 0.0
125
+ rows = []
126
+ subtotals: dict = {}
127
+ for i, s in enumerate(steps, start=1):
128
+ if s.op == "start":
129
+ running = s.operand
130
+ shown = s.operand
131
+ elif s.op == "multiply":
132
+ running *= s.operand
133
+ shown = s.operand
134
+ elif s.op == "add":
135
+ running += s.operand
136
+ shown = s.operand
137
+ elif s.op == "segment_multiply":
138
+ effective = 1.0 - s.weight + s.weight * s.operand
139
+ running *= effective
140
+ shown = effective
141
+ elif s.op == "checkpoint":
142
+ subtotals[s.label] = running
143
+ shown = np.nan
144
+ else:
145
+ raise ValueError(f"unknown operation {s.op!r}")
146
+ rows.append(
147
+ {
148
+ "step": i,
149
+ "operation": s.op,
150
+ "label": s.label,
151
+ "operand": shown,
152
+ "running_total": running,
153
+ }
154
+ )
155
+ breakdown = pd.DataFrame(rows, columns=_BREAKDOWN_COLUMNS)
156
+ return BuildUpResult(
157
+ value=float(running),
158
+ breakdown=breakdown,
159
+ subtotals=subtotals,
160
+ steps=list(steps),
161
+ )
162
+
163
+
164
+ class BuildUp:
165
+ """Fluent builder for a build-up; sugar over a list of :class:`Step`.
166
+
167
+ >>> r = (BuildUp()
168
+ ... .start("Par Base", 941.63)
169
+ ... .add("$30 specialist copay", -11.44)
170
+ ... .multiply("Rating Region", 1.083)
171
+ ... .checkpoint("Medical Par Base Claim Cost")
172
+ ... .evaluate())
173
+ """
174
+
175
+ def __init__(self) -> None:
176
+ self._steps: list[Step] = []
177
+
178
+ def start(self, label: str, value: float) -> "BuildUp":
179
+ self._steps.append(start(label, value))
180
+ return self
181
+
182
+ def multiply(self, label: str, factor: float) -> "BuildUp":
183
+ self._steps.append(multiply(label, factor))
184
+ return self
185
+
186
+ def add(self, label: str, amount: float) -> "BuildUp":
187
+ self._steps.append(add(label, amount))
188
+ return self
189
+
190
+ def segment_multiply(self, label: str, factor: float, weight: float) -> "BuildUp":
191
+ self._steps.append(segment_multiply(label, factor, weight))
192
+ return self
193
+
194
+ def checkpoint(self, label: str) -> "BuildUp":
195
+ self._steps.append(checkpoint(label))
196
+ return self
197
+
198
+ def steps(self) -> list:
199
+ return list(self._steps)
200
+
201
+ def evaluate(self) -> BuildUpResult:
202
+ return evaluate(self._steps)
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # combining streams
207
+ # --------------------------------------------------------------------------- #
208
+ ValueLike = Union[BuildUpResult, float]
209
+
210
+
211
+ def _val(x: ValueLike) -> float:
212
+ return float(x.value) if isinstance(x, BuildUpResult) else float(x)
213
+
214
+
215
+ def combine_streams(
216
+ streams: Mapping[str, ValueLike],
217
+ label: str = "Combined",
218
+ ) -> BuildUpResult:
219
+ """Additively combine named streams (e.g. ``{"Medical": ..., "Drug": ...}``).
220
+
221
+ Implemented as a build-up (start + adds) so the result carries a running
222
+ total and an audit trail.
223
+ """
224
+ items = list(streams.items())
225
+ if not items:
226
+ raise ValueError("provide at least one stream")
227
+ steps = [start(items[0][0], _val(items[0][1]))]
228
+ for lab, v in items[1:]:
229
+ steps.append(add(lab, _val(v)))
230
+ steps.append(checkpoint(label))
231
+ return evaluate(steps)
232
+
233
+
234
+ def participation_blend(
235
+ par: ValueLike,
236
+ nonpar: ValueLike,
237
+ participation_rate: float,
238
+ label: str = "PPO Claim Cost",
239
+ ) -> BuildUpResult:
240
+ r"""In-/out-of-network blend :math:`\text{par}\,p + \text{nonpar}\,(1-p)`.
241
+
242
+ ``participation_rate`` is the in-network (par) share ``p``.
243
+ """
244
+ if not 0.0 <= participation_rate <= 1.0:
245
+ raise ValueError("participation_rate must lie in [0, 1]")
246
+ p = participation_rate
247
+ return combine_streams(
248
+ {
249
+ f"Par x {p:.1%}": _val(par) * p,
250
+ f"Non-Par x {1 - p:.1%}": _val(nonpar) * (1 - p),
251
+ },
252
+ label=label,
253
+ )
@@ -0,0 +1,58 @@
1
+ r"""Constraints applied to indicated rates before they become rate actions.
2
+
3
+ Indicated rates are rarely charged as-is. Common adjustments:
4
+
5
+ * **Caps / floors** on the rate change to limit renewal shock.
6
+ * **Banding** -- snapping small changes to zero, or to discrete steps.
7
+ * **Rounding** to a filed precision.
8
+ * **Corridors** -- limiting how far a rate may move over successive renewals.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+
15
+ def cap_change(change: float, cap: float | None = None, floor: float | None = None) -> float:
16
+ """Clip a proportional rate change to ``[floor, cap]`` (either may be None)."""
17
+ out = float(change)
18
+ if cap is not None:
19
+ out = min(out, float(cap))
20
+ if floor is not None:
21
+ out = max(out, float(floor))
22
+ return out
23
+
24
+
25
+ def apply_cap(
26
+ current_rate: float,
27
+ indicated_rate: float,
28
+ cap: float | None = None,
29
+ floor: float | None = None,
30
+ ) -> float:
31
+ """Return the charged rate after capping the implied change."""
32
+ if current_rate <= 0:
33
+ raise ValueError("current_rate must be positive")
34
+ change = indicated_rate / current_rate - 1.0
35
+ return current_rate * (1.0 + cap_change(change, cap, floor))
36
+
37
+
38
+ def band(change: float, deadband: float = 0.0, step: float | None = None) -> float:
39
+ """Snap a change to zero within ``deadband``; optionally to ``step`` grid."""
40
+ out = 0.0 if abs(change) <= deadband else float(change)
41
+ if step is not None and step > 0:
42
+ out = round(out / step) * step
43
+ return out
44
+
45
+
46
+ def round_rate(rate: float, ndigits: int = 2) -> float:
47
+ """Round a rate to a filed precision (default cents)."""
48
+ return float(np.round(rate, ndigits))
49
+
50
+
51
+ def corridor(
52
+ current_rate: float,
53
+ indicated_rate: float,
54
+ max_up: float,
55
+ max_down: float,
56
+ ) -> float:
57
+ """Limit a single renewal move to ``[-max_down, +max_up]`` proportionally."""
58
+ return apply_cap(current_rate, indicated_rate, cap=max_up, floor=-abs(max_down))