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.
Files changed (134) hide show
  1. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/CHANGELOG.md +20 -0
  2. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/PKG-INFO +8 -4
  3. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/README.md +7 -3
  4. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/pyproject.toml +1 -1
  5. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/__init__.py +29 -3
  6. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/alignment.py +18 -32
  7. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/calendar.py +5 -10
  8. portlearn-0.0.1.dev2/src/portlearn/costs.py +276 -0
  9. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/__init__.py +2 -3
  10. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/adapters/ff.py +4 -5
  11. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/adapters/fred.py +9 -12
  12. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/dataset.py +17 -28
  13. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_plot.py +1 -1
  14. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/fama_french.py +1 -1
  15. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/fred.py +2 -3
  16. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/ingestion.py +16 -22
  17. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/forecasting.py +20 -35
  18. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/interfaces.py +256 -22
  19. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/leakage.py +6 -7
  20. portlearn-0.0.1.dev2/src/portlearn/ledger.py +767 -0
  21. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/observations.py +4 -4
  22. portlearn-0.0.1.dev2/src/portlearn/rebalance.py +587 -0
  23. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/timing.py +2 -2
  24. portlearn-0.0.1.dev2/src/portlearn/trades.py +197 -0
  25. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/transforms.py +49 -50
  26. portlearn-0.0.1.dev2/src/portlearn/turnover.py +96 -0
  27. portlearn-0.0.1.dev2/src/portlearn/weights.py +376 -0
  28. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_fred_unqualified.py +3 -2
  29. portlearn-0.0.1.dev2/tests/test_composition_falsification.py +1784 -0
  30. portlearn-0.0.1.dev2/tests/test_costs_contracts.py +443 -0
  31. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_interface_contracts.py +274 -27
  32. portlearn-0.0.1.dev2/tests/test_ledger_contracts.py +755 -0
  33. portlearn-0.0.1.dev2/tests/test_ledger_invariants.py +1390 -0
  34. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_package_contract.py +1 -1
  35. portlearn-0.0.1.dev2/tests/test_rebalance_contracts.py +812 -0
  36. portlearn-0.0.1.dev2/tests/test_trades_contracts.py +338 -0
  37. portlearn-0.0.1.dev2/tests/test_turnover_contracts.py +158 -0
  38. portlearn-0.0.1.dev2/tests/test_weight_contracts.py +768 -0
  39. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_wiring_contracts.py +32 -11
  40. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/uv.lock +1 -1
  41. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/.github/workflows/ci.yml +0 -0
  42. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/.github/workflows/release.yml +0 -0
  43. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/.gitignore +0 -0
  44. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/LICENSE +0 -0
  45. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/MANIFEST.txt +0 -0
  46. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/README.md +0 -0
  47. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/favicon/portlearn-favicon.ico +0 -0
  48. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-1024.png +0 -0
  49. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-128.png +0 -0
  50. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-16.png +0 -0
  51. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-256.png +0 -0
  52. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-32.png +0 -0
  53. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-48.png +0 -0
  54. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-512.png +0 -0
  55. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/icons/portlearn-icon-64.png +0 -0
  56. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-icon-dark.png +0 -0
  57. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-logo-horizontal-dark.png +0 -0
  58. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-logo-stacked-dark.png +0 -0
  59. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/dark/portlearn-logo-stacked-simple-dark.png +0 -0
  60. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-icon-navy.png +0 -0
  61. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-icon-white.png +0 -0
  62. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-navy.png +0 -0
  63. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png +0 -0
  64. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-navy.png +0 -0
  65. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-navy.png +0 -0
  66. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-white.png +0 -0
  67. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-white.png +0 -0
  68. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-icon.png +0 -0
  69. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-logo-horizontal.png +0 -0
  70. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-logo-stacked-simple.png +0 -0
  71. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-logo-stacked.png +0 -0
  72. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-tagline.png +0 -0
  73. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/png/primary/portlearn-wordmark.png +0 -0
  74. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/preview/portlearn-brand-preview.png +0 -0
  75. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/source/PortLearn_approved_concept.png +0 -0
  76. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-icon-monochrome.svg +0 -0
  77. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-icon-white.svg +0 -0
  78. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-icon.svg +0 -0
  79. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-logo-horizontal.svg +0 -0
  80. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-logo-stacked-simple.svg +0 -0
  81. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-logo-stacked.svg +0 -0
  82. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-tagline.svg +0 -0
  83. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/docs/assets/brand/svg/portlearn-wordmark.svg +0 -0
  84. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/examples/foundation_contract_wiring.py +0 -0
  85. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/examples/information_set_smoke.py +0 -0
  86. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/scripts/verify_built_wheel.py +0 -0
  87. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/_records.py +0 -0
  88. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/adapters/__init__.py +0 -0
  89. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/__init__.py +0 -0
  90. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_correlation.py +0 -0
  91. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_coverage.py +0 -0
  92. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_describe.py +0 -0
  93. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_missingness.py +0 -0
  94. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/data/diagnostics/_renderer.py +0 -0
  95. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/manifest.py +0 -0
  96. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/src/portlearn/py.typed +0 -0
  97. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/MANIFEST.md +0 -0
  98. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_factors_daily_csv.zip +0 -0
  99. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_factors_monthly_csv.zip +0 -0
  100. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_factors_monthly_txt.zip +0 -0
  101. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/ff/ff_industry49_monthly_csv.zip +0 -0
  102. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/MANIFEST.md +0 -0
  103. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/meta_synthcpim.json +0 -0
  104. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/meta_synthdffd.json +0 -0
  105. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/meta_synthgdpq.json +0 -0
  106. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_synthcpim_monthly.json +0 -0
  107. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_synthdffd_daily.json +0 -0
  108. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_synthgdpq_quarterly.json +0 -0
  109. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/fixtures/fred/obs_unknown_series.json +0 -0
  110. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_ff_decoder.py +0 -0
  111. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_ff_unqualified.py +0 -0
  112. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/adapters/test_fred_decoder.py +0 -0
  113. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/_synthetic.py +0 -0
  114. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_correlation_deletion_semantics.py +0 -0
  115. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_coverage_support_accounting.py +0 -0
  116. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_data_diagnostics_surface.py +0 -0
  117. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_descriptive_summaries.py +0 -0
  118. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_missingness_expected_grid.py +0 -0
  119. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/data/diagnostics/test_plot_renderer_boundary.py +0 -0
  120. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_alignment_contracts.py +0 -0
  121. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_aware_validator_dedup.py +0 -0
  122. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_calendar_contracts.py +0 -0
  123. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_data_facade.py +0 -0
  124. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_data_namespace_cleanup.py +0 -0
  125. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_fold_semantics.py +0 -0
  126. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_forecaster_lifecycle.py +0 -0
  127. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_information_contracts.py +0 -0
  128. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_information_set_smoke_replay.py +0 -0
  129. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_ingestion_contracts.py +0 -0
  130. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_invariant_battery.py +0 -0
  131. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_public_release_mechanism.py +0 -0
  132. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_public_release_surface.py +0 -0
  133. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev2}/tests/test_timing_contracts.py +0 -0
  134. {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.dev1
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.dev1"
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 ``data`` facade package (PEP 562), or fail."""
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 frozen observation, timing, and period-calendar
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 frozen
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 a named,
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 the frozen chronology error, and
42
- naive instants reject with the frozen timestamp error at every entry
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 the
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 frozen record discipline: every record's own
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 frozen vintage operation's job
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 frozen vintage operation's answer for one group."""
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 frozen
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 frozen vintage operation's visible vintage at
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 frozen
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 the frozen
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
- substrate's own law; no default zone is ever assumed. The builder's
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 the frozen
45
- ``to_instant`` law, which also rejects a naive/invalid zone
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 the frozen law then UTC-normalizes the result.
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 law as
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, so the
25
- package's import side-effect laws hold exactly as before: a bare
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