Skip to main content

quantwave_core/portfolio/
hrp.rs

1//! Hierarchical Risk Parity (Lopez de Prado), scipy-free single-linkage port.
2//!
3//! Source: Lopez de Prado, M. (2016), "Building Diversified Portfolios that
4//! Outperform Out-of-Sample", Journal of Portfolio Management. Pipeline:
5//! correlation -> distance, single-linkage hierarchical clustering,
6//! quasi-diagonalization (seriation), then recursive bisection allocation
7//! using inverse-variance sub-portfolios. Direct port of the Python v1's
8//! `hrp` / `_single_linkage` / `_linkage_order` / `_hrp_bisection`.
9//!
10//! The single-linkage step here is the same O(n^2 log n)-per-merge (O(n^3)
11//! total) nearest-fragment scan as the Python v1 -- deliberately simple,
12//! matching the original "scipy-free" design note; a proper O(n^2) SLINK
13//! implementation is a possible follow-up for large universes but was not
14//! needed to match the v1's behavior (see task report).
15
16use 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/// Interpretation of the input matrix passed to [`hrp`].
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub enum HrpInputKind {
26    /// A `(T, N)` returns matrix (sample covariance is computed internally).
27    #[default]
28    Returns,
29    /// A precomputed `(N, N)` covariance matrix.
30    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
80/// One linkage row: `(cluster_a, cluster_b, distance, size)`.
81type LinkageRow = (usize, usize, f64, usize);
82
83/// Single-linkage agglomerative clustering on a full distance matrix.
84///
85/// Reimplements the core of
86/// `scipy.cluster.hierarchy.linkage(method="single")` with a simple
87/// nearest-fragment merge (no scipy dependency). Returns a SciPy-compatible
88/// linkage list where clusters `0..n-1` are the original items and `n+i` is
89/// the cluster created at merge step `i`.
90fn 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        // `alive` always has >=2 members here (loop guard) and `d` holds a
121        // finite distance for every pair still alive, so `best` is always
122        // populated; fall back to a defensive first-pair pick rather than
123        // panicking if that invariant is ever violated.
124        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
152/// Quasi-diagonalization: recover leaf order from a linkage list.
153///
154/// Standard "seriation" walk used by HRP: recursively expand each merge
155/// into its two children until only original leaves (`< n`) remain.
156fn 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
172/// Recursive bisection allocation over quasi-diagonalized order.
173fn 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    // Original-asset-index -> position in `sorted_idx`, built once so the
178    // per-cluster weight update below is a lookup rather than a linear scan.
179    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
238/// Hierarchical Risk Parity (Lopez de Prado) portfolio weights.
239///
240/// `returns_or_cov` is either a `(T, N)` returns matrix (`input_kind = HrpInputKind::Returns`,
241/// sample covariance is computed internally) or a precomputed `(N, N)`
242/// covariance matrix (`input_kind = HrpInputKind::Covariance`). `bounds` are optional per-asset
243/// `(low, high)` bounds applied to the final weights via capped-simplex
244/// projection. Direct port of `hrp` in the Python v1.
245pub 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        // 4-asset covariance with a clear 2-cluster block structure.
314        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        // Fully uncorrelated -> single-linkage merge order is arbitrary
348        // among ties, but bisection on independent variances should still
349        // sum to 1 and be strictly positive.
350        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}