diff --git a/README.md b/README.md index a0ba998..ce19bd9 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,31 @@ A default CQM run with a transaction cost factor of 1% can be done with the foll `python portfolio.py -m 'CQM' -t 0.01` +##### Stride Runs + +Similar to the CQM runs, the user can run the same problem using the Stride™ hybrid solver. + +```python portfolio.py -m 'Stride'``` + +The same parameters as the CQM formulation can be used. The output of the default Stride solver run is printed on the console as follows. + +```bash +Stride run... +Minimize mean-variance expression + +Best feasible solution: +AAPL 65 +MSFT 9 +AAL 1 +WMT 1 + +Estimated Returns: 15.31 +Sales Revenue: 0.00 +Purchase Cost: 999.60 +Transaction Cost: 0.00 +Variance: 2464.38 +``` + ##### DQM Runs The user can select to build a disctrete quadratic model (DQM) using the following command: @@ -272,25 +297,25 @@ expression with an associated penalty coefficient γ. The DQM code contains to do a grid search in order to obtain the best values of the penalty coefficients γ and the risk-aversion coefficient α that result in the best objective value. -### CQM +### CQM and Stride ### Variables Each xi denotes the number of shares of stock i to be purchased. ### Constraints -All CQM formulations include the budget constraint described earlier. +All CQM and Stride solver formulations include the budget constraint described earlier. ##### Risk-Bounding Formulation This includes an upper bound on the risk as a quadratic constraint. -Such a constraint is supported natively by CQM. +Such a constraint is supported natively by the CQM and Stride solvers. ##### Return-Bounding Formulation This includes a lower bound on the returns as a linear inequality constraint. ### Objective -There are 3 CQM formulations, each with a different objective: +There are 3 formulations, each with a different objective: - The bi-objective formulation corresponds to the original problem formulation where a combination of variance and mean is minimized. - The risk-bounding formulation where only the returns are maximized. diff --git a/portfolio.py b/portfolio.py index de835a8..1a007a1 100644 --- a/portfolio.py +++ b/portfolio.py @@ -87,9 +87,9 @@ "--model-type", default="CQM", multiple=False, - type=click.Choice(["CQM", "DQM"], case_sensitive=False), + type=click.Choice(["CQM", "DQM", "Stride"], case_sensitive=False), show_default=True, - help="Model type, CQM or DQM", + help="Model type, CQM, Stride or DQM", ) @click.option( "-r", @@ -142,19 +142,26 @@ def main( t_cost: float, ): - solver_type = SolverType.CQM if model_type == "CQM" else SolverType.DQM + if model_type == "CQM": + solver_type = SolverType.CQM + elif model_type == "Stride": + solver_type = SolverType.Stride + else: + solver_type = SolverType.DQM if (max_risk or min_return) and solver_type is SolverType.DQM: - raise Exception("The bound options require a CQM.") + raise Exception("The bound options require a CQM or Stride.") - if (gamma or bin_size) and solver_type is SolverType.CQM: + if (gamma or bin_size) and solver_type in (SolverType.CQM, SolverType.Stride): raise Exception("The option gamma or bin-size requires a DQM.") if num and not dates: raise Exception("User must provide dates with option 'num'.") if t_cost and solver_type is SolverType.DQM: - raise Exception("The transaction cost option requires a CQM. Set t_cost=0 for DQM.") + raise Exception( + "The transaction cost option requires a CQM or Stride. Set t_cost=0 for DQM." + ) if rebalance: print(f"\nRebalancing portfolio optimization run...") diff --git a/src/demo_enums.py b/src/demo_enums.py index 23ba628..9bd04eb 100644 --- a/src/demo_enums.py +++ b/src/demo_enums.py @@ -18,12 +18,14 @@ class SolverType(Enum): CQM = 0 DQM = 1 + Stride = 2 @property def label(self): return { - SolverType.CQM: "Quantum Hybrid (CQM)", - SolverType.DQM: "Quantum Hybrid (DQM)", + SolverType.CQM: "CQM Hybrid Solver", + SolverType.DQM: "DQM Hybrid Solver", + SolverType.Stride: "Stride Hybrid Solver", }[self] diff --git a/src/multi_period.py b/src/multi_period.py index a151a59..c8371f8 100644 --- a/src/multi_period.py +++ b/src/multi_period.py @@ -21,7 +21,7 @@ import matplotlib.pyplot as plt -from dwave.system import LeapHybridCQMSampler, LeapHybridDQMSampler +from dwave.system import LeapHybridCQMSampler, LeapHybridDQMSampler, LeapHybridNLSampler from src.single_period import SinglePeriod @@ -180,6 +180,7 @@ def initiate_run_update( self.sampler = { "CQM": LeapHybridCQMSampler(**self.sampler_args), "DQM": LeapHybridDQMSampler(**self.sampler_args), + "Stride": LeapHybridNLSampler(**self.sampler_args), } baseline_result, months, all_solutions, init_holdings, initial_budget = self.run_update( @@ -197,6 +198,7 @@ def initiate_run_update( self.sampler = {} self.sample_set["CQM"] = {} self.sample_set["DQM"] = {} + self.sample_set["Stride"] = {} return baseline_result, months, all_solutions, init_holdings @@ -301,6 +303,16 @@ def run_update( if self.model_type is SolverType.DQM: self.build_dqm() solution = self.solve_dqm() + elif self.model_type is SolverType.Stride: + # Set budget to 0 to enforce that portfolio is self-financing + if self.t_cost and not first_purchase: + self.budget = 0 + + solution = self.solve_stride( + max_risk=max_risk, min_return=min_return, init_holdings=init_holdings + ) + + init_holdings = solution["stocks"] else: # Set budget to 0 to enforce that portfolio is self-financing if self.t_cost and not first_purchase: diff --git a/src/single_period.py b/src/single_period.py index 27f0f69..c1d2aab 100644 --- a/src/single_period.py +++ b/src/single_period.py @@ -19,7 +19,10 @@ import numpy as np import pandas as pd from dimod import Binary, ConstrainedQuadraticModel, DiscreteQuadraticModel, Integer, quicksum -from dwave.system import LeapHybridCQMSampler, LeapHybridDQMSampler +from dwave.optimization import Model +from dwave.optimization.mathematical import add, safe_divide +from dwave.optimization.symbols import BinaryVariable, IntegerVariable +from dwave.system import LeapHybridCQMSampler, LeapHybridDQMSampler, LeapHybridNLSampler from src.demo_enums import SolverType from src.utils import get_baseline_data, get_requested_stocks, get_stock_data @@ -55,7 +58,7 @@ def __init__( otherwise, no grid search. file_path (str): Full path of CSV file containing stock data. dates (list of str): Pair of strings for start date and end date. - model_type (str): CQM or DQM. + model_type (str): CQM, Stride or DQM. time_limit (int): The time limit for the runs. alpha (float or int or list or tuple): Risk aversion coefficient. If alpha is a tuple/list and model is DQM, grid search will be done; @@ -105,7 +108,7 @@ def __init__( else: self.bin_size = 10 - self.model = {"CQM": None, "DQM": None} + self.model = {"CQM": None, "DQM": None, "Stride": None} self.sample_set = {} if sampler_args: @@ -116,6 +119,7 @@ def __init__( self.sampler = { "CQM": LeapHybridCQMSampler(**self.sampler_args), "DQM": LeapHybridDQMSampler(**self.sampler_args), + "Stride": LeapHybridNLSampler(**self.sampler_args), } self.solution = {} @@ -467,6 +471,171 @@ def dqm_grid_search(self): print(f"DQM Grid Search Completed: alpha={self.alpha}, gamma={self.gamma}.-") + def build_stride(self, max_risk=None, min_return=None, init_holdings=None) -> None: + """Build a Stride formulation. + + This method allows the user a choice of 3 problem formulations: + 1) max return - alpha*risk (default formulation) + 2) max return s.t. risk <= max_risk + 3) min risk s.t. return >= min_return + + Args: + max_risk (int): Maximum risk for the risk bounding formulation. + min_return (int): Minimum return for the return bounding formulation. + init_holdings (float): Initial holdings, or initial portfolio state. + """ + # Instantiating the Stride object + model = Model() + + # Required constants + stock_prices = np.asarray(self.price) + avg_returns = np.asarray(self.avg_monthly_returns) + const_stock_prices = model.constant(stock_prices) + const_budget_upper = model.constant(self.budget) + const_budget_lower = model.constant(0.997 * self.budget) + cov = self.covariance_matrix.loc[self.stocks, self.stocks].to_numpy() + risk_coeff = cov * np.outer(stock_prices, stock_prices) + const_risk_coeff = model.constant(risk_coeff) + const_avg_returns = model.constant(avg_returns * stock_prices) + const_neg_one = model.constant(-1) + const_alpha = model.constant(self.alpha) + const_zero = model.constant(0) + const_trans_cost = model.constant(self.t_cost) + const_one = model.constant(1) + const_two = model.constant(2) + const_budget_lower_with_trans = model.constant(self.budget - 0.003 * self.init_budget) + + num_stocks = len(self.stocks) + + # Defining and adding variables to the Stride model + x = model.integer( + num_stocks, lower_bound=0, upper_bound=list(self.max_num_shares) + ) + + # Defining risk expression + risk = [] + for i1, i2 in product(range(num_stocks), range(num_stocks)): + risk.append(const_risk_coeff[i1, i2] * x[i1] * x[i2]) + + # Defining the returns expression + returns = const_avg_returns * x + + # Adding budget and related constraints + if not init_holdings: + init_holdings = self.init_holdings + else: + self.init_holdings = init_holdings + + if not self.t_cost: + stock_purchases = x * const_stock_prices + model.add_constraint(stock_purchases.sum() <= const_budget_upper) + model.add_constraint(stock_purchases.sum() >= const_budget_lower) + else: + # Modeling transaction cost + y = model.binary(num_stocks) # dummy binary variable + x0 = model.constant(np.array(list(init_holdings.values()))) + lhs = [] + for i in range(num_stocks): + curr_return = const_stock_prices[i] * (const_one - const_trans_cost) * x[i] + prev_return = const_stock_prices[i] * (const_one - const_trans_cost) * x0[i] + curr_cost = const_two * const_trans_cost * const_stock_prices[i] * x[i] * y[i] + prev_cost = const_two * const_trans_cost * const_stock_prices[i] * x0[i] * y[i] + lhs.append(curr_cost + curr_return - prev_cost - prev_return) + # Indicator linking constraints + model.add_constraint(x[i] - x0[i] * y[i] >= const_zero) + model.add_constraint(x[i] - x[i] * y[i] <= x0[i]) + model.add_constraint(add(*lhs) <= const_budget_upper) + model.add_constraint(add(*lhs) >= const_budget_lower_with_trans) + + if max_risk: + print("Maximize returns s.t. an upper bound of risk") + # Adding maximum risk constraint + const_max_risk = model.constant(max_risk) + model.add_constraint(add(*risk) <= const_max_risk) + # Objective: maximize return + model.minimize(const_neg_one * returns.sum()) + elif min_return: + print("Minimize risk s.t. a lower bound of return") + # Adding minimum returns constraint + const_min_return = model.constant(min_return) + model.add_constraint(returns.sum() >= const_min_return) + # Objective: minimize risk + model.minimize(add(*risk)) + else: + print("Minimize mean-variance expression") + # Objective: minimize mean-variance expression + model.minimize(const_alpha * add(*risk) - returns.sum()) + """ + Alternative objective function - minimize the ratio of risk per unit return. This can be also done in Stride. + obj_expr = safe_divide((Alpha * add(*risk)), returns.sum()) + model.minimize(obj_expr) + """ + self.model["Stride"] = model + + def solve_stride(self, max_risk=None, min_return=None, init_holdings=None) -> dict: + """Solve the Stride formulation. + + This method allows the user a choice of 3 problem formulations: + 1) max return - alpha*risk (default formulation) + 2) max return s.t. risk <= max_risk + 3) min risk s.t. return >= min_return + + Args: + max_risk (int): Maximum risk for the risk bounding formulation. + min_return (int): Minimum return for the return bounding formulation. + init_holdings (float): Initial holdings, or initial portfolio state. + + Returns: + A dictionary containing the portfolio solution metrics: + + - sales: Portfolio sales value. + - cost: Portfolio cost value. + - transaction cost: Transaction cost incurred. + """ + + self.build_stride(max_risk, min_return, init_holdings) + + self.sample_set["Stride"] = self.sampler["Stride"].sample( + self.model["Stride"], label="Example - Portfolio Optimization", time_limit=self.time_limit + ) + self.model["Stride"].lock() + solution = {} + + if not self.model["Stride"].feasible(): + raise Exception("No feasible solution could be found for this problem instance.") + + model_des = list(sym for sym in self.model["Stride"].iter_decisions()) + for i, var in enumerate(model_des): + if isinstance(var, IntegerVariable): + x_var = var + break + else: + x_var = None + solution["stocks"] = {s: int(x_var.state(0)[i]) for i, s in enumerate(self.stocks)} + solution["return"], solution["risk"] = self.compute_risk_and_returns(solution["stocks"]) + cost = sum( + [ + self.price[s] * max(0, solution["stocks"][s] - self.init_holdings[s]) + for s in self.stocks + ] + ) + sales = sum( + [ + self.price[s] * max(0, self.init_holdings[s] - solution["stocks"][s]) + for s in self.stocks + ] + ) + transaction = self.t_cost * (cost + sales) + solution.update( + { + "sales": sales, + "cost": cost, + "transaction cost": transaction, + } + ) + + return solution + def compute_risk_and_returns(self, solution): """Compute the risk and return values of solution.""" variance = 0.0 @@ -488,6 +657,7 @@ def compute_risk_and_returns(self, solution): def print_results(self, solution: dict): """Print results to the console given a solution dictionary.""" is_cqm_run = self.model_type is SolverType.CQM + is_dqm_run = self.model_type is SolverType.DQM if self.verbose and is_cqm_run: print( @@ -497,7 +667,7 @@ def print_results(self, solution: dict): sep="\n", ) - if not is_cqm_run: + if is_dqm_run: print(f"\nSolution for alpha = {solution['alpha']} and gamma = {solution['gamma']}:") print( @@ -507,12 +677,10 @@ def print_results(self, solution: dict): sep="\n", ) - if is_cqm_run: - print(f"Sales Revenue: {solution['sales']:.2f}") - print(f"Purchase Cost: {solution['cost']:.2f}") - if is_cqm_run: + if not is_dqm_run: + print(f"Sales Revenue: {solution['sales']:.2f}") print(f"Transaction Cost: {solution['transaction cost']:.2f}") print(f"Variance: {solution['risk']}\n") @@ -537,6 +705,11 @@ def run( solution = self.solve_cqm( min_return=min_return, max_risk=max_risk, init_holdings=init_holdings ) + elif self.model_type is SolverType.Stride: + print(f"\nStride run...") + solution = self.solve_stride( + min_return=min_return, max_risk=max_risk, init_holdings=init_holdings + ) else: print(f"\nDQM run...") if len(self.alpha_list) > 1 or len(self.gamma_list) > 1: diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 7a5ddb3..1dc2991 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -111,6 +111,28 @@ def test_build_cqm_no_transaction(self): self.assertEqual(len(test_portfolio.model["CQM"].variables), 4) self.assertEqual(len(test_portfolio.model["CQM"].constraints), 4) + def test_build_stride(self): + test_portfolio = SinglePeriod(model_type="Stride", stocks=DEFAULT_STOCKS) + + data = { + "IBM": [93.043, 84.585, 111.453, 99.525, 95.819], + "WMT": [51.826, 52.823, 56.477, 49.805, 50.287], + } + + idx = ["Nov-00", "Dec-00", "Jan-01", "Feb-01", "Mar-01"] + + df = pd.DataFrame(data, index=idx) + + test_portfolio.load_data(df=df) + test_portfolio.build_stride() + test_portfolio.model["Stride"].lock() + + num_variables = test_portfolio.model["Stride"].num_decisions() + num_constraints = test_portfolio.model["Stride"].num_constraints() + + self.assertEqual(num_variables, 2) + self.assertEqual(num_constraints, 6) + class TestIntegration(unittest.TestCase): @unittest.skipIf(os.getenv("SKIP_INT_TESTS"), "Skipping integration test.") @@ -147,3 +169,20 @@ def test_dqm_integration(self): self.assertIn("purchase cost", output) self.assertIn("variance", output) self.assertNotIn("traceback", output) + + @unittest.skipIf(os.getenv("SKIP_INT_TESTS"), "Skipping integration test.") + def test_stride_integration(self): + """Test integration of portfolio script stride run.""" + + demo_file = os.path.join(project_dir, "portfolio.py") + + output = subprocess.check_output([sys.executable, demo_file] + ["-m", "Stride"]) + output = output.decode("utf-8") # Bytes to str + output = output.lower() + + self.assertIn("stride run", output) + self.assertIn("best feasible solution", output) + self.assertIn("estimated returns", output) + self.assertIn("purchase cost", output) + self.assertIn("variance", output) + self.assertNotIn("traceback", output)