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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Point-in-time observations, vintages, and feature lineage.
|
|
2
|
+
|
|
3
|
+
This module freezes the information semantics:
|
|
4
|
+
|
|
5
|
+
- **Data law** — data is what observations exist: records carrying both
|
|
6
|
+
``observation_time`` (when the underlying phenomenon is dated) and
|
|
7
|
+
``available_time`` (when the information first could have been known);
|
|
8
|
+
an observation without declared availability is not data PortLearn can
|
|
9
|
+
admit.
|
|
10
|
+
- **Identity law** — an observation's identity is the triple
|
|
11
|
+
``(series_id, observation_time, available_time)``. A revision is a
|
|
12
|
+
separate record with the same ``series_id`` and ``observation_time``
|
|
13
|
+
and a later ``available_time``; revisions are never mutations.
|
|
14
|
+
``series_id`` is an opaque, non-empty, non-blank string compared by
|
|
15
|
+
exact string equality — no case folding, whitespace stripping, or
|
|
16
|
+
Unicode normalization of any kind.
|
|
17
|
+
- **Vintage law** — at a decision time, the visible vintage of a
|
|
18
|
+
series-observation is the record with the latest ``available_time``
|
|
19
|
+
at or before the decision; a revised value is invisible before its
|
|
20
|
+
own availability. ``vintage_as_of`` accepts exactly one
|
|
21
|
+
``(series_id, observation_time)`` group and rejects duplicate
|
|
22
|
+
full-identity records and mixed-group input fail-closed.
|
|
23
|
+
- **Feature lineage law** — a derived feature may not be declared
|
|
24
|
+
available before its latest input: ``feature available_time ≥
|
|
25
|
+
max(input available_times)``, and an empty input collection has no
|
|
26
|
+
defensible availability.
|
|
27
|
+
|
|
28
|
+
This module owns ``AmbiguousObservationError`` and
|
|
29
|
+
``FeatureLineageError`` and imports the timing errors it raises;
|
|
30
|
+
``portlearn.timing`` imports nothing from here, so no
|
|
31
|
+
import cycle exists on this contract surface.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Iterable, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from .timing import (
|
|
42
|
+
InvalidChronologyError,
|
|
43
|
+
MissingAvailabilityError,
|
|
44
|
+
NaiveTimestampError, # noqa: F401 # re-exported: raised at this surface by the validator from portlearn.timing
|
|
45
|
+
_require_aware_instant, # noqa: F401 # re-exported: the shared validator from portlearn.timing, imported per the reuse law
|
|
46
|
+
instant_key,
|
|
47
|
+
to_instant,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"AmbiguousObservationError",
|
|
52
|
+
"FeatureLineageError",
|
|
53
|
+
"TimedObservation",
|
|
54
|
+
"require_lineage_monotone",
|
|
55
|
+
"vintage_as_of",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
# Fail-closed error taxonomy — the observations-owned arm
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AmbiguousObservationError(Exception):
|
|
65
|
+
"""Observation identity is ambiguous: duplicate records or mixed input.
|
|
66
|
+
|
|
67
|
+
Raised when two records share the full identity triple
|
|
68
|
+
``(series_id, observation_time, available_time)`` — whether or not
|
|
69
|
+
their values agree, since the identity triple is a key with no
|
|
70
|
+
last-write-wins and no value-equality exception — or when input to
|
|
71
|
+
``vintage_as_of`` spans more than one ``(series_id,
|
|
72
|
+
observation_time)`` group. No preference rule is applied.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FeatureLineageError(Exception):
|
|
77
|
+
"""A feature's declared availability violates lineage monotonicity.
|
|
78
|
+
|
|
79
|
+
A derived feature may not be declared available before its latest
|
|
80
|
+
input's availability, and a feature with an empty input collection
|
|
81
|
+
has no defensible availability at all: both fail closed rather than
|
|
82
|
+
warn, because a feature visible before the data it derives from is
|
|
83
|
+
look-ahead leakage.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
# Instant validation — reused, not re-implemented
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
|
|
91
|
+
# The aware-instant validator is implemented in ``portlearn.timing``
|
|
92
|
+
# and imported above: shared invariant validation has exactly one
|
|
93
|
+
# implementation, and every surface that needs it re-exports that
|
|
94
|
+
# exact function object by import, so re-exports preserve object
|
|
95
|
+
# identity package-wide. The canonical invalid-input wording —
|
|
96
|
+
# including its calendar-date tail — is timing.py's.
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
# The observation value object
|
|
101
|
+
# --------------------------------------------------------------------------- #
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class TimedObservation:
|
|
106
|
+
"""One point-in-time record of a series observation.
|
|
107
|
+
|
|
108
|
+
``observation_time`` is when the underlying phenomenon is dated;
|
|
109
|
+
``available_time`` is when the information first could have been
|
|
110
|
+
known (publication is one source of availability); ``value`` is the
|
|
111
|
+
observed figure. The identity of the record is the triple
|
|
112
|
+
``(series_id, observation_time, available_time)``: a revision is a
|
|
113
|
+
separate record with a later ``available_time``, never a mutation.
|
|
114
|
+
|
|
115
|
+
Construction is fail-closed: ``series_id`` must be a non-empty,
|
|
116
|
+
non-blank string (validated with the built-in ``ValueError`` because
|
|
117
|
+
a blank identifier names no series); both instants must be aware;
|
|
118
|
+
``available_time`` may not be absent, ``None``, or precede the
|
|
119
|
+
observation it describes. The identifier is preserved exactly —
|
|
120
|
+
no case folding, trimming, or Unicode normalization.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
series_id: str
|
|
124
|
+
observation_time: datetime
|
|
125
|
+
available_time: datetime
|
|
126
|
+
value: Any
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
# ValueError (not TypeError) is pinned for non-string identifiers:
|
|
130
|
+
# the built-in ValueError is the closed-taxonomy choice for every
|
|
131
|
+
# malformed series_id, non-string included.
|
|
132
|
+
if not isinstance(self.series_id, str):
|
|
133
|
+
raise ValueError( # noqa: TRY004 — pinned for malformed identifiers
|
|
134
|
+
"series_id must be a string identifier naming the series "
|
|
135
|
+
"the observation belongs to; got "
|
|
136
|
+
f"{type(self.series_id).__name__}: {self.series_id!r}. "
|
|
137
|
+
"A non-string identifier names no series, so the "
|
|
138
|
+
"observation is rejected fail-closed."
|
|
139
|
+
)
|
|
140
|
+
if not self.series_id.strip():
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"series_id must be a non-empty, non-blank string identifier "
|
|
143
|
+
f"naming the series the observation belongs to; got "
|
|
144
|
+
f"{self.series_id!r}. A blank identifier names no series, "
|
|
145
|
+
"so the observation is rejected fail-closed."
|
|
146
|
+
)
|
|
147
|
+
observation = to_instant(
|
|
148
|
+
self.observation_time, "observation_time"
|
|
149
|
+
)
|
|
150
|
+
if self.available_time is None:
|
|
151
|
+
raise MissingAvailabilityError(
|
|
152
|
+
f"the observation for series {self.series_id!r} declares "
|
|
153
|
+
"available_time=None; availability is mandatory — a source "
|
|
154
|
+
"unable to declare when the information first could have "
|
|
155
|
+
"been known must fail, never default"
|
|
156
|
+
)
|
|
157
|
+
available = to_instant(self.available_time, "available_time")
|
|
158
|
+
if available < observation:
|
|
159
|
+
raise InvalidChronologyError(
|
|
160
|
+
"chronology violation: availability may not precede the "
|
|
161
|
+
f"observation it describes, but series {self.series_id!r} "
|
|
162
|
+
f"declares observation_time={observation.isoformat()} after "
|
|
163
|
+
f"available_time={available.isoformat()}. Information "
|
|
164
|
+
"cannot be published before the phenomenon it reports."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------- #
|
|
169
|
+
# Point-in-time vintage selection
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def vintage_as_of(
|
|
174
|
+
observations: Sequence[TimedObservation], decision_time: Any
|
|
175
|
+
) -> TimedObservation | None:
|
|
176
|
+
"""The visible vintage of one series-observation at a decision time.
|
|
177
|
+
|
|
178
|
+
Selection follows the point-in-time rule: among
|
|
179
|
+
exactly one ``(series_id, observation_time)`` group, the visible
|
|
180
|
+
vintage is the record with the latest ``available_time`` at or
|
|
181
|
+
before ``decision_time`` — availability exactly at the decision is
|
|
182
|
+
visible. A revised value is invisible before its own availability.
|
|
183
|
+
|
|
184
|
+
Evaluation is fail-closed in this order: ``decision_time`` is
|
|
185
|
+
validated as an aware instant before
|
|
186
|
+
any branching; empty input returns ``None`` (no vintage, explicitly
|
|
187
|
+
not an error); input spanning more than one group rejects with
|
|
188
|
+
``AmbiguousObservationError``; duplicate full-identity records
|
|
189
|
+
reject with ``AmbiguousObservationError`` whether or not their
|
|
190
|
+
values agree; when no record is visible, the outcome is ``None``,
|
|
191
|
+
again not an error.
|
|
192
|
+
"""
|
|
193
|
+
decision = to_instant(decision_time, "decision_time")
|
|
194
|
+
|
|
195
|
+
if not observations:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
groups: dict[tuple[str, datetime], list[TimedObservation]] = {}
|
|
199
|
+
for record in observations:
|
|
200
|
+
groups.setdefault(
|
|
201
|
+
(record.series_id, instant_key(record.observation_time)), []
|
|
202
|
+
).append(record)
|
|
203
|
+
|
|
204
|
+
if len(groups) > 1:
|
|
205
|
+
named = sorted({series_id for series_id, _ in groups})
|
|
206
|
+
raise AmbiguousObservationError(
|
|
207
|
+
"ambiguous observation input: the records span more than one "
|
|
208
|
+
f"(series_id, observation_time) group — series {named!r} — so "
|
|
209
|
+
"no single series-observation vintage can be selected without "
|
|
210
|
+
"an illegitimate cross-group preference rule. Submit exactly "
|
|
211
|
+
"one group per vintage query."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
group = next(iter(groups.values()))
|
|
215
|
+
seen: set[tuple[str, datetime, datetime]] = set()
|
|
216
|
+
for record in group:
|
|
217
|
+
identity = (
|
|
218
|
+
record.series_id,
|
|
219
|
+
instant_key(record.observation_time),
|
|
220
|
+
instant_key(record.available_time),
|
|
221
|
+
)
|
|
222
|
+
if identity in seen:
|
|
223
|
+
raise AmbiguousObservationError(
|
|
224
|
+
"ambiguous observation input: two records share the full "
|
|
225
|
+
f"identity triple (series_id={record.series_id!r}, "
|
|
226
|
+
f"observation_time={record.observation_time.isoformat()}, "
|
|
227
|
+
f"available_time={record.available_time.isoformat()}) — the "
|
|
228
|
+
"identity triple is a key, so there is no last-write-wins "
|
|
229
|
+
"and no value-equality exception; conflicting or "
|
|
230
|
+
"indeterminate availability fails closed."
|
|
231
|
+
)
|
|
232
|
+
seen.add(identity)
|
|
233
|
+
|
|
234
|
+
visible = [
|
|
235
|
+
record
|
|
236
|
+
for record in group
|
|
237
|
+
if instant_key(record.available_time) <= decision
|
|
238
|
+
]
|
|
239
|
+
if not visible:
|
|
240
|
+
return None
|
|
241
|
+
return max(visible, key=lambda record: instant_key(record.available_time))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
245
|
+
# Feature lineage monotonicity
|
|
246
|
+
# --------------------------------------------------------------------------- #
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def require_lineage_monotone(
|
|
250
|
+
feature_available_time: Any, input_available_times: Iterable[Any]
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Assert a feature is not declared available before its latest input.
|
|
253
|
+
|
|
254
|
+
A derived feature may enter an information set no earlier than the
|
|
255
|
+
latest input it derives from: ``feature_available_time`` must be at
|
|
256
|
+
or after ``max(input_available_times)``. An
|
|
257
|
+
empty input collection also rejects — a feature with no declared
|
|
258
|
+
inputs has no defensible availability. Both the feature instant
|
|
259
|
+
and every input instant must be aware; naive or date inputs fail
|
|
260
|
+
closed with ``NaiveTimestampError`` on this surface too.
|
|
261
|
+
"""
|
|
262
|
+
feature_available = to_instant(
|
|
263
|
+
feature_available_time, "feature_available_time"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
inputs = list(input_available_times)
|
|
267
|
+
if not inputs:
|
|
268
|
+
raise FeatureLineageError(
|
|
269
|
+
"feature lineage violation: the feature declares an empty "
|
|
270
|
+
"input collection, so no input availability bounds its own — "
|
|
271
|
+
"a feature with no declared inputs has no defensible "
|
|
272
|
+
"availability and cannot be admitted."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
input_instants = [
|
|
276
|
+
to_instant(instant, "input_available_time") for instant in inputs
|
|
277
|
+
]
|
|
278
|
+
latest_input = max(input_instants)
|
|
279
|
+
|
|
280
|
+
if feature_available < latest_input:
|
|
281
|
+
raise FeatureLineageError(
|
|
282
|
+
"feature lineage violation: the feature is declared available "
|
|
283
|
+
"before its latest input — feature_available_time="
|
|
284
|
+
f"{feature_available.isoformat()} precedes the latest "
|
|
285
|
+
f"input_available_time={latest_input.isoformat()}. A derived "
|
|
286
|
+
"value visible before the data it derives from is look-ahead "
|
|
287
|
+
"leakage, so the declaration is rejected."
|
|
288
|
+
)
|
portlearn/py.typed
ADDED
|
File without changes
|
portlearn/timing.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Time-index and admission contracts for portfolio decisions.
|
|
2
|
+
|
|
3
|
+
This module freezes the timing semantics:
|
|
4
|
+
|
|
5
|
+
- every financial time is a timezone-aware instant, compared as instants
|
|
6
|
+
(wall clock and zone name never affect ordering or equality);
|
|
7
|
+
- naive datetimes and ``datetime.date`` inputs are rejected fail-closed —
|
|
8
|
+
no default zone is ever assumed and no date-to-midnight coercion is
|
|
9
|
+
ever performed, because silent coercion is the classic daily-data
|
|
10
|
+
look-ahead leakage vector;
|
|
11
|
+
- the chronology law
|
|
12
|
+
|
|
13
|
+
``observation_time ≤ available_time ≤ decision_time ≤ execution_time
|
|
14
|
+
≤ realization start_time < realization end_time``
|
|
15
|
+
|
|
16
|
+
holds with equal adjacent instants admissible at every ``≤`` boundary
|
|
17
|
+
(zero publication lag, availability exactly at the decision,
|
|
18
|
+
same-instant decide-and-execute) and strictly positive realization
|
|
19
|
+
length;
|
|
20
|
+
- the admission law: an item of information may enter the information
|
|
21
|
+
set for a decision at ``decision_time`` iff its ``available_time`` is
|
|
22
|
+
at or before ``decision_time``, evaluated on aware instants. The gate
|
|
23
|
+
is the decision instant only, never the execution instant, so
|
|
24
|
+
information arriving in the decision-to-execution gap is look-ahead
|
|
25
|
+
leakage and is rejected.
|
|
26
|
+
|
|
27
|
+
This module defines four fail-closed errors:
|
|
28
|
+
``NaiveTimestampError``, ``InvalidChronologyError``,
|
|
29
|
+
``FutureInformationError``, and ``MissingAvailabilityError``. It is
|
|
30
|
+
stdlib-only and imports nothing from ``portlearn.observations``, so no
|
|
31
|
+
import cycle exists on the frozen contract surface. Every semantic
|
|
32
|
+
ordering, equality, and identity-key comparison operates on the
|
|
33
|
+
normalized UTC instant (``to_instant`` / ``instant_key``), so DST-fold
|
|
34
|
+
ambiguity can never make two distinct instants compare equal.
|
|
35
|
+
PortLearn renders instants in no local timezone of its own;
|
|
36
|
+
formatting an instant for display in any zone is the caller's
|
|
37
|
+
concern, and no contract comparison depends on any local timezone.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from datetime import UTC, date, datetime
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"DecisionTiming",
|
|
48
|
+
"FutureInformationError",
|
|
49
|
+
"InvalidChronologyError",
|
|
50
|
+
"MissingAvailabilityError",
|
|
51
|
+
"NaiveTimestampError",
|
|
52
|
+
"ReturnRealizationPeriod",
|
|
53
|
+
"instant_key",
|
|
54
|
+
"is_available_for_decision",
|
|
55
|
+
"require_available_for_decision",
|
|
56
|
+
"to_instant",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
# Fail-closed error taxonomy — the timing-owned arm
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NaiveTimestampError(Exception):
|
|
66
|
+
"""A time input lacks an explicit timezone, or a date was supplied.
|
|
67
|
+
|
|
68
|
+
Financial times are compared as instants on a single timeline. An
|
|
69
|
+
instant without an explicit UTC offset cannot be placed on that
|
|
70
|
+
timeline without assuming a default zone, and a ``datetime.date``
|
|
71
|
+
cannot be placed on it without silently coercing it to that date's
|
|
72
|
+
midnight — the classic daily-data leakage vector. PortLearn does
|
|
73
|
+
neither, so the input is rejected.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InvalidChronologyError(Exception):
|
|
78
|
+
"""The chronology law was violated.
|
|
79
|
+
|
|
80
|
+
Availability may not precede the observation it describes, a trade
|
|
81
|
+
may not execute before its decision is made, outcomes may not start
|
|
82
|
+
realizing before the trade executes, and the return-realization
|
|
83
|
+
period must have strictly positive length.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FutureInformationError(Exception):
|
|
88
|
+
"""An item failing the admission law was submitted for a decision.
|
|
89
|
+
|
|
90
|
+
This is the look-ahead rejection: the item's ``available_time`` is
|
|
91
|
+
after the ``decision_time`` of the decision that would consume it,
|
|
92
|
+
so admitting it would let the decision see information that did not
|
|
93
|
+
exist yet.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MissingAvailabilityError(Exception):
|
|
98
|
+
"""``available_time`` is absent or ``None`` on a submitted item.
|
|
99
|
+
|
|
100
|
+
Availability is mandatory: a source unable to declare when the
|
|
101
|
+
information first could have been known must fail, never default to
|
|
102
|
+
the observation instant, "immediately available", or "never
|
|
103
|
+
available".
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
# Instant validation
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _require_aware_instant(value: Any, field_name: str) -> datetime:
|
|
113
|
+
"""Return ``value`` as a timezone-aware instant, or fail closed.
|
|
114
|
+
|
|
115
|
+
Naive datetimes, ``datetime.date`` inputs, and non-instant inputs
|
|
116
|
+
are all rejected with ``NaiveTimestampError``: no default timezone
|
|
117
|
+
is assumed and no date-to-midnight coercion is performed anywhere.
|
|
118
|
+
"""
|
|
119
|
+
if isinstance(value, datetime):
|
|
120
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
121
|
+
raise NaiveTimestampError(
|
|
122
|
+
f"{field_name} is a naive timestamp — it carries no explicit "
|
|
123
|
+
f"UTC offset, so it cannot be placed on the single timeline "
|
|
124
|
+
f"on which financial times are compared without assuming a "
|
|
125
|
+
f"default timezone; got {value!r}. Supply a timezone-aware "
|
|
126
|
+
"datetime (an explicit offset) instead."
|
|
127
|
+
)
|
|
128
|
+
return value
|
|
129
|
+
if isinstance(value, date):
|
|
130
|
+
raise NaiveTimestampError(
|
|
131
|
+
f"{field_name} is a calendar date ({value.isoformat()}), not an "
|
|
132
|
+
"instant: PortLearn never coerces a date to that date's "
|
|
133
|
+
"midnight, because such coercion is the classic daily-data "
|
|
134
|
+
"look-ahead leakage vector. Supply a timezone-aware datetime "
|
|
135
|
+
"with an explicit UTC offset declaring when the information "
|
|
136
|
+
"first could have been known."
|
|
137
|
+
)
|
|
138
|
+
raise NaiveTimestampError(
|
|
139
|
+
f"{field_name} must be a timezone-aware datetime instant carrying "
|
|
140
|
+
f"an explicit UTC offset; got {type(value).__name__}: {value!r}. "
|
|
141
|
+
"Financial times are compared as instants, so an input that is not "
|
|
142
|
+
"an aware instant is rejected fail-closed."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _item_available_time(item: Any) -> datetime:
|
|
147
|
+
"""Extract and validate ``item.available_time`` for the admission law.
|
|
148
|
+
|
|
149
|
+
Attribute absence and ``None`` are ``MissingAvailabilityError``; a
|
|
150
|
+
naive datetime or a ``datetime.date`` is ``NaiveTimestampError``.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
available = item.available_time
|
|
154
|
+
except AttributeError:
|
|
155
|
+
raise MissingAvailabilityError(
|
|
156
|
+
"the submitted information item declares no available_time; "
|
|
157
|
+
"availability is mandatory on every contract surface — a source "
|
|
158
|
+
"unable to declare when the information first could have been "
|
|
159
|
+
"known must fail, never default"
|
|
160
|
+
) from None
|
|
161
|
+
if available is None:
|
|
162
|
+
raise MissingAvailabilityError(
|
|
163
|
+
"the submitted information item declares available_time=None; "
|
|
164
|
+
"availability is mandatory on every contract surface — a source "
|
|
165
|
+
"unable to declare when the information first could have been "
|
|
166
|
+
"known must fail, never default"
|
|
167
|
+
)
|
|
168
|
+
return _require_aware_instant(available, "available_time")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _item_series_id(item: Any) -> Any:
|
|
172
|
+
"""The item's series identifier when one is in context."""
|
|
173
|
+
return getattr(item, "series_id", None)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
# Instant normalization — the single comparison basis
|
|
178
|
+
# --------------------------------------------------------------------------- #
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def to_instant(value: Any, field_name: str = "timestamp") -> datetime:
|
|
182
|
+
"""Return the UTC datetime denoting ``value``'s true instant.
|
|
183
|
+
|
|
184
|
+
``value`` is validated fail-closed exactly as
|
|
185
|
+
``_require_aware_instant`` validates it, then normalized through
|
|
186
|
+
``astimezone(timezone.utc)``, which resolves ``fold`` through the
|
|
187
|
+
zone's ``utcoffset``: the two Melbourne 2026-04-05 02:30
|
|
188
|
+
ambiguities normalize to ``2026-04-04T15:30Z`` (fold=0, UTC+11)
|
|
189
|
+
and ``2026-04-04T16:30Z`` (fold=1, UTC+10). Every semantic
|
|
190
|
+
ordering and equality comparison in this package compares these
|
|
191
|
+
normalized instants, never the raw datetimes, because raw
|
|
192
|
+
aware-datetime comparison ignores ``fold`` whenever both sides
|
|
193
|
+
share one ``tzinfo`` object and can therefore call two distinct
|
|
194
|
+
instants equal. Original datetime objects stay stored where
|
|
195
|
+
they are held for provenance and display; normalization serves
|
|
196
|
+
comparison only — stored data is never UTC-converted.
|
|
197
|
+
"""
|
|
198
|
+
moment = _require_aware_instant(value, field_name)
|
|
199
|
+
return moment.astimezone(UTC)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def instant_key(value: Any, field_name: str = "timestamp") -> datetime:
|
|
203
|
+
"""Hashable normalized-UTC identity for timestamp keys and sorting.
|
|
204
|
+
|
|
205
|
+
The same normalization as ``to_instant``, named for its role:
|
|
206
|
+
use it wherever a datetime becomes a dictionary key, a set
|
|
207
|
+
member, or a sort key, so two fold-distinct instants never
|
|
208
|
+
silently merge into one identity. Sorting on ``instant_key``
|
|
209
|
+
values is safe because they all carry ``timezone.utc``, where no
|
|
210
|
+
fold ambiguity exists.
|
|
211
|
+
"""
|
|
212
|
+
return to_instant(value, field_name)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --------------------------------------------------------------------------- #
|
|
216
|
+
# Value objects
|
|
217
|
+
# --------------------------------------------------------------------------- #
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class ReturnRealizationPeriod:
|
|
222
|
+
"""The half-open instant interval ``[start_time, end_time)`` over
|
|
223
|
+
which a decision's outcome realizes.
|
|
224
|
+
|
|
225
|
+
Both bounds must be aware instants and the period must have strictly
|
|
226
|
+
positive length: ``start_time < end_time``. Equal adjacent bounds
|
|
227
|
+
are admissible everywhere else in the chronology law, but a
|
|
228
|
+
zero-length or reversed realization period is malformed.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
start_time: datetime
|
|
232
|
+
end_time: datetime
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
start = to_instant(self.start_time, "realization start_time")
|
|
236
|
+
end = to_instant(self.end_time, "realization end_time")
|
|
237
|
+
if not start < end:
|
|
238
|
+
raise InvalidChronologyError(
|
|
239
|
+
"malformed return-realization period: start_time must "
|
|
240
|
+
"strictly precede end_time so the half-open period "
|
|
241
|
+
f"[start_time, end_time) has strictly positive length; got "
|
|
242
|
+
f"start_time={start.isoformat()}, end_time={end.isoformat()}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class DecisionTiming:
|
|
248
|
+
"""When a single portfolio decision is made, executed, and realized.
|
|
249
|
+
|
|
250
|
+
Validates the decision-event portion of the chronology law:
|
|
251
|
+
``decision_time ≤ execution_time ≤ realization start_time``,
|
|
252
|
+
with equal adjacent instants admissible (same-instant
|
|
253
|
+
decide-and-execute is an explicit design) and all instants aware.
|
|
254
|
+
Rebalance schedules and decision frequency are out of scope here.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
decision_time: datetime
|
|
258
|
+
execution_time: datetime
|
|
259
|
+
return_realization_period: ReturnRealizationPeriod
|
|
260
|
+
|
|
261
|
+
def __post_init__(self) -> None:
|
|
262
|
+
decision = to_instant(self.decision_time, "decision_time")
|
|
263
|
+
execution = to_instant(self.execution_time, "execution_time")
|
|
264
|
+
start = to_instant(
|
|
265
|
+
self.return_realization_period.start_time, "realization start_time"
|
|
266
|
+
)
|
|
267
|
+
if execution < decision:
|
|
268
|
+
raise InvalidChronologyError(
|
|
269
|
+
"chronology violation: a trade cannot execute before its "
|
|
270
|
+
"decision is made, but execution_time precedes "
|
|
271
|
+
f"decision_time; got decision_time={decision.isoformat()}, "
|
|
272
|
+
f"execution_time={execution.isoformat()}"
|
|
273
|
+
)
|
|
274
|
+
if start < execution:
|
|
275
|
+
raise InvalidChronologyError(
|
|
276
|
+
"chronology violation: outcomes cannot start realizing "
|
|
277
|
+
"before the trade executes, but realization start_time "
|
|
278
|
+
"precedes execution_time; got execution_time="
|
|
279
|
+
f"{execution.isoformat()}, realization start_time="
|
|
280
|
+
f"{start.isoformat()}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --------------------------------------------------------------------------- #
|
|
285
|
+
# The admission law
|
|
286
|
+
# --------------------------------------------------------------------------- #
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def is_available_for_decision(item: Any, decision_time: Any) -> bool:
|
|
290
|
+
"""Pure admission query: is ``item``'s information visible yet?
|
|
291
|
+
|
|
292
|
+
Returns ``True`` iff ``item.available_time <= decision_time`` on
|
|
293
|
+
aware instants — availability exactly at the decision admits the
|
|
294
|
+
item. ``False`` means "not yet available", a legitimate verdict,
|
|
295
|
+
never a silent drop. Malformed inputs still raise: the
|
|
296
|
+
``decision_time`` is validated before any comparison, and an item
|
|
297
|
+
whose ``available_time`` is absent, ``None``, naive, or a date fails
|
|
298
|
+
closed with its typed error so "not yet available" is never
|
|
299
|
+
conflated with "malformed".
|
|
300
|
+
"""
|
|
301
|
+
decision = to_instant(decision_time, "decision_time")
|
|
302
|
+
available = to_instant(_item_available_time(item), "available_time")
|
|
303
|
+
return available <= decision
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def require_available_for_decision(item: Any, decision_time: Any) -> None:
|
|
307
|
+
"""Admission gate: raise on look-ahead, return ``None`` on admission.
|
|
308
|
+
|
|
309
|
+
Raises ``FutureInformationError`` when ``item.available_time`` is
|
|
310
|
+
after ``decision_time`` — including information arriving inside the
|
|
311
|
+
decision-to-execution gap, which satisfies no admission verdict
|
|
312
|
+
because the gate is the decision instant only, never the execution
|
|
313
|
+
instant. Malformed inputs raise their typed
|
|
314
|
+
errors exactly as the boolean query does.
|
|
315
|
+
"""
|
|
316
|
+
decision = to_instant(decision_time, "decision_time")
|
|
317
|
+
available = to_instant(_item_available_time(item), "available_time")
|
|
318
|
+
if available > decision:
|
|
319
|
+
series_id = _item_series_id(item)
|
|
320
|
+
raise FutureInformationError(
|
|
321
|
+
f"look-ahead rejection: the information item {series_id!r} is "
|
|
322
|
+
"not available at the decision instant — available_time="
|
|
323
|
+
f"{available.isoformat()} is after decision_time="
|
|
324
|
+
f"{decision.isoformat()}. Information that first could have "
|
|
325
|
+
"been known after the decision (including inside the "
|
|
326
|
+
"decision-to-execution gap) is future information and may not "
|
|
327
|
+
"enter the information set."
|
|
328
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: portlearn
|
|
3
|
+
Version: 0.0.1.dev0
|
|
4
|
+
Summary: Finance-first research framework for controlled, reproducible, and modular experimentation in machine-learned portfolio choice.
|
|
5
|
+
Project-URL: Repository, https://github.com/fmasoudy/PortLearn
|
|
6
|
+
Project-URL: Issues, https://github.com/fmasoudy/PortLearn/issues
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# PortLearn
|
|
13
|
+
|
|
14
|
+
PortLearn is a finance-first research framework for controlled, reproducible, and modular experimentation in machine-learned portfolio choice.
|
|
15
|
+
|
|
16
|
+
> Change the research component without accidentally changing the financial experiment.
|
|
17
|
+
|
|
18
|
+
PortLearn is being developed to support comparable portfolio-learning research across classical methods, forecasting-based machine learning, direct deep learning, reinforcement learning, optimization-based approaches, and user-defined research components under common financial experiment contracts.
|
|
19
|
+
|
|
20
|
+
## Status
|
|
21
|
+
|
|
22
|
+
PortLearn is in early development and has not yet been published as a stable package release. The current public surface exposes the research foundation and API contracts. The public API is not yet stable.
|
|
23
|
+
|
|
24
|
+
## Design Direction
|
|
25
|
+
|
|
26
|
+
PortLearn aims to provide reusable infrastructure for:
|
|
27
|
+
|
|
28
|
+
- financial data and information-set construction;
|
|
29
|
+
- modular feature engineering;
|
|
30
|
+
- portfolio strategy composition;
|
|
31
|
+
- common portfolio accounting;
|
|
32
|
+
- controlled comparison of alternative methods;
|
|
33
|
+
- research reproducibility and provenance.
|
|
34
|
+
|
|
35
|
+
PortLearn is a research toolkit, not a repository for individual paper-specific models or unpublished research architectures.
|
|
36
|
+
|
|
37
|
+
## Roadmap
|
|
38
|
+
|
|
39
|
+
PortLearn is under active research development. This roadmap is intentionally high-level: it communicates broad direction only, is subject to change as the research framework develops, and does not promise dates or specific functionality.
|
|
40
|
+
|
|
41
|
+
**Available now**
|
|
42
|
+
|
|
43
|
+
- Research foundation: time/chronology contracts, observation handling, information sets, and the core research interfaces that define how portfolio research components compose.
|
|
44
|
+
- Validation utilities for detecting violations of point-in-time information contracts.
|
|
45
|
+
- Canonical run manifests for recording environment and command provenance.
|
|
46
|
+
|
|
47
|
+
**Next**
|
|
48
|
+
|
|
49
|
+
- Datasets and data access: adapters and alignment for market and macro data.
|
|
50
|
+
- Experiment and reproducibility infrastructure.
|
|
51
|
+
|
|
52
|
+
**Planned**
|
|
53
|
+
|
|
54
|
+
- Forecasting methods.
|
|
55
|
+
- Portfolio construction and accounting.
|
|
56
|
+
- Later deep-learning and reinforcement-learning research capabilities.
|
|
57
|
+
|
|
58
|
+
Entries move forward on this roadmap as the underlying research foundation stabilizes; nothing here is a dated commitment.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
The local development battery, from a fresh clone:
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
uv sync --locked
|
|
66
|
+
uv run ruff check .
|
|
67
|
+
uv run pytest
|
|
68
|
+
uv build
|
|
69
|
+
uv run python scripts/verify_built_wheel.py
|
|
70
|
+
```
|