Skip to content

Feature/qp long only min variance - #11

Merged
thinkjrs merged 7 commits into
developfrom
feature/qp-long-only-min-variance
Oct 27, 2026
Merged

thinkjrs merged 7 commits into
developfrom
feature/qp-long-only-min-variance

Conversation

@thinkjrs

@thinkjrs thinkjrs commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Adds long-only minimum variance for 0.2.4, solved by a small active-set QP solver written for cutup, so no crate dependency is added. It also fixes an SVD tolerance bug that could make min variance, mean-variance and max Sharpe fail on well-conditioned input.

Long-only min variance

long_only_min_variance_allocation() and long_only_min_variance_allocation_with_config(&CovarianceConfig) minimize wᵀΣw subject to 1ᵀw = 1 and w ≥ 0. When the unconstrained portfolio has no short positions, the two agree. Shrinkage and regularization work as they do for the other methods.

These are new methods rather than a flag on min_variance_allocation_with_config, because that method already takes a CovarianceConfig, and changing its argument would break the API. The 0.2.5 long-only methods can use the same long_only_ prefix.

When Σ is singular, several long-only portfolios can share the minimum variance. Which one is returned depends on the solver's path, and the docs point to shrinkage for a unique answer.

Solver (src/qp.rs)

solve_nonnegative solves min ½xᵀQx − cᵀx subject to Eᵀx = b and x ≥ 0 for a positive semidefinite Q, starting from a feasible point. It is a primal active-set method (Nocedal and Wright, Algorithm 16.3):

  1. Solve. Each iteration solves the equality-constrained problem on the free variables with the existing KKT solve. That solve moved here from lib.rs, and it now also returns the multipliers λ.
  2. Block. If the solution has a negative entry, the step stops where the first variable reaches zero, and that variable joins the active set.
  3. Release. At an equality-constrained optimum, the active variable with the most negative multiplier ν = Qx − c + Eλ is released. When none is negative, the KKT conditions hold and x is optimal.
  4. Singular Q. When the free problem has no minimum, the solver follows a zero-curvature direction that lowers the objective, which is the projection of c onto the null space of [Q; Eᵀ]. If nothing blocks that direction, it returns Unbounded.
  5. Guards.
    • A variable that is released and then blocks at once, without x moving, isn't released again until x moves.
    • An iteration cap returns NotConverged.
    • Entries within rounding of zero are clamped rather than blocking.

Min variance uses only c = 0 with the budget constraint. The general form, with c, several equalities and the unbounded case, is there for 0.2.5's long-only mean-variance, max diversification and max Sharpe, and the solver's own unit tests exercise all of it.

Fix: inaccurate SVD

solve_kkt and max Sharpe called nalgebra's try_svd with a convergence tolerance of exactly f64::EPSILON. On some matrices this returns singular vectors that don't reconstruct the input: on one heavily shrunk covariance the reconstruction was off by 1e-2. The KKT residual check then rejected the solve, and min variance returned SingularCovariance on a covariance close to a multiple of I. A stress run on 200k random KKT matrices found 31 with errors up to 2e-3.

  • The SVD now uses nalgebra's own default tolerance, 5ε. On the same 200k matrices, the worst reconstruction error drops to 1e-10.
  • solve_kkt adds two rounds of iterative refinement against the exact system, which removes errors of that size.

Neither change alone is enough: with an error of 1e-2, refinement doesn't converge. test_min_variance_survives_inaccurate_svd fails on the old code and passes now. Upgrading nalgebra would break the API, because nalgebra types are part of cutup's public API.

Tests

  • Reference fixtures grow from 126 to 138 cases, and all pass. The drift check reports 0 of 138 cases drifted.

    • The reference uses support enumeration: the closed-form minimum variance portfolio on every subset of assets, keeping the lowest-variance one with no negative weights. Every candidate is feasible and the optimum is one of them, so it needs no multipliers and no iteration.
    • Several cases have weights the constraint pins at zero, for example on two_blocks5, prices10x60 and near_singular4.
    • Singular covariances are used only with shrinkage, since their optimum needn't be unique.
  • Unit tests:

    • qp.rs:
      • an interior optimum matches the KKT solve;
      • the multipliers satisfy stationarity;
      • a negative weight is clamped;
      • active variables are released from a vertex;
      • a linear objective follows zero-curvature rays to the best vertex;
      • an unbounded objective is reported;
      • a singular Q with no descent direction stays feasible.
    • lib.rs:
      • long-only matches unconstrained when the constraint is inactive;
      • long-only drops the hedge on a collinear pair, giving exactly [1, 0];
      • singular covariances;
      • config and zero-variance errors;
      • the SVD regression case.
  • Property tests add two properties:

    • The weights satisfy the KKT certificate, with and without shrinkage: (Σw)ᵢ ≥ wᵀΣw for every asset, with equality where wᵢ > 0.
    • The long-only portfolio has the lowest variance of any long-only method, at least the unconstrained minimum, and equals the unconstrained one when that has no short positions.

    Long-only min variance also joins the existing properties for summing to 1, never shorting, column order and return scale.

    The "weights sum to 1" property now measures its tolerance relative to the largest weight. Two nearly equal expected returns give mean-variance weights of ±3500, and those tripped the old absolute 1e-9.

    At 50,000 cases everything passes except an occasional failure in max_sharpe_ignores_column_order_and_joint_scale: 7e-9 against a 4.8e-9 tolerance on ill-conditioned draws. It was failing before this PR, and the PR doesn't change that test.

  • Toolchains: stable and MSRV 1.78 both pass. Clippy, docs and fmt are clean.

Docs

  • README: method list, features, the "Choosing a method" table, the usage note on short positions, and the testing section.
  • reference/README.md.
  • CHANGELOG, under Unreleased, with Added and Fixed sections.
  • examples/compare.rs now prints long-only min variance.

@thinkjrs thinkjrs self-assigned this Oct 26, 2026
@thinkjrs thinkjrs added the enhancement New feature or request label Oct 26, 2026
@thinkjrs

thinkjrs commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner Author

Code review

I also ran a random probe: 3,000 singular cases (fewer rows than assets) plus 600 cases with up to 62 assets. Every solve returned without error and met the KKT conditions. So most of the points below are edge cases or cleanup, not observed failures.

  1. src/qp.rs:190: refused variables are skipped by the final optimality check. entering filters out refused[i], so when no other variable has a negative multiplier, solve_nonnegative returns Ok(x) even if a refused variable still has ν < 0. That happens when a variable is released, then blocks at step 0 (possible when Σ is singular and the min-norm target is negative for it), and x never moves again. The result is a suboptimal portfolio reported as the optimum. Consider re-checking refused variables before returning, or using a proper anti-cycling rule (Bland's rule).
  2. src/qp.rs:181: refused is cleared after any full step, even one that didn't move x. If the target equals the current x (a degenerate vertex), the refused set is reset with no progress. The same release-and-block pair can then repeat until the 100 + 20n cap, giving NotConverged. Clear it only when x actually changed, as block does with step > 0.
  3. src/qp.rs:143 / src/qp.rs:261: a numerical KKT rejection turns into SingularCovariance for long-only min variance. With c = 0, the problem is always bounded. So solve_kkt returns None only when its residual check fails numerically (the same failure this PR fixes for the unconstrained case). The code then calls descent_ray, which projects c = 0 and always fails the direction.norm() == 0 check. The result is SingularCovariance on a well-posed problem. Treat None with c = 0 as a numerical failure, or retry with a looser check, instead of following a ray.
  4. src/qp.rs:228: ratio_test has no tolerance. d[k] < 0.0 counts rounding noise (for example -1e-17) as blocking. On a truly unbounded ray this returns a huge finite step (x around 1e15) instead of Unbounded. The full-step path already uses a relative tolerance (target[k] < -tolerance). Apply the same one here.
  5. src/qp.rs:83: the zero-trace guard was removed from the shared KKT solve. The old solve_kkt returned SingularCovariance when trace(K) <= 0. k_scale now maps 0 to 1, so min_variance_allocation_with_config with shrinkage on an all-zero covariance now returns equal weights instead of an error. That change in behavior is neither tested nor in the CHANGELOG. Also, when the free set is empty, trace / 0 is NaN, which surfaces as an unhelpful SingularCovariance.
  6. src/lib.rs:1189 / CHANGELOG.md:20: max Sharpe gets only the tolerance change, not the fix. The CHANGELOG says max Sharpe is fixed. But its SVD has no iterative refinement, and the new comment on SVD_EPSILON admits that some matrices still reconstruct only to about 1e-10. Since Σ and the KKT blocks are symmetric, symmetric_eigen (or a Cholesky/LDLᵀ solve) would fix the root cause for every caller, instead of tuning the SVD tolerance and adding refinement in one place.
  7. src/qp.rs:136: each active-set iteration runs a fresh full SVD. Every iteration rebuilds q_free and runs an O(k³) SVD on the (k+1)×(k+1) KKT matrix, for up to 100 + 20n iterations. I measured about 1.3 s per solve at 300 assets with 400 rows, against one SVD for unconstrained min variance. A Cholesky of the free block, updated as variables join or leave, would be far cheaper.
  8. tests/properties.rs:197: the looser sum tolerance applies to every method. 1e-9 * size was added for long-short mean-variance weights, but it now applies to all methods. A long-short method whose largest weight is 1e3 can drift from a sum of 1 by up to 1e-6 without failing. Scope the relaxation to the mean-variance and max-Sharpe entries.

@thinkjrs

Copy link
Copy Markdown
Owner Author

Triage of the review above:

Fixing in this PR (1–5)

  1. Refused variables skipped by the optimality check. Agreed. When no other variable can enter, the solver now returns NotConverged if a refused variable still has a negative multiplier, instead of returning that x as optimal.
  2. refused cleared without progress. Agreed. It is now cleared only when x actually moves.
  3. c = 0 routed through descent_ray. Agreed. The result was already SingularCovariance, but now the reason is explicit: with no linear term the free problem is bounded below, so a rejected KKT solve is numerical and returns SingularCovariance without searching for a ray.
  4. No tolerance in ratio_test. Agreed. Components of the direction within rounding of zero no longer block, using the same kind of relative tolerance as the full-step path.
  5. Zero-trace guard. Agreed for the shared KKT solve. Min variance and mean-variance get SingularCovariance for a zero covariance again, as they did before. Only the active-set solver maps a zero free block to scale 1, which it needs for linear objectives. An empty free set can't happen while an equality constraint has b ≠ 0, as the budget constraint does.

Not in this PR

  1. Max Sharpe gets only the tolerance change. Fair: it gets the tolerance change but not the refinement, so the CHANGELOG overstates the fix for max Sharpe. The suggested root-cause fix doesn't hold up, though. symmetric_eigen was far worse in a stress run (5,033 bad reconstructions in 200k random KKT matrices, against 23 for the SVD at 5ε), and Cholesky doesn't apply because the KKT matrix is indefinite. The right fix is to add the same refinement to max Sharpe's pseudo-inverse solves, in a follow-up.
  2. A full SVD every iteration. Agreed that it's slow, about 1.3 s for one solve at n = 300. It's acceptable for 0.2.4. Updating a factorization between iterations is a later optimization.
  3. Looser sum tolerance for every method. Fair, but minor. It could be limited to the long-short methods in a follow-up.

@thinkjrs
thinkjrs merged commit 8e443aa 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