portlearn 0.0.1.dev1__tar.gz → 0.0.1.dev3__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 (143) hide show
  1. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/.github/workflows/ci.yml +2 -2
  2. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/.github/workflows/release.yml +6 -6
  3. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/.gitignore +1 -1
  4. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/CHANGELOG.md +27 -2
  5. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/PKG-INFO +10 -4
  6. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/README.md +8 -3
  7. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/examples/foundation_contract_wiring.py +2 -2
  8. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/examples/information_set_smoke.py +4 -4
  9. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/pyproject.toml +15 -9
  10. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/scripts/verify_built_wheel.py +10 -10
  11. portlearn-0.0.1.dev3/src/portlearn/__init__.py +67 -0
  12. portlearn-0.0.1.dev3/src/portlearn/_allocation.py +206 -0
  13. portlearn-0.0.1.dev3/src/portlearn/_optimizer_adapters/__init__.py +6 -0
  14. portlearn-0.0.1.dev3/src/portlearn/_optimizer_adapters/scipy_adapter.py +339 -0
  15. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/alignment.py +22 -36
  16. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/calendar.py +6 -11
  17. portlearn-0.0.1.dev3/src/portlearn/costs.py +276 -0
  18. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/__init__.py +2 -3
  19. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/_records.py +6 -6
  20. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/adapters/ff.py +18 -19
  21. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/adapters/fred.py +42 -45
  22. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/dataset.py +37 -48
  23. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_correlation.py +1 -1
  24. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_coverage.py +1 -1
  25. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_describe.py +1 -1
  26. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_missingness.py +9 -9
  27. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_plot.py +4 -4
  28. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/fama_french.py +9 -9
  29. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/fred.py +7 -8
  30. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/ingestion.py +21 -27
  31. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/forecasting.py +51 -66
  32. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/interfaces.py +268 -34
  33. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/leakage.py +9 -10
  34. portlearn-0.0.1.dev3/src/portlearn/ledger.py +767 -0
  35. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/manifest.py +4 -4
  36. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/observations.py +13 -13
  37. portlearn-0.0.1.dev3/src/portlearn/rebalance.py +587 -0
  38. portlearn-0.0.1.dev3/src/portlearn/strategies.py +1702 -0
  39. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/timing.py +11 -11
  40. portlearn-0.0.1.dev3/src/portlearn/trades.py +197 -0
  41. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/transforms.py +66 -67
  42. portlearn-0.0.1.dev3/src/portlearn/turnover.py +96 -0
  43. portlearn-0.0.1.dev3/src/portlearn/weights.py +376 -0
  44. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/MANIFEST.md +1 -1
  45. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/MANIFEST.md +2 -2
  46. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/test_ff_decoder.py +4 -4
  47. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/test_ff_unqualified.py +7 -7
  48. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/test_fred_decoder.py +8 -8
  49. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/test_fred_unqualified.py +6 -5
  50. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/data/diagnostics/_synthetic.py +2 -2
  51. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_correlation_deletion_semantics.py +8 -8
  52. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_coverage_support_accounting.py +12 -12
  53. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_data_diagnostics_surface.py +1 -1
  54. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_missingness_expected_grid.py +7 -7
  55. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_plot_renderer_boundary.py +6 -6
  56. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_alignment_contracts.py +12 -12
  57. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_aware_validator_dedup.py +2 -2
  58. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_calendar_contracts.py +3 -3
  59. portlearn-0.0.1.dev3/tests/test_composition_falsification.py +1784 -0
  60. portlearn-0.0.1.dev3/tests/test_costs_contracts.py +443 -0
  61. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_data_facade.py +53 -79
  62. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_data_namespace_cleanup.py +3 -3
  63. portlearn-0.0.1.dev3/tests/test_equal_weight.py +1215 -0
  64. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_fold_semantics.py +2 -2
  65. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_forecaster_lifecycle.py +24 -24
  66. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_information_contracts.py +6 -6
  67. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_information_set_smoke_replay.py +1 -1
  68. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_ingestion_contracts.py +3 -3
  69. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_interface_contracts.py +275 -28
  70. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_invariant_battery.py +4 -4
  71. portlearn-0.0.1.dev3/tests/test_inverse_volatility.py +1337 -0
  72. portlearn-0.0.1.dev3/tests/test_ledger_contracts.py +755 -0
  73. portlearn-0.0.1.dev3/tests/test_ledger_invariants.py +1390 -0
  74. portlearn-0.0.1.dev3/tests/test_mean_variance.py +1792 -0
  75. portlearn-0.0.1.dev3/tests/test_minimum_variance.py +1762 -0
  76. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_package_contract.py +70 -53
  77. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_public_release_mechanism.py +28 -28
  78. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_public_release_surface.py +1 -1
  79. portlearn-0.0.1.dev3/tests/test_rebalance_contracts.py +812 -0
  80. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_timing_contracts.py +4 -4
  81. portlearn-0.0.1.dev3/tests/test_trades_contracts.py +338 -0
  82. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_transforms.py +16 -16
  83. portlearn-0.0.1.dev3/tests/test_turnover_contracts.py +158 -0
  84. portlearn-0.0.1.dev3/tests/test_weight_contracts.py +768 -0
  85. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/test_wiring_contracts.py +40 -19
  86. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/uv.lock +159 -1
  87. portlearn-0.0.1.dev1/src/portlearn/__init__.py +0 -37
  88. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/LICENSE +0 -0
  89. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/MANIFEST.txt +0 -0
  90. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/README.md +0 -0
  91. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/favicon/portlearn-favicon.ico +0 -0
  92. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-1024.png +0 -0
  93. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-128.png +0 -0
  94. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-16.png +0 -0
  95. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-256.png +0 -0
  96. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-32.png +0 -0
  97. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-48.png +0 -0
  98. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-512.png +0 -0
  99. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-64.png +0 -0
  100. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-icon-dark.png +0 -0
  101. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-logo-horizontal-dark.png +0 -0
  102. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-logo-stacked-dark.png +0 -0
  103. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-logo-stacked-simple-dark.png +0 -0
  104. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-icon-navy.png +0 -0
  105. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-icon-white.png +0 -0
  106. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-navy.png +0 -0
  107. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png +0 -0
  108. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-navy.png +0 -0
  109. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-navy.png +0 -0
  110. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-white.png +0 -0
  111. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-white.png +0 -0
  112. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-icon.png +0 -0
  113. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-logo-horizontal.png +0 -0
  114. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-logo-stacked-simple.png +0 -0
  115. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-logo-stacked.png +0 -0
  116. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-tagline.png +0 -0
  117. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-wordmark.png +0 -0
  118. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/preview/portlearn-brand-preview.png +0 -0
  119. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/source/PortLearn_approved_concept.png +0 -0
  120. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-icon-monochrome.svg +0 -0
  121. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-icon-white.svg +0 -0
  122. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-icon.svg +0 -0
  123. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-logo-horizontal.svg +0 -0
  124. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-logo-stacked-simple.svg +0 -0
  125. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-logo-stacked.svg +0 -0
  126. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-tagline.svg +0 -0
  127. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-wordmark.svg +0 -0
  128. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/adapters/__init__.py +0 -0
  129. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/__init__.py +0 -0
  130. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_renderer.py +0 -0
  131. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/src/portlearn/py.typed +0 -0
  132. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_factors_daily_csv.zip +0 -0
  133. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_factors_monthly_csv.zip +0 -0
  134. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_factors_monthly_txt.zip +0 -0
  135. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_industry49_monthly_csv.zip +0 -0
  136. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/meta_synthcpim.json +0 -0
  137. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/meta_synthdffd.json +0 -0
  138. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/meta_synthgdpq.json +0 -0
  139. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_synthcpim_monthly.json +0 -0
  140. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_synthdffd_daily.json +0 -0
  141. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_synthgdpq_quarterly.json +0 -0
  142. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_unknown_series.json +0 -0
  143. {portlearn-0.0.1.dev1 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_descriptive_summaries.py +0 -0
@@ -1,6 +1,6 @@
1
1
  # Public CI for PortLearn.
2
2
  # Runs on every push and pull request, guarded to the public fmasoudy/
3
- # PortLearn repository, least-privilege permissions, frozen
3
+ # PortLearn repository, least-privilege permissions, fixed
4
4
  # per-interpreter sequence, single dependent build-and-verify-wheel job,
5
5
  # immutable action pins.
6
6
  name: ci
@@ -28,7 +28,7 @@ jobs:
28
28
  uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
29
29
  with:
30
30
  version: "0.12.10"
31
- - name: Run the frozen per-interpreter sequence
31
+ - name: Run the fixed per-interpreter sequence
32
32
  run: |
33
33
  uv sync --locked --python ${{ matrix.python-version }}
34
34
  uv run --python ${{ matrix.python-version }} ruff check .
@@ -8,7 +8,7 @@
8
8
  # It validates every consistency rule FIRST and fails closed on any
9
9
  # mismatch, leaving no partial state beyond the failed run log. It never
10
10
  # deletes, moves, or overwrites an existing tag or release. Existence
11
- # queries fail closed the same way: a query whose answer cannot be
11
+ # queries unconditional the same way: a query whose answer cannot be
12
12
  # confirmed (transport error, auth error, unexpected HTTP status)
13
13
  # aborts the run rather than being read as "absent". Runs are also
14
14
  # serialized in a 'public-release' concurrency group (no cancel), so
@@ -77,7 +77,7 @@ jobs:
77
77
  # final release creation, which names its token and
78
78
  # repository explicitly.
79
79
  persist-credentials: false
80
- - name: Fail closed unless the target commit is the current origin main head
80
+ - name: abort unless the target commit is the current origin main head
81
81
  run: |
82
82
  set -euo pipefail
83
83
  git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main"
@@ -88,7 +88,7 @@ jobs:
88
88
  echo "::error::target ${PL_TARGET_SHA} is not the current origin/main head (${main_head}); a stale or off-main commit is never tagged"
89
89
  exit 1
90
90
  fi
91
- - name: Fail closed if the tag already exists locally or on the remote
91
+ - name: Abort if the tag already exists locally or on the remote
92
92
  run: |
93
93
  set -euo pipefail
94
94
  if [ -n "$(git tag --list "$PL_TAG_NAME")" ]; then
@@ -109,7 +109,7 @@ jobs:
109
109
  echo "::error::remote tag ${PL_TAG_NAME} already exists; existing tags are never overwritten"
110
110
  exit 1
111
111
  fi
112
- - name: Fail closed if the GitHub Release already exists
112
+ - name: Abort if the GitHub Release already exists
113
113
  env:
114
114
  GH_TOKEN: ${{ github.token }}
115
115
  GH_REPO: ${{ github.repository }}
@@ -358,7 +358,7 @@ jobs:
358
358
  def main(argv: list[str] | None = None) -> int:
359
359
  """Run validation from command-line arguments; 0 on success."""
360
360
  parser = argparse.ArgumentParser(
361
- description="Validate a public release request (fail-closed)."
361
+ description="Validate a public release request (unconditional)."
362
362
  )
363
363
  parser.add_argument("--version", required=True)
364
364
  parser.add_argument("--tag-name", required=True)
@@ -404,7 +404,7 @@ jobs:
404
404
  run: |
405
405
  set -euo pipefail
406
406
  git tag --list "v*" | sort > "${RUNNER_TEMP}/existing_tags.txt"
407
- - name: Fail closed on any release-consistency mismatch
407
+ - name: Abort on any release-consistency mismatch
408
408
  run: |
409
409
  set -euo pipefail
410
410
  uv sync --locked
@@ -16,5 +16,5 @@ venv/
16
16
  # OS
17
17
  .DS_Store
18
18
 
19
- # Local scratch (never commit run artifacts without an explicit milestone decision)
19
+ # Local scratch (never commit run artifacts without an explicit release decision)
20
20
  scratch/
@@ -2,12 +2,37 @@
2
2
 
3
3
  All notable public changes to PortLearn are documented in this file.
4
4
 
5
- Git history remains the detailed development record. This changelog is the curated user/researcher-facing history: it tracks meaningful public changes and public software releases, with release sections recording releases and the entries within them recording user-visible changes.
6
-
7
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows the project's PEP 440 policy: early-development `0.0.x` public releases.
8
6
 
9
7
  ## [Unreleased]
10
8
 
9
+ ## [0.0.1.dev3]
10
+
11
+ ### Added
12
+
13
+ - Strategies: the `portlearn.strategies` module with the four built-in classical strategies — `EqualWeight`, `InverseVolatility`, `MinimumVariance`, and `MeanVariance` — implementing the strategy decision contract.
14
+ - SciPy ships as a core runtime dependency: the constrained optimization solvers used by `MinimumVariance` and `MeanVariance` are available from a plain `pip install portlearn`. SciPy is lazily imported inside the internal optimizer adapter, never at package import.
15
+
16
+ ## [0.0.1.dev2]
17
+
18
+ ### Added
19
+
20
+ - Portfolio weights: the `portlearn.weights` module with the closed portfolio-role vocabulary, target-weight validation, and immutable weight books.
21
+ - Rebalancing: schedule policies and the drift law carrying held weights across holding segments (`portlearn.rebalance`).
22
+ - Transaction and turnover accounting with proportional transaction-cost models (`portlearn.trades`, `portlearn.turnover`, `portlearn.costs`).
23
+ - The transaction ledger: segment-composed accounting over the wealth path with the reference accounting engine (`portlearn.ledger`).
24
+ - The strategy decision-contract seam — `Strategy.decide(context) -> DecisionResult` over `DecisionContext`, validated by `require_decision_result_compatible`.
25
+ - Lazy module facades for the portfolio modules under the top-level package namespace.
26
+ - Public test modules covering the portfolio-weight, rebalance, transaction, cost, ledger, and decision-contract surfaces.
27
+
28
+ ### Changed
29
+
30
+ ### Fixed
31
+
32
+ ### Deprecated
33
+
34
+ ### Removed
35
+
11
36
  ## [0.0.1.dev1]
12
37
 
13
38
  ### 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.dev3
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
@@ -8,6 +8,7 @@ License-Expression: Apache-2.0
8
8
  License-File: LICENSE
9
9
  Requires-Python: >=3.11
10
10
  Requires-Dist: pandas>=2.2.3
11
+ Requires-Dist: scipy>=1.14
11
12
  Provides-Extra: parquet
12
13
  Requires-Dist: pyarrow<26,>=21.0.0; extra == 'parquet'
13
14
  Provides-Extra: plot
@@ -16,8 +17,8 @@ Description-Content-Type: text/markdown
16
17
 
17
18
  <p align="center">
18
19
  <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">
20
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/fmasoudy/PortLearn/main/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png">
21
+ <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
22
  </picture>
22
23
  </p>
23
24
 
@@ -63,6 +64,12 @@ PortLearn is under active research development. This roadmap is intentionally hi
63
64
  - Chronologically valid feature transforms: lags, rolling statistics, scalers, and carry-forward.
64
65
  - Contracts for the forecasting and estimation lifecycle (fitting, refitting, forecast timing, tuning, seeds, determinism, provenance); estimators are not provided yet.
65
66
  - Descriptive research-dataset diagnostics (summary, correlation, coverage, missingness) with renderer-neutral plotting; rendering is available through the optional `plot` extra.
67
+ - Portfolio weights: target-weight validation, weight books, and the closed portfolio-role vocabulary, under the `portlearn.weights` module.
68
+ - Rebalancing: schedule policies and the drift law that carries held weights across holding segments (`portlearn.rebalance`).
69
+ - 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`).
70
+ - Trading and cost accounting: cost-aware transaction and turnover accounting with proportional cost models (`portlearn.trades`, `portlearn.turnover`, `portlearn.costs`).
71
+ - 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`.
72
+ - Strategies: the built-in classical strategies — equal weight, inverse volatility, minimum variance, and mean-variance (`portlearn.strategies`).
66
73
 
67
74
  **Next**
68
75
 
@@ -71,7 +78,6 @@ PortLearn is under active research development. This roadmap is intentionally hi
71
78
 
72
79
  **Planned**
73
80
 
74
- - Portfolio construction and accounting.
75
81
  - Later deep-learning and reinforcement-learning research capabilities.
76
82
 
77
83
  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,12 @@ 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`.
55
+ - Strategies: the built-in classical strategies — equal weight, inverse volatility, minimum variance, and mean-variance (`portlearn.strategies`).
50
56
 
51
57
  **Next**
52
58
 
@@ -55,7 +61,6 @@ PortLearn is under active research development. This roadmap is intentionally hi
55
61
 
56
62
  **Planned**
57
63
 
58
- - Portfolio construction and accounting.
59
64
  - Later deep-learning and reinforcement-learning research capabilities.
60
65
 
61
66
  Entries move forward on this roadmap as the underlying research foundation stabilizes; nothing here is a dated commitment.
@@ -97,7 +97,7 @@ def main() -> None:
97
97
  print(f"[4] lineage: {derived_feature.series_id} accepted (not available before its input)")
98
98
 
99
99
  # [5] Information-leakage battery: each synthetic leak attempt must be
100
- # blocked by its frozen contract error.
100
+ # blocked by its fixed contract error.
101
101
  report = run_leakage_cases([
102
102
  LeakageCase(
103
103
  name="admit an observation not yet available at the decision",
@@ -129,7 +129,7 @@ def main() -> None:
129
129
  print(f"[6] manifest: {canonical}")
130
130
  print(f" round-trips unchanged: {RunManifest.from_json(canonical) == manifest}")
131
131
  print("\nComposition complete: the foundation contracts compose, and the")
132
- print("synthetic leak attempts were blocked by the frozen contracts.")
132
+ print("synthetic leak attempts were blocked by the fixed contract.")
133
133
 
134
134
 
135
135
  if __name__ == "__main__":
@@ -13,7 +13,7 @@ Offline and side-effect free: importing this module executes nothing;
13
13
  every read is a committed fixture under ``tests/adapters/fixtures``,
14
14
  no network is touched, no file is written, and no clock feeds the data
15
15
  path — every retrieval instant is a fixed fixture-derived constant, so
16
- repeated replays print byte-identical summaries.
16
+ repeated replays print identical summaries.
17
17
  """
18
18
 
19
19
  from __future__ import annotations
@@ -230,7 +230,7 @@ def compose_replay() -> types.SimpleNamespace:
230
230
  metadata_bytes=FRED_DFFD_META.read_bytes(),
231
231
  )
232
232
 
233
- # Daily-derived monthly feature: the frozen rolling transform over
233
+ # Daily-derived monthly feature: the fixed rolling transform over
234
234
  # the daily factor leg, lineage-checked at each output's own window.
235
235
  feature_inputs = sorted(
236
236
  (
@@ -268,7 +268,7 @@ def compose_replay() -> types.SimpleNamespace:
268
268
 
269
269
  # Alignment: one store over every decoded family, one month-end
270
270
  # decision instant from the calendar builder, and the requested
271
- # observation groups admitted through the frozen vintage operation.
271
+ # observation groups admitted through the strict vintage operation.
272
272
  store = ObservationStore(
273
273
  [*ff_monthly, *ff_daily, *feature_outputs, *cpim, *dffd, *ff49]
274
274
  )
@@ -315,7 +315,7 @@ def compose_replay() -> types.SimpleNamespace:
315
315
  )
316
316
 
317
317
  # The leakage battery: future, revision, lineage, and undated-
318
- # decision leak attempts, each expected to be blocked by its frozen
318
+ # decision leak attempts, each expected to be blocked by its fixed
319
319
  # contract error.
320
320
  naive_decision = datetime(2026, 9, 30) # noqa: DTZ001 — deliberately naive
321
321
  early_feature = TimedObservation(
@@ -4,15 +4,17 @@
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.dev3"
8
8
 
9
9
  # Minimum supported Python; no untested upper-version exclusion.
10
10
  requires-python = ">=3.11"
11
11
 
12
- # Runtime dependencies: pandas is the sole unconditional core runtime
13
- # dependency — evidence-supported floor
14
- # (first Python-3.13-compatible release), no upper cap.
15
- dependencies = ["pandas>=2.2.3"]
12
+ # Runtime dependencies: pandas and scipy are the unconditional core
13
+ # runtime dependencies — evidence-supported floors, no upper caps.
14
+ # SciPy is core: it backs the minimum-variance and
15
+ # mean-variance strategies, but is imported lazily inside the private
16
+ # optimizer adapter, never at package import.
17
+ dependencies = ["pandas>=2.2.3", "scipy>=1.14"]
16
18
 
17
19
  # Public packaging metadata (PEP 621): one-line description of present
18
20
  # functionality only; README and licence referenced, never duplicated.
@@ -25,16 +27,20 @@ license = "Apache-2.0"
25
27
  Repository = "https://github.com/fmasoudy/PortLearn"
26
28
  Issues = "https://github.com/fmasoudy/PortLearn/issues"
27
29
 
28
- # The two optional capability groups:
30
+ # The optional capability groups:
29
31
  # pyarrow is pulled only by portlearn[parquet] and matplotlib only by
30
32
  # portlearn[plot]; neither is ever imported by the core package.
33
+ # (The former portlearn[optimization] extra was removed
34
+ # when SciPy became a core dependency.)
31
35
  [project.optional-dependencies]
32
36
  parquet = ["pyarrow>=21.0.0,<26"]
33
37
  plot = ["matplotlib>=3.9.2"]
34
38
 
35
- # Development dependencies: the test harness, the source-quality checker,
36
- # and the plotting stack at exactly the plot-extra requirement,
37
- # so a development environment can exercise the renderer.
39
+ # Development dependencies: the test harness, the source-quality
40
+ # checker, and a dev mirror of the plot extra (matplotlib at exactly
41
+ # the plot-extra requirement) so a development environment can
42
+ # exercise the renderer test path. scipy no longer needs a dev mirror:
43
+ # it is an unconditional core runtime dependency.
38
44
  [dependency-groups]
39
45
  dev = [
40
46
  "pytest>=9.1.1,<10",
@@ -1,4 +1,4 @@
1
- """Fail-closed verification of the PortLearn distribution built by ``uv build``.
1
+ """Strict verification of the PortLearn distribution built by ``uv build``.
2
2
 
3
3
  Executed verbatim after the build::
4
4
 
@@ -11,7 +11,7 @@ ambiguity:
11
11
 
12
12
  1. exactly one wheel and one source distribution produced by ``uv build``
13
13
  exist under ``dist/``;
14
- 2. the wheel contains the complete package surface — the static frozen
14
+ 2. the wheel contains the complete package surface — the static fixed
15
15
  floor of contract modules plus every shippable file currently under
16
16
  ``src/portlearn/`` (``.py`` modules and the ``py.typed`` marker,
17
17
  derived recursively so subpackages are included) — so a wheel missing
@@ -64,13 +64,13 @@ LOCK_PATH = REPOSITORY_ROOT / "uv.lock"
64
64
  DISTRIBUTION_NAME = "portlearn"
65
65
  PACKAGE_SOURCE_ROOT = REPOSITORY_ROOT / "src" / DISTRIBUTION_NAME
66
66
 
67
- #: The frozen contract floor a built wheel must always carry, whatever the
67
+ #: The fixed contract floor a built wheel must always carry, whatever the
68
68
  #: source tree later adds: the seven foundation members plus the public
69
69
  #: diagnostics subpackage marker. A source-tree deletion cannot shrink this
70
- #: floor, so a wheel missing any frozen contract module — or silently
70
+ #: floor, so a wheel missing any fixed contract module — or silently
71
71
  #: omitting the entire diagnostics subpackage — fails verification even
72
72
  #: when the derived surface has moved on. Only the public package marker
73
- #: is frozen: exact private per-block filenames are implementation detail
73
+ #: is fixed: exact private per-block filenames are implementation detail
74
74
  #: and join through the recursive derivation alone. Pinned by
75
75
  #: ``tests/test_package_contract.py::test_required_wheel_members_cover_the_complete_package_surface``.
76
76
  REQUIRED_FLOOR_MEMBERS = (
@@ -91,10 +91,10 @@ def _derived_package_surface() -> tuple[str, ...]:
91
91
  Derivation (not enumeration) keeps this the single source of truth:
92
92
  a module or subpackage added to the source tree joins
93
93
  the required wheel surface automatically, so the check can never again
94
- go stale — a new module colliding with a frozen static list is the exact
94
+ go stale — a new module colliding with a pinned static list is the exact
95
95
  defect class this hybrid cures. Recursion (``rglob``) includes
96
96
  subpackages, so a wheel silently omitting an entire subpackage fails.
97
- Fail-closed: an unreadable or empty source tree is an error, never a
97
+ Strict rejection: an unreadable or empty source tree is an error, never a
98
98
  vacuous pass.
99
99
  """
100
100
  if not PACKAGE_SOURCE_ROOT.is_dir():
@@ -115,9 +115,9 @@ def _derived_package_surface() -> tuple[str, ...]:
115
115
  return tuple(sorted(members))
116
116
 
117
117
 
118
- #: The complete surface a built wheel must carry: the frozen floor UNION the
118
+ #: The complete surface a built wheel must carry: the fixed floor UNION the
119
119
  #: recursively derived source surface — additions auto-join (derivation),
120
- #: deletions of frozen contract modules stay detected (floor). Pinned by
120
+ #: deletions of fixed contract modules stay detected (floor). Pinned by
121
121
  #: ``tests/test_package_contract.py::test_required_wheel_members_cover_the_complete_package_surface``.
122
122
  REQUIRED_WHEEL_MEMBERS = tuple(
123
123
  sorted(set(REQUIRED_FLOOR_MEMBERS) | set(_derived_package_surface()))
@@ -190,7 +190,7 @@ def declared_version() -> str:
190
190
 
191
191
 
192
192
  def locate_distribution_artifacts() -> tuple[Path, Path]:
193
- """Return (wheel, sdist) — exactly one of each, else fail closed."""
193
+ """Return (wheel, sdist) — exactly one of each, else abort."""
194
194
  if not DIST_DIRECTORY.is_dir():
195
195
  fail("dist/ does not exist; run `uv build` first")
196
196
 
@@ -0,0 +1,67 @@
1
+ """PortLearn — research infrastructure for portfolio-learning research.
2
+
3
+ This package ships its public identity only: importing this module is
4
+ side-effect-free — it performs no filesystem writes, network access,
5
+ configuration changes, logging initialization, data retrieval, or
6
+ application computation — and it eagerly imports no other PortLearn
7
+ module, so the import surface stays minimal and stable.
8
+
9
+ ``pyproject.toml`` is the single version authority: ``__version__`` is
10
+ derived from the installed distribution metadata rather than
11
+ hard-coded, so it can never drift from the declared release.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from importlib import metadata
17
+ from typing import Any
18
+
19
+ __version__ = metadata.version("portlearn")
20
+
21
+ __all__ = ["__version__", "data", "weights"]
22
+
23
+
24
+ def __getattr__(name: str) -> Any:
25
+ """Lazily import the public module facades (PEP 562)."""
26
+ if name == "data":
27
+ from importlib import import_module
28
+
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")
54
+ if name == "strategies":
55
+ from importlib import import_module
56
+
57
+ return import_module("portlearn.strategies")
58
+ raise AttributeError(
59
+ f"module {__name__!r} has no attribute {name!r}; the lazily "
60
+ "exposed public subpackage is 'data'; the public modules are "
61
+ "'weights', 'rebalance', 'trades', 'turnover', 'costs', "
62
+ "'ledger', and 'strategies'."
63
+ )
64
+
65
+
66
+ def __dir__() -> list[str]:
67
+ return sorted(__all__)
@@ -0,0 +1,206 @@
1
+ """Private allocation engine: long-only minimum- and mean-variance entry
2
+ points."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import math
7
+ import numbers
8
+ from collections.abc import Sequence
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from portlearn._optimizer_adapters.scipy_adapter import ScipySolveResult
13
+
14
+
15
+ def solve_long_only_min_variance(
16
+ covariance: Sequence[Sequence[float]],
17
+ identifiers: Sequence[str],
18
+ ) -> ScipySolveResult:
19
+ """Validate inputs and delegate the long-only minimum-variance solve.
20
+
21
+ Validation performed here is exact (no numerical tolerance): the
22
+ covariance matrix must be square with shape matching ``identifiers``,
23
+ identifiers must be unique, and the matrix must be exactly symmetric.
24
+ Only after validation passes is the optimizer adapter imported, so
25
+ malformed input never depends on the optional optimization extra.
26
+
27
+ Args:
28
+ covariance: Symmetric covariance matrix with shape ``(N, N)`` matching
29
+ ``identifiers``.
30
+ identifiers: Ordered, unique identifiers, one per row/column of
31
+ ``covariance``.
32
+
33
+ Returns:
34
+ The immutable solve result produced by the optimizer adapter.
35
+
36
+ Raises:
37
+ ValueError: If any validation check fails.
38
+ OptimizationUnavailableError: If SciPy is not installed
39
+ (propagated from the adapter).
40
+ OptimizationFailureError: If the backend fails (propagated from the
41
+ adapter).
42
+ """
43
+ n = len(identifiers)
44
+ if n == 0:
45
+ message = "identifiers must name at least one asset"
46
+ raise ValueError(message)
47
+
48
+ if len(covariance) != n:
49
+ message = (
50
+ f"covariance shape mismatch: expected {n} rows for {n} "
51
+ f"identifiers, got {len(covariance)} rows"
52
+ )
53
+ raise ValueError(message)
54
+
55
+ for index, row in enumerate(covariance):
56
+ if len(row) != n:
57
+ message = f"covariance row {index} has length {len(row)}; expected {n}"
58
+ raise ValueError(message)
59
+
60
+ seen: set[str] = set()
61
+ duplicates: set[str] = set()
62
+ for name in identifiers:
63
+ if name in seen:
64
+ duplicates.add(name)
65
+ else:
66
+ seen.add(name)
67
+ if duplicates:
68
+ message = f"identifiers must be unique; duplicates: {sorted(duplicates)}"
69
+ raise ValueError(message)
70
+
71
+ for i in range(n):
72
+ for j in range(i + 1, n):
73
+ if covariance[i][j] != covariance[j][i]:
74
+ message = (
75
+ "covariance must be exactly symmetric: "
76
+ f"covariance[{i}][{j}] == {covariance[i][j]!r} but "
77
+ f"covariance[{j}][{i}] == {covariance[j][i]!r}"
78
+ )
79
+ raise ValueError(message)
80
+
81
+ # Late lookup: the adapter (and its SciPy dependency) is only
82
+ # imported after every input validation check has passed.
83
+ from portlearn._optimizer_adapters import scipy_adapter
84
+
85
+ return scipy_adapter.solve(covariance, identifiers)
86
+
87
+
88
+ def solve_long_only_mean_variance(
89
+ covariance: Sequence[Sequence[float]],
90
+ expected_returns: Sequence[float],
91
+ risk_aversion: float,
92
+ identifiers: Sequence[str],
93
+ ) -> ScipySolveResult:
94
+ """Validate inputs and delegate the long-only mean-variance solve.
95
+
96
+ Validation performed here is exact (no numerical tolerance): the
97
+ covariance matrix must be square with shape matching
98
+ ``identifiers``, identifiers must be unique, the matrix must be
99
+ exactly symmetric, ``expected_returns`` must be a length-``N``
100
+ sequence of real finite numbers, and ``risk_aversion`` must be a
101
+ finite strictly-positive real number. Only after validation passes
102
+ is the optimizer adapter imported, so malformed input never
103
+ depends on the optional optimization extra.
104
+
105
+ Args:
106
+ covariance: Symmetric covariance matrix with shape ``(N, N)``
107
+ matching ``identifiers``.
108
+ expected_returns: Expected-return vector of length ``N`` in
109
+ ``identifiers`` order.
110
+ risk_aversion: Finite strictly-positive risk-aversion scalar.
111
+ identifiers: Ordered, unique identifiers, one per row/column of
112
+ ``covariance``.
113
+
114
+ Returns:
115
+ The immutable solve result produced by the optimizer adapter,
116
+ carried verbatim (no reordering, no wrapping).
117
+
118
+ Raises:
119
+ ValueError: If any validation check fails.
120
+ OptimizationUnavailableError: If SciPy is not installed
121
+ (propagated from the adapter).
122
+ OptimizationFailureError: If the backend fails (propagated from
123
+ the adapter).
124
+ """
125
+ n = len(identifiers)
126
+ if n == 0:
127
+ message = "identifiers must name at least one asset"
128
+ raise ValueError(message)
129
+
130
+ if len(covariance) != n:
131
+ message = (
132
+ f"covariance shape mismatch: expected {n} rows for {n} "
133
+ f"identifiers, got {len(covariance)} rows"
134
+ )
135
+ raise ValueError(message)
136
+
137
+ for index, row in enumerate(covariance):
138
+ if len(row) != n:
139
+ message = f"covariance row {index} has length {len(row)}; expected {n}"
140
+ raise ValueError(message)
141
+
142
+ seen: set[str] = set()
143
+ duplicates: set[str] = set()
144
+ for name in identifiers:
145
+ if name in seen:
146
+ duplicates.add(name)
147
+ else:
148
+ seen.add(name)
149
+ if duplicates:
150
+ message = f"identifiers must be unique; duplicates: {sorted(duplicates)}"
151
+ raise ValueError(message)
152
+
153
+ for i in range(n):
154
+ for j in range(i + 1, n):
155
+ if covariance[i][j] != covariance[j][i]:
156
+ message = (
157
+ "covariance must be exactly symmetric: "
158
+ f"covariance[{i}][{j}] == {covariance[i][j]!r} but "
159
+ f"covariance[{j}][{i}] == {covariance[j][i]!r}"
160
+ )
161
+ raise ValueError(message)
162
+
163
+ if len(expected_returns) != n:
164
+ message = (
165
+ f"expected_returns length mismatch: expected {n} entries for "
166
+ f"{n} identifiers, got {len(expected_returns)} entries"
167
+ )
168
+ raise ValueError(message)
169
+ for index, value in enumerate(expected_returns):
170
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
171
+ message = (
172
+ "expected_returns entries must be real numbers; entry "
173
+ f"{index} is {type(value).__name__}"
174
+ )
175
+ raise ValueError( # noqa: TRY004 — the rejection surface is ValueError-only, mirroring the solve law
176
+ message
177
+ )
178
+ if not math.isfinite(float(value)):
179
+ message = (
180
+ "expected_returns entries must be finite; entry "
181
+ f"{index} ({identifiers[index]}) is {value!r}"
182
+ )
183
+ raise ValueError(message)
184
+
185
+ if isinstance(risk_aversion, bool) or not isinstance(risk_aversion, numbers.Real):
186
+ message = (
187
+ f"risk_aversion must be a real number; got {type(risk_aversion).__name__}"
188
+ )
189
+ raise ValueError( # noqa: TRY004 — the rejection surface is ValueError-only, mirroring the solve law
190
+ message
191
+ )
192
+ risk_aversion_float = float(risk_aversion)
193
+ if not math.isfinite(risk_aversion_float):
194
+ message = f"risk_aversion must be finite; got {risk_aversion!r}"
195
+ raise ValueError(message)
196
+ if risk_aversion_float <= 0.0:
197
+ message = f"risk_aversion must be strictly positive; got {risk_aversion!r}"
198
+ raise ValueError(message)
199
+
200
+ # Late lookup: the adapter (and its SciPy dependency) is only
201
+ # imported after every input validation check has passed.
202
+ from portlearn._optimizer_adapters import scipy_adapter
203
+
204
+ return scipy_adapter.solve_mean_variance(
205
+ covariance, expected_returns, risk_aversion_float, identifiers
206
+ )
@@ -0,0 +1,6 @@
1
+ """Internal optimizer adapter package for :mod:`portlearn`.
2
+
3
+ This package is an internal implementation detail of the allocation engine
4
+ and intentionally exports nothing; adapter modules are addressed directly by
5
+ their private names.
6
+ """