portlearn 0.0.1.dev0__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.
- portlearn/__init__.py +18 -0
- portlearn/interfaces.py +539 -0
- portlearn/leakage.py +290 -0
- portlearn/manifest.py +298 -0
- portlearn/observations.py +288 -0
- portlearn/py.typed +0 -0
- portlearn/timing.py +328 -0
- portlearn-0.0.1.dev0.dist-info/METADATA +70 -0
- portlearn-0.0.1.dev0.dist-info/RECORD +11 -0
- portlearn-0.0.1.dev0.dist-info/WHEEL +4 -0
- portlearn-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
portlearn/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""PortLearn — research infrastructure for portfolio-learning research.
|
|
2
|
+
|
|
3
|
+
This package ships its public identity only: importing this module is
|
|
4
|
+
side-effect-free — it performs no filesystem writes, network access,
|
|
5
|
+
configuration mutation, logging initialization, data retrieval, or
|
|
6
|
+
application computation — and it eagerly imports no other PortLearn
|
|
7
|
+
module, so the import surface stays minimal and stable.
|
|
8
|
+
|
|
9
|
+
``pyproject.toml`` is the single version authority: ``__version__`` is
|
|
10
|
+
derived from the installed distribution metadata rather than
|
|
11
|
+
hard-coded, so it can never drift from the declared release.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from importlib import metadata
|
|
17
|
+
|
|
18
|
+
__version__ = metadata.version("portlearn")
|
portlearn/interfaces.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Core research interfaces for portfolio decisions.
|
|
2
|
+
|
|
3
|
+
This module freezes the core interface contract: the
|
|
4
|
+
admitted-information value object ``InformationSet``, the
|
|
5
|
+
provenance-bearing ``Forecast`` and ``PortfolioDecision`` value
|
|
6
|
+
objects with the deliberately thin ``AccountingResult``, the seven
|
|
7
|
+
structural contracts, and the two compatibility validators
|
|
8
|
+
``require_feature_lineage`` and ``require_forecast_decision_compatible``.
|
|
9
|
+
|
|
10
|
+
Four laws are inherited from the timing and observations modules
|
|
11
|
+
without change:
|
|
12
|
+
|
|
13
|
+
- **No new time semantics** — every instant an interface carries is one
|
|
14
|
+
of this package's financial timestamps with its fixed meaning, and a
|
|
15
|
+
naive ``datetime`` or a ``datetime.date`` is rejected fail-closed at
|
|
16
|
+
every boundary with the reused ``NaiveTimestampError``: no default
|
|
17
|
+
timezone is assumed and no date-to-midnight coercion is performed.
|
|
18
|
+
- **No second admission rule** — admission into an ``InformationSet``
|
|
19
|
+
is exactly the inclusive law
|
|
20
|
+
``available_time <= decision_time``, delegated to
|
|
21
|
+
``require_available_for_decision``; this module never re-implements,
|
|
22
|
+
widens, or shadows it.
|
|
23
|
+
- **No new error classes** — the fail-closed surface reuses the timing
|
|
24
|
+
and observations errors with their fixed module ownership
|
|
25
|
+
(``portlearn.timing`` and ``portlearn.observations``); the only
|
|
26
|
+
built-in error raised is ``ValueError`` for blank identifiers, blank
|
|
27
|
+
targets, and blank provenance strings, mirroring the ``series_id``
|
|
28
|
+
discipline.
|
|
29
|
+
- **No weight arithmetic** — ``Forecast.values`` and
|
|
30
|
+
``PortfolioDecision.target_weights`` are stored exactly as given;
|
|
31
|
+
sum-to-one, gross and net exposure, and feasibility conventions are
|
|
32
|
+
left to later portfolio-weight contracts to freeze, not this
|
|
33
|
+
module's.
|
|
34
|
+
|
|
35
|
+
Exactly four contracts (``RebalancePolicy``, ``CostModel``,
|
|
36
|
+
``AccountingEngine``, ``Evaluator``) carry ``@runtime_checkable``,
|
|
37
|
+
because runtime structural checks genuinely exist on those surfaces;
|
|
38
|
+
``FeatureTransform``, ``Forecaster``, and ``Strategy`` remain
|
|
39
|
+
static-only and are validated behaviorally. The module is stdlib-only
|
|
40
|
+
and imports only from the timing and observations modules.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from datetime import datetime
|
|
48
|
+
from typing import Protocol, runtime_checkable
|
|
49
|
+
|
|
50
|
+
from .observations import (
|
|
51
|
+
AmbiguousObservationError,
|
|
52
|
+
TimedObservation,
|
|
53
|
+
require_lineage_monotone,
|
|
54
|
+
)
|
|
55
|
+
from .timing import (
|
|
56
|
+
InvalidChronologyError,
|
|
57
|
+
NaiveTimestampError, # noqa: F401 # re-exported: raised at this surface by the validator from portlearn.timing
|
|
58
|
+
_require_aware_instant,
|
|
59
|
+
require_available_for_decision,
|
|
60
|
+
)
|
|
61
|
+
from .timing import (
|
|
62
|
+
to_instant as _to_instant, # timing-owned normalizer reused for comparison only; the private alias keeps the frozen public surface unchanged
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"AccountingEngine",
|
|
67
|
+
"AccountingResult",
|
|
68
|
+
"CostModel",
|
|
69
|
+
"Evaluator",
|
|
70
|
+
"FeatureTransform",
|
|
71
|
+
"Forecast",
|
|
72
|
+
"Forecaster",
|
|
73
|
+
"InformationSet",
|
|
74
|
+
"PortfolioDecision",
|
|
75
|
+
"RebalancePolicy",
|
|
76
|
+
"Strategy",
|
|
77
|
+
"require_feature_lineage",
|
|
78
|
+
"require_forecast_decision_compatible",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------- #
|
|
83
|
+
# Fail-closed input gates — reused from portlearn.timing
|
|
84
|
+
# --------------------------------------------------------------------------- #
|
|
85
|
+
|
|
86
|
+
# The aware-instant validator is implemented in ``portlearn.timing``
|
|
87
|
+
# and imported above: shared invariant validation has exactly one
|
|
88
|
+
# implementation per public interface, and every surface that needs
|
|
89
|
+
# it re-exports that exact function object, preserving object
|
|
90
|
+
# identity package-wide. The canonical
|
|
91
|
+
# invalid-input wording — including its calendar-date tail — is
|
|
92
|
+
# timing.py's and is reused verbatim rather than shortened; the
|
|
93
|
+
# rejection itself is unchanged in kind: naive datetimes,
|
|
94
|
+
# ``datetime.date`` inputs, and non-instant inputs all still fail
|
|
95
|
+
# closed with the reused ``NaiveTimestampError``.
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _require_identifier(identifier: object, field_name: str) -> None:
|
|
99
|
+
"""Reject blank or non-string identifiers fail-closed.
|
|
100
|
+
|
|
101
|
+
Identifiers are exact strings — no case folding, trimming, or
|
|
102
|
+
Unicode normalization of any kind — and a blank or non-string
|
|
103
|
+
identifier names no instrument or series, so the value is rejected
|
|
104
|
+
with the built-in ``ValueError`` exactly as a malformed
|
|
105
|
+
``series_id`` is rejected.
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(identifier, str):
|
|
108
|
+
raise ValueError( # noqa: TRY004 — identifier discipline reuses ValueError
|
|
109
|
+
f"{field_name} must be an exact string identifier naming "
|
|
110
|
+
f"the instrument or series it refers to; got "
|
|
111
|
+
f"{type(identifier).__name__}: {identifier!r}. A non-string "
|
|
112
|
+
"identifier names no instrument or series, so the value is "
|
|
113
|
+
"rejected fail-closed."
|
|
114
|
+
)
|
|
115
|
+
if not identifier.strip():
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"{field_name} must be a non-empty, non-blank string "
|
|
118
|
+
f"identifier naming the instrument or series it refers to; "
|
|
119
|
+
f"got {identifier!r}. A blank identifier names no "
|
|
120
|
+
"instrument or series, so the value is rejected "
|
|
121
|
+
"fail-closed."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
# Value objects
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, init=False)
|
|
131
|
+
class InformationSet:
|
|
132
|
+
"""The point-in-time information admitted for one portfolio decision.
|
|
133
|
+
|
|
134
|
+
An ``InformationSet`` is the set of ``TimedObservation`` records
|
|
135
|
+
admitted at one decision instant ``as_of``: every record's
|
|
136
|
+
``available_time`` is at or before ``as_of`` (the inclusive
|
|
137
|
+
admission law, delegated to ``require_available_for_decision``), so
|
|
138
|
+
the set is exactly what could have been known when the decision was
|
|
139
|
+
made — never information from the decision-to-execution gap.
|
|
140
|
+
|
|
141
|
+
Construction is fail-closed over the whole submitted collection, in
|
|
142
|
+
this exact order:
|
|
143
|
+
|
|
144
|
+
1. ``as_of`` must be an aware instant (naive datetimes and
|
|
145
|
+
``datetime.date`` reject with ``NaiveTimestampError``) — checked
|
|
146
|
+
before any item is inspected;
|
|
147
|
+
2. two or more records sharing the full identity triple
|
|
148
|
+
``(series_id, observation_time, available_time)`` reject with
|
|
149
|
+
``AmbiguousObservationError`` — no last-write-wins, no
|
|
150
|
+
deduplication, no value-equality exception;
|
|
151
|
+
3. two or more records sharing one ``(series_id,
|
|
152
|
+
observation_time)`` group with differing ``available_time`` — a
|
|
153
|
+
revision pair — reject with the same ``AmbiguousObservationError``:
|
|
154
|
+
the set is given at most one vintage per series-observation and
|
|
155
|
+
never selects among vintages itself. Vintage selection is the
|
|
156
|
+
caller's, performed beforehand with the ``vintage_as_of``
|
|
157
|
+
operation;
|
|
158
|
+
4. each record is admitted in submission order by the admission
|
|
159
|
+
predicate; any look-ahead record rejects with
|
|
160
|
+
``FutureInformationError``.
|
|
161
|
+
|
|
162
|
+
Empty input is admissible as the empty information set: when
|
|
163
|
+
nothing is available at ``as_of``, the admitted set is empty, and
|
|
164
|
+
empty is not ambiguous. The object is immutable and iterable (its
|
|
165
|
+
records, in submission order); ``as_of`` is exposed read-only.
|
|
166
|
+
There is no query, filter, transformation, lazy-admission, or
|
|
167
|
+
vintage-selection mechanism on this type.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
_records: tuple[TimedObservation, ...]
|
|
171
|
+
_as_of: datetime
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self, items: Iterable[TimedObservation], as_of: datetime
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Admit ``items`` at ``as_of`` or reject fail-closed."""
|
|
177
|
+
decision = _require_aware_instant(as_of, "as_of")
|
|
178
|
+
submitted = tuple(items)
|
|
179
|
+
|
|
180
|
+
seen_identities: set[tuple[str, datetime, datetime]] = set()
|
|
181
|
+
seen_groups: set[tuple[str, datetime]] = set()
|
|
182
|
+
for record in submitted:
|
|
183
|
+
# Identity and group keys carry the normalized UTC instant
|
|
184
|
+
# (the timing-owned normalizer), never the raw datetime:
|
|
185
|
+
# raw comparison ignores ``fold`` within one zone and calls
|
|
186
|
+
# two distinct instants equal, while across zones it calls
|
|
187
|
+
# one instant two. Keys only — the stored records keep
|
|
188
|
+
# their original datetimes.
|
|
189
|
+
observation_instant = _to_instant(
|
|
190
|
+
record.observation_time, "observation_time"
|
|
191
|
+
)
|
|
192
|
+
available_instant = _to_instant(
|
|
193
|
+
record.available_time, "available_time"
|
|
194
|
+
)
|
|
195
|
+
identity = (
|
|
196
|
+
record.series_id,
|
|
197
|
+
observation_instant,
|
|
198
|
+
available_instant,
|
|
199
|
+
)
|
|
200
|
+
if identity in seen_identities:
|
|
201
|
+
raise AmbiguousObservationError(
|
|
202
|
+
"ambiguous information-set input: two submitted "
|
|
203
|
+
"records share the full identity triple "
|
|
204
|
+
f"(series_id={record.series_id!r}, "
|
|
205
|
+
f"observation_time="
|
|
206
|
+
f"{record.observation_time.isoformat()}, "
|
|
207
|
+
f"available_time="
|
|
208
|
+
f"{record.available_time.isoformat()}) — the "
|
|
209
|
+
"identity triple is a key, so the admitted "
|
|
210
|
+
"information set accepts no duplicate records: no "
|
|
211
|
+
"last-write-wins, no deduplication, and no "
|
|
212
|
+
"value-equality exception."
|
|
213
|
+
)
|
|
214
|
+
seen_identities.add(identity)
|
|
215
|
+
group = (record.series_id, observation_instant)
|
|
216
|
+
if group in seen_groups:
|
|
217
|
+
raise AmbiguousObservationError(
|
|
218
|
+
"ambiguous information-set input: the submitted "
|
|
219
|
+
"records carry more than one vintage of the same "
|
|
220
|
+
f"series-observation (series_id="
|
|
221
|
+
f"{record.series_id!r}, observation_time="
|
|
222
|
+
f"{record.observation_time.isoformat()}) — the "
|
|
223
|
+
"admitted information set is given at most one "
|
|
224
|
+
"vintage per series-observation and never selects "
|
|
225
|
+
"among vintages itself. Select the point-in-time "
|
|
226
|
+
"vintage with the ``vintage_as_of`` operation "
|
|
227
|
+
"before constructing the set."
|
|
228
|
+
)
|
|
229
|
+
seen_groups.add(group)
|
|
230
|
+
|
|
231
|
+
for record in submitted:
|
|
232
|
+
require_available_for_decision(record, decision)
|
|
233
|
+
|
|
234
|
+
object.__setattr__(self, "_records", submitted)
|
|
235
|
+
object.__setattr__(self, "_as_of", decision)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def as_of(self) -> datetime:
|
|
239
|
+
"""The decision instant the records were admitted at."""
|
|
240
|
+
return self._as_of
|
|
241
|
+
|
|
242
|
+
def __iter__(self):
|
|
243
|
+
"""Iterate the admitted records in submission order."""
|
|
244
|
+
return iter(self._records)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class Forecast:
|
|
249
|
+
"""A provenance-bearing forecast for one portfolio decision.
|
|
250
|
+
|
|
251
|
+
``values`` maps exact-string instrument or series identifiers to
|
|
252
|
+
forecasted floats with no numeric, distributional, or behavioral
|
|
253
|
+
constraint at this interface — negative expected returns,
|
|
254
|
+
one, and extreme magnitudes are all legitimate forecast content.
|
|
255
|
+
``target`` declares the financial quantity the values speak about
|
|
256
|
+
(an expected return, a risk measure, or any other declared target —
|
|
257
|
+
an open vocabulary: the surface guarantees target identity as an
|
|
258
|
+
exact non-blank string, never a taxonomy). ``decision_time`` is the
|
|
259
|
+
decision instant of the ``InformationSet`` the forecast was
|
|
260
|
+
formed from — equality with that set's ``as_of`` is the
|
|
261
|
+
``Forecaster`` implementer's obligation, verified in contract
|
|
262
|
+
tests, not a constructor-enforced invariant. ``produced_by`` names
|
|
263
|
+
the producing forecaster for provenance.
|
|
264
|
+
|
|
265
|
+
Construction is fail-closed: ``decision_time`` must be an aware
|
|
266
|
+
instant, and ``target``, ``produced_by``, and every key of
|
|
267
|
+
``values`` must be a non-blank exact string. ``values`` is stored
|
|
268
|
+
exactly as given — no copy, no deep freeze.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
values: Mapping[str, float]
|
|
272
|
+
target: str
|
|
273
|
+
decision_time: datetime
|
|
274
|
+
produced_by: str
|
|
275
|
+
|
|
276
|
+
def __post_init__(self) -> None:
|
|
277
|
+
_require_aware_instant(self.decision_time, "decision_time")
|
|
278
|
+
for identifier in self.values:
|
|
279
|
+
_require_identifier(identifier, "the forecast's values key")
|
|
280
|
+
_require_identifier(self.target, "the forecast's target")
|
|
281
|
+
_require_identifier(self.produced_by, "the forecast's produced_by")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True)
|
|
285
|
+
class PortfolioDecision:
|
|
286
|
+
"""One portfolio decision: when it is made, executed, and targeted.
|
|
287
|
+
|
|
288
|
+
``decision_time`` and ``execution_time`` are aware instants
|
|
289
|
+
satisfying the chronology law ``decision_time <=
|
|
290
|
+
execution_time`` — same-instant decide-and-execute is admissible, a
|
|
291
|
+
reversed order is not. ``target_weights`` maps exact-string
|
|
292
|
+
instrument identifiers to target portfolio weights and is stored
|
|
293
|
+
exactly as given: this interface places no weight constraint of
|
|
294
|
+
any kind — sum-to-one, gross and net exposure, long/short, and
|
|
295
|
+
feasibility conventions are left to later portfolio-weight
|
|
296
|
+
contracts to freeze.
|
|
297
|
+
|
|
298
|
+
Construction is fail-closed: both instants must be aware, every
|
|
299
|
+
``target_weights`` key must be a non-blank exact string, and the
|
|
300
|
+
chronology law must hold.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
decision_time: datetime
|
|
304
|
+
execution_time: datetime
|
|
305
|
+
target_weights: Mapping[str, float]
|
|
306
|
+
|
|
307
|
+
def __post_init__(self) -> None:
|
|
308
|
+
_require_aware_instant(self.decision_time, "decision_time")
|
|
309
|
+
_require_aware_instant(self.execution_time, "execution_time")
|
|
310
|
+
for identifier in self.target_weights:
|
|
311
|
+
_require_identifier(
|
|
312
|
+
identifier, "the decision's target_weights key"
|
|
313
|
+
)
|
|
314
|
+
decision_instant = _to_instant(self.decision_time, "decision_time")
|
|
315
|
+
execution_instant = _to_instant(self.execution_time, "execution_time")
|
|
316
|
+
if execution_instant < decision_instant:
|
|
317
|
+
raise InvalidChronologyError(
|
|
318
|
+
"chronology violation: a trade cannot execute before "
|
|
319
|
+
"its decision is made, but execution_time precedes "
|
|
320
|
+
f"decision_time; got decision_time="
|
|
321
|
+
f"{decision_instant.isoformat()}, execution_time="
|
|
322
|
+
f"{execution_instant.isoformat()}. Same-instant "
|
|
323
|
+
"decide-and-execute is admissible; a reversed order is "
|
|
324
|
+
"not."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True)
|
|
329
|
+
class AccountingResult:
|
|
330
|
+
"""The result of accounting one portfolio decision (deliberately thin).
|
|
331
|
+
|
|
332
|
+
A frozen value object with exactly one fact: accounting produces an
|
|
333
|
+
identifiable accounting result containing the post-trade portfolio
|
|
334
|
+
weights. It is deliberately not a ledger schema — no lot, cash,
|
|
335
|
+
fee, or realized-P&L field exists on it, and ``post_trade_weights``
|
|
336
|
+
carries no timing, realization-period, or accounting convention at
|
|
337
|
+
this interface. No validation is applied to its contents: which
|
|
338
|
+
accounting rules produced the weights is the accounting engine's
|
|
339
|
+
to define.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
post_trade_weights: Mapping[str, float]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --------------------------------------------------------------------------- #
|
|
346
|
+
# Structural contracts
|
|
347
|
+
# --------------------------------------------------------------------------- #
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class FeatureTransform(Protocol):
|
|
351
|
+
"""A lineage-monotone derivation of feature observations.
|
|
352
|
+
|
|
353
|
+
A transform consumes admitted ``TimedObservation`` records and
|
|
354
|
+
produces new ``TimedObservation`` records — derived features are
|
|
355
|
+
observations, carrying their own ``series_id``,
|
|
356
|
+
``observation_time``, ``available_time``, and ``value``, and
|
|
357
|
+
re-entering every observation law (identity, chronology,
|
|
358
|
+
admission) as such. Lineage monotonicity is enforced at this
|
|
359
|
+
surface through the exported ``require_feature_lineage``
|
|
360
|
+
validator, never inside the transform. Static-only: validated
|
|
361
|
+
behaviorally, not by ``isinstance``. This interface ships no
|
|
362
|
+
concrete transform, registry, or feature library.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
def transform(
|
|
366
|
+
self, observations: Iterable[TimedObservation]
|
|
367
|
+
) -> Sequence[TimedObservation]:
|
|
368
|
+
"""Derive feature observations from admitted input records."""
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class Forecaster(Protocol):
|
|
372
|
+
"""A forecaster producing provenance-bearing forecasts.
|
|
373
|
+
|
|
374
|
+
The ``information_set`` passed in has already enforced admission at
|
|
375
|
+
its ``as_of``; the forecaster treats that instant as the forecast
|
|
376
|
+
origin and returns a ``Forecast`` whose ``decision_time`` equals it
|
|
377
|
+
(the implementer's obligation, verified in contract tests). The
|
|
378
|
+
interface performs no admission of its own and accepts no
|
|
379
|
+
information outside the ``InformationSet``. No architecture —
|
|
380
|
+
LSTM, statistical, econometric, or otherwise — is prescribed;
|
|
381
|
+
fitting-window, estimation, and state semantics belong to the
|
|
382
|
+
implementer. Static-only: validated behaviorally, not by
|
|
383
|
+
``isinstance``.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def forecast(self, information_set: InformationSet) -> Forecast:
|
|
387
|
+
"""Produce the forecast for one admitted information set."""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class Strategy(Protocol):
|
|
391
|
+
"""A strategy turning one forecast into one portfolio decision.
|
|
392
|
+
|
|
393
|
+
The minimal signature takes the forecast alone; pre-trade
|
|
394
|
+
portfolio state as a further input is out of scope here.
|
|
395
|
+
Static-only: validated behaviorally, not by ``isinstance``.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def decide(self, forecast: Forecast) -> PortfolioDecision:
|
|
399
|
+
"""Decide the target portfolio from one forecast."""
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@runtime_checkable
|
|
403
|
+
class RebalancePolicy(Protocol):
|
|
404
|
+
"""A policy deciding whether a decision instant is a rebalance.
|
|
405
|
+
|
|
406
|
+
One pure query on aware instants: should the portfolio be
|
|
407
|
+
rebalanced at ``decision_time``, given the last rebalance happened
|
|
408
|
+
at ``last_rebalance_time``. No default schedule, calendar, or
|
|
409
|
+
frequency is implied — scheduling policy belongs to
|
|
410
|
+
implementations. Runtime-checkable because a runtime structural
|
|
411
|
+
check genuinely exists on this surface.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
def should_rebalance(
|
|
415
|
+
self, decision_time: datetime, last_rebalance_time: datetime
|
|
416
|
+
) -> bool:
|
|
417
|
+
"""Answer whether the portfolio should rebalance at this instant."""
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@runtime_checkable
|
|
421
|
+
class CostModel(Protocol):
|
|
422
|
+
"""A model estimating the cost of moving between two weight books.
|
|
423
|
+
|
|
424
|
+
One method estimating the trade cost of moving from
|
|
425
|
+
``pre_trade_weights`` to ``target_weights`` as a bare float — a
|
|
426
|
+
deliberately provisional typing. No functional form, units,
|
|
427
|
+
currency, or turnover definition is implied; cost semantics
|
|
428
|
+
belong to implementations. Runtime-checkable because a runtime
|
|
429
|
+
structural check genuinely exists on this surface.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
def estimate_trade_cost(
|
|
433
|
+
self,
|
|
434
|
+
pre_trade_weights: Mapping[str, float],
|
|
435
|
+
target_weights: Mapping[str, float],
|
|
436
|
+
) -> float:
|
|
437
|
+
"""Estimate the cost of trading to the target weight book."""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@runtime_checkable
|
|
441
|
+
class AccountingEngine(Protocol):
|
|
442
|
+
"""An engine accounting one executed portfolio decision.
|
|
443
|
+
|
|
444
|
+
One method consuming the decision, the pre-trade weight book, and
|
|
445
|
+
the realized returns of the decision's outcome, and producing an
|
|
446
|
+
``AccountingResult``. Neither ``pre_trade_weights`` nor
|
|
447
|
+
``realized_returns`` carries timing, realization-period, or
|
|
448
|
+
accounting convention at this interface — which realization
|
|
449
|
+
period and which accounting rules they must correspond to is
|
|
450
|
+
the implementer's to define. Runtime-checkable because a runtime
|
|
451
|
+
structural check genuinely exists on this surface.
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
def account(
|
|
455
|
+
self,
|
|
456
|
+
decision: PortfolioDecision,
|
|
457
|
+
pre_trade_weights: Mapping[str, float],
|
|
458
|
+
realized_returns: Mapping[str, float],
|
|
459
|
+
) -> AccountingResult:
|
|
460
|
+
"""Account one decision into an accounting result."""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@runtime_checkable
|
|
464
|
+
class Evaluator(Protocol):
|
|
465
|
+
"""An evaluator computing metrics from accounting results.
|
|
466
|
+
|
|
467
|
+
One method consuming an ``AccountingResult`` and returning an
|
|
468
|
+
explicitly open metric mapping — no metric catalogue, statistic,
|
|
469
|
+
benchmark, or significance semantics at this interface.
|
|
470
|
+
Runtime-checkable because a runtime structural check genuinely
|
|
471
|
+
exists on this surface.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
def evaluate(
|
|
475
|
+
self, accounting_result: AccountingResult
|
|
476
|
+
) -> Mapping[str, float]:
|
|
477
|
+
"""Compute the metric mapping for one accounting result."""
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------------------------------- #
|
|
481
|
+
# Compatibility validators
|
|
482
|
+
# --------------------------------------------------------------------------- #
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def require_feature_lineage(
|
|
486
|
+
inputs: Iterable[TimedObservation], outputs: Iterable[TimedObservation]
|
|
487
|
+
) -> None:
|
|
488
|
+
"""Assert no derived output is declared available before its inputs.
|
|
489
|
+
|
|
490
|
+
Thin reuse of the frozen lineage law: a
|
|
491
|
+
derived feature may not be declared available before the latest
|
|
492
|
+
input it derives from, and a feature with an empty input collection
|
|
493
|
+
has no defensible availability at all. The input availabilities
|
|
494
|
+
are collected once and each output's availability is checked by
|
|
495
|
+
``require_lineage_monotone`` — the law, the errors, and the
|
|
496
|
+
empty-input rejection all remain the observations module's;
|
|
497
|
+
nothing is redefined here.
|
|
498
|
+
|
|
499
|
+
Raises ``FeatureLineageError`` (from ``portlearn.observations``)
|
|
500
|
+
when any output's ``available_time`` precedes the latest input
|
|
501
|
+
availability or when ``inputs`` is empty.
|
|
502
|
+
"""
|
|
503
|
+
input_available_times = [item.available_time for item in inputs]
|
|
504
|
+
for output in outputs:
|
|
505
|
+
require_lineage_monotone(output.available_time, input_available_times)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def require_forecast_decision_compatible(
|
|
509
|
+
forecast: Forecast, decision: PortfolioDecision
|
|
510
|
+
) -> None:
|
|
511
|
+
"""Assert a decision is not dated before the forecast it consumes.
|
|
512
|
+
|
|
513
|
+
A decision made on a forecast must satisfy ``forecast.decision_time
|
|
514
|
+
<= decision.decision_time`` — deciding exactly at the forecast
|
|
515
|
+
origin is admissible, and deciding strictly later is admissible
|
|
516
|
+
with information admitted at the later instant. The impossible
|
|
517
|
+
ordering alone is forbidden: a decision dated before the
|
|
518
|
+
information it consumed is look-ahead leakage. Both instants are
|
|
519
|
+
aware-gated first, so naive or date-valued instants reject with
|
|
520
|
+
``NaiveTimestampError`` before any comparison.
|
|
521
|
+
|
|
522
|
+
Raises ``InvalidChronologyError`` (from ``portlearn.timing``) on
|
|
523
|
+
violation; returns ``None`` otherwise.
|
|
524
|
+
"""
|
|
525
|
+
forecast_origin = _to_instant(
|
|
526
|
+
forecast.decision_time, "the forecast's decision_time"
|
|
527
|
+
)
|
|
528
|
+
decision_instant = _to_instant(
|
|
529
|
+
decision.decision_time, "the decision's decision_time"
|
|
530
|
+
)
|
|
531
|
+
if forecast_origin > decision_instant:
|
|
532
|
+
raise InvalidChronologyError(
|
|
533
|
+
"chronology violation: a decision cannot be dated before "
|
|
534
|
+
"the forecast it consumes, but the decision's "
|
|
535
|
+
f"decision_time={decision_instant.isoformat()} precedes "
|
|
536
|
+
f"the forecast's decision_time="
|
|
537
|
+
f"{forecast_origin.isoformat()}. Deciding on information "
|
|
538
|
+
"requires the information to exist first."
|
|
539
|
+
)
|