From 0cdacef472e61deea0f5697c52df593f4620ba33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:38:11 +0000 Subject: [PATCH 1/4] Initial plan From 888442d01f8ba09810291f701258a0b5e5c2f788 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:46:58 +0000 Subject: [PATCH 2/4] Fix all deprecation and performance warnings by replacing np.matrix with arrays and ensuring contiguous arrays Co-authored-by: fchareyr <3171960+fchareyr@users.noreply.github.com> --- pyproject.toml | 6 +++--- src/pyrb/allocation.py | 22 +++++++++++----------- src/pyrb/solvers.py | 4 ++-- src/pyrb/tools.py | 16 +++++++++++----- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ae964a0..2d468cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,11 @@ classifiers = [ "Topic :: Scientific/Engineering :: Mathematics", ] dependencies = [ - "pandas>=2.3.0", - "numba>=0.61.0", + "pandas>=2.3.1", + "numba>=0.61.2", "quadprog>=0.1.13", "scipy>=1.16.0", - "numpy>=2.2.0", + "numpy>=2.3.1", ] [dependency-groups] diff --git a/src/pyrb/allocation.py b/src/pyrb/allocation.py index 8e0311a..c87e888 100644 --- a/src/pyrb/allocation.py +++ b/src/pyrb/allocation.py @@ -71,8 +71,8 @@ def get_variance(self): x = self.x cov = self.cov x = tools.to_column_matrix(x) - cov = np.matrix(cov) - RC = np.multiply(x, cov * x) + cov = np.asarray(cov) + RC = np.multiply(x, cov @ x) return np.sum(tools.to_array(RC)) def get_volatility(self): @@ -122,8 +122,8 @@ def get_risk_contributions(self, scale=True): x = self.x cov = self.cov x = tools.to_column_matrix(x) - cov = np.matrix(cov) - RC = np.multiply(x, cov * x) / self.get_volatility() + cov = np.asarray(cov) + RC = np.multiply(x, cov @ x) / self.get_volatility() if scale: RC = RC / RC.sum() return tools.to_array(RC) @@ -157,8 +157,8 @@ def get_risk_contributions(self, scale=True): x = self.x cov = self.cov x = tools.to_column_matrix(x) - cov = np.matrix(cov) - RC = np.multiply(x, cov * x) / self.get_volatility() + cov = np.asarray(cov) + RC = np.multiply(x, cov @ x) / self.get_volatility() if scale: RC = RC / RC.sum() return tools.to_array(RC) @@ -198,8 +198,8 @@ def get_risk_contributions(self, scale=True): x = self.x cov = self.cov x = tools.to_column_matrix(x) - cov = np.matrix(cov) - RC = np.multiply(x, cov * x) / self.get_volatility() * self.c - self.x * self.pi + cov = np.asarray(cov) + RC = np.multiply(x, cov @ x) / self.get_volatility() * self.c - self.x * self.pi if scale: RC = RC / RC.sum() return tools.to_array(RC) @@ -359,13 +359,13 @@ def get_risk_contributions(self, scale=True): x = self.x cov = self.cov x = tools.to_column_matrix(x) - cov = np.matrix(cov) + cov = np.asarray(cov) if self.solver == "admm_qp": - RC = np.multiply(x, cov * x) - self.c * self.x * self.pi + RC = np.multiply(x, cov @ x) - self.c * self.x * self.pi else: RC = np.multiply( - x, cov * x + x, cov @ x ).T / self.get_volatility() * self.c - tools.to_array( self.x.T ) * tools.to_array(self.pi) diff --git a/src/pyrb/solvers.py b/src/pyrb/solvers.py index c0a715e..c390bd2 100644 --- a/src/pyrb/solvers.py +++ b/src/pyrb/solvers.py @@ -56,8 +56,8 @@ def _cycle(x, c, var, _varphi, sigma_x, Sx, budgets, pi, bounds, lambda_log, cov x_tilde = np.maximum(np.minimum(x_tilde, bounds[i, 1]), bounds[i, 0]) x[i] = x_tilde - Sx = np.dot(cov, x) - sigma_x = np.sqrt(np.dot(Sx, x)) + Sx = np.dot(np.ascontiguousarray(cov), np.ascontiguousarray(x)) + sigma_x = np.sqrt(np.dot(np.ascontiguousarray(Sx), np.ascontiguousarray(x))) return x, Sx, sigma_x diff --git a/src/pyrb/tools.py b/src/pyrb/tools.py index 363ecd5..4943519 100644 --- a/src/pyrb/tools.py +++ b/src/pyrb/tools.py @@ -4,13 +4,19 @@ def to_column_matrix(x): """Return x as a matrix columns.""" - x = np.matrix(x) - if x.shape[1] != 1: - x = x.T - if x.shape[1] == 1: - return x + x = np.asarray(x) + if x.ndim == 1: + x = x.reshape(-1, 1) + elif x.ndim == 2: + if x.shape[1] != 1: + x = x.T + if x.shape[1] == 1: + return x + else: + raise ValueError("x is not a vector") else: raise ValueError("x is not a vector") + return x def to_array(x): From 5ee86ca743c0b12adaf49a13fd2fb386f0252cde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:54:17 +0000 Subject: [PATCH 3/4] Convert all docstrings to Google style guide format and fix linting issues Co-authored-by: fchareyr <3171960+fchareyr@users.noreply.github.com> --- src/pyrb/allocation.py | 182 ++++++++++++++++++++--------------------- src/pyrb/solvers.py | 84 ++++++++----------- src/pyrb/tools.py | 50 ++++++++++- src/pyrb/validation.py | 45 ++++++++++ 4 files changed, 213 insertions(+), 148 deletions(-) diff --git a/src/pyrb/allocation.py b/src/pyrb/allocation.py index c87e888..9cb522c 100644 --- a/src/pyrb/allocation.py +++ b/src/pyrb/allocation.py @@ -27,20 +27,13 @@ def n(self): return self.__n def __init__(self, cov, pi=None, x=None): - """ - Base class for Risk Budgeting Allocation. - - Parameters - ---------- - cov : array, shape (n, n) - Covariance matrix of the returns. - - pi : array, shape(n,) - Expected excess return for each asset (the default is None which implies 0 for each asset). - - x : array, shape(n,) - Array of weights. + """Base class for Risk Budgeting Allocation. + Args: + cov: Covariance matrix of the returns, shape (n, n). + pi: Expected excess return for each asset, shape (n,). + The default is None which implies 0 for each asset. + x: Array of weights, shape (n,). """ self.__n = cov.shape[0] if x is None: @@ -58,7 +51,10 @@ def __init__(self, cov, pi=None, x=None): @abstractmethod def solve(self): - """Solve the problem.""" + """Solve the problem. + + This is an abstract method that must be implemented by subclasses. + """ pass @abstractmethod @@ -67,7 +63,11 @@ def get_risk_contributions(self): pass def get_variance(self): - """Get the portfolio variance: x.T * cov * x.""" + """Get the portfolio variance: x.T * cov * x. + + Returns: + Portfolio variance as a float. + """ x = self.x cov = self.cov x = tools.to_column_matrix(x) @@ -76,11 +76,19 @@ def get_variance(self): return np.sum(tools.to_array(RC)) def get_volatility(self): - """Get the portfolio volatility: x.T * cov * x.""" + """Get the portfolio volatility: sqrt(x.T * cov * x). + + Returns: + Portfolio volatility as a float. + """ return self.get_variance() ** 0.5 def get_expected_return(self): - """Get the portfolio expected excess returns: x.T * pi.""" + """Get the portfolio expected excess returns: x.T * pi. + + Returns: + Portfolio expected excess return as a float, or NaN if pi is None. + """ if self.pi is None: return np.nan else: @@ -100,20 +108,22 @@ def __str__(self): class EqualRiskContribution(RiskBudgetAllocation): def __init__(self, cov): - """ - Solve the equal risk contribution problem using cyclical coordinate descent. Although this does not change - the optimal solution, the risk measure considered is the portfolio volatility. + """Solve the equal risk contribution problem using cyclical coordinate descent. - Parameters - ---------- - cov : array, shape (n, n) - Covariance matrix of the returns. + Although this does not change the optimal solution, the risk measure + considered is the portfolio volatility. + Args: + cov: Covariance matrix of the returns, shape (n, n). """ RiskBudgetAllocation.__init__(self, cov) def solve(self): + """Solve the equal risk contribution problem using cyclical coordinate descent. + + Updates the internal weights (x) and lambda_star attributes. + """ x = solve_rb_ccd(cov=self.cov) self._x = tools.to_array(x / x.sum()) self.lambda_star = self.get_volatility() @@ -131,24 +141,24 @@ def get_risk_contributions(self, scale=True): class RiskBudgeting(RiskBudgetAllocation): def __init__(self, cov, budgets): - """ - Solve the risk budgeting problem using cyclical coordinate descent. Although this does not change - the optimal solution, the risk measure considered is the portfolio volatility. + """Solve the risk budgeting problem using cyclical coordinate descent. - Parameters - ---------- - cov : array, shape (n, n) - Covariance matrix of the returns. - - budgets : array, shape(n,) - Risk budgets for each asset (the default is None which implies equal risk budget). + Although this does not change the optimal solution, the risk measure + considered is the portfolio volatility. + Args: + cov: Covariance matrix of the returns, shape (n, n). + budgets: Risk budgets for each asset, shape (n,). """ RiskBudgetAllocation.__init__(self, cov=cov) validation.check_risk_budget(budgets, self.n) self.budgets = budgets def solve(self): + """Solve the risk budgeting problem using cyclical coordinate descent. + + Updates the internal weights (x) and lambda_star attributes. + """ x = solve_rb_ccd(cov=self.cov, budgets=self.budgets) self._x = tools.to_array(x / x.sum()) self.lambda_star = self.get_volatility() @@ -166,23 +176,18 @@ def get_risk_contributions(self, scale=True): class RiskBudgetingWithER(RiskBudgetAllocation): def __init__(self, cov, budgets=None, pi=None, c=1): - """ - Solve the risk budgeting problem for the standard deviation risk measure using cyclical coordinate descent. - The risk measure is given by R(x) = c * sqrt(x^T cov x) - pi^T x. - - Parameters - ---------- - cov : array, shape (n, n) - Covariance matrix of the returns. - - budgets : array, shape(n,) - Risk budgets for each asset (the default is None which implies equal risk budget). - - pi : array, shape(n,) - Expected excess return for each asset (the default is None which implies 0 for each asset). - - c : float - Risk aversion parameter equals to one by default. + """Solve the risk budgeting problem for the standard deviation risk measure. + + Uses cyclical coordinate descent. The risk measure is given by + R(x) = c * sqrt(x^T cov x) - pi^T x. + + Args: + cov: Covariance matrix of the returns, shape (n, n). + budgets: Risk budgets for each asset, shape (n,). + Default is None which implies equal risk budget. + pi: Expected excess return for each asset, shape (n,). + Default is None which implies 0 for each asset. + c: Risk aversion parameter, default is 1. """ RiskBudgetAllocation.__init__(self, cov=cov, pi=pi) validation.check_risk_budget(budgets, self.n) @@ -223,38 +228,31 @@ def __init__( bounds=None, solver="admm_ccd", ): - """ - Solve the constrained risk budgeting problem. It supports linear inequality (Cx <= d) and bounds constraints. - Notations follow the paper Constrained Risk Budgeting Portfolios by Richard J-C. and Roncalli T. (2019). - - Parameters - ---------- - cov : array, shape (n, n) - Covariance matrix of the returns. - - budgets : array, shape (n,) - Risk budgets for each asset (the default is None which implies equal risk budget). - - pi : array, shape (n,) - Expected excess return for each asset (the default is None which implies 0 for each asset). - - c : float - Risk aversion parameter equals to one by default. - - C : array, shape (p, n) - Array of p inequality constraints. If None the problem is unconstrained and solved using CCD - (algorithm 3) and it solves equation (17). - - d : array, shape (p,) - Array of p constraints that matches the inequalities. - - bounds : array, shape (n, 2) - Array of minimum and maximum bounds. If None the default bounds are [0,1]. - - solver : basestring - "admm_ccd" (default): generalized standard deviation-based risk measure + linear constraints. The algorithm is ADMM_CCD (algorithm 4) and it solves equation (14). - "admm_qp" : mean variance risk measure + linear constraints. The algorithm is ADMM_QP and it solves equation (15). - + """Solve the constrained risk budgeting problem. + + Supports linear inequality (Cx <= d) and bounds constraints. + Notations follow the paper Constrained Risk Budgeting Portfolios + by Richard J-C. and Roncalli T. (2019). + + Args: + cov: Covariance matrix of the returns, shape (n, n). + budgets: Risk budgets for each asset, shape (n,). + Default is None which implies equal risk budget. + pi: Expected excess return for each asset, shape (n,). + Default is None which implies 0 for each asset. + c: Risk aversion parameter, default is 1. + C: Array of p inequality constraints, shape (p, n). If None the + problem is unconstrained and solved using CCD (algorithm 3) + and it solves equation (17). + d: Array of p constraints that matches the inequalities, shape (p,). + bounds: Array of minimum and maximum bounds, shape (n, 2). + If None the default bounds are [0,1]. + solver: Solver method, either "admm_ccd" (default) or "admm_qp". + "admm_ccd": generalized standard deviation-based risk measure + + linear constraints. The algorithm is ADMM_CCD (algorithm 4) and + it solves equation (14). + "admm_qp": mean variance risk measure + linear constraints. + The algorithm is ADMM_QP and it solves equation (15). """ RiskBudgetingWithER.__init__(self, cov=cov, budgets=budgets, pi=pi, c=c) @@ -340,21 +338,15 @@ def solve(self): logging.exception("Problem not solved: " + str(e)) def get_risk_contributions(self, scale=True): - """ - Return the risk contribution. If the solver is "admm_qp" the mean variance risk - measure is considered. - - Parameters - ---------- - scale : bool - If True, the sum on risk contribution is scaled to one. + """Return the risk contribution. - Returns - ------- + If the solver is "admm_qp" the mean variance risk measure is considered. - RC : array, shape (n,) - Returns the risk contribution of each asset. + Args: + scale: If True, the sum on risk contribution is scaled to one. + Returns: + Risk contribution of each asset, shape (n,). """ x = self.x cov = self.cov diff --git a/src/pyrb/solvers.py b/src/pyrb/solvers.py index c390bd2..1e287b2 100644 --- a/src/pyrb/solvers.py +++ b/src/pyrb/solvers.py @@ -9,21 +9,18 @@ @numba.njit def accelarate(_varphi, r, s, u, alpha=10, tau=2): - """ - Update varphy and dual error for accelerating convergence after ADMM steps. - - Parameters - ---------- - _varphi - r: primal_error. - s: dual error. - u: primal_error. - alpha: error treshld. - tau: scaling parameter. - - Returns - ------- - updated varphy and primal_error. + """Update varphy and dual error for accelerating convergence after ADMM steps. + + Args: + _varphi: Current varphi value. + r: Primal error. + s: Dual error. + u: Primal error. + alpha: Error threshold (default 10). + tau: Scaling parameter (default 2). + + Returns: + tuple: Updated varphi and primal_error. """ primal_error = np.sum(r**2) @@ -64,40 +61,29 @@ def _cycle(x, c, var, _varphi, sigma_x, Sx, budgets, pi, bounds, lambda_log, cov def solve_rb_ccd( cov, budgets=None, pi=None, c=1.0, bounds=None, lambda_log=1.0, _varphi=0.0 ): - """ - Solve the risk budgeting problem for standard deviation risk-based measure with bounds constraints using cyclical - coordinate descent (CCD). It is corresponding to solve equation (17) in the paper. - - By default the function solve the ERC portfolio or the RB portfolio if budgets are given. - - Parameters - ---------- - cov : array, shape (n, n) - Covariance matrix of the returns. - - budgets : array, shape (n,) - Risk budgets for each asset (the default is None which implies equal risk budget). - - pi : array, shape (n,) - Expected excess return for each asset (the default is None which implies 0 for each asset). - - c : float - Risk aversion parameter equals to one by default. - - bounds : array, shape (n, 2) - Array of minimum and maximum bounds. If None the default bounds are [0,1]. - - lambda_log : float - Log penalty parameter. - - _varphi : float - This parameters is only useful for solving ADMM-CCD algorithm should be zeros otherwise. - - Returns - ------- - x : aray shape(n,) - The array of optimal solution. - + """Solve the risk budgeting problem using cyclical coordinate descent. + + Solves the risk budgeting problem for standard deviation risk-based measure with + bounds constraints using cyclical coordinate descent (CCD). It corresponds to + solving equation (17) in the paper. + + By default the function solves the ERC portfolio or the RB portfolio if budgets are given. + + Args: + cov: Covariance matrix of the returns, shape (n, n). + budgets: Risk budgets for each asset, shape (n,). + Default is None which implies equal risk budget. + pi: Expected excess return for each asset, shape (n,). + Default is None which implies 0 for each asset. + c: Risk aversion parameter, default is 1. + bounds: Array of minimum and maximum bounds, shape (n, 2). + If None the default bounds are [0,1]. + lambda_log: Log penalty parameter. + _varphi: This parameter is only useful for solving ADMM-CCD algorithm, + should be zeros otherwise. + + Returns: + Optimal solution array, shape (n,). """ n = cov.shape[0] diff --git a/src/pyrb/tools.py b/src/pyrb/tools.py index 4943519..c212e4f 100644 --- a/src/pyrb/tools.py +++ b/src/pyrb/tools.py @@ -3,7 +3,17 @@ def to_column_matrix(x): - """Return x as a matrix columns.""" + """Return x as a matrix columns. + + Args: + x: Input array to convert to column matrix format. + + Returns: + Array reshaped as a column matrix. + + Raises: + ValueError: If x is not a vector. + """ x = np.asarray(x) if x.ndim == 1: x = x.reshape(-1, 1) @@ -20,7 +30,14 @@ def to_column_matrix(x): def to_array(x): - """Turn a columns or row matrix to an array.""" + """Turn a columns or row matrix to an array. + + Args: + x: Input matrix to convert to array format. + + Returns: + Array squeezed from matrix format, or None if x is None. + """ if x is None: return None elif (len(x.shape)) == 1: @@ -32,7 +49,20 @@ def to_array(x): def quadprog_solve_qp(P, q, G=None, h=None, A=None, b=None, bounds=None): - """Quadprog helper.""" + """Quadprog helper for solving quadratic programming problems. + + Args: + P: Quadratic term matrix. + q: Linear term vector. + G: Inequality constraint matrix (optional). + h: Inequality constraint vector (optional). + A: Equality constraint matrix (optional). + b: Equality constraint vector (optional). + bounds: Variable bounds (optional). + + Returns: + Solution vector from quadratic programming solver. + """ n = P.shape[0] if bounds is not None: identity = np.eye(n) @@ -61,7 +91,19 @@ def quadprog_solve_qp(P, q, G=None, h=None, A=None, b=None, bounds=None): def proximal_polyhedra(y, C, d, bound, A=None, b=None): - """Wrapper for projecting a vector on the constrained set.""" + """Wrapper for projecting a vector on the constrained set. + + Args: + y: Vector to project. + C: Constraint matrix. + d: Constraint vector. + bound: Variable bounds. + A: Additional constraint matrix (optional). + b: Additional constraint vector (optional). + + Returns: + Projected vector on the constrained set. + """ n = len(y) return quadprog_solve_qp( np.eye(n), np.array(y), np.array(C), np.array(d), A=A, b=b, bounds=bound diff --git a/src/pyrb/validation.py b/src/pyrb/validation.py index 624f327..d57fe9e 100644 --- a/src/pyrb/validation.py +++ b/src/pyrb/validation.py @@ -4,6 +4,14 @@ def check_covariance(cov): + """Check if the covariance matrix is valid. + + Args: + cov: Covariance matrix to validate. + + Raises: + ValueError: If matrix is not square or contains missing values. + """ if cov.shape[0] != cov.shape[1]: raise ValueError("The covariance matrix is not squared") if np.isnan(cov).sum().sum() > 0: @@ -11,6 +19,15 @@ def check_covariance(cov): def check_expected_return(mu, n): + """Check if the expected return vector is valid. + + Args: + mu: Expected return vector to validate. + n: Number of assets. + + Raises: + ValueError: If vector size doesn't match number of assets or contains missing values. + """ if mu is None: return if n != len(mu): @@ -22,6 +39,16 @@ def check_expected_return(mu, n): def check_constraints(C, d, n): + """Check if the constraint matrix and vector are valid. + + Args: + C: Constraint matrix. + d: Constraint vector. + n: Number of assets. + + Raises: + ValueError: If matrix dimensions don't match or contain missing values. + """ if C is None: return if n != C.shape[1]: @@ -31,6 +58,15 @@ def check_constraints(C, d, n): def check_bounds(bounds, n): + """Check if the bounds array is valid. + + Args: + bounds: Bounds array to validate. + n: Number of assets. + + Raises: + ValueError: If bounds dimensions don't match or contain invalid values. + """ if bounds is None: return if n != bounds.shape[0]: @@ -44,6 +80,15 @@ def check_bounds(bounds, n): def check_risk_budget(riskbudgets, n): + """Check if the risk budget vector is valid. + + Args: + riskbudgets: Risk budget vector to validate. + n: Number of assets. + + Raises: + ValueError: If vector size doesn't match number of assets or contains invalid values. + """ if riskbudgets is None: return if np.isnan(riskbudgets).sum() > 0: From 42d32861f0b6915351ea1f26967308b343e7fbbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:19:35 +0000 Subject: [PATCH 4/4] Fix matrix multiplication bug and dependency conflicts Co-authored-by: fchareyr <3171960+fchareyr@users.noreply.github.com> --- pyproject.toml | 6 +++--- src/pyrb/allocation.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2d468cd..ae964a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,11 @@ classifiers = [ "Topic :: Scientific/Engineering :: Mathematics", ] dependencies = [ - "pandas>=2.3.1", - "numba>=0.61.2", + "pandas>=2.3.0", + "numba>=0.61.0", "quadprog>=0.1.13", "scipy>=1.16.0", - "numpy>=2.3.1", + "numpy>=2.2.0", ] [dependency-groups] diff --git a/src/pyrb/allocation.py b/src/pyrb/allocation.py index 9cb522c..21cb5bd 100644 --- a/src/pyrb/allocation.py +++ b/src/pyrb/allocation.py @@ -94,7 +94,7 @@ def get_expected_return(self): else: x = self.x x = tools.to_column_matrix(x) - return float(x.T * self.pi) + return float(x.T @ self.pi) def __str__(self): return (