aggregate_api 1.0.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.
- aggregate_api/__init__.py +41 -0
- aggregate_api/__main__.py +154 -0
- aggregate_api/app.py +206 -0
- aggregate_api/audit.py +395 -0
- aggregate_api/bounds.py +331 -0
- aggregate_api/cache.py +319 -0
- aggregate_api/capability.py +823 -0
- aggregate_api/completion.py +219 -0
- aggregate_api/config.py +363 -0
- aggregate_api/cors.py +61 -0
- aggregate_api/examples.py +620 -0
- aggregate_api/layer_pricing.py +840 -0
- aggregate_api/library.py +94 -0
- aggregate_api/library_notes.py +96 -0
- aggregate_api/models.py +1407 -0
- aggregate_api/net.py +281 -0
- aggregate_api/pnl.py +101 -0
- aggregate_api/pricing.py +778 -0
- aggregate_api/resources.py +257 -0
- aggregate_api/routes/__init__.py +8 -0
- aggregate_api/routes/decl.py +327 -0
- aggregate_api/routes/examples.py +82 -0
- aggregate_api/routes/meta.py +282 -0
- aggregate_api/routes/objects.py +4119 -0
- aggregate_api/routes/status.py +466 -0
- aggregate_api/serializers.py +565 -0
- aggregate_api/sessions.py +353 -0
- aggregate_api/static/aggregate-api-logo-512.png +0 -0
- aggregate_api/static/aggregate-api-logo.png +0 -0
- aggregate_api/static/aggregate-api-trim.png +0 -0
- aggregate_api/static/android-chrome-192x192.png +0 -0
- aggregate_api/static/android-chrome-512x512.png +0 -0
- aggregate_api/static/apple-touch-icon.png +0 -0
- aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
- aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
- aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
- aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
- aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
- aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
- aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
- aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
- aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
- aggregate_api/static/assets/main-CmoEiPit.js +9 -0
- aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
- aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
- aggregate_api/static/favicon-16x16.png +0 -0
- aggregate_api/static/favicon-32x32.png +0 -0
- aggregate_api/static/favicon.ico +0 -0
- aggregate_api/static/index.html +912 -0
- aggregate_api/static/lite.html +83 -0
- aggregate_api/static/logo.png +0 -0
- aggregate_api/static/site.webmanifest +14 -0
- aggregate_api/static/sw.js +78 -0
- aggregate_api/status.py +536 -0
- aggregate_api/status_page.html +546 -0
- aggregate_api/tables.py +316 -0
- aggregate_api-1.0.0.dist-info/METADATA +187 -0
- aggregate_api-1.0.0.dist-info/RECORD +63 -0
- aggregate_api-1.0.0.dist-info/WHEEL +5 -0
- aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
- aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
- aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
"""One deterministic quote per reinsurance layer, written into the DecL clause.
|
|
2
|
+
|
|
3
|
+
The Quick Re row composes coverage and, through here, a price: each layer leaves
|
|
4
|
+
with a ``deposit`` or a ``rol``, so the program that lands in the editor is a
|
|
5
|
+
quote sheet rather than a bare structure. One method, no options, no radio
|
|
6
|
+
buttons; the parameters are the constants at the top of this module and nothing
|
|
7
|
+
in the UI reaches them.
|
|
8
|
+
|
|
9
|
+
The method, per layer: four candidate prices, take the largest.
|
|
10
|
+
|
|
11
|
+
.. code-block:: text
|
|
12
|
+
|
|
13
|
+
sd_load = el + LAMBDA * sd
|
|
14
|
+
min_rol = MIN_ROL * share * limit
|
|
15
|
+
premium = max(sd_load, ph_price, dual_price, min_rol)
|
|
16
|
+
written = premium / (1 - cede)
|
|
17
|
+
|
|
18
|
+
The minimum rate on line is a **candidate** rather than a floor applied after
|
|
19
|
+
the comparison. It is the same number either way, and it says more: ``binds``
|
|
20
|
+
names the measure that set the price, so a layer quoted off the minimum reads
|
|
21
|
+
``min_rol`` instead of reading ``dual`` beside a premium the dual transform did
|
|
22
|
+
not produce. An unlimited layer has no rate-on-line base, so it has no fourth
|
|
23
|
+
candidate and competes on three.
|
|
24
|
+
|
|
25
|
+
Why each piece is there, since none of it is a clamp and the shaping is meant
|
|
26
|
+
to be read off the formula rather than configured.
|
|
27
|
+
|
|
28
|
+
**The standard deviation load does the shaping.** It gives
|
|
29
|
+
``LR = 1 / (1 + LAMBDA * cv)``, which is literally "low volatility, high loss
|
|
30
|
+
ratio". It also delivers the attachment ladder without a ladder being drawn: a
|
|
31
|
+
layer hit with annual probability ``p`` always satisfies
|
|
32
|
+
``cv >= sqrt((1 - p) / p)``, because among all payments in ``[0, limit]`` with a
|
|
33
|
+
given attachment probability the all-or-nothing one has the smallest variance
|
|
34
|
+
relative to its mean. So ``LR <= 1 / (1 + LAMBDA * sqrt((1 - p) / p))``, whose
|
|
35
|
+
right side is increasing in ``p``: the loss ratio cannot rise as the attachment
|
|
36
|
+
probability falls. The margin-monotone clamp of the retired ``aggregate-asp``
|
|
37
|
+
experiment is a theorem here, and is not implemented.
|
|
38
|
+
|
|
39
|
+
**The two distortions bind in different places.** On the worked tower
|
|
40
|
+
``dual 1.8`` binds on the working layers and ``ph 0.7`` on the upper ones, which
|
|
41
|
+
is right economically: the dual transform loads the body of a distribution and
|
|
42
|
+
the proportional hazard transform loads the tail. Taking the maximum of three
|
|
43
|
+
measures is also what a reinsurer does with three indications.
|
|
44
|
+
|
|
45
|
+
**The minimum rate on line terminates the ladder.** As ``p`` goes to zero the
|
|
46
|
+
standard deviation load behaves like ``LAMBDA * limit * sqrt(p)``, so it decays
|
|
47
|
+
through any sensible floor and the floor takes over exactly where capacity
|
|
48
|
+
layers live. It is the one clamp kept, and it is the natural end of the ladder
|
|
49
|
+
rather than a patch on it.
|
|
50
|
+
|
|
51
|
+
**No aggregate surcharge.** "An aggregate cover costs more than the
|
|
52
|
+
corresponding occurrence layer" falls out, where "corresponding" means the same
|
|
53
|
+
probability pair resolved on each tier's own distribution, which is exactly what
|
|
54
|
+
the Quick Re percentage boxes mean: at equal percentiles the aggregate layer is a
|
|
55
|
+
genuinely rare event and its cv says so. If the ordering is ever wanted at equal
|
|
56
|
+
annual attachment probability too, that is a second ``LAMBDA`` for the aggregate
|
|
57
|
+
tier and not a clamp.
|
|
58
|
+
|
|
59
|
+
**Determinism.** Every input is a pure function of the gross object and the
|
|
60
|
+
layer: survival curves off a built object, auxiliary builds at a fixed capped
|
|
61
|
+
log2 from rendered DecL, fixed distortion parameters, fixed rounding. Same
|
|
62
|
+
boxes, same program text, every time. No solving, no iteration, and no
|
|
63
|
+
dependence on a layer's position in the tower, which is what made the ``asp``
|
|
64
|
+
ladder move every price whenever a tower was re-chopped.
|
|
65
|
+
|
|
66
|
+
Notes
|
|
67
|
+
-----
|
|
68
|
+
The quote relies on three properties of the engine, each measured against it
|
|
69
|
+
rather than assumed. They are why none of this needs new distributional
|
|
70
|
+
mathematics or any library internals.
|
|
71
|
+
|
|
72
|
+
**A layer's annual moments are closed form off the gross object**, so the form
|
|
73
|
+
can quote with no build at all. See :func:`layer_moments`, which ties to
|
|
74
|
+
``Aggregate.reins_stats_df`` to 5e-11 relative on the mean and 8e-8 on the cv.
|
|
75
|
+
|
|
76
|
+
**A layer's exact annual distribution costs one small build.**
|
|
77
|
+
``Aggregate.reins_program('occurrence ceded to <y> xs <a>')`` renders the DecL
|
|
78
|
+
for a single-layer *ceded* program, whose aggregate distribution is that layer's
|
|
79
|
+
annual ceded loss. ``ceded to`` replaces the tier's program rather than
|
|
80
|
+
appending to it, so the auxiliary object carries that one layer and nothing
|
|
81
|
+
else even when the object it came from already cedes. Measured at 55 ms per
|
|
82
|
+
layer at log2 16, and the quote is flat in log2 (0.13% between log2 14 and 19),
|
|
83
|
+
which is why :data:`PRICE_LOG2` caps the auxiliary builds regardless of the
|
|
84
|
+
object's own log2: a pricing indication does not need a fine lattice, and the
|
|
85
|
+
cap keeps the press snappy on a book built at log2 20.
|
|
86
|
+
|
|
87
|
+
``Aggregate.reins_price_df`` is deliberately not used. Its views are ``gross`` /
|
|
88
|
+
``ceded`` / ``net``, so it prices the whole cession and not the layers.
|
|
89
|
+
|
|
90
|
+
**The grammar already spells all of this.** ``deposit <amount>``,
|
|
91
|
+
``rol <fraction>``, ``rate <fraction>`` and ``cede <fraction>`` all parse on
|
|
92
|
+
both tiers and survive the round trip through ``reins_program``. Two parser
|
|
93
|
+
constraints matter here: ``rol`` needs a finite limit, which is why an unlimited
|
|
94
|
+
layer is always spelled as a deposit, and ``cede`` needs a premium clause on the
|
|
95
|
+
same layer, which it always has by construction.
|
|
96
|
+
|
|
97
|
+
One wrinkle worth knowing before reading a build's notes. A plain ``agg`` builds
|
|
98
|
+
the loss structure and *ignores* the ceded premium, with
|
|
99
|
+
``IgnoredDecLClauseWarning``: "a plain 'agg' has no premium context". The price
|
|
100
|
+
only bites inside a ``pnl`` or ``xpnl``. The program text keeps the full
|
|
101
|
+
declaration and ``pnl_program`` inlines it, so the demo is that Quick Re writes
|
|
102
|
+
a priced program and PnL then turns it into a ledger carrying the ceded premium
|
|
103
|
+
layer by layer. The warning is accurate and rides along in the build notes
|
|
104
|
+
rather than being suppressed.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
from __future__ import annotations
|
|
108
|
+
|
|
109
|
+
import math
|
|
110
|
+
from typing import Any
|
|
111
|
+
|
|
112
|
+
import numpy as np
|
|
113
|
+
from aggregate import Distortion
|
|
114
|
+
|
|
115
|
+
#: Standard deviation load, so ``LR = 1 / (1 + 0.3 cv)``.
|
|
116
|
+
#:
|
|
117
|
+
#: Not arbitrary. On the worked tower the three measures agree within about 3%,
|
|
118
|
+
#: so the load is calibrated against the two distortions rather than guessed,
|
|
119
|
+
#: and the maximum is a cross-check as often as it is a binding constraint.
|
|
120
|
+
LAMBDA = 0.30
|
|
121
|
+
|
|
122
|
+
#: Proportional hazard shape. Loads the tail, and binds high in a tower.
|
|
123
|
+
PH_SHAPE = 0.7
|
|
124
|
+
|
|
125
|
+
#: Dual shape. Loads the body, and binds low in a tower.
|
|
126
|
+
DUAL_SHAPE = 1.8
|
|
127
|
+
|
|
128
|
+
#: Minimum rate on line, as a fraction of ``share * limit``.
|
|
129
|
+
MIN_ROL = 0.01
|
|
130
|
+
|
|
131
|
+
#: Annual attachment probability below which the quote is spelled ``rol``.
|
|
132
|
+
#:
|
|
133
|
+
#: A high bar: on the worked tower only the top layer clears it, where the
|
|
134
|
+
#: ``asp`` rule of thumb ("claim count to layer < 0.5") would put the top three
|
|
135
|
+
#: on a rate. It has the merit of biting in the same place as
|
|
136
|
+
#: :data:`MIN_ROL`, so the two top-of-tower behaviors arrive together.
|
|
137
|
+
ROL_THRESHOLD = 0.10
|
|
138
|
+
|
|
139
|
+
#: Significant figures the written premium is rounded to. A quote-sheet number.
|
|
140
|
+
SIG_FIGS = 3
|
|
141
|
+
|
|
142
|
+
#: Significant figures the rate on line is rounded to, on a ``rol``-spelled
|
|
143
|
+
#: layer where the rate is what the clause carries.
|
|
144
|
+
#:
|
|
145
|
+
#: Four rather than three, because the rate is a small number read to the basis
|
|
146
|
+
#: point: a top-of-tower rate of 1.217% rounded to three figures is 1.22%, which
|
|
147
|
+
#: moves the premium by a quarter of a percent for no gain in legibility.
|
|
148
|
+
ROL_FIGS = 4
|
|
149
|
+
|
|
150
|
+
#: Cap on the auxiliary pricing builds. See the module Notes for why 16 is
|
|
151
|
+
#: enough and why the object's own log2 is not used.
|
|
152
|
+
PRICE_LOG2 = 16
|
|
153
|
+
|
|
154
|
+
#: Default ceding commission. There is no box for it in the UI; the gross-up is
|
|
155
|
+
#: implemented and tested so that the box is a one-line addition later.
|
|
156
|
+
CEDE = 0.0
|
|
157
|
+
|
|
158
|
+
#: The two distortions, built once. They are stateless.
|
|
159
|
+
_PH = Distortion("ph", PH_SHAPE)
|
|
160
|
+
_DUAL = Distortion("dual", DUAL_SHAPE)
|
|
161
|
+
|
|
162
|
+
#: The tier vocabulary, matching the quantiles route and the DecL keyword.
|
|
163
|
+
_TIERS = ("occurrence", "aggregate")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _round_sig(x: float, figures: int = SIG_FIGS) -> float:
|
|
167
|
+
"""Round to a number of significant figures, not decimal places.
|
|
168
|
+
|
|
169
|
+
A quote is read as ``823`` or ``1,560``, never as ``823.26``, and the scale
|
|
170
|
+
of a premium is whatever the book's scale is, so decimal places cannot be
|
|
171
|
+
fixed in advance.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
x : float
|
|
176
|
+
The value to round. Zero and negatives pass through unchanged, since
|
|
177
|
+
neither has a leading digit to count from.
|
|
178
|
+
figures : int, optional
|
|
179
|
+
Significant figures to keep. Defaults to :data:`SIG_FIGS`.
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
float
|
|
184
|
+
"""
|
|
185
|
+
if not (x > 0) or not math.isfinite(x):
|
|
186
|
+
return float(x)
|
|
187
|
+
return round(x, -int(math.floor(math.log10(x))) + (figures - 1))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _layer_grid(obj: Any, attach: float, limit: float):
|
|
191
|
+
"""The midpoint grid across one layer, and the lattice step.
|
|
192
|
+
|
|
193
|
+
Midpoints rather than endpoints, because the sums below are Riemann sums
|
|
194
|
+
over the lattice cells and the engine's own layering arithmetic reads the
|
|
195
|
+
cell that way. An unlimited limit runs to the end of the lattice, which is
|
|
196
|
+
the whole of what the model knows about.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
obj : Any
|
|
201
|
+
A built ``Aggregate``, for its bucket size and lattice extent.
|
|
202
|
+
attach : float
|
|
203
|
+
Bottom of the layer, on the loss axis.
|
|
204
|
+
limit : float
|
|
205
|
+
Width of the layer. ``inf`` runs to the top of the lattice.
|
|
206
|
+
|
|
207
|
+
Returns
|
|
208
|
+
-------
|
|
209
|
+
(numpy.ndarray, float)
|
|
210
|
+
The midpoints, and ``bs``.
|
|
211
|
+
"""
|
|
212
|
+
bs = float(obj.bs)
|
|
213
|
+
if not math.isfinite(limit):
|
|
214
|
+
top = float(obj.density_df.index[-1])
|
|
215
|
+
limit = max(top - attach, 0.0)
|
|
216
|
+
xs = np.arange(attach, attach + limit, bs) + bs / 2
|
|
217
|
+
return xs, bs
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _survival(obj: Any, tier: str, xs) -> np.ndarray:
|
|
221
|
+
"""The survival function the tier's layer is cut out of.
|
|
222
|
+
|
|
223
|
+
Occurrence reads the per-claim severity and aggregate the annual loss, which
|
|
224
|
+
is the whole difference between the two tiers: a cession applies either to
|
|
225
|
+
one claim or to the year.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
obj : Any
|
|
230
|
+
A built ``Aggregate``.
|
|
231
|
+
tier : {'occurrence', 'aggregate'}
|
|
232
|
+
xs : numpy.ndarray
|
|
233
|
+
Points to evaluate at.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
numpy.ndarray
|
|
238
|
+
|
|
239
|
+
Notes
|
|
240
|
+
-----
|
|
241
|
+
The aggregate side interpolates ``density_df.S`` rather than reading it at
|
|
242
|
+
the index, because the midpoint grid falls between lattice points by
|
|
243
|
+
construction. That interpolation is the whole of the aggregate tier's small
|
|
244
|
+
disagreement with the engine's exact per-layer figures, which lands in the
|
|
245
|
+
fourth or fifth significant figure.
|
|
246
|
+
"""
|
|
247
|
+
if tier == "occurrence":
|
|
248
|
+
return np.asarray(obj.sev.sf(xs), dtype=float)
|
|
249
|
+
index = obj.density_df.index.to_numpy()
|
|
250
|
+
return np.interp(xs, index, obj.density_df.S.to_numpy())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def layer_moments(obj: Any, tier: str, attach: float, limit: float,
|
|
254
|
+
share: float = 1.0) -> tuple[float, float]:
|
|
255
|
+
"""Mean and standard deviation of the annual loss in one layer.
|
|
256
|
+
|
|
257
|
+
Closed form off the **gross** object, with no build, which is what lets the
|
|
258
|
+
Quick Re row quote live as the boxes are typed.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
obj : Any
|
|
263
|
+
A built ``Aggregate``, read for its severity, its frequency and its
|
|
264
|
+
lattice. The gross object, not a ceded one.
|
|
265
|
+
tier : {'occurrence', 'aggregate'}
|
|
266
|
+
Which distribution the layer is cut out of.
|
|
267
|
+
attach : float
|
|
268
|
+
Bottom of the layer, on the loss axis.
|
|
269
|
+
limit : float
|
|
270
|
+
Width of the layer. ``inf`` runs to the top of the lattice.
|
|
271
|
+
share : float, optional
|
|
272
|
+
Placed share, which scales the mean linearly and the standard deviation
|
|
273
|
+
linearly, so it leaves the cv alone.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
(float, float)
|
|
278
|
+
``(mean, sd)``. A layer the model places no mass in returns
|
|
279
|
+
``(0.0, 0.0)`` rather than a nan, which is the honest answer for a
|
|
280
|
+
capacity layer above the severity limit and is what lets the minimum
|
|
281
|
+
rate on line quote it anyway.
|
|
282
|
+
|
|
283
|
+
Notes
|
|
284
|
+
-----
|
|
285
|
+
With ``S`` the tier's survival function, ``m1`` the integral of ``S`` over
|
|
286
|
+
``[a, a + y]`` and ``m2 = 2 * integral of (x - a) S(x) dx`` over the same
|
|
287
|
+
range, ``m1`` and ``m2`` are the first two moments of the layered loss. The
|
|
288
|
+
second uses the identity ``E[Y^2] = 2 * integral of y P(Y > y) dy``, applied
|
|
289
|
+
to the layered variable ``Y = min(max(X - a, 0), y)``, which is why the
|
|
290
|
+
integrand carries the ``(x - a)`` factor and the range is the layer rather
|
|
291
|
+
than the half line.
|
|
292
|
+
|
|
293
|
+
On the occurrence tier the layered severity is then compounded through the
|
|
294
|
+
frequency, taking ``f1`` and ``f2`` from ``frequency.freq_moms(n)``:
|
|
295
|
+
|
|
296
|
+
.. code-block:: text
|
|
297
|
+
|
|
298
|
+
mean = s * f1 * m1
|
|
299
|
+
var = s^2 * (f1 * (m2 - m1^2) + (f2 - f1^2) * m1^2)
|
|
300
|
+
|
|
301
|
+
which is the usual compound variance, the within-claim term plus the term
|
|
302
|
+
the frequency's own dispersion contributes. On the aggregate tier the layer
|
|
303
|
+
is cut out of the annual loss directly, so ``mean = s * m1`` and
|
|
304
|
+
``var = s^2 * (m2 - m1^2)`` with no compounding.
|
|
305
|
+
|
|
306
|
+
Measured against the engine's own exact per-layer figures from
|
|
307
|
+
``Aggregate.reins_stats_df``, which computes each layer's annual
|
|
308
|
+
distribution by FFT, on ``agg G 100 claims 20000 xs 0 sev lognorm 50 cv 4``
|
|
309
|
+
at log2 19:
|
|
310
|
+
|
|
311
|
+
.. code-block:: text
|
|
312
|
+
|
|
313
|
+
layer el cv relative error, el / cv
|
|
314
|
+
100 xs 100 704.1686 0.3515 5.2e-11 / 7.7e-08
|
|
315
|
+
300 xs 200 758.9755 0.5598 4.4e-11 / 2.4e-08
|
|
316
|
+
500 xs 500 385.7024 1.0270 1.3e-10 / 4.1e-08
|
|
317
|
+
1000 xs 1000 231.9494 1.8441 2.3e-10 / 3.8e-08
|
|
318
|
+
3000 xs 2000 141.8330 3.8117 7.0e-10 / 4.4e-08
|
|
319
|
+
5000 xs 5000 39.1135 9.6663 4.7e-10 / 1.7e-08
|
|
320
|
+
|
|
321
|
+
The agreement holds for a mixed frequency as well as for Poisson, since the
|
|
322
|
+
frequency enters only through its first two moments. The aggregate tier
|
|
323
|
+
agrees to four or five significant figures, the small loss being the grid
|
|
324
|
+
interpolation noted on :func:`_survival`.
|
|
325
|
+
"""
|
|
326
|
+
if tier not in _TIERS:
|
|
327
|
+
raise ValueError(f"tier is one of {_TIERS}, got {tier!r}")
|
|
328
|
+
xs, bs = _layer_grid(obj, attach, limit)
|
|
329
|
+
if not xs.size:
|
|
330
|
+
return 0.0, 0.0
|
|
331
|
+
sf = _survival(obj, tier, xs)
|
|
332
|
+
m1 = float(np.sum(sf) * bs)
|
|
333
|
+
m2 = float(2.0 * np.sum((xs - attach) * sf) * bs)
|
|
334
|
+
if tier == "occurrence":
|
|
335
|
+
f1, f2 = (float(m) for m in obj.frequency.freq_moms(obj.n)[:2])
|
|
336
|
+
mean = share * f1 * m1
|
|
337
|
+
var = share ** 2 * (f1 * (m2 - m1 ** 2) + (f2 - f1 ** 2) * m1 ** 2)
|
|
338
|
+
else:
|
|
339
|
+
mean = share * m1
|
|
340
|
+
var = share ** 2 * (m2 - m1 ** 2)
|
|
341
|
+
return float(mean), float(math.sqrt(max(var, 0.0)))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def attachment_probability(obj: Any, tier: str, attach: float) -> float:
|
|
345
|
+
"""Probability that the layer is hit at all this year.
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
obj : Any
|
|
350
|
+
A built ``Aggregate``, the gross object.
|
|
351
|
+
tier : {'occurrence', 'aggregate'}
|
|
352
|
+
attach : float
|
|
353
|
+
Bottom of the layer.
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
float
|
|
358
|
+
|
|
359
|
+
Notes
|
|
360
|
+
-----
|
|
361
|
+
The aggregate tier reads ``S`` off ``density_df`` and is exact: the annual
|
|
362
|
+
loss exceeding the attachment *is* the layer being hit.
|
|
363
|
+
|
|
364
|
+
The occurrence tier answers ``1 - exp(-n * S_sev(a))``, the chance that at
|
|
365
|
+
least one of the year's claims pierces the attachment. That is exact for
|
|
366
|
+
Poisson and a close reading otherwise, since a mixed frequency has the same
|
|
367
|
+
mean and more dispersion, so the true figure is a little lower. Exactness is
|
|
368
|
+
not needed: the only decision this number makes is whether the quote is
|
|
369
|
+
spelled ``deposit`` or ``rol``, a threshold comparison at
|
|
370
|
+
:data:`ROL_THRESHOLD`, and it is reported on the form as a reading of how
|
|
371
|
+
remote the layer is.
|
|
372
|
+
|
|
373
|
+
This is deliberately **not** the ``pr_attach`` of
|
|
374
|
+
``Aggregate.reins_stats_df``, which on the occurrence tier is the severity
|
|
375
|
+
survival ``S_sev(a)``, the chance that *one claim* pierces the attachment.
|
|
376
|
+
For a book with a hundred claims a year the two differ by two orders of
|
|
377
|
+
magnitude, and it is the annual reading that says whether a layer is a
|
|
378
|
+
working one.
|
|
379
|
+
"""
|
|
380
|
+
if tier not in _TIERS:
|
|
381
|
+
raise ValueError(f"tier is one of {_TIERS}, got {tier!r}")
|
|
382
|
+
if tier == "aggregate":
|
|
383
|
+
index = obj.density_df.index.to_numpy()
|
|
384
|
+
return float(np.clip(
|
|
385
|
+
np.interp(attach, index, obj.density_df.S.to_numpy()), 0.0, 1.0))
|
|
386
|
+
expected = float(obj.n) * float(obj.sev.sf(attach))
|
|
387
|
+
return float(-np.expm1(-expected))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _expected_count(obj: Any, tier: str, attach: float) -> float | None:
|
|
391
|
+
"""Expected number of claims reaching the layer in a year.
|
|
392
|
+
|
|
393
|
+
Occurrence only. On the aggregate tier a layer is pierced by the year's
|
|
394
|
+
total, so a claim count to the layer is not a quantity that means anything
|
|
395
|
+
and the answer is ``None`` rather than a number nobody should read.
|
|
396
|
+
|
|
397
|
+
Parameters
|
|
398
|
+
----------
|
|
399
|
+
obj : Any
|
|
400
|
+
A built ``Aggregate``, the gross object.
|
|
401
|
+
tier : {'occurrence', 'aggregate'}
|
|
402
|
+
attach : float
|
|
403
|
+
Bottom of the layer.
|
|
404
|
+
|
|
405
|
+
Returns
|
|
406
|
+
-------
|
|
407
|
+
float or None
|
|
408
|
+
"""
|
|
409
|
+
if tier != "occurrence":
|
|
410
|
+
return None
|
|
411
|
+
return float(obj.n) * float(obj.sev.sf(attach))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _min_rol_premium(share: float, limit: float) -> float | None:
|
|
415
|
+
"""The minimum-rate-on-line premium for one layer, or ``None``.
|
|
416
|
+
|
|
417
|
+
One place computes it, because it is read twice: as the fourth candidate in
|
|
418
|
+
:func:`layer_quotes`, and as the floor :func:`_finish` applies on the
|
|
419
|
+
indication path, which has no distortion candidates to compare it against.
|
|
420
|
+
|
|
421
|
+
Parameters
|
|
422
|
+
----------
|
|
423
|
+
share, limit : float
|
|
424
|
+
The layer as placed. ``limit`` may be ``inf``.
|
|
425
|
+
|
|
426
|
+
Returns
|
|
427
|
+
-------
|
|
428
|
+
float or None
|
|
429
|
+
``None`` on an unlimited layer: a fraction of an infinite limit is not
|
|
430
|
+
a number, and a rate on line is not how such a cover is quoted anyway.
|
|
431
|
+
"""
|
|
432
|
+
base = share * limit
|
|
433
|
+
return MIN_ROL * base if math.isfinite(base) else None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _spelling(attach_pr: float, limit: float) -> str:
|
|
437
|
+
"""How this layer's premium is written: ``deposit`` or ``rol``.
|
|
438
|
+
|
|
439
|
+
An unlimited layer is always a deposit, because the parser refuses a rate on
|
|
440
|
+
line without a finite limit, a rate on line being a fraction of
|
|
441
|
+
``share * limit``. Otherwise a layer remote enough to clear
|
|
442
|
+
:data:`ROL_THRESHOLD` quotes on a rate, which is how a capacity layer is
|
|
443
|
+
actually quoted.
|
|
444
|
+
"""
|
|
445
|
+
if not math.isfinite(limit):
|
|
446
|
+
return "deposit"
|
|
447
|
+
return "rol" if attach_pr < ROL_THRESHOLD else "deposit"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _finish(el: float, sd: float, technical: float, tier: str,
|
|
451
|
+
attach: float, limit: float, share: float, attach_pr: float,
|
|
452
|
+
cede: float) -> dict:
|
|
453
|
+
"""Floor, gross up, round, and describe one layer's quote.
|
|
454
|
+
|
|
455
|
+
The tail shared by the no-build indication and the full quote, so the floor,
|
|
456
|
+
the gross-up, the rounding and the spelling decision cannot drift between
|
|
457
|
+
what the form promises and what the press writes.
|
|
458
|
+
|
|
459
|
+
**What gets rounded follows the spelling.** A ``deposit`` layer carries its
|
|
460
|
+
premium in the clause, so the premium is the quote-sheet number and the rate
|
|
461
|
+
is its quotient. A ``rol`` layer carries its rate, so the rate rounds to
|
|
462
|
+
:data:`ROL_FIGS` and the premium follows from it. Either way the clause and
|
|
463
|
+
this row agree to the digit, which is the point: the clause states one of
|
|
464
|
+
the pair and the quote sheet is where the other is checked.
|
|
465
|
+
|
|
466
|
+
Parameters
|
|
467
|
+
----------
|
|
468
|
+
el, sd : float
|
|
469
|
+
The layer's annual mean and standard deviation.
|
|
470
|
+
technical : float
|
|
471
|
+
The reinsurer's net risk-loaded premium before the floor.
|
|
472
|
+
tier : {'occurrence', 'aggregate'}
|
|
473
|
+
attach, limit, share : float
|
|
474
|
+
The layer as placed.
|
|
475
|
+
attach_pr : float
|
|
476
|
+
Annual attachment probability, which decides the spelling.
|
|
477
|
+
cede : float
|
|
478
|
+
Ceding commission. The technical premium is the reinsurer's net, so the
|
|
479
|
+
written premium is ``technical / (1 - cede)``.
|
|
480
|
+
|
|
481
|
+
Returns
|
|
482
|
+
-------
|
|
483
|
+
dict
|
|
484
|
+
One quote row. ``cv`` and ``loss_ratio`` are ``None`` on a layer the
|
|
485
|
+
model places no mass in, rather than a nan that no serializer and no
|
|
486
|
+
reader handles well, and ``limit`` is ``None`` on an unlimited cover,
|
|
487
|
+
matching how the request spells one. The rate-on-line floor does not
|
|
488
|
+
apply to an unlimited layer: a fraction of an infinite limit is not a
|
|
489
|
+
number, and a rate on line is not how such a cover is quoted anyway.
|
|
490
|
+
"""
|
|
491
|
+
if not (0.0 <= cede < 1.0):
|
|
492
|
+
raise ValueError(f"cede is a fraction below 1, got {cede!r}")
|
|
493
|
+
base = share * limit
|
|
494
|
+
min_rol = _min_rol_premium(share, limit)
|
|
495
|
+
floor = min_rol if min_rol is not None else 0.0
|
|
496
|
+
premium = max(technical, floor)
|
|
497
|
+
spelling = _spelling(attach_pr, limit)
|
|
498
|
+
gross = premium / (1.0 - cede)
|
|
499
|
+
if spelling == "rol" and base > 0:
|
|
500
|
+
rol = _round_sig(gross / base, ROL_FIGS)
|
|
501
|
+
written = rol * base
|
|
502
|
+
else:
|
|
503
|
+
written = _round_sig(gross)
|
|
504
|
+
rol = (written / base) if math.isfinite(base) and base > 0 else None
|
|
505
|
+
return {
|
|
506
|
+
"tier": tier,
|
|
507
|
+
"attach": float(attach),
|
|
508
|
+
"limit": float(limit) if math.isfinite(limit) else None,
|
|
509
|
+
"share": float(share),
|
|
510
|
+
"el": el,
|
|
511
|
+
"sd": sd,
|
|
512
|
+
"cv": (sd / el) if el > 0 else None,
|
|
513
|
+
"pr_attach": attach_pr,
|
|
514
|
+
"lol": (el / base) if base > 0 and math.isfinite(base) else None,
|
|
515
|
+
"floored": premium <= floor and floor > 0,
|
|
516
|
+
"premium": written,
|
|
517
|
+
"rol": rol,
|
|
518
|
+
"spelling": spelling,
|
|
519
|
+
"cede": float(cede),
|
|
520
|
+
"loss_ratio": (el / written) if written > 0 else None,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def indication(obj: Any, tier: str, attach: float, limit: float,
|
|
525
|
+
share: float = 1.0, cede: float = CEDE) -> dict:
|
|
526
|
+
"""The no-build quote for one layer, for the form to show as you type.
|
|
527
|
+
|
|
528
|
+
The standard deviation load, the floor and the gross-up, with no distortion
|
|
529
|
+
term and so no build. Cheap enough to recompose on every settled keystroke,
|
|
530
|
+
which is what it is for.
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
obj : Any
|
|
535
|
+
A built ``Aggregate``, the gross object.
|
|
536
|
+
tier : {'occurrence', 'aggregate'}
|
|
537
|
+
attach, limit : float
|
|
538
|
+
The layer, on the loss axis. ``limit`` may be ``inf``.
|
|
539
|
+
share : float, optional
|
|
540
|
+
Placed share.
|
|
541
|
+
cede : float, optional
|
|
542
|
+
Ceding commission.
|
|
543
|
+
|
|
544
|
+
Returns
|
|
545
|
+
-------
|
|
546
|
+
dict
|
|
547
|
+
The layer's expected loss, standard deviation, cv, expected claim count
|
|
548
|
+
on the occurrence tier, loss on line, annual attachment probability,
|
|
549
|
+
indicated premium (as ``premium``, the same key the full quote uses),
|
|
550
|
+
rate on line, spelling and loss ratio.
|
|
551
|
+
|
|
552
|
+
Notes
|
|
553
|
+
-----
|
|
554
|
+
**The indication can only be revised upward by the press**, never down,
|
|
555
|
+
which is what makes showing it on the form honest. The full quote is a
|
|
556
|
+
maximum that includes this same standard deviation load and is then floored
|
|
557
|
+
by the same floor, so it is bounded below by what was shown. The moments the
|
|
558
|
+
press uses are these same closed-form ones, so the bound is exact rather
|
|
559
|
+
than approximate.
|
|
560
|
+
"""
|
|
561
|
+
el, sd = layer_moments(obj, tier, attach, limit, share)
|
|
562
|
+
attach_pr = attachment_probability(obj, tier, attach)
|
|
563
|
+
row = _finish(el, sd, el + LAMBDA * sd, tier, attach, limit, share,
|
|
564
|
+
attach_pr, cede)
|
|
565
|
+
row["count"] = _expected_count(obj, tier, attach)
|
|
566
|
+
return row
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _layer_decl(share: float, limit: float, attach: float,
|
|
570
|
+
label: str | None = None,
|
|
571
|
+
premium: tuple[str, float] | None = None) -> str:
|
|
572
|
+
"""One layer as DecL, with its placement, its name and its price.
|
|
573
|
+
|
|
574
|
+
The tier and the ``net of`` / ``ceded to`` keyword open the clause and are
|
|
575
|
+
the caller's, so they are not arguments here: this writes one layer, and
|
|
576
|
+
every layer in a clause is spelled the same way whichever tier it sits on.
|
|
577
|
+
|
|
578
|
+
Parameters
|
|
579
|
+
----------
|
|
580
|
+
share : float
|
|
581
|
+
Placed share. A whole-layer share emits no placement head, since
|
|
582
|
+
``100% po`` is a clause saying nothing. The percentage is emitted as a
|
|
583
|
+
literal with its ``%``, which is what tells the grammar this is a share
|
|
584
|
+
and not an absolute amount of cover.
|
|
585
|
+
limit, attach : float
|
|
586
|
+
The layer. An unlimited limit is spelled ``inf``.
|
|
587
|
+
label : str, optional
|
|
588
|
+
The ``as "..."`` name. Load bearing where present: these become the
|
|
589
|
+
``Step`` labels on every P&L exhibit.
|
|
590
|
+
premium : (str, float), optional
|
|
591
|
+
``('deposit', amount)`` or ``('rol', fraction)``.
|
|
592
|
+
|
|
593
|
+
Returns
|
|
594
|
+
-------
|
|
595
|
+
str
|
|
596
|
+
|
|
597
|
+
Notes
|
|
598
|
+
-----
|
|
599
|
+
**A rate on line is written as a bare fraction, never as a percentage.**
|
|
600
|
+
That is the library's own canonical spelling, and
|
|
601
|
+
``decl_writer._render_reins_clause`` says what it is for: a bare number
|
|
602
|
+
re-parses identically, with no float dust. The ``%`` form does not. The
|
|
603
|
+
parser computes ``12.1705 / 100``, which is ``0.12170500000000001``, and
|
|
604
|
+
``decl_writer._fmt_num`` renders a non-integral float with ``repr``, the
|
|
605
|
+
shortest string that round-trips, so a layer written ``rol 12.1705%`` comes
|
|
606
|
+
back out of ``reins_program`` as ``rol 0.12170500000000001``. Written
|
|
607
|
+
``rol 0.121705`` it comes back verbatim.
|
|
608
|
+
"""
|
|
609
|
+
head = "" if abs(share - 1.0) < 1e-9 else f"{share * 100:g}% po "
|
|
610
|
+
span = "inf" if not math.isfinite(limit) else f"{limit:g}"
|
|
611
|
+
text = f"{head}{span} xs {attach:g}"
|
|
612
|
+
if premium is not None:
|
|
613
|
+
keyword, value = premium
|
|
614
|
+
text += (f" rol {value:g}" if keyword == "rol"
|
|
615
|
+
else f" deposit {value:g}")
|
|
616
|
+
if label:
|
|
617
|
+
text += f' as "{label}"'
|
|
618
|
+
return text
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def layer_quotes(uw: Any, obj: Any, layers, cede: float = CEDE,
|
|
622
|
+
labels=None) -> list[dict]:
|
|
623
|
+
"""One quote per layer: three measures and the minimum rate on line.
|
|
624
|
+
|
|
625
|
+
Parameters
|
|
626
|
+
----------
|
|
627
|
+
uw : aggregate.underwriter.Underwriter
|
|
628
|
+
The caller's own base, used for the auxiliary pricing builds. Passed in
|
|
629
|
+
rather than reached for, for the reason every build path here takes it:
|
|
630
|
+
which base builds a program is the whole of what keeps one user's
|
|
631
|
+
declarations out of another user's.
|
|
632
|
+
obj : Any
|
|
633
|
+
A built ``Aggregate``, the gross object the layers are cut out of.
|
|
634
|
+
layers : iterable of tuple
|
|
635
|
+
``(tier, share, limit, attach)`` per layer, which is the library's own
|
|
636
|
+
ordering in ``occ_reins`` and ``agg_reins`` with the tier prepended.
|
|
637
|
+
cede : float, optional
|
|
638
|
+
Ceding commission, applied to every layer.
|
|
639
|
+
labels : sequence of (str or None), optional
|
|
640
|
+
The ``as "..."`` name of each layer, in the order given. A missing or
|
|
641
|
+
empty entry falls back to ``Occ n`` / ``Agg n``, numbered within its own
|
|
642
|
+
tier in the order the layers arrive, which is the order the clause
|
|
643
|
+
spells them. Shorter than ``layers`` is not an error; the tail falls
|
|
644
|
+
back.
|
|
645
|
+
|
|
646
|
+
Returns
|
|
647
|
+
-------
|
|
648
|
+
list of dict
|
|
649
|
+
One row per layer in the order given, each carrying every component and
|
|
650
|
+
not only the winner: ``sd_load``, ``ph``, ``dual``, ``min_rol``, which
|
|
651
|
+
of the four bound, the layer's ``label``, and then the grossed-up and
|
|
652
|
+
rounded ``premium``. The components are what the Reins quote table
|
|
653
|
+
shows, and the demo is more interesting for having them side by side.
|
|
654
|
+
|
|
655
|
+
Notes
|
|
656
|
+
-----
|
|
657
|
+
**The minimum rate on line is the fourth candidate, not a floor after the
|
|
658
|
+
fact.** Through a197 it was applied inside :func:`_finish` after the three
|
|
659
|
+
measures had been compared, so a layer priced off the minimum reported
|
|
660
|
+
``binds = 'dual'`` beside a premium the dual transform did not produce, and
|
|
661
|
+
the only signal that the minimum had bitten was the ``floored`` boolean.
|
|
662
|
+
The premium is the same number; ``binds`` now names what set it.
|
|
663
|
+
|
|
664
|
+
``label`` and ``min_rol`` are attached here rather than inside
|
|
665
|
+
:func:`_finish`, which the indication path shares. The response models carry
|
|
666
|
+
``extra="forbid"``, and ``LayerIndication`` declares neither, so a row
|
|
667
|
+
carrying them would fail validation on the preview route. They belong
|
|
668
|
+
beside ``sd_load``, ``ph``, ``dual`` and ``binds`` in any case: all six are
|
|
669
|
+
the full quote's, and the indication has no clause to take a name from.
|
|
670
|
+
|
|
671
|
+
The expected loss and standard deviation come from :func:`layer_moments` off
|
|
672
|
+
the gross object rather than from the auxiliary build's own ``est_m`` and
|
|
673
|
+
``est_sd``. Two reasons: the closed form is exact where the auxiliary
|
|
674
|
+
build's figures carry the discretization of a log2 16 lattice, and using the
|
|
675
|
+
same moments the form used makes :func:`indication` an exact lower bound on
|
|
676
|
+
this result rather than an approximate one. The auxiliary build is used for
|
|
677
|
+
the two distortion prices alone, which is the one thing it is needed for.
|
|
678
|
+
|
|
679
|
+
The auxiliary pmf carries numerical noise of order 1e-16 negative, which
|
|
680
|
+
trips ``choquet_weights`` with "distorted atom weights materially
|
|
681
|
+
negative", so it is clipped at zero and renormalized before pricing.
|
|
682
|
+
"""
|
|
683
|
+
given = list(labels or ())
|
|
684
|
+
counts: dict[str, int] = {}
|
|
685
|
+
rows = []
|
|
686
|
+
for i, (tier, share, limit, attach) in enumerate(layers):
|
|
687
|
+
el, sd = layer_moments(obj, tier, attach, limit, share)
|
|
688
|
+
attach_pr = attachment_probability(obj, tier, attach)
|
|
689
|
+
sd_load = el + LAMBDA * sd
|
|
690
|
+
ph_price, dual_price = _distortion_prices(
|
|
691
|
+
uw, obj, tier, share, limit, attach)
|
|
692
|
+
candidates = {"sd": sd_load, "ph": ph_price, "dual": dual_price}
|
|
693
|
+
min_rol = _min_rol_premium(share, limit)
|
|
694
|
+
if min_rol is not None:
|
|
695
|
+
candidates["min_rol"] = min_rol
|
|
696
|
+
binds = max(candidates, key=candidates.__getitem__)
|
|
697
|
+
counts[tier] = counts.get(tier, 0) + 1
|
|
698
|
+
label = (given[i] if i < len(given) else None) or (
|
|
699
|
+
f"{'Occ' if tier == 'occurrence' else 'Agg'} {counts[tier]}")
|
|
700
|
+
row = _finish(el, sd, candidates[binds], tier, attach, limit, share,
|
|
701
|
+
attach_pr, cede)
|
|
702
|
+
row.update(sd_load=sd_load, ph=ph_price, dual=dual_price, binds=binds,
|
|
703
|
+
min_rol=min_rol, label=label,
|
|
704
|
+
count=_expected_count(obj, tier, attach))
|
|
705
|
+
rows.append(row)
|
|
706
|
+
return rows
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _distortion_prices(uw: Any, obj: Any, tier: str, share: float,
|
|
710
|
+
limit: float, attach: float) -> tuple[float, float]:
|
|
711
|
+
"""The proportional hazard and dual prices of one layer's annual loss.
|
|
712
|
+
|
|
713
|
+
One auxiliary build per layer, at :data:`PRICE_LOG2`. See the module Notes
|
|
714
|
+
for why ``ceded to`` isolates the layer and why the cap is enough.
|
|
715
|
+
|
|
716
|
+
Parameters
|
|
717
|
+
----------
|
|
718
|
+
uw : aggregate.underwriter.Underwriter
|
|
719
|
+
The caller's base.
|
|
720
|
+
obj : Any
|
|
721
|
+
The gross object, for its ``reins_program``.
|
|
722
|
+
tier : {'occurrence', 'aggregate'}
|
|
723
|
+
share, limit, attach : float
|
|
724
|
+
The layer as placed.
|
|
725
|
+
|
|
726
|
+
Returns
|
|
727
|
+
-------
|
|
728
|
+
(float, float)
|
|
729
|
+
``(ph, dual)``. A layer the model places no mass in prices at zero on
|
|
730
|
+
both, which lets the minimum rate on line take over cleanly.
|
|
731
|
+
"""
|
|
732
|
+
clause = f"{tier} ceded to " + _layer_decl(share, limit, attach)
|
|
733
|
+
aux = uw(obj.reins_program(clause), log2=PRICE_LOG2, bs=0)
|
|
734
|
+
pmf = aux.density_df.p.clip(lower=0.0)
|
|
735
|
+
total = float(pmf.sum())
|
|
736
|
+
if not total > 0:
|
|
737
|
+
return 0.0, 0.0
|
|
738
|
+
pmf = pmf / total
|
|
739
|
+
return float(_PH.price(pmf).ask), float(_DUAL.price(pmf).ask)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def price_clause(uw: Any, obj: Any, cession, cede: float = CEDE
|
|
743
|
+
) -> tuple[str, list[dict]]:
|
|
744
|
+
"""Rewrite one composed cession clause with a premium on every layer.
|
|
745
|
+
|
|
746
|
+
Parameters
|
|
747
|
+
----------
|
|
748
|
+
uw : aggregate.underwriter.Underwriter
|
|
749
|
+
The caller's base.
|
|
750
|
+
obj : Any
|
|
751
|
+
The gross object being ceded.
|
|
752
|
+
cession : str or list of str
|
|
753
|
+
The clause, or one per tier, exactly as ``reins_program`` takes it.
|
|
754
|
+
cede : float, optional
|
|
755
|
+
Ceding commission, applied to every layer.
|
|
756
|
+
|
|
757
|
+
Returns
|
|
758
|
+
-------
|
|
759
|
+
(str or list of str, list of dict)
|
|
760
|
+
The priced clause, one entry per tier the cession named, and the quote
|
|
761
|
+
rows behind it in the order the clause spells them.
|
|
762
|
+
|
|
763
|
+
Notes
|
|
764
|
+
-----
|
|
765
|
+
**The library's reading of the clause is authoritative.** The layers are
|
|
766
|
+
read back off the spec rather than scraped out of the caller's string, so a
|
|
767
|
+
placement or a limit the Quick Re row spelled loosely cannot diverge from
|
|
768
|
+
what gets priced, and the returned clause is recomposed from those same
|
|
769
|
+
triples. That is the point of the round trip rather than an accident of it.
|
|
770
|
+
|
|
771
|
+
The spec is read with ``uw(program, update=False)``, which parses and builds
|
|
772
|
+
the spec without the FFT, measured at 13 ms. The alternative is the full
|
|
773
|
+
build, which ``post_reins`` then does again on the priced program, so this
|
|
774
|
+
saves a build of the net object rather than paying for one.
|
|
775
|
+
"""
|
|
776
|
+
program = obj.reins_program(cession)
|
|
777
|
+
spec = uw(program, update=False)
|
|
778
|
+
clauses, rows = [], []
|
|
779
|
+
for tier in _TIERS:
|
|
780
|
+
prefix = "occ" if tier == "occurrence" else "agg"
|
|
781
|
+
triples = getattr(spec, f"{prefix}_reins", None)
|
|
782
|
+
if not triples:
|
|
783
|
+
continue
|
|
784
|
+
kind = getattr(spec, f"{prefix}_kind", "") or "net of"
|
|
785
|
+
labels = spec.spec.get(f"{prefix}_reins_label") or [None] * len(triples)
|
|
786
|
+
quotes = layer_quotes(
|
|
787
|
+
uw, obj, [(tier, s, y, a) for s, y, a in triples], cede=cede,
|
|
788
|
+
labels=labels)
|
|
789
|
+
parts = [
|
|
790
|
+
_layer_decl(share, limit, attach, label,
|
|
791
|
+
(quote["spelling"],
|
|
792
|
+
quote["premium"] if quote["spelling"] == "deposit"
|
|
793
|
+
else quote["rol"]))
|
|
794
|
+
for (share, limit, attach), label, quote
|
|
795
|
+
in zip(triples, labels, quotes)
|
|
796
|
+
]
|
|
797
|
+
# The clause keeps the spec's own label, which is `None` where the
|
|
798
|
+
# cession wrote no `as "..."`; the fallback name belongs to the quote
|
|
799
|
+
# sheet's `Layer` column and not to the program text, where an invented
|
|
800
|
+
# name would come back out of `reins_program` as if it had been typed.
|
|
801
|
+
clauses.append(f"{tier} {kind} " + " and ".join(parts))
|
|
802
|
+
rows.extend(quotes)
|
|
803
|
+
if not clauses:
|
|
804
|
+
return cession, []
|
|
805
|
+
return (clauses[0] if len(clauses) == 1 else clauses), rows
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def policy_limit(obj: Any) -> float | None:
|
|
809
|
+
"""The largest occurrence limit the program declares, or ``None``.
|
|
810
|
+
|
|
811
|
+
The per-claim window, which is what bounds any occurrence cession: a layer
|
|
812
|
+
attaching at or above it covers nothing, because no single claim can reach
|
|
813
|
+
it. Read off the object's own ``exp_limit``, taking the maximum over a
|
|
814
|
+
profile, which is the same reading the library's
|
|
815
|
+
``charts._emit_reins._claim_window`` takes.
|
|
816
|
+
|
|
817
|
+
Parameters
|
|
818
|
+
----------
|
|
819
|
+
obj : Any
|
|
820
|
+
Any built object. Only an ``Aggregate`` carries an ``exp_limit``.
|
|
821
|
+
|
|
822
|
+
Returns
|
|
823
|
+
-------
|
|
824
|
+
float or None
|
|
825
|
+
``None`` where the cover is unlimited, where any one line of a profile
|
|
826
|
+
is unlimited (the book's per-claim cover is then unbounded, whatever the
|
|
827
|
+
other lines say), or where the object declares no such clause at all.
|
|
828
|
+
An infinite limit is not a window, so it is reported as the absence of
|
|
829
|
+
one rather than as ``inf``: the caller's question is "is there a ceiling
|
|
830
|
+
here", and ``None`` is the honest answer when there is not.
|
|
831
|
+
"""
|
|
832
|
+
spec = getattr(obj, "spec", None)
|
|
833
|
+
if not isinstance(spec, dict):
|
|
834
|
+
return None
|
|
835
|
+
limit = spec.get("exp_limit", math.inf)
|
|
836
|
+
try:
|
|
837
|
+
limit = float(max(limit)) if hasattr(limit, "__len__") else float(limit)
|
|
838
|
+
except (TypeError, ValueError):
|
|
839
|
+
return None
|
|
840
|
+
return limit if math.isfinite(limit) else None
|