Ledge
Factor-structured mean-variance portfolio optimization for Rust and Python.
Ledge solves continuous convex portfolio QPs without expanding the factor covariance
[ \Sigma = F\Omega F^\mathsf{T} + \operatorname{diag}(d) ]
into a dense asset-by-asset matrix. The practical target is repeated, small-to-medium portfolio rebalancing with budget, box constraints, and a modest number of linear constraints.
Status: alpha (0.2.0). Residuals are auditable; APIs and defaults may
change before 1.0. This is usable early software, not a production risk system.
Ledge is licensed under Apache-2.0. The complete open-core boundary, clean-history publication procedure, and package-release controls live in the release runbook and maintainer sign-off checklist.
Rust package / library name: ledge-portfolio / ledge. Python distribution /
import name: ledge-portfolio / ledge.
The animation is generated from a real seeded release-build command. Its wall
time is machine-dependent and is not a benchmark; capture data and the
reproduction script live in docs/assets/.
Who is this for?
- Python quantitative researchers and engineers whose covariance already
comes as
F,omega, andd, and who want a direct NumPy API plus warm starts for rolling rebalances. - Rust backtest and service authors who need an in-process solver with no commercial runtime dependency.
- Researchers and practitioners who want reproducible factor-QP examples and independently inspectable KKT residuals.
Install
Requirements: Rust 1.83+; Python 3.9+ for the bindings.
Python registry install (after the 0.2.0 artifacts are published):
Build from source:
Rust registry dependency:
[]
= { = "ledge-portfolio", = "0.2" }
Five-minute Python example
=
, = 100, 8
=
=
=
=
=
=
# Next date: turnover control around the previous weights + warm start.
# `turnover_penalty` is a smooth L2 preference; `l1_turnover_costs` is
# exact proportional transaction cost (scalar or per-asset array) with a
# genuine no-trade region. They can be combined.
=
=
Arrays must be NumPy float64. Optional equality_* / inequality_*
arguments add linear constraints (A @ w <= b for inequalities).
solve_mean_variance_factor(...) is the one-shot function form.
For rolling multi-date workloads, problem.sequence() returns a
PortfolioSequence that keeps the equilibration and reduced factorizations
cached and chains warm starts automatically; each date is one
sequence.solve_next(expected_returns=..., previous_weights=...) call. See
python/examples/rolling.py for the minimal
loop and
docs/examples/rolling_backtest.py
for a full momentum backtest with measured warm-start numbers. For many
accounts sharing one model, ledge.solve_batch(problems, steps) runs one
sequence per account in parallel over the account axis and returns one
result list per account.
Coming from cvxpy? docs/cvxpy_migration.md
maps the common portfolio patterns (constraints, turnover costs, tracking
error, cp.Parameter loops, statuses) onto this API; every mapping in it
is executed against cvxpy + Clarabel in CI.
Rust API
use ;
let factors = new?;
let problem = new?
.with_risk_aversion?
.with_bounds?;
let solution = problem.solve?;
assert_eq!;
println!;
# Ok::
Use solution.warm_start() for a full primal/dual warm start. The lower-level
QpProblem API remains available for custom continuous convex QPs.
Architecture
What is implemented
- Native factor covariance (
F, diagonal or dense PSDomega,d); no dense (\Sigma) is formed. - Mean-variance objective, budget, bounds, equalities, upper-form inequalities.
- Smooth L2 turnover around previous weights, and exact L1 proportional
transaction costs (
with_l1_turnover/l1_turnover_costs) via a dedicated soft-threshold prox block — machine-exact no-trade region, no growth of the reduced factorization; the two can be combined. - Tracking-error objectives (
with_tracking_benchmark/benchmark_weights): active risk against a benchmark as a pure linear-cost shift — same QP underneath. - Constraint template builders (
with_industry_neutrality/with_group_targets/with_style_bounds/with_concentration_limit/with_short_limit; Pythonindustry_ids=,style_matrix=,max_weight=,max_short=, ...): industry neutrality, sleeve targets, style bands, and per-name caps compiled onto the existing constraint rows and boxes, with eager validation. - Problem / solution serialization for bug reproduction (non-default
serdefeature; PythonPortfolioProblem.to_json()/from_json()andSolveResult.to_json()): lossless, validated, bit-exact replayable dumps of problems, settings, warm starts, and full solver output. - Primal / primal-dual warm starts.
- Reusable solve workspace (
Solver::workspace): equilibration and the reduced factorization are built once and reused across rolling solves; expected returns / budget updates are applied in place. - Rolling sequence API (
PortfolioProblem::sequence/solve_sequence; PythonPortfolioProblem.sequence()→PortfolioSequence.solve_next(...)): per-date data updates plus automatic warm-start chaining on top of the workspace cache. - Multi-thread batch over the account axis (
solve_batch/BatchAccount, non-defaultrayonfeature; Pythonledge.solve_batch(problems, steps)): one rolling sequence per account, optional backtest anchor chaining, per-account error isolation, and results identical to the serial loop regardless of thread count; published 1 model × 500 accounts × 250 dates throughput inbenchmarks/results/2026-07-batch/. - Residual-balancing adaptive ADMM penalty and over-relaxation
(default
over_relaxation=1.6;1.0recovers plain ADMM). - Automatic Ruiz equilibration with cost scaling (default on); residuals and termination are always evaluated on the original, unscaled data.
- SMW x-update; reduced factorization size is
factors + explicit linear constraints. - Status, objective, iteration/time diagnostics, and independent KKT checks.
- Solution polishing (default on): after convergence, one direct active-set solve refines residuals from ~1e-5 to ~1e-11 or better; adopted only when the independently audited KKT residuals improve.
- Infeasibility certificates: contradictory constraints stop early with a normalized, independently auditable Farkas certificate (unbounded objectives with a descent ray) and hints naming the conflicting portfolio constraints.
- Installable PyO3 package (releases the GIL while solving).
- Deterministic Rust / Python examples.
Performance smoke
We publish self-timing smoke numbers, not competitive rankings. Full method
and caveats: docs/SMOKE_TIMINGS.md.
Reproduce:
Recent smoke on a 4-vCPU Xeon cloud VM (rustc 1.83, --release, default
settings including over-relaxation):
| instance | status | iterations | time (ms) |
|---|---|---|---|
| n=100, k=5 | Solved | 30 | ~0.2 |
| n=500, k=10 | Solved | 90 | ~2.8 |
| n=1000, k=20 | Solved | 170 | ~15 |
| n=2000, k=50 | Solved | 260 | ~90 |
| n=5000, k=100 | Solved | 660 | ~1100 |
Practical range today: the declared envelope ((n \le 5000), (k \le 100), few explicit constraints) passes the synthetic smoke matrix under default settings since automatic scaling landed. These are single-seed synthetic instances, not a guarantee for every real problem of this size.
Protocol-compliant OSQP/Clarabel comparisons are published under
benchmarks/results/ (adapters behind non-default features; see
benchmarks/README.md): the
first report, a
re-run after over-relaxation became the default,
a
re-run after workspace factorization reuse,
and a
re-run with polish-on defaults and L1 turnover instances.
The honest headline: naive dense-Q usage of general solvers is 1–2 orders
of magnitude slower than factor-aware solving; Ledge beats OSQP at every
size and, on instances with proportional (L1) transaction costs — the
realistic rebalancing case — is the fastest solver at every size, cold and
rolling (the epigraph reformulation external solvers need costs them
2–6x; Ledge's prox block costs ~8%). On smooth instances a hand-lifted
Clarabel formulation remains the fastest cold baseline at n >= 2000
(2.3x at n = 5000 under polish-on defaults). Default residuals are now
audited at ~1e-11 or better. Read the reports' findings before quoting any
single number. The short technical note
docs/factor_structure_note.md walks
through what the gap measures and where it does not apply.
The chart is generated directly from the committed L1 report, not transcribed
by hand. Reproduce it with python scripts/generate_demo_assets.py; read the
full report before quoting a bar.
L1 turnover
Exact proportional transaction costs (c^\mathsf{T}|w-w_0|) are supported
natively (with_l1_turnover in Rust, l1_turnover_costs= in Python — a
scalar broadcasts to all assets). The term is handled by a dedicated
soft-threshold proximal block inside ADMM, so the reduced factorization
keeps its factors + constraints dimension — unlike an epigraph
reformulation, which adds (2n) constraints and loses the factor-structure
advantage. The no-trade region is machine-exact, the L1 multipliers are
audited like every other dual, and rolling sequences move the anchor
without refactorizing. Validated against epigraph reformulations and
cvxpy + Clarabel.
Limitations
- Alpha: APIs/defaults may change before 1.0.
- Continuous convex QPs only — no integers, SOCP, or general NLP.
- Problems infeasible by a margin below
infeasibility_tolerance(default1e-5) stop atMaxIterationswith hints rather than a certificate. - Dense storage of (F), dense (\Omega), and explicit constraint blocks; unsuitable when explicit constraints are (O(n)).
- Workspace reuse requires a fixed structure (covariance, constraint matrices, bounds); adaptive penalty changes still rebuild the reduced factorization in full.
- Release automation builds abi3 wheels for Linux x86-64/aarch64, macOS universal2, and Windows x86-64; other platforms build from source.
- Degenerate active sets (every weight pinned on a bound) can reject the
polishing step; those solves return ADMM-tolerance (~1e-5) residuals
instead of polished (~1e-11) ones.
SolveResult.polishedreports which.
Roadmap
Public technical plan and milestones: docs/PLAN.md,
docs/ROADMAP.md.
Near-term theme: complete the public-release gate without weakening trust. Immediate priorities:
Automatic scaling (Ruiz / equilibration) and large-instance reliability— landed; smoke matrix now passes at (n=5000, k=100)- PyPI wheels;
fair OSQP/Clarabel comparison— first protocol report published underbenchmarks/results/2026-07/ Exact L1 turnover— landed, dedicated soft-threshold prox block with audited subgradients;polishing— landed, audit-gated active-set refinement to ~1e-11 residuals by default;infeasibility certificates— landed with auditable Farkas / descent-ray proofs and portfolio-vocabulary hints;solve sequences—PortfolioSequence/solve_sequencelanded (Rust + Python);tracking-error objectives and rolling example— landed (docs/examples/)- M3 vertical productization:
constraint templates— landed (industry / group / style / concentration / short-limit builders);problem & solution serialization— landed (serdefeature, Pythonto_json/from_json);multi-thread batch over the account axis— landed (solve_batch, non-defaultrayonfeature, published 1×500×250 throughput);docs site— landed as an mdBook built in CI (docs/book/; public deployment waits on the public-release gate) - Later: 1.0 API/MSRV/deprecation review after external workload evidence
Non-goals
- Mixed-integer programming (MIP / MIQP) or a general modeling language.
- Feature parity with commercial solvers (Gurobi, CPLEX, MOSEK) or CVXPY.
- GPU / distributed execution.
- Unverified “faster than X” marketing claims.
- Becoming a general-purpose commercial QP company.
Security
Please do not file public issues for sensitive reports. See
SECURITY.md.
Repository layout
crates/ledge-core/ numerical kernel, portfolio API, KKT checks, generators
crates/ledge/ `ledge-portfolio` Rust package (`ledge` library) + examples
python/ PyO3/maturin package + examples/tests
docs/ plan, roadmap, algorithm notes, smoke timings, assets
docs/book/ mdBook docs site source (built in CI; `mdbook build docs/book`)
docs/examples/ rolling backtest example + published warm-start numbers
docs/assets/ reproducible README diagrams, chart, terminal GIF + provenance
benchmarks/ comparison protocol, adapters (non-default features), results
.github/workflows/ CI + manual release/docs deploy (strict-gated on public release)
.github/ISSUE_TEMPLATE/ bug / performance / problem-instance templates
Further reading: docs/PLAN.md,
docs/ROADMAP.md,
docs/DECISIONS.md,
docs/OPEN_CORE.md,
docs/algorithm.md,
docs/factor_structure_note.md,
docs/cvxpy_migration.md,
docs/SMOKE_TIMINGS.md.
License
Licensed under Apache-2.0. See LICENSE and NOTICE.
Development notes: CONTRIBUTING.md.