diff --git a/backtest.py b/backtest.py index c0f96d2..a3875df 100644 --- a/backtest.py +++ b/backtest.py @@ -3,81 +3,42 @@ import numpy as np import pandas as pd -from datetime import datetime as dt -from matplotlib import pyplot from tests.test_config import TEST_YAHOO_STOCK_UNIVERSE_16, TEST_YAHOO_STOCK_UNIVERSE_8, TEST_YAHOO_STOCK_UNIVERSE_4 -from src.abacus.utils.instrument import Instrument from src.abacus.utils.portfolio import Portfolio +from src.abacus.utils.universe import Universe from src.abacus.simulator.simulator import Simulator -from src.abacus.assessor.risk_assessor import RiskAssessor -from src.abacus.optimizer.optimizer import SPMaximumUtility, MPCMaximumUtility, MPCMaximumReturn +from src.abacus.optimizer.optimizer import MPCMaximumReturn -""" -Starting 2020, monthly updates to 2023. -Record portfolio weights -Record portfolio returns -Record 1/n returns -""" -time_series_data = {} -instruments = [] -initial_weights = {} -inital_holdings = {} -inital_cash = 10_000 - -# TODO: Should be in a universe class maybe... -instrument_mapping = {} -for id, ticker in enumerate(sorted(TEST_YAHOO_STOCK_UNIVERSE_8)): +instrument_specification = {} +inital_weights = {} +wealth = [] +number_of_start_assets = 5 +for i, ticker in enumerate(sorted(TEST_YAHOO_STOCK_UNIVERSE_8)): file = f"tests/data/{ticker}.csv" time_series = pd.read_csv(file, index_col='Date') - time_series_data[ticker] = time_series - ins = Instrument(id, ticker, "Stock", None) - instruments.append(ins) - initial_weights[ins] = 1 / len(TEST_YAHOO_STOCK_UNIVERSE_8) - inital_holdings[ins] = 10 - instrument_mapping[ticker] = ins + instrument_specification[ticker] = time_series + if i < number_of_start_assets: inital_weights[ticker] = 1 / number_of_start_assets + +universe = Universe(instrument_specifications=instrument_specification) +portfolio = Portfolio(weights=inital_weights) +simulator = Simulator(universe) +simulator.calibrate() +simulator.run_simulation(time_steps=5, number_of_simulations=100) -ts = time_series_data["XOM"] +# Date range for backtesting. start_date = "2020-01-02" end_date = "2020-01-03" # "2023-05-31" -us_bd = CustomBusinessDay(calendar=USFederalHolidayCalendar()) -pr = pd.date_range(start=start_date, end=end_date, freq='B') - -portfolio1 = Portfolio(weights=initial_weights) -portfolio2 = Portfolio(weights=initial_weights) -portfolio3 = Portfolio(holdings=inital_holdings, cash=inital_cash) - - -wealth = np.zeros(len(pr)) -for i, date in enumerate(pr): - +date_range = pd.date_range(start=start_date, end=end_date, freq='B') - # Build universe. - for ins in instruments: - ins.price_history = time_series_data[ins.identifier].loc[:str(date)] - - # Build simulator. - simulator = Simulator(instruments) +for date in date_range: + universe.date_today = date + simulator = Simulator(universe) simulator.calibrate() simulator.run_simulation(time_steps=5, number_of_simulations=25) - - # Run optimizer on portfolio. - optimizer = MPCMaximumReturn(portfolio1, simulator.return_tensor, gamma=10, l1_penalty=0, l2_penalty=1, covariance_matrix=simulator.covariance_matrix) + optimizer = MPCMaximumReturn(universe, portfolio, simulator.return_tensor, gamma=1, l1_penalty=0, l2_penalty=0.05, covariance_matrix=simulator.covariance_matrix) optimizer.solve() - solution = optimizer.solution - solution = {instrument_mapping[ticker]: weight for ticker, weight in solution.items()} - print(solution) - # optimizer = MPCMaximumUtility(portfolio2, simulator.return_tensor, gamma=1) - # optimizer.solve() - - # optimizer = SPMaximumUtility(portfolio3, simulator.price_tensor, simulator._inital_prices, gamma=-3) - # optimizer.solve() - - exit() - - - # Update portfolio weights. - # Record portfolio wealth. - wealth[i] = 0 + print(optimizer.solution) + break diff --git a/src/abacus/optimizer/optimizer.py b/src/abacus/optimizer/optimizer.py index 7cd0363..001242c 100644 --- a/src/abacus/optimizer/optimizer.py +++ b/src/abacus/optimizer/optimizer.py @@ -8,6 +8,7 @@ from typing import ClassVar from src.abacus.utils.portfolio import Portfolio +from src.abacus.utils.universe import Universe from src.abacus.config import DEFAULT_SOLVER from src.abacus.utils.enumerations import OptimizationSpecifications @@ -17,9 +18,11 @@ class OptimizationModel(ABC): _model_specification: ClassVar[str] - def __init__(self, portfolio: Portfolio, simulation_tensor: torch.Tensor, solver: str=DEFAULT_SOLVER): + def __init__(self, universe: Universe, portfolio: Portfolio, simulation_tensor: torch.Tensor, solver: str=DEFAULT_SOLVER): self._portfolio = portfolio self._simulation_tensor = simulation_tensor + self._universe = universe + self._asset_identifiers = universe.instrument_identifiers self._solver = solver self._solved = False self._ampl = None @@ -59,8 +62,8 @@ class SPMaximumUtility(OptimizationModel): _model_specification = OptimizationSpecifications.SP_MAXIMIZE_UTILITY - def __init__(self, portfolio: Portfolio, price_tensor: torch.Tensor, inital_prices: torch.Tensor, gamma: float): - super().__init__(portfolio, price_tensor) + def __init__(self, universe: Universe, portfolio: Portfolio, price_tensor: torch.Tensor, inital_prices: torch.Tensor, gamma: float): + super().__init__(universe, portfolio, price_tensor) self._inital_prices = inital_prices self._gamma = gamma @@ -99,8 +102,8 @@ class MPCMaximumUtility(OptimizationModel): _model_specification = OptimizationSpecifications.MPC_MAXIMIZE_UTILITY - def __init__(self, portfolio: Portfolio, return_tensor: torch.Tensor, gamma: float): - super().__init__(portfolio, return_tensor) + def __init__(self, universe: Universe, portfolio: Portfolio, return_tensor: torch.Tensor, gamma: float): + super().__init__(universe, portfolio, return_tensor) self._gamma = gamma @property @@ -116,14 +119,13 @@ def _set_ampl_data(self): # TODO: Add these as properties in superclass. assets = self._portfolio.instruments inital_weights = self._portfolio.weights - asset_identifiers = [instrument.identifier for instrument in assets] - inital_weights = dict(zip(asset_identifiers, inital_weights.values())) + inital_weights = dict(zip(self._asset_identifiers, inital_weights.values())) expected_return_tensor = np.array(self._return_expectation_tensor) tensor_size = expected_return_tensor.shape number_of_time_steps = tensor_size[1] return_dict = {(j+1, asset.identifier): expected_return_tensor[asset.id][j] for asset in assets for j in range(number_of_time_steps)} - self._ampl.get_set("assets").set_values(asset_identifiers) + self._ampl.get_set("assets").set_values(self._asset_identifiers) self._ampl.param["gamma"] = self._gamma self._ampl.param["number_of_time_steps"] = number_of_time_steps self._ampl.param["inital_weights"] = inital_weights @@ -134,50 +136,67 @@ class MPCMaximumReturn(OptimizationModel): _model_specification = OptimizationSpecifications.MPC_MAXIMIZE_RETURN - def __init__(self, portfolio: Portfolio, simulation_tensor: torch.Tensor, gamma: float, l1_penalty: float, l2_penalty: float, - covariance_matrix: torch.Tensor): - super().__init__(portfolio, simulation_tensor) + def __init__(self, universe: Universe, portfolio: Portfolio, simulation_tensor: torch.Tensor, gamma: float, + l1_penalty: float, l2_penalty: float, covariance_matrix: torch.Tensor): + super().__init__(universe, portfolio, simulation_tensor) self._gamma = gamma self._l1_penalty = l1_penalty self._l2_penalty = l2_penalty self._covariance_matrix = covariance_matrix - @property - def _return_expectation_tensor(self): - return torch.mean(self._simulation_tensor, dim=2) + def solve(self): + super().solve() @property def solution(self): self._check_solved() # TODO: Should be general? ampl_output = self._ampl.get_variable("weights").to_pandas().loc[1].to_dict()["weights.val"] + # print(self._ampl.eval("display OBJECTIVE;")) return ampl_output - def solve(self): - # TODO: Should weights be a configuration variable? - super().solve() - #print(self._ampl.eval("display OBJECTIVE;")) + @property + def _return_expectation_tensor(self): + return torch.mean(self._simulation_tensor, dim=2) + @property + def _assets(self): + return self._universe.instruments - def _set_ampl_data(self): - inital_weights = self._portfolio.weights - assets = self._portfolio.instruments - asset_identifiers = [instrument.identifier for instrument in assets] - inital_weights = dict(zip(asset_identifiers, inital_weights.values())) - expected_return_tensor = np.array(self._return_expectation_tensor) - tensor_size = expected_return_tensor.shape - number_of_time_steps = tensor_size[1] - return_dict = {(j+1, asset.identifier): expected_return_tensor[asset.id][j] for asset in assets for j in range(number_of_time_steps)} - covariance_matrix = np.array(self._covariance_matrix) + @property + def _inital_weights(self): + portfolio_weights = self._portfolio.weights + universe_instruments = self._universe.instrument_identifiers + return {identifier: portfolio_weights.get(identifier, 0) for identifier in universe_instruments} - l1_penalty_vector = self._l1_penalty * np.ones(len(assets)) - l2_penalty_vector = self._l2_penalty * np.ones(len(assets)) + @property + def _instrument_returns(self): + expected_return_array = np.array(self._return_expectation_tensor) + return {(j+1, asset.identifier): expected_return_array[asset.id][j] for asset in self._assets + for j in range(self._number_of_time_steps)} - self._ampl.get_set("assets").set_values(asset_identifiers) + @property + def _number_of_time_steps(self): + return self._return_expectation_tensor.shape[1] + + @property + def _number_of_assets(self): + return len(self._universe.instruments) + + @property + def _l1_penalty_array(self): + return self._l1_penalty * np.ones(self._number_of_assets) + + @property + def _l2_penalty_array(self): + return self._l2_penalty * np.ones(self._number_of_assets) + + def _set_ampl_data(self): + self._ampl.get_set("assets").set_values(self._asset_identifiers) self._ampl.param["gamma"] = self._gamma - self._ampl.param["number_of_time_steps"] = number_of_time_steps - self._ampl.param["inital_weights"] = inital_weights - self._ampl.param["returns"] = return_dict - self._ampl.param["covariance"] = covariance_matrix - self._ampl.param["l1_penalty"] = l1_penalty_vector - self._ampl.param["l2_penalty"] = l2_penalty_vector + self._ampl.param["number_of_time_steps"] = self._number_of_time_steps + self._ampl.param["inital_weights"] = self._inital_weights + self._ampl.param["returns"] = self._instrument_returns + self._ampl.param["covariance"] = np.array(self._covariance_matrix) + self._ampl.param["l1_penalty"] = self._l1_penalty_array + self._ampl.param["l2_penalty"] = self._l2_penalty_array diff --git a/src/abacus/simulator/simulator.py b/src/abacus/simulator/simulator.py index 5dfa1a9..52c84fe 100644 --- a/src/abacus/simulator/simulator.py +++ b/src/abacus/simulator/simulator.py @@ -3,22 +3,23 @@ import numpy as np import pyvinecopulib as pv -from src.abacus.utils.instrument import Instrument from src.abacus.utils.exceptions import ParameterError from src.abacus.utils.enumerations import DataTypes +from src.abacus.utils.universe import Universe from src.abacus.simulator.model_selector import ModelSelector from src.abacus.config import VINE_COPULA_FAMILIES, VINE_COPULA_NUMBER_OF_THREADS + class Simulator: """ TODO: Add description of resulting tensor. """ - def __init__(self, instruments: list[Instrument]): + def __init__(self, universe: Universe): self._model_selector = ModelSelector() - self._instruments = sorted(instruments, key=lambda x: x.identifier) + self._instruments = universe.instruments self._calibrated = False self._return_tensor = None self._price_tensor = None diff --git a/src/abacus/utils/universe.py b/src/abacus/utils/universe.py new file mode 100644 index 0000000..5d03de2 --- /dev/null +++ b/src/abacus/utils/universe.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +from pandas import DataFrame +from datetime import date +from src.abacus.utils.instrument import Instrument + + + +class Universe: + + def __init__(self, instrument_specifications: dict[str:DataFrame], date_today: str=str(date.today())): + self._instrument_specifications = instrument_specifications + self._date_today = date_today + self._instruments = None + self._instrument_build_date = None + + @property + def date_today(self): + return self._date_today + + @date_today.setter + def date_today(self, new): + self._date_today = new + + @property + def instrument_identifiers(self): + return sorted(self._instrument_specifications.keys()) + + @property + def instruments(self) -> list[Instrument]: + if self.has_updated_cache(): + return self._instruments + + built_instruments = [] + for id, identifier in enumerate(self.instrument_identifiers): + time_series = self._instrument_specifications[identifier].loc[:str(self.date_today)] + ins = Instrument(id, identifier, "Stock", time_series) + built_instruments.append(ins) + return built_instruments + + def has_updated_cache(self) -> bool: + has_cache = self._instruments is not None + has_last_build = self._instrument_build_date is not None + updated_date = self._date_today == self._instrument_build_date + conditions = has_cache, has_last_build, updated_date + return any(conditions)