quantlab.portfolio.predefined.mean_variance.MeanVarianceOptimizer#
- class quantlab.portfolio.predefined.mean_variance.MeanVarianceOptimizer(config)[source]#
Bases:
PortfolioConstructorMean-variance weights with a turnover penalty, long-only or long-short.
On each rebalance bar the candidates are the symbols the context marks tradable, covered by the covariance estimator (enough recent return history), and either with a finite prediction of
expected_return_labelor held; a held candidate without a prediction has an expected return of 0.0, so its turnover cost decides whether it is closed. A locked position (held, not tradable) keeps its current weight; a held, tradable symbol the risk model does not cover is closed, and the row reports it inattrs["events"]["closed_without_risk"]; every other symbol gets 0.0. Over the candidates the optimiser solves- maximise w @ mu - risk_aversion / 2 * w @ Sigma @ w
turnover_penalty * |w - w_current|_1
subject to, with
direction="long_only",sum(w) = 1, 0 <= w <= weight_cap
and with
direction="long_short",sum(w) = 0, |w|_1 <= 1, |w| <= weight_cap.
win the risk term also holds the locked weights, so their covariance with the candidates counts, while the turnover and the cap apply to the candidates only; the budget is what the locked positions leave: the candidates sum to1 - Llong-only (nothing whenL >= 1) and to-Llong-short with|w|_1 <= 1 - |L|_1,Lthe locked weights. A locked position the covariance estimator does not cover adds no variance. The long-short gross exposure is a ceiling, not an equality: when the expected returns do not pay for the risk and the turnover, part of the book stays uninvested, down to no position at all.Sigmais the covariance estimator’s one-bar covariance times the spannofexpected_return_label(variance is linear in time). When the risk model’s estimate has a factor form (B F B' + diag(D)), the risk term is built as|F^(1/2) B' w|^2 + w' diag(D) wand the dense matrix is never formed.muis theexpected_returnmodel’s (anExpectedReturnModel, ADR 0046) on the prediction of the predicted candidates, handedsigma, the square root ofSigma’s diagonal, when the model needs it:GrinoldExpectedReturngivesic * sigma * z,zthe candidates’ cross-sectional z-score (ddof=1), so the prediction only ranks and any model’s output can feed the optimiser;ZScoreExpectedReturndrops the tilt to risky names,RawExpectedReturntakes a prediction in return units andPredictionExpectedReturnone in any units.w_currentis the context’s current weights.With
volatility_labelset (aVolatilitylabel, say), the volatilities come from a model: the label’s prediction at the bar, divided bysqrt(n), is handed to the covariance estimator as the one-bar volatilities, soSigmais the predicted volatilities around the risk model’s historical correlations, its diagonal the squared predictions, and thesigmathe expected return model reads is the prediction itself. A symbol without a finite positive volatility prediction has no risk estimate, like one without enough history.bindrequires the label to have the span ofexpected_return_labeland a"raw"scale.With
candidate_top_kset, only a pool is optimised: thecandidate_top_kcandidates with the largestmu(largest|mu|long-short) plus every candidate currently held, so a held symbol that fell out of the top can still be closed at its turnover cost. The z-score is taken over every candidate before the pool is cut.With
max_namesset, at most that many symbols hold a weight, long and short together, locked positions included: each bar is solved exactly as a mixed-integer program (one boolean per candidate,|w| <= weight_cap * z,sum(z) <= max_namesless the locked names), names it leaves out get exactly 0, and a held one is closed whatevermin_trade. A bar whose locked positions exceed the cap (long-only, or fill it while budget is left), or whose solve is not optimal, raisesPortfolioConstructionError.The solver’s solution is cleaned of its round-off: projected onto the feasible set long-only (the nearest weights summing to one within
[0, weight_cap]), clipped and rescaled to dollar neutrality and a gross exposure of at most one long-short.A bar whose candidates cannot hold their budget under
weight_cap, a long-short bar whose locked positions exceed a gross exposure of one, a bar the solver fails on or leaves unsolved (bounds the candidates cannot reach among them), and a bar with a locked position lacking a bounded exposure raisePortfolioConstructionError: the backtest holds the current position there and records the bar.Two extension points let a rule built on the optimiser change what risk means:
reference_weights(the risk term pricesw - bagainst a reference book, a benchmark say) andrisk_constraints(constraints on the risk term, a tracking-error cap say). A third,posterior, lets it replacemuand the covariance estimate and change the candidates, aftermuis computed and beforecandidate_top_k(ADR 0047). All are unused by default, and the problem is then exactly the one above.ActiveRiskOptimizer(quantlab.portfolio.predefined.active_risk) builds a benchmark-relative rule on the first two.declared_inputs()is the covariance estimator’s declaration (its return window, its factor risk model) merged with theexposure_factors, whose outputs must not repeat a name.exposure_boundsmay bound any of their outputs or any of the risk model’sexposure_names(read fromcontext.risk_exposures); a bounded name must come from one of the two, not both.bindreads the span from the label’sLabelSpec, so a backtest binds the optimiser when it is built.- Parameters:
- configMeanVarianceConfig
expected_return_label,covariance,risk_aversion,expected_return,turnover_penalty,weight_cap,direction,candidate_top_k,volatility_label,exposure_factors,exposure_bounds,max_names,mip_gapandsolver.
- Raises:
- TypeError
If
expected_returnis not anExpectedReturnModel.- ValueError
If
directionis unknown,weight_capis not in(0, 1],risk_aversionorturnover_penaltyis negative,candidate_top_kis not a positive integer,volatility_labelis set with a covariance estimator that does not take volatilities (FactorRiskStoreEstimator),solveris not an installed cvxpy solver,max_namesis not a positive integer, is set long-only withweight_cap * max_names < 1, or with a solver that cannot solve mixed-integer conic programs, ormip_gapis outside(0, 1), set withoutmax_namesor for a solver it is not passed to.
Examples
specsholds the spec of a 5-barret_5label (LabelSpec("ret_5", "raw", 1, 5));contextis a bar of four symbolsAAA..``DDD``, all tradable and none held, with 60 bars of one-bar returns of volatility 1%, 1.5%, 2% and 2.5% andret_5predictions 0.8, -0.1, -0.3 and 0.2. With a risk aversion of 5 on so small an expected return the book leans toward the low-volatility symbols:>>> from quantlab.portfolio.config import ( ... GrinoldExpectedReturnConfig, LedoitWolfEstimatorConfig, MeanVarianceConfig, ... ) >>> from quantlab.portfolio.predefined.expected_return import GrinoldExpectedReturn >>> from quantlab.portfolio.predefined.ledoit_wolf import LedoitWolfEstimator >>> optimizer = MeanVarianceOptimizer(MeanVarianceConfig( ... expected_return_label="ret_5", ... covariance=LedoitWolfEstimator(LedoitWolfEstimatorConfig(lookback_bars=60)), ... expected_return=GrinoldExpectedReturn(GrinoldExpectedReturnConfig(ic=0.05)), ... risk_aversion=5.0, weight_cap=0.4, ... )) >>> optimizer.declared_inputs().lookback_bars 60 >>> optimizer.bind(specs) >>> weights = optimizer.construct(context) >>> float(weights.sum().round(6)), bool((weights >= 0).all()), bool((weights <= 0.4).all()) (1.0, True, True)
Long-short on the same bar the book is dollar-neutral, and its gross exposure (0.946) stays under the ceiling of one:
>>> long_short = MeanVarianceOptimizer(MeanVarianceConfig( ... expected_return_label="ret_5", ... covariance=LedoitWolfEstimator(LedoitWolfEstimatorConfig(lookback_bars=60)), ... expected_return=GrinoldExpectedReturn(GrinoldExpectedReturnConfig(ic=0.05)), ... risk_aversion=5.0, weight_cap=0.4, direction="long_short", ... )) >>> long_short.bind(specs) >>> weights = long_short.construct(context) >>> weights.values.round(3) array([ 0.4 , -0.212, -0.261, 0.073])
With
DDDheld at 0.3 and halted, it is a locked position: it keeps its weight and the candidates share the remaining 0.7:>>> import dataclasses >>> halted = dataclasses.replace( ... context, ... tradable=context.tradable.copy(data=[True, True, True, False]), ... current_weights=context.current_weights.copy(data=[0.0, 0.0, 0.0, 0.3]), ... ) >>> optimizer.construct(halted).values.round(3) array([0.4, 0.3, 0. , 0.3])
- bind(labels)[source]#
Check the specs hold the optimiser’s labels and read the span.
- Parameters:
- labelsSequence[LabelSpec]
The specs of the predicted labels.
- Raises:
- ValueError
If
expected_return_labelorvolatility_labelis not one of the specs, either has no span (it is not aForwardlabel), their spans differ,volatility_label’s scale is not"raw", or theexpected_returnmodel refusesexpected_return_label’s spec (RawExpectedReturn: a scale other than"raw").
Examples
>>> from quantlab.runs.prediction_panel import LabelSpec >>> optimizer.bind([LabelSpec(name="ret_5", scale="raw", delay=1, span=5)]) >>> optimizer.span 5
- config_cls#
alias of
MeanVarianceConfig
- construct(context)[source]#
Solve the bar’s mean-variance problem and return its weights.
- Parameters:
- contextPortfolioContext
The bar’s context.
- Returns:
- xr.DataArray
One finite weight per symbol of
context.symbols, locked positions at their current weights: summing to one long-only (to the locked weights’ sum when that is already one or more); summing to zero with gross exposure at most one long-short. When a held symbol was closed for want of a risk estimate,attrs["events"]["closed_without_risk"]lists it.
- Raises:
- PortfolioConstructionError
If the bar is infeasible or the solver fails or leaves it unsolved.
Examples
>>> optimizer.construct(context).values.round(3) array([0.4 , 0.4 , 0.009, 0.191])
- declared_inputs()[source]#
The covariance estimator’s declaration, merged with the
exposure_factors.Examples
>>> optimizer.declared_inputs().history_bars # Ledoit-Wolf: 60 + 1 + 5 66
- keeps_every_symbol()[source]#
Whether the covariance estimator pools the symbols (
CovarianceEstimator.pools_symbols).Examples
>>> rule.keeps_every_symbol() # a Ledoit-Wolf estimator True
- posterior(context, symbols, expected_return, estimate, candidates)[source]#
Return the expected return, the covariance estimate and the candidates the bar optimises.
An extension point, called by
problem_inputsaftermuis computed and beforecandidate_top_kcuts the pool: a rule may replace the expected return (a Bayes update toward a prior, say), replace the estimate (a posterior covariance) and widen or narrow the candidates among the selectable symbols. The default returns them unchanged, and the problem is then exactly the one in the class docstring.- Parameters:
- contextPortfolioContext
The bar’s context.
- symbolsnp.ndarray
The selectable symbols, in the context’s order: tradable, not locked, covered by the estimate and with every bounded exposure.
- expected_returnnp.ndarray
muper selectable symbol on the expected-return label’s span, NaN where there is no prediction.- estimateReturnCovariance
The covariance estimator’s estimate on the span, over the symbols it covers.
- candidatesnp.ndarray
Booleans per selectable symbol: the ones the bar optimises (a prediction, or held).
- Returns:
- expected_returnnp.ndarray
muper selectable symbol; a candidate left NaN gets 0.0.- estimateReturnCovariance
The estimate the risk term prices; it must cover every candidate (a
ValueErrorotherwise), and the locked positions it covers enter the risk term.- candidatesnp.ndarray
Booleans per selectable symbol.
Examples
With
optimizerandcontextas in the class example, the default changes nothing:>>> symbols = context.symbols >>> estimate = optimizer.config.covariance.estimate(context).scaled(5) >>> mu, same, candidates = optimizer.posterior( ... context, symbols, np.array([0.01, np.nan, -0.01, 0.0]), estimate, ... np.array([True, False, True, True]), ... ) >>> mu.tolist(), same is estimate, candidates.tolist() ([0.01, nan, -0.01, 0.0], True, [True, False, True, True])
- problem_inputs(context)[source]#
Return the candidates of the context’s bar and their
mu,Sigmaand weights.See the class docstring for how each is computed.
- Parameters:
- contextPortfolioContext
The bar’s context.
- Returns:
- MeanVarianceInputs
The candidates in the context’s symbol order.
- Raises:
- RuntimeError
If the optimiser has not been bound to label specs.
Examples
>>> inputs = optimizer.problem_inputs(context) >>> inputs.expected_return.round(4) array([ 0.0017, -0.0008, -0.0022, 0.0003])
- ranking_label(names)[source]#
Return
expected_return_label: the scores the optimiser ranks by.Examples
>>> optimizer.ranking_label(["vol_5", "ret_5"]) 'ret_5'
- reference_weights(context)[source]#
The book the risk term measures against at the bar, or
Nonefor total risk.An extension point: with a reference book
b(a benchmark, say) the risk term prices(w - b)' Sigma (w - b), the active risk, instead ofw' Sigma w; the expected return, turnover, budget, cap and exposure bounds are unchanged.bis read on the bar’s symbols (NaN counts as 0); a symbol of it the covariance estimator does not cover is left out of it and reported in the row’sreference_without_riskevent.Noneby default.- Parameters:
- contextPortfolioContext
The bar’s context.
- Returns:
- xr.DataArray or None
Weights on
symbol.
Examples
>>> optimizer.reference_weights(context) is None True
- risk_constraints(risk)[source]#
Constraints on the bar’s risk term, the forecast variance over the span.
An extension point:
riskis the cvxpy expression ofw' Sigma w(ofw - bwith a reference book), locked positions included, on the expected-return label’s span; a rule capping it returns, for example,[risk <= cap**2]. A cap the candidates cannot meet makes the bar infeasible, and the backtest holds it. Empty by default.- Parameters:
- riskcvxpy.Expression
The risk term.
- Returns:
- list of cvxpy constraints
Examples
>>> optimizer.risk_constraints(None) []
- property span#
The expected-return label’s span in bars, once
bindhas read it.Examples
>>> optimizer.span 5