portlearn 0.0.1.dev1__tar.gz → 0.0.1.dev2__tar.gz
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-0.0.1.dev1 → portlearn-0.0.1.dev2}/CHANGELOG.md +20 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/PKG-INFO +8 -4
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/README.md +7 -3
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/pyproject.toml +1 -1
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/__init__.py +29 -3
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/alignment.py +18 -32
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/calendar.py +5 -10
- portlearn-0.0.1.dev2/src/portlearn/costs.py +276 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/__init__.py +2 -3
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/adapters/ff.py +4 -5
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/adapters/fred.py +9 -12
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/dataset.py +17 -28
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_plot.py +1 -1
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/fama_french.py +1 -1
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/fred.py +2 -3
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/ingestion.py +16 -22
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/forecasting.py +20 -35
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/interfaces.py +256 -22
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/leakage.py +6 -7
- portlearn-0.0.1.dev2/src/portlearn/ledger.py +767 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/observations.py +4 -4
- portlearn-0.0.1.dev2/src/portlearn/rebalance.py +587 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/timing.py +2 -2
- portlearn-0.0.1.dev2/src/portlearn/trades.py +197 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/transforms.py +49 -50
- portlearn-0.0.1.dev2/src/portlearn/turnover.py +96 -0
- portlearn-0.0.1.dev2/src/portlearn/weights.py +376 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_fred_unqualified.py +3 -2
- portlearn-0.0.1.dev2/tests/test_composition_falsification.py +1784 -0
- portlearn-0.0.1.dev2/tests/test_costs_contracts.py +443 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_interface_contracts.py +274 -27
- portlearn-0.0.1.dev2/tests/test_ledger_contracts.py +755 -0
- portlearn-0.0.1.dev2/tests/test_ledger_invariants.py +1390 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_package_contract.py +1 -1
- portlearn-0.0.1.dev2/tests/test_rebalance_contracts.py +812 -0
- portlearn-0.0.1.dev2/tests/test_trades_contracts.py +338 -0
- portlearn-0.0.1.dev2/tests/test_turnover_contracts.py +158 -0
- portlearn-0.0.1.dev2/tests/test_weight_contracts.py +768 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_wiring_contracts.py +32 -11
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/uv.lock +1 -1
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/.github/workflows/ci.yml +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/.github/workflows/release.yml +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/.gitignore +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/LICENSE +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/MANIFEST.txt +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/README.md +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/favicon/portlearn-favicon.ico +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-1024.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-128.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-16.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-256.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-32.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-48.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-512.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-64.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-icon-dark.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-logo-horizontal-dark.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-logo-stacked-dark.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-logo-stacked-simple-dark.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-icon-navy.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-icon-white.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-navy.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-navy.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-navy.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-white.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-white.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-icon.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-logo-horizontal.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-logo-stacked-simple.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-logo-stacked.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-tagline.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-wordmark.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/preview/portlearn-brand-preview.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/source/PortLearn_approved_concept.png +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-icon-monochrome.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-icon-white.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-icon.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-logo-horizontal.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-logo-stacked-simple.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-logo-stacked.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-tagline.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-wordmark.svg +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/examples/foundation_contract_wiring.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/examples/information_set_smoke.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/scripts/verify_built_wheel.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/_records.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/adapters/__init__.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/__init__.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_correlation.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_coverage.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_describe.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_missingness.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_renderer.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/manifest.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/py.typed +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/MANIFEST.md +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_factors_daily_csv.zip +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_factors_monthly_csv.zip +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_factors_monthly_txt.zip +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_industry49_monthly_csv.zip +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/MANIFEST.md +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/meta_synthcpim.json +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/meta_synthdffd.json +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/meta_synthgdpq.json +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_synthcpim_monthly.json +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_synthdffd_daily.json +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_synthgdpq_quarterly.json +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_unknown_series.json +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_ff_decoder.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_ff_unqualified.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_fred_decoder.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/_synthetic.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_correlation_deletion_semantics.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_coverage_support_accounting.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_data_diagnostics_surface.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_descriptive_summaries.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_missingness_expected_grid.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_plot_renderer_boundary.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_alignment_contracts.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_aware_validator_dedup.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_calendar_contracts.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_data_facade.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_data_namespace_cleanup.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_fold_semantics.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_forecaster_lifecycle.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_information_contracts.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_information_set_smoke_replay.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_ingestion_contracts.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_invariant_battery.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_public_release_mechanism.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_public_release_surface.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_timing_contracts.py +0 -0
- {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_transforms.py +0 -0
|
@@ -8,6 +8,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.0.1.dev2]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Portfolio weights: the `portlearn.weights` module with the closed portfolio-role vocabulary, target-weight validation, and immutable weight books.
|
|
16
|
+
- Rebalancing: schedule policies and the drift law carrying held weights across holding segments (`portlearn.rebalance`).
|
|
17
|
+
- Transaction and turnover accounting with proportional transaction-cost models (`portlearn.trades`, `portlearn.turnover`, `portlearn.costs`).
|
|
18
|
+
- The transaction ledger: segment-composed accounting over the wealth path with the reference accounting engine (`portlearn.ledger`).
|
|
19
|
+
- The strategy decision-contract seam — `Strategy.decide(context) -> DecisionResult` over `DecisionContext`, validated by `require_decision_result_compatible`.
|
|
20
|
+
- Lazy module facades for the portfolio modules under the top-level package namespace.
|
|
21
|
+
- Public test modules covering the portfolio-weight, rebalance, transaction, cost, ledger, and decision-contract surfaces.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
### Deprecated
|
|
28
|
+
|
|
29
|
+
### Removed
|
|
30
|
+
|
|
11
31
|
## [0.0.1.dev1]
|
|
12
32
|
|
|
13
33
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: portlearn
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.dev2
|
|
4
4
|
Summary: Finance-first research framework for controlled, reproducible, and modular experimentation in machine-learned portfolio choice.
|
|
5
5
|
Project-URL: Repository, https://github.com/fmasoudy/PortLearn
|
|
6
6
|
Project-URL: Issues, https://github.com/fmasoudy/PortLearn/issues
|
|
@@ -16,8 +16,8 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
18
|
<picture>
|
|
19
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png">
|
|
20
|
-
<img src="docs/assets/brand/png/primary/portlearn-logo-horizontal.png" alt="PortLearn logo: a rounded navy-and-teal PL monogram with a segmented circular motif, beside the PortLearn wordmark" width="460">
|
|
19
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/fmasoudy/PortLearn/main/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png">
|
|
20
|
+
<img src="https://raw.githubusercontent.com/fmasoudy/PortLearn/main/docs/assets/brand/png/primary/portlearn-logo-horizontal.png" alt="PortLearn logo: a rounded navy-and-teal PL monogram with a segmented circular motif, beside the PortLearn wordmark" width="460">
|
|
21
21
|
</picture>
|
|
22
22
|
</p>
|
|
23
23
|
|
|
@@ -63,6 +63,11 @@ PortLearn is under active research development. This roadmap is intentionally hi
|
|
|
63
63
|
- Chronologically valid feature transforms: lags, rolling statistics, scalers, and carry-forward.
|
|
64
64
|
- Contracts for the forecasting and estimation lifecycle (fitting, refitting, forecast timing, tuning, seeds, determinism, provenance); estimators are not provided yet.
|
|
65
65
|
- Descriptive research-dataset diagnostics (summary, correlation, coverage, missingness) with renderer-neutral plotting; rendering is available through the optional `plot` extra.
|
|
66
|
+
- Portfolio weights: target-weight validation, weight books, and the closed portfolio-role vocabulary, under the `portlearn.weights` module.
|
|
67
|
+
- Rebalancing: schedule policies and the drift law that carries held weights across holding segments (`portlearn.rebalance`).
|
|
68
|
+
- Transaction ledger: segment-composed accounting over the wealth path, built on immutable per-period ledger records with retained execution details, and the reference accounting engine (`portlearn.ledger`).
|
|
69
|
+
- Trading and cost accounting: cost-aware transaction and turnover accounting with proportional cost models (`portlearn.trades`, `portlearn.turnover`, `portlearn.costs`).
|
|
70
|
+
- The strategy decision-contract seam: `Strategy.decide(context) -> DecisionResult` over `DecisionContext` — the decision-time aggregate of forecast, information, holdings, and strategy state — validated by `require_decision_result_compatible`.
|
|
66
71
|
|
|
67
72
|
**Next**
|
|
68
73
|
|
|
@@ -71,7 +76,6 @@ PortLearn is under active research development. This roadmap is intentionally hi
|
|
|
71
76
|
|
|
72
77
|
**Planned**
|
|
73
78
|
|
|
74
|
-
- Portfolio construction and accounting.
|
|
75
79
|
- Later deep-learning and reinforcement-learning research capabilities.
|
|
76
80
|
|
|
77
81
|
Entries move forward on this roadmap as the underlying research foundation stabilizes; nothing here is a dated commitment.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<picture>
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png">
|
|
4
|
-
<img src="docs/assets/brand/png/primary/portlearn-logo-horizontal.png" alt="PortLearn logo: a rounded navy-and-teal PL monogram with a segmented circular motif, beside the PortLearn wordmark" width="460">
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/fmasoudy/PortLearn/main/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/fmasoudy/PortLearn/main/docs/assets/brand/png/primary/portlearn-logo-horizontal.png" alt="PortLearn logo: a rounded navy-and-teal PL monogram with a segmented circular motif, beside the PortLearn wordmark" width="460">
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
@@ -47,6 +47,11 @@ PortLearn is under active research development. This roadmap is intentionally hi
|
|
|
47
47
|
- Chronologically valid feature transforms: lags, rolling statistics, scalers, and carry-forward.
|
|
48
48
|
- Contracts for the forecasting and estimation lifecycle (fitting, refitting, forecast timing, tuning, seeds, determinism, provenance); estimators are not provided yet.
|
|
49
49
|
- Descriptive research-dataset diagnostics (summary, correlation, coverage, missingness) with renderer-neutral plotting; rendering is available through the optional `plot` extra.
|
|
50
|
+
- Portfolio weights: target-weight validation, weight books, and the closed portfolio-role vocabulary, under the `portlearn.weights` module.
|
|
51
|
+
- Rebalancing: schedule policies and the drift law that carries held weights across holding segments (`portlearn.rebalance`).
|
|
52
|
+
- Transaction ledger: segment-composed accounting over the wealth path, built on immutable per-period ledger records with retained execution details, and the reference accounting engine (`portlearn.ledger`).
|
|
53
|
+
- Trading and cost accounting: cost-aware transaction and turnover accounting with proportional cost models (`portlearn.trades`, `portlearn.turnover`, `portlearn.costs`).
|
|
54
|
+
- The strategy decision-contract seam: `Strategy.decide(context) -> DecisionResult` over `DecisionContext` — the decision-time aggregate of forecast, information, holdings, and strategy state — validated by `require_decision_result_compatible`.
|
|
50
55
|
|
|
51
56
|
**Next**
|
|
52
57
|
|
|
@@ -55,7 +60,6 @@ PortLearn is under active research development. This roadmap is intentionally hi
|
|
|
55
60
|
|
|
56
61
|
**Planned**
|
|
57
62
|
|
|
58
|
-
- Portfolio construction and accounting.
|
|
59
63
|
- Later deep-learning and reinforcement-learning research capabilities.
|
|
60
64
|
|
|
61
65
|
Entries move forward on this roadmap as the underlying research foundation stabilizes; nothing here is a dated commitment.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# The distribution name and the PEP 440 version declared here are the single
|
|
5
5
|
# version authority for the package.
|
|
6
6
|
name = "portlearn"
|
|
7
|
-
version = "0.0.1.
|
|
7
|
+
version = "0.0.1.dev2"
|
|
8
8
|
|
|
9
9
|
# Minimum supported Python; no untested upper-version exclusion.
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -18,18 +18,44 @@ from typing import Any
|
|
|
18
18
|
|
|
19
19
|
__version__ = metadata.version("portlearn")
|
|
20
20
|
|
|
21
|
-
__all__ = ["__version__", "data"]
|
|
21
|
+
__all__ = ["__version__", "data", "weights"]
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def __getattr__(name: str) -> Any:
|
|
25
|
-
"""Lazily import the
|
|
25
|
+
"""Lazily import the public module facades (PEP 562)."""
|
|
26
26
|
if name == "data":
|
|
27
27
|
from importlib import import_module
|
|
28
28
|
|
|
29
29
|
return import_module("portlearn.data")
|
|
30
|
+
if name == "weights":
|
|
31
|
+
from importlib import import_module
|
|
32
|
+
|
|
33
|
+
return import_module("portlearn.weights")
|
|
34
|
+
if name == "rebalance":
|
|
35
|
+
from importlib import import_module
|
|
36
|
+
|
|
37
|
+
return import_module("portlearn.rebalance")
|
|
38
|
+
if name == "trades":
|
|
39
|
+
from importlib import import_module
|
|
40
|
+
|
|
41
|
+
return import_module("portlearn.trades")
|
|
42
|
+
if name == "turnover":
|
|
43
|
+
from importlib import import_module
|
|
44
|
+
|
|
45
|
+
return import_module("portlearn.turnover")
|
|
46
|
+
if name == "costs":
|
|
47
|
+
from importlib import import_module
|
|
48
|
+
|
|
49
|
+
return import_module("portlearn.costs")
|
|
50
|
+
if name == "ledger":
|
|
51
|
+
from importlib import import_module
|
|
52
|
+
|
|
53
|
+
return import_module("portlearn.ledger")
|
|
30
54
|
raise AttributeError(
|
|
31
55
|
f"module {__name__!r} has no attribute {name!r}; the lazily "
|
|
32
|
-
"exposed public subpackage is 'data'
|
|
56
|
+
"exposed public subpackage is 'data'; the public modules are "
|
|
57
|
+
"'weights', 'rebalance', 'trades', 'turnover', 'costs', and "
|
|
58
|
+
"'ledger'."
|
|
33
59
|
)
|
|
34
60
|
|
|
35
61
|
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
"""Availability-aware alignment of mixed-frequency observations.
|
|
2
2
|
|
|
3
3
|
This module implements the alignment laws as a thin composition
|
|
4
|
-
surface over the
|
|
5
|
-
laws: an immutable
|
|
4
|
+
surface over the observation, timing, and period-calendar laws: an immutable
|
|
6
5
|
:class:`ObservationStore` indexed by ``(series_id, observation_time)``
|
|
7
|
-
group, a single visibility query that delegates vintage selection to
|
|
8
|
-
the frozen ``vintage_as_of`` operation, an :func:`align` output of the
|
|
6
|
+
group, a single visibility query that delegates vintage selection to the ``vintage_as_of`` operation, an :func:`align` output of the
|
|
9
7
|
caller's own vintage records, exactly one convenience month-end
|
|
10
8
|
decision-calendar builder composed over the shared period-calendar
|
|
11
|
-
substrate, and one grid-chronology validator reusing the
|
|
12
|
-
chronology and timestamp errors.
|
|
9
|
+
substrate, and one grid-chronology validator reusing the chronology and timestamp errors defined in ``portlearn.timing``.
|
|
13
10
|
|
|
14
11
|
The laws, in summary:
|
|
15
12
|
|
|
16
13
|
- **Alignment is admission, parameterized by availability.** The only
|
|
17
14
|
question a decision instant asks of any series — daily, monthly, or
|
|
18
15
|
quarterly — is *what was knowable at this instant?* Visibility is
|
|
19
|
-
decided by each record's own declared ``available_time`` through the
|
|
20
|
-
frozen vintage operation and the frozen inclusive admission law;
|
|
16
|
+
decided by each record's own declared ``available_time`` through the ``vintage_as_of`` operation and the inclusive admission rule;
|
|
21
17
|
this module never re-implements admission, never selects among
|
|
22
18
|
vintages of one observation, and contains no date-matching or
|
|
23
19
|
calendar-proximity join of any kind.
|
|
@@ -28,8 +24,7 @@ The laws, in summary:
|
|
|
28
24
|
- **The decision calendar is input, not machinery.** Alignment
|
|
29
25
|
consumes one aware instant; decision grids are researcher inputs.
|
|
30
26
|
One convenience builder ships — month-end instants through the
|
|
31
|
-
shared substrate — and nothing else; schedule machinery is
|
|
32
|
-
deferred seam owned elsewhere.
|
|
27
|
+
shared substrate — and nothing else; schedule machinery is not provided here.
|
|
33
28
|
- **No aggregation, no resampling, no implicit publication lag.**
|
|
34
29
|
Alignment aligns; frequency transformation and bounded carry-forward
|
|
35
30
|
belong to the transforms module and are composed by the researcher,
|
|
@@ -38,8 +33,8 @@ The laws, in summary:
|
|
|
38
33
|
assumes none, and cannot be configured with one.
|
|
39
34
|
- **Chronology of the grid.** Calendar-builder output and any
|
|
40
35
|
researcher-supplied grid must be strictly increasing aware instants;
|
|
41
|
-
non-monotone grids reject with
|
|
42
|
-
naive instants reject with
|
|
36
|
+
non-monotone grids reject with ``InvalidChronologyError``, and
|
|
37
|
+
naive instants reject with ``NaiveTimestampError`` at every entry
|
|
43
38
|
point.
|
|
44
39
|
|
|
45
40
|
**RESEARCHER WARNING — align on availability, never on dates.**
|
|
@@ -104,8 +99,7 @@ class GridDeclarationError(ValueError):
|
|
|
104
99
|
Raised for malformed calendar declarations (bad year/month/count
|
|
105
100
|
shapes), malformed request elements, and grids that are not
|
|
106
101
|
non-empty sequences of entries. Chronology violations and naive
|
|
107
|
-
instants are not declaration failures — they reject with
|
|
108
|
-
frozen chronology and timestamp errors respectively.
|
|
102
|
+
instants are not declaration failures — they reject with ``InvalidChronologyError`` and ``NaiveTimestampError`` respectively.
|
|
109
103
|
"""
|
|
110
104
|
|
|
111
105
|
|
|
@@ -121,7 +115,7 @@ class ObservationStore:
|
|
|
121
115
|
"""An immutable point-in-time index over declared observations.
|
|
122
116
|
|
|
123
117
|
Built from :class:`~portlearn.observations.TimedObservation`
|
|
124
|
-
records under the
|
|
118
|
+
records under the record rules: every record's own
|
|
125
119
|
constructor laws apply (aware instants, mandatory availability,
|
|
126
120
|
availability never preceding the observation), and two records
|
|
127
121
|
sharing the full identity triple ``(series_id, observation_time,
|
|
@@ -129,7 +123,7 @@ class ObservationStore:
|
|
|
129
123
|
last-write-wins, no value-equality exception. A revision — the
|
|
130
124
|
same observation with a later ``available_time`` — is a separate
|
|
131
125
|
record and is exactly what the store holds; selecting among
|
|
132
|
-
vintages of one observation is the
|
|
126
|
+
vintages of one observation is the ``vintage_as_of`` operation's job
|
|
133
127
|
at query time, never the store's at build time.
|
|
134
128
|
|
|
135
129
|
The index maps each ``(series_id, observation_time)`` group to its
|
|
@@ -207,7 +201,7 @@ class ObservationStore:
|
|
|
207
201
|
def _visible_group_record(
|
|
208
202
|
self, key: tuple[str, datetime], decision: datetime
|
|
209
203
|
) -> TimedObservation | None:
|
|
210
|
-
"""The
|
|
204
|
+
"""The ``vintage_as_of`` operation's answer for one group."""
|
|
211
205
|
visible = vintage_as_of(self._groups[key], decision)
|
|
212
206
|
if visible is not None:
|
|
213
207
|
return visible
|
|
@@ -234,9 +228,7 @@ class ObservationStore:
|
|
|
234
228
|
|
|
235
229
|
For each requested series identifier, in request order, the
|
|
236
230
|
latest of its observation groups that has a visible vintage at
|
|
237
|
-
``decision_instant`` — selected by issuing the
|
|
238
|
-
``vintage_as_of`` operation per group and admitting by the
|
|
239
|
-
frozen inclusive law (``available_time <= decision_instant``).
|
|
231
|
+
``decision_instant`` — selected by issuing ``vintage_as_of`` per group and admitting by the inclusive rule (``available_time <= decision_instant``).
|
|
240
232
|
A series with no visible vintage at the instant answers
|
|
241
233
|
``None`` in its slot: explicit absence, not an error, not an
|
|
242
234
|
imputation, and never a silently carried stale value. The
|
|
@@ -328,7 +320,7 @@ def align(
|
|
|
328
320
|
"""The admissible vintage records for one decision instant.
|
|
329
321
|
|
|
330
322
|
For each requested ``(series_id, observation_time)`` group, in
|
|
331
|
-
request order, the
|
|
323
|
+
request order, the ``vintage_as_of`` operation's visible vintage at
|
|
332
324
|
``decision_instant`` (the latest ``available_time`` at or before
|
|
333
325
|
the instant — availability exactly at the decision admits). A
|
|
334
326
|
group with no visible vintage contributes no record: explicit
|
|
@@ -337,8 +329,7 @@ def align(
|
|
|
337
329
|
order with absence removed — and holds the store's own record
|
|
338
330
|
objects.
|
|
339
331
|
|
|
340
|
-
The output is the caller's to submit to the
|
|
341
|
-
``InformationSet(items, as_of=decision_instant)`` constructor for
|
|
332
|
+
The output is the caller's to submit to the ``InformationSet(items, as_of=decision_instant)`` constructor for
|
|
342
333
|
fail-closed admission; this function never constructs an
|
|
343
334
|
information set and never bypasses its constructor laws.
|
|
344
335
|
|
|
@@ -387,9 +378,7 @@ def require_increasing_instants(grid: Iterable[Any]) -> None:
|
|
|
387
378
|
builder emits — must be a non-empty sequence of aware instants in
|
|
388
379
|
strictly increasing order: a decision calendar that repeats or
|
|
389
380
|
reverses an instant is malformed. Equal adjacent instants reject
|
|
390
|
-
(strict increase); a non-monotone grid rejects with
|
|
391
|
-
``InvalidChronologyError``; a naive entry rejects with the frozen
|
|
392
|
-
``NaiveTimestampError``; a non-sequence, a string, or an empty
|
|
381
|
+
(strict increase); a non-monotone grid rejects with ``InvalidChronologyError``; a naive entry rejects with ``NaiveTimestampError``; a non-sequence, a string, or an empty
|
|
393
382
|
grid rejects with ``GridDeclarationError``. Comparisons use
|
|
394
383
|
normalized instants, so equal instants expressed in different
|
|
395
384
|
timezones still reject as equal. Returns ``None`` on success.
|
|
@@ -476,14 +465,11 @@ def monthly_decision_calendar(
|
|
|
476
465
|
calendar day, leap February included) in the declared timezone
|
|
477
466
|
``tz``, UTC-normalized at storage. The period-end mapping is the
|
|
478
467
|
substrate's, imported here and never restated; no quarterly,
|
|
479
|
-
weekly, or custom-frequency builder ships, and no schedule
|
|
480
|
-
machinery exists in this module (that seam is named and owned
|
|
481
|
-
elsewhere).
|
|
468
|
+
weekly, or custom-frequency builder ships, and no schedule machinery exists in this module.
|
|
482
469
|
|
|
483
470
|
``tz`` must be an aware timezone object — a naive or invalid zone
|
|
484
|
-
rejects fail-closed with ``NaiveTimestampError`` through the
|
|
485
|
-
|
|
486
|
-
output is validated by the same grid law researchers' grids obey
|
|
471
|
+
rejects fail-closed with ``NaiveTimestampError`` through the substrate's own validation; no default zone is ever assumed. The builder's
|
|
472
|
+
output is validated by the same grid rule researchers' grids obey
|
|
487
473
|
(:func:`require_increasing_instants`) before it is returned, so
|
|
488
474
|
every calendar this module emits is strictly increasing aware
|
|
489
475
|
instants by construction. ``SAME_INSTANT`` availability under this
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Period-calendar substrate: the period-end → aware-instant mapping.
|
|
2
2
|
|
|
3
|
-
This module implements, exactly once, the period-calendar convention
|
|
4
|
-
frozen here: a month-end or
|
|
3
|
+
This module implements, exactly once, the period-calendar convention: a month-end or
|
|
5
4
|
quarter-end calendar day maps to the **last instant of that period in a
|
|
6
5
|
declared timezone** — ``23:59:59.999999`` on the last calendar day of
|
|
7
6
|
the period, expressed in the declared zone, then UTC-normalized at
|
|
@@ -11,10 +10,6 @@ It is a neutral substrate:
|
|
|
11
10
|
|
|
12
11
|
- **stdlib-only** (``calendar``/``datetime`` arithmetic, no providers);
|
|
13
12
|
- **pure** — same inputs, equal instants, no state;
|
|
14
|
-
- **frozen-law-abiding** — every produced instant constructs through
|
|
15
|
-
:func:`portlearn.timing.to_instant`, so a naive or invalid declared
|
|
16
|
-
timezone rejects fail-closed with ``NaiveTimestampError`` exactly as
|
|
17
|
-
every other time input does;
|
|
18
13
|
- **disciplined** — it defines no contract errors, imports from
|
|
19
14
|
``portlearn.timing`` only, and knows nothing about ingestion,
|
|
20
15
|
adapters, alignment, or schedules. Consumers import these helpers;
|
|
@@ -41,13 +36,13 @@ def _period_end_instant(year: int, month: int, tzinfo: Any) -> datetime:
|
|
|
41
36
|
"""The last instant of ``month`` in ``year`` under declared ``tzinfo``.
|
|
42
37
|
|
|
43
38
|
Builds ``23:59:59.999999`` on the month's last calendar day in the
|
|
44
|
-
declared zone and UTC-normalizes it through
|
|
45
|
-
``to_instant
|
|
39
|
+
declared zone and UTC-normalizes it through
|
|
40
|
+
``to_instant``, which also rejects a naive/invalid zone
|
|
46
41
|
fail-closed.
|
|
47
42
|
"""
|
|
48
43
|
last_day = _calendar.monthrange(year, month)[1]
|
|
49
44
|
# A deliberately naive wall-clock reading: the declared zone is attached
|
|
50
|
-
# on the next line and
|
|
45
|
+
# on the next line and to_instant then UTC-normalizes the result.
|
|
51
46
|
wall = datetime( # noqa: DTZ001 — zone attached immediately below
|
|
52
47
|
year, month, last_day, *_LAST_TIME_OF_DAY
|
|
53
48
|
)
|
|
@@ -72,7 +67,7 @@ def quarter_end_instant(year: int, quarter: int, tzinfo: Any) -> datetime:
|
|
|
72
67
|
``quarter`` is 1–4; the quarter's closing month is March, June,
|
|
73
68
|
September, or December. Returns the UTC-normalized instant of
|
|
74
69
|
``23:59:59.999999`` on that month's last calendar day in ``tzinfo``,
|
|
75
|
-
with the same fail-closed timezone
|
|
70
|
+
with the same fail-closed timezone check as
|
|
76
71
|
:func:`month_end_instant`.
|
|
77
72
|
"""
|
|
78
73
|
if not isinstance(quarter, int) or isinstance(quarter, bool):
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Proportional transaction costs, convention-bound and fail-closed.
|
|
2
|
+
|
|
3
|
+
This module defines the proportional cost model: the
|
|
4
|
+
standard linear cost model ``Proportional(rate, turnover=...)`` bound
|
|
5
|
+
to a **named turnover convention**, because a rate statement such as
|
|
6
|
+
"25 bps transaction cost" is incomplete unless the library also
|
|
7
|
+
records whether the rate applies to ``one_way`` or ``two_sided``
|
|
8
|
+
turnover. The convention is a first-class binding input — a bare
|
|
9
|
+
scalar turnover without a named convention is an incomplete cost
|
|
10
|
+
specification.
|
|
11
|
+
|
|
12
|
+
The laws, in summary:
|
|
13
|
+
|
|
14
|
+
- **Convention binding.** The keyword is exactly ``turnover`` (never
|
|
15
|
+
alternated publicly with ``turnover_convention``) and accepts only
|
|
16
|
+
PortLearn's two recognized turnover conventions,
|
|
17
|
+
``portlearn.turnover.one_way`` and
|
|
18
|
+
``portlearn.turnover.two_sided``; arbitrary lambdas or unknown
|
|
19
|
+
callables reject fail-closed. Internally the model preserves a
|
|
20
|
+
stable canonical identity — ``"one_way"`` / ``"two_sided"`` — so
|
|
21
|
+
future experiment provenance (e.g. YAML ``costs: {model:
|
|
22
|
+
proportional, rate: 0.0025, turnover: one_way}``) can represent the
|
|
23
|
+
convention without redesigning the public API. No registries, no
|
|
24
|
+
plugin machinery: the two conventions of the turnover module are
|
|
25
|
+
the only selectable measures.
|
|
26
|
+
- **Cost fraction and factor.** ``q = rate ×
|
|
27
|
+
selected_turnover_measure(trade)`` and ``F_cost = 1 − q``; the
|
|
28
|
+
domain is ``0 ≤ q < 1``, enforced fail-closed on non-finite,
|
|
29
|
+
negative, or ``q ≥ 1`` inputs (``F_cost = 0`` or negative is not a
|
|
30
|
+
lawful portfolio state). The domain check is the safety net
|
|
31
|
+
especially for leveraged/short books, whose two-sided turnover may
|
|
32
|
+
exceed 2 and push ``q`` to 1 even at moderate rates.
|
|
33
|
+
- **Rate law.** ``rate ≥ 0`` real finite; negative, non-real
|
|
34
|
+
(``bool``/``Decimal``/string/complex), and non-finite rates reject;
|
|
35
|
+
``rate = 0`` is lawful (the frictionless model).
|
|
36
|
+
- **Baseline cost-to-weights separation.** Under this declared
|
|
37
|
+
simplified baseline convention: weights track — ``POST_TRADE =
|
|
38
|
+
TARGET`` exactly, the rebalancing law unaltered; value track
|
|
39
|
+
— multiplied by ``F_cost``. Costs multiply value, never weights.
|
|
40
|
+
Cash financing, execution-level fee funding, partial fills,
|
|
41
|
+
spreads, slippage, market impact, and share-space execution are
|
|
42
|
+
outside this baseline and would alter it. The model is timeless:
|
|
43
|
+
no execution-timestamp binding, accounting-period assignment,
|
|
44
|
+
ledger row placement, or wealth path (downstream execution
|
|
45
|
+
accounting owns those).
|
|
46
|
+
- **Composition and purity.** Over a trade sequence the total cost
|
|
47
|
+
factor is ``F_cost,total = Π_k F_cost,k``, and where a growth basis
|
|
48
|
+
is needed ``G_after_cost = G_before_cost × F_cost,total``; no
|
|
49
|
+
``r_net`` return-record form is defined here. Models are pure with
|
|
50
|
+
respect to the trade: evaluating ``q``/``f_cost`` never mutates or
|
|
51
|
+
consumes it, so one stored trade can be evaluated under many cost
|
|
52
|
+
models (rates 0.0010/0.0025/0.0050, say) without rerunning the
|
|
53
|
+
optimizer.
|
|
54
|
+
|
|
55
|
+
The error surface is ``ValueError`` only (plus ``TypeError`` for a
|
|
56
|
+
missing required constructor argument); numerics follow the
|
|
57
|
+
checked-real discipline.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import math
|
|
63
|
+
from collections.abc import Mapping
|
|
64
|
+
from typing import Any
|
|
65
|
+
|
|
66
|
+
from portlearn import turnover as _turnover
|
|
67
|
+
from portlearn.trades import WeightTrade, from_weights
|
|
68
|
+
from portlearn.weights import PortfolioWeights, WeightState
|
|
69
|
+
|
|
70
|
+
__all__ = ["Proportional"]
|
|
71
|
+
|
|
72
|
+
# The closed convention registry of record: the two named measures of
|
|
73
|
+
# the turnover module, keyed by their canonical identity strings. No
|
|
74
|
+
# plugin machinery — this fixed mapping is the entire selectable set.
|
|
75
|
+
_RECOGNIZED_CONVENTIONS: dict[str, Any] = {
|
|
76
|
+
"one_way": _turnover.one_way,
|
|
77
|
+
"two_sided": _turnover.two_sided,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _require_rate(value: object) -> float:
|
|
82
|
+
"""The rate law: a finite real ≥ 0; ``bool`` is not a number; no
|
|
83
|
+
clipping or normalization — out-of-domain inputs reject."""
|
|
84
|
+
if isinstance(value, bool):
|
|
85
|
+
raise ValueError( # noqa: TRY004 — bool rejection is law, not type discipline
|
|
86
|
+
f"rate must be a real number, not bool; got {value!r}"
|
|
87
|
+
)
|
|
88
|
+
if not isinstance(value, (int, float)):
|
|
89
|
+
raise ValueError( # noqa: TRY004 — ValueError-only surface is the error law
|
|
90
|
+
f"rate must be a real number; got {type(value).__name__}: "
|
|
91
|
+
f"{value!r}. Use float(rate)."
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
converted = float(value)
|
|
95
|
+
except OverflowError:
|
|
96
|
+
converted = math.inf
|
|
97
|
+
if not math.isfinite(converted):
|
|
98
|
+
raise ValueError(f"rate must be finite; got {value!r}")
|
|
99
|
+
if converted < 0.0:
|
|
100
|
+
raise ValueError(f"rate must be nonnegative; got {converted!r}")
|
|
101
|
+
return converted
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _require_recognized_convention(value: object) -> tuple[str, Any]:
|
|
105
|
+
"""Only PortLearn's two named turnover conventions are selectable;
|
|
106
|
+
arbitrary lambdas/callables/scalars reject fail-closed."""
|
|
107
|
+
for identity, measure in _RECOGNIZED_CONVENTIONS.items():
|
|
108
|
+
if value is measure:
|
|
109
|
+
return identity, measure
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"the turnover convention must be one of PortLearn's recognized "
|
|
112
|
+
f"named conventions (portlearn.turnover.one_way or "
|
|
113
|
+
f"portlearn.turnover.two_sided); got {value!r}. A bare scalar or "
|
|
114
|
+
"arbitrary callable is an incomplete cost specification and "
|
|
115
|
+
"fails closed."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _require_weight_mapping(value: object, role: str) -> Mapping[str, float]:
|
|
120
|
+
"""A weight book operand must be a mapping of identifiers to
|
|
121
|
+
weights; anything else rejects fail-closed on the ValueError-only
|
|
122
|
+
surface before book construction is attempted."""
|
|
123
|
+
if not isinstance(value, Mapping):
|
|
124
|
+
raise ValueError( # noqa: TRY004 — ValueError-only surface is the error law
|
|
125
|
+
f"the {role} weights must be a mapping of asset identifiers "
|
|
126
|
+
f"to weights; got {type(value).__name__}: {value!r}"
|
|
127
|
+
)
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Proportional:
|
|
132
|
+
"""The convention-bound proportional cost model.
|
|
133
|
+
|
|
134
|
+
Public shape: constructor ``Proportional(rate,
|
|
135
|
+
turnover)`` — both required, the keyword name exactly ``turnover``;
|
|
136
|
+
public read attributes ``rate: float`` and ``convention: str`` (the
|
|
137
|
+
stable canonical identity ``"one_way"``/``"two_sided"``); methods
|
|
138
|
+
``q(trade) -> float``, ``f_cost(trade) -> float``, and
|
|
139
|
+
``estimate_trade_cost(pre_trade_weights, target_weights) -> float``
|
|
140
|
+
(the public ``CostModel`` protocol surface: it builds the same
|
|
141
|
+
canonical ``WeightTrade`` from the two weight mappings —
|
|
142
|
+
``PRE_TRADE`` and ``TARGET`` books under the same validation —
|
|
143
|
+
and returns ``q`` of it, with no duplicated turnover arithmetic).
|
|
144
|
+
Immutable
|
|
145
|
+
from the instant it exists; pure with respect to every trade it
|
|
146
|
+
evaluates.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
__slots__ = ("_convention", "_measure", "_rate")
|
|
150
|
+
|
|
151
|
+
def __init__(self, rate: object, turnover: object) -> None:
|
|
152
|
+
# Both inputs are first-class binding inputs: neither may be
|
|
153
|
+
# omitted — a bare rate is as incomplete as a missing one.
|
|
154
|
+
# (A missing argument is Python's own TypeError; an explicitly
|
|
155
|
+
# supplied non-convention rejects as a ValueError domain
|
|
156
|
+
# failure below, keeping the domain error surface ValueError-only.)
|
|
157
|
+
checked_rate = _require_rate(rate)
|
|
158
|
+
identity, measure = _require_recognized_convention(turnover)
|
|
159
|
+
object.__setattr__(self, "_rate", checked_rate)
|
|
160
|
+
object.__setattr__(self, "_convention", identity)
|
|
161
|
+
object.__setattr__(self, "_measure", measure)
|
|
162
|
+
|
|
163
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
164
|
+
raise AttributeError(
|
|
165
|
+
f"{type(self).__name__} is an immutable value object; "
|
|
166
|
+
f"attribute {name!r} cannot be assigned"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def __delattr__(self, name: str) -> None:
|
|
170
|
+
raise AttributeError(
|
|
171
|
+
f"{type(self).__name__} is an immutable value object; "
|
|
172
|
+
f"attribute {name!r} cannot be deleted"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def rate(self) -> float:
|
|
177
|
+
"""The nonnegative finite real proportional rate."""
|
|
178
|
+
return self._rate
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def convention(self) -> str:
|
|
182
|
+
"""The stable canonical convention identity string."""
|
|
183
|
+
return self._convention
|
|
184
|
+
|
|
185
|
+
def q(self, trade: WeightTrade) -> float:
|
|
186
|
+
"""The cost fraction ``q = rate × turnover_measure(trade)``.
|
|
187
|
+
|
|
188
|
+
Fail-closed on the domain ``0 ≤ q < 1``: a non-finite,
|
|
189
|
+
negative, or ``q ≥ 1`` result rejects — ``F_cost = 0`` or
|
|
190
|
+
negative is not a lawful portfolio state, and leveraged books
|
|
191
|
+
can reach the boundary even at moderate rates. Pure with
|
|
192
|
+
respect to the trade.
|
|
193
|
+
"""
|
|
194
|
+
measure_q = self._checked_measure(trade)
|
|
195
|
+
fraction = self._rate * measure_q
|
|
196
|
+
if not math.isfinite(fraction):
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"the cost fraction q must be finite; got {fraction!r} "
|
|
199
|
+
f"(rate {self._rate!r} × {self._convention} turnover "
|
|
200
|
+
f"{measure_q!r})"
|
|
201
|
+
)
|
|
202
|
+
if fraction < 0.0:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"the cost fraction q must be nonnegative; got {fraction!r}"
|
|
205
|
+
)
|
|
206
|
+
if fraction >= 1.0:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"the cost fraction q must satisfy q < 1 (F_cost > 0); "
|
|
209
|
+
f"got q = {fraction!r} (rate {self._rate!r} × "
|
|
210
|
+
f"{self._convention} turnover {measure_q!r}) — F_cost = 0 "
|
|
211
|
+
"or negative is not a lawful portfolio state and fails "
|
|
212
|
+
"closed."
|
|
213
|
+
)
|
|
214
|
+
return fraction
|
|
215
|
+
|
|
216
|
+
def f_cost(self, trade: WeightTrade) -> float:
|
|
217
|
+
"""The cost factor ``F_cost = 1 − q`` on the domain ``0 <
|
|
218
|
+
F_cost ≤ 1``; identical fail-closed domain enforcement."""
|
|
219
|
+
return 1.0 - self.q(trade)
|
|
220
|
+
|
|
221
|
+
def estimate_trade_cost(
|
|
222
|
+
self,
|
|
223
|
+
pre_trade_weights: Mapping[str, float],
|
|
224
|
+
target_weights: Mapping[str, float],
|
|
225
|
+
) -> float:
|
|
226
|
+
"""Estimate the cost of trading to the target weight book.
|
|
227
|
+
|
|
228
|
+
The public ``CostModel`` surface over raw weight mappings:
|
|
229
|
+
constructs the canonical ``WeightTrade`` with exactly the
|
|
230
|
+
``from_weights`` semantics (books validated as ``PRE_TRADE``
|
|
231
|
+
and ``TARGET``, deltas over the union of assets) and returns
|
|
232
|
+
``q`` of that trade — the bound convention is applied through
|
|
233
|
+
the canonical path, with no turnover arithmetic duplicated
|
|
234
|
+
here. Fail-closed domain enforcement is therefore identical
|
|
235
|
+
to ``q``'s.
|
|
236
|
+
"""
|
|
237
|
+
trade = from_weights(
|
|
238
|
+
PortfolioWeights(
|
|
239
|
+
_require_weight_mapping(pre_trade_weights, "pre-trade"),
|
|
240
|
+
WeightState.PRE_TRADE,
|
|
241
|
+
),
|
|
242
|
+
PortfolioWeights(
|
|
243
|
+
_require_weight_mapping(target_weights, "target"),
|
|
244
|
+
WeightState.TARGET,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
return self.q(trade)
|
|
248
|
+
|
|
249
|
+
def __repr__(self) -> str:
|
|
250
|
+
return (
|
|
251
|
+
f"{type(self).__name__}(rate={self._rate!r}, "
|
|
252
|
+
f"turnover={self._convention!r})"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _checked_measure(self, trade: WeightTrade) -> float:
|
|
256
|
+
"""The bound convention evaluated on the trade, fail-closed on
|
|
257
|
+
non-finite turnover intermediates (ValueError-only surface)."""
|
|
258
|
+
if not isinstance(trade, WeightTrade):
|
|
259
|
+
raise ValueError( # noqa: TRY004 — ValueError-only surface is the error law
|
|
260
|
+
"the cost model operates on a WeightTrade value object; "
|
|
261
|
+
f"got {type(trade).__name__}: {trade!r}. Construct one "
|
|
262
|
+
"with portlearn.trades.from_weights(pre_trade, target)."
|
|
263
|
+
)
|
|
264
|
+
measured = self._measure(trade)
|
|
265
|
+
if not math.isfinite(measured):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"the {self._convention} turnover must be finite; got "
|
|
268
|
+
f"{measured!r} — a non-finite turnover intermediate is "
|
|
269
|
+
"undefined and fails closed."
|
|
270
|
+
)
|
|
271
|
+
if measured < 0.0:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"the {self._convention} turnover must be nonnegative; "
|
|
274
|
+
f"got {measured!r}"
|
|
275
|
+
)
|
|
276
|
+
return measured
|
|
@@ -21,9 +21,8 @@ def __getattr__(name: str) -> Any:
|
|
|
21
21
|
"""Lazily import one public name, or fail with the module error.
|
|
22
22
|
|
|
23
23
|
``ResearchDataset`` — the provider-neutral sealed two-state
|
|
24
|
-
container — imports lazily like the provider facades,
|
|
25
|
-
|
|
26
|
-
``import portlearn.data`` still registers this package alone (the
|
|
24
|
+
container — imports lazily like the provider facades, preserving the package's import side-effect guarantees: a bare
|
|
25
|
+
``import portlearn.data`` registers this package alone (the
|
|
27
26
|
dataset module, and with it pandas, loads only on first use). The
|
|
28
27
|
state types (``UnqualifiedDataset``/``QualifiedDataset``) stay
|
|
29
28
|
internal to :mod:`portlearn.data.dataset`. The diagnostics
|