Skip to content

Solve every covariance method in correlation units - #16

Merged
thinkjrs merged 6 commits into
developfrom
feature/scale-robust-solves
Oct 27, 2026
Merged

thinkjrs merged 6 commits into
developfrom
feature/scale-robust-solves

Conversation

@thinkjrs

@thinkjrs thinkjrs commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Makes every covariance-based method accurate when asset volatilities are many orders of magnitude apart. The scales4 dataset from #15 showed that max diversification wasn't the only method with this problem.

The problem

Assets whose volatilities are about 1e8 apart make Σ numerically singular in raw units, even when the correlations are well conditioned. On scales4, with volatilities from about 1e-2 to 1e-10, the old code gave:

  • wrong weights from min variance, long-only min variance, mean-variance and long-only mean-variance (off by up to 8e-5);
  • SingularCovariance from risk parity, where a risk parity portfolio exists;
  • RiskFreeRateTooHigh from max Sharpe, and weights 99.9% off from long-only max Sharpe.

The fix: correlation units

With Σ = diag(σ) C diag(σ), every solve now works in u = diag(σ)·w on the correlation matrix C, which has a unit diagonal and doesn't depend on any asset's units.

  • QP solver (src/qp.rs): solve_kkt and solve_nonnegative rescale their variables by sqrt(diag K). This covers min variance, mean-variance and their long-only versions, and long-only max Sharpe and max diversification through long_only_max_ratio. Max diversification no longer needs its own correlation code. Each constraint column is divided by its largest entry first, so the rescaling can't overflow.
  • Max Sharpe: the range and null space come from C, and Σ⁺x = D⁻¹C⁺D⁻¹x.
  • Risk parity: Newton runs on C, where the risk contributions uᵢ(Cu)ᵢ equal wᵢ(Σw)ᵢ, and the result is divided by the volatilities.
  • Null-space cutoffs: they use C's eigenvalues, so whether a covariance counts as singular doesn't depend on units. The Rayleigh-quotient test moved into an in_null_space helper, shared by long_only_max_ratio and risk parity.

Tolerances that depended on units

  • Max Sharpe: a rate within 1e-10 · Σ |wᵢμᵢ| of the min variance return now counts as at it, for the min variance weights w. The old tolerance used the largest |μᵢ|, so with μ = [0.1, 1e-13] a rate below r_mvp was rejected.
  • Long-only max Sharpe: each asset's excess return counts as positive beyond 1e-10 · max(|r_f|, |μᵢ|), its own rounding. The old tolerance used the largest |μᵢ|, so rescaling one asset down could make it look like it had no excess return.

Risk parity on rank-one covariances

Working on C exposed a risk parity bug. When a long-only zero-variance portfolio exists, rounding could stall Newton on that direction, or let it converge on the rounding left in a singular C. Either way it returned weights instead of SingularCovariance. Risk parity now checks its result against C's null space, with the same in_null_space cutoff as max diversification, before returning it.

Behavior change: minimum norm on singular covariances

When Σ is singular, min variance, mean-variance and max Sharpe return the minimum-norm optimum in correlation units instead of in the weights. Which of the tied portfolios is returned then doesn't depend on units, but it can differ from 0.2.4. Two fixtures on rank_deficient6 moved, by up to 0.087. The docs and CHANGELOG say so.

Tests

  • Unit tests:
    • Two uncorrelated assets with volatilities 1e4, 1e8 and 1e12 apart match closed forms for six methods.
      • Max diversification, risk parity, max Sharpe and long-only max Sharpe give inverse volatility, to 1e-12 relative to each weight.
      • Min variance and long-only min variance give inverse variance, to 1e-15 absolute, since their small weight is about s².
    • The singular min variance test expects the minimum norm in correlation units, which an independent NumPy pseudo-inverse agrees with.
  • Properties:
    • Units: rescaling one asset's returns by up to 1e±8 only rescales its weight, for max diversification, risk parity and long-only max Sharpe.
    • Rank-one covariances: risk parity has no answer exactly when max diversification has none. It fails with the null-space check disabled and passes 500k cases with it.
    • Three runs at PROPTEST_CASES=50000 pass.
    • The two failing inputs proptest found are saved in tests/properties.proptest-regressions.
  • Reference fixtures: 196 → 207 cases, and the drift check reports 0 of 207 drifted.
    • The Python references work in correlation units too: linear solves, pseudo-inverses, null spaces, minimum norms and condition numbers come from C.
    • Support enumeration solves each subset through the Schur complement, since lstsq on the whole KKT matrix lost about 1e-9 accuracy on scales4.
    • scales4 now runs min variance, risk parity, long-only min variance, both mean-variance objectives for both methods, max Sharpe and long-only max Sharpe.
    • The old code fails 10 of the new cases.
  • Probe: 3,000 random histories × 8 methods × 2 configs give the same count of each outcome (weights or each error) as before.
  • Suite: all suites, clippy, docs and fmt pass on stable and on MSRV 1.78.

Docs

  • The CHANGELOG has Changed and Fixed sections for this, and a tests entry.
  • reference/README.md describes the correlation-unit references, the Schur complement and scales4.

@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/scale robust solves Solve every covariance method in correlation units Oct 27, 2026
Comment thread src/lib.rs Resolved
Comment thread reference/generate.py Resolved
Comment thread tests/properties.rs Resolved
Comment thread src/qp.rs Resolved
Comment thread src/lib.rs Resolved
Comment thread src/lib.rs Resolved
@thinkjrs
thinkjrs merged commit 0bc0a25 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