portlearn 0.0.1.dev2__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.
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/.github/workflows/ci.yml +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/.github/workflows/release.yml +6 -6
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/.gitignore +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/CHANGELOG.md +7 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/PKG-INFO +3 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/README.md +1 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/examples/foundation_contract_wiring.py +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/examples/information_set_smoke.py +4 -4
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/pyproject.toml +15 -9
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/scripts/verify_built_wheel.py +10 -10
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/__init__.py +7 -3
- portlearn-0.0.1.dev3/src/portlearn/_allocation.py +206 -0
- portlearn-0.0.1.dev3/src/portlearn/_optimizer_adapters/__init__.py +6 -0
- portlearn-0.0.1.dev3/src/portlearn/_optimizer_adapters/scipy_adapter.py +339 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/alignment.py +5 -5
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/calendar.py +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/costs.py +10 -10
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/_records.py +6 -6
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/adapters/ff.py +15 -15
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/adapters/fred.py +35 -35
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/dataset.py +20 -20
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_correlation.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_coverage.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_describe.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_missingness.py +9 -9
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_plot.py +3 -3
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/fama_french.py +8 -8
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/fred.py +5 -5
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/ingestion.py +5 -5
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/forecasting.py +32 -32
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/interfaces.py +21 -21
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/leakage.py +3 -3
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/ledger.py +7 -7
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/manifest.py +4 -4
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/observations.py +9 -9
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/rebalance.py +18 -18
- portlearn-0.0.1.dev3/src/portlearn/strategies.py +1702 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/timing.py +9 -9
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/trades.py +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/transforms.py +23 -23
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/turnover.py +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/weights.py +11 -11
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/MANIFEST.md +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/MANIFEST.md +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/test_ff_decoder.py +4 -4
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/test_ff_unqualified.py +7 -7
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/test_fred_decoder.py +8 -8
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/test_fred_unqualified.py +3 -3
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/data/diagnostics/_synthetic.py +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_correlation_deletion_semantics.py +8 -8
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_coverage_support_accounting.py +12 -12
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_data_diagnostics_surface.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_missingness_expected_grid.py +7 -7
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/data/diagnostics/test_plot_renderer_boundary.py +6 -6
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_alignment_contracts.py +12 -12
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_aware_validator_dedup.py +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_calendar_contracts.py +3 -3
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_composition_falsification.py +15 -15
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_costs_contracts.py +10 -10
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_data_facade.py +53 -79
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_data_namespace_cleanup.py +3 -3
- portlearn-0.0.1.dev3/tests/test_equal_weight.py +1215 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_fold_semantics.py +2 -2
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_forecaster_lifecycle.py +24 -24
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_information_contracts.py +6 -6
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_information_set_smoke_replay.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_ingestion_contracts.py +3 -3
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_interface_contracts.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_invariant_battery.py +4 -4
- portlearn-0.0.1.dev3/tests/test_inverse_volatility.py +1337 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_ledger_contracts.py +35 -35
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_ledger_invariants.py +20 -20
- portlearn-0.0.1.dev3/tests/test_mean_variance.py +1792 -0
- portlearn-0.0.1.dev3/tests/test_minimum_variance.py +1762 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_package_contract.py +70 -53
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_public_release_mechanism.py +28 -28
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_public_release_surface.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_rebalance_contracts.py +16 -16
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_timing_contracts.py +4 -4
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_trades_contracts.py +3 -3
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_transforms.py +16 -16
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_turnover_contracts.py +1 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_weight_contracts.py +22 -22
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/test_wiring_contracts.py +8 -8
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/uv.lock +159 -1
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/LICENSE +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/MANIFEST.txt +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/README.md +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/favicon/portlearn-favicon.ico +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-1024.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-128.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-16.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-256.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-32.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-48.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-512.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/icons/portlearn-icon-64.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-icon-dark.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-logo-horizontal-dark.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-logo-stacked-dark.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/dark/portlearn-logo-stacked-simple-dark.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-icon-navy.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-icon-white.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-navy.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-horizontal-white.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-navy.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-navy.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-simple-white.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/monochrome/portlearn-logo-stacked-white.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-icon.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-logo-horizontal.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-logo-stacked-simple.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-logo-stacked.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-tagline.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/png/primary/portlearn-wordmark.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/preview/portlearn-brand-preview.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/source/PortLearn_approved_concept.png +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-icon-monochrome.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-icon-white.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-icon.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-logo-horizontal.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-logo-stacked-simple.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-logo-stacked.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-tagline.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/docs/assets/brand/svg/portlearn-wordmark.svg +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/__init__.py +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/adapters/__init__.py +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/__init__.py +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/data/diagnostics/_renderer.py +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/src/portlearn/py.typed +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_factors_daily_csv.zip +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_factors_monthly_csv.zip +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_factors_monthly_txt.zip +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/ff/ff_industry49_monthly_csv.zip +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/meta_synthcpim.json +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/meta_synthdffd.json +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/meta_synthgdpq.json +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_synthcpim_monthly.json +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_synthdffd_daily.json +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_synthgdpq_quarterly.json +0 -0
- {portlearn-0.0.1.dev2 → portlearn-0.0.1.dev3}/tests/adapters/fixtures/fred/obs_unknown_series.json +0 -0
- {portlearn-0.0.1.dev2 → 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,
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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 (
|
|
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:
|
|
407
|
+
- name: Abort on any release-consistency mismatch
|
|
408
408
|
run: |
|
|
409
409
|
set -euo pipefail
|
|
410
410
|
uv sync --locked
|
|
@@ -2,12 +2,17 @@
|
|
|
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
|
+
|
|
11
16
|
## [0.0.1.dev2]
|
|
12
17
|
|
|
13
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: portlearn
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.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
|
|
@@ -68,6 +69,7 @@ PortLearn is under active research development. This roadmap is intentionally hi
|
|
|
68
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`).
|
|
69
70
|
- Trading and cost accounting: cost-aware transaction and turnover accounting with proportional cost models (`portlearn.trades`, `portlearn.turnover`, `portlearn.costs`).
|
|
70
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`).
|
|
71
73
|
|
|
72
74
|
**Next**
|
|
73
75
|
|
|
@@ -52,6 +52,7 @@ PortLearn is under active research development. This roadmap is intentionally hi
|
|
|
52
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
53
|
- Trading and cost accounting: cost-aware transaction and turnover accounting with proportional cost models (`portlearn.trades`, `portlearn.turnover`, `portlearn.costs`).
|
|
54
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`).
|
|
55
56
|
|
|
56
57
|
**Next**
|
|
57
58
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
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
|
|
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
|
|
36
|
-
# and
|
|
37
|
-
# so a development environment can
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This package ships its public identity only: importing this module is
|
|
4
4
|
side-effect-free — it performs no filesystem writes, network access,
|
|
5
|
-
configuration
|
|
5
|
+
configuration changes, logging initialization, data retrieval, or
|
|
6
6
|
application computation — and it eagerly imports no other PortLearn
|
|
7
7
|
module, so the import surface stays minimal and stable.
|
|
8
8
|
|
|
@@ -51,11 +51,15 @@ def __getattr__(name: str) -> Any:
|
|
|
51
51
|
from importlib import import_module
|
|
52
52
|
|
|
53
53
|
return import_module("portlearn.ledger")
|
|
54
|
+
if name == "strategies":
|
|
55
|
+
from importlib import import_module
|
|
56
|
+
|
|
57
|
+
return import_module("portlearn.strategies")
|
|
54
58
|
raise AttributeError(
|
|
55
59
|
f"module {__name__!r} has no attribute {name!r}; the lazily "
|
|
56
60
|
"exposed public subpackage is 'data'; the public modules are "
|
|
57
|
-
"'weights', 'rebalance', 'trades', 'turnover', 'costs',
|
|
58
|
-
"'ledger'."
|
|
61
|
+
"'weights', 'rebalance', 'trades', 'turnover', 'costs', "
|
|
62
|
+
"'ledger', and 'strategies'."
|
|
59
63
|
)
|
|
60
64
|
|
|
61
65
|
|
|
@@ -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
|
+
)
|