Skip to content

API Reference

The public API of pyhrp. Import the main entry points directly from the top-level package:

from pyhrp.hrp import hrp, build_tree, Dendrogram
from pyhrp.algos import risk_parity, one_over_n
from pyhrp.cluster import Cluster, Portfolio

Hierarchical Risk Parity (HRP) allocation entry points.

This module owns the top-level allocation functions: - hrp: Compute HRP portfolio weights from prices - schur_hrp: Compute Schur Complementary Allocation weights from prices

For backward compatibility the supporting building blocks stay importable from pyhrp.hrp, but they are owned (and exported) by the modules that define them: - build_tree, Dendrogram: see :mod:pyhrp.dendrogram - compute_returns, compute_cov, compute_corr: see :mod:pyhrp.covariance

hrp(prices, node=None, method='ward', bisection=False)

Compute the hierarchical risk parity portfolio weights.

This is the main entry point for the HRP algorithm. It calculates returns from prices, builds a hierarchical clustering tree if not provided, and applies risk parity weights.

Note

A node passed in is weighted in place and returned; see the allocator contract in :mod:pyhrp.algos. Passing dendrogram.root therefore rewrites the portfolios inside that Dendrogram, even though the Dendrogram itself is a frozen dataclass.

Parameters:

Name Type Description Default
prices DataFrame

Asset price time series (columns are assets, rows are dates)

required
node Cluster

Root node of the hierarchical clustering tree. If None, a tree will be built from the correlation matrix.

None
method Literal['single', 'complete', 'average', 'ward']

Linkage method to use for distance calculation - "single": minimum distance between points (nearest neighbor) - "complete": maximum distance between points (furthest neighbor) - "average": average distance between all points - "ward": Ward variance minimization

'ward'
bisection bool

Whether to use bisection method for tree construction

False

Returns:

Name Type Description
Cluster Cluster

The root cluster with portfolio weights assigned according to HRP

Source code in src/pyhrp/hrp.py
def hrp(
    prices: pl.DataFrame,
    node: Cluster | None = None,
    method: Method = "ward",
    bisection: bool = False,
) -> Cluster:
    """Compute the hierarchical risk parity portfolio weights.

    This is the main entry point for the HRP algorithm. It calculates returns from prices,
    builds a hierarchical clustering tree if not provided, and applies risk parity weights.

    Note:
        A ``node`` passed in is weighted in place and returned; see the allocator
        contract in :mod:`pyhrp.algos`. Passing ``dendrogram.root`` therefore
        rewrites the portfolios inside that ``Dendrogram``, even though the
        ``Dendrogram`` itself is a frozen dataclass.

    Args:
        prices (pl.DataFrame): Asset price time series (columns are assets, rows are dates)
        node (Cluster, optional): Root node of the hierarchical clustering tree.
            If None, a tree will be built from the correlation matrix.
        method (Literal["single", "complete", "average", "ward"]): Linkage method to use for distance calculation
            - "single": minimum distance between points (nearest neighbor)
            - "complete": maximum distance between points (furthest neighbor)
            - "average": average distance between all points
            - "ward": Ward variance minimization
        bisection (bool): Whether to use bisection method for tree construction

    Returns:
        Cluster: The root cluster with portfolio weights assigned according to HRP
    """
    cov, node = _prepare(prices, node, method, bisection)
    return risk_parity(root=node, cov=cov)

schur_hrp(prices, node=None, method='ward', bisection=False, gamma=0.5)

Compute Schur Complementary Allocation portfolio weights.

Extends HRP by augmenting each sub-covariance block with off-diagonal information via Schur complements before splitting risk between clusters. Introduced by Peter Cotton (arXiv:2411.05807). At gamma=0 this is identical to HRP; at gamma=1 it recovers the global minimum-variance portfolio through the same recursive hierarchy.

Note

A node passed in is weighted in place and returned; see the allocator contract in :mod:pyhrp.algos. Passing dendrogram.root therefore rewrites the portfolios inside that Dendrogram, even though the Dendrogram itself is a frozen dataclass.

Parameters:

Name Type Description Default
prices DataFrame

Asset price time series (columns are assets, rows are dates)

required
node Cluster

Root node of the hierarchical clustering tree. If None, a tree will be built from the correlation matrix.

None
method Literal['single', 'complete', 'average', 'ward']

Linkage method for clustering

'ward'
bisection bool

Whether to use bisection method for tree construction

False
gamma float

Schur interpolation parameter in [0, 1]. 0 recovers standard HRP; 1 recovers minimum-variance portfolio.

0.5

Returns:

Name Type Description
Cluster Cluster

The root cluster with portfolio weights assigned

Source code in src/pyhrp/hrp.py
def schur_hrp(
    prices: pl.DataFrame,
    node: Cluster | None = None,
    method: Method = "ward",
    bisection: bool = False,
    gamma: float = 0.5,
) -> Cluster:
    """Compute Schur Complementary Allocation portfolio weights.

    Extends HRP by augmenting each sub-covariance block with off-diagonal information
    via Schur complements before splitting risk between clusters. Introduced by Peter Cotton
    (arXiv:2411.05807). At gamma=0 this is identical to HRP; at gamma=1 it recovers the
    global minimum-variance portfolio through the same recursive hierarchy.

    Note:
        A ``node`` passed in is weighted in place and returned; see the allocator
        contract in :mod:`pyhrp.algos`. Passing ``dendrogram.root`` therefore
        rewrites the portfolios inside that ``Dendrogram``, even though the
        ``Dendrogram`` itself is a frozen dataclass.

    Args:
        prices (pl.DataFrame): Asset price time series (columns are assets, rows are dates)
        node (Cluster, optional): Root node of the hierarchical clustering tree.
            If None, a tree will be built from the correlation matrix.
        method (Literal["single", "complete", "average", "ward"]): Linkage method for clustering
        bisection (bool): Whether to use bisection method for tree construction
        gamma (float): Schur interpolation parameter in [0, 1].
            0 recovers standard HRP; 1 recovers minimum-variance portfolio.

    Returns:
        Cluster: The root cluster with portfolio weights assigned
    """
    cov, node = _prepare(prices, node, method, bisection)
    return schur_risk_parity(root=node, cov=cov, gamma=gamma)

Portfolio optimization algorithms for hierarchical risk parity.

This module implements various portfolio optimization algorithms: - risk_parity: The main hierarchical risk parity algorithm - schur_risk_parity: Schur Complementary Allocation (Cotton, arXiv:2411.05807) - one_over_n: A simple equal-weight allocation strategy

Allocator contract

All three allocators take the same inputs — a Cluster tree (root) plus the asset names — and none of them changes the shape of that tree: no node is added, removed or re-parented. They differ in where the weights land.

risk_parity and schur_risk_parity share the recursive _allocate_with scaffolding and write into the tree they are given: every node's portfolio is replaced rather than accumulated into, and the same root object is returned. Replacing makes them idempotent — re-running on an already weighted tree, with a different covariance matrix or gamma, gives the same answer as running on a fresh one — but the caller's tree is modified. Note in particular that Dendrogram is a frozen dataclass while the Cluster it holds is not: passing dendrogram.root to either allocator rewrites the portfolios inside that dendrogram. Pass copy.deepcopy(dendrogram.root) to keep the original tree unweighted.

one_over_n differs on both counts. It accumulates into a local buffer, so it leaves the input tree untouched entirely, and its output is a generator yielding the equal-weight portfolio one tree level at a time (see its docstring), because its purpose is to expose the allocation as it deepens rather than a single final result.

one_over_n(root, assets)

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

This implements a hierarchical 1/N strategy where weights are distributed equally among the leaves of each cluster, and the weight budget halves at each successive level of the tree.

Unlike :func:risk_parity and :func:schur_risk_parity — which rebuild a single final allocation and return the root Cluster — this allocator is intentionally a generator: its purpose is to expose the equal-weight allocation as the tree deepens, yielding one portfolio per level. It shares the sibling input contract (a Cluster tree plus the asset names) and, like them, does not mutate the tree: weights accumulate in a local buffer, so a leaf that terminates at a shallow level keeps its weight in the deeper levels (each yielded portfolio is therefore a complete allocation over all assets), and re-running on the same tree yields an identical sequence.

Parameters:

Name Type Description Default
root Cluster

The root node of the cluster tree.

required
assets list[str]

Asset names; a leaf's value indexes into this list.

required

Yields:

Type Description
Generator[tuple[int, Portfolio]]

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

Generator[tuple[int, Portfolio]]

portfolio at that level.

Examples:

>>> import polars as pl
>>> from pyhrp.hrp import build_tree
>>> from pyhrp.algos import one_over_n
>>> cor = pl.DataFrame({"A": [1.0, 0.3], "B": [0.3, 1.0]})
>>> dg = build_tree(cor, method="ward")
>>> levels = list(one_over_n(dg.root, dg.assets))
>>> len(levels) > 0
True
Source code in src/pyhrp/algos.py
def one_over_n(root: Cluster, assets: list[str]) -> Generator[tuple[int, Portfolio]]:
    """Generate 1/N (equal-weight) portfolios one tree level at a time.

    This implements a hierarchical 1/N strategy where weights are distributed
    equally among the leaves of each cluster, and the weight budget halves at
    each successive level of the tree.

    Unlike :func:`risk_parity` and :func:`schur_risk_parity` — which rebuild a
    single final allocation and return the root ``Cluster`` — this allocator is
    intentionally a **generator**: its purpose is to expose the equal-weight
    allocation as the tree deepens, yielding one portfolio per level. It shares
    the sibling input contract (a ``Cluster`` tree plus the asset names) and, like
    them, does not mutate the tree: weights accumulate in a local buffer, so a
    leaf that terminates at a shallow level keeps its weight in the deeper levels
    (each yielded portfolio is therefore a complete allocation over all assets),
    and re-running on the same tree yields an identical sequence.

    Args:
        root (Cluster): The root node of the cluster tree.
        assets (list[str]): Asset names; a leaf's value indexes into this list.

    Yields:
        tuple[int, Portfolio]: The level number and the (cumulative) equal-weight
        portfolio at that level.

    Examples:
        >>> import polars as pl
        >>> from pyhrp.hrp import build_tree
        >>> from pyhrp.algos import one_over_n
        >>> cor = pl.DataFrame({"A": [1.0, 0.3], "B": [0.3, 1.0]})
        >>> dg = build_tree(cor, method="ward")
        >>> levels = list(one_over_n(dg.root, dg.assets))
        >>> len(levels) > 0
        True
    """
    # Accumulate into a local buffer so the input tree is never mutated.
    portfolio = Portfolio()

    # Initial weight to distribute
    w: float = 1.0

    # Process each level of the tree
    for n, level in enumerate(root.levels):
        for node in level:
            # Distribute weight equally among all leaves in this node
            for leaf in node.leaves:
                portfolio[assets[leaf.value]] = w / node.leaf_count

        # Reduce weight for the next level
        w *= 0.5

        # Yield the current level number and a deep copy of the portfolio
        yield n, deepcopy(portfolio)

risk_parity(root, cov, assets=None)

Compute hierarchical risk parity weights for a cluster tree.

This is the main algorithm for hierarchical risk parity. It recursively traverses the cluster tree and assigns weights to each node based on the risk parity principle.

Note

The tree is modified in place: the portfolio of every node is rebuilt from scratch and root itself is returned, so the function is idempotent and a tree can be reused with a different covariance matrix. Deep-copy the tree first if you need to keep it unweighted.

The covariance is reached only through block products, one per child of every node, so a factor or Gram :class:~pyhrp.operators.CovarianceOperator allocates without forming the n x n matrix.

Parameters:

Name Type Description Default
root Cluster

The root node of the cluster tree

required
cov DataFrame | CovarianceOperator

Covariance matrix of asset returns, as a DataFrame or as an operator

required
assets Sequence[str]

Asset names for an operator cov; defaults to "0", "1", .... A DataFrame names its own assets.

None

Returns:

Name Type Description
Cluster Cluster

The same root node, with portfolio weights assigned

Examples:

>>> import polars as pl
>>> from pyhrp.cluster import Cluster
>>> from pyhrp.algos import risk_parity
>>> cov = pl.DataFrame({"A": [4.0, 0.0], "B": [0.0, 1.0]})
>>> root = Cluster(2, left=Cluster(0), right=Cluster(1))
>>> cluster = risk_parity(root=root, cov=cov)
>>> round(cluster.portfolio["B"], 1)
0.8
Source code in src/pyhrp/algos.py
def risk_parity(root: Cluster, cov: pl.DataFrame | CovarianceOperator, assets: Sequence[str] | None = None) -> Cluster:
    """Compute hierarchical risk parity weights for a cluster tree.

    This is the main algorithm for hierarchical risk parity. It recursively
    traverses the cluster tree and assigns weights to each node based on
    the risk parity principle.

    Note:
        The tree is modified in place: the portfolio of every node is rebuilt
        from scratch and ``root`` itself is returned, so the function is
        idempotent and a tree can be reused with a different covariance matrix.
        Deep-copy the tree first if you need to keep it unweighted.

    The covariance is reached only through block products, one per child of
    every node, so a factor or Gram :class:`~pyhrp.operators.CovarianceOperator`
    allocates without forming the ``n x n`` matrix.

    Args:
        root (Cluster): The root node of the cluster tree
        cov (pl.DataFrame | CovarianceOperator): Covariance matrix of asset returns,
            as a DataFrame or as an operator
        assets (Sequence[str], optional): Asset names for an operator ``cov``;
            defaults to ``"0", "1", ...``. A DataFrame names its own assets.

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

    Examples:
        >>> import polars as pl
        >>> from pyhrp.cluster import Cluster
        >>> from pyhrp.algos import risk_parity
        >>> cov = pl.DataFrame({"A": [4.0, 0.0], "B": [0.0, 1.0]})
        >>> root = Cluster(2, left=Cluster(0), right=Cluster(1))
        >>> cluster = risk_parity(root=root, cov=cov)
        >>> round(cluster.portfolio["B"], 1)
        0.8
    """

    def node_variances(left: Block, right: Block, op: CovarianceOperator) -> tuple[float, float]:
        """Plain block variance of each child sub-portfolio."""
        return _block_variance(left, op), _block_variance(right, op)

    return _allocate_with(root, cov, node_variances, assets)

schur_risk_parity(root, cov, gamma=0.5, assets=None)

Compute Schur Complementary Allocation weights for a cluster tree.

An extension of HRP introduced by Peter Cotton (arXiv:2411.05807) that augments sub-covariance matrices with off-diagonal block information via Schur complements. At gamma=0 this recovers standard HRP; at gamma=1 it recovers the minimum-variance portfolio through the same recursive structure.

Note

The tree is modified in place: the portfolio of every node is rebuilt from scratch and root itself is returned, so the function is idempotent and a tree can be reused with a different covariance matrix or gamma. Deep-copy the tree first if you need to keep it unweighted.

The Schur terms need, per node, the two cross-block products Sigma[L, R] w_R and Sigma[R, L] w_L and one solve against each child's principal block, which a factor operator does by Woodbury in O(|C| k**2) rather than O(|C|**3). The block quadratic forms B D^{-1} B^T are never formed: only their action on the child weights is.

Parameters:

Name Type Description Default
root Cluster

The root node of the cluster tree

required
cov DataFrame | CovarianceOperator

Covariance matrix of asset returns, as a DataFrame or as an operator

required
gamma float

Interpolation parameter in [0, 1]. 0 = HRP, 1 = minimum variance.

0.5
assets Sequence[str]

Asset names for an operator cov; defaults to "0", "1", .... A DataFrame names its own assets.

None

Returns:

Name Type Description
Cluster Cluster

The same root node, with portfolio weights assigned

Raises:

Type Description
ValueError

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

Examples:

>>> import polars as pl
>>> from pyhrp.cluster import Cluster
>>> from pyhrp.algos import schur_risk_parity
>>> cov = pl.DataFrame({"A": [4.0, 0.0], "B": [0.0, 1.0]})
>>> root = Cluster(2, left=Cluster(0), right=Cluster(1))
>>> cluster = schur_risk_parity(root=root, cov=cov, gamma=0.5)
>>> round(cluster.portfolio["B"], 1)
0.8
Source code in src/pyhrp/algos.py
def schur_risk_parity(
    root: Cluster,
    cov: pl.DataFrame | CovarianceOperator,
    gamma: float = 0.5,
    assets: Sequence[str] | None = None,
) -> Cluster:
    """Compute Schur Complementary Allocation weights for a cluster tree.

    An extension of HRP introduced by Peter Cotton (arXiv:2411.05807) that augments
    sub-covariance matrices with off-diagonal block information via Schur complements.
    At gamma=0 this recovers standard HRP; at gamma=1 it recovers the minimum-variance
    portfolio through the same recursive structure.

    Note:
        The tree is modified in place: the portfolio of every node is rebuilt
        from scratch and ``root`` itself is returned, so the function is
        idempotent and a tree can be reused with a different covariance matrix
        or gamma. Deep-copy the tree first if you need to keep it unweighted.

    The Schur terms need, per node, the two cross-block products
    ``Sigma[L, R] w_R`` and ``Sigma[R, L] w_L`` and one solve against each
    child's principal block, which a factor operator does by Woodbury in
    ``O(|C| k**2)`` rather than ``O(|C|**3)``. The block quadratic forms
    ``B D^{-1} B^T`` are never formed: only their action on the child weights is.

    Args:
        root (Cluster): The root node of the cluster tree
        cov (pl.DataFrame | CovarianceOperator): Covariance matrix of asset returns,
            as a DataFrame or as an operator
        gamma (float): Interpolation parameter in [0, 1]. 0 = HRP, 1 = minimum variance.
        assets (Sequence[str], optional): Asset names for an operator ``cov``;
            defaults to ``"0", "1", ...``. A DataFrame names its own assets.

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

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

    Examples:
        >>> import polars as pl
        >>> from pyhrp.cluster import Cluster
        >>> from pyhrp.algos import schur_risk_parity
        >>> cov = pl.DataFrame({"A": [4.0, 0.0], "B": [0.0, 1.0]})
        >>> root = Cluster(2, left=Cluster(0), right=Cluster(1))
        >>> cluster = schur_risk_parity(root=root, cov=cov, gamma=0.5)
        >>> round(cluster.portfolio["B"], 1)
        0.8
    """
    if not 0.0 <= gamma <= 1.0:
        msg = f"gamma must be in [0, 1], got {gamma}"
        raise ValueError(msg)

    def node_variances(left: Block, right: Block, op: CovarianceOperator) -> tuple[float, float]:
        """Schur-augmented block variance of each child, conditioned on the other."""
        (li, w_left), (ri, w_right) = left, right

        v_left = _block_variance(left, op)
        v_right = _block_variance(right, op)
        if gamma == 0.0:
            return v_left, v_right

        # Schur-augmented blocks A - gamma B D^{-1} B^T and D - gamma B^T A^{-1} B
        # (A = Sigma_LL, B = Sigma_LR, D = Sigma_RR), applied to the child weights
        # through one cross product and one block solve each.
        bt_w = op.block_matvec(ri, li, w_left)
        b_w = op.block_matvec(li, ri, w_right)
        return (
            _schur_variance(v_left, gamma * float(bt_w @ solve_block(op, ri, bt_w))),
            _schur_variance(v_right, gamma * float(b_w @ solve_block(op, li, b_w))),
        )

    return _allocate_with(root, cov, node_variances, assets)

Data structures for hierarchical risk parity portfolio optimization.

This module defines the core data structures used in the hierarchical risk parity algorithm: - Portfolio: Manages a collection of asset weights (strings identify assets) - Cluster: Represents a node in the hierarchical clustering tree

Cluster

Bases: Node[int]

Represents a cluster in the hierarchical clustering tree.

Clusters are the nodes of the graphs we build. Each cluster is aware of the left and the right cluster it is connecting to. Each cluster also has an associated portfolio.

Attributes:

Name Type Description
portfolio Portfolio

The portfolio associated with this cluster

After an allocation a node does not hold its portfolio, only its split: the share of its weight that goes to the left child. The portfolio is built from the splits below the node when it is first read, and then kept. Holding every node's portfolio would cost the sum of all cluster sizes, which is quadratic in the number of assets for the chain-like trees single linkage builds.

Source code in src/pyhrp/cluster.py
class Cluster(Node[int]):
    """Represents a cluster in the hierarchical clustering tree.

    Clusters are the nodes of the graphs we build.
    Each cluster is aware of the left and the right cluster
    it is connecting to. Each cluster also has an associated portfolio.

    Attributes:
        portfolio (Portfolio): The portfolio associated with this cluster

    After an allocation a node does not hold its portfolio, only its split: the
    share of its weight that goes to the left child. The portfolio is built from
    the splits below the node when it is first read, and then kept. Holding every
    node's portfolio would cost the sum of all cluster sizes, which is quadratic in
    the number of assets for the chain-like trees single linkage builds.
    """

    def __init__(self, value: int, left: Cluster | None = None, right: Cluster | None = None) -> None:
        """Initialize a new Cluster.

        Args:
            value (int): The identifier for this cluster
            left (Cluster, optional): The left child cluster
            right (Cluster, optional): The right child cluster
        """
        super().__init__(value=value, left=left, right=right)
        self._portfolio: Portfolio | None = Portfolio()
        self._share = 1.0
        self._assets: Sequence[str] = ()

    @property
    def portfolio(self) -> Portfolio:
        """The portfolio of this cluster, built from the allocation's splits on first access."""
        if self._portfolio is None:
            leaves, spans = self._layout()
            w = np.ones(len(leaves))
            _apply_shares(w, spans)
            self._portfolio = _portfolio(leaves, w, self._assets)
        return self._portfolio

    @portfolio.setter
    def portfolio(self, value: Portfolio) -> None:
        """Set the portfolio explicitly."""
        self._portfolio = value

    def _set_allocation(self, share: float, assets: Sequence[str]) -> None:
        """Record this node's left share and drop its portfolio, to be rebuilt on access."""
        self._share = share
        self._assets = assets
        self._portfolio = None

    def _layout(self) -> tuple[list[Cluster], list[Span]]:
        """Lay the subtree out over its leaf order.

        Returns the leaves left to right and, for every non-leaf node in
        post-order (children before parents), the slices of that leaf order its
        two subtrees cover. Iterative and linear in the subtree size; every
        non-leaf node is validated before anything is returned.
        """
        preorder: list[Cluster] = []
        stack: list[Cluster] = [self]
        while stack:
            node = stack.pop()
            preorder.append(node)
            if not node.is_leaf:
                left, right = node._child_clusters()
                stack.append(right)
                stack.append(left)

        # Leaf counts bottom-up, then each node's first leaf position top-down.
        count: dict[int, int] = {}
        for node in reversed(preorder):
            if node.is_leaf:
                count[id(node)] = 1
            else:
                left, right = node._child_clusters()
                count[id(node)] = count[id(left)] + count[id(right)]
        start = {id(self): 0}
        leaves: list[Cluster] = []
        spans: list[Span] = []
        for node in preorder:
            lo = start[id(node)]
            if node.is_leaf:
                leaves.append(node)
            else:
                left, right = node._child_clusters()
                mid = lo + count[id(left)]
                start[id(left)] = lo
                start[id(right)] = mid
                spans.append((node, lo, mid, lo + count[id(node)]))
        spans.reverse()
        return leaves, spans

    # Override narrows the return type to list[Cluster] and validates tree integrity;
    # the traversal order (left to right) matches Node.leaves.
    @property
    def leaves(self) -> list[Cluster]:
        """Get all reachable leaf nodes in left-to-right dendrogram order.

        Returns:
            list[Cluster]: List of all leaf nodes reachable from this cluster
        """
        # Iterative for the same reason as Node.leaves, and still validating each
        # non-leaf node through _child_clusters() so a malformed tree raises here
        # rather than yielding a silently short leaf list.
        result: list[Cluster] = []
        stack: list[Cluster] = [self]
        while stack:
            node = stack.pop()
            if node.is_leaf:
                result.append(node)
                continue
            left, right = node._child_clusters()
            # Right first, so the left subtree is emitted first.
            stack.append(right)
            stack.append(left)
        return result

    def _child_clusters(self) -> tuple[Cluster, Cluster]:
        """Return the validated (left, right) child clusters of a non-leaf node.

        Raises:
            ValueError: If either child is missing on a non-leaf cluster.
            TypeError: If either child is not a Cluster.
        """
        if self.left is None:
            msg = "Expected left child to exist for non-leaf cluster"
            raise ValueError(msg)
        if self.right is None:
            msg = "Expected right child to exist for non-leaf cluster"
            raise ValueError(msg)
        if not isinstance(self.left, Cluster):
            msg = f"Expected left child to be a Cluster for node {self.value}"
            raise TypeError(msg)
        if not isinstance(self.right, Cluster):
            msg = f"Expected right child to be a Cluster for node {self.value}"
            raise TypeError(msg)
        return self.left, self.right

leaves property

Get all reachable leaf nodes in left-to-right dendrogram order.

Returns:

Type Description
list[Cluster]

list[Cluster]: List of all leaf nodes reachable from this cluster

portfolio property writable

The portfolio of this cluster, built from the allocation's splits on first access.

__init__(value, left=None, right=None)

Initialize a new Cluster.

Parameters:

Name Type Description Default
value int

The identifier for this cluster

required
left Cluster

The left child cluster

None
right Cluster

The right child cluster

None
Source code in src/pyhrp/cluster.py
def __init__(self, value: int, left: Cluster | None = None, right: Cluster | None = None) -> None:
    """Initialize a new Cluster.

    Args:
        value (int): The identifier for this cluster
        left (Cluster, optional): The left child cluster
        right (Cluster, optional): The right child cluster
    """
    super().__init__(value=value, left=left, right=right)
    self._portfolio: Portfolio | None = Portfolio()
    self._share = 1.0
    self._assets: Sequence[str] = ()

Portfolio dataclass

Container for portfolio asset weights.

This lightweight class stores and manipulates a mapping from asset names to their portfolio weights, and provides convenience helpers for analysis and visualization.

Attributes:

Name Type Description
_weights dict[str, float]

Internal mapping from asset symbol to weight.

Source code in src/pyhrp/cluster.py
@dataclass
class Portfolio:
    """Container for portfolio asset weights.

    This lightweight class stores and manipulates a mapping from asset names to
    their portfolio weights, and provides convenience helpers for analysis and
    visualization.

    Attributes:
        _weights (dict[str, float]): Internal mapping from asset symbol to weight.
    """

    _weights: dict[str, float] = field(default_factory=dict)

    @property
    def assets(self) -> list[str]:
        """List of asset names present in the portfolio.

        Returns:
            list[str]: Asset identifiers in insertion order (Python 3.7+ dict order).
        """
        return list(self._weights.keys())

    def variance(self, cov: pl.DataFrame) -> float:
        """Calculate the variance of the portfolio.

        Args:
            cov (pl.DataFrame): Covariance matrix where columns and rows correspond
                to assets in the same order as columns list.

        Returns:
            float: Portfolio variance
        """
        assets = self.assets
        index = {name: i for i, name in enumerate(cov.columns)}
        row_indices = [index[a] for a in assets]
        cov_matrix = cov.to_numpy()
        c = cov_matrix[np.ix_(row_indices, row_indices)]
        w = np.array([self._weights[a] for a in assets])
        return float(w @ c @ w)

    def __getitem__(self, item: str) -> float:
        """Return the weight for a given asset.

        Args:
            item (str): Asset name/symbol.

        Returns:
            float: The weight associated with the asset.

        Raises:
            KeyError: If the asset is not present in the portfolio.
        """
        return self._weights[item]

    def __setitem__(self, key: str, value: float) -> None:
        """Set or update the weight for an asset.

        Args:
            key (str): Asset name/symbol.
            value (float): Portfolio weight for the asset.
        """
        self._weights[key] = value

    @property
    def weights(self) -> dict[str, float]:
        """Get all weights as a dict sorted alphabetically by asset name.

        Returns:
            dict[str, float]: Mapping from asset name to weight, sorted by name.
        """
        return dict(sorted(self._weights.items()))

    def plot(self, names: list[str]) -> go.Figure:
        """Plot the portfolio weights as a bar chart.

        Args:
            names (list[str]): List of asset names to include in the plot

        Returns:
            go.Figure: The plotly figure

        Note:
            The plotly dependency is imported lazily so importing the allocation
            core (which pulls in this module) stays plotly-free.
        """
        import plotly.graph_objects as go

        w = self.weights
        values = [w[n] for n in names]
        fig = go.Figure(go.Bar(x=names, y=values, marker_color="steelblue"))
        fig.update_layout(xaxis={"tickangle": -90})
        return fig

assets property

List of asset names present in the portfolio.

Returns:

Type Description
list[str]

list[str]: Asset identifiers in insertion order (Python 3.7+ dict order).

weights property

Get all weights as a dict sorted alphabetically by asset name.

Returns:

Type Description
dict[str, float]

dict[str, float]: Mapping from asset name to weight, sorted by name.

__getitem__(item)

Return the weight for a given asset.

Parameters:

Name Type Description Default
item str

Asset name/symbol.

required

Returns:

Name Type Description
float float

The weight associated with the asset.

Raises:

Type Description
KeyError

If the asset is not present in the portfolio.

Source code in src/pyhrp/cluster.py
def __getitem__(self, item: str) -> float:
    """Return the weight for a given asset.

    Args:
        item (str): Asset name/symbol.

    Returns:
        float: The weight associated with the asset.

    Raises:
        KeyError: If the asset is not present in the portfolio.
    """
    return self._weights[item]

__setitem__(key, value)

Set or update the weight for an asset.

Parameters:

Name Type Description Default
key str

Asset name/symbol.

required
value float

Portfolio weight for the asset.

required
Source code in src/pyhrp/cluster.py
def __setitem__(self, key: str, value: float) -> None:
    """Set or update the weight for an asset.

    Args:
        key (str): Asset name/symbol.
        value (float): Portfolio weight for the asset.
    """
    self._weights[key] = value

plot(names)

Plot the portfolio weights as a bar chart.

Parameters:

Name Type Description Default
names list[str]

List of asset names to include in the plot

required

Returns:

Type Description
Figure

go.Figure: The plotly figure

Note

The plotly dependency is imported lazily so importing the allocation core (which pulls in this module) stays plotly-free.

Source code in src/pyhrp/cluster.py
def plot(self, names: list[str]) -> go.Figure:
    """Plot the portfolio weights as a bar chart.

    Args:
        names (list[str]): List of asset names to include in the plot

    Returns:
        go.Figure: The plotly figure

    Note:
        The plotly dependency is imported lazily so importing the allocation
        core (which pulls in this module) stays plotly-free.
    """
    import plotly.graph_objects as go

    w = self.weights
    values = [w[n] for n in names]
    fig = go.Figure(go.Bar(x=names, y=values, marker_color="steelblue"))
    fig.update_layout(xaxis={"tickangle": -90})
    return fig

variance(cov)

Calculate the variance of the portfolio.

Parameters:

Name Type Description Default
cov DataFrame

Covariance matrix where columns and rows correspond to assets in the same order as columns list.

required

Returns:

Name Type Description
float float

Portfolio variance

Source code in src/pyhrp/cluster.py
def variance(self, cov: pl.DataFrame) -> float:
    """Calculate the variance of the portfolio.

    Args:
        cov (pl.DataFrame): Covariance matrix where columns and rows correspond
            to assets in the same order as columns list.

    Returns:
        float: Portfolio variance
    """
    assets = self.assets
    index = {name: i for i, name in enumerate(cov.columns)}
    row_indices = [index[a] for a in assets]
    cov_matrix = cov.to_numpy()
    c = cov_matrix[np.ix_(row_indices, row_indices)]
    w = np.array([self._weights[a] for a in assets])
    return float(w @ c @ w)

Plotly visualization for hierarchical clustering dendrograms.

Kept separate from the allocation core (hrp.py) so that the optional plotting dependency (plotly) and the visualization logic do not couple the algorithm modules. Dendrogram.plot delegates here.

plot_dendrogram(dendrogram, **kwargs)

Build and return a plotly dendrogram figure for a Dendrogram.

Parameters:

Name Type Description Default
dendrogram Dendrogram

Clustering result carrying the linkage matrix and asset labels to render.

required
**kwargs object

Extra keyword arguments forwarded to scipy.cluster.hierarchy.dendrogram (e.g. color_threshold).

{}

Returns:

Type Description
Figure

go.Figure: A plotly figure drawing the dendrogram as line traces.

Raises:

Type Description
ValueError

If the dendrogram has no linkage matrix to plot.

Examples:

>>> import polars as pl
>>> from pyhrp.hrp import build_tree
>>> from pyhrp.plot import plot_dendrogram
>>> cor = pl.DataFrame({"A": [1.0, 0.5], "B": [0.5, 1.0]})
>>> fig = plot_dendrogram(build_tree(cor, method="ward"))
>>> type(fig).__name__
'Figure'
Source code in src/pyhrp/plot.py
def plot_dendrogram(dendrogram: Dendrogram, **kwargs: object) -> go.Figure:
    """Build and return a plotly dendrogram figure for a Dendrogram.

    Args:
        dendrogram (Dendrogram): Clustering result carrying the linkage matrix
            and asset labels to render.
        **kwargs (object): Extra keyword arguments forwarded to
            ``scipy.cluster.hierarchy.dendrogram`` (e.g. ``color_threshold``).

    Returns:
        go.Figure: A plotly figure drawing the dendrogram as line traces.

    Raises:
        ValueError: If the dendrogram has no linkage matrix to plot.

    Examples:
        >>> import polars as pl
        >>> from pyhrp.hrp import build_tree
        >>> from pyhrp.plot import plot_dendrogram
        >>> cor = pl.DataFrame({"A": [1.0, 0.5], "B": [0.5, 1.0]})
        >>> fig = plot_dendrogram(build_tree(cor, method="ward"))
        >>> type(fig).__name__
        'Figure'
    """
    if dendrogram.linkage is None:
        msg = "Dendrogram has no linkage matrix to plot."
        raise ValueError(msg)
    ddata = sch.dendrogram(dendrogram.linkage, labels=dendrogram.assets, no_plot=True, **kwargs)
    fig = go.Figure()
    for xs, ys in zip(ddata["icoord"], ddata["dcoord"], strict=False):
        fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line={"color": "steelblue"}, showlegend=False))
    n = len(dendrogram.assets)
    fig.update_layout(
        xaxis={
            "tickmode": "array",
            "tickvals": [5 + 10 * i for i in range(n)],
            "ticktext": ddata["ivl"],
            "tickangle": -90,
        },
    )
    return fig

A lightweight binary tree implementation to replace the binarytree dependency.

This module provides a simple Node class that can be used to create binary trees. It implements only the functionality needed by the pyhrp package.

Node

Bases: Generic[T]

A binary tree node with left and right children.

This class implements the minimal functionality needed from the binarytree.Node class that is used in the pyhrp package.

Attributes:

Name Type Description
value

The value of the node

left

The left child node

right

The right child node

Source code in src/pyhrp/treelib.py
class Node(Generic[T]):
    """A binary tree node with left and right children.

    This class implements the minimal functionality needed from the binarytree.Node class
    that is used in the pyhrp package.

    Attributes:
        value: The value of the node
        left: The left child node
        right: The right child node
    """

    def __init__(self, value: T, left: Node[T] | None = None, right: Node[T] | None = None) -> None:
        """Initialize a new Node.

        Args:
            value: The value of the node
            left: The left child node
            right: The right child node
        """
        self.value = value
        self.left = left
        self.right = right

    @property
    def is_leaf(self) -> bool:
        """Check if this node is a leaf node (has no children).

        Returns:
            bool: True if this is a leaf node, False otherwise
        """
        return self.left is None and self.right is None

    @property
    def leaves(self) -> Sequence[Node[T]]:
        """Get all leaf nodes in the tree rooted at this node.

        Returns:
            List[Node]: List of all leaf nodes
        """
        # Iterative depth-first walk with an explicit stack: the leaf order is the
        # same left-to-right order the recursive form produced, but the traversal
        # depth is bounded by the heap rather than by sys.getrecursionlimit(). A
        # chain-degenerate cluster tree is as deep as it is wide, so recursing here
        # put a ceiling on the number of assets the package could handle.
        result: list[Node[T]] = []
        stack: list[Node[T]] = [self]
        while stack:
            node = stack.pop()
            if node.is_leaf:
                result.append(node)
                continue
            # Right first, so the left subtree is popped and emitted first.
            if node.right is not None:
                stack.append(node.right)
            if node.left is not None:
                stack.append(node.left)

        return result

    @property
    def levels(self) -> list[list[Node[T]]]:
        """Get nodes by level in the tree.

        Returns:
            List[List[Node]]: List of lists of nodes at each level
        """
        result: list[list[Node[T]]] = []
        current_level: list[Node[T]] = [self]

        while current_level:
            result.append(current_level)
            next_level = []

            for node in current_level:
                if node.left:
                    next_level.append(node.left)
                if node.right:
                    next_level.append(node.right)

            current_level = next_level

        return result

    @property
    def leaf_count(self) -> int:
        """Count the number of leaf nodes in the tree.

        Returns:
            int: Number of leaf nodes
        """
        return len(self.leaves)

    @property
    def size(self) -> int:
        """Count the total number of nodes in the tree.

        Returns:
            int: Total number of nodes
        """
        # Counts via __iter__, which is already an iterative level-order walk, so
        # this inherits its freedom from the recursion limit.
        return sum(1 for _ in self)

    def __iter__(self) -> Iterator[Node[T]]:
        """Iterate through all nodes in the tree in level-order.

        Returns:
            Iterator[Node]: Iterator over all nodes
        """
        queue: deque[Node[T]] = deque([self])
        while queue:
            node = queue.popleft()
            yield node
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)

is_leaf property

Check if this node is a leaf node (has no children).

Returns:

Name Type Description
bool bool

True if this is a leaf node, False otherwise

leaf_count property

Count the number of leaf nodes in the tree.

Returns:

Name Type Description
int int

Number of leaf nodes

leaves property

Get all leaf nodes in the tree rooted at this node.

Returns:

Type Description
Sequence[Node[T]]

List[Node]: List of all leaf nodes

levels property

Get nodes by level in the tree.

Returns:

Type Description
list[list[Node[T]]]

List[List[Node]]: List of lists of nodes at each level

size property

Count the total number of nodes in the tree.

Returns:

Name Type Description
int int

Total number of nodes

__init__(value, left=None, right=None)

Initialize a new Node.

Parameters:

Name Type Description Default
value T

The value of the node

required
left Node[T] | None

The left child node

None
right Node[T] | None

The right child node

None
Source code in src/pyhrp/treelib.py
def __init__(self, value: T, left: Node[T] | None = None, right: Node[T] | None = None) -> None:
    """Initialize a new Node.

    Args:
        value: The value of the node
        left: The left child node
        right: The right child node
    """
    self.value = value
    self.left = left
    self.right = right

__iter__()

Iterate through all nodes in the tree in level-order.

Returns:

Type Description
Iterator[Node[T]]

Iterator[Node]: Iterator over all nodes

Source code in src/pyhrp/treelib.py
def __iter__(self) -> Iterator[Node[T]]:
    """Iterate through all nodes in the tree in level-order.

    Returns:
        Iterator[Node]: Iterator over all nodes
    """
    queue: deque[Node[T]] = deque([self])
    while queue:
        node = queue.popleft()
        yield node
        if node.left:
            queue.append(node.left)
        if node.right:
            queue.append(node.right)