quantwave_core/portfolio/
hrp.rs1use super::covariance::{check_cov, check_returns_matrix};
17use super::projection::{PortfolioConstraint, apply_bounds_and_budget};
18use super::{Bounds, PortfolioError, PortfolioResult};
19use nalgebra::DMatrix;
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, HashSet};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub enum HrpInputKind {
26 #[default]
28 Returns,
29 Covariance,
31}
32
33impl HrpInputKind {
34 pub const fn is_covariance(self) -> bool {
35 matches!(self, Self::Covariance)
36 }
37
38 pub const fn is_returns(self) -> bool {
39 matches!(self, Self::Returns)
40 }
41}
42
43impl From<bool> for HrpInputKind {
44 fn from(is_cov: bool) -> Self {
45 if is_cov {
46 Self::Covariance
47 } else {
48 Self::Returns
49 }
50 }
51}
52
53impl From<HrpInputKind> for bool {
54 fn from(kind: HrpInputKind) -> Self {
55 kind.is_covariance()
56 }
57}
58
59fn corr_from_cov(cov: &DMatrix<f64>) -> DMatrix<f64> {
60 let n = cov.nrows();
61 let std: Vec<f64> = (0..n)
62 .map(|i| {
63 let s = cov[(i, i)].sqrt();
64 if s <= 0.0 { 1e-12 } else { s }
65 })
66 .collect();
67 let mut corr = DMatrix::<f64>::zeros(n, n);
68 for i in 0..n {
69 for j in 0..n {
70 let v = cov[(i, j)] / (std[i] * std[j]);
71 corr[(i, j)] = v.clamp(-1.0, 1.0);
72 }
73 }
74 for i in 0..n {
75 corr[(i, i)] = 1.0;
76 }
77 corr
78}
79
80type LinkageRow = (usize, usize, f64, usize);
82
83fn single_linkage(dist: &DMatrix<f64>) -> Vec<LinkageRow> {
91 let n = dist.nrows();
92 let mut next_id = n;
93 let mut linkage: Vec<LinkageRow> = Vec::with_capacity(n.saturating_sub(1));
94
95 let mut id_to_size: HashMap<usize, usize> = (0..n).map(|i| (i, 1)).collect();
96 let mut alive: HashSet<usize> = (0..n).collect();
97 let mut d: HashMap<(usize, usize), f64> = HashMap::new();
98 for i in 0..n {
99 for j in (i + 1)..n {
100 d.insert((i, j), dist[(i, j)]);
101 }
102 }
103
104 while alive.len() > 1 {
105 let mut alive_list: Vec<usize> = alive.iter().copied().collect();
106 alive_list.sort_unstable();
107
108 let mut best: Option<(usize, usize)> = None;
109 let mut best_d = f64::INFINITY;
110 for ai in 0..alive_list.len() {
111 for bi in (ai + 1)..alive_list.len() {
112 let (a, b) = (alive_list[ai], alive_list[bi]);
113 let key = if a < b { (a, b) } else { (b, a) };
114 if d.get(&key).is_some_and(|&dd| dd < best_d) {
115 best_d = d[&key];
116 best = Some((a, b));
117 }
118 }
119 }
120 let (a, b) = best.unwrap_or((alive_list[0], alive_list[1]));
125 let size = id_to_size[&a] + id_to_size[&b];
126 linkage.push((a, b, best_d, size));
127
128 let new_id = next_id;
129 next_id += 1;
130 id_to_size.insert(new_id, size);
131
132 alive.remove(&a);
133 alive.remove(&b);
134 for &c in &alive_list {
135 if c == a || c == b {
136 continue;
137 }
138 let key_a = if a < c { (a, c) } else { (c, a) };
139 let key_b = if b < c { (b, c) } else { (c, b) };
140 let d_a = d.get(&key_a).copied().unwrap_or(f64::INFINITY);
141 let d_b = d.get(&key_b).copied().unwrap_or(f64::INFINITY);
142 let d_new = d_a.min(d_b);
143 let key_new = if new_id < c { (new_id, c) } else { (c, new_id) };
144 d.insert(key_new, d_new);
145 }
146 alive.insert(new_id);
147 }
148
149 linkage
150}
151
152fn linkage_order(linkage: &[LinkageRow], n: usize) -> Vec<usize> {
157 fn expand(cluster_id: usize, n: usize, linkage: &[LinkageRow], out: &mut Vec<usize>) {
158 if cluster_id < n {
159 out.push(cluster_id);
160 return;
161 }
162 let (a, b, _, _) = linkage[cluster_id - n];
163 expand(a, n, linkage, out);
164 expand(b, n, linkage, out);
165 }
166 let root = n - 1 + linkage.len();
167 let mut out = Vec::with_capacity(n);
168 expand(root, n, linkage, &mut out);
169 out
170}
171
172fn hrp_bisection(cov: &DMatrix<f64>, sorted_idx: &[usize]) -> Vec<f64> {
174 let n = sorted_idx.len();
175 let mut weights = vec![1.0_f64; n];
176 let mut clusters: Vec<Vec<usize>> = vec![sorted_idx.to_vec()];
177 let pos_of: HashMap<usize, usize> = sorted_idx
180 .iter()
181 .enumerate()
182 .map(|(pos, &idx)| (idx, pos))
183 .collect();
184
185 let cluster_var = |members: &[usize]| -> f64 {
186 let m = members.len();
187 let inv_diag: Vec<f64> = members.iter().map(|&i| 1.0 / cov[(i, i)]).collect();
188 let s: f64 = inv_diag.iter().sum();
189 let w: Vec<f64> = inv_diag.iter().map(|v| v / s).collect();
190 let mut var = 0.0_f64;
191 for a in 0..m {
192 for b in 0..m {
193 var += w[a] * cov[(members[a], members[b])] * w[b];
194 }
195 }
196 var
197 };
198
199 while !clusters.is_empty() {
200 let mut new_clusters = Vec::new();
201 for cluster in &clusters {
202 if cluster.len() <= 1 {
203 continue;
204 }
205 let mid = cluster.len() / 2;
206 let left = &cluster[..mid];
207 let right = &cluster[mid..];
208
209 let var_left = cluster_var(left);
210 let var_right = cluster_var(right);
211 let denom = var_left + var_right;
212 let alpha = if denom > 1e-18 {
213 1.0 - var_left / denom
214 } else {
215 0.5
216 };
217
218 for &idx in left {
219 if let Some(&pos) = pos_of.get(&idx) {
220 weights[pos] *= alpha;
221 }
222 }
223 for &idx in right {
224 if let Some(&pos) = pos_of.get(&idx) {
225 weights[pos] *= 1.0 - alpha;
226 }
227 }
228
229 new_clusters.push(left.to_vec());
230 new_clusters.push(right.to_vec());
231 }
232 clusters = new_clusters;
233 }
234
235 weights
236}
237
238pub fn hrp(
246 returns_or_cov: &DMatrix<f64>,
247 bounds: Option<&[Bounds]>,
248 input_kind: HrpInputKind,
249) -> PortfolioResult<nalgebra::DVector<f64>> {
250 let cov = match input_kind {
251 HrpInputKind::Covariance => {
252 check_cov(returns_or_cov)?;
253 returns_or_cov.clone()
254 }
255 HrpInputKind::Returns => {
256 check_returns_matrix(returns_or_cov)?;
257 super::sample_cov(returns_or_cov)?
258 }
259 };
260
261 let n = cov.nrows();
262 if n == 1 {
263 return apply_bounds_and_budget(
264 &nalgebra::DVector::from_element(1, 1.0),
265 bounds,
266 PortfolioConstraint::LongOnly,
267 );
268 }
269
270 let corr = corr_from_cov(&cov);
271 let mut dist = DMatrix::<f64>::zeros(n, n);
272 for i in 0..n {
273 for j in 0..n {
274 dist[(i, j)] = (((1.0 - corr[(i, j)]) / 2.0).clamp(0.0, 1.0)).sqrt();
275 }
276 }
277 for i in 0..n {
278 dist[(i, i)] = 0.0;
279 }
280
281 let linkage = single_linkage(&dist);
282 let order = linkage_order(&linkage, n);
283 let w_sorted = hrp_bisection(&cov, &order);
284
285 let mut w = vec![0.0_f64; n];
286 for (pos, &idx) in order.iter().enumerate() {
287 w[idx] = w_sorted[pos];
288 }
289
290 let total: f64 = w.iter().sum();
291 if total.abs() < 1e-12 || !w.iter().all(|v| v.is_finite()) {
292 return Err(PortfolioError::HrpDegenerate);
293 }
294 let w = nalgebra::DVector::from_vec(w.iter().map(|v| v / total).collect());
295 apply_bounds_and_budget(&w, bounds, PortfolioConstraint::LongOnly)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use nalgebra::DVector;
302
303 #[test]
304 fn hrp_single_asset_is_full_weight() {
305 let cov = DMatrix::from_row_slice(1, 1, &[0.04]);
306 let w = hrp(&cov, None, HrpInputKind::Covariance).unwrap();
307 assert_eq!(w.len(), 1);
308 assert!((w[0] - 1.0).abs() < 1e-12);
309 }
310
311 #[test]
312 fn hrp_weights_sum_to_one_and_nonnegative() {
313 let cov = DMatrix::from_row_slice(
315 4,
316 4,
317 &[
318 0.04, 0.03, 0.001, 0.0, 0.03, 0.05, 0.0, 0.001, 0.001, 0.0, 0.02, 0.015, 0.0,
319 0.001, 0.015, 0.03,
320 ],
321 );
322 let w = hrp(&cov, None, HrpInputKind::Covariance).unwrap();
323 assert!((w.sum() - 1.0).abs() < 1e-9);
324 assert!(w.iter().all(|v| *v >= -1e-9));
325 }
326
327 #[test]
328 fn hrp_from_returns_matches_hrp_from_sample_cov() {
329 let returns = DMatrix::from_row_slice(
330 6,
331 3,
332 &[
333 0.01, 0.02, -0.01, 0.015, -0.005, 0.02, -0.02, 0.01, 0.005, 0.005, 0.03, -0.01,
334 0.0, 0.01, 0.015, 0.012, -0.004, 0.006,
335 ],
336 );
337 let cov = super::super::sample_cov(&returns).unwrap();
338 let w_from_returns = hrp(&returns, None, HrpInputKind::Returns).unwrap();
339 let w_from_cov = hrp(&cov, None, HrpInputKind::Covariance).unwrap();
340 for i in 0..3 {
341 assert!((w_from_returns[i] - w_from_cov[i]).abs() < 1e-9);
342 }
343 }
344
345 #[test]
346 fn hrp_diagonal_cov_two_clusters_equal_split() {
347 let cov =
351 DMatrix::<f64>::from_diagonal(&DVector::from_row_slice(&[0.01, 0.02, 0.03, 0.04]));
352 let w = hrp(&cov, None, HrpInputKind::Covariance).unwrap();
353 assert!((w.sum() - 1.0).abs() < 1e-9);
354 assert!(w.iter().all(|v| *v > 0.0));
355 }
356}