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.
@@ -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
+ ```