quantlab.portfolio.config.MeanVarianceConfig#

class quantlab.portfolio.config.MeanVarianceConfig(*, expected_return_label, covariance, risk_aversion, expected_return, turnover_penalty=0.0, min_trade=0.0, weight_cap=1.0, direction='long_only', candidate_top_k=None, max_names=None, mip_gap=None, volatility_label=None, exposure_factors=(), exposure_bounds=<factory>, solver='CLARABEL')[source]#

Bases: FrozenConfig

Config of MeanVarianceOptimizer: Markowitz weights with a turnover penalty.

The optimiser maximises w @ mu - risk_aversion / 2 * w @ Sigma @ w - turnover_penalty * |w - w_current|_1, with mu and Sigma on the span of expected_return_label.

Examples

>>> cfg = MeanVarianceConfig(
...     expected_return_label="ret_5",
...     covariance=LedoitWolfEstimator(LedoitWolfEstimatorConfig(lookback_bars=252)),
...     expected_return=GrinoldExpectedReturn(GrinoldExpectedReturnConfig(ic=0.05)),
...     risk_aversion=10.0, weight_cap=0.05,
... )
>>> cfg.direction, cfg.turnover_penalty, cfg.candidate_top_k
('long_only', 0.0, None)
>>> cfg.volatility_label is None
True
candidate_top_k = None#

Optimise only over the candidate_top_k symbols with the largest expected return (largest absolute one for "long_short") plus every symbol currently held (|w| > HELD_WEIGHT_TOLERANCE); the rest get 0.0. None optimises over every tradable symbol.

covariance#

The covariance estimator estimating the covariance of one-bar returns.

direction = 'long_only'#

"long_only": fully invested, non-negative weights (sum(w) = 1). "long_short": dollar-neutral (sum(w) = 0) with gross exposure |w|_1 <= 1, a ceiling, not an equality: the optimiser may leave part of the book uninvested.

expected_return#

How expected_return_label’s prediction becomes the expected return mu (an ExpectedReturnModel, ADR 0046): GrinoldExpectedReturn (ic * sigma * z), ZScoreExpectedReturn (ic * median(sigma) * z), RawExpectedReturn (the prediction, in return units) or PredictionExpectedReturn (the prediction as it is).

expected_return_label#

The label whose prediction gives the expected return; its span sets the horizon of the expected return and the covariance.

exposure_bounds#

Output name of an exposure_factors factor, or of an exposure of the factor risk model the covariance estimator declares (exposure_names, such as style_beta, read from context.risk_exposures), but not of both, to (lower, upper): the book’s exposure sum_i w_i * x_i, the locked positions’ included, is held between the two, to the solver’s tolerance (about 1e-9 with CLARABEL, 1e-5 with OSQP). A candidate without an exposure gets no weight. Empty: no exposure is bounded.

exposure_factors = ()#

Factors whose outputs exposure_bounds bounds, such as a BenchmarkBeta; the rule declares them in declared_inputs(), so the backtest hands their values at each bar in context.factors.

max_names = None#

The largest name count of the book decided at each bar: the symbols with a non-zero weight, locked positions included. Each bar is then solved exactly as a mixed-integer program (one boolean per candidate), so solver must solve mixed-integer conic programs ("MOSEK", which needs a licence); names the solve leaves out get exactly 0, a held one closed in full whatever min_trade. Long-only, weight_cap * max_names must be at least 1 (near 1 the weights are all near the cap); long-short, long and short names count together and any cap fits, the gross being a ceiling. With candidate_top_k the program is solved on the pool, faster. None: no cap, a convex program.

min_trade = 0.0#

Smallest trade: a candidate whose solved weight differs from its current one by less than this keeps its current weight, so no order is placed for a solver residue or a tiny adjustment, and a traded target below it (a residue left on a position being sold) is 0. Skipped sells that leave the book above its budget are absorbed by the candidates that do trade, scaled down; when they cannot, those sells are made. A skipped buy leaves its budget in cash, and the bounded exposures may move by about the skipped trades’ total. "long_only" only; 0 trades every change.

mip_gap = None#

The relative optimality gap the mixed-integer solve with max_names stops at, in (0, 1): the book’s objective is within this fraction of the best book’s. Passed to MOSEK (MSK_DPAR_MIO_TOL_REL_GAP) or GUROBI (MIPGap). None keeps the solver’s default (1e-4 for MOSEK). No time limit is set.

risk_aversion#

Risk aversion lambda of the variance penalty.

solver = 'CLARABEL'#

The cvxpy solver each bar’s problem is solved with, by its cvxpy name (cvxpy.installed_solvers()). None is refused, since cvxpy’s own choice depends on the installed solvers and the cvxpy version. Our choice: "CLARABEL" (interior point), whose constraints hold to about 1e-9, against about 1e-5 for "OSQP" (first order, the one cvxpy 1.9 picks); it leaves residues of 1e-10 to 1e-6 on the symbols it does not hold, which min_trade keeps from being traded.

turnover_penalty = 0.0#

Penalty kappa per unit of one-way turnover against the current weights; 0 trades freely.

volatility_label = None#

The label whose prediction gives each symbol’s volatility over the span, such as Volatility; it must have the expected-return label’s span and a "raw" scale. The covariance is then these volatilities around the covariance estimator’s correlations, and the sigma an expected_return model reads is the prediction. None keeps the covariance estimator’s own (historical) volatilities.

weight_cap = 1.0#

Largest absolute weight of one symbol.