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 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")
@@ -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
+ )