From d5af87f81629351b9bcab3e2d6971e76d23b5ad0 Mon Sep 17 00:00:00 2001 From: Rudy Osuna Date: Tue, 23 Jun 2026 15:43:01 -0700 Subject: [PATCH 1/2] Fix MaximumSharpeRatioPortfolioOptimizer to maximize the Sharpe ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optimizer fixed the portfolio return to the equal-weight return ((µ − r_f)ᵀw = k) and minimized variance, which collapsed it to a minimum-variance optimizer instead of maximizing the Sharpe ratio. Python now maximizes (µ − r_f)ᵀw / √(wᵀΣw) directly with SLSQP, keeping the budget constraint Σw = 1 and the per-weight bounds. C# applies the Charnes-Cooper substitution y = κw, minimizing yᵀΣy subject to (µ − r_f)ᵀy = 1 and recovering w = y / (1ᵀy); the per-weight bounds are written as linear constraints in y (yᵢ − up·(1ᵀy) ≤ 0, yᵢ − lw·(1ᵀy) ≥ 0) so the problem stays a convex QP and the [lower, upper] range is honored. Both languages reach the same optimum, and the unit-test expectations are updated to the corrected weights. Addresses QuantConnect/Lean#9322 --- .../MaximumSharpeRatioPortfolioOptimizer.cs | 81 ++++++++++--------- .../MaximumSharpeRatioPortfolioOptimizer.py | 19 +++-- ...ximumSharpeRatioPortfolioOptimizerTests.cs | 21 ++--- 3 files changed, 65 insertions(+), 56 deletions(-) diff --git a/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.cs b/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.cs index 30ffeed3885f..abad946fdee4 100644 --- a/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.cs +++ b/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.cs @@ -14,6 +14,7 @@ */ using System.Collections.Generic; +using System.Linq; using Accord.Math; using Accord.Math.Optimization; using Accord.Statistics; @@ -44,41 +45,36 @@ public MaximumSharpeRatioPortfolioOptimizer(double lower = -1, double upper = 1, _riskFreeRate = riskFreeRate; } - /// - /// Sum of all weight is one: 1^T w = 1 / Σw = 1 - /// - /// number of variables - /// linear constraint object - protected LinearConstraint GetBudgetConstraint(int size) - { - return new LinearConstraint(size) - { - CombinedAs = Vector.Create(size, 1.0), - ShouldBe = ConstraintType.EqualTo, - Value = 1.0 - }; - } - /// /// Boundary constraints on weights: lw ≤ w ≤ up /// + /// + /// Expressed in the substituted variable y = κw (κ = 1ᵀy > 0), the per-weight bounds + /// become linear: yᵢ − up·(1ᵀy) ≤ 0 and yᵢ − lw·(1ᵀy) ≥ 0. + /// /// number of variables /// enumeration of linear constraint objects protected IEnumerable GetBoundaryConditions(int size) { for (int i = 0; i < size; i++) { - yield return new LinearConstraint(1) + // yᵢ − up·(1ᵀy) ≤ 0 + var upper = Vector.Create(size, -_upper); + upper[i] += 1.0; + yield return new LinearConstraint(size) { - VariablesAtIndices = new int[] { i }, - ShouldBe = ConstraintType.GreaterThanOrEqualTo, - Value = _lower + CombinedAs = upper, + ShouldBe = ConstraintType.LesserThanOrEqualTo, + Value = 0.0 }; - yield return new LinearConstraint(1) + // yᵢ − lw·(1ᵀy) ≥ 0 + var lower = Vector.Create(size, -_lower); + lower[i] += 1.0; + yield return new LinearConstraint(size) { - VariablesAtIndices = new int[] { i }, - ShouldBe = ConstraintType.LesserThanOrEqualTo, - Value = _upper + CombinedAs = lower, + ShouldBe = ConstraintType.GreaterThanOrEqualTo, + Value = 0.0 }; } } @@ -96,36 +92,49 @@ public double[] Optimize(double[,] historicalReturns, double[] expectedReturns = var returns = (expectedReturns ?? historicalReturns.Mean(0)).Subtract(_riskFreeRate); var size = covariance.GetLength(0); - var x0 = Vector.Create(size, 1.0 / size); - var k = returns.Dot(x0); + var equalWeights = Vector.Create(size, 1.0 / size); + + // The Charnes-Cooper substitution needs a portfolio with positive expected excess + // return to exist, otherwise the Sharpe ratio cannot be maximized. + var feasible = _lower >= 0 ? returns.Any(x => x > 0) : returns.Any(x => x != 0); + if (!feasible) + { + return equalWeights; + } + // Charnes-Cooper substitution y = κw (κ = 1ᵀy): maximizing the Sharpe ratio + // (µ − r_f)ᵀw / √(wᵀΣw) becomes minimizing wᵀΣw subject to (µ − r_f)ᵀy = 1, + // recovering the weights afterwards as w = y / (1ᵀy). + // https://quant.stackexchange.com/questions/18521/sharpe-maximization-under-quadratic-constraints var constraints = new List { - // Sharpe Maximization under Quadratic Constraints - // https://quant.stackexchange.com/questions/18521/sharpe-maximization-under-quadratic-constraints - // (µ − r_f)^T w = k + // (µ − r_f)ᵀy = 1 new LinearConstraint(size) { CombinedAs = returns, ShouldBe = ConstraintType.EqualTo, - Value = k + Value = 1.0 } }; - // Σw = 1 - constraints.Add(GetBudgetConstraint(size)); - // lw ≤ w ≤ up constraints.AddRange(GetBoundaryConditions(size)); - // Setup solver + // Setup solver: minimize yᵀΣy var optfunc = new QuadraticObjectiveFunction(covariance, Vector.Create(size, 0.0)); var solver = new GoldfarbIdnani(optfunc, constraints); // Solve problem - var success = solver.Minimize(Vector.Copy(x0)); - var sharpeRatio = returns.Dot(solver.Solution) / solver.Value; - return success ? solver.Solution : x0; + var success = solver.Minimize(Vector.Copy(equalWeights)); + if (!success) + { + return equalWeights; + } + + // Recover the portfolio weights: w = y / (1ᵀy) + var y = solver.Solution; + var sum = y.Sum(); + return sum > 0 ? y.Divide(sum) : equalWeights; } } } \ No newline at end of file diff --git a/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.py b/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.py index 14a7ee8d99d8..0e6246859e4d 100644 --- a/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.py +++ b/Algorithm.Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizer.py @@ -55,24 +55,23 @@ def optimize(self, historical_returns, expected_returns = None, covariance = Non size = covariance.columns.size # K x 1 x0 = np.array(size * [1. / size]) - k = expected_returns.dot(x0) - # Sharpe Maximization under Quadratic Constraints + # SLSQP maximizes the Sharpe ratio (µ − r_f)^T w / √(w^T Σ w) directly, so the fractional + # objective is optimized in place without any substitution. The budget constraint Σw = 1 and + # the per-weight bounds lw ≤ w ≤ up are applied as-is. The previous implementation instead + # fixed (µ − r_f)^T w to the equal-weight return, which collapsed the optimizer to minimum + # variance. The C# implementation uses the Charnes-Cooper QP substitution because its solver + # only handles quadratic objectives. # https://quant.stackexchange.com/questions/18521/sharpe-maximization-under-quadratic-constraints - # (µ − r_f)^T w = k constraints = [ - {'type': 'eq', 'fun': lambda weights: expected_returns.dot(weights) - k}] + # Σw = 1 + {'type': 'eq', 'fun': lambda weights: self.get_budget_constraint(weights)}] - # Σw = 1 - constraints.append( - {'type': 'eq', 'fun': lambda weights: self.get_budget_constraint(weights)}) - - opt = minimize(lambda weights: self.portfolio_variance(weights, covariance), # Objective function + opt = minimize(lambda weights: -expected_returns.dot(weights) / np.sqrt(self.portfolio_variance(weights, covariance)), # Objective function: −Sharpe ratio x0, # Initial guess bounds = self.get_boundary_conditions(size), # Bounds for variables: lw ≤ w ≤ up constraints = constraints, # Constraints definition method='SLSQP') # Optimization method: Sequential Least SQuares Programming - sharpe_ratio = expected_returns.dot(opt['x']) / opt.fun return opt['x'] if opt['success'] else x0 diff --git a/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs b/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs index 5efd3e04f114..1c654fb4b7a7 100644 --- a/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs +++ b/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs @@ -65,14 +65,14 @@ public void SetUp() ExpectedResults = new List { - new double[] { -0.562396, 0.608942, 0.953453 }, - new double[] { 0.686025, -0.269589, 0.583023 }, - new double[] { 0.26394, -0.043374, 0.779434 }, - new double[] { -0.223905, 0.401036, 1, 0.065329, -0.24246 }, - new double[] { 0.5, 0.5 }, new double[] { -0.5, 0.5, 1 }, - new double[] { -0.242647, 1, 0.242647 }, - new double[] { -1, 0.922902, 0.364512, 0.712585 }, + new double[] { 0, 0, 1 }, + new double[] { -0.404692, 0.404692, 1 }, + new double[] { -0.418338, 0.023261, 1, 0.040668, 0.35441 }, + new double[] { 0.5, 0.5 }, + new double[] { -0.670213, 0.670213, 1 }, + new double[] { -1, 1, 1 }, + new double[] { -1, 0.315476, 0.684524, 1 }, }; } @@ -98,7 +98,7 @@ public override void OptimizeWeightings(int testCaseNumber) public void OptimizeWeightingsSpecifyingLowerBoundAndRiskFreeRate(int testCaseNumber) { var testOptimizer = new MaximumSharpeRatioPortfolioOptimizer(lower: 0, riskFreeRate: 0.04); - var expectedResult = new double[] { 0, 0.44898, 0.55102 }; + var expectedResult = new double[] { 0, 0, 1 }; var result = testOptimizer.Optimize(HistoricalReturns[testCaseNumber]); @@ -106,13 +106,14 @@ public void OptimizeWeightingsSpecifyingLowerBoundAndRiskFreeRate(int testCaseNu } [Test] - public void SingleSecurityPortfolioReturnsNaN() + public void SingleSecurityPortfolioReturnsFullWeight() { var testOptimizer = new MaximumSharpeRatioPortfolioOptimizer(); var historicalReturns = new double[,] { { -0.1 } }; var expectedReturns = new double[] { -0.1 }; - var expectedResult = new double[] { double.NaN }; + // With a single security the budget constraint Σw = 1 leaves it fully invested + var expectedResult = new double[] { 1 }; var result = testOptimizer.Optimize(historicalReturns, expectedReturns); From a853cad232cb585630891c0fac49a7600e83843e Mon Sep 17 00:00:00 2001 From: Rudy Osuna Date: Wed, 24 Jun 2026 09:19:45 -0700 Subject: [PATCH 2/2] Add property-based test that the optimizer maximizes the Sharpe ratio --- ...ximumSharpeRatioPortfolioOptimizerTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs b/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs index 1c654fb4b7a7..dceb7272128e 100644 --- a/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs +++ b/Tests/Algorithm/Framework/Portfolio/MaximumSharpeRatioPortfolioOptimizerTests.cs @@ -152,5 +152,73 @@ public void BoundariesAreNotViolated() Assert.LessOrEqual(rounded, upper); }; } + + // Cases with a positive-definite covariance, where the maximum Sharpe ratio + // portfolio is well defined (case 1 has a zero-variance asset and case 4 an + // indefinite covariance, so they are excluded here). + [TestCase(0)] + [TestCase(2)] + [TestCase(5)] + [TestCase(7)] + public void OptimizedWeightsMaximizeSharpeRatio(int testCaseNumber) + { + // Independent of the hardcoded ExpectedResults: the returned portfolio must + // achieve a Sharpe ratio no lower than equal weights or any other feasible + // portfolio drawn from the constraint set. + var testOptimizer = new MaximumSharpeRatioPortfolioOptimizer(); + var expectedReturns = ExpectedReturns[testCaseNumber]; + var covariance = Covariances[testCaseNumber]; + + var result = testOptimizer.Optimize(HistoricalReturns[testCaseNumber], expectedReturns, covariance); + var optimalSharpe = SharpeRatio(result, expectedReturns, covariance); + + var size = result.Length; + var equalWeights = Enumerable.Repeat(1.0 / size, size).ToArray(); + Assert.GreaterOrEqual(optimalSharpe, SharpeRatio(equalWeights, expectedReturns, covariance)); + + var random = new Random(0); + for (var i = 0; i < 10000; i++) + { + var candidate = RandomFeasibleWeights(random, size, lower: -1.0, upper: 1.0); + Assert.GreaterOrEqual(optimalSharpe + 1e-6, SharpeRatio(candidate, expectedReturns, covariance)); + } + } + + private static double SharpeRatio(double[] weights, double[] expectedReturns, double[,] covariance) + { + var size = weights.Length; + var portfolioReturn = 0.0; + var portfolioVariance = 0.0; + for (var i = 0; i < size; i++) + { + portfolioReturn += weights[i] * expectedReturns[i]; + for (var j = 0; j < size; j++) + { + portfolioVariance += weights[i] * covariance[i, j] * weights[j]; + } + } + return portfolioReturn / Math.Sqrt(portfolioVariance); + } + + private static double[] RandomFeasibleWeights(Random random, int size, double lower, double upper) + { + // Draw weights uniformly from the box and keep only those summing to one. + while (true) + { + var weights = new double[size]; + var sum = 0.0; + for (var i = 0; i < size - 1; i++) + { + weights[i] = lower + random.NextDouble() * (upper - lower); + sum += weights[i]; + } + var last = 1.0 - sum; + if (last >= lower && last <= upper) + { + weights[size - 1] = last; + return weights; + } + } + } } }