Oracle3
Open-source trading engine and MCP server for prediction markets. Fee-aware no-arbitrage trading across related contracts, live on Kalshi, Polymarket and Solana or on paper.
critical line algorithm for efficient frontier
Translation pending
FinancePython · MakefileGitHub @cvxgrpOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Loading the full README. The saved excerpt is shown below.
📈 cvxcla - Critical Line Algorithm for Portfolio Optimization
cvxcla is a Python package that implements the Critical Line Algorithm (CLA)
for portfolio optimization.
The CLA efficiently computes the entire efficient frontier for portfolio optimization
problems with linear constraints and bounds on the weights.Read the full README ↗Quick Links: 📖 Documentation • 🐛 Report Bug • 💡 Request Feature
cvxcla is a Python package that implements the Critical Line Algorithm (CLA)
for portfolio optimization.
The CLA efficiently computes the entire efficient frontier for portfolio optimization
problems with linear constraints and bounds on the weights.
The same parametric active-set engine also traces the LASSO regularisation path
(the Lasso class) — including general inequality constraints Gβ ≤ h and the
non-negative LASSO β ≥ 0 — so the Critical Line Algorithm and the LARS/LASSO
homotopy come out as two instances of one path-following engine.
The Critical Line Algorithm was introduced by Harry Markowitz in The Optimization of Quadratic Functions Subject to Linear Constraints and further described in his book Portfolio Selection.
The algorithm is based on the observation that the optimal weights are a piecewise linear function of the return tilt λ. The CLA computes the turning points (corners) where that path bends, and the whole frontier is recovered from them exactly. In the (variance, return) plane the frontier is a chain of parabolic arcs that meet with continuous slope, which is why its corners are invisible on a frontier plot but show up as kinks on a LASSO coefficient path.
I gave the plenary talk at EQD's Singapore conference.
The Markowitz problem is a quadratic program parametrized by a return tilt λ (a weight on expected return, not a return target or a risk aversion):
min ½ wᵀΣw - λ · μᵀw
s.t. Aw = b, Gw ≤ h, lb ≤ w ≤ ub
where Aw = b are linear equalities (the budget, sector/factor neutrality, …) and
Gw ≤ h are linear inequalities (group or sector exposure caps; a ≥ floor is the
negated row). As λ sweeps from ∞ (maximize return) down to 0 (minimize variance), the solution
traces the entire efficient frontier. The key insight is that between consecutive
events, the optimal weights are a linear function of λ:
w(λ) = α + λ · β
This holds because the KKT optimality conditions are linear in λ whenever the active set — which assets sit at their bounds — is fixed. The algorithm exploits this in three steps:
Start at λ = ∞, where the portfolio concentrates on the highest-return asset
within bounds
(init_algo).
Solve the KKT system for the current active set to find α and β by
block elimination, then decrease λ until one of two events occurs
(main loop in cla.py):
General inequality rows Gw ≤ h add the row analogue of these two events: an
inactive row becomes binding when its slack reaches zero, and an active row is
released when its multiplier reaches zero. An active row is carried through the
same block-eliminated solve as an extra equality row, so the structure is
preserved.
Update the active set (exactly one asset or constraint row changes status) and repeat until λ ≤ 0.
Because only one coordinate changes per step and each step requires only a single linear solve, the algorithm traces the full frontier cheaply and exactly — no approximation needed. Ties are broken by a Bland-style lowest-index rule, which keeps the walk deterministic and finite.
The four events are the same ones the LASSO homotopy uses, under different names (Schmelzer and Hastie, 2026, Table 2):
| Event | Critical Line Algorithm | LASSO / LARS |
|---|---|---|
| P₁ | an asset reaches a bound and leaves the free set | a coefficient crosses zero (the "leave" move) |
| P₂ | a group or exposure row Gw ≤ h becomes tight | a linear inequality on β becomes tight |
| D₁ | a blocked asset's reduced cost changes sign and it re-enters | an inactive correlation reaches λ (the "enter" move) |
| D₂ | an active row's multiplier hits zero and it releases | an active inequality's multiplier hits zero |
The only difference at the level of the algorithm is D₁: for a box the threshold is fixed at zero, while for the ℓ₁ penalty it moves with λ.
CLA.problem(mean, cov).long_only().budget().trace() — as a
readable alternative to the explicit constructorAw = b (budget, dollar-neutral, sector/factor neutrality) and general linear
inequality constraints Gw ≤ h (group or sector exposure caps)‖w‖₁ ≤ c, traced exactly (130/30 books,
capped dollar-neutral books)Lasso), with the same
fluent builder, general inequality constraints Gβ ≤ h, per-coefficient bounds
(caps, and signs on any of the coefficients), and equality rows Aβ = bpip install cvxcla
To include plotting support (Plotly and Kaleido):
pip install cvxcla[plot]
To set up a development environment:
Clone the repository:
git clone https://github.com/cvxgrp/cvxcla.git
cd cvxcla
Create a virtual environment and install dependencies:
make install
This will:
Here's a simple example of how to use cvxcla to compute the efficient frontier:
import numpy as np
# Set a seed for reproducibility
np.random.seed(42)
from cvxcla import CLA
# Define your portfolio problem
n = 10 # Number of assets
mean = np.random.randn(n) # Expected returns
cov = np.random.randn(n, n)
covariance = cov @ cov.T # Covariance matrix
lower_bounds = np.zeros(n) # No short selling
upper_bounds = np.ones(n) # No leverage
# Create a CLA instance
cla = CLA(
mean=mean,
covariance=covariance,
lower_bounds=lower_bounds,
upper_bounds=upper_bounds,
a=np.ones((1, n)), # Fully invested constraint
b=np.ones(1),
)
# Access the efficient frontier
frontier = cla.frontier
# Get the maximum Sharpe ratio portfolio
max_sharpe_ratio, max_sharpe_weights = frontier.max_sharpe
print(f"Maximum Sharpe ratio: {max_sharpe_ratio:.6f}")
# Print first few weights to avoid long output
print(f"First 3 weights: {max_sharpe_weights[:3]}")
Maximum Sharpe ratio: 2.946979
First 3 weights: [0. 0. 0.08509841]
The same problem reads more declaratively through the fluent builder, reached via
CLA.problem(...). Each step maps onto one constructor argument, and the terminal
.trace() returns the identical CLA:
# Same fully-invested, long-only problem as above, assembled fluently.
built = CLA.problem(mean, covariance).long_only().budget().trace()
# Pure sugar over the constructor: it returns the identical frontier.
assert built.frontier.max_sharpe[0] == cla.frontier.max_sharpe[0]
.bounds, .equality, and .inequality add general bounds and constraints, as in
the examples below.
For visualization, you can plot the efficient frontier:
# Plot the efficient frontier
fig = frontier.plot(volatility=True)
fig.show()
Pass g and h to add general inequality rows Gw ≤ h on top of the box bounds and
the budget. For example, to cap the combined weight of the first three assets (a
sector) at 40%:
import numpy as np
from cvxcla import CLA
n = 10
rng = np.random.default_rng(42)
mean = rng.standard_normal(n)
factor = rng.standard_normal((n, n))
covariance = factor @ factor.T # symmetric positive definite
# sum of the first three weights ≤ 0.40
g = np.zeros((1, n))
g[0, :3] = 1.0
h = np.array([0.40])
cla = CLA(
mean=mean,
covariance=covariance,
lower_bounds=np.zeros(n),
upper_bounds=np.full(n, 0.35),
a=np.ones((1, n)), # fully invested
b=np.ones(1),
g=g, # inequality matrix G (p x n)
h=h, # inequality vector h (p,)
)
# every turning point now satisfies the sector cap
assert all(tp.weights[:3].sum() <= 0.40 + cla.tol for tp in cla.turning_points)
g/h default to None, so omitting them recovers the equality-only problem
unchanged. Stack multiple rows in g for several caps at once, and express a ≥
floor by negating a row (-Gw ≤ -h).
Pass leverage=c (or chain .leverage(c) on the builder) to cap the gross
exposure ‖w‖₁ = Σ|wᵢ| ≤ c. With a fully-invested budget, c = 1.3 is a 130/30
book:
import numpy as np
from cvxcla import CLA
n = 10
rng = np.random.default_rng(42)
mean = rng.standard_normal(n)
factor = rng.standard_normal((n, n))
covariance = factor @ factor.T # symmetric positive definite
cla = (
CLA.problem(mean, covariance)
.bounds(-0.2, 0.45) # shorts allowed down to -20% per asset
.budget() # fully invested
.leverage(1.3) # at most 130% long + short
.trace()
)
assert all(np.abs(tp.weights).sum() <= 1.3 + cla.tol for tp in cla.turning_points)
The 1-norm is polyhedral, so the frontier is still traced exactly. Internally each
asset whose box straddles zero is split into a long and a short leg, wᵢ = uᵢ − vᵢ,
and the cap becomes one extra inequality row Σuᵢ + Σvᵢ ≤ c. The turning points
are reported in the original weights. Assets that can only be long (or only short)
are not split, so the cap is redundant for a long-only, fully-invested book.
For diagonal-plus-low-rank covariances (factor risk models, eigenvalue-clipped
covariances) pass a FactorCovariance instead of a dense matrix; the algorithm
then never forms an n-by-n matrix:
import numpy as np
from cvxcla import CLA, FactorCovariance
rng = np.random.default_rng(42)
n, k = 10_000, 50
covariance = FactorCovariance(
d=rng.uniform(0.1, 0.5, n), # idiosyncratic variances
u=rng.standard_normal((n, k)), # factor loadings
delta=rng.uniform(0.5, 2.0, k), # factor variances, (k,) or (k, k)
)
See the factor backend documentation for the protocol, the math, and benchmarks against the dense path.
The same path-tracer drives the LASSO regularisation path. Lasso plays the Gram
matrix XᵀX and the vector Xᵀy the way the CLA plays the covariance and the mean,
and traces the whole path from λ_max (where β = 0) down to the least-squares fit:
import numpy as np
from cvxcla import Lasso
rng = np.random.default_rng(0)
X = rng.standard_normal((60, 12))
y = X @ rng.standard_normal(12) + 0.1 * rng.standard_normal(60)
# plain LASSO path; .solution(lam) evaluates beta at any penalty
lasso = Lasso(x=X, y=y)
beta = lasso.solution(0.5 * lasso.lam_max)
# the same fluent builder as the CLA
lasso = Lasso.problem(X, y).trace()
It carries the same constraints the CLA does. Add general inequality rows Gβ ≤ h
(with h > 0, e.g. per-group exposure caps), or restrict to the non-negative
LASSO β ≥ 0 — where the ℓ₁ penalty collapses to the linear term λ·1ᵀβ, making
it exactly the CLA's box-bounded parametric QP:
# group-exposure caps G beta <= h (cap features 0–3 and 4–7)
G = np.zeros((2, 12))
G[0, :4] = 1.0
G[1, 4:8] = 1.0
h = np.array([1.0, 1.0])
lasso = Lasso.problem(X, y).inequality(G, h).trace()
# non-negative LASSO (beta >= 0)
lasso = Lasso.problem(X, y).non_negative().trace()
Both return the exact path, validated breakpoint-by-breakpoint against a per-λ QP solver.
Homogeneous equality constraints Aβ = 0 are supported too, for example
sum-to-zero coefficients or contrasts:
# sum-to-zero LASSO (1ᵀβ = 0); rows accumulate
lasso = Lasso.problem(X, y).equality(np.ones(12)).trace()
assert abs(lasso.solution(0.5 * lasso.lam_max).sum()) < 1e-10
The LASSO homotopy starts from β = 0, where an equality row is already active,
so it cannot seed this path one coordinate at a time. The path is traced instead as
one leverage-capped CLA on Σ = XᵀX, μ = Xᵀy and rescaled, β = w / λ, using
the identity described next. Signs on any of the coefficients come along, since a
bound of zero survives the rescaling.
Per-coefficient bounds and equality rows with a nonzero right-hand side go
through the CLA in the penalty form: each coefficient is split into a long and a
short leg, β = Sz with z ≥ 0, and the penalty λ‖β‖₁ becomes a cost of λ
per unit of gross position, so the CLA's tilt is λ itself (covariance SᵀXᵀXS,
expected returns −1, benchmark q = SᵀXᵀy). Caps, signs, Aβ = b and inequality
rows Gβ ≤ h (with h ≥ 0) combine:
# beta_0..2 >= 0, and every |beta_j| <= 2
lower = np.full(12, -2.0)
lower[:3] = 0.0
upper = np.full(12, 2.0)
lasso = Lasso.problem(X, y).bounds(lower, upper).trace()
# the same, with a fit that must pass through the first observation
lasso = Lasso.problem(X, y).equality(X[0], y[0]).bounds(lower, upper).trace()
assert abs(X[0] @ lasso.solution(0.5 * lasso.lam_max) - y[0]) < 1e-9
# and with the net effect of features 0-3 capped, |beta_0 + ... + beta_3| <= 1
G = np.zeros((2, 12))
G[0, :4], G[1, :4] = 1.0, -1.0
lasso = Lasso.problem(X, y).equality(X[0], y[0]).inequality(G, np.ones(2)).bounds(lower, upper).trace()
assert abs(G[0] @ lasso.path[-1].beta) <= 1.0 + 1e-9
# a monotone fit, beta_0 <= beta_1 <= ... <= beta_11: homogeneous rows, h = 0
D = np.eye(11, 12) - np.eye(11, 12, k=1)
lasso = Lasso.problem(X, y).inequality(D, np.zeros(11)).trace()
assert all((D @ bp.beta <= 1e-12).all() for bp in lasso.path)
Homogeneous rows (h = 0) are all tight at β = 0, so they start on a degenerate
vertex. They are traced twice, relaxed to h = ε and h = 2ε, and on a fixed
sequence of active sets every knot is affine in h, so 2·path(ε) − path(2ε) is the
exact path at h = 0. The result is returned only after it passes an optimality
certificate: a linear program finds the best multipliers at every knot and segment
midpoint, and the conditions must hold to rounding.
The two routes have these limits:
Aβ = 0 takes signs (bounds of 0 or ±∞) but neither a finite cap nor
inequality rows: the rescaling cannot carry them, and the penalty form starts on
a degenerate vertexAβ = b with b ≠ 0 needs a unique smallest-ℓ₁ point on Aβ = b to start
from; the fully invested 1ᵀβ = 1 has a whole face of them and is refusedAβ = 0 the Gram XᵀX must be positive definite, so the constrained
least-squares end is uniqueBlock-structured homogeneous rows, such as two groups that each sum to zero, make the first vertex degenerate and are refused.
The link between the two classes is more than a shared engine. Under Σ = XᵀX and
μ = Xᵀy, the gross-exposure-capped Markowitz program and the constrained LASSO
trace the same piecewise-linear curve, under arbitrary linear equality and
inequality constraints
(Schmelzer and Hastie, 2026):
M_c: min ½ wᵀΣw − μᵀw s.t. ‖w‖₁ ≤ c, Aw = b, Gw ≤ h
L_λ: min ½‖y − Xβ‖² + λ‖β‖₁ s.t. Aβ = b, Gβ ≤ h
β(λ) solves
M_c at c(λ) = ‖β(λ)‖₁. Wherever c is strictly decreasing, the two paths
share the same breakpoints, visited in the same order.b = 0,
h = 0), sweeping the tilt λ at a fixed leverage cap c (which is what
CLA(leverage=c) does) gives the budget-indexed path rescaled:
w_c(λ) = λ · w^M(c/λ).t = 1/λ.df = E[|F| − rank M_F], where F is the free set and M_F stacks the active
constraint rows on it. For a long-only, fully invested frontier this is the
expected number of holdings away from a bound, less one.In cvxcla terms, a CLA with mean = Xᵀy, covariance = XᵀX and
leverage = c reproduces the rescaled LASSO path, and so does a Lasso fitted on
(X, y):
import numpy as np
from cvxcla import CLA, Lasso
rng = np.random.default_rng(0)
X = rng.standard_normal((40, 8))
y = X @ rng.standard_normal(8) + 0.3 * rng.standard_normal(40)
lasso = Lasso(x=X, y=y)
c = 0.8 * np.abs(lasso.path[-1].beta).sum() # a binding leverage cap
cla = CLA(
mean=X.T @ y,
covariance=X.T @ X,
lower_bounds=np.full(8, -50.0),
upper_bounds=np.full(8, 50.0),
a=np.zeros((0, 8)),
b=np.zeros(0),
leverage=c,
)
# every turning point w at tilt lam is lam * beta, where ||beta||_1 = c / lam
The identity carries statements about the curve, such as the breakpoints, the
order in which coordinates enter, and the path length. It does not carry
statements averaged over the response at a fixed parameter, such as
post-selection intervals, because a fixed λ and a fixed c are different
experiments.
Run the test suite with:
make test
or directly via pytest:
uv run pytest
Format and lint the code with:
make fmt
If you use cvxcla, please cite the software through its
Zenodo DOI (see CITATION.cff).
If you use the CLA–LASSO correspondence, please also cite:
@misc{schmelzer2026onecurve,
title = {The Critical Line Algorithm and the Constrained {LASSO}:
One Curve, Two Literatures},
author = {Schmelzer, Thomas and Hastie, Trevor},
year = {2026},
eprint = {2609.25704},
archivePrefix = {arXiv},
primaryClass = {stat.ME},
url = {https://arxiv.org/abs/2609.25704}
}
Contributions are welcome! Please feel free to submit a Pull Request.
git checkout -b feature/amazing-feature)make test)make fmt)git commit -m 'Add some amazing feature')git push origin feature/amazing-feature)This project is licensed under the MIT License - see the LICENSE file for details.