Skip to content

✨ Add long-only mean-variance - #13

Merged
thinkjrs merged 4 commits into
developfrom
feature/long-only-mean-variance
Oct 27, 2026
Merged

thinkjrs merged 4 commits into
developfrom
feature/long-only-mean-variance

Conversation

@thinkjrs

@thinkjrs thinkjrs commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Adds long-only mean-variance, the first of the 0.2.5 methods built on the active-set QP solver from #11. It takes the same MeanVarianceConfig as mean_variance_allocation, so both the risk aversion and the target return objectives work without shorting.

long_only_mean_variance_allocation(&MeanVarianceConfig)

  • Risk aversion γ: maximize μᵀw − (γ/2)wᵀΣw subject to 1ᵀw = 1 and w ≥ 0. This is solve_nonnegative with Q = γΣ, c = μ and the budget constraint, starting from equal weights. The feasible set is bounded, so there is always an optimum. On a singular covariance where mean_variance_allocation returns Unbounded, this one returns weights.
  • Target return ρ: minimize wᵀΣw subject to 1ᵀw = 1, μᵀw = ρ and w ≥ 0. The solver starts from a mix of the lowest- and highest-return assets that hits the target exactly, and the result is renormalized to sum to 1 after zero-clamping, like long-only min variance.

When the unconstrained portfolio has no short positions, the two methods agree. When Σ is singular, several long-only portfolios can share the optimum, and the docs point to shrinkage for a unique answer, as they do for long-only min variance.

There's no _with_config variant, because MeanVarianceConfig already carries the covariance adjustments, as it does for mean_variance_allocation.

New error: CutupError::TargetReturnOutOfRange

No long-only portfolio can return less than the lowest or more than the highest expected return, so such a target returns TargetReturnOutOfRange { target_return, lowest, highest }. CutupError is #[non_exhaustive], so adding a variant isn't breaking.

When every expected return is equal, only that return can be targeted. Any portfolio reaches it, so the result is the long-only min variance portfolio. Unconstrained mean-variance returns DegenerateExpectedReturns here for a different target. The long-only method returns TargetReturnOutOfRange instead, since the range is a single point.

The risk aversion and target return checks moved into a check_objective helper shared by both mean-variance methods.

Tests

  • Unit tests:
    • It matches unconstrained mean-variance when that has no shorts, for both objectives.
    • With almost no risk aversion, or a target equal to the highest expected return, everything goes to that asset.
    • Targets are hit to within 1e-15.
    • Unreachable targets and equal expected returns are handled.
    • It returns weights on the rank-one covariance where unconstrained mean-variance is Unbounded.
    • Bad config and mismatched expected returns are rejected.
  • Properties:
    • Risk aversion: the utility gradient μ − γΣw is equal on every held asset and no higher on the others.
    • Target return: the marginal variance Σw equals λ₁ + λ₂μᵢ on every held asset and is no lower on the others, with the multipliers fitted on the held assets.
    • It matches unconstrained mean-variance whenever that has no shorts, for both objectives.
    • It's added to the never-short and sums-to-one invariants.
    • Three runs at PROPTEST_CASES=50000 pass.
  • Reference fixtures: 138 → 156 cases, and the drift check reports 0 of 156 drifted.
    • The Python support enumeration is now one general function. On every subset of assets it solves the KKT system with the rest held at zero, and keeps the best feasible candidate with no negative weights. It never checks multipliers' signs or iterates, unlike cutup's solver.
    • Long-only min variance uses the same function now. Its 138 existing cases moved by at most 1.7e-12.
    • The new cases cover:
      • risk aversion on the full-rank datasets, including a low γ = 5 and custom expected returns;
      • targets from the middle to near the top of each dataset's range;
      • the singular datasets with shrinkage;
      • targeting zero when every expected return is zero;
      • two TargetReturnOutOfRange cases.
    • Many of the new cases have weights pinned at zero, so the constraint binds.
  • Suite: all suites, clippy, docs and fmt pass on stable and on MSRV 1.78.

Docs

  • CHANGELOG has a new Unreleased section.
  • The README feature list and method table list the new method.
  • reference/README.md describes the shared support enumeration.
  • The compare example adds a long-only mean-variance row.

@thinkjrs thinkjrs self-assigned this Oct 27, 2026
@thinkjrs thinkjrs added the enhancement New feature or request label Oct 27, 2026
@thinkjrs thinkjrs changed the title Feature/long only mean variance ✨ Add long-only mean-variance Oct 27, 2026
Comment thread src/lib.rs Resolved
Comment thread src/lib.rs Resolved
Comment thread src/lib.rs Resolved
Comment thread src/lib.rs Resolved
Comment thread src/lib.rs Resolved
Comment thread src/lib.rs Resolved
Comment thread tests/properties.rs Resolved
Comment thread src/lib.rs Resolved
@thinkjrs
thinkjrs merged commit 8a4aafb into develop Oct 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant