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,778 @@
1
+ """The Pricing group: validate a form, call the method, serve the exhibits.
2
+
3
+ Five runners, one shape each. ``run_pricing_preview`` completes the pentagon
4
+ and answers with scalars, because a preview line prints numbers.
5
+ ``run_calibration``, ``run_natural_allocation`` and ``run_evaluation`` hand back
6
+ library exhibit envelopes, because everything else on this pane is a table the
7
+ library owns. ``run_ruin`` serves the Pr Ruin pill: the ``ruin`` chart document
8
+ and the ``ruin`` exhibit envelopes from one press, both built by the library on
9
+ the same seed (``dev/plan-pk-tab.md``).
10
+
11
+ Two of them calibrate and differ only in what they then ask for.
12
+ ``run_calibration`` serves the receipt and the parts priced on their own
13
+ (``pricing.calibrate``, ``pricing.stand_alone``); ``run_natural_allocation``
14
+ serves the one premium split across those parts (``pricing.allocate``). They are
15
+ two presses rather than one because the allocation is real work on an
16
+ occurrence program, where it builds the joint distribution of gross and ceded,
17
+ and a reader who wants a calibration should not pay for one they did not ask
18
+ for.
19
+
20
+ There is no pandas in this file, and that is the point of it. Through 1.0.0a84
21
+ it held the other half of the pane: a ``_BasisView`` duck type presenting one
22
+ column of ``reins_density_df`` to an unbound ``Aggregate.calibrate_distortions``,
23
+ a pentagon completed row by row, a subtraction producing the difference rows, and
24
+ four ``tables.FORMATS`` entries saying how the results should print. All of it
25
+ was this repo deciding what a price means.
26
+
27
+ The library owns that now. ``calibrate_distortions`` and ``evaluate`` return
28
+ ``CalibrationResult`` and ``EvaluationResult``; the registry dispatches
29
+ ``pricing.calibrate``, ``pricing.stand_alone``, ``pricing.allocate`` and
30
+ ``pricing.evaluate`` on those, with the frames materialized on the result, the
31
+ captions written upstream and the formats resolved into the document.
32
+ ``calibrate_distortions(reins_view=...)`` does what the shim did and more, since
33
+ the library knows five views where the shim knew three.
34
+
35
+ See ``dev/plan-pricing-exhibits.md``, and for the shipped contract this codes
36
+ against, ``aggregate_REFACTOR/dev/plan-pricing-exhibits-LIB.md`` section 4.
37
+ The stand-alone / allocate split and the allocation route are
38
+ ``dev/plan-pricing-natural-allocation.md`` phase B1.
39
+
40
+ Notes
41
+ -----
42
+ Every ``ValueError`` raised on this path is written to be read by a person, so
43
+ each route turns one into an HTTP 400 whose ``detail`` is the message verbatim.
44
+ Three reach the app: the unbounded anchor guard on ``p = 1``, a loss-ratio target
45
+ implying a premium above the assets, and the "exactly one of" validations.
46
+
47
+ **The standing rule that makes this safe on a shared object.** Pricing writes
48
+ result attributes onto the object it prices (``distortions``,
49
+ ``distortion_df``, ``calibration_df``), and since a110 the object cache is
50
+ deliberately shared while the recipe base is not, so two sessions can be pricing
51
+ one object at once. That residue is accepted, on measured grounds: nothing in
52
+ this repo reads any of those attributes back, and every request recomputes from
53
+ its own form. The one condition attached to that acceptance is a rule for this
54
+ file, so it is written here rather than left as a habit.
55
+
56
+ **Expenses enter only through the premium leg, and the arithmetic lives here,
57
+ once.** ``octet.premium`` everywhere in this group is a technical premium, net
58
+ of expenses, and a reader pricing against a real quote types a gross one. With
59
+ an ``expense_ratio`` a premium target is read as gross and the engine is fed
60
+ ``premium * (1 - expense_ratio)``; a CoC or LR target calibrates exactly as
61
+ before, the ratio only translating the resolved technical premium into a gross
62
+ reading on the preview. The flat-ratio identity ``gross = net / (1 - e)``
63
+ matches ``pnl_program``'s own ``expense_ratio`` meaning (gross expense as a
64
+ fraction of premium). If the expense model ever grows past a flat ratio, the
65
+ arithmetic moves upstream; it does not grow a second home here or in the SPA,
66
+ whose forms hold no arithmetic.
67
+
68
+ **Every call from here passes its own target and its own distortions
69
+ explicitly. Never lean on a library default that reads ``self.distortions``.**
70
+ A method that falls back to the stored fit would serve this request an answer
71
+ struck for whoever priced the object last, silently and plausibly.
72
+ ``Portfolio.price`` raises when ``distortions`` is unset, which is the guard
73
+ that would otherwise have hidden the problem by making it look like a bug in
74
+ the first caller rather than in the second. ``tests/test_sessions.py`` pins the
75
+ residue directly: two sessions calibrating one shared object at different
76
+ targets get the answers their own forms imply.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ from typing import Any
82
+
83
+ from .capability import can_natural_allocation
84
+ from .library_notes import library_warnings
85
+
86
+ #: Both perspectives travel in every response. The frames are tiny, and bundling
87
+ #: is what lets the app's RAW / INSURER toggle flip with no recompute and no
88
+ #: server-side result cache: a ``CalibrationResult`` is not in the object cache,
89
+ #: so a second request would have to calibrate again to answer the other reading.
90
+ _PERSPECTIVES = ("raw", "insurer")
91
+
92
+
93
+ def _one_target(coc: float | None, lr: float | None,
94
+ premium: float | None = None) -> dict:
95
+ """Exactly one pricing target, in the caller's own vocabulary.
96
+
97
+ Three members since 1.0.0a100. ``price_pentagon`` has always accepted a
98
+ premium target (``P=``) beside ``ROE`` and ``LR``, so the app's three forms
99
+ are one form over that signature and this is where the third spelling
100
+ enters. :func:`_pentagon_target` translates to the pentagon's vocabulary;
101
+ ``calibrate_distortions`` speaks the caller's, which is why the two
102
+ spellings stay separate rather than one being translated at the call site.
103
+ """
104
+ named = [name for name, value in
105
+ (("coc", coc), ("lr", lr), ("premium", premium))
106
+ if value is not None]
107
+ if len(named) != 1:
108
+ raise ValueError(
109
+ "pass exactly one of coc (CoC/ROE), lr (loss ratio) "
110
+ "or premium (P)")
111
+ return {named[0]: {"coc": coc, "lr": lr, "premium": premium}[named[0]]}
112
+
113
+
114
+ #: The caller's spelling of a pricing target, to the pentagon's own. The
115
+ #: pentagon's keywords are the canonical stat names, which is why they are
116
+ #: capitalized and the caller's are not.
117
+ _PENTAGON_TARGET = {"coc": "ROE", "lr": "LR", "premium": "P"}
118
+
119
+
120
+ def _pentagon_target(target: dict) -> dict:
121
+ """One caller-spelled target as the keyword ``price_pentagon`` takes."""
122
+ (name, value), = target.items()
123
+ return {_PENTAGON_TARGET[name]: value}
124
+
125
+
126
+ def _one_anchor(p: float | None, a: float | None) -> dict:
127
+ """Exactly one capital anchor, as the keyword the library takes."""
128
+ if (p is None) == (a is None):
129
+ raise ValueError("pass exactly one of p (VaR probability) or a (assets)")
130
+ return {"p": p} if p is not None else {"a": a}
131
+
132
+
133
+ def _technical_target(target: dict, expense_ratio: float | None) -> dict:
134
+ """A premium target read as gross, scaled to the technical premium.
135
+
136
+ Parameters
137
+ ----------
138
+ target : dict
139
+ The single validated target, from :func:`_one_target`.
140
+ expense_ratio : float or None
141
+ Gross expense as a fraction of premium, in ``[0, 1)``, or None.
142
+
143
+ Returns
144
+ -------
145
+ dict
146
+ The target the engine should see: ``premium * (1 - e)`` for a premium
147
+ target with a non-zero ratio, otherwise the target unchanged. A CoC or
148
+ LR target never moves: the ratio only changes how the resolved premium
149
+ is *reported*, which is the caller's job on the way out.
150
+ """
151
+ if expense_ratio and "premium" in target:
152
+ return {"premium": target["premium"] * (1.0 - expense_ratio)}
153
+ return target
154
+
155
+
156
+ def _envelopes(result: Any, name: str) -> dict[str, dict]:
157
+ """One exhibit on one result object, under both perspectives.
158
+
159
+ Parameters
160
+ ----------
161
+ result : CalibrationResult | EvaluationResult
162
+ What the pricing call returned. The exhibit registry dispatches on its
163
+ type, which is why these methods return objects rather than frames.
164
+ name : str
165
+ Registry name, e.g. ``'pricing.allocate'``. The dot is part of the name
166
+ and not a namespace this repo has to take apart.
167
+
168
+ Returns
169
+ -------
170
+ dict
171
+ Perspective to envelope payload, the same shape
172
+ ``GET /objects/{id}/exhibit/{name}`` serves. That route hands back
173
+ deterministic bytes because its ETag hashes them; these ride inside a
174
+ response model, so the parsed payload is the useful form.
175
+ """
176
+ from aggregate import exhibits as agg_exhibits
177
+
178
+ from .tables import MAX_ROWS
179
+
180
+ return {
181
+ perspective: agg_exhibits.build_exhibit(
182
+ result, name, perspective, max_rows=MAX_ROWS).to_payload()
183
+ for perspective in _PERSPECTIVES
184
+ }
185
+
186
+
187
+ def _kind_of(obj: Any) -> str:
188
+ """The wire name for what was priced."""
189
+ name = type(obj).__name__
190
+ return {"Portfolio": "port", "PnL": "pnl"}.get(name, "agg")
191
+
192
+
193
+ def _coc_for_premium(obj: Any, anchor: dict, premium: float,
194
+ basis: str | None) -> float:
195
+ """The cost of capital a premium implies, at one capital anchor.
196
+
197
+ Parameters
198
+ ----------
199
+ obj : Aggregate | Portfolio
200
+ The live object.
201
+ anchor : dict
202
+ The single capital anchor, already validated, as ``price_pentagon``
203
+ takes it.
204
+ premium : float
205
+ The premium target.
206
+ basis : str or None
207
+ The reinsurance view, so both legs resolve on the distribution the
208
+ calibration is about to be struck on.
209
+
210
+ Returns
211
+ -------
212
+ float
213
+
214
+ Notes
215
+ -----
216
+ The bridge between the two vocabularies. ``price_pentagon`` accepts a
217
+ premium target and ``calibrate_distortions`` does not, so a premium becomes
218
+ the cost of capital it implies before the fit. The pentagon identity is
219
+ never written here: the library solves ``{L, a, P}`` and reports ``ROE``,
220
+ and this reads it off. Used by :func:`run_calibration` and by
221
+ ``bounds._calibrate_for_envelope``, which asked the same question with its
222
+ own arithmetic through 1.0.0a100.
223
+
224
+ A refusal on the way (the unbounded anchor guard, a premium above the
225
+ assets) is the library's own ``ValueError`` and travels to the caller
226
+ unchanged, which is what puts its sentence in the preview line.
227
+ """
228
+ row = obj.price_pentagon(**anchor, P=premium, reins_view=basis).iloc[0]
229
+ return _scalar(row["ROE"])
230
+
231
+
232
+ def run_pricing_preview(
233
+ obj: Any,
234
+ *,
235
+ p: float | None = None,
236
+ a: float | None = None,
237
+ coc: float | None = None,
238
+ lr: float | None = None,
239
+ premium: float | None = None,
240
+ basis: str | None = None,
241
+ expense_ratio: float | None = None,
242
+ ) -> dict:
243
+ """Complete the pentagon and report it as scalars, with no calibration.
244
+
245
+ Parameters
246
+ ----------
247
+ obj : Aggregate | Portfolio
248
+ The live object.
249
+ p, a : float, optional
250
+ Exactly one capital anchor: a VaR probability, or an asset level the
251
+ library snaps to its grid.
252
+ coc, lr, premium : float, optional
253
+ Exactly one pricing target.
254
+ basis : str, optional
255
+ Which reinsurance view to answer on, passed through as ``reins_view``.
256
+ Both legs of the anchor come off the named view, so the reading is the
257
+ one a reader who chose that calibration basis is about to get.
258
+ expense_ratio : float, optional
259
+ Gross expense as a fraction of premium, in ``[0, 1)``. A premium
260
+ target is then read as gross and the pentagon is fed
261
+ ``premium * (1 - e)``; whatever the target, the response reports
262
+ ``gross_premium``, the resolved technical premium grossed back up.
263
+ Refused for a P&L, whose ledger states its own expenses.
264
+
265
+ Returns
266
+ -------
267
+ dict
268
+ Matches :class:`aggregate_api.models.PricingPreviewResponse`: the
269
+ pentagon octet under wire names, plus the probability the caller named.
270
+ ``gross_premium`` rides when a ratio was sent. On a P&L it is the
271
+ ledger's own stated gross, off ``economic_ratios_df``'s gross (first)
272
+ block, and ``net_premium`` and ``net_of_expense_premium`` are ``P`` and
273
+ ``P - E`` off the **closing** (last) block, which is the position the
274
+ whole program has worked.
275
+
276
+ Notes
277
+ -----
278
+ This feeds the Calibrate form's live preview line, so it is deliberately the
279
+ cheapest question in the group: one completion of five numbers and three
280
+ ratios, no distortion fitting, no allocation. It is also where the library's
281
+ unbounded anchor guard reaches the reader as a sentence, since a preview that
282
+ refuses says why in the place they are already looking rather than after they
283
+ press the button.
284
+
285
+ ``p`` is echoed, not resolved. A caller who anchored on assets gets null, and
286
+ the asset level is the answer they asked for; resolving the probability back
287
+ would mean reaching for a grid distribution on a named view, which is a
288
+ private surface and buys a number the line does not print.
289
+
290
+ A P&L resolves to its wrapped engine, mirroring the Bounds group's own
291
+ see-through (aggregate 1.0.0a375, [Bounds-PnL-Engine]): the Bounds form
292
+ resolves its premium and asset pair through this preview before it sweeps,
293
+ and the sweep answers on the engine's loss distribution, so the pentagon
294
+ has to be the engine's too. Preview only: ``run_calibration`` and the
295
+ other runners still refuse a P&L, whose ledger states a premium on every
296
+ row and has no single pentagon of its own to calibrate.
297
+ """
298
+ target = _one_target(coc, lr, premium)
299
+ anchor = _one_anchor(p, a)
300
+ is_pnl = _kind_of(obj) == "pnl"
301
+ if is_pnl and expense_ratio is not None:
302
+ raise ValueError("a P&L states its own expenses in the ledger; "
303
+ "drop the expense ratio")
304
+ target = _technical_target(target, expense_ratio)
305
+ source = obj
306
+ if not hasattr(obj, "price_pentagon"):
307
+ engine = getattr(obj, "engine", None)
308
+ if hasattr(engine, "price_pentagon"):
309
+ obj = engine
310
+ else:
311
+ raise ValueError("pricing requires an Aggregate or a Portfolio")
312
+
313
+ row = obj.price_pentagon(**anchor, **_pentagon_target(target),
314
+ reins_view=basis).iloc[0]
315
+ out = {
316
+ "p": p,
317
+ "assets": _scalar(row["a"]),
318
+ "loss": _scalar(row["L"]),
319
+ "margin": _scalar(row["M"]),
320
+ "premium": _scalar(row["P"]),
321
+ "capital": _scalar(row["Q"]),
322
+ "lr": _scalar(row["LR"]),
323
+ "pq": _scalar(row["PQ"]),
324
+ "coc": _scalar(row["ROE"]),
325
+ }
326
+ # `premium` stays technical, as documented; the gross reading is a second
327
+ # field rather than a relabeling. For a premium target the gross is the
328
+ # number the caller typed, recovered exactly by the flat-ratio identity.
329
+ if expense_ratio is not None and out["premium"] is not None:
330
+ out["gross_premium"] = out["premium"] / (1.0 - expense_ratio)
331
+ if is_pnl:
332
+ # The ledger's own figures, not an assumed ratio. `economic_ratios_df`'s
333
+ # amounts satisfy M == P - L - E on every block, so a block's `P - E` is
334
+ # that block's premium net of its expenses.
335
+ #
336
+ # **Two blocks, not one.** The gross (first) block is the book before
337
+ # any cession and is what `gross_premium` has always meant. The pair the
338
+ # form needs is the closing (last) block: the position once every tier's
339
+ # program has worked, which is what a reader looking at a peeled P&L is
340
+ # pricing. Through a198 this read the gross block for both, so the
341
+ # Bounds form on `Capstone.PnL` opened at 21,000 where the net position
342
+ # is 4,881, and then swept a range the book does not occupy. On a
343
+ # single-group ledger the closing block *is* the gross block and all
344
+ # three figures agree, which is why the error was invisible until a
345
+ # program with a cession reached the form.
346
+ blocks = source.economic_ratios_df
347
+ gross = _scalar(blocks.iloc[0]["P"])
348
+ closing = blocks.iloc[-1]
349
+ net = _scalar(closing["P"])
350
+ expense = _scalar(closing["E"])
351
+ out["gross_premium"] = gross
352
+ out["net_premium"] = net
353
+ if net is not None and expense is not None:
354
+ out["net_of_expense_premium"] = net - expense
355
+ return out
356
+
357
+
358
+ def run_calibration(
359
+ obj: Any,
360
+ *,
361
+ p: float | None = None,
362
+ a: float | None = None,
363
+ coc: float | None = None,
364
+ lr: float | None = None,
365
+ premium: float | None = None,
366
+ basis: str | None = None,
367
+ expense_ratio: float | None = None,
368
+ ) -> dict:
369
+ """Fit the standard distortion set, and serve what it says.
370
+
371
+ Parameters
372
+ ----------
373
+ obj : Aggregate | Portfolio
374
+ The live object.
375
+ p, a : float, optional
376
+ Exactly one capital anchor.
377
+ coc, lr, premium : float, optional
378
+ Exactly one pricing target. The library owns the loss-ratio conversion
379
+ since 1.0.0a262, including its refusal: a loss ratio can imply a premium
380
+ above the assets, which is a position with negative capital, and it says
381
+ so rather than reporting a receipt of garbage.
382
+ basis : str, optional
383
+ Calibration basis for a reinsured Aggregate, passed as ``reins_view``.
384
+ expense_ratio : float, optional
385
+ Gross expense as a fraction of premium, in ``[0, 1)``. A premium
386
+ target is read as gross and the fit runs on ``premium * (1 - e)``;
387
+ with a CoC or LR target the ratio changes nothing here, only the
388
+ preview's gross reading. See the module docstring.
389
+
390
+ Returns
391
+ -------
392
+ dict
393
+ Matches :class:`aggregate_api.models.PricingExhibitsResponse`: the
394
+ ``pricing.calibrate`` and ``pricing.stand_alone`` envelopes, each under
395
+ both perspectives, and any warnings the library raised on the way.
396
+
397
+ Notes
398
+ -----
399
+ Two exhibits from one call because the pane draws two subtabs from one press.
400
+ ``pricing.stand_alone``'s frames are materialized lazily off the result, so
401
+ the parts are priced here, inside the warning capture, rather than on the
402
+ reader's next click.
403
+
404
+ **The second exhibit is the parts priced alone, not the whole split across
405
+ them.** Through 1.0.0a102 this bundle carried ``pricing.allocate``, and for a
406
+ book that was the ``analyze_distortions`` sweep, which is the more expensive
407
+ of the two questions and the one a Calibrate press does not ask. The split
408
+ moved that sweep behind its own press, :func:`run_natural_allocation`, and
409
+ put the stand-alone reading here: per-part ``Distortion.price`` calls on
410
+ families that are already fitted.
411
+
412
+ A distortion the library declines to price (the mass distortion on an
413
+ unbounded book) is a warning, not a failure: the exhibit carries the rows
414
+ that did answer and the warning says which did not. That is why the capture
415
+ wraps the envelope building and not just the calibration.
416
+
417
+ **A premium target costs one extra library call.**
418
+ ``calibrate_distortions`` takes a cost of capital or a loss ratio and not a
419
+ premium, so :func:`_coc_for_premium` completes the pentagon first and hands
420
+ over the cost of capital it reports. Two library calls chained, with no
421
+ arithmetic here: the pentagon identity stays upstream where it belongs. The
422
+ tidier form is ``calibrate_distortions(P=...)``, which is an open ask.
423
+ """
424
+ target = _one_target(coc, lr, premium)
425
+ anchor = _one_anchor(p, a)
426
+ if not hasattr(obj, "calibrate_distortions"):
427
+ raise ValueError("calibration requires an Aggregate or a Portfolio")
428
+ target = _technical_target(target, expense_ratio)
429
+ if "premium" in target:
430
+ target = {"coc": _coc_for_premium(obj, anchor, target["premium"], basis)}
431
+
432
+ warns: list[str] = []
433
+ with library_warnings() as caught:
434
+ result = obj.calibrate_distortions(**target, **anchor, reins_view=basis)
435
+ exhibits = {name: _envelopes(result, name)
436
+ for name in ("pricing.calibrate", "pricing.stand_alone")}
437
+ warns.extend(caught)
438
+ return {"kind": _kind_of(obj), "exhibits": exhibits, "warnings": warns}
439
+
440
+
441
+ def _allocation_basis(obj: Any, basis: str | None) -> str | None:
442
+ """The one reinsurance view an allocation can be struck on, or a refusal.
443
+
444
+ Parameters
445
+ ----------
446
+ obj : Aggregate | Portfolio
447
+ The live object, already known to have parts to allocate across.
448
+ basis : str or None
449
+ What the form sent, which is ``None`` where the object offers no basis
450
+ row at all.
451
+
452
+ Returns
453
+ -------
454
+ str or None
455
+ The view to pass as ``reins_view``.
456
+
457
+ Notes
458
+ -----
459
+ Structural rather than a preference, in both directions. An occurrence
460
+ program's allocation splits the **gross** premium into its ceded and net
461
+ parts, so a fit struck on net has no gross premium to split and there is
462
+ nothing for the tab to say; the library's own availability predicate makes
463
+ the same test on the result. A book is locked to net by the 1.0.0a100 ruling
464
+ that governs the Calibrate row beside this one: reinsurance is placed at the
465
+ unit level, so a book has no cession of its own to choose.
466
+
467
+ An ``Aggregate`` that states nothing is read as gross rather than as the
468
+ library's default. That default is the object's own distribution, which for
469
+ a reinsured aggregate is the net view, and answering a request for the
470
+ allocation by striking the one calibration that cannot produce it would be a
471
+ refusal wearing a 500. The app always states a basis here; this is for
472
+ everyone else.
473
+ """
474
+ if _kind_of(obj) == "port":
475
+ if basis not in (None, "net"):
476
+ raise ValueError(
477
+ "a book is allocated on its net view: reinsurance is placed at "
478
+ "the unit level, so there is no book-wide basis to choose. Pass "
479
+ "net, or drop the basis")
480
+ return basis
481
+ if basis is None:
482
+ return "gross"
483
+ if basis != "gross":
484
+ raise ValueError(
485
+ "the natural allocation splits a gross premium; calibrate on gross")
486
+ return basis
487
+
488
+
489
+ def run_natural_allocation(
490
+ obj: Any,
491
+ *,
492
+ p: float | None = None,
493
+ a: float | None = None,
494
+ coc: float | None = None,
495
+ lr: float | None = None,
496
+ premium: float | None = None,
497
+ basis: str | None = None,
498
+ expense_ratio: float | None = None,
499
+ ) -> dict:
500
+ """Split one calibrated premium across the parts, and serve the exhibit.
501
+
502
+ Parameters
503
+ ----------
504
+ obj : Aggregate | Portfolio
505
+ The live object. A book, whose parts are its units, or an aggregate
506
+ carrying an occurrence program, whose parts are the ceded and net halves
507
+ of it.
508
+ p, a : float, optional
509
+ Exactly one capital anchor, as :func:`run_calibration` takes it.
510
+ coc, lr, premium : float, optional
511
+ Exactly one pricing target, as :func:`run_calibration` takes it.
512
+ basis : str, optional
513
+ The calibration basis, narrowed by :func:`_allocation_basis`.
514
+ expense_ratio : float, optional
515
+ As :func:`run_calibration` takes it: a premium target is read as
516
+ gross and the fit runs on ``premium * (1 - e)``.
517
+
518
+ Returns
519
+ -------
520
+ dict
521
+ Matches :class:`aggregate_api.models.PricingExhibitsResponse`, carrying
522
+ the ``pricing.allocate`` envelope under both perspectives.
523
+
524
+ Notes
525
+ -----
526
+ **Not** ``bounds.run_allocation``, which sweeps every distortion consistent
527
+ with a premium and reports each unit's range. The names are close because
528
+ both split a total across units; this one is the pricing pane's single
529
+ answer under fitted families, that one is the Bounds group's interval. They
530
+ are imported into the same route module, so the longer name here is what
531
+ keeps the shorter one meaning what it always has.
532
+
533
+ **Stateless, like its siblings.** The calibration is struck again here rather
534
+ than read off the one a Calibrate press already made. Nothing about a
535
+ ``CalibrationResult`` is cached server side, so holding one would mean a
536
+ result cache keyed on a form body, and a press that recomputes is the same
537
+ decision the Evaluate route made.
538
+
539
+ Two different questions arrive at one registry name, which is the point of
540
+ the name. For a book it is ``analyze_distortions``, the per-unit premium
541
+ allocation the library has always served. For an occurrence program it is
542
+ the natural allocation off the joint distribution of gross and ceded, where
543
+ each family's distorted view of the gross sets the weights and ceded plus
544
+ net foot to gross exactly. The app draws whichever it is served and holds no
545
+ opinion about which arrived.
546
+
547
+ The occurrence path is the expensive one on this pane: it builds a joint
548
+ distribution rather than reading a density frame. That is why it sits behind
549
+ its own press.
550
+ """
551
+ target = _one_target(coc, lr, premium)
552
+ anchor = _one_anchor(p, a)
553
+ if not hasattr(obj, "calibrate_distortions"):
554
+ raise ValueError("allocation requires an Aggregate or a Portfolio")
555
+ if not can_natural_allocation(obj):
556
+ raise ValueError(
557
+ "there is nothing to allocate across: the natural allocation splits "
558
+ "one premium among parts, which means the units of a book or the "
559
+ "halves of an occurrence cession")
560
+ basis = _allocation_basis(obj, basis)
561
+ target = _technical_target(target, expense_ratio)
562
+ if "premium" in target:
563
+ target = {"coc": _coc_for_premium(obj, anchor, target["premium"], basis)}
564
+
565
+ warns: list[str] = []
566
+ with library_warnings() as caught:
567
+ result = obj.calibrate_distortions(**target, **anchor, reins_view=basis)
568
+ exhibits = {"pricing.allocate": _envelopes(result, "pricing.allocate")}
569
+ warns.extend(caught)
570
+ return {"kind": _kind_of(obj), "exhibits": exhibits, "warnings": warns}
571
+
572
+
573
+ def run_evaluation(
574
+ obj: Any,
575
+ *,
576
+ premium: float | None = None,
577
+ basis: str | None = None,
578
+ p: float | None = None,
579
+ a: float | None = None,
580
+ expense_ratio: float | None = None,
581
+ ) -> dict:
582
+ """The breakeven acceptability panel, as the library's own exhibit.
583
+
584
+ Parameters
585
+ ----------
586
+ obj : Aggregate | Portfolio | PnL
587
+ The live object; all three expose ``evaluate``.
588
+ premium : float, optional
589
+ The consideration to measure against. Only an ``Aggregate`` or a
590
+ ``Portfolio`` takes one, and only when its own exposure states none.
591
+ basis : str, optional
592
+ Which premium is being input, for a reinsured ``Aggregate``: the gross
593
+ one, the one net of the occurrence program, or the net. Passed as
594
+ ``reins_view``.
595
+ p, a : float, optional
596
+ At most one asset anchor. The acceptability solve is a function of it
597
+ since 1.0.0a261, so evaluating at the anchor a calibration was struck at
598
+ closes the round trip and recovers that calibration's parameters. Omit
599
+ both for the unlimited reading, which is the library's default and
600
+ reports four families rather than five: ``ccoc`` needs an asset level.
601
+ expense_ratio : float, optional
602
+ Gross expense as a fraction of premium, in ``[0, 1)``. A typed
603
+ premium is read as gross and evaluated at ``premium * (1 - e)``;
604
+ with no typed premium the ratio has nothing to scale and is ignored.
605
+ Refused for a P&L, whose ledger states its own expenses.
606
+
607
+ Returns
608
+ -------
609
+ dict
610
+ Matches :class:`aggregate_api.models.PricingExhibitsResponse`, carrying
611
+ the ``pricing.evaluate`` envelope under both perspectives.
612
+
613
+ Notes
614
+ -----
615
+ ``DegenerateEvaluationWarning`` is not an error and is not swallowed. It
616
+ fires when no breakeven level exists, either because the premium does not
617
+ cover the expected loss or because the position cannot lose. Both report
618
+ ``NaN`` in the panel, so the warning is what tells the reader which of the
619
+ two they are looking at.
620
+ """
621
+ if not hasattr(obj, "evaluate"):
622
+ raise ValueError(
623
+ "evaluation requires an Aggregate, a Portfolio or a P&L")
624
+
625
+ is_pnl = type(obj).__name__ == "PnL"
626
+ if is_pnl:
627
+ # Every ledger row is its own position with its own consideration, so
628
+ # there is no single premium to state and no single asset level to
629
+ # anchor on. The anchor question is genuinely open upstream rather than
630
+ # merely unimplemented; see the plan's decision 4.
631
+ if premium is not None:
632
+ raise ValueError(
633
+ "a P&L carries its own premium in the ledger; drop the premium "
634
+ "argument and evaluate it as it stands")
635
+ if expense_ratio is not None:
636
+ raise ValueError(
637
+ "a P&L states its own expenses in the ledger; drop the "
638
+ "expense ratio")
639
+ if basis is not None or p is not None or a is not None:
640
+ raise ValueError(
641
+ "a P&L evaluates every row of its ledger on that row's own "
642
+ "terms; drop the basis and the asset anchor")
643
+
644
+ # The typed premium is read as gross when a ratio rides with it; the
645
+ # library sees the technical number, matching the calibrate runners.
646
+ if premium is not None and expense_ratio:
647
+ premium = premium * (1.0 - expense_ratio)
648
+
649
+ warns: list[str] = []
650
+ with library_warnings() as caught:
651
+ result = (obj.evaluate() if is_pnl
652
+ else obj.evaluate(premium, p=p, a=a, reins_view=basis))
653
+ exhibits = {"pricing.evaluate": _envelopes(result, "pricing.evaluate")}
654
+ warns.extend(caught)
655
+ return {"kind": _kind_of(obj), "exhibits": exhibits, "warnings": warns}
656
+
657
+
658
+ def run_ruin(
659
+ obj: Any,
660
+ *,
661
+ p: float | None = None,
662
+ a: float | None = None,
663
+ coc: float | None = None,
664
+ lr: float | None = None,
665
+ premium: float | None = None,
666
+ ruin_p: float | None = None,
667
+ u: float | None = None,
668
+ seed: int | None = None,
669
+ sample: bool = False,
670
+ n_plot: int | None = None,
671
+ detail: int | None = None,
672
+ ) -> dict:
673
+ """The eventual-ruin reading: the two-panel chart and its stats strip.
674
+
675
+ Parameters
676
+ ----------
677
+ obj : Aggregate
678
+ The live object. Must serve the ``ruin`` chart, which the library's
679
+ own predicate limits to an updated aggregate with a Poisson or
680
+ renewal frequency.
681
+ p, a : float, optional
682
+ Exactly one capital anchor, the Calibrate form's.
683
+ coc, lr, premium : float, optional
684
+ Exactly one pricing target. The ruin engine takes a loss ratio, so
685
+ ``lr`` passes through and the other two are completed to one through
686
+ :meth:`price_pentagon`, the same bridge :func:`run_calibration` uses
687
+ for a premium target: the pentagon identity stays upstream.
688
+ ruin_p, u : float, optional
689
+ At most one capital level: a probability of eventual default the
690
+ library resolves through the ruin function's capital lookup, or the
691
+ initial surplus directly. With neither, the library's teaching
692
+ default applies.
693
+ seed : int, optional
694
+ rng seed for the simulated paths; omitted, the library's fixed
695
+ teaching seed keeps the document deterministic.
696
+ sample : bool
697
+ The Sample action. Draws one fresh integer seed here so the chart
698
+ and the exhibit describe the same draw; the chart's ``meta.seed``
699
+ reports it.
700
+ n_plot, detail : int, optional
701
+ Paths drawn, and the per-path display budget, passed only when set.
702
+
703
+ Returns
704
+ -------
705
+ dict
706
+ Matches :class:`aggregate_api.models.RuinResponse`: the ``ruin``
707
+ chart document parsed from its canonical JSON, and the ``ruin``
708
+ exhibit envelopes under both perspectives, built on the
709
+ :class:`~aggregate.results.RuinResult` the same inputs produce.
710
+
711
+ Notes
712
+ -----
713
+ One POST answers with both halves because both move together under the
714
+ debounced form, and because neither can travel the generic GETs: the
715
+ chart needs options those routes do not carry, and the exhibit registers
716
+ on the result object rather than on the cached ``obj``. The exhibit and
717
+ the chart run the same capped simulation twice, deliberately: sharing
718
+ one seed makes the strip describe the drawn paths, and lifting the
719
+ simulation out to share the arrays would be this service reaching past
720
+ the library's public surface.
721
+ """
722
+ from aggregate import charts as agg_charts
723
+
724
+ target = _one_target(coc, lr, premium)
725
+ if "lr" not in target:
726
+ anchor = _one_anchor(p, a)
727
+ if not hasattr(obj, "price_pentagon"):
728
+ raise ValueError("the ruin reading requires an Aggregate")
729
+ row = obj.price_pentagon(**anchor,
730
+ **_pentagon_target(target)).iloc[0]
731
+ target = {"lr": _scalar(row["LR"])}
732
+ if "ruin" not in agg_charts.available_charts(obj):
733
+ raise ValueError(
734
+ "no ruin reading for this object: it needs an updated aggregate "
735
+ "with a Poisson or renewal (wait) frequency")
736
+ if ruin_p is not None and u is not None:
737
+ raise ValueError("pass at most one of ruin_p (a default probability) "
738
+ "or u (an initial surplus)")
739
+ if sample:
740
+ import numpy as np
741
+ seed = int(np.random.default_rng().integers(1, 2 ** 31 - 1))
742
+
743
+ options: dict = {"lr": target["lr"]}
744
+ if ruin_p is not None:
745
+ options["p"] = ruin_p
746
+ if u is not None:
747
+ options["u"] = u
748
+ if seed is not None:
749
+ options["seed"] = seed
750
+ warns: list[str] = []
751
+ with library_warnings() as caught:
752
+ chart_options = dict(options)
753
+ if n_plot is not None:
754
+ chart_options["n_plot"] = n_plot
755
+ if detail is not None:
756
+ chart_options["detail"] = detail
757
+ doc = agg_charts.build_chart_doc(obj, "ruin", **chart_options)
758
+ result = obj.eventual_ruin(**options)
759
+ exhibits = {"ruin": _envelopes(result, "ruin")}
760
+ warns.extend(caught)
761
+ import json
762
+ chart = json.loads(agg_charts.canonical_json(doc))
763
+ return {"kind": _kind_of(obj), "chart": chart, "exhibits": exhibits,
764
+ "warnings": warns}
765
+
766
+
767
+ def _scalar(value):
768
+ """Coerce numpy scalars and non-finite floats for JSON."""
769
+ import math
770
+ import numpy as np
771
+
772
+ if value is None:
773
+ return None
774
+ if isinstance(value, (np.integer, np.floating)):
775
+ value = value.item()
776
+ if isinstance(value, float) and not math.isfinite(value):
777
+ return None
778
+ return value