Chapter 13. Building a portfolio allocation library

In this chapter, we'll build a simple portfolio allocation library in Rust.

This library will allow users to calculate the optimal allocation of assets of three types of algorithms, popular in the asset management industry.

The three algorithms we'll implement are:

  1. Equal Weighting: Each asset in the portfolio is given an equal weight.
  2. Minimum Variance: The portfolio is constructed to minimize the overall variance.
  3. Hierarchical Risk Parity: The portfolio is constructed to balance risk across different assets.

Your author maintains the cutup library which is the blueprint for what we'll be building here.

Here's the thing: don't sweat much about the algorithms or the topic. It's extremely complex, however, we'll be using the nalgebra library to do all the heavy lifting for us. People who could win nobel prizes spend their lives working on this stuff for banks, hedge funds and other financial institutions. Your job is to make it easy and performant for them to use.

Note: This is a simplified version of the algorithms and is not intended for real-world financial use. Your author leverages these types of algorithms to allocate funds for advertising campaigns!

In short, we're taking a quantity of something, like groceries, and distributing them according to some rules. To do that, we use their past prices. That's it.

Chapter 13. A Portfolio Allocation Library

Beware of little expenses; a small leak will sink a great ship. - Benjamin Franklin

Setting up the project

In your terminal, run cargo new portfolio_allocation --lib to create a new Rust library project.

Move into the project and add the only dependency we'll need for this project, ndarray, to your Cargo.toml file.

[dependencies]
nalgebra = "0.33.2"

or cargo add nalgebra.

Directory structure

Your file layout should look something like the below, when we're finished:

├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── src
│   ├── lib.rs
└── tests
    └── integration.rs

For now, ignore the integration tests directory.

Add PortfolioAllocator struct

In this section, we'll define the PortfolioAllocator struct and implement methods for it.

This will be our main struct that will drive our public API for the library. It will hold the price data, the covariance matrix and methods to act on those data, all of which are essential for portfolio optimization.

This keeps our API clean and allows callers to easily call multiple methods on the same data, which is a common task in portfolio optimization.

Imagine that you have a list of prices for different assets over time. You want to know which assets you should invest in, given a fixed amount of money. If all the methods say the same thing, you can reliably assume that the outputs are correct, at least quantitatively.

To get started, open your src/lib.rs file and add the following use statements.

use nalgebra::{DMatrix, DVector};
use std::collections::HashMap;

Now add the following code to define the PortfolioAllocator struct and its associated methods.

pub struct PortfolioAllocator {
    price_data: DMatrix<f64>,
    cov_matrix: DMatrix<f64>,
}

impl PortfolioAllocator {

}

We first define the struct and next we'll add methods to it in the impl block.

Add new method

The new method initializes the PortfolioAllocator struct with price data and calculates the covariance matrix.

impl PortfolioAllocator {
    pub fn new(price_data: DMatrix<f64>) -> Self {
        let cov_matrix = PortfolioAllocator::compute_covariance_matrix(&price_data);
        PortfolioAllocator {
            price_data,
            cov_matrix,
        }
    }
}

Callers will use a price matrix to create a new PortfolioAllocator instance. The new method takes a DMatrix<f64> as input, which is a matrix of prices, and calculates the covariance matrix using the compute_covariance_matrix method.

That looks like this:

let prices = DMatrix::from_row_slice(
    4,
    4,
    &[
        125.0, 1500.0, 210.0, 600.0,
        123.0, 1520.0, 215.0, 620.0,
        130.0, 1510.0, 220.0, 610.0,
        128.0, 1530.0, 225.0, 630.0,
    ],
);

let allocator = PortfolioAllocator::new(prices);

Inside the PortfolioAllocator struct we use the compute_covariance_matrix method to calculate the covariance matrix from the price data, so now let's implement that.

Add compute_covariance_matrix method

The compute_covariance_matrix method calculates the covariance matrix from the price data. This is a key step in portfolio optimization.

impl PortfolioAllocator {
    fn compute_covariance_matrix(returns: &DMatrix<f64>) -> DMatrix<f64> {
        let mean = returns.row_mean();
        let mean_matrix = DMatrix::from_rows(&vec![mean.clone(); returns.nrows()]);
        let centered = returns - mean_matrix;
        (centered.transpose() * centered) / (returns.nrows() as f64 - 1.0)
    }
}

Here we calculate the mean of the returns, create a matrix of averages, center the data by subtracting the mean and then compute the covariance matrix.

Now let's proceed by adding our actual allocation methods, that will use the covariance matrix output by the function we just wrote.

Add ew_allocation method

Now we can add our first allocation method. We'll use it like

let prices = dmatrix![
    100.0, 200.0, 300.0;
    110.0, 210.0, 310.0;
    120.0, 220.0, 320.0
];

let allocator = PortfolioAllocator::new(prices);
let weights = allocator.ew_allocation();

The ew_allocation method calculates the equal weight allocation for each asset in the portfolio and we call it on the PortfolioAllocator instance.

impl PortfolioAllocator {
    pub fn ew_allocation(&self) -> HashMap<usize, f64> {
        let n = self.price_data.ncols();
        (0..n).map(|i| (i, 1.0 / n as f64)).collect()
    }
}

For each asset, we assign an equal weight of 1/n, where n is the number of assets in the portfolio.

Add hrp_allocation method

Now we'll add the Hierarchical Risk Parity (HRP) allocation method. This method is a bit more complex, as it involves clustering the assets based on their covariance matrix.

impl PortfolioAllocator {
    pub fn hrp_allocation(&self) -> HashMap<usize, f64> {
        let n = self.cov_matrix.ncols();
        let mut weights = vec![1.0; n];
        let mut clusters: Vec<Vec<usize>> = vec![(0..n).collect()];

        while let Some(cluster) = clusters.pop() {
            if cluster.len() != 1 {
                let mid = cluster.len() / 2;
                let left = &cluster[..mid];
                let right = &cluster[mid..];

                let vol_left: f64 = left.iter().map(|&i| self.cov_matrix[(i, i)]).sum();
                let vol_right: f64 = right.iter().map(|&i| self.cov_matrix[(i, i)]).sum();
                let total_vol = vol_left + vol_right;

                for &idx in left {
                    weights[idx] *= vol_right / total_vol;
                }
                for &idx in right {
                    weights[idx] *= vol_left / total_vol;
                }

                clusters.push(left.to_vec());
                clusters.push(right.to_vec());
            }
        }

        (0..n).map(|i| (i, weights[i])).collect()
    }
}

Digging into the mechanics of this algorithm is out of scope, however it's a very useful and popular algorithm for this allocation task.

Add mvo_allocation method

Released in the 1950s, Mean Variance Optimization (MVO) is a classic portfolio optimization algorithm. It aims to maximize the expected return for a given level of risk.

Our particular implementation will add a few additional features, such as shrinkage and regularization, to improve the robustness of the covariance matrix estimation. Without this, our MVO implementation would be very sensitive to the input data.

impl PortfolioAllocator {
    pub fn mvo_allocation_with_config(&self, config: &MvoConfig) -> HashMap<usize, f64> {
        let n = self.cov_matrix.ncols();
        let ones = DVector::from_element(n, 1.0);

        let identity = DMatrix::identity(n, n);

        let shrunk_cov = if let Some(lambda) = config.shrinkage {
            lambda * &identity + (1.0 - lambda) * &self.cov_matrix
        } else {
            self.cov_matrix.clone()
        };

        let regularized_cov = if let Some(eps) = config.regularization {
            &shrunk_cov + eps * &identity
        } else {
            shrunk_cov
        };

        // Note: pseudo_inverse(...).expect(...) is not covered by tests because nalgebra::pseudo_inverse rarely fails.
        // This fallback is defensive and unreachable in practice unless the matrix is non-finite or SVD fails internally.
        let inv_cov = regularized_cov.clone().try_inverse().unwrap_or_else(|| {
            let eps = config.regularization.unwrap_or(1e-8);
            regularized_cov
                .pseudo_inverse(eps)
                .expect("Pseudo-inverse failed")
        });

        let denom = (ones.transpose() * &inv_cov * &ones)[(0, 0)];
        let weights = &inv_cov * &ones / denom;

        (0..n).map(|i| (i, weights[i])).collect()
    }
}

The mvo_allocation_with_config method calculates the MVO allocation for each asset in the portfolio. It takes a MvoConfig struct as an argument, which contains optional parameters for shrinkage and regularization.

Now let's add a default, so we can call it .mvo_allocation without any arguments.

impl PortfolioAllocator {
    pub fn mvo_allocation(&self) -> HashMap<usize, f64> {
        self.mvo_allocation_with_config(&MvoConfig::default())
    }
}

This helps us keep our API clean like the other allocation methods.

Now let's add the MvoConfig struct.

/// `MvoConfig` provides optional configuration parameters for
/// the Mean-Variance Optimization (MVO) allocation strategy.
///
/// Both fields are optional. If omitted, standard MVO without
/// regularization or shrinkage is applied.
#[derive(Debug, Clone)]
pub struct MvoConfig {
    /// Optional regularization parameter (ε).
    /// Adds ε * I to the covariance matrix to improve numerical stability.
    /// Recommended small values are in the range of 1e-6 to 1e-3.
    /// If `None`, no regularization is applied.
    ///
    /// https://en.wikipedia.org/wiki/Tikhonov_regularization
    pub regularization: Option<f64>,

    /// Optional shrinkage intensity (λ) toward the identity matrix.
    /// The covariance matrix becomes: λ * I + (1 - λ) * Σ
    /// Helps mitigate estimation error in empirical covariances.
    /// If `None`, no shrinkage is applied.
    ///
    /// https://en.wikipedia.org/wiki/Shrinkage_estimator
    pub shrinkage: Option<f64>,
}

impl Default for MvoConfig {
    fn default() -> Self {
        Self {
            regularization: None,
            shrinkage: None,
        }
    }
}

Here we add our first comments to the code, which is a good practice to help other developers understand what the code is doing and automatically shows up in crates.io and docs.rs, once we publish our library.

Adding unit tests

Now let's add some unit tests to verify that our allocation methods are working correctly. At the bottom on your src/lib.rs file, add the following:

#[cfg(test)]
mod tests {
    use super::*;
    use nalgebra::dmatrix;

    #[test]
    fn test_ew_allocation() {
        let prices = dmatrix![
            100.0, 200.0, 300.0;
            110.0, 210.0, 310.0;
            120.0, 220.0, 320.0
        ];
        let allocator = PortfolioAllocator::new(prices);
        let ew_weights = allocator.ew_allocation();
        assert_eq!(ew_weights.len(), 3);
        assert!((ew_weights.values().sum::<f64>() - 1.0).abs() < 1e-6);
    }
    #[test]
    fn test_single_asset() {
        let prices = dmatrix![
            100.0;
            110.0;
            120.0
        ];
        let allocator = PortfolioAllocator::new(prices);
        let ew_weights = allocator.ew_allocation();
        assert_eq!(ew_weights.len(), 1);
        assert_eq!(ew_weights.get(&0), Some(&1.0));
    }
}

That test checks that the equal weight allocation method returns the correct number of weights and that they sum to 1.0. This is the easy one.

Now let's do the same for MVO:

#[cfg(test)]
mod tests {
    use super::*;
    use nalgebra::dmatrix;

    #[test]
    fn test_mvo_allocation() {
        let prices = dmatrix![
            100.0, 200.0, 300.0;
            110.0, 210.0, 310.0;
            120.0, 220.0, 320.0
        ];
        let allocator = PortfolioAllocator::new(prices);
        let mvo_weights = allocator.mvo_allocation();
        assert_eq!(mvo_weights.len(), 3);
        assert!((mvo_weights.values().sum::<f64>() - 1.0).abs() < 1e-6);
    }

   #[test]
    fn test_mvo_allocation_with_config_variants() {
        let prices = dmatrix![
            100.0, 200.0, 300.0;
            110.0, 210.0, 310.0;
            120.0, 220.0, 320.0
        ];
        let allocator = PortfolioAllocator::new(prices);

        // 1. No regularization or shrinkage
        let config_none = MvoConfig::default();
        let weights_none = allocator.mvo_allocation_with_config(&config_none);
        assert_eq!(weights_none.len(), 3);
        assert!((weights_none.values().sum::<f64>() - 1.0).abs() < 1e-6);

        // 2. Regularization only
        let config_reg = MvoConfig {
            regularization: Some(1e-6),
            shrinkage: None,
        };
        let weights_reg = allocator.mvo_allocation_with_config(&config_reg);
        assert_eq!(weights_reg.len(), 3);
        assert!((weights_reg.values().sum::<f64>() - 1.0).abs() < 1e-6);

        // 3. Shrinkage only
        let config_shrink = MvoConfig {
            regularization: None,
            shrinkage: Some(0.1),
        };
        let weights_shrink = allocator.mvo_allocation_with_config(&config_shrink);
        assert_eq!(weights_shrink.len(), 3);
        assert!((weights_shrink.values().sum::<f64>() - 1.0).abs() < 1e-6);

        // 4. Both regularization and shrinkage
        let config_both = MvoConfig {
            regularization: Some(1e-6),
            shrinkage: Some(0.2),
        };
        let weights_both = allocator.mvo_allocation_with_config(&config_both);
        assert_eq!(weights_both.len(), 3);
        assert!((weights_both.values().sum::<f64>() - 1.0).abs() < 1e-6);
    }

    #[test]
    fn test_mvo_allocation_pseudo_inverse_succeeds() {
        // Create an allocator with any prices
        let prices = dmatrix![
            100.0, 200.0, 300.0;
            101.0, 201.0, 301.0;
            102.0, 202.0, 302.0
        ];
        let mut allocator = PortfolioAllocator::new(prices);

        // Force a corrupted covariance matrix with NaN
        allocator.cov_matrix = dmatrix![
            f64::NAN, 0.0, 0.0;
            0.0, 1.0, 0.0;
            0.0, 0.0, 1.0
        ];

        let config = MvoConfig {
            regularization: None,
            shrinkage: None,
        };

        // This should panic on .expect("Pseudo-inverse failed")
        let _ = allocator.mvo_allocation_with_config(&config);
    }

    #[test]
    fn test_mvo_allocation_pseudo_inverse_on_nan_matrix() {
        // Any shape, all NaNs
        let bad_matrix = DMatrix::<f64>::from_element(3, 3, f64::NAN);

        let mut allocator = PortfolioAllocator::new(dmatrix![
            100.0, 200.0, 300.0;
            101.0, 201.0, 301.0;
            102.0, 202.0, 302.0
        ]);

        allocator.cov_matrix = bad_matrix;

        let config = MvoConfig {
            regularization: None,
            shrinkage: None,
        };

        // Will fail at .pseudo_inverse(...).expect(...)
        let _ = allocator.mvo_allocation_with_config(&config);
    }
}

And now let's do the same for HRP:

#[cfg(test)]
mod tests {
    use super::*;
    use nalgebra::dmatrix;

    #[test]
    fn test_hrp_allocation() {
        let prices = dmatrix![
            100.0, 200.0, 300.0;
            110.0, 210.0, 310.0;
            120.0, 220.0, 320.0
        ];
        let allocator = PortfolioAllocator::new(prices);
        let hrp_weights = allocator.hrp_allocation();
        assert_eq!(hrp_weights.len(), 3);
        assert!((hrp_weights.values().sum::<f64>() - 1.0).abs() < 1e-6);
    }

    #[test]
    fn test_hrp_allocation_triggers_continue_path() {
        let prices = dmatrix![
            100.0, 200.0, 300.0, 400.0;
            101.0, 201.0, 301.0, 401.0;
            102.0, 202.0, 302.0, 402.0;
            103.0, 203.0, 303.0, 403.0
        ];
        let allocator = PortfolioAllocator::new(prices);
        let weights = allocator.hrp_allocation();

        assert_eq!(weights.len(), 4);
        for &w in weights.values() {
            assert!(w.is_finite());
        }
        let sum: f64 = weights.values().sum();
        assert!((sum - 1.0).abs() < 1e-6);
    }

}

Running the tests

Now that we've added our tests, let's run them with cargo test.

cargo test

You should see output similar to the following:

❯ cargo test
   Compiling portfolio_allocation v0.1.0 (/home/jason/repos/portfolio_allocation)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.88s
     Running unittests src/lib.rs (target/debug/deps/portfolio_allocation-f9220291e58d3b87)

running 9 tests
test tests::test_ew_allocation ... ok
test tests::test_hrp_allocation ... ok
test tests::test_hrp_allocation_triggers_continue_path ... ok
test tests::test_mvo_allocation_pseudo_inverse_succeeds ... ok
test tests::test_mvo_allocation ... ok
test tests::test_mvo_allocation_with_config_variants ... ok
test tests::test_mvo_allocation_pseudo_inverse_on_nan_matrix ... ok
test tests::test_single_asset ... ok

Setting up CI/CD

To set up CI/CD for your Rust project, you can use GitHub Actions. Create a .github/workflows directory in your project root and add a test.yml file with the following content:

name: Tests

on:
  push:
    branches:
      - main
      - develop
      - feature/*
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        rust: [1.78.0, stable, beta, nightly]
        include:
          - rust: stable
            cache-key: stable
          - rust: beta
            cache-key: beta
          - rust: nightly
            cache-key: nightly
          - rust: 1.78.0
            cache-key: msrv

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Rust ${{ matrix.rust }}
        uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ matrix.rust }}

      - name: Fix Cargo.lock for MSRV
        if: matrix.rust == '1.78.0'
        run: |
          if grep -q 'version = "4"' Cargo.lock; then
            echo "Cargo.lock is incompatible with Rust 1.78.0. Regenerating..."
            rm Cargo.lock
            cargo generate-lockfile
          fi

      - name: Cache cargo dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/bin
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: cargo-${{ matrix.cache-key }}-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            cargo-${{ matrix.cache-key }}-${{ runner.os }}-

      - name: Run tests
        run: cargo test --all-features --all-targets

This runs your tests on multiple Rust versions and caches dependencies to speed up the build process.

Cleaning up Cargo.toml

Before publishing let's add some metadata to our Cargo.toml file. The [package] directive tells Cargo about the package's metadata, such as its name, version, authors, license, and description.

Importantly, we'll define a minimum Rust version. Here we've chosen 1.78.0 beccause that's the version where Rust Analyzer, a great tool, was introduced into the toolchain.

[package]
name = "portfolio_allocation"
version = "0.1.0"
edition = "2021"
authors = ["Jason R. Stevens, CFA <jason@thinkjrs.dev>"]
license = "MIT"
description = "A flexible and efficient allocation library for Rust, capable of distributing assets, resources, and other divisible entities."
repository = "https://github.com/thinkjrs/portfolio_allocation"
homepage = "https://github.com/thinkjrs/portfolio_allocation"
documentation = "https://docs.rs/portfolio_allocation"
readme = "README.md"
keywords = ["allocation", "distribution", "portfolio", "resources", "quant"]
categories = ["finance", "mathematics", "science"]
exclude = [".github", ".gitignore", "tests/", "examples/", ".vscode/", ".idea/"]
rust-version = "1.78.0"

Publishing to crates.io

To publish your library to crates.io, you need to create an account on crates.io and obtain an API token. Once you have the token, run the following command in your terminal:

cargo login

This will prompt you for your API key. Paste it into your terminal.

Next, run the following command to publish your library:

cargo publish

This will package your library and upload it to crates.io. You can then use it in other Rust projects by adding it as a dependency in your Cargo.toml file.

[dependencies]
portfolio_allocation = "0.1.0"

Making some improvements

Now let's add some integration tests - those that test the library as a whole, rather than individual functions, just like a user would.

Adding integrataion tests

Make yourself a tests directory, i.e. mkdir tests, and add a file called integration.rs to it.

Inside that file, let's add our tests.

use cutup::{run_portfolio_allocation, PortfolioAllocator};
use nalgebra::dmatrix;

#[test]
fn test_portfolio_allocations() {
    let prices = dmatrix![
        100.0, 200.0, 300.0;
        110.0, 210.0, 310.0;
        120.0, 220.0, 320.0
    ];

    let allocator = PortfolioAllocator::new(prices.clone());

    let ew = allocator.ew_allocation();
    assert_eq!(ew.len(), 3);
    assert!((ew.values().sum::<f64>() - 1.0).abs() < 1e-6);

    let mvo = allocator.mvo_allocation();
    assert_eq!(mvo.len(), 3);
    assert!((mvo.values().sum::<f64>() - 1.0).abs() < 1e-6);

    let hrp = allocator.hrp_allocation();
    assert_eq!(hrp.len(), 3);
    assert!((hrp.values().sum::<f64>() - 1.0).abs() < 1e-6);

    let external = run_portfolio_allocation(prices);
    assert_eq!(external.len(), 3);
    assert!((external.values().sum::<f64>() - 1.0).abs() < 1e-6);
}

We first import the PortfolioAllocator struct and the run_portfolio_allocation function from our library (which just does simple MVO).

Then we define a test function called test_portfolio_allocations. Inside this function, we create a new PortfolioAllocator instance with some sample price data and allocate it.

Now when you run cargo test, it will run both the unit tests and the integration tests.

Next let's add some proper documentation to our code so that we get all the amazing built-ins that cargo and crates.io offer us. When we add these docs they're automatically added to the docs.rs page for our library, which is a great feature and help for library users.

Adding inline documentation

As we already saw with our MvoCongig struct, we can add inline documentation to our code using the /// syntax. This is a great way to provide context and explanations for your code.

In this section, we'll add documentation to our PortfolioAllocator struct and its methods. Let's jump in.

On the pub struct PortfolioAllocator line, add the following just above it:


/// `PortfolioAllocator` handles various portfolio allocation strategies.
/// Supports Mean-Variance Optimization (MVO), Equal Weight (EW), and Hierarchical Risk Parity (HRP).

Next, add documentation to the new method:

/// Creates a new `PortfolioAllocator` from a matrix of asset prices.
///
/// # Arguments
///
/// * `price_data` - A `DMatrix<f64>` representing asset price history (rows: time, columns: assets).
///
/// # Returns
///
/// * A new instance of `PortfolioAllocator`.
///
/// # Example
///
/// ```
/// use cutup::PortfolioAllocator;
/// use nalgebra::dmatrix;
///
/// let prices = dmatrix![
///     100.0, 200.0, 300.0;
///     110.0, 210.0, 310.0;
///     120.0, 220.0, 320.0
/// ];
///
/// let allocator = PortfolioAllocator::new(prices);
/// ```

Now that has a really cool feature built-in. When you run cargo test it will automatically check that the code in the documentation examples actually works. This is a great way to ensure that your documentation is always up-to-date and accurate.

Adding documentation to the allocation methods

Next, let's add documentation to the allocation methods. For example, for the ew_allocation method, we can add:

/// Computes Equal Weight (EW) portfolio allocation.
///
/// # Returns
///
/// * A `HashMap<usize, f64>` where each asset has equal weight.
///
/// # Example
///
/// ```
/// use cutup::PortfolioAllocator;
/// use nalgebra::dmatrix;
///
/// let prices = dmatrix![
///     100.0, 200.0, 300.0;
///     110.0, 210.0, 310.0;
///     120.0, 220.0, 320.0
/// ];
///
/// let allocator = PortfolioAllocator::new(prices);
/// let weights = allocator.ew_allocation();
///
/// assert_eq!(weights.len(), 3);
/// ```

This documentation provides a clear explanation of what the method does, what it returns, and an example of how to use it.

The other two - hrp_allocation and mvo_allocation - can be documented in a similar way. That's your job, so get to it!

Publishing to crates.io, again

Now pop into your Cargo.toml and update the version to 0.1.1 and run the following command to publish your library again:

cargo publish

What we've done

In this chapter, we built a portfolio allocation library in Rust. We started by defining the PortfolioAllocator struct and its methods for different allocation strategies, including Equal Weight (EW), Mean-Variance Optimization (MVO), and Hierarchical Risk Parity (HRP).

We then added unit tests to verify the correctness of our methods and integration tests to ensure that the library works as a whole.

Finally, we added integration tests, documentation to our code, making it easier for users to understand how to use the library and deployed it.

Was this page helpful?