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.
Files changed (63) hide show
  1. aggregate_api/__init__.py +41 -0
  2. aggregate_api/__main__.py +154 -0
  3. aggregate_api/app.py +206 -0
  4. aggregate_api/audit.py +395 -0
  5. aggregate_api/bounds.py +331 -0
  6. aggregate_api/cache.py +319 -0
  7. aggregate_api/capability.py +823 -0
  8. aggregate_api/completion.py +219 -0
  9. aggregate_api/config.py +363 -0
  10. aggregate_api/cors.py +61 -0
  11. aggregate_api/examples.py +620 -0
  12. aggregate_api/layer_pricing.py +840 -0
  13. aggregate_api/library.py +94 -0
  14. aggregate_api/library_notes.py +96 -0
  15. aggregate_api/models.py +1407 -0
  16. aggregate_api/net.py +281 -0
  17. aggregate_api/pnl.py +101 -0
  18. aggregate_api/pricing.py +778 -0
  19. aggregate_api/resources.py +257 -0
  20. aggregate_api/routes/__init__.py +8 -0
  21. aggregate_api/routes/decl.py +327 -0
  22. aggregate_api/routes/examples.py +82 -0
  23. aggregate_api/routes/meta.py +282 -0
  24. aggregate_api/routes/objects.py +4119 -0
  25. aggregate_api/routes/status.py +466 -0
  26. aggregate_api/serializers.py +565 -0
  27. aggregate_api/sessions.py +353 -0
  28. aggregate_api/static/aggregate-api-logo-512.png +0 -0
  29. aggregate_api/static/aggregate-api-logo.png +0 -0
  30. aggregate_api/static/aggregate-api-trim.png +0 -0
  31. aggregate_api/static/android-chrome-192x192.png +0 -0
  32. aggregate_api/static/android-chrome-512x512.png +0 -0
  33. aggregate_api/static/apple-touch-icon.png +0 -0
  34. aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
  35. aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
  36. aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
  37. aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
  38. aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
  39. aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
  40. aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
  41. aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
  42. aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
  43. aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
  44. aggregate_api/static/assets/main-CmoEiPit.js +9 -0
  45. aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
  46. aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
  47. aggregate_api/static/favicon-16x16.png +0 -0
  48. aggregate_api/static/favicon-32x32.png +0 -0
  49. aggregate_api/static/favicon.ico +0 -0
  50. aggregate_api/static/index.html +912 -0
  51. aggregate_api/static/lite.html +83 -0
  52. aggregate_api/static/logo.png +0 -0
  53. aggregate_api/static/site.webmanifest +14 -0
  54. aggregate_api/static/sw.js +78 -0
  55. aggregate_api/status.py +536 -0
  56. aggregate_api/status_page.html +546 -0
  57. aggregate_api/tables.py +316 -0
  58. aggregate_api-1.0.0.dist-info/METADATA +187 -0
  59. aggregate_api-1.0.0.dist-info/RECORD +63 -0
  60. aggregate_api-1.0.0.dist-info/WHEEL +5 -0
  61. aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
  62. aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
  63. 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