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,823 @@
1
+ """What an object can answer, computed rather than declared.
2
+
3
+ The app used to carry two hand-written tables saying what each kind cannot do
4
+ (``NA_TABS_BY_KIND`` and ``NA_MORE_BY_KIND`` in ``web/src/main.js``). The
5
+ library now knows the same thing properly, through
6
+ :func:`aggregate.exhibits.available_exhibits` and
7
+ :func:`aggregate.charts.available_charts`, and
8
+ ``aggregate/dev/exhibits-and-charts.md`` states the invariant: capability is
9
+ derived, never declared twice. This module is the api's side of that, and the
10
+ build response carries its output so the navigation can paint immediately
11
+ rather than after a second round trip.
12
+
13
+ Three kinds of leaf, and the distinction decides where a future one goes. An
14
+ **exhibit leaf** lights from :func:`exhibits_for` and needs no app change to
15
+ appear when the library registers a new exhibit. A **chart leaf** lights from
16
+ :func:`charts_for`. An **app leaf** (the Narrative pane, the pricing forms) is
17
+ gated on a flag, because it is a piece of app behavior rather than a library
18
+ document.
19
+
20
+ Notes
21
+ -----
22
+ **A flag earns its place or it is not here.** Anything the exhibit or chart
23
+ list already says must not be repeated as a flag: that is the second
24
+ declaration this module exists to delete. ``is_tower`` was dropped from the
25
+ plan's draft list for exactly that reason, since ``economic_waterfall`` is
26
+ registered against a tower predicate and a single group P&L therefore already
27
+ drops it from :func:`exhibits_for`. Each flag below names the consumer that
28
+ cannot get its answer any other way.
29
+
30
+ ``kind`` and ``has_reins`` are not repeated here either. Both already ride on
31
+ the build response (``has_reins`` through ``_summary_fields``, which the cache
32
+ hit path shares), and one field per fact is the whole point.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import inspect
38
+ from typing import Any
39
+
40
+ import numpy as np
41
+ from aggregate import Aggregate, PnL, Portfolio
42
+ from aggregate import charts as agg_charts
43
+ from aggregate import exhibits as agg_exhibits
44
+
45
+ #: Namespace prefix the library stamps on the sharpen verdict it merges into a
46
+ #: program's ``note{}`` (``aggregate._program._SHARPEN_NOTE``). The note body is
47
+ #: ``;``-separated and the author's own prose never carries the prefix, which is
48
+ #: what makes this a safe test rather than a substring guess.
49
+ SHARPEN_NOTE_PREFIX = "sharpen: "
50
+
51
+
52
+ def exhibits_for(obj: Any) -> list[dict]:
53
+ """The exhibits this object can serve, with titles and perspectives.
54
+
55
+ A passthrough of :func:`aggregate.exhibits.available_exhibits`, shaped the
56
+ same way ``GET /v1/objects/{id}/exhibits`` shapes it so the inline payload
57
+ and the standalone route cannot drift.
58
+
59
+ Parameters
60
+ ----------
61
+ obj : Any
62
+ A built object; an unregistered type yields an empty list.
63
+
64
+ Returns
65
+ -------
66
+ list of dict
67
+ ``{"name", "title", "perspectives"}`` in registry order.
68
+ """
69
+ return [
70
+ {
71
+ "name": name,
72
+ "title": agg_exhibits.EXHIBITS[name][0].title,
73
+ "perspectives": [p.value for p in perspectives],
74
+ }
75
+ for name, perspectives in agg_exhibits.available_exhibits(obj)
76
+ ]
77
+
78
+
79
+ def charts_for(obj: Any) -> list[str]:
80
+ """The chart names this object can serve.
81
+
82
+ A passthrough of :func:`aggregate.charts.available_charts`. Complete as of
83
+ library a244: every first-class kind publishes its own chart, and the app
84
+ draws nothing it does not find here.
85
+
86
+ Parameters
87
+ ----------
88
+ obj : Any
89
+
90
+ Returns
91
+ -------
92
+ list of str
93
+ Registry names, in registration order.
94
+ """
95
+ return list(agg_charts.available_charts(obj))
96
+
97
+
98
+ def primary_chart_for(obj: Any) -> str | None:
99
+ """The chart that is this object's own picture, or None.
100
+
101
+ :func:`charts_for` answers what *can* be drawn, which for a reinsured
102
+ aggregate is two things. This answers which one to draw when nothing else
103
+ has been asked for, which is exactly the Overview Plot leaf's question and
104
+ the one it used to answer with a per-kind table in the browser. A
105
+ passthrough of :func:`aggregate.charts.primary_chart`, so the answer moves
106
+ when the library's registrations move and never when this file does.
107
+
108
+ Parameters
109
+ ----------
110
+ obj : Any
111
+
112
+ Returns
113
+ -------
114
+ str or None
115
+ None where no registered chart claims the object, which is the app's
116
+ cue to say the picture does not exist yet rather than to approximate
117
+ one.
118
+ """
119
+ return agg_charts.primary_chart(obj)
120
+
121
+
122
+ def has_premium(obj: Any) -> bool:
123
+ """Does this object's exposure state a premium?
124
+
125
+ Consumer: the PnL button's form. :meth:`Aggregate.pnl_program` writes
126
+ ``derive premium`` when there is one (the technical premium grossed up
127
+ for the expense clause; upstream since ``aggregate`` 1.0.0a270) and sizes
128
+ it from a loss ratio when there is not, so this decides whether the app
129
+ offers a loss-ratio input at all rather than showing one that will be
130
+ ignored.
131
+
132
+ Mirrors the library's own test in ``aggregate._program._pnl_consideration``:
133
+ the sum of ``exp_premium``, which is an array on a portfolio and a scalar
134
+ on an aggregate.
135
+
136
+ Parameters
137
+ ----------
138
+ obj : Any
139
+
140
+ Returns
141
+ -------
142
+ bool
143
+ """
144
+ return premium_for(obj) is not None
145
+
146
+
147
+ def premium_for(obj: Any) -> float | None:
148
+ """This object's own premium, or None when its exposure states none.
149
+
150
+ Consumer: the Pricing group's Evaluate form, which prefills the premium box
151
+ from it. ``needs_premium`` says whether the reader **must** type one; this
152
+ says what to put in the box when they need not, so an exposure that states a
153
+ consideration is evaluated as it stands with the number visible rather than
154
+ with an empty field that silently means "use your own".
155
+
156
+ Parameters
157
+ ----------
158
+ obj : Any
159
+
160
+ Returns
161
+ -------
162
+ float or None
163
+ The total, summed over units on a portfolio. None where there is no
164
+ ``exp_premium`` at all (a ``PnL`` keeps its premium in its ledger) and
165
+ where the total is zero, which is the library's own reading of an
166
+ exposure written without a consideration.
167
+ """
168
+ premium = getattr(obj, "exp_premium", None)
169
+ if premium is None:
170
+ return None
171
+ try:
172
+ total = float(np.sum(np.asarray(premium, dtype=float)))
173
+ except (TypeError, ValueError):
174
+ return None
175
+ return total if total > 0.0 else None
176
+
177
+
178
+ def can_sharpen(obj: Any) -> bool:
179
+ """Is a grid audit worth offering for this object?
180
+
181
+ Consumer: the Sharpen button, which has no exhibit or chart of its own.
182
+
183
+ Two conditions. The object has to have the method at all, which is
184
+ ``Aggregate`` and ``Portfolio`` and nothing else, and it is read off the
185
+ object rather than from a kind list so a future host needs no edit here.
186
+ And the program must not already carry a sharpen verdict, since a second
187
+ audit of a grid the probe just confirmed is a slow no-op.
188
+
189
+ The verdict test is one-sided on purpose. A probe that **moved** the grid
190
+ writes ``hints{}`` and clears its note, so Sharpen stays live: probing again
191
+ from the new center can find more, and the library says as much when a walk
192
+ runs out of its bucket limit while still improving. A probe that
193
+ **confirmed** the grid writes the note and no hints, and that is the case
194
+ worth refusing.
195
+
196
+ Parameters
197
+ ----------
198
+ obj : Any
199
+
200
+ Returns
201
+ -------
202
+ bool
203
+ """
204
+ if not hasattr(obj, "sharpen"):
205
+ return False
206
+ note = str(getattr(obj, "note", "") or "")
207
+ verdicts = (chunk.strip() for chunk in note.split(";"))
208
+ return not any(v.startswith(SHARPEN_NOTE_PREFIX) for v in verdicts)
209
+
210
+
211
+ def has_sharpen(obj: Any) -> bool:
212
+ """Has a grid audit actually run on this object, leaving a table to read?
213
+
214
+ Consumer: the More group's Sharpen leaf, which shows what the probe tried.
215
+
216
+ **Not the negation of** :func:`can_sharpen`, and the two are not two views of
217
+ one fact. ``can_sharpen`` asks whether running the probe is worth offering;
218
+ this asks whether one has already run. An object that has never been
219
+ sharpened answers False to this and True to that; an object whose probe
220
+ *moved* the grid answers True to both, because the library says a walk that
221
+ ran out of its bucket limit while still improving is worth resuming.
222
+
223
+ Read off ``sharpen_df``, which the library leaves at ``None`` until
224
+ ``sharpen()`` runs (``_aggregate.py:814``, ``_portfolio.py:1041``), so this
225
+ is the frame's own account of whether it exists rather than a guess from the
226
+ note.
227
+
228
+ Parameters
229
+ ----------
230
+ obj : Any
231
+
232
+ Returns
233
+ -------
234
+ bool
235
+ """
236
+ df = getattr(obj, "sharpen_df", None)
237
+ return df is not None and not df.empty
238
+
239
+
240
+ #: The whole program views, in the order the two pricing forms draw them.
241
+ #:
242
+ #: A subset of ``Aggregate.reins_views``, which also carries ``ceded`` and
243
+ #: ``ceded occ``: those are a cession read as a position in its own right, which
244
+ #: is the seller's question, and both forms here ask the insurer's. The library
245
+ #: serves the ceded rows in the ``pricing.stand_alone`` RAW reading, which is
246
+ #: where that perspective belongs until there is a REINSURER one.
247
+ _CALIBRATION_BASES = ("gross", "net occ", "net")
248
+
249
+
250
+ def reins_bases_for(obj: Any) -> list[str]:
251
+ """Which reinsurance bases this object can be calibrated on.
252
+
253
+ Consumers: the Pricing group's "calibrate on" row, and the Evaluate form's
254
+ "premium is" row. Both offered all three of gross, net occ and net to any
255
+ reinsured object before a52, and on most objects at least one of the three
256
+ was a button that either 400'd or repeated a column already on screen.
257
+
258
+ Read straight off ``obj.reins_views`` since a85, filtered to the whole
259
+ program views and put in the order the forms draw them. That property is the
260
+ library's own answer to what distributions a program has, so ``net occ``
261
+ appears exactly on the two-stage programs that have a distinct one, and the
262
+ app no longer has to know why. It replaces a local list of
263
+ ``reins_density_df`` column names, which knew three views where the library
264
+ knows five and had to reason about the stages itself.
265
+
266
+ Empty for an object carrying no cession, which is what makes both rows grey
267
+ out as a whole rather than vanish.
268
+
269
+ **A reinsured Portfolio is locked to net** (author's ruling, 1.0.0a100).
270
+ Reinsurance is placed at the unit level: a book has no cession of its own to
271
+ choose and takes whatever its units produce, so there is no book-wide
272
+ decision for the row to offer. ``Portfolio.reins_views`` has reported
273
+ ``['gross', 'ceded', 'net']`` since library a223 and both this filter's
274
+ survivors were live here through a99, which was wrong in a way a reader
275
+ could not see: ``CalibrationResult.pricing_df`` allocates ``density_df``,
276
+ the net book, whatever view was asked for, so a gross calibration arrived
277
+ beside a net allocation from one press, labeled as one calculation. Measured
278
+ on a two unit book, the two allocations agreed to the last digit while the
279
+ calibrations differed. Net is the only basis a book answers end to end.
280
+
281
+ An Aggregate is unaffected: a cession is placed on it directly, and all
282
+ three views are its own.
283
+
284
+ Parameters
285
+ ----------
286
+ obj : Any
287
+
288
+ Returns
289
+ -------
290
+ list of str
291
+ """
292
+ try:
293
+ views = set(obj.reins_views or ())
294
+ except Exception: # noqa: BLE001 -- an object that cannot answer offers none
295
+ return []
296
+ live = [name for name in _CALIBRATION_BASES if name in views]
297
+ if live and isinstance(obj, Portfolio):
298
+ return ["net"] if "net" in live else []
299
+ return live
300
+
301
+
302
+ def can_price(obj: Any) -> bool:
303
+ """Can this object answer the pricing forms?
304
+
305
+ Consumer: the Pricing group's Calibrate and Allocate leaves, which are app
306
+ behavior rather than a library document, so no exhibit or chart says this.
307
+ They gate together because one press fills both.
308
+
309
+ Read off the object as the method the pricing path actually calls, which is
310
+ the same test :func:`aggregate_api.pricing.run_calibration` makes before
311
+ it raises. A kind list would have been a second declaration of a fact the
312
+ object already carries, and the app had exactly that in
313
+ ``NA_TABS_BY_KIND``.
314
+
315
+ Parameters
316
+ ----------
317
+ obj : Any
318
+
319
+ Returns
320
+ -------
321
+ bool
322
+ """
323
+ return hasattr(obj, "price_pentagon")
324
+
325
+
326
+ def can_evaluate(obj: Any) -> bool:
327
+ """Can this object answer the acceptability panel?
328
+
329
+ Consumer: the Pricing group's Evaluate leaf. A separate flag from
330
+ :func:`can_price` and not a synonym for it: evaluation reaches a ``PnL``,
331
+ which has no ``price_pentagon`` and is the kind the panel says the most
332
+ about, since it evaluates every margin row of the ledger rather than one
333
+ position.
334
+
335
+ Read off ``evaluate`` for the same reason the others are read off their
336
+ methods: the object is the authority on what it can do.
337
+
338
+ Parameters
339
+ ----------
340
+ obj : Any
341
+
342
+ Returns
343
+ -------
344
+ bool
345
+ """
346
+ return hasattr(obj, "evaluate")
347
+
348
+
349
+ def can_pnl(obj: Any) -> bool:
350
+ """Can this object be wrapped in a P&L?
351
+
352
+ Consumer: the action row's PnL button.
353
+
354
+ Read off ``pnl_program``, which the library puts only on the two classes
355
+ that can honestly answer, so "does this object have it" is the whole test.
356
+ Deliberately not folded into :func:`can_price`, even though both are true
357
+ for exactly an ``Aggregate`` and a ``Portfolio`` today: they are two
358
+ different questions and a shared flag would tie a future change in one to
359
+ the other.
360
+
361
+ Parameters
362
+ ----------
363
+ obj : Any
364
+
365
+ Returns
366
+ -------
367
+ bool
368
+ """
369
+ return hasattr(obj, "pnl_program")
370
+
371
+
372
+ #: Does the installed library's ``pnl_program`` accept ``premium_style``?
373
+ #:
374
+ #: Feature-detected once at import, off the public method's signature, because
375
+ #: the keyword is an upstream ask (``dev/plan-a182-pricing-pnl-forms.md``, the
376
+ #: upstream section) and the api must be honest before it ships: while this is
377
+ #: False the route refuses ``premium_style='rate'`` with a 400 and the two rate
378
+ #: menu items grey through :func:`can_pnl_rate`. No version dance: the next
379
+ #: editable-checkout sync flips it.
380
+ PNL_PREMIUM_STYLE_SUPPORTED = (
381
+ "premium_style" in inspect.signature(Aggregate.pnl_program).parameters)
382
+
383
+
384
+ def can_pnl_rate(obj: Any) -> bool:
385
+ """Can this object be wrapped with ceded premiums written as rates?
386
+
387
+ Consumer: the action row's two rate menu items, ``PnL (rate)`` and
388
+ ``xPnL (rate)``.
389
+
390
+ :func:`can_pnl` plus the installed library accepting
391
+ ``pnl_program(premium_style=)``. The second condition is the honest one:
392
+ the keyword is an upstream ask, so a fresh install can carry a library
393
+ that cannot write a ``rate`` clause yet, and the items grey with a why
394
+ rather than the press producing a 400.
395
+
396
+ Parameters
397
+ ----------
398
+ obj : Any
399
+
400
+ Returns
401
+ -------
402
+ bool
403
+ """
404
+ return PNL_PREMIUM_STYLE_SUPPORTED and can_pnl(obj)
405
+
406
+
407
+ #: Does the installed library's ``PnL`` serve ``pentagon_df``?
408
+ #:
409
+ #: Feature-detected once at import, for the same reason
410
+ #: :data:`PNL_PREMIUM_STYLE_SUPPORTED` is: the frame is an upstream ask that
411
+ #: landed in ``aggregate`` 1.0.0a393, so a fresh checkout can carry a library
412
+ #: without it. While this is False the Pentagon leaf greys with a why and the
413
+ #: route refuses with a 400, rather than the press producing a traceback.
414
+ PNL_PENTAGON_SUPPORTED = hasattr(PnL, "pentagon_df")
415
+
416
+
417
+ def can_pnl_pentagon(obj: Any) -> bool:
418
+ """Can this object answer the Pentagon figure?
419
+
420
+ Consumer: the ``PnL / Pentagon`` leaf, which is a picture this app draws
421
+ rather than a library document, so it gates on a flag rather than on the
422
+ exhibit list. That is the third of the three gating kinds this module's
423
+ header names.
424
+
425
+ Two conditions. The object is a :class:`~aggregate.PnL`, which is the only
426
+ kind carrying a ledger to read as a pentagon; and the installed library
427
+ serves the frame at all (:data:`PNL_PENTAGON_SUPPORTED`). The second is
428
+ what earns the flag its place beside ``kind``, which already rides on the
429
+ build response and would otherwise say the first half on its own.
430
+
431
+ A single-group P&L answers. It has no cession, so the figure draws one
432
+ pentagon with nothing left over, which is the honest picture of a book with
433
+ no program rather than a reason to grey the leaf. A hand-built kernel P&L
434
+ answers too: its rows carry distributions, so the capital is a real
435
+ quantile, and the frame asks nothing of the stochastic engine it lacks.
436
+
437
+ Parameters
438
+ ----------
439
+ obj : Any
440
+
441
+ Returns
442
+ -------
443
+ bool
444
+ """
445
+ return PNL_PENTAGON_SUPPORTED and isinstance(obj, PnL)
446
+
447
+
448
+ def can_xpnl(obj: Any) -> bool:
449
+ """Can this object end up exploded, layer by layer, from one press?
450
+
451
+ Consumer: the action row's ``xPnL`` menu item (and, with
452
+ :func:`can_pnl_rate`, the ``xPnL (rate)`` one).
453
+
454
+ Two shapes answer, because the item means "get me the exploded P&L of
455
+ what is in the box" wherever it starts. A non-P&L wraps and explodes in
456
+ one request, which needs :func:`can_pnl` and a single ``Aggregate``: the
457
+ library refuses to explode a portfolio because the portfolio total hides
458
+ its units. A P&L has only the explode left to do, so it answers through
459
+ the existing :func:`can_explode`, which stays, unchanged, as the explode
460
+ route's own gate.
461
+
462
+ Parameters
463
+ ----------
464
+ obj : Any
465
+
466
+ Returns
467
+ -------
468
+ bool
469
+ """
470
+ if type(obj).__name__ == "PnL":
471
+ return can_explode(obj)
472
+ return can_pnl(obj) and isinstance(obj, Aggregate)
473
+
474
+
475
+ def can_explode(obj: Any) -> bool:
476
+ """Can this P&L be broken out layer by layer?
477
+
478
+ Consumer: the action row's PnL button in its second state, where it reads
479
+ ``Explode`` and posts to ``/objects/{id}/explode``.
480
+
481
+ True for a ``PnL`` built by ``pnl`` whose engine is a single aggregate.
482
+ False for one already built by ``xpnl``, which has nothing left to do, and
483
+ false for a portfolio engine, which the library refuses to explode because
484
+ the portfolio total hides the units the walk would step through.
485
+
486
+ Read off ``PnL.program``, the text the object was built from, because the
487
+ two keywords produce the same class and the source is the only thing that
488
+ tells them apart. The leading ``x`` is the whole test: an object of this
489
+ class leads with one keyword or the other.
490
+
491
+ Parameters
492
+ ----------
493
+ obj : Any
494
+
495
+ Returns
496
+ -------
497
+ bool
498
+ """
499
+ if type(obj).__name__ != "PnL":
500
+ return False
501
+ if type(getattr(obj, "engine", None)).__name__ == "Portfolio":
502
+ return False
503
+ return str(getattr(obj, "program", "") or "").lstrip().startswith("pnl")
504
+
505
+
506
+ def can_hints(obj: Any) -> bool:
507
+ """Can this object pin its realized grid into a ``hints{}`` clause?
508
+
509
+ Consumer: the action row's Hints button.
510
+
511
+ Read off ``with_hints``, which ``aggregate`` 1.0.0a291 put on ``Aggregate``
512
+ and ``Portfolio``, so "does this object have it" is the whole test.
513
+
514
+ Deliberately not folded into :func:`can_pnl`, even though both are true for
515
+ exactly those two classes today. They are two different questions, and one
516
+ flag would tie a future change in either to the other. The same argument
517
+ ``can_pnl`` makes about ``can_price``.
518
+
519
+ Parameters
520
+ ----------
521
+ obj : Any
522
+
523
+ Returns
524
+ -------
525
+ bool
526
+ """
527
+ return hasattr(obj, "with_hints")
528
+
529
+
530
+ def can_reins(obj: Any) -> bool:
531
+ """Can a cession be added to this object?
532
+
533
+ Consumer: the Reinsurance group's entry box, and the group pill above it.
534
+ This is the flag that makes the group live for an aggregate carrying **no**
535
+ cession, which is the case the entry box exists for: every table and chart
536
+ in there is dark until there is a program to describe, so without this the
537
+ group would grey out exactly when you wanted to add cover.
538
+
539
+ Read off ``reins_program``, which the library puts on ``Aggregate`` alone.
540
+ A portfolio cedes through its units rather than as a whole.
541
+
542
+ Parameters
543
+ ----------
544
+ obj : Any
545
+
546
+ Returns
547
+ -------
548
+ bool
549
+ """
550
+ return hasattr(obj, "reins_program")
551
+
552
+
553
+ def can_views(obj: Any) -> bool:
554
+ """Can this object be re-read as a gross / ceded / net view pair?
555
+
556
+ Consumer: the action row's GCN control, which prepends ``grossceded``,
557
+ ``grossnet`` or ``netceded`` to the program and rebuilds. The result is a
558
+ ``BivariateAggregate`` of the named pair.
559
+
560
+ Two conditions, and the second is the one that is easy to get wrong. The
561
+ grammar's three view prefixes take an ``agg_out``, so this is an
562
+ ``Aggregate`` question and a portfolio cannot answer it. And they build the
563
+ joint **per-occurrence** aggregate of the pair, so what they need is an
564
+ **occurrence** cession specifically: ``has_reins`` is the weaker test and
565
+ would light the control for a program carrying only an aggregate cession,
566
+ which then fails on submit. The house rule is that things grey with a
567
+ reason rather than fail when pressed, so the narrower test is the right one.
568
+
569
+ Read off ``occ_reins``, the cession spec itself, which is what
570
+ ``routes.objects._has_reinsurance`` reads for the same reason: it costs
571
+ nothing, where materializing ``reins_summary_df`` on every build would.
572
+
573
+ Parameters
574
+ ----------
575
+ obj : Any
576
+
577
+ Returns
578
+ -------
579
+ bool
580
+ """
581
+ if not hasattr(obj, "reins_program"):
582
+ return False
583
+ return getattr(obj, "occ_reins", None) is not None
584
+
585
+
586
+ def can_bounds(obj: Any) -> bool:
587
+ """Can pricing bounds be computed for this object?
588
+
589
+ Consumers: the Bounds group's envelope and PricingBounds leaves.
590
+
591
+ An ``isinstance`` rather than a ``hasattr``, unlike the flags above, and
592
+ the difference is honest: those name a method the object either has or does
593
+ not, while ``aggregate.bounds.Bounds`` declares the types it accepts and a
594
+ duck-typed near miss would fail somewhere deep instead of at the door. This
595
+ is the library's own accepted set, restricted to the two members the api
596
+ can hold, so it is still the library deciding. A P&L qualifies through its
597
+ engine: the library unwraps ``obj.engine`` itself (aggregate 1.0.0a375,
598
+ [Bounds-PnL-Engine]), so this see-through mirrors the constructor and the
599
+ flag and the constructor cannot disagree.
600
+
601
+ Parameters
602
+ ----------
603
+ obj : Any
604
+
605
+ Returns
606
+ -------
607
+ bool
608
+ """
609
+ return isinstance(obj, (Aggregate, Portfolio)) or \
610
+ isinstance(getattr(obj, "engine", None), (Aggregate, Portfolio))
611
+
612
+
613
+ def can_allocate(obj: Any) -> bool:
614
+ """Can allocation bounds be computed?
615
+
616
+ Consumer: the Bounds group's AllocationBounds leaf.
617
+
618
+ A portfolio and nothing else. The calculation reads the ``exeqa_*`` columns
619
+ that a portfolio's density frame carries, which is the conditional
620
+ expectation of each unit given the total, and a single aggregate has no
621
+ analogue: there is one unit, so there is nothing to allocate.
622
+
623
+ Parameters
624
+ ----------
625
+ obj : Any
626
+
627
+ Returns
628
+ -------
629
+ bool
630
+ """
631
+ return isinstance(obj, Portfolio)
632
+
633
+
634
+ def can_natural_allocation(obj: Any) -> bool:
635
+ """Does this object have parts to split one premium across?
636
+
637
+ Consumer: the Pricing group's Allocate leaf, and the route behind it. An
638
+ app leaf rather than an exhibit one, because ``pricing.allocate`` dispatches
639
+ on a ``CalibrationResult`` and never appears in :func:`exhibits_for`, so the
640
+ exhibit list cannot light this pill.
641
+
642
+ Two shapes qualify. A ``Portfolio`` allocates the book's premium across its
643
+ units, which is what ``analyze_distortions`` has always done. An
644
+ ``Aggregate`` carrying an occurrence program allocates the gross premium
645
+ across the two halves of that program, ceded and net, off the joint
646
+ distribution.
647
+
648
+ **Not** :func:`can_allocate`, which is the Bounds group's per-unit range and
649
+ is a ``Portfolio`` alone. The names are close because the questions are
650
+ cousins; the flags are separate because one lights a pricing subtab and the
651
+ other a bounds leaf, and tying them would tie a future change in one to the
652
+ other.
653
+
654
+ Parameters
655
+ ----------
656
+ obj : Any
657
+
658
+ Returns
659
+ -------
660
+ bool
661
+
662
+ Notes
663
+ -----
664
+ An aggregate cession is deliberately not enough. The natural allocation
665
+ reads the kappa curve off the joint distribution of gross and ceded, which
666
+ the library builds per occurrence; a program that only cedes in the
667
+ aggregate has no such joint to condition on. ``occ_reins`` is the library's
668
+ own public record of whether an occurrence stage was placed, so this asks it
669
+ rather than reasoning about the program text.
670
+
671
+ The basis is a second gate and it is not here: the allocation splits a
672
+ **gross** premium, so a fit struck on net has nothing to split. That is a
673
+ property of the calibration rather than of the object, which is why it lives
674
+ on the route and in the library's own availability predicate.
675
+ """
676
+ if isinstance(obj, Portfolio):
677
+ return True
678
+ return isinstance(obj, Aggregate) and obj.occ_reins is not None
679
+
680
+
681
+ def needs_premium(obj: Any) -> bool:
682
+ """Must the Evaluate form ask for a premium before it can run?
683
+
684
+ Consumer: the Pricing group's Evaluate leaf, which otherwise shows a form
685
+ with nothing in it and posts straight into a 400.
686
+
687
+ Three conditions, and none of them is a kind test. The object can be
688
+ evaluated at all. It is the sort of thing that carries its own
689
+ consideration, which is what ``exp_premium`` being present means: a ``PnL``
690
+ has no such attribute, because its premium lives in its ledger and there is
691
+ nothing to ask for. And it does not actually carry one.
692
+
693
+ Parameters
694
+ ----------
695
+ obj : Any
696
+
697
+ Returns
698
+ -------
699
+ bool
700
+ """
701
+ return (can_evaluate(obj)
702
+ and hasattr(obj, "exp_premium")
703
+ and not has_premium(obj))
704
+
705
+
706
+ #: Text fields are found by suffix rather than listed, so a narrative the
707
+ #: library adds appears with no edit here. Leading-underscore names are the
708
+ #: private working copies behind the public properties and would print twice.
709
+ NARRATIVE_SUFFIXES = ("_description", "_explanation")
710
+
711
+ #: Reading order for the Narrative pane, by attribute stem.
712
+ #:
713
+ #: The sections used to come out sorted by stem, which is alphabetical order over
714
+ #: names the reader never sees and has no reason to care about: `bs` before
715
+ #: `info` before `reins` before `sharpen` before `tail` before `validation`. That
716
+ #: is not an order, it is the absence of one.
717
+ #:
718
+ #: This is the order the questions actually get asked in. What was built, what
719
+ #: grid it was built on, whether that grid is trustworthy, how it behaves out in
720
+ #: the tail, what was ceded off it, and finally what the grid audit found, which
721
+ #: is the most specialist of the six and the one most often absent.
722
+ #:
723
+ #: Anything the library adds later is appended, alphabetically among itself, so a
724
+ #: new stem still appears without an edit here. That is the same contract the
725
+ #: suffix rule above keeps: derived by default, ordered by hand only where the
726
+ #: order carries meaning.
727
+ #:
728
+ #: ``info`` never matches a stem, since the object's ``info`` block is a plain
729
+ #: string served alongside these rather than a ``*_description`` pair. It leads
730
+ #: the list because that is where the pane puts it, and naming it here is what
731
+ #: makes this tuple the whole reading order rather than most of it.
732
+ NARRATIVE_ORDER = ("info", "bs", "validation", "tail", "reins", "sharpen")
733
+
734
+
735
+ def narrative_for(obj: Any) -> list[dict]:
736
+ """Every text field this object carries, in one list.
737
+
738
+ Consumer: the More group's Narrative leaf, which is the pane that collects
739
+ what the object says about itself in prose rather than in numbers.
740
+
741
+ Derived by suffix, in the same spirit as the exhibit list: a new
742
+ ``*_description`` upstream shows up here on its own. Sections are grouped by
743
+ stem, so ``validation_description`` and ``validation_explanation`` arrive as
744
+ one heading with a short form and a long one, which is what they are.
745
+
746
+ Empty strings are dropped rather than rendered as blank headings. Most of
747
+ these are empty most of the time: ``sharpen_*`` before a probe has run,
748
+ ``reins_*`` with no cession.
749
+
750
+ Parameters
751
+ ----------
752
+ obj : Any
753
+
754
+ Returns
755
+ -------
756
+ list of dict
757
+ ``{"name", "description", "explanation"}``, in ``NARRATIVE_ORDER``, with
758
+ anything unlisted appended alphabetically.
759
+ """
760
+ sections: dict[str, dict] = {}
761
+ for attr in dir(obj):
762
+ if attr.startswith("_"):
763
+ continue
764
+ for suffix in NARRATIVE_SUFFIXES:
765
+ if not attr.endswith(suffix):
766
+ continue
767
+ try:
768
+ text = getattr(obj, attr)
769
+ except Exception: # noqa: BLE001 -- a field that raises is absent
770
+ continue
771
+ text = str(text or "").strip()
772
+ if not text:
773
+ continue
774
+ stem = attr[: -len(suffix)]
775
+ section = sections.setdefault(
776
+ stem, {"name": stem, "description": "", "explanation": ""})
777
+ section[suffix.lstrip("_")] = text
778
+
779
+ # Listed stems first in their declared order, then whatever else the object
780
+ # carried, alphabetically among themselves so the tail of the list is at
781
+ # least stable.
782
+ rank = {name: i for i, name in enumerate(NARRATIVE_ORDER)}
783
+ order = sorted(sections, key=lambda n: (rank.get(n, len(rank)), n))
784
+ return [sections[name] for name in order]
785
+
786
+
787
+ def capability_for(obj: Any) -> dict:
788
+ """The whole capability block for one object.
789
+
790
+ Parameters
791
+ ----------
792
+ obj : Any
793
+ A built object.
794
+
795
+ Returns
796
+ -------
797
+ dict
798
+ The whole block, matching :class:`aggregate_api.models.Capability`.
799
+ """
800
+ return {
801
+ "exhibits": exhibits_for(obj),
802
+ "charts": charts_for(obj),
803
+ "primary_chart": primary_chart_for(obj),
804
+ "has_premium": has_premium(obj),
805
+ "premium": premium_for(obj),
806
+ "can_sharpen": can_sharpen(obj),
807
+ "has_sharpen": has_sharpen(obj),
808
+ "can_pnl": can_pnl(obj),
809
+ "can_pnl_rate": can_pnl_rate(obj),
810
+ "can_pnl_pentagon": can_pnl_pentagon(obj),
811
+ "can_xpnl": can_xpnl(obj),
812
+ "can_explode": can_explode(obj),
813
+ "can_hints": can_hints(obj),
814
+ "can_reins": can_reins(obj),
815
+ "can_views": can_views(obj),
816
+ "reins_bases": reins_bases_for(obj),
817
+ "can_price": can_price(obj),
818
+ "can_evaluate": can_evaluate(obj),
819
+ "can_bounds": can_bounds(obj),
820
+ "can_allocate": can_allocate(obj),
821
+ "can_natural_allocation": can_natural_allocation(obj),
822
+ "needs_premium": needs_premium(obj),
823
+ }