robertmartin8 / PyPortfolioOpt

Financial portfolio optimisation in python, including classical efficient frontier, Black-Litterman, Hierarchical Risk Parity
https://pyportfolioopt.readthedocs.io/
MIT License
4.38k stars 940 forks source link

Feature request: add support for providing initial value to the cvxpy solver #397

Open metodmove opened 2 years ago

metodmove commented 2 years ago

I have a use case where I want to backtest a daily strategy over multiple years - this means that I want to run e.g.

ef = EfficientFrontier(expected_returns=mu, cov_matrix=S)
ef.add_constraint(lambda x: x <= self.max_weights_per_asset)
ef.add_constraint(lambda x: x >= self.min_weights_per_asset)
weights = ef.max_quadratic_utility(risk_aversion=self.risk_aversion)

for every trading day in a simulation. What this does (to the best of my understanding) is instantiate a new cvxpy Problem under the hood (in method _solve_cvxpy_opt_problem in BaseConvexOptimizer). This prevents cvxpy from reusing the previous solution, i.e. weights from day{t-1}, as an initial point when computing weights on day{t}.

While this does not affect the actual weights we compute (provided that the convex solver converged the solution found should be independent of the initial point), it can be quite a bit slower since the solver needs to search for a feasible starting point first. See Warm Up chapter here for an example of speed-up that reusing the previous solution can bring in cvxpy.

Would it make sense to add an option for reusing the previous solution to EfficientFrontier class? Or is this perhaps available already and I simply overlooked it?

robertmartin8 commented 2 years ago

This is interesting – unfortunately due to how the package is designed (in particular, how it constructs the actual optimisation problem) this isn't straightforward. A possible sketch for a solution:


ef = EfficientFrontier(mu, S, solver_options={"warm_start": True})
ef.add_constraint(...)

for rets, cov_matrix in history:
    ef. expected_returns = rets # each day pass the expected returns
    ef.cov_matrix = cov_matrix # and the new cov matrix
    w = ef.convex_objective(objective_functions.quadratic_utility, ef.expected_returns, ef.cov_matrix, risk_aversion))

Something like this should work. The reason why you have to use convex_objective rather than max_quadratic_utility) is an unforeseen consequence/bug of v1.5.0 (parameterised problems) here:

        if update_existing_parameter:
            self._validate_market_neutral(market_neutral)
            self.update_parameter_value("risk_aversion", risk_aversion)
        else:
            self._objective = objective_functions.quadratic_utility(
                self._w,
                self.expected_returns,
                self.cov_matrix,
                risk_aversion=risk_aversion,
            )
            for obj in self._additional_objectives:
                self._objective += obj

            self._make_weight_sum_constraint(market_neutral)

Basically, if the risk aversion parameter has already been defined, then it doesn't reconstruct the cp.Problem. But in this case you need the problem to be reconstructed since the expected_returns and cov_matrix are different each day.

I'm not sure if there is a simple fix for that.