Skip to content

Commit

Permalink
[WIP] Enum constraint names (#103)
Browse files Browse the repository at this point in the history
* test approx and test a mismatch

* test approx and test a mismatch

* use a Makefile approach

* Add Constraints as enums

* Fix tests

* Fixes pre-commit

* Improves error message

* Fixes pre-commit

* post rebase issues

* Enums

* Enums

* Enums

* more enum

* more enum

* more enum

* more enum

* less enum

* less enum

* less enum

* less enum

* less enum

* less enum

* fix examples

* preparing for better updates

* aux for updates

* more testing

* more testing

* more testing

* towards removing VariableName class

* towards removing VariableName class

* towards removing VariableName class

* remove model import from __init__

* full test coverage

* test cholesky

* test cholesky for speed

* testing linalg part

* test aux

* cleaning

---------

Co-authored-by: Thomas Schmelzer <[email protected]>
Co-authored-by: Thomas Schmelzer <[email protected]>
  • Loading branch information
3 people authored Jul 21, 2023
1 parent 5320a13 commit d8477ff
Show file tree
Hide file tree
Showing 39 changed files with 749 additions and 542 deletions.
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ KERNEL=$(shell poetry version | cut -d' ' -f1)

.PHONY: install
install: ## Install a virtual environment
@poetry install -vv
@poetry install -vv --all-extras

.PHONY: kernel
kernel: install ## Create a kernel for jupyter lab
@echo ${KERNEL}
@poetry run pip install ipykernel
@poetry run python -m ipykernel install --user --name=${KERNEL}

Expand Down
4 changes: 2 additions & 2 deletions cvx/cli/minvariance.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def minvariance(json_file, problem_file=None, assets=None, factors=None) -> None
# useful as the user can specify coarse upper bounds
problem = MinVar(assets=assets, factors=factors).build()
else:
assets, factors = estimate_dimensions(input_data)
assets, factors = estimate_dimensions(**input_data)
click.echo(
f"Estimated the numbers of assets as {assets} and factors as {factors}"
)
Expand All @@ -59,7 +59,7 @@ def minvariance(json_file, problem_file=None, assets=None, factors=None) -> None

problem.update(**input_data)
problem.solve()
click.echo(f"Solution: {problem.solution()}")
click.echo(f"Solution: {problem.weights}")

except Exception as e:
click.echo(traceback.print_exception(type(e), e, e.__traceback__))
Expand Down
5 changes: 4 additions & 1 deletion cvx/linalg/pca.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ def __post_init__(self):
# compute the principal components without sklearn
# 1. compute the correlation
cov = np.cov(self.returns.T)
cov = np.atleast_2d(cov)

# 2. compute the eigenvalues and eigenvectors
self.eigenvalues, eigenvectors = np.linalg.eigh(cov)

# 3. sort the eigenvalues in descending order
idx = self.eigenvalues.argsort()[::-1]
self.eigenvalues = self.eigenvalues[idx]
Expand All @@ -38,7 +41,7 @@ def explained_variance(self) -> np.ndarray:

@property
def cov(self) -> np.ndarray:
return np.cov(self.factors.T)
return np.atleast_2d(np.cov(self.factors.T))

@property
def systematic_returns(self) -> np.ndarray:
Expand Down
2 changes: 1 addition & 1 deletion cvx/linalg/valid.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def valid(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""
# make sure matrix is quadratic
if matrix.shape[0] != matrix.shape[1]:
raise AssertionError
raise ValueError("Matrix must be quadratic")

_valid = np.isfinite(np.diag(matrix))
return _valid, matrix[:, _valid][_valid]
4 changes: 0 additions & 4 deletions cvx/markowitz/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations

from .model import Model
67 changes: 43 additions & 24 deletions cvx/markowitz/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@

import cvxpy as cp

from cvx.markowitz import Model
from cvx.markowitz.cvxerror import CvxError
from cvx.markowitz.model import Model
from cvx.markowitz.models.bounds import Bounds
from cvx.markowitz.names import DataNames as D
from cvx.markowitz.names import ModelName as M
from cvx.markowitz.risk import FactorModel, SampleCovariance


@dataclass(frozen=True)
class _Problem:
problem: cp.Problem
model: Dict[str, Model] = field(default_factory=dict)
# problem has var_dict and param_dict

def update(self, **kwargs):
"""
Expand All @@ -34,10 +35,6 @@ def update(self, **kwargs):
# exactly the correct shape.
model.update(**kwargs)

for name, model in self.model.items():
for key in model.data.keys():
self.problem.param_dict[key].value = model.data[key].value

return self

def solve(self, solver=cp.ECOS, **kwargs):
Expand All @@ -51,12 +48,6 @@ def solve(self, solver=cp.ECOS, **kwargs):

return value

def solution(self, variable="weights"):
"""
Return the solution
"""
return self.problem.var_dict[variable].value

@property
def value(self):
return self.problem.value
Expand All @@ -70,6 +61,22 @@ def data(self):
for key, value in model.data.items():
yield (name, key), value

@property
def parameter(self):
return self.problem.param_dict

@property
def variables(self):
return self.problem.var_dict

@property
def weights(self):
return self.variables[D.WEIGHTS].value

@property
def factor_weights(self):
return self.variables[D.FACTOR_WEIGHTS].value


@dataclass(frozen=True)
class Builder:
Expand All @@ -83,32 +90,31 @@ class Builder:
def __post_init__(self):
# pick the correct risk model
if self.factors is not None:
self.model["risk"] = FactorModel(assets=self.assets, factors=self.factors)
self.model[M.RISK] = FactorModel(assets=self.assets, factors=self.factors)

# add variable for factor weights
self.variables["factor_weights"] = cp.Variable(
self.factors, name="factor_weights"
self.variables[D.FACTOR_WEIGHTS] = cp.Variable(
self.factors, name=D.FACTOR_WEIGHTS
)
# add bounds for factor weights
self.model["bounds_factors"] = Bounds(
assets=self.factors, name="factors", acting_on="factor_weights"
self.model[M.BOUND_FACTORS] = Bounds(
assets=self.factors, name="factors", acting_on=D.FACTOR_WEIGHTS
)
# add variable for absolute factor weights
self.variables["_abs"] = cp.Variable(self.factors, name="_abs", nonneg=True)
self.variables[D._ABS] = cp.Variable(self.factors, name=D._ABS, nonneg=True)

else:
self.model["risk"] = SampleCovariance(assets=self.assets)
#
self.model[M.RISK] = SampleCovariance(assets=self.assets)
# add variable for absolute weights
self.variables["_abs"] = cp.Variable(self.assets, name="_abs", nonneg=True)
self.variables[D._ABS] = cp.Variable(self.assets, name=D._ABS, nonneg=True)

# Note that for the SampleCovariance model the factor_weights are None.
# They are only included for the harmony of the interfaces for both models.
self.variables["weights"] = cp.Variable(self.assets, name="weights")
self.variables[D.WEIGHTS] = cp.Variable(self.assets, name=D.WEIGHTS)

# add bounds on assets
self.model["bound_assets"] = Bounds(
assets=self.assets, name="assets", acting_on="weights"
self.model[M.BOUND_ASSETS] = Bounds(
assets=self.assets, name="assets", acting_on=D.WEIGHTS
)

@property
Expand All @@ -130,4 +136,17 @@ def build(self):

problem = cp.Problem(self.objective, list(self.constraints.values()))
assert problem.is_dpp(), "Problem is not DPP"

return _Problem(problem=problem, model=self.model)

@property
def weights(self):
return self.variables[D.WEIGHTS]

@property
def risk(self):
return self.model[M.RISK]

@property
def factor_weights(self):
return self.variables[D.FACTOR_WEIGHTS]
14 changes: 7 additions & 7 deletions cvx/markowitz/models/bounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import numpy as np

from cvx.markowitz.model import Model
from cvx.markowitz.utils.aux import fill_vector


@dataclass(frozen=True)
Expand Down Expand Up @@ -36,13 +37,12 @@ def __post_init__(self):
)

def update(self, **kwargs):
lower = kwargs[self._f("lower")]
self.data[self._f("lower")].value = np.zeros(self.assets)
self.data[self._f("lower")].value[: len(lower)] = lower

upper = kwargs[self._f("upper")]
self.data[self._f("upper")].value = np.zeros(self.assets)
self.data[self._f("upper")].value[: len(upper)] = upper
self.data[self._f("lower")].value = fill_vector(
num=self.assets, x=kwargs[self._f("lower")]
)
self.data[self._f("upper")].value = fill_vector(
num=self.assets, x=kwargs[self._f("upper")]
)

def constraints(
self, variables: Dict[str, cp.Variable]
Expand Down
23 changes: 12 additions & 11 deletions cvx/markowitz/models/expected_returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@
import cvxpy as cp
import numpy as np

from cvx.markowitz import Model
from cvx.markowitz.builder import CvxError
from cvx.markowitz.model import Model
from cvx.markowitz.names import DataNames as D
from cvx.markowitz.utils.aux import fill_vector


@dataclass(frozen=True)
class ExpectedReturns(Model):
"""Model for expected returns"""

def __post_init__(self):
self.data["mu"] = cp.Parameter(
self.data[D.MU] = cp.Parameter(
shape=self.assets,
name="mu",
name=D.MU,
value=np.zeros(self.assets),
)

Expand All @@ -32,20 +34,19 @@ def __post_init__(self):
)

def estimate(self, variables: Dict[str, cp.Variable]) -> cp.Expression:
return self.data["mu"] @ variables["weights"] - self.parameter[
return self.data[D.MU] @ variables[D.WEIGHTS] - self.parameter[
"mu_uncertainty"
] @ cp.abs(variables["weights"])
] @ cp.abs(variables[D.WEIGHTS])

def update(self, **kwargs):
exp_returns = kwargs["mu"]
num = exp_returns.shape[0]
self.data["mu"].value = np.zeros(self.assets)
self.data["mu"].value[:num] = exp_returns
exp_returns = kwargs[D.MU]
self.data[D.MU].value = fill_vector(num=self.assets, x=exp_returns)

# Robust return estimate
uncertainty = kwargs["mu_uncertainty"]
if not uncertainty.shape[0] == exp_returns.shape[0]:
raise CvxError("Mismatch in length for mu and mu_uncertainty")

self.parameter["mu_uncertainty"].value = np.zeros(self.assets)
self.parameter["mu_uncertainty"].value[:num] = uncertainty
self.parameter["mu_uncertainty"].value = fill_vector(
num=self.assets, x=uncertainty
)
17 changes: 9 additions & 8 deletions cvx/markowitz/models/holding_costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,26 @@
import cvxpy as cp
import numpy as np

from cvx.markowitz import Model
from cvx.markowitz.model import Model
from cvx.markowitz.names import DataNames as D
from cvx.markowitz.utils.aux import fill_vector


@dataclass(frozen=True)
class HoldingCosts(Model):
"""Model for holding costs"""

def __post_init__(self):
self.data["holding_costs"] = cp.Parameter(
shape=self.assets, name="holding_costs", value=np.zeros(self.assets)
self.data[D.HOLDING_COSTS] = cp.Parameter(
shape=self.assets, name=D.HOLDING_COSTS, value=np.zeros(self.assets)
)

def estimate(self, variables: Dict[str, cp.Variable]) -> cp.Expression:
return cp.sum(
cp.neg(cp.multiply(variables["weights"], self.data["holding_costs"]))
cp.neg(cp.multiply(variables[D.WEIGHTS], self.data[D.HOLDING_COSTS]))
)

def update(self, **kwargs):
costs = kwargs["holding_costs"]
num = costs.shape[0]
self.data["holding_costs"].value = np.zeros(self.assets)
self.data["holding_costs"].value[:num] = costs
self.data[D.HOLDING_COSTS].value = fill_vector(
num=self.assets, x=kwargs[D.HOLDING_COSTS]
)
14 changes: 7 additions & 7 deletions cvx/markowitz/models/trading_costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import cvxpy as cp
import numpy as np

from cvx.markowitz import Model
from cvx.markowitz.model import Model
from cvx.markowitz.names import DataNames as D
from cvx.markowitz.utils.aux import fill_vector


@dataclass(frozen=True)
Expand All @@ -18,20 +20,18 @@ class TradingCosts(Model):
def __post_init__(self):
self.parameter["power"] = cp.Parameter(shape=1, name="power", value=np.ones(1))

# intial weights before rebalancing
self.data["weights"] = cp.Parameter(
shape=self.assets, name="weights", value=np.zeros(self.assets)
)

def estimate(self, variables: Dict[str, cp.Variable]) -> cp.Expression:
def estimate(self, variables: Dict[str | D, cp.Variable]) -> cp.Expression:
return cp.sum(
cp.power(
cp.abs(variables["weights"] - self.data["weights"]),
cp.abs(variables[D.WEIGHTS] - self.data["weights"]),
p=self.parameter["power"],
)
)

def update(self, **kwargs):
weights = kwargs["weights"]
num = weights.shape[0]
self.data["weights"].value = np.zeros(self.assets)
self.data["weights"].value[:num] = weights
self.data["weights"].value = fill_vector(num=self.assets, x=kwargs["weights"])
37 changes: 37 additions & 0 deletions cvx/markowitz/names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# see https://stackoverflow.com/a/54950492/1695486


class DataNames:
RETURNS = "returns"
MU = "mu"
MU_UNCERTAINTY = "mu_uncertainty"
CHOLESKY = "chol"
VOLA_UNCERTAINTY = "vola_uncertainty"
LOWER_BOUND_ASSETS = "lower_assets"
LOWER_BOUND_FACTORS = "lower_factors"
UPPER_BOUND_ASSETS = "upper_assets"
UPPER_BOUND_FACTORS = "upper_factors"
EXPOSURE = "exposure"
HOLDING_COSTS = "holding_costs"
IDIOSYNCRATIC_VOLA = "idiosyncratic_vola"
IDIOSYNCRATIC_VOLA_UNCERTAINTY = "idiosyncratic_vola_uncertainty"
SYSTEMATIC_VOLA_UNCERTAINTY = "systematic_vola_uncertainty"
FACTOR_WEIGHTS = "factor_weights"
WEIGHTS = "weights"
_ABS = "_abs"


class ModelName:
RISK = "risk"
RETURN = "return"
BOUND_ASSETS = "bound_assets"
BOUND_FACTORS = "bound_factors"


class ConstraintName:
BUDGET = "budget"
CONCENTRATION = "concentration"
LONG_ONLY = "long_only"
LEVERAGE = "leverage"
RISK = "risk"
Loading

0 comments on commit d8477ff

Please sign in to comment.