Coverage for src/pyhrp/algos.py: 100%

84 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-09-15 05:20 +0000

1"""Portfolio optimization algorithms for hierarchical risk parity. 

2 

3This module implements various portfolio optimization algorithms: 

4- risk_parity: The main hierarchical risk parity algorithm 

5- schur_risk_parity: Schur Complementary Allocation (Cotton, arXiv:2411.05807) 

6- one_over_n: A simple equal-weight allocation strategy 

7 

8Allocator contract 

9------------------ 

10All three allocators take the same inputs — a ``Cluster`` tree (``root``) plus 

11the asset names — and none of them changes the *shape* of that tree: no node is 

12added, removed or re-parented. They differ in where the weights land. 

13 

14``risk_parity`` and ``schur_risk_parity`` share the recursive ``_allocate_with`` 

15scaffolding and write **into the tree they are given**: every node's 

16``portfolio`` is replaced rather than accumulated into, and the same root object 

17is returned. Replacing makes them idempotent — re-running on an already weighted 

18tree, with a different covariance matrix or gamma, gives the same answer as 

19running on a fresh one — but the caller's tree *is* modified. Note in particular 

20that ``Dendrogram`` is a frozen dataclass while the ``Cluster`` it holds is not: 

21passing ``dendrogram.root`` to either allocator rewrites the portfolios inside 

22that dendrogram. Pass ``copy.deepcopy(dendrogram.root)`` to keep the original 

23tree unweighted. 

24 

25``one_over_n`` differs on both counts. It accumulates into a local buffer, so it 

26leaves the input tree untouched entirely, and its *output* is a generator 

27yielding the equal-weight portfolio one tree level at a time (see its 

28docstring), because its purpose is to expose the allocation as it deepens rather 

29than a single final result. 

30""" 

31 

32from __future__ import annotations 

33 

34from collections.abc import Callable, Generator 

35from copy import deepcopy 

36 

37import numpy as np 

38import polars as pl 

39 

40from .cluster import Cluster, Portfolio 

41 

42__all__ = ["one_over_n", "risk_parity", "schur_risk_parity"] 

43 

44 

45def risk_parity(root: Cluster, cov: pl.DataFrame) -> Cluster: 

46 """Compute hierarchical risk parity weights for a cluster tree. 

47 

48 This is the main algorithm for hierarchical risk parity. It recursively 

49 traverses the cluster tree and assigns weights to each node based on 

50 the risk parity principle. 

51 

52 Note: 

53 The tree is modified in place: the portfolio of every node is rebuilt 

54 from scratch and ``root`` itself is returned, so the function is 

55 idempotent and a tree can be reused with a different covariance matrix. 

56 Deep-copy the tree first if you need to keep it unweighted. 

57 

58 Args: 

59 root (Cluster): The root node of the cluster tree 

60 cov (pl.DataFrame): Covariance matrix of asset returns 

61 

62 Returns: 

63 Cluster: The same root node, with portfolio weights assigned 

64 

65 Examples: 

66 >>> import polars as pl 

67 >>> from pyhrp.cluster import Cluster 

68 >>> from pyhrp.algos import risk_parity 

69 >>> cov = pl.DataFrame({"A": [4.0, 0.0], "B": [0.0, 1.0]}) 

70 >>> root = Cluster(2, left=Cluster(0), right=Cluster(1)) 

71 >>> cluster = risk_parity(root=root, cov=cov) 

72 >>> round(cluster.portfolio["B"], 1) 

73 0.8 

74 """ 

75 

76 def node_variances(left: Cluster, right: Cluster, cov_np: np.ndarray, index: dict[str, int]) -> tuple[float, float]: 

77 """Plain block variance of each child sub-portfolio.""" 

78 return ( 

79 _block_variance(left.portfolio, cov_np, index), 

80 _block_variance(right.portfolio, cov_np, index), 

81 ) 

82 

83 return _allocate_with(root, cov, node_variances) 

84 

85 

86def schur_risk_parity(root: Cluster, cov: pl.DataFrame, gamma: float = 0.5) -> Cluster: 

87 """Compute Schur Complementary Allocation weights for a cluster tree. 

88 

89 An extension of HRP introduced by Peter Cotton (arXiv:2411.05807) that augments 

90 sub-covariance matrices with off-diagonal block information via Schur complements. 

91 At gamma=0 this recovers standard HRP; at gamma=1 it recovers the minimum-variance 

92 portfolio through the same recursive structure. 

93 

94 Note: 

95 The tree is modified in place: the portfolio of every node is rebuilt 

96 from scratch and ``root`` itself is returned, so the function is 

97 idempotent and a tree can be reused with a different covariance matrix 

98 or gamma. Deep-copy the tree first if you need to keep it unweighted. 

99 

100 Args: 

101 root (Cluster): The root node of the cluster tree 

102 cov (pl.DataFrame): Covariance matrix of asset returns 

103 gamma (float): Interpolation parameter in [0, 1]. 0 = HRP, 1 = minimum variance. 

104 

105 Returns: 

106 Cluster: The same root node, with portfolio weights assigned 

107 

108 Raises: 

109 ValueError: If gamma is outside the interval [0, 1]. 

110 

111 Examples: 

112 >>> import polars as pl 

113 >>> from pyhrp.cluster import Cluster 

114 >>> from pyhrp.algos import schur_risk_parity 

115 >>> cov = pl.DataFrame({"A": [4.0, 0.0], "B": [0.0, 1.0]}) 

116 >>> root = Cluster(2, left=Cluster(0), right=Cluster(1)) 

117 >>> cluster = schur_risk_parity(root=root, cov=cov, gamma=0.5) 

118 >>> round(cluster.portfolio["B"], 1) 

119 0.8 

120 """ 

121 if not 0.0 <= gamma <= 1.0: 

122 msg = f"gamma must be in [0, 1], got {gamma}" 

123 raise ValueError(msg) 

124 

125 def node_variances(left: Cluster, right: Cluster, cov_np: np.ndarray, index: dict[str, int]) -> tuple[float, float]: 

126 """Schur-augmented block variance of each child, conditioned on the other.""" 

127 li = [index[a] for a in left.portfolio.assets] 

128 ri = [index[a] for a in right.portfolio.assets] 

129 

130 a_mat = cov_np[np.ix_(li, li)] 

131 b_mat = cov_np[np.ix_(li, ri)] 

132 d_mat = cov_np[np.ix_(ri, ri)] 

133 

134 w_left = np.array([left.portfolio[a] for a in left.portfolio.assets]) 

135 w_right = np.array([right.portfolio[a] for a in right.portfolio.assets]) 

136 

137 # Schur-augmented blocks: condition each group on the other 

138 a_aug = a_mat - gamma * (b_mat @ _solve(d_mat, b_mat.T)) 

139 d_aug = d_mat - gamma * (b_mat.T @ _solve(a_mat, b_mat)) 

140 

141 v_left = float(w_left @ a_aug @ w_left) 

142 v_right = float(w_right @ d_aug @ w_right) 

143 return v_left, v_right 

144 

145 return _allocate_with(root, cov, node_variances) 

146 

147 

148# Given a node's two children (plus the precomputed covariance array and the 

149# column->row index), return the (v_left, v_right) risk pair used to split it. 

150NodeVariances = Callable[[Cluster, Cluster, np.ndarray, dict[str, int]], tuple[float, float]] 

151 

152 

153def _allocate_with(root: Cluster, cov: pl.DataFrame, node_variances: NodeVariances) -> Cluster: 

154 """Shared scaffolding for the recursive risk-based allocators. 

155 

156 Builds the numpy covariance array and column index once, then walks the tree 

157 bottom-up, splitting each node's weight between its children inversely to the 

158 ``(v_left, v_right)`` pair supplied by ``node_variances``. The only thing that 

159 distinguishes ``risk_parity`` from ``schur_risk_parity`` is that per-node 

160 variance rule; everything else — the ``cov``/``index`` setup, the combine 

161 wrapper, and the rebuild-from-scratch traversal — lives here. 

162 

163 Args: 

164 root (Cluster): The root node of the cluster tree. 

165 cov (pl.DataFrame): Covariance matrix of asset returns. 

166 node_variances (NodeVariances): Per-node rule mapping a node's left/right 

167 children (and the precomputed covariance array and column index) to 

168 the ``(v_left, v_right)`` risk pair used to split that node. 

169 

170 Returns: 

171 Cluster: The root node with portfolio weights assigned. 

172 """ 

173 cov_np = cov.to_numpy() 

174 index = {name: i for i, name in enumerate(cov.columns)} 

175 

176 def combine(cluster: Cluster) -> Cluster: 

177 """Combine the child portfolios of a cluster via an inverse-variance split.""" 

178 left, right = cluster._child_clusters() 

179 v_left, v_right = node_variances(left, right, cov_np, index) 

180 return _split(cluster, v_left, v_right) 

181 

182 return _allocate(root, cov.columns, combine) 

183 

184 

185def _allocate(root: Cluster, assets: list[str], combine: Callable[[Cluster], Cluster]) -> Cluster: 

186 """Traverse the tree bottom-up, assigning leaf portfolios and combining children. 

187 

188 Every node's portfolio is replaced, never accumulated into, which keeps 

189 repeated allocations on the same tree idempotent. 

190 

191 Args: 

192 root (Cluster): The (sub)tree to allocate weights for 

193 assets (list[str]): Asset names; a leaf's value indexes into this list 

194 combine (Callable[[Cluster], Cluster]): Combines the two child portfolios 

195 of a node into the node's own portfolio 

196 

197 Returns: 

198 Cluster: The input node with portfolio weights assigned 

199 """ 

200 # Iterative post-order rather than recursion: a chain-degenerate tree is as deep 

201 # as the universe is wide, and recursing here capped the number of assets that 

202 # could be allocated. Pass one collects nodes parent-before-child (validating 

203 # each non-leaf as it goes, so a malformed tree still raises before any weight is 

204 # written); reversing that order guarantees both children hold a portfolio before 

205 # their parent combines them. 

206 order: list[Cluster] = [] 

207 stack: list[Cluster] = [root] 

208 while stack: 

209 node = stack.pop() 

210 order.append(node) 

211 if not node.is_leaf: 

212 left, right = node._child_clusters() 

213 stack.append(left) 

214 stack.append(right) 

215 

216 for node in reversed(order): 

217 if node.is_leaf: 

218 node.portfolio = Portfolio() 

219 node.portfolio[assets[int(node.value)]] = 1.0 

220 else: 

221 # combine() replaces node.portfolio in place; the return value is the 

222 # same node, which is why the recursive form's reassignment of 

223 # root.left/root.right was always a no-op. 

224 combine(node) 

225 

226 return root 

227 

228 

229def _block_variance(portfolio: Portfolio, cov_np: np.ndarray, index: dict[str, int]) -> float: 

230 """Compute the variance of a portfolio against a precomputed covariance array.""" 

231 assets = portfolio.assets 

232 idx = [index[a] for a in assets] 

233 w = np.array([portfolio[a] for a in assets]) 

234 return float(w @ cov_np[np.ix_(idx, idx)] @ w) 

235 

236 

237def _split(cluster: Cluster, v_left: float, v_right: float) -> Cluster: 

238 """Distribute weight between the two children inversely proportional to risk. 

239 

240 The split satisfies v_left * alpha_left == v_right * alpha_right with 

241 alpha_left + alpha_right == 1. If both variances are zero (e.g. riskless 

242 sub-portfolios), the weight is split equally. 

243 

244 Args: 

245 cluster (Cluster): The parent cluster with left and right children 

246 v_left (float): Variance of the left sub-portfolio 

247 v_right (float): Variance of the right sub-portfolio 

248 

249 Returns: 

250 Cluster: The parent cluster with portfolio weights assigned 

251 """ 

252 left, right = cluster._child_clusters() 

253 total = v_left + v_right 

254 alpha_left = v_right / total if total > 0 else 0.5 

255 alpha_right = 1.0 - alpha_left 

256 

257 cluster.portfolio = Portfolio() 

258 for asset, weight in left.portfolio.weights.items(): 

259 cluster.portfolio[asset] = alpha_left * weight 

260 for asset, weight in right.portfolio.weights.items(): 

261 cluster.portfolio[asset] = alpha_right * weight 

262 

263 return cluster 

264 

265 

266def _solve(m: np.ndarray, b: np.ndarray) -> np.ndarray: 

267 """Solve m @ x = b, falling back to least squares for singular matrices. 

268 

269 Covariance blocks of collinear assets are singular; the minimum-norm 

270 least-squares solution keeps the Schur augmentation well-defined there. 

271 """ 

272 try: 

273 return np.linalg.solve(m, b) 

274 except np.linalg.LinAlgError: 

275 return np.asarray(np.linalg.lstsq(m, b, rcond=None)[0]) 

276 

277 

278def one_over_n(root: Cluster, assets: list[str]) -> Generator[tuple[int, Portfolio]]: 

279 """Generate 1/N (equal-weight) portfolios one tree level at a time. 

280 

281 This implements a hierarchical 1/N strategy where weights are distributed 

282 equally among the leaves of each cluster, and the weight budget halves at 

283 each successive level of the tree. 

284 

285 Unlike :func:`risk_parity` and :func:`schur_risk_parity` — which rebuild a 

286 single final allocation and return the root ``Cluster`` — this allocator is 

287 intentionally a **generator**: its purpose is to expose the equal-weight 

288 allocation as the tree deepens, yielding one portfolio per level. It shares 

289 the sibling input contract (a ``Cluster`` tree plus the asset names) and, like 

290 them, does not mutate the tree: weights accumulate in a local buffer, so a 

291 leaf that terminates at a shallow level keeps its weight in the deeper levels 

292 (each yielded portfolio is therefore a complete allocation over all assets), 

293 and re-running on the same tree yields an identical sequence. 

294 

295 Args: 

296 root (Cluster): The root node of the cluster tree. 

297 assets (list[str]): Asset names; a leaf's value indexes into this list. 

298 

299 Yields: 

300 tuple[int, Portfolio]: The level number and the (cumulative) equal-weight 

301 portfolio at that level. 

302 

303 Examples: 

304 >>> import polars as pl 

305 >>> from pyhrp.hrp import build_tree 

306 >>> from pyhrp.algos import one_over_n 

307 >>> cor = pl.DataFrame({"A": [1.0, 0.3], "B": [0.3, 1.0]}) 

308 >>> dg = build_tree(cor, method="ward") 

309 >>> levels = list(one_over_n(dg.root, dg.assets)) 

310 >>> len(levels) > 0 

311 True 

312 """ 

313 # Accumulate into a local buffer so the input tree is never mutated. 

314 portfolio = Portfolio() 

315 

316 # Initial weight to distribute 

317 w: float = 1.0 

318 

319 # Process each level of the tree 

320 for n, level in enumerate(root.levels): 

321 for node in level: 

322 # Distribute weight equally among all leaves in this node 

323 for leaf in node.leaves: 

324 portfolio[assets[leaf.value]] = w / node.leaf_count 

325 

326 # Reduce weight for the next level 

327 w *= 0.5 

328 

329 # Yield the current level number and a deep copy of the portfolio 

330 yield n, deepcopy(portfolio)