diff --git a/Makefile b/Makefile index a968c850a..df1721054 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,15 @@ PYTHON = python PROJECT = cvxportfolio ENVDIR = env BINDIR = $(ENVDIR)/bin +COVERAGE = 97 # target coverage score +LINT = 6.6 # target lint score + ifeq ($(OS), Windows_NT) BINDIR=$(ENVDIR)/Scripts endif -.PHONY: env test hardtest clean docs opendocs coverage fix hardfix release +.PHONY: env test lint clean docs opendocs coverage release fix env: $(PYTHON) -m venv $(ENVDIR) @@ -17,19 +20,22 @@ env: test: $(BINDIR)/coverage run -m unittest $(PROJECT)/tests/*.py - $(BINDIR)/coverage report + $(BINDIR)/coverage report --fail-under $(COVERAGE) $(BINDIR)/coverage xml $(BINDIR)/diff-cover --compare-branch origin/master coverage.xml -hardtest: - $(BINDIR)/pytest --cov --cov-report=xml -W error $(PROJECT)/tests/*.py - $(BINDIR)/coverage report --fail-under 97 - $(BINDIR)/ruff --line-length=79 --per-file-ignores='$(PROJECT)/__init__.py:F403' $(PROJECT)/*.py $(PROJECT)/tests/*.py - $(BINDIR)/isort --check-only $(PROJECT)/*.py $(PROJECT)/tests/*.py - $(BINDIR)/flake8 --per-file-ignores='$(PROJECT)/__init__.py:F401,F403' $(PROJECT)/*.py $(PROJECT)/tests/*.py - $(BINDIR)/docstr-coverage $(PROJECT)/*.py $(PROJECT)/tests/*.py - $(BINDIR)/bandit $(PROJECT)/*.py $(PROJECT)/tests/*.py - $(BINDIR)/pylint $(PROJECT)/*.py $(PROJECT)/tests/*.py +lint: + $(BINDIR)/pylint --fail-under $(LINT) $(PROJECT)/*.py $(PROJECT)/tests/*.py + +# hardtest: +# $(BINDIR)/pytest --cov --cov-report=xml -W error $(PROJECT)/tests/*.py +# $(BINDIR)/coverage report --fail-under 97 +# $(BINDIR)/ruff --line-length=79 --per-file-ignores='$(PROJECT)/__init__.py:F403' $(PROJECT)/*.py $(PROJECT)/tests/*.py +# $(BINDIR)/isort --check-only $(PROJECT)/*.py $(PROJECT)/tests/*.py +# $(BINDIR)/flake8 --per-file-ignores='$(PROJECT)/__init__.py:F401,F403' $(PROJECT)/*.py $(PROJECT)/tests/*.py +# $(BINDIR)/docstr-coverage $(PROJECT)/*.py $(PROJECT)/tests/*.py +# $(BINDIR)/bandit $(PROJECT)/*.py $(PROJECT)/tests/*.py +# $(BINDIR)/pylint $(PROJECT)/*.py $(PROJECT)/tests/*.py clean: -rm -rf $(BUILDDIR)/* @@ -61,7 +67,7 @@ fix: # THIS ONE DOES SAME AS RUFF, PLUS REMOVING PASS # $(BINDIR)/autoflake --in-place $(PROJECT)/*.py $(PROJECT)/tests/*.py -release: cleanenv env test +release: cleanenv env lint test $(BINDIR)/python bumpversion.py git push $(BINDIR)/python -m build diff --git a/cvxportfolio/benchmark.py b/cvxportfolio/benchmark.py index 8e23e2bf2..de28cc891 100644 --- a/cvxportfolio/benchmark.py +++ b/cvxportfolio/benchmark.py @@ -26,7 +26,6 @@ class BaseBenchmark(PolicyEstimator): """Base class for cvxportfolio benchmark weights.""" - pass class Benchmark(BaseBenchmark, DataEstimator): diff --git a/cvxportfolio/constraints.py b/cvxportfolio/constraints.py index 25109efe5..4c038e6ce 100644 --- a/cvxportfolio/constraints.py +++ b/cvxportfolio/constraints.py @@ -304,22 +304,22 @@ def __init__(self, asset, periods): self.periods = periods def _pre_evaluation(self, universe, backtest_times): - self.index = (universe.get_loc if hasattr( + self._index = (universe.get_loc if hasattr( universe, 'get_loc') else universe.index)(self.asset) - self.low = cp.Parameter() - self.high = cp.Parameter() + self._low = cp.Parameter() + self._high = cp.Parameter() def _values_in_time(self, t, **kwargs): if t in self.periods: - self.low.value = 0. - self.high.value = 0. + self._low.value = 0. + self._high.value = 0. else: - self.low.value = -100. - self.high.value = +100. + self._low.value = -100. + self._high.value = +100. def _compile_to_cvxpy(self, w_plus, z, w_plus_minus_w_bm): - return [z[self.index] >= self.low, - z[self.index] <= self.high] + return [z[self._index] >= self._low, + z[self._index] <= self._high] class LeverageLimit(BaseWeightConstraint, InequalityConstraint): diff --git a/cvxportfolio/costs.py b/cvxportfolio/costs.py index 83561f9cf..0aa119999 100644 --- a/cvxportfolio/costs.py +++ b/cvxportfolio/costs.py @@ -31,7 +31,7 @@ from .errors import ConvexityError, ConvexSpecificationError from .estimator import CvxpyExpressionEstimator, DataEstimator from .hyperparameters import HyperParameter -from .utils import periods_per_year +from .utils import periods_per_year_from_datetime_index __all__ = ["HoldingCost", "TransactionCost", "SoftConstraint", "StocksTransactionCost", "StocksHoldingCost"] @@ -215,9 +215,8 @@ def __init__(self, constraint): def _compile_to_cvxpy(self, w_plus, z, w_plus_minus_w_bm): """Compile cost to cvxpy expression.""" try: - expr = (self.constraint._compile_constr_to_cvxpy(w_plus, z, - w_plus_minus_w_bm) - - self.constraint._rhs()) + expr = (self.constraint._compile_constr_to_cvxpy( + w_plus, z, w_plus_minus_w_bm) - self.constraint._rhs()) except AttributeError: raise SyntaxError( f"{self.__class__.__name__} can only be used with" @@ -353,8 +352,9 @@ def _values_in_time(self, t, past_returns, **kwargs): if not ((self.short_fees is None) and (self.long_fees is None) and (self.dividends is None)): - ppy = periods_per_year(past_returns.index) if\ - self.periods_per_year is None else self.periods_per_year + ppy = periods_per_year_from_datetime_index( + past_returns.index) if self.periods_per_year is None else \ + self.periods_per_year if self.short_fees is not None: self._short_fees_parameter.value = np.ones( @@ -507,7 +507,7 @@ def _values_in_time(self, t, current_portfolio_value, past_returns, if (self.window_sigma_est is None) or\ (self.window_volume_est is None): - ppy = periods_per_year(past_returns.index) + ppy = periods_per_year_from_datetime_index(past_returns.index) windowsigma = ppy if ( self.window_sigma_est is None) else self.window_sigma_est windowvolume = ppy if ( @@ -527,7 +527,7 @@ def _simulate(self, t, u, current_and_past_returns, current_and_past_volumes, current_prices, **kwargs): if self.window_sigma_est is None: - windowsigma = periods_per_year(current_and_past_returns.index) + windowsigma = periods_per_year_from_datetime_index(current_and_past_returns.index) else: windowsigma = self.window_sigma_est diff --git a/cvxportfolio/result.py b/cvxportfolio/result.py index 5801d9fe3..be148aea3 100644 --- a/cvxportfolio/result.py +++ b/cvxportfolio/result.py @@ -21,7 +21,7 @@ import pandas as pd from .estimator import Estimator -from .utils import periods_per_year +from .utils import periods_per_year_from_datetime_index __all__ = ['BacktestResult'] @@ -55,7 +55,7 @@ def cash_key(self): @property def PPY(self): - return periods_per_year(self.h.index) + return periods_per_year_from_datetime_index(self.h.index) @property def v(self): @@ -222,21 +222,29 @@ def __repr__(self): f"Final value ({self.cash_key})": f"{self.v.iloc[-1]:.3e}", f"Profit ({self.cash_key})": f"{self.profit:.3e}", ' '*4: '', - "Absolute return (annualized)": f"{100 * self.mean_return:.1f}%", - "Absolute risk (annualized)": f"{100 * self.volatility:.1f}%", - "Excess return (annualized)": f"{self.excess_returns.mean() * 100 * self.PPY:.1f}%", - "Excess risk (annualized)": f"{self.excess_returns.std() * 100 * np.sqrt(self.PPY):.1f}%", + "Absolute return (annualized)": + f"{100 * self.mean_return:.1f}%", + "Absolute risk (annualized)": + f"{100 * self.volatility:.1f}%", + "Excess return (annualized)": + f"{self.excess_returns.mean() * 100 * self.PPY:.1f}%", + "Excess risk (annualized)": + f"{self.excess_returns.std() * 100 * np.sqrt(self.PPY):.1f}%", ' '*5: '', - "Avg. growth rate (absolute)": self._print_growth_rate(self.growth_rates.mean()), - "Avg. growth rate (excess)": self._print_growth_rate(self.excess_growth_rates.mean()), + "Avg. growth rate (absolute)": + self._print_growth_rate(self.growth_rates.mean()), + "Avg. growth rate (excess)": + self._print_growth_rate(self.excess_growth_rates.mean()), }) if len(self.costs): stats[' '*6] = '' for cost in self.costs: - stats[f'Avg. {cost}'] = f"{(self.costs[cost]/self.v).mean()*1E4:.0f}bp" - stats[f'Max. {cost}'] = f"{(self.costs[cost]/self.v).max()*1E4:.0f}bp" + stats[f'Avg. {cost}'] = \ + f"{(self.costs[cost]/self.v).mean()*1E4:.0f}bp" + stats[f'Max. {cost}'] = \ + f"{(self.costs[cost]/self.v).max()*1E4:.0f}bp" stats.update(collections.OrderedDict({ ' '*7: '', @@ -251,7 +259,8 @@ def __repr__(self): ' '*9: '', "Avg. policy time": f"{self.policy_times.mean():.3f}s", "Avg. simulator time": f"{self.simulator_times.mean():.3f}s", - "Total time": f"{self.simulator_times.sum() + self.policy_times.sum():.3f}s", + "Total time": + f"{self.simulator_times.sum() + self.policy_times.sum():.3f}s", })) content = pd.Series(stats).to_string() diff --git a/cvxportfolio/risks.py b/cvxportfolio/risks.py index 5321a5604..00891fa46 100644 --- a/cvxportfolio/risks.py +++ b/cvxportfolio/risks.py @@ -13,12 +13,9 @@ # limitations under the License. import logging -import warnings import cvxpy as cp import numpy as np -import pandas as pd -import scipy.linalg from .costs import BaseCost from .estimator import DataEstimator @@ -43,7 +40,7 @@ class BaseRiskModel(BaseCost): class FullCovariance(BaseRiskModel): - """Quadratic risk model with full covariance matrix. + r"""Quadratic risk model with full covariance matrix. It represents the objective term: @@ -152,7 +149,7 @@ def _compile_to_cvxpy(self, w_plus, z, w_plus_minus_w_bm): class FactorModelCovariance(BaseRiskModel): - """Factor model covariance, either user-provided or fitted from the data. + r"""Factor model covariance, either user-provided or fitted from the data. It represents the objective term: @@ -162,7 +159,7 @@ class FactorModelCovariance(BaseRiskModel): where the factors exposure :math:`F` has as many rows as the number of assets and as many columns as the number of factors, - the factors covariance matrix :math:`Sigma_{F}` is positive semi-definite, + the factors covariance matrix :math:`\Sigma_{F}` is positive semi-definite, and the idyosyncratic variances vector :math:`d` is non-negative. The advantage of this risk model over the standard :class:`FullCovariance` is mostly diff --git a/cvxportfolio/simulator.py b/cvxportfolio/simulator.py index ec1229b35..38932b6bc 100644 --- a/cvxportfolio/simulator.py +++ b/cvxportfolio/simulator.py @@ -38,7 +38,8 @@ from .errors import DataError from .estimator import DataEstimator, Estimator from .result import BacktestResult -from .utils import * +from .utils import (periods_per_year_from_datetime_index, repr_numpy_pandas, + resample_returns) PPY = 252 __all__ = ['StockMarketSimulator', 'MarketSimulator'] @@ -55,15 +56,17 @@ def _hash_universe(universe): def _load_cache(universe, trading_frequency, base_location): """Load cache from disk.""" - folder = base_location /\ - f'hash(universe)={_hash_universe(universe)},trading_frequency={trading_frequency}' + folder = base_location / ( + f'hash(universe)={_hash_universe(universe)},' + + f'trading_frequency={trading_frequency}') if 'LOCK' in globals(): logging.debug(f'Acquiring cache lock from process {os.getpid()}') LOCK.acquire() try: with open(folder/'cache.pkl', 'rb') as f: logging.info( - f'Loading cache for universe = {universe} and trading_frequency = {trading_frequency}') + f'Loading cache for universe = {universe}' + f' and trading_frequency = {trading_frequency}') return pickle.load(f) except FileNotFoundError: logging.info(f'Cache not found!') @@ -76,8 +79,9 @@ def _load_cache(universe, trading_frequency, base_location): def _store_cache(cache, universe, trading_frequency, base_location): """Store cache to disk.""" - folder = base_location /\ - f'hash(universe)={_hash_universe(universe)},trading_frequency={trading_frequency}' + folder = base_location / ( + f'hash(universe)={_hash_universe(universe)},' + f'trading_frequency={trading_frequency}') if 'LOCK' in globals(): logging.debug(f'Acquiring cache lock from process {os.getpid()}') LOCK.acquire() @@ -251,7 +255,7 @@ def _downsample(self, interval): @property def PPY(self): """Periods per year, assumes returns are about equally spaced.""" - return periods_per_year(self.returns.index) + return periods_per_year_from_datetime_index(self.returns.index) def _check_sizes(self): diff --git a/cvxportfolio/tests/test_hyperparameters.py b/cvxportfolio/tests/test_hyperparameters.py index dc85200f2..cb43a5d5f 100644 --- a/cvxportfolio/tests/test_hyperparameters.py +++ b/cvxportfolio/tests/test_hyperparameters.py @@ -11,35 +11,34 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Unit tests for the data and parameter estimator objects.""" +"""Unit tests for the hyper-parameters interface.""" import unittest from pathlib import Path import cvxpy as cp -import numpy as np import pandas as pd import cvxportfolio as cvx -from cvxportfolio.hyperparameters import * +from cvxportfolio.hyperparameters import GammaRisk, GammaTrade class TestHyperparameters(unittest.TestCase): + """Test hyper-parameters interface.""" @classmethod def setUpClass(cls): """Load the data and initialize cvxpy vars.""" - # cls.sigma = pd.read_csv(Path(__file__).parent / "sigmas.csv", index_col=0, parse_dates=[0]) cls.returns = pd.read_csv( - Path(__file__).parent / "returns.csv", index_col=0, parse_dates=[0]) - # cls.volumes = pd.read_csv(Path(__file__).parent / "volumes.csv", index_col=0, parse_dates=[0]) + Path(__file__).parent / "returns.csv", + index_col=0, parse_dates=[0]) cls.w_plus = cp.Variable(cls.returns.shape[1]) cls.w_plus_minus_w_bm = cp.Variable(cls.returns.shape[1]) cls.z = cp.Variable(cls.returns.shape[1]) cls.N = cls.returns.shape[1] - def test_basic_HP(self): - + def test_basic_hyper_parameters(self): + """Test simple syntax and errors.""" gamma = GammaRisk(current_value=1) self.assertTrue((-gamma).current_value == -1) @@ -54,7 +53,8 @@ def test_basic_HP(self): cvx.SinglePeriodOptimization(-GammaRisk() * cvx.FullCovariance()) - def test_HP_algebra(self): + def test_hyper_parameters_algebra(self): + """Test algebra of HPs objects.""" grisk = GammaRisk(current_value=1) gtrade = GammaRisk(current_value=.5) @@ -67,8 +67,8 @@ def test_HP_algebra(self): self.assertTrue((grisk/2 + 2*gtrade).current_value == 1.5) self.assertTrue((grisk/2 + 2 * (gtrade + gtrade/2)).current_value == 2) - def test_collect_HPs(self): - """Collect hyperparameters.""" + def test_collect_hyper_parameters(self): + """Test collect hyperparameters.""" pol = cvx.SinglePeriodOptimization(GammaRisk() * cvx.FullCovariance()) @@ -76,15 +76,17 @@ def test_collect_HPs(self): print(res) self.assertTrue(len(res) == 1) - pol = cvx.SinglePeriodOptimization(-GammaRisk() * cvx.FullCovariance() - - GammaTrade() * cvx.TransactionCost()) + pol = cvx.SinglePeriodOptimization( + - GammaRisk() * cvx.FullCovariance() + - GammaTrade() * cvx.TransactionCost()) res = pol._collect_hyperparameters() print(res) self.assertTrue(len(res) == 2) - pol = cvx.SinglePeriodOptimization(-(GammaRisk() + .5 * GammaRisk()) - * cvx.FullCovariance() - GammaTrade() * cvx.TransactionCost()) + pol = cvx.SinglePeriodOptimization( + -(GammaRisk() + .5 * GammaRisk()) * cvx.FullCovariance() + - GammaTrade() * cvx.TransactionCost()) res = pol._collect_hyperparameters() print(res) diff --git a/cvxportfolio/utils.py b/cvxportfolio/utils.py index c37845b8e..dd0bc0dcb 100644 --- a/cvxportfolio/utils.py +++ b/cvxportfolio/utils.py @@ -20,11 +20,11 @@ TRUNCATE_REPR_HASH = 10 # probability of conflict is 1e-16 -__all__ = ['periods_per_year', 'resample_returns', +__all__ = ['periods_per_year_from_datetime_index', 'resample_returns', 'flatten_heterogeneous_list', 'repr_numpy_pandas'] -def periods_per_year(idx): +def periods_per_year_from_datetime_index(idx): """Given a datetime pandas index return the periods per year.""" return int(np.round(len(idx) / ((idx[-1] - idx[0]) / pd.Timedelta('365.24d')))) diff --git a/requirements.txt b/requirements.txt index 6b1a03186..70a39cf0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,17 +8,14 @@ cvxpy scipy yfinance matplotlib -wheel numpydoc myst_parser coverage -multiprocess -flake8 -autopep8 sphinx-rtd-theme -pytest -ruff -isort -docstr-coverage pylint +autopep8 docformatter +isort +diff-cover +pytest +pytest-cov