diff --git a/cvx/risk/bounds.py b/cvx/risk/bounds.py index 4acf9d4b..ba0abfab 100644 --- a/cvx/risk/bounds.py +++ b/cvx/risk/bounds.py @@ -13,18 +13,22 @@ @dataclass class Bounds(Model): m: int = 0 + name: str = "" def estimate(self, weights, **kwargs): """No estimation for bounds""" raise NotImplementedError("No estimation for bounds") + def _f(self, str): + return f"{str}_{self.name}" + def __post_init__(self): - self.parameter["lower"] = cp.Parameter( + self.parameter[self._f("lower")] = cp.Parameter( shape=self.m, name="lower bound", value=np.zeros(self.m), ) - self.parameter["upper"] = cp.Parameter( + self.parameter[self._f("upper")] = cp.Parameter( shape=self.m, name="upper bound", value=np.ones(self.m), @@ -32,18 +36,18 @@ def __post_init__(self): def update(self, **kwargs): # lower = kwargs.get("lower", np.zeros(self.m)) - lower = kwargs["lower"] - self.parameter["lower"].value = np.zeros(self.m) - self.parameter["lower"].value[: len(lower)] = lower + lower = kwargs[self._f("lower")] + self.parameter[self._f("lower")].value = np.zeros(self.m) + self.parameter[self._f("lower")].value[: len(lower)] = lower - upper = kwargs["upper"] # .get("upper", np.ones(self.m)) - self.parameter["upper"].value = np.zeros(self.m) - self.parameter["upper"].value[ + upper = kwargs[self._f("upper")] # .get("upper", np.ones(self.m)) + self.parameter[self._f("upper")].value = np.zeros(self.m) + self.parameter[self._f("upper")].value[ : len(upper) ] = upper # kwargs.get("upper", np.ones(m)) def constraints(self, weights, **kwargs): return [ - weights >= self.parameter["lower"], - weights <= self.parameter["upper"], + weights >= self.parameter[self._f("lower")], + weights <= self.parameter[self._f("upper")], ] diff --git a/cvx/risk/cvar/cvar.py b/cvx/risk/cvar/cvar.py index 4b3377a5..05dbf84c 100644 --- a/cvx/risk/cvar/cvar.py +++ b/cvx/risk/cvar/cvar.py @@ -23,7 +23,7 @@ def __post_init__(self): self.parameter["R"] = cvx.Parameter( shape=(self.n, self.m), name="returns", value=np.zeros((self.n, self.m)) ) - self.bounds = Bounds(m=self.m) + self.bounds = Bounds(m=self.m, name="assets") def estimate(self, weights, **kwargs): """Estimate the risk by computing the Cholesky decomposition of self.cov""" @@ -41,5 +41,5 @@ def update(self, **kwargs): self.parameter["R"].value[:, :m] = kwargs["returns"] self.bounds.update(**kwargs) - def constraints(self, weights): + def constraints(self, weights, **kwargs): return self.bounds.constraints(weights) diff --git a/cvx/risk/factor/factor.py b/cvx/risk/factor/factor.py index 7c74115b..22bb4ae3 100644 --- a/cvx/risk/factor/factor.py +++ b/cvx/risk/factor/factor.py @@ -37,7 +37,8 @@ def __post_init__(self): value=np.zeros((self.k, self.k)), ) - self.bounds = Bounds(m=self.assets) + self.bounds_assets = Bounds(m=self.assets, name="assets") + self.bounds_factors = Bounds(m=self.k, name="factors") def estimate(self, weights, **kwargs): """ @@ -62,11 +63,14 @@ def update(self, **kwargs): "idiosyncratic_risk" ] self.parameter["chol"].value[:k, :k] = cholesky(kwargs["cov"]) - self.bounds.update(**kwargs) + self.bounds_assets.update(**kwargs) + self.bounds_factors.update(**kwargs) def constraints(self, weights, **kwargs): y = kwargs.get("y", self.parameter["exposure"] @ weights) - return self.bounds.constraints(weights) + [ - y == self.parameter["exposure"] @ weights - ] + return ( + self.bounds_assets.constraints(weights) + + self.bounds_factors.constraints(y) + + [y == self.parameter["exposure"] @ weights] + ) diff --git a/cvx/risk/sample/sample.py b/cvx/risk/sample/sample.py index 836b5206..4cd50652 100644 --- a/cvx/risk/sample/sample.py +++ b/cvx/risk/sample/sample.py @@ -25,7 +25,7 @@ def __post_init__(self): name="cholesky of covariance", value=np.zeros((self.num, self.num)), ) - self.bounds = Bounds(m=self.num) + self.bounds = Bounds(m=self.num, name="assets") def estimate(self, weights, **kwargs): """Estimate the risk by computing the Cholesky decomposition of self.cov""" diff --git a/tests/test_risk/test_bounds.py b/tests/test_risk/test_bounds.py index e69d4098..502b6f54 100644 --- a/tests/test_risk/test_bounds.py +++ b/tests/test_risk/test_bounds.py @@ -10,7 +10,7 @@ def test_raise_not_implemented(): weights = cp.Variable(3) - bounds = Bounds(m=3) + bounds = Bounds(m=3, name="assets") with pytest.raises(NotImplementedError): bounds.estimate(weights) @@ -18,10 +18,16 @@ def test_raise_not_implemented(): def test_constraints(): weights = cp.Variable(3) - bounds = Bounds(m=3) - bounds.update(lower=np.array([0.1, 0.2]), upper=np.array([0.3, 0.4, 0.5])) - - assert bounds.parameter["lower"].value == pytest.approx(np.array([0.1, 0.2, 0])) - assert bounds.parameter["upper"].value == pytest.approx(np.array([0.3, 0.4, 0.5])) + bounds = Bounds(m=3, name="assets") + bounds.update( + lower_assets=np.array([0.1, 0.2]), upper_assets=np.array([0.3, 0.4, 0.5]) + ) + + assert bounds.parameter["lower_assets"].value == pytest.approx( + np.array([0.1, 0.2, 0]) + ) + assert bounds.parameter["upper_assets"].value == pytest.approx( + np.array([0.3, 0.4, 0.5]) + ) assert len(bounds.constraints(weights)) == 2 diff --git a/tests/test_risk/test_cvar/test_cvar.py b/tests/test_risk/test_cvar/test_cvar.py index 8a6b4725..530b4882 100644 --- a/tests/test_risk/test_cvar/test_cvar.py +++ b/tests/test_risk/test_cvar/test_cvar.py @@ -21,11 +21,19 @@ def test_estimate_risk(): prob = minvar_problem(model, weights) assert prob.is_dpp() - model.update(returns=np.random.randn(50, 10), lower=np.zeros(10), upper=np.ones(10)) + model.update( + returns=np.random.randn(50, 10), + lower_assets=np.zeros(10), + upper_assets=np.ones(10), + ) prob.solve() assert prob.value == pytest.approx(0.5058720677762698) # it's enough to only update the R value... - model.update(returns=np.random.randn(50, 10), lower=np.zeros(10), upper=np.ones(10)) + model.update( + returns=np.random.randn(50, 10), + lower_assets=np.zeros(10), + upper_assets=np.ones(10), + ) prob.solve() assert prob.value == pytest.approx(0.43559171295408616) diff --git a/tests/test_risk/test_factor/test_factor.py b/tests/test_risk/test_factor/test_factor.py index 0a3435c7..11a7b155 100644 --- a/tests/test_risk/test_factor/test_factor.py +++ b/tests/test_risk/test_factor/test_factor.py @@ -30,8 +30,10 @@ def test_timeseries_model(returns): cov=factors.cov.values, exposure=factors.exposure.values, idiosyncratic_risk=factors.idiosyncratic.std().values, - lower=np.zeros(20), - upper=np.ones(20), + lower_assets=np.zeros(20), + upper_assets=np.ones(20), + lower_factors=np.zeros(10), + upper_factors=np.ones(10), ) w = np.zeros(25) @@ -76,20 +78,34 @@ def test_estimate_risk(): cov=rand_cov(10), exposure=np.random.randn(10, 20), idiosyncratic_risk=np.random.randn(20), - lower=np.zeros(20), - upper=np.ones(20), + lower_assets=np.zeros(20), + upper_assets=np.ones(20), + lower_factors=np.zeros(10), + upper_factors=np.ones(10), ) prob.solve() - assert prob.value == pytest.approx(0.13625197847921858) + assert prob.value == pytest.approx(0.14138117837204583) assert np.array(weights.value[20:]) == pytest.approx(np.zeros(5), abs=1e-6) model.update( cov=rand_cov(10), exposure=np.random.randn(10, 20), idiosyncratic_risk=np.random.randn(20), - lower=np.zeros(20), - upper=np.ones(20), + lower_assets=np.zeros(20), + upper_assets=np.ones(20), + lower_factors=-0.1 * np.ones(10), + upper_factors=0.1 * np.ones(10), ) prob.solve() - assert prob.value == pytest.approx(0.40835167515605786) + assert prob.value == pytest.approx(0.5454593844618784) assert np.array(weights.value[20:]) == pytest.approx(np.zeros(5), abs=1e-6) + + # test that the exposure is correct, e.g. the factor weights match the exposure * asset weights + assert model.parameter["exposure"].value @ weights.value == pytest.approx( + y.value, abs=1e-6 + ) + + # test all entries of y are smaller than 0.1 + assert np.all([y.value <= 0.1 + 1e-6]) + # test all entries of y are larger than -0.1 + assert np.all([y.value >= -(0.1 + 1e-6)]) diff --git a/tests/test_risk/test_sample/test_sample.py b/tests/test_risk/test_sample/test_sample.py index 36ba5bce..6af2ce43 100644 --- a/tests/test_risk/test_sample/test_sample.py +++ b/tests/test_risk/test_sample/test_sample.py @@ -11,7 +11,9 @@ def test_sample(): riskmodel = SampleCovariance(num=2) riskmodel.update( - cov=np.array([[1.0, 0.5], [0.5, 2.0]]), lower=np.zeros(2), upper=np.ones(2) + cov=np.array([[1.0, 0.5], [0.5, 2.0]]), + lower_assets=np.zeros(2), + upper_assets=np.ones(2), ) vola = riskmodel.estimate(np.array([1.0, 1.0])).value np.testing.assert_almost_equal(vola, 2.0) @@ -20,7 +22,9 @@ def test_sample(): def test_sample_large(): riskmodel = SampleCovariance(num=4) riskmodel.update( - cov=np.array([[1.0, 0.5], [0.5, 2.0]]), lower=np.zeros(2), upper=np.ones(2) + cov=np.array([[1.0, 0.5], [0.5, 2.0]]), + lower_assets=np.zeros(2), + upper_assets=np.ones(2), ) vola = riskmodel.estimate(np.array([1.0, 1.0, 0.0, 0.0])).value np.testing.assert_almost_equal(vola, 2.0) @@ -33,7 +37,9 @@ def test_min_variance(): assert problem.is_dpp() riskmodel.update( - cov=np.array([[1.0, 0.5], [0.5, 2.0]]), lower=np.zeros(2), upper=np.ones(2) + cov=np.array([[1.0, 0.5], [0.5, 2.0]]), + lower_assets=np.zeros(2), + upper_assets=np.ones(2), ) problem.solve() np.testing.assert_almost_equal( @@ -42,7 +48,9 @@ def test_min_variance(): # It's enough to only update the value for the cholesky decomposition riskmodel.update( - cov=np.array([[1.0, 0.5], [0.5, 4.0]]), lower=np.zeros(2), upper=np.ones(2) + cov=np.array([[1.0, 0.5], [0.5, 4.0]]), + lower_assets=np.zeros(2), + upper_assets=np.ones(2), ) problem.solve() np.testing.assert_almost_equal(