Coverage for src/cvxmarkowitz/portfolios/soft_risk.py: 100%
28 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 05:21 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 05:21 +0000
1# Copyright 2023 Stanford University Convex Optimization Group
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Portfolio builder with soft risk penalty allowing target risk relaxation."""
16from __future__ import annotations
18from dataclasses import dataclass, field
20import cvxpy as cp
22from cvxmarkowitz.builder import Builder
23from cvxmarkowitz.models.expected_returns import ExpectedReturns
24from cvxmarkowitz.names import ConstraintName as C
25from cvxmarkowitz.names import ModelName as M
26from cvxmarkowitz.names import ParameterName as P
29@dataclass(frozen=True)
30class SoftRisk(Builder):
31 """Maximize w^T mu minus a soft penalty on excess risk.
33 The objective is maximize w^T mu - omega * (sigma - sigma_target)_+
34 subject to long-only, budget, and max-risk constraints.
35 """
37 _sigma: cp.Variable = field(default_factory=lambda: cp.Variable(nonneg=True, name="sigma"))
38 _sigma_target_times_omega: cp.CallbackParam = field(
39 default_factory=lambda: cp.CallbackParam(
40 callback=lambda _p, _q: 0.0, nonneg=True, name="sigma_target_times_omega"
41 )
42 )
44 @property
45 def objective(self) -> cp.Maximize:
46 """Return the CVXPY objective for soft-risk maximization."""
47 expected_return = self.model[M.RETURN].estimate(self.variables)
48 soft_risk = cp.pos(self.parameter[P.OMEGA] * self._sigma - self._sigma_target_times_omega)
49 return cp.Maximize(expected_return - soft_risk)
51 def __post_init__(self) -> None:
52 """Initialize models, parameters, and constraints for soft-risk portfolio."""
53 super().__post_init__()
55 self.model[M.RETURN] = ExpectedReturns(assets=self.assets)
57 self.parameter[P.SIGMA_MAX] = cp.Parameter(nonneg=True, name="limit volatility")
59 self.parameter[P.SIGMA_TARGET] = cp.Parameter(nonneg=True, name="target volatility")
61 self.parameter[P.OMEGA] = cp.Parameter(nonneg=True, name="risk priority")
62 self._sigma_target_times_omega._callback = lambda: (
63 self.parameter[P.SIGMA_TARGET].value * self.parameter[P.OMEGA].value # ty: ignore[unsupported-operator]
64 )
66 self.constraints[C.LONG_ONLY] = self.weights >= 0
67 self.constraints[C.BUDGET] = cp.sum(self.weights) == 1.0
68 self.constraints[C.RISK] = self.risk.estimate(self.variables) <= self._sigma
69 self.constraints["max_risk"] = self._sigma <= self.parameter[P.SIGMA_MAX]