Portfolio construction
src/portfolio/optimizer.py turns alphas and risk into the portfolio that maximizes
the information ratio you can actually implement. Where the OR-Tools
allocator maximizes a scalar score subject to constraints — and so
piles weight onto the highest-scoring names regardless of how correlated they are —
the MeanVarianceOptimizer trades expected residual return against active risk:
maximize U(w) = αᵀw − λ_A · wᵀΣw (long-only absolute: benchmark w_B = 0)
subject to Σw = 1, 0 ≤ w ≤ max_weight, ‖w‖₀ ≤ max_names
α is the alpha vector, Σ the covariance, and λ_A the
aversion to active variance.
This is portfolio construction: it proposes target weights (a config a human
promotes), it never places an order. It is deliberately separate from the operational
position sizer used by live --portfolio, so no covariance model
reaches the trade clock.
The closed form anchors everything
With no constraints or costs the optimum is closed-form, and it's worth stating because every diagnostic is read against it:
w* = (1 / 2λ_A) · Σ⁻¹ α IR* = √(αᵀ Σ⁻¹ α)
IR* is the best achievable information ratio — fully determined by α and Σ.
This is why Σ must be invertible (the reason for shrinkage) and why
scaling alphas correctly matters.
You specify tracking error, not λ. Users think in TE, so the optimizer inverts
the relation λ_A = IR* / (2·ψ_target) — pass --target-te 0.04 and it solves for
λ_A such that the optimal tracking error is 4%.
The transfer coefficient makes constraints visible
Constraints pull the implemented portfolio away from w*. The transfer
coefficient measures the damage, and the achievable IR degrades exactly as:
TC = corr(α, Σ-adjusted active weights) ∈ [0, 1] IR_achieved = TC · IR*
So tightening the cardinality cap or the position limit lowers TC and the predicted IR — a far more honest knob than "maximize score." A regression test pins this monotonicity. The report also surfaces predicted TE, predicted IR, value added, and turnover.
How it solves (pure numpy)
- Unconstrained: the closed form above (used for
IR*,w*, and the calibration identities). - Constrained:
Uis a concave quadratic, so the long-only / box / budget optimum is a small convex QP solved by projected gradient with a capped-simplex projection — no heavyweight QP dependency. - Cardinality (
‖w‖₀ ≤ k) and the dust floor (w ∈ {0} ∪ [min_weight, max_weight]) are both non-convex, so each is handled the same pragmatic way: solve the convex QP, drop the offending names (the smallest beyond the cardinality cap, or any stuck in the(0, min_weight)hole), and re-solve on the survivors until stable.
Edge cases it handles
- Infeasibility.
max_names · max_weight < 1can't fund the book; the optimizer returnsfeasible=Falseand names the binding constraint rather than a silent empty portfolio. - Turnover from
w₀. Turnover is measured against current holdings (w₀, default cash), so the same target from where you already are costs nothing. - No-trade band. A sub-threshold move keeps the current weights, suppressing churn. (A full cost-in-the-objective term arrives with the transaction-cost model.)
Where it runs
services/analysis.py::construct_portfolio scans the universe, builds benchmark-neutral
alphas and Σ as of the date, optimizes, and returns the proposed weights plus the
report. The CLI (python main.py allocate --objective utility --target-te 0.04) and the
read-only MCP tool construct_portfolio route through it. See the
usage guide.