Skip to content

Commit

Permalink
introduce bounds for factor weights (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
tschm authored Jun 21, 2023
1 parent e46a601 commit 32a99ac
Show file tree
Hide file tree
Showing 8 changed files with 84 additions and 38 deletions.
24 changes: 14 additions & 10 deletions cvx/risk/bounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,41 @@
@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),
)

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")],
]
4 changes: 2 additions & 2 deletions cvx/risk/cvar/cvar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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)
14 changes: 9 additions & 5 deletions cvx/risk/factor/factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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]
)
2 changes: 1 addition & 1 deletion cvx/risk/sample/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
18 changes: 12 additions & 6 deletions tests/test_risk/test_bounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,24 @@

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)


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
12 changes: 10 additions & 2 deletions tests/test_risk/test_cvar/test_cvar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
32 changes: 24 additions & 8 deletions tests/test_risk/test_factor/test_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)])
16 changes: 12 additions & 4 deletions tests/test_risk/test_sample/test_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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(
Expand Down

0 comments on commit 32a99ac

Please sign in to comment.