aggregate_api 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aggregate_api/__init__.py +41 -0
- aggregate_api/__main__.py +154 -0
- aggregate_api/app.py +206 -0
- aggregate_api/audit.py +395 -0
- aggregate_api/bounds.py +331 -0
- aggregate_api/cache.py +319 -0
- aggregate_api/capability.py +823 -0
- aggregate_api/completion.py +219 -0
- aggregate_api/config.py +363 -0
- aggregate_api/cors.py +61 -0
- aggregate_api/examples.py +620 -0
- aggregate_api/layer_pricing.py +840 -0
- aggregate_api/library.py +94 -0
- aggregate_api/library_notes.py +96 -0
- aggregate_api/models.py +1407 -0
- aggregate_api/net.py +281 -0
- aggregate_api/pnl.py +101 -0
- aggregate_api/pricing.py +778 -0
- aggregate_api/resources.py +257 -0
- aggregate_api/routes/__init__.py +8 -0
- aggregate_api/routes/decl.py +327 -0
- aggregate_api/routes/examples.py +82 -0
- aggregate_api/routes/meta.py +282 -0
- aggregate_api/routes/objects.py +4119 -0
- aggregate_api/routes/status.py +466 -0
- aggregate_api/serializers.py +565 -0
- aggregate_api/sessions.py +353 -0
- aggregate_api/static/aggregate-api-logo-512.png +0 -0
- aggregate_api/static/aggregate-api-logo.png +0 -0
- aggregate_api/static/aggregate-api-trim.png +0 -0
- aggregate_api/static/android-chrome-192x192.png +0 -0
- aggregate_api/static/android-chrome-512x512.png +0 -0
- aggregate_api/static/apple-touch-icon.png +0 -0
- aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
- aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
- aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
- aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
- aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
- aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
- aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
- aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
- aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
- aggregate_api/static/assets/main-CmoEiPit.js +9 -0
- aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
- aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
- aggregate_api/static/favicon-16x16.png +0 -0
- aggregate_api/static/favicon-32x32.png +0 -0
- aggregate_api/static/favicon.ico +0 -0
- aggregate_api/static/index.html +912 -0
- aggregate_api/static/lite.html +83 -0
- aggregate_api/static/logo.png +0 -0
- aggregate_api/static/site.webmanifest +14 -0
- aggregate_api/static/sw.js +78 -0
- aggregate_api/status.py +536 -0
- aggregate_api/status_page.html +546 -0
- aggregate_api/tables.py +316 -0
- aggregate_api-1.0.0.dist-info/METADATA +187 -0
- aggregate_api-1.0.0.dist-info/RECORD +63 -0
- aggregate_api-1.0.0.dist-info/WHEEL +5 -0
- aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
- aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
- aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
aggregate_api/net.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Where a request came from, behind exactly one trusted proxy.
|
|
2
|
+
|
|
3
|
+
The api runs on ``127.0.0.1:8001`` behind two Caddy front doors, one public
|
|
4
|
+
(``agg.mynl.com``) and one on the VPN (``10.8.0.1:19456``), and both of them
|
|
5
|
+
``reverse_proxy`` to that same address. So ``request.client.host`` is
|
|
6
|
+
``127.0.0.1`` for a visitor from the public internet exactly as much as for one
|
|
7
|
+
on the VPN, and any code reading the peer address to decide who is asking gets
|
|
8
|
+
the same answer for both. Two things follow, and this module is both of them.
|
|
9
|
+
|
|
10
|
+
**The audit log has been recording a constant.** ``_client_ip`` in
|
|
11
|
+
``routes/objects.py`` read ``request.client.host`` and nothing else, so every
|
|
12
|
+
production row carries ``ip = '127.0.0.1'``, the ``builds_ip`` index indexes one
|
|
13
|
+
value, and :meth:`aggregate_api.audit.AuditLog.by_ip` cannot answer the question
|
|
14
|
+
it exists for. Rows written before this module are not retroactively meaningful.
|
|
15
|
+
|
|
16
|
+
**And a private-origin gate cannot be written on the peer.** "Allow if the peer
|
|
17
|
+
is loopback" would test green on a laptop, test green over the VPN, and publish
|
|
18
|
+
the page it guards to the entire internet. The gate reads the forwarded chain
|
|
19
|
+
instead, and it reads it from the correct end.
|
|
20
|
+
|
|
21
|
+
The correct end
|
|
22
|
+
---------------
|
|
23
|
+
|
|
24
|
+
Caddy **appends** the address it observed to whatever ``X-Forwarded-For``
|
|
25
|
+
arrived, so the **last** element is the one Caddy saw and everything before it
|
|
26
|
+
is whatever the client chose to send. A public visitor who sends
|
|
27
|
+
``X-Forwarded-For: 10.8.0.2`` produces ``10.8.0.2, <their real address>``, and
|
|
28
|
+
reading the first element hands them the page. Every function here reads the
|
|
29
|
+
last, and :func:`forwarded_chain` flattens repeated header lines in arrival
|
|
30
|
+
order first, because ``Headers.get`` returns only the first occurrence and a
|
|
31
|
+
client can send its own line ahead of Caddy's.
|
|
32
|
+
|
|
33
|
+
**Reading the last element is correct for exactly one trusted proxy**, which is
|
|
34
|
+
the deployment ``human-hints.md`` describes. Put a second proxy in front and the
|
|
35
|
+
last element becomes that proxy's view of the first, the index is off by one,
|
|
36
|
+
and the gate opens. Nothing here can detect that, so it is a deployment
|
|
37
|
+
invariant rather than a check: one hop, and a change to the topology is a change
|
|
38
|
+
to this module.
|
|
39
|
+
|
|
40
|
+
Fail closed
|
|
41
|
+
-----------
|
|
42
|
+
|
|
43
|
+
Anything unparseable is neither private nor a client address. A present but
|
|
44
|
+
empty header, a garbled element, an unexpected format: each answers "no" rather
|
|
45
|
+
than falling back to the peer, because the peer is loopback and loopback is what
|
|
46
|
+
the private list allows.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import ipaddress
|
|
52
|
+
from typing import Iterable
|
|
53
|
+
|
|
54
|
+
#: The forwarded-for header, spelled once. Compared case-insensitively by
|
|
55
|
+
#: Starlette's ``Headers``, so the casing here is presentation only.
|
|
56
|
+
FORWARDED_FOR = "X-Forwarded-For"
|
|
57
|
+
|
|
58
|
+
#: What ``AGGAPI_PRIVATE_CIDRS`` defaults to: loopback, the IPv6 loopback, and
|
|
59
|
+
#: the VPN subnet from ``human-hints.md``. A setting rather than a constant
|
|
60
|
+
#: because the VPN subnet is a deployment fact.
|
|
61
|
+
DEFAULT_PRIVATE_CIDRS = "127.0.0.0/8, ::1, 10.8.0.0/24"
|
|
62
|
+
|
|
63
|
+
#: Returned when no client address can be established. Matches what
|
|
64
|
+
#: ``_client_ip`` has always written for a request with no peer.
|
|
65
|
+
UNKNOWN = "-"
|
|
66
|
+
|
|
67
|
+
Network = ipaddress.IPv4Network | ipaddress.IPv6Network
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_cidrs(raw: str) -> tuple[Network, ...]:
|
|
71
|
+
"""Parse a comma-separated CIDR list into networks.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
raw : str
|
|
76
|
+
Comma-separated networks or bare addresses, for example
|
|
77
|
+
``"127.0.0.0/8, ::1, 10.8.0.0/24"``. A bare address becomes a single
|
|
78
|
+
host network (``::1`` is ``::1/128``).
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
tuple
|
|
83
|
+
The parsed networks, in the order given.
|
|
84
|
+
|
|
85
|
+
Raises
|
|
86
|
+
------
|
|
87
|
+
ValueError
|
|
88
|
+
If any element fails to parse. Deliberately loud: a typo in this
|
|
89
|
+
setting is a gate that silently allows less, or nothing, and a server
|
|
90
|
+
that refuses to start reports that better than a page which has quietly
|
|
91
|
+
stopped answering.
|
|
92
|
+
|
|
93
|
+
Notes
|
|
94
|
+
-----
|
|
95
|
+
``strict=False`` so an entry written with host bits set (``10.8.0.1/24``,
|
|
96
|
+
which is how an operator naturally writes the VPN address they can see) is
|
|
97
|
+
read as the network containing it rather than rejected.
|
|
98
|
+
"""
|
|
99
|
+
networks: list[Network] = []
|
|
100
|
+
for element in raw.split(","):
|
|
101
|
+
text = element.strip()
|
|
102
|
+
if not text:
|
|
103
|
+
continue
|
|
104
|
+
networks.append(ipaddress.ip_network(text, strict=False))
|
|
105
|
+
return tuple(networks)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def as_ip(text: str | None):
|
|
109
|
+
"""Parse one forwarded element into an address, or ``None``.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
text : str or None
|
|
114
|
+
One element of a forwarded chain, or a peer address.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
ipaddress.IPv4Address or ipaddress.IPv6Address or None
|
|
119
|
+
``None`` for anything that does not parse, which every caller reads as
|
|
120
|
+
"not private" and "not a client address".
|
|
121
|
+
|
|
122
|
+
Notes
|
|
123
|
+
-----
|
|
124
|
+
Three decorations are unwrapped, and only three. ``[::1]:443`` is the
|
|
125
|
+
bracketed IPv6-with-port form; ``1.2.3.4:5678`` is the IPv4 one, told apart
|
|
126
|
+
by having exactly one colon, since a bare IPv6 address always has at least
|
|
127
|
+
two; and an IPv4-mapped IPv6 address (``::ffff:10.8.0.2``, which a
|
|
128
|
+
dual-stack listener can report) is reduced to the IPv4 address it carries,
|
|
129
|
+
without which a genuine VPN peer would be compared against an IPv4 network
|
|
130
|
+
as a v6 address and refused.
|
|
131
|
+
|
|
132
|
+
Anything else fails closed rather than being guessed at. That direction is
|
|
133
|
+
the whole point: an unrecognized format costs a legitimate request a 404, an
|
|
134
|
+
over-clever parse costs the page.
|
|
135
|
+
"""
|
|
136
|
+
if not text:
|
|
137
|
+
return None
|
|
138
|
+
candidate = text.strip()
|
|
139
|
+
if not candidate:
|
|
140
|
+
return None
|
|
141
|
+
if candidate.startswith("["):
|
|
142
|
+
end = candidate.find("]")
|
|
143
|
+
if end < 0:
|
|
144
|
+
return None
|
|
145
|
+
candidate = candidate[1:end]
|
|
146
|
+
elif candidate.count(":") == 1:
|
|
147
|
+
candidate = candidate.split(":", 1)[0]
|
|
148
|
+
try:
|
|
149
|
+
address = ipaddress.ip_address(candidate)
|
|
150
|
+
except ValueError:
|
|
151
|
+
return None
|
|
152
|
+
if address.version == 6 and address.ipv4_mapped is not None:
|
|
153
|
+
return address.ipv4_mapped
|
|
154
|
+
return address
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_private(text: str | None, networks: Iterable[Network]) -> bool:
|
|
158
|
+
"""True when ``text`` parses to an address inside one of ``networks``.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
text : str or None
|
|
163
|
+
A candidate address.
|
|
164
|
+
networks : iterable
|
|
165
|
+
Networks from :func:`parse_cidrs`.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
bool
|
|
170
|
+
|
|
171
|
+
Notes
|
|
172
|
+
-----
|
|
173
|
+
The version is checked before the membership test because
|
|
174
|
+
``IPv4Address in IPv6Network`` raises :class:`TypeError` rather than
|
|
175
|
+
answering False, and a gate that raises on a mixed-family list is a gate
|
|
176
|
+
that 500s instead of refusing.
|
|
177
|
+
"""
|
|
178
|
+
address = as_ip(text)
|
|
179
|
+
if address is None:
|
|
180
|
+
return False
|
|
181
|
+
return any(address.version == net.version and address in net
|
|
182
|
+
for net in networks)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def forwarded_chain(request) -> list[str]:
|
|
186
|
+
"""Every ``X-Forwarded-For`` element, in arrival order.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
request : starlette.requests.Request
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
list of str
|
|
195
|
+
Stripped elements, earliest hop first. Empty **only** when the header is
|
|
196
|
+
absent, so an empty list means "nothing proxied this request" and never
|
|
197
|
+
"a proxy said nothing".
|
|
198
|
+
|
|
199
|
+
Notes
|
|
200
|
+
-----
|
|
201
|
+
``getlist`` rather than ``get``. Repeated header lines are equivalent to one
|
|
202
|
+
comma-joined line, and ``Headers.get`` returns only the first occurrence, so
|
|
203
|
+
a client sending its own ``X-Forwarded-For`` line ahead of the proxy's would
|
|
204
|
+
have its own line read as the whole chain. Flattening every occurrence in
|
|
205
|
+
order puts the proxy's observation last, where the rest of this module
|
|
206
|
+
expects it.
|
|
207
|
+
|
|
208
|
+
**Empty elements are kept, deliberately.** ``"10.8.0.2,"`` yields
|
|
209
|
+
``['10.8.0.2', '']`` and the last element is the empty string, which parses
|
|
210
|
+
to nothing and is therefore refused. Dropping empties would make the last
|
|
211
|
+
element ``10.8.0.2``, a value the client chose, and a proxy that had stopped
|
|
212
|
+
appending would then admit whatever a visitor cared to send. The degenerate
|
|
213
|
+
case has to fail closed, and keeping the empty element is what makes it.
|
|
214
|
+
"""
|
|
215
|
+
elements: list[str] = []
|
|
216
|
+
for value in request.headers.getlist(FORWARDED_FOR):
|
|
217
|
+
elements.extend(part.strip() for part in value.split(","))
|
|
218
|
+
return elements
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def client_address(request) -> str:
|
|
222
|
+
"""The address this request came from, honoring one trusted proxy.
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
request : starlette.requests.Request
|
|
227
|
+
|
|
228
|
+
Returns
|
|
229
|
+
-------
|
|
230
|
+
str
|
|
231
|
+
The client address, or :data:`UNKNOWN` when none can be established.
|
|
232
|
+
|
|
233
|
+
Notes
|
|
234
|
+
-----
|
|
235
|
+
Two cases, and the header's **presence** decides which applies, because the
|
|
236
|
+
peer is loopback either way. With no ``X-Forwarded-For`` nothing proxied the
|
|
237
|
+
request, so the peer *is* the client, which is the local-development and
|
|
238
|
+
direct-curl case. With the header present the request came through Caddy and
|
|
239
|
+
the last element is what Caddy observed: the module docstring says why it is
|
|
240
|
+
the last and not the first.
|
|
241
|
+
|
|
242
|
+
A present but unusable header does **not** fall back to the peer, which is
|
|
243
|
+
why the branch turns on presence rather than on whether an address was
|
|
244
|
+
recovered. Falling back would report ``127.0.0.1`` for a public visitor,
|
|
245
|
+
which is the bug this function exists to fix, and in
|
|
246
|
+
:func:`aggregate_api.routes.status.require_private` it would be the
|
|
247
|
+
difference between refusing and admitting.
|
|
248
|
+
"""
|
|
249
|
+
chain = forwarded_chain(request)
|
|
250
|
+
if chain:
|
|
251
|
+
address = as_ip(chain[-1])
|
|
252
|
+
return str(address) if address is not None else UNKNOWN
|
|
253
|
+
if request.client and request.client.host:
|
|
254
|
+
address = as_ip(request.client.host)
|
|
255
|
+
return str(address) if address is not None else request.client.host
|
|
256
|
+
return UNKNOWN
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def is_private_request(request, networks: Iterable[Network]) -> tuple[bool, str]:
|
|
260
|
+
"""Whether this request demonstrably came from a private origin.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
request : starlette.requests.Request
|
|
265
|
+
networks : iterable
|
|
266
|
+
Networks from :func:`parse_cidrs`.
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
(bool, str)
|
|
271
|
+
The verdict, and the address it was judged on, for the log line and the
|
|
272
|
+
refusal buffer.
|
|
273
|
+
|
|
274
|
+
Notes
|
|
275
|
+
-----
|
|
276
|
+
Deny by default: False unless an address was established *and* it falls
|
|
277
|
+
inside the list. :data:`UNKNOWN` is in no network, so the two failure modes
|
|
278
|
+
(no address, wrong address) converge on one refusal with no special case.
|
|
279
|
+
"""
|
|
280
|
+
address = client_address(request)
|
|
281
|
+
return is_private(address, networks), address
|
aggregate_api/pnl.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The PnL group's own runner: the ledger read as a pentagon.
|
|
2
|
+
|
|
3
|
+
One function, and it has a module to itself for a reason. The obvious home was
|
|
4
|
+
``pricing.py``, beside the four runners that already complete pentagons, but
|
|
5
|
+
that file opens by saying there is no pandas in it and that this is the point of
|
|
6
|
+
it: everything there hands back library exhibit envelopes or scalars, and the
|
|
7
|
+
frames are materialized upstream. This runner serializes a DataFrame, so putting
|
|
8
|
+
it there would quietly retire a claim worth keeping.
|
|
9
|
+
|
|
10
|
+
The division of labor is the plan's governing principle (``dev/plan-a195-pnl-
|
|
11
|
+
pentagon.md``, ruled 2026-10-06): **the library owns the arithmetic and the
|
|
12
|
+
browser owns the geometry**. Every number the figure draws comes out of
|
|
13
|
+
``PnL.pentagon_df``; nothing about the economics is computed in JavaScript, and
|
|
14
|
+
nothing about the picture is computed here. The SVG lives in ``web/src/charts/
|
|
15
|
+
pentagon.js`` because it is a bespoke figure rather than a chart anyone else
|
|
16
|
+
emits, and forcing it through the chart IR would be a large extension for one
|
|
17
|
+
picture.
|
|
18
|
+
|
|
19
|
+
Why this is a route at all, rather than the app making do with ``/v1`` as it
|
|
20
|
+
stands. The make-do route would be per-ledger-row quantiles at an arbitrary
|
|
21
|
+
level, which this api does not expose and should not: it would be one frame per
|
|
22
|
+
level per row, fetched so the browser could reassemble arithmetic the library
|
|
23
|
+
already does in one call.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .capability import can_pnl_pentagon
|
|
31
|
+
from .serializers import frame_to_payload, reset_index_safe
|
|
32
|
+
|
|
33
|
+
#: The strip's own six solvency levels, in reading order, so the first is the
|
|
34
|
+
#: default the leaf opens on.
|
|
35
|
+
#:
|
|
36
|
+
#: Carried here rather than in the SPA so one list drives both: the response
|
|
37
|
+
#: states which levels it answered at and the strip renders exactly those, which
|
|
38
|
+
#: means a change here needs no matching edit in the browser. The six are the
|
|
39
|
+
#: ones the retired ``aggregate-asp`` experiment offered (``js/state.js``), which
|
|
40
|
+
#: are the standards a reader of a solvency filing expects to be shown.
|
|
41
|
+
PENTAGON_RETURN_PERIODS = (100.0, 200.0, 250.0, 500.0, 1000.0, 2000.0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_pnl_pentagon(obj: Any, *, periods=None) -> dict:
|
|
45
|
+
"""The ledger as a pentagon, at each of several solvency levels.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
obj : Any
|
|
50
|
+
The live object, which must be a ``PnL``.
|
|
51
|
+
periods : list of float, optional
|
|
52
|
+
Return periods to answer at, each above 1. Defaults to
|
|
53
|
+
:data:`PENTAGON_RETURN_PERIODS`.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
dict
|
|
58
|
+
Matches :class:`aggregate_api.models.PnLPentagonResponse`: one entry per
|
|
59
|
+
level, in the order asked for, each carrying the level and the frame
|
|
60
|
+
struck at it with ``Step`` as its first column.
|
|
61
|
+
|
|
62
|
+
Raises
|
|
63
|
+
------
|
|
64
|
+
ValueError
|
|
65
|
+
If the object is not a P&L, or if the installed library does not serve
|
|
66
|
+
``pentagon_df``, or, from the library itself, if a period does not
|
|
67
|
+
exceed 1. Every message here is written to be shown to a reader, and the
|
|
68
|
+
route turns one into a 400 whose ``detail`` is the message verbatim.
|
|
69
|
+
|
|
70
|
+
Notes
|
|
71
|
+
-----
|
|
72
|
+
**Every level in one answer**, which is what the control needs. The leaf's
|
|
73
|
+
control is a strip of mini pentagons, each labeled with the assets its level
|
|
74
|
+
implies, so the reader chooses from figures they can already see rather than
|
|
75
|
+
pressing six times to find out. Serving one level per request would leave
|
|
76
|
+
the strip unable to label itself.
|
|
77
|
+
|
|
78
|
+
The cost of the extra five is a handful of quantile reads per ledger row.
|
|
79
|
+
The amounts, the expenses and the margin do not move with the level at all,
|
|
80
|
+
being properties of the book rather than of the standard applied to it; only
|
|
81
|
+
capital, assets and the two ratios over capital do.
|
|
82
|
+
|
|
83
|
+
A reader should not assume the figures grow with the level. A bounded
|
|
84
|
+
program exhausted in the far tail releases *less* capital at a remoter
|
|
85
|
+
standard, so the cession's own capital can shrink as the level rises while
|
|
86
|
+
the gross and net both grow. That is the reading the strip exists to make
|
|
87
|
+
visible, and it is pinned upstream in
|
|
88
|
+
``tests/test_pnl_pentagon.py::test_an_exhausted_program_releases_less_capital_further_out``.
|
|
89
|
+
"""
|
|
90
|
+
if not can_pnl_pentagon(obj):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"the pentagon reads a P&L ledger, and this object is not a P&L. "
|
|
93
|
+
"Wrap it with the PnL button first, or explode it into a tower to "
|
|
94
|
+
"see a cession in the figure.")
|
|
95
|
+
wanted = (PENTAGON_RETURN_PERIODS if not periods
|
|
96
|
+
else tuple(float(t) for t in periods))
|
|
97
|
+
levels = []
|
|
98
|
+
for t in wanted:
|
|
99
|
+
frame = reset_index_safe(obj.pentagon_df(t=t))
|
|
100
|
+
levels.append({"t": t, "frame": frame_to_payload(frame)})
|
|
101
|
+
return {"levels": levels}
|