Skip to content

✨ Add long-only max Sharpe - #14

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

thinkjrs merged 4 commits into
developfrom
feature/long-only-max-sharpe

Conversation

@thinkjrs

@thinkjrs thinkjrs commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Adds long-only max Sharpe, the second of the 0.2.5 methods built on the active-set QP solver from #11. It takes the same MaxSharpeConfig as max_sharpe_allocation.

long_only_max_sharpe_allocation(&MaxSharpeConfig)

The method maximizes (μᵀw − r_f) / √(wᵀΣw) subject to 1ᵀw = 1 and w ≥ 0. The ratio isn't a quadratic objective, but the change of variables y = w / (μ − r_f)ᵀw turns it into one (Cornuejols and Tütüncü, 2007, §8.2):

minimize yᵀΣy  subject to  (μ − r_f)ᵀy = 1,  y ≥ 0,   then  w = y / 1ᵀy
  • Solver call: this is solve_nonnegative with Q = Σ, c = 0 and the single constraint E = μ − r_f.
  • Start: holding only the asset with the highest expected return, scaled so its excess return is 1, is feasible.
  • Mapping back: the long_only_weights helper from ✨ Add long-only mean-variance #13 already rescales the solution to sum to 1, which is exactly the map from y back to w. Its doc now says so.

When Σ is invertible and the tangency portfolio has no short positions, the two methods agree. When Σ is singular, several long-only portfolios can share the maximum, and the docs point to shrinkage, as for the other long-only methods.

When it exists

A long-only portfolio has a positive Sharpe ratio only if some asset's expected return is above the risk-free rate. So the long-only maximum needs r_f below the highest expected return. That's a weaker condition than the unconstrained one, which needs r_f below the minimum variance portfolio's return. For rates between the two, max_sharpe_allocation returns RiskFreeRateTooHigh and this method returns weights.

  • New error, CutupError::NoPositiveExcessReturn { risk_free_rate, highest_return }: returned when no asset's expected return is above the rate. A rate within 1e-10 · (|r_f| + max |μᵢ|) of the highest return counts as at it, the same tolerance max Sharpe uses. RiskFreeRateTooHigh isn't reused, because its min_variance_return field would be the wrong number here. CutupError is #[non_exhaustive], so the new variant isn't breaking.
  • Unbounded: the error when the optimum has zero variance, to within n·ε of Σ's largest entry, the same scale as max Sharpe's pseudo-inverse cutoff. The optimum's excess return is positive by construction, so zero variance means a riskless long-only portfolio beats the rate and the Sharpe ratio has no upper bound. That can only happen when Σ is singular.

Tests

  • Unit tests:
    • It matches the tangency portfolio on uncorrelated assets with positive excess returns.
    • At a rate just below the highest return, max_sharpe_allocation returns RiskFreeRateTooHigh, and this method holds only that asset.
    • At the highest return, 1e-12 past it, and well above it, it returns NoPositiveExcessReturn.
    • It returns Unbounded on the rank-one covariance with a zero-variance long-only portfolio, and weights once shrinkage is added.
    • A non-finite rate and mismatched expected returns are rejected, and the new error's message is checked.
  • Properties:
    • Optimality: the Sharpe ratio doesn't change when w is scaled. So at the optimum its gradient, proportional to (μ − r_f)·σ² − (μ − r_f)ᵀw·Σw, is zero on held assets and no higher on the others.
    • Best long-only: its Sharpe ratio is at least that of every other long-only method: equal weight, inverse volatility and variance, long-only min variance and mean-variance, risk parity, and HRP and HERC with every linkage.
    • Agreement: it matches the tangency portfolio whenever that is long-only.
    • Invariants: it's added to the never-short and sums-to-one checks, with a risk-free rate below every generated return.
    • Runs: three runs at PROPTEST_CASES=50000 pass.
  • Reference fixtures: 156 → 173 cases, and the drift check reports 0 of 173 drifted.
    • The weights come from the same Python support enumeration as ✨ Add long-only mean-variance #13, run on the transformed problem.
    • Unbounded cases are decided independently. scipy.optimize.linprog maximizes the excess return over long-only portfolios with Σw = 0, and a positive maximum means the Sharpe ratio is unbounded.
    • The new cases cover:
      • rates below every return and in the middle of the range, on the full-rank datasets;
      • rate 0.004 on prices10x60, between the minimum variance return and the highest, where only the long-only maximum exists;
      • custom expected returns;
      • the singular datasets with shrinkage, and without it, giving Unbounded on rank_deficient6 and weights on collinear2, whose zero-variance hedge isn't long-only;
      • two NoPositiveExcessReturn cases.
  • Probe: a run on 2,000 random short histories with singular covariances gave weights or Unbounded (1,138 and 818) and no unexpected errors.
  • Suite: all suites, clippy, docs and fmt pass on stable and on MSRV 1.78.

Docs

  • The CHANGELOG has entries for the method, the error and the tests.
  • The README feature list, method table and testing paragraph mention the method.
  • reference/README.md describes the transformed support enumeration and the linear program.
  • The compare example adds a row.

Next

Max diversification follows in its own PR, with the same change of variables and the asset volatilities in place of μ − r_f.

@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 max sharpe ✨ Add long-only max Sharpe 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
highest_return,
});
}
let cov = self.adjusted_covariance(&config.covariance)?;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike max_sharpe_allocation, which builds the covariance before checking the rate, this validates the covariance config after the NoPositiveExcessReturn check. With an invalid config (e.g. shrinkage outside [0, 1]) or a zero-variance asset, and a rate at or above the highest return, the caller gets NoPositiveExcessReturn instead of InvalidConfig or ZeroVariance. So the same inputs report different errors from the two max Sharpe methods. If the early rejection is meant (as in long_only_mean_variance_allocation), say in the # Errors docs which error comes first.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this order on purpose. The rate check doesn't need Σ, so it runs before the covariance is built. That matches long_only_mean_variance_allocation, where review on #13 asked for an unreachable target to be rejected before adjusted_covariance, and it matches the Python reference. max_sharpe_allocation builds Σ first only because its threshold, the minimum variance return, needs Σ.

Comment thread reference/generate.py Resolved
Comment thread src/lib.rs Resolved
Comment thread reference/generate.py Resolved
@thinkjrs
thinkjrs merged commit d7825c7 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