Skip to content
thinkjrsPublic

About

A Rust library for portfolio allocation strategies, providing implementations for a variety of algorithms.

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

Cutup: A Rust Portfolio Allocation Library

Crates.io Build Status codecov Docs.rs License

Cutup is a Rust library for portfolio allocation strategies, providing implementations for various allocation methods.

It's designed to be efficient and easy to use.

Here are some of the allocation methods included in this library:

  • Minimum Variance (GMV), unconstrained or long-only
  • Mean-Variance Optimization (MVO), unconstrained or long-only
  • Maximum Sharpe Ratio (tangency portfolio), unconstrained or long-only
  • Equal Weight Allocation (EW)
  • Inverse Volatility and Inverse Variance
  • Maximum Diversification
  • Risk Parity (Equal Risk Contribution) and Risk Budgeting
  • Hierarchical Risk Parity (HRP)
  • Hierarchical Equal Risk Contribution (HERC)
  • Minimum CVaR (expected shortfall), from historical scenarios
  • Black-Litterman expected returns, to feed mean-variance or max Sharpe
  • Rebalancing from current weights: turnover caps, turnover penalties and transaction costs
  • Asset labels, such as tickers, on the data and the weights, and in every per-asset setting

Inputs are plain Rust slices, so you don't need a matrix library. cutup uses nalgebra internally, and its optional nalgebra feature accepts DMatrix input directly.

Features

  • Minimum Variance: The global minimum variance portfolio, using only the covariance matrix. Optional shrinkage, with a fixed intensity or Ledoit-Wolf's estimate, and regularization via CovarianceConfig, which every covariance-based method takes, in place of the sample covariance an exponentially weighted one or a semicovariance, or a covariance of your own with CovarianceConfig::matrix.
  • Long-Only Minimum Variance: The lowest-variance portfolio with no short positions, solved exactly by a built-in active-set solver. Cap or floor each asset's weight with WeightBounds, and the total weight of groups of assets with WeightBounds::group.
  • Mean-Variance Optimization: The Markowitz portfolio for a given risk aversion, target return or target volatility, and the efficient frontier, using expected returns (the sample mean by default, an exponentially weighted mean, CAPM returns, or James-Stein and Bayes-Stein shrinkage) and the covariance matrix.
  • Long-Only Mean-Variance: The same objectives with no short positions, solved exactly by the active-set solver. Also takes WeightBounds and their groups.
  • Maximum Sharpe Ratio: The tangency portfolio for a given risk-free rate, using expected returns and the covariance matrix.
  • Long-Only Maximum Sharpe Ratio: The highest Sharpe ratio with no short positions, solved exactly by the active-set solver.
  • EW Allocation: Assigns equal weights to all assets.
  • Inverse Volatility / Inverse Variance: Weights each asset by 1 / σ or 1 / σ², ignoring correlations. Simple risk-based weights that are always positive.
  • Maximum Diversification: The long-only portfolio with the highest ratio of weighted average volatility to portfolio volatility, using only the covariance matrix.
  • Risk Parity: Long-only weights where every asset contributes the same share of portfolio variance, accounting for correlations. Set unequal shares with RiskParity::risk_budgets.
  • HRP Allocation: Uses hierarchical clustering and recursive bisection for risk-based allocation. Choose single, complete, average or Ward linkage with Hrp::linkage, and inspect the cluster order with Hrp::cluster_order.
  • HERC Allocation: Cuts the same clustering into groups, chosen by a gap statistic or set with Herc::clusters, then splits risk down the tree and by inverse variance within each group.
  • Minimum CVaR: The long-only portfolio with the lowest average loss over the worst 1 − β of the historical return scenarios, solved exactly as a linear program by a built-in simplex solver. Takes weight bounds, groups and a target return, and cvar measures any portfolio's CVaR.
  • Rebalancing: From your current weights, cap the turnover of min variance and mean-variance, with a risk aversion or a target return, penalize it, or, for mean-variance, charge proportional transaction costs against the expected return. With a target return, costs make it a floor on the net return. Solved exactly by the same active-set solver.
  • Black-Litterman: Expected returns that blend the returns the market portfolio implies with your views on some assets, each with a variance or an Idzorek confidence. Pass them to MeanVariance or MaxSharpe, with the posterior covariance if you like.
  • Portfolio Statistics: data.stats(&weights) gives any weights' expected return, volatility, Sharpe ratio, diversification ratio, risk contributions and CVaR, to compare methods on the same data, and data.stats_with(&weights, &covariance) the same under a covariance config.
  • Rolling Allocation: data.rolling(60).allocate(method) recomputes any method's weights over rolling windows of the data, giving weights over time, with each window's own result.
  • JSON: With the serde feature, weights, bounds, covariance configs and every method serialize and deserialize, each method as an object of its builders' settings.
  • Fully Unit-Tested: Includes test cases for correctness verification.

Choosing a method

Each method is a value you pass to Returns::allocate. Methods that can short are long-only unless you call .allow_short().

Method Uses Long-only Notes
EqualWeight nothing ✅ A baseline that needs no estimates and can't fail.
InverseVolatility volatilities ✅ Riskier assets get less; correlations are ignored.
InverseVariance variances ✅ A stronger tilt than inverse volatility. Equals min variance when assets are uncorrelated.
MinVariance covariance ✅ by default The lowest-variance portfolio. Long-only, it usually concentrates in a few low-risk assets and holds none of the rest; .bounds(..) caps or floors each asset, or groups of them. Long-short, it's sensitive to estimation error, so consider .shrinkage(..). From .current_weights(..), .max_turnover(..) and .turnover_penalty(..) limit trading.
MeanVariance covariance, expected returns ✅ by default Markowitz, built from MeanVariance::risk_aversion(..) or MeanVariance::target_return(..). Most sensitive to the expected returns you supply. Long-only, a target return must lie between the lowest and highest expected return, or the lowest and highest return the bounds and groups allow; .bounds(..) caps or floors each asset, or groups of them. .transaction_costs(..), .max_turnover(..) and .turnover_penalty(..) rebalance from .current_weights(..); with a target return and costs, the target is a floor on the return net of them.
MaxSharpe covariance, expected returns, risk-free rate ✅ by default The tangency portfolio. Long-only, it only needs some asset's expected return above the risk-free rate; long-short, the risk-free rate must be below the min variance portfolio's return.
MinCvar return scenarios ✅ The lowest average loss in the worst 1 − β of the scenarios, 5% by default. Looks at the losses themselves rather than a covariance, so it reacts to skew and fat tails; needs enough scenarios for a tail, at least 20 at 95%. .bounds(..) and .target_return(..) work as for mean-variance.
MaxDiversification covariance ✅ The most diversified portfolio: the highest weighted average volatility per unit of portfolio volatility. Inverse volatility when assets are uncorrelated. Needs every long-only portfolio to have some variance, so consider shrinkage when data is short.
RiskParity covariance ✅ Every asset contributes the same risk, or the shares set by .risk_budgets(..). Diversifies more than inverse volatility when correlations differ. Needs every long-only portfolio to have some variance, so consider shrinkage when data is short.
Hrp covariance, correlations ✅ Clusters correlated assets, then splits risk between clusters. Never inverts the covariance, so it's robust when data is short.
Herc covariance, correlations ✅ Like HRP, but splits where the clustering tree branches and groups similar assets, so the result doesn't depend on column order.

Long-only methods never return negative weights. The others can. To see every method on the same data:

cargo run --example compare

Installation

Add cutup to your Cargo.toml:

[dependencies]
cutup = "0.3.9"

cutup 0.3 needs Rust 1.85 or newer. cutup 0.2 needs 1.78.

To pass nalgebra matrices in and get DVector weights out, turn on the nalgebra feature. It tracks the nalgebra version cutup depends on, currently 0.33:

[dependencies]
cutup = { version = "0.3.9", features = ["nalgebra"] }

To read methods from JSON and write weights to it, turn on the serde feature: see JSON with serde.

[dependencies]
cutup = { version = "0.3.9", features = ["serde"] }

For TypeScript types that match that JSON, turn on the ts-rs feature, which turns on serde too. Every type that serializes then implements ts-rs' TS. Exporting them takes ts-rs 12 as a dependency of your own, for its TS trait and its Config. A key that's left out is optional in TypeScript, and a value JSON writes as null, such as no cap, is number | null.

[dependencies]
cutup = { version = "0.3.9", features = ["ts-rs"] }
ts-rs = "12"

Then export each type, with the types it uses, from a build script or a test:

use cutup::MeanVariance;
use ts_rs::TS;

MeanVariance::export_all(&ts_rs::Config::new().with_out_dir("bindings"))?;

Usage

Pass prices to Returns::from_prices, one row per period and one value per asset in each row. Rows can be arrays, Vecs or slices. Prices are converted to simple returns, and every covariance-based method works on the covariance of those returns. If you already have returns, use Returns::new instead. For log returns, convert either with data.log_returns(): see Log returns.

For data in one flat, row-major slice, such as a JavaScript Float64Array or a CSV read row by row, use from_prices_flat(&values, assets) or new_flat(&values, assets). With the nalgebra feature, from_prices_matrix and from_matrix take a DMatrix.

Build a method from default(), or for mean-variance from its objective, and chained settings, and pass it to allocate. Any type that implements the Allocation trait works, including your own. The PortfolioAllocator methods from 0.2, such as min_variance_allocation, were removed in 0.3.0: see Upgrading from 0.2.

Allocation methods return Weights: one weight per asset, in the same order as the columns of your input, and labeled with the assets: see Labels. Bad input (no assets, too few rows, NaN or infinite values, non-positive prices) returns a CutupError instead of producing meaningless weights.

use cutup::{
    CutupError, EqualWeight, Hrp, InverseVolatility, MeanVariance, MinVariance,
    Returns, WeightBounds,
};

fn main() -> Result<(), CutupError> {
    let prices = [
        [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],
        [131.0, 1525.0, 223.0, 640.0],
        [129.5, 1550.0, 228.0, 635.0],
        [133.0, 1545.0, 226.0, 650.0],
        [135.5, 1560.0, 231.0, 645.0],
        [134.0, 1580.0, 229.0, 660.0],
        [137.0, 1575.0, 234.0, 668.0],
        [136.0, 1600.0, 238.0, 662.0],
        [139.5, 1595.0, 236.0, 675.0],
        [141.0, 1615.0, 241.0, 680.0],
    ];

    let data = Returns::from_prices(&prices)?;

    let min_variance_weights = data.allocate(&MinVariance::default().allow_short())?;
    // No asset above 40%.
    let capped_weights =
        data.allocate(&MinVariance::default().bounds(WeightBounds::uniform(0.0, 0.4)))?;
    // Risk aversion is in the units of your returns: per-period returns have small variances,
    // so meaningful values are large. Shrinking the sample covariance toward its average
    // variance reduces estimation error.
    let mean_variance_weights =
        data.allocate(&MeanVariance::risk_aversion(100.0).shrinkage(0.1))?;
    let ew_weights = data.allocate(&EqualWeight)?;
    let inverse_volatility_weights = data.allocate(&InverseVolatility)?;
    let hrp_weights = data.allocate(&Hrp::default())?;

    println!("Min Variance Weights: {:?}", min_variance_weights.as_slice());
    println!("Capped Min Variance Weights: {:?}", capped_weights.as_slice());
    println!("Mean-Variance Weights: {:?}", mean_variance_weights.as_slice());
    println!("EW Weights: {:?}", ew_weights.as_slice());
    println!("Inverse Volatility Weights: {:?}", inverse_volatility_weights.as_slice());
    println!("HRP Weights: {:?}", hrp_weights.as_slice());

    Ok(())
}

Solvers can leave weights like 0.0003 on assets they barely use. weights.clean(0.001) sets every weight smaller in size than 0.001 to 0 and rescales the rest to sum to 1. Unlike PyPortfolioOpt's clean_weights, it rescales, so the result can be passed back as current weights. Rescaling can move a weight just past its bounds or a turnover cap.

Labels

Name the assets with labeled, for example with their tickers. The weights carry the labels, and every setting that takes an asset or a value per asset takes them too: bounds, Black-Litterman views and market weights, current weights, expected returns, risk budgets and transaction costs. Values per asset can be in column order, as before, or pairs of a label and a value, in any order, as an array, a Vec or a HashMap. Without labels, each asset's label is its column: "0", "1", and so on.

use cutup::{CutupError, MinVariance, Returns, WeightBounds};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?.labeled(["SPY", "TLT", "GLD"])?;

    let weights = data.allocate(
        &MinVariance::default()
            // No asset above 60%, and at most 10% in gold.
            .bounds(WeightBounds::uniform(0.0, 0.6).asset("GLD", 0.0, 0.1))
            // Current weights left out are 0.
            .current_weights([("SPY", 0.6), ("TLT", 0.4)])
            .max_turnover(0.2),
    )?;
    assert!(weights["GLD"] <= 0.1 + 1e-12);
    for (ticker, weight) in weights.labeled() {
        println!("{ticker}: {weight:.4}");
    }
    Ok(())
}

println!("{weights}") prints the same labels and weights as a table, with 4 decimals, or {weights:.2} for 2. weights.iter(), weights[i] and as_slice() still go by column. A label that isn't in the data returns CutupError::UnknownAsset, and one given twice CutupError::DuplicateAsset. Errors about one asset, such as ZeroVariance, give both its label and its column.

Group constraints

WeightBounds::group(members, lower, upper) limits the total weight of several assets at once, such as every ad channel on one platform, every signal from one source or every fund in one asset class, on top of each asset's own bounds. Members are labels or columns. Groups can overlap, an asset listed twice in one counts once, and an upper limit of f64::INFINITY means no cap. MinVariance, MeanVariance with any objective, with or without rebalancing, Returns::frontier and MinCvar take them.

use cutup::{CutupError, MinVariance, Returns, WeightBounds};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.004,  0.002,  0.030, -0.020,  0.015],
        [-0.002,  0.003, -0.025,  0.030, -0.010],
        [ 0.003, -0.001,  0.040, -0.015,  0.020],
        [-0.001,  0.004, -0.030,  0.025, -0.012],
        [ 0.002, -0.002,  0.020, -0.010,  0.008],
        [ 0.003,  0.001, -0.015,  0.020, -0.005],
        [-0.003,  0.002,  0.035, -0.025,  0.018],
        [ 0.002, -0.001, -0.020,  0.015, -0.008],
    ];
    let data = Returns::new(&returns)?
        .labeled(["search", "shopping", "video", "social", "display"])?;

    // Alone, min variance puts 83% in search and shopping, the two steadiest, and 6% in
    // video and social.
    let bounds = WeightBounds::default()
        // At most 40% in search and shopping together.
        .group(["search", "shopping"], 0.0, 0.4)
        // At least 30% in video and social together.
        .group(["video", "social"], 0.3, 1.0);
    let weights = data.allocate(&MinVariance::default().bounds(bounds))?;
    assert!((weights["search"] + weights["shopping"] - 0.4).abs() < 1e-12);
    assert!((weights["video"] + weights["social"] - 0.3).abs() < 1e-12);
    println!("{weights}");
    Ok(())
}

A group holds to the rounding of its sum, as the weights sum to 1 to theirs, and limits within 1e-12 of what its members can hold together are taken as rounding. An empty group, a floor that isn't finite and nonnegative, or a cap that's NaN or below the floor returns CutupError::InvalidConfig, and a member that isn't one of the assets CutupError::UnknownAsset. A group no portfolio within the bounds can meet returns CutupError::InfeasibleGroups, naming it by its members' labels joined with " + ", and groups that can each be met but not all at once return it with no name. With groups, a target return must lie in the range of returns the bounds and groups allow together. For a longer example, with the plain answer, a target return and the error, run:

cargo run --example groups

Log returns

Returns::from_prices computes simple returns, p_t / p_{t-1} - 1, as pandas' pct_change does. data.log_returns() converts any Returns to log returns, ln(1 + r), keeping the labels, and every method then works on those. It's PyPortfolioOpt's returns_from_prices(prices, log_returns=True), computed with ln_1p so small returns keep their digits. A return of −100% or worse has no log, and gives CutupError::TotalLoss. Log returns don't add up across assets, so statistics and the methods that weight returns, such as mean-variance, work with Σwᵢ ln(1 + rᵢ), close to the portfolio's log return ln(1 + Σwᵢrᵢ) only while returns are small.

use cutup::{CutupError, MinVariance, Returns};

fn main() -> Result<(), CutupError> {
    let prices = [
        [125.0, 1500.0, 210.0],
        [123.0, 1520.0, 215.0],
        [130.0, 1510.0, 220.0],
        [128.0, 1530.0, 225.0],
        [131.0, 1525.0, 223.0],
    ];
    let data = Returns::from_prices(&prices)?.log_returns()?;
    let weights = data.allocate(&MinVariance::default())?;
    println!("Min variance, log returns: {:?}", weights.as_slice());
    Ok(())
}

Target volatility

MeanVariance::target_volatility(σ) gives the highest expected return at a volatility of at most σ, the efficient frontier portfolio at that risk, like PyPortfolioOpt's efficient_risk. σ is a standard deviation per period, in the units of the returns. Below the minimum variance portfolio's volatility no portfolio is that safe, and it returns CutupError::TargetVolatilityOutOfRange with that minimum. Long-only, from the volatility of the highest-return portfolio within the bounds up, the limit doesn't bind and that portfolio is returned.

use cutup::{CutupError, MeanVariance, Returns, WeightBounds};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?;
    let method = MeanVariance::target_volatility(0.006)
        .expected_returns([0.004, 0.006, 0.002])
        .bounds(WeightBounds::uniform(0.0, 0.6));
    let weights = data.allocate(&method)?;
    let stats = data.stats(&weights)?;
    println!("{:.4} at a volatility of {:.4}", stats.expected_return(), stats.volatility());
    Ok(())
}

Efficient frontier

data.frontier(&method, n) traces the efficient frontier: n portfolios with the least variance for their expected return, at returns evenly spaced from the minimum variance portfolio's to the highest reachable, each with its expected return, volatility and weights. It uses the method's expected returns, covariance settings and bounds, and ignores its objective. Long-only, it ends at the highest-return portfolio within the bounds; long-short, where the return has no limit, at the highest expected return of any one asset. When there's no range of returns to cover, as when every expected return is the same, it's the minimum variance portfolio alone.

use cutup::{CutupError, MeanVariance, Returns, WeightBounds};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?;
    let method = MeanVariance::risk_aversion(1.0)
        .expected_returns([0.004, 0.006, 0.002])
        .bounds(WeightBounds::uniform(0.0, 0.6));
    for point in data.frontier(&method, 5)? {
        println!(
            "{:.4} at a volatility of {:.4}: {:?}",
            point.expected_return(),
            point.volatility(),
            point.weights().as_slice()
        );
    }
    Ok(())
}

Rolling allocation

data.rolling(window) gives windows of window periods each, and .step(k) moves each one k periods past the one before, 1 by default. .allocate(method) then allocates with any method, by value or by reference, over each window, oldest first. The result is weights over time. A period is a row of returns: from prices, period t is the return from row t to row t + 1.

The last window ends at the last period, so with a step over 1 the first periods are the ones left out, not the latest. Each window is the method on its own rows alone, with its own covariance and expected returns and the data's labels. It's pure computation, not a backtest: no window's weights feed the next's current weights.

use cutup::{CutupError, MinVariance, Returns, WeightBounds};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
        [ 0.004, -0.008,  0.003],
        [-0.006,  0.011, -0.002],
        [ 0.001,  0.002, -0.004],
    ];
    let data = Returns::new(&returns)?.labeled(["SPY", "TLT", "GLD"])?;

    // Windows of 4 periods, 2 apart: 0..4, 2..6 and 4..8.
    let method = MinVariance::default().bounds(WeightBounds::uniform(0.0, 0.6));
    for window in data.rolling(4).step(2).allocate(method)? {
        match window.weights() {
            Ok(weights) => println!("{:?}: {weights:.2}", window.periods()),
            Err(error) => println!("{:?}: {error}", window.periods()),
        }
    }
    Ok(())
}

Each window has its own Result, so a window the method can't allocate doesn't stop the others. An example is ZeroVariance, where an asset's returns don't move over a stretch. To stop at the first window that fails instead, collect into a Result, as in let weights: Result<Vec<_>, _> = windows.map(Window::into_weights).collect();. A window under 2 periods or longer than the data, or a step of 0, is CutupError::InvalidConfig from allocate, before any window runs. For a longer example, run:

cargo run --example rolling

Rebalancing

Give MinVariance or MeanVariance your current weights to rebalance from them. Turnover is the total weight bought and sold, Σ|wᵢ − w₀ᵢ|. Transaction costs are in units of return, so only mean-variance takes them; for min variance with costs, use mean-variance with zero expected returns.

use cutup::{CutupError, MeanVariance, MinVariance, Returns};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?;
    let current = [0.5, 0.3, 0.2];

    // The lowest-variance portfolio within 10% turnover of the current one.
    let capped = data.allocate(&MinVariance::default().current_weights(current).max_turnover(0.1))?;
    let turnover: f64 = capped.iter().zip(current).map(|(w, c)| (w - c).abs()).sum();
    assert!(turnover <= 0.1 + 1e-12);

    // Mean-variance net of 10 basis points a trade.
    let net = data.allocate(
        &MeanVariance::risk_aversion(100.0)
            .current_weights(current)
            .transaction_costs(0.001),
    )?;
    println!("Rebalanced weights: {:?}", net.as_slice());
    Ok(())
}

Black-Litterman

Black-Litterman estimates expected returns rather than allocating. Start from the market weights (or capitalizations) and the market's risk aversion δ, add views, and pass the result to a method that uses expected returns:

use cutup::{BlackLitterman, CutupError, MeanVariance, Returns, View};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?;

    let mu = BlackLitterman::new([0.5, 0.3, 0.2], 2.5)
        // Asset 0 returns 0.1% a period, and you're 60% sure of it.
        .view(View::absolute(0, 0.001).confidence(0.6))
        // Asset 1 beats asset 2 by 0.05% a period.
        .view(View::relative(1, 2, 0.0005))
        .expected_returns(&data)?;

    let weights = data.allocate(&MeanVariance::risk_aversion(2.5).expected_returns(mu))?;
    println!("Black-Litterman weights: {:?}", weights.as_slice());
    Ok(())
}

posterior_covariance gives the posterior covariance too, Σ + τΣ less what the views resolve, as a CovarianceConfig to pass along with the posterior returns:

use cutup::{BlackLitterman, CutupError, MeanVariance, Returns, View};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?;
    let model = BlackLitterman::new([0.5, 0.3, 0.2], 2.5)
        .view(View::relative(1, 2, 0.0005).confidence(0.7));

    let method = MeanVariance::risk_aversion(2.5)
        .expected_returns(model.expected_returns(&data)?)
        .covariance(model.posterior_covariance(&data)?);
    let weights = data.allocate(&method)?;
    println!("Black-Litterman weights, posterior covariance: {:?}", weights.as_slice());
    Ok(())
}

Exponentially weighted mean

In place of the sample mean, data.exponentially_weighted_mean(half_life) gives each asset's mean with recent periods weighted more: each period's weight halves every half_life periods back from the last row. exponentially_weighted_mean_decay(λ) takes the decay instead. It's pandas' ewm().mean(), the mean CovarianceConfig::exponentially_weighted uses, so the two go together:

use cutup::{CovarianceConfig, CutupError, MaxSharpe, Returns};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.006,  0.008, -0.003],
    ];
    let data = Returns::new(&returns)?;
    let method = MaxSharpe::default()
        .expected_returns(data.exponentially_weighted_mean(3.0)?)
        .covariance(CovarianceConfig::exponentially_weighted(3.0));
    let weights = data.allocate(&method)?;
    println!("Max Sharpe, recent: {:?}", weights.as_slice());
    Ok(())
}

CAPM returns

data.capm_returns(market_weights, risk_free_rate) gives each asset the expected return the capital asset pricing model does, r_f + βᵢ(E[r_m] − r_f): its beta to the market portfolio, from the sample covariance, times the market's mean excess return. The market weights are one per asset, in column order or by label, and capitalizations work too. With equal weights it's PyPortfolioOpt's capm_return, per period and without compounding. For the returns the market weights imply through a risk aversion instead, δΣw, use BlackLitterman::prior_returns.

use cutup::{CutupError, MaxSharpe, Returns};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.006,  0.008, -0.003],
    ];
    let data = Returns::new(&returns)?.labeled(["SPY", "TLT", "GLD"])?;
    // Market capitalizations, and a risk-free rate of 0.01% a period.
    let capm = data.capm_returns([("SPY", 600.0), ("TLT", 300.0), ("GLD", 100.0)], 0.0001)?;
    let weights = data.allocate(&MaxSharpe::default().risk_free_rate(0.0001).expected_returns(capm))?;
    println!("Max Sharpe, CAPM returns: {:?}", weights.as_slice());
    Ok(())
}

James-Stein and Bayes-Stein means

Sample means are noisy, and mean-variance and max Sharpe amplify their differences. data.james_stein_mean() shrinks them toward their average, and data.bayes_stein_mean() toward the minimum variance portfolio's mean (Jorion, 1986), each by an intensity estimated from the data: the closer the means are next to their noise, the more they're shrunk. James-Stein keeps the average of the means, and Bayes-Stein the minimum variance portfolio's return.

use cutup::{CutupError, MeanVariance, Returns};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005,  0.003],
        [-0.004,  0.015,  0.002, -0.001],
        [ 0.007,  0.003, -0.006,  0.004],
        [ 0.002, -0.001,  0.004,  0.000],
        [-0.006,  0.008, -0.003,  0.002],
        [ 0.004, -0.002,  0.001, -0.003],
    ];
    let data = Returns::new(&returns)?;
    let method = MeanVariance::risk_aversion(3.0).expected_returns(data.bayes_stein_mean()?);
    let weights = data.allocate(&method)?;
    println!("Mean-variance, Bayes-Stein: {:?}", weights.as_slice());
    Ok(())
}

Ledoit-Wolf shrinkage

.shrinkage(λ) takes a fixed intensity. .ledoit_wolf(target) estimates it from the returns instead, with Ledoit and Wolf's formulas for the intensity that minimizes the expected error, toward one of two targets: ShrinkageTarget::ConstantVariance, the average variance with no correlation, as .shrinkage(λ) uses, or ShrinkageTarget::ConstantCorrelation, each asset's own variance with the average correlation between them. Whichever of the two is set last applies. Its intensity is for the sample covariance, so it isn't supported with the other estimates below or a covariance of your own.

use cutup::{CutupError, MinVariance, Returns, ShrinkageTarget};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.006,  0.008, -0.003],
    ];
    let data = Returns::new(&returns)?;
    let method = MinVariance::default().ledoit_wolf(ShrinkageTarget::ConstantCorrelation);
    let weights = data.allocate(&method)?;
    println!("Min variance, shrunk: {:?}", weights.as_slice());
    Ok(())
}

Exponentially weighted covariance and semicovariance

In place of the sample covariance, CovarianceConfig::exponentially_weighted(half_life) weights recent periods more: each period's weight halves every half_life periods back from the last row. exponentially_weighted_decay(λ) takes the decay instead, such as RiskMetrics' daily 0.94, a half-life of about 11.2 periods. It's pandas' ewm().cov(), so a long half-life gives the sample covariance.

For downside risk, CovarianceConfig::semicovariance() counts only the returns below each asset's mean, and semicovariance_below(threshold) only those below a threshold such as 0 or a risk-free rate per period. Shrinkage and regularization apply on top of each.

use cutup::{CovarianceConfig, CutupError, MinVariance, Returns, RiskParity};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.006,  0.008, -0.003],
    ];
    let data = Returns::new(&returns)?;
    let recent = CovarianceConfig::exponentially_weighted(3.0);
    let downside = CovarianceConfig::semicovariance_below(0.0).shrinkage(0.1);

    let min_variance = data.allocate(&MinVariance::default().covariance(recent))?;
    let risk_parity = data.allocate(&RiskParity::default().covariance(downside))?;
    println!("Min variance, recent: {:?}", min_variance.as_slice());
    println!("Risk parity, downside: {:?}", risk_parity.as_slice());
    Ok(())
}

Your own covariance

Every covariance-based method uses the sample covariance of your returns unless you give it another estimate, such as one from a factor model or another library, with CovarianceConfig::matrix. It takes one row and one column per asset, in column order, and checks the matrix is symmetric and positive semidefinite, to rounding. Shrinkage and regularization still apply on top of it.

use cutup::{CovarianceConfig, CutupError, MinVariance, Returns, RiskParity};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
    ];
    let data = Returns::new(&returns)?;
    let covariance = CovarianceConfig::matrix(&[
        [1.0e-4, 2.0e-5, 0.0],
        [2.0e-5, 4.0e-4, 1.0e-5],
        [0.0, 1.0e-5, 9.0e-5],
    ])?;

    let min_variance = data.allocate(&MinVariance::default().covariance(covariance.clone()))?;
    let risk_parity = data.allocate(&RiskParity::default().covariance(covariance.shrinkage(0.1)))?;
    println!("Min variance: {:?}", min_variance.as_slice());
    println!("Risk parity: {:?}", risk_parity.as_slice());
    Ok(())
}

Portfolio statistics

data.stats(&weights) measures any weights on the same returns: the expected return, the volatility, the Sharpe ratio at a risk-free rate, the diversification ratio, each asset's share of the variance, and CVaR at a confidence. They're per period, in the units of the returns, with the sample mean and covariance, so methods are compared on the same footing whatever settings produced their weights. A riskless portfolio, such as cash, has a volatility of 0, and no Sharpe ratio, diversification ratio or risk shares.

use cutup::{CutupError, Hrp, MinVariance, Returns, RiskParity};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?;

    for (name, weights) in [
        ("min variance", data.allocate(&MinVariance::default())?),
        ("risk parity", data.allocate(&RiskParity::default())?),
        ("HRP", data.allocate(&Hrp::default())?),
    ] {
        let stats = data.stats(&weights)?;
        println!(
            "{name}: return {:.5}, volatility {:.5}, Sharpe {:.2}, risk shares {:.2?}, CVaR {:.5}",
            stats.expected_return(),
            stats.volatility(),
            // 4% a year, for daily returns.
            stats.sharpe_ratio(0.04 / 252.0).unwrap_or(f64::NAN),
            stats.risk_contributions().unwrap_or_default(),
            stats.cvar(0.8)?,
        );
    }
    Ok(())
}

data.stats_with(&weights, &covariance) measures them under a CovarianceConfig instead, such as the one a method was given, an exponentially weighted covariance or a Black-Litterman posterior: the volatility, the Sharpe ratio, the diversification ratio and the risk shares use its matrix, while the expected return and CVaR stay those of the returns. With the default config it's data.stats(&weights).

use cutup::{CovarianceConfig, CutupError, MinVariance, Returns};

fn main() -> Result<(), CutupError> {
    let returns = [
        [ 0.010, -0.020,  0.005],
        [-0.004,  0.015,  0.002],
        [ 0.007,  0.003, -0.006],
        [ 0.002, -0.001,  0.004],
        [-0.003,  0.006,  0.001],
    ];
    let data = Returns::new(&returns)?;
    let recent = CovarianceConfig::exponentially_weighted(2.0);
    let weights = data.allocate(&MinVariance::default().covariance(recent.clone()))?;

    let sample = data.stats(&weights)?;
    let weighted = data.stats_with(&weights, &recent)?;
    println!(
        "volatility {:.5} on the sample covariance, {:.5} on the one it was optimized for",
        sample.volatility(),
        weighted.volatility(),
    );
    Ok(())
}

JSON with serde

With the serde feature, these all serialize and deserialize:

  • Weights, Asset and PerAsset;
  • WeightBounds and CovarianceConfig;
  • every method, plus BlackLitterman and View;
  • FrontierPoint.

CutupError and rolling windows serialize, but don't deserialize. The format follows the API rather than cutup's internals: each method or config is an object whose keys are its builders, each with its argument. A builder without one, such as allow_short or semicovariance, takes true.

use cutup::{MeanVariance, Returns, Weights};

let method: MeanVariance = serde_json::from_str(r#"{
    "risk_aversion": 50.0,
    "bounds": {
        "upper": 0.6,
        "groups": [{"members": ["SPY", "TLT"], "lower": 0.0, "upper": 0.7}]
    },
    "covariance": {"shrinkage": 0.1}
}"#)?;
// The same as MeanVariance::risk_aversion(50.0)
//     .bounds(WeightBounds::uniform(0.0, 0.6).group(["SPY", "TLT"], 0.0, 0.7))
//     .shrinkage(0.1)
let weights = data.allocate(&method)?;
let json = serde_json::to_string(&weights)?; // {"SPY":…,"TLT":…,"GLD":…}
let back: Weights = serde_json::from_str(&json)?;

The forms:

Type JSON
Asset its column, 2, or its label, "SPY"
PerAsset [0.6, 0.4] in column order, {"SPY": 0.6, "TLT": 0.4} by label, or [[0, 0.6], ["TLT", 0.4]] pairs
Weights {"SPY": 0.6, "TLT": 0.4}, in column order
WeightBounds {"lower": 0.0, "upper": 0.3, "assets": [{"asset": "GLD", "lower": 0.0, "upper": 0.1}], "groups": [{"members": ["SPY", "TLT"], "lower": 0.0, "upper": 0.4}]}
CovarianceConfig {"exponentially_weighted": 11.2, "shrinkage": 0.1}, or {"matrix": [[1e-4, 0.0], [0.0, 4e-4]]}, {"semicovariance": true}, {"ledoit_wolf": "constant_correlation"}
A method {"target_return": 0.001, "expected_returns": {"SPY": 0.0012, "TLT": 0.0004}, "allow_short": true}; {} for EqualWeight or a method's defaults
View {"absolute": "SPY", "q": 0.001}, {"relative": ["SPY", "TLT"], "q": 0.0005, "confidence": 0.5} or {"portfolio": [0.5, -0.5], "q": 0.0002, "variance": 1e-6}
A rolling window {"start": 0, "end": 60, "weights": {…}}, or "error" in place of "weights"
CutupError "no_assets", or {"invalid_config": {"field": "risk_aversion", "value": -1.0}}

Rules:

  • Defaults: a setting that's left out is the default, and a setting at its default is left out when writing.
  • Errors:
    • An unknown key, such as "uper".
    • Keys that exclude each other, such as bounds with allow_short, or two objectives.
    • A missing required key, such as mean-variance's objective.
  • No cap is null: an upper bound of f64::INFINITY is written as null, which JSON can carry. Only an explicit null reads back as infinity, so an asset's or a group's missing upper is still an error.
  • Checks: a given covariance matrix is checked as CovarianceConfig::matrix checks it. Everything else is checked when a method uses it, as for the builders.
  • Formats: values per asset take several forms and limits can be null, so this needs a self-describing format such as JSON, not one like bincode.

Upgrading from 0.2

0.3.0 removed the PortfolioAllocator methods, run_portfolio_allocation and the config structs that were deprecated in 0.2.8. Their 0.2.8 docs still have the deprecation notes. In 0.2.8, each one returned exactly what allocate returns for its replacement, so switching changes no weights. The old min variance, mean-variance and max Sharpe methods are long-short unless their name starts with long_only_, while MinVariance, MeanVariance and MaxSharpe are long-only unless you call .allow_short(). The other methods were always long-only.

Removed Replacement
allocator.ew_allocation() allocator.allocate(&EqualWeight)?
inverse_volatility_allocation() allocate(&InverseVolatility)
inverse_variance_allocation() allocate(&InverseVariance)
min_variance_allocation() allocate(&MinVariance::default().allow_short())
min_variance_allocation_with_config(&c) allocate(&MinVariance::default().allow_short().covariance(c))
long_only_min_variance_allocation() allocate(&MinVariance::default())
long_only_min_variance_allocation_with_config(&c) allocate(&MinVariance::default().covariance(c))
long_only_min_variance_allocation_with_bounds(&c, &b) allocate(&MinVariance::default().covariance(c).bounds(b))
mean_variance_allocation(&MeanVarianceConfig::risk_aversion(γ)) allocate(&MeanVariance::risk_aversion(γ).allow_short())
long_only_mean_variance_allocation(&MeanVarianceConfig::target_return(ρ)) allocate(&MeanVariance::target_return(ρ))
long_only_mean_variance_allocation_with_bounds(&config, &b) allocate(&MeanVariance::risk_aversion(γ).bounds(b)), or target_return(ρ)
max_sharpe_allocation(&config) allocate(&MaxSharpe::default().allow_short())
long_only_max_sharpe_allocation(&config) allocate(&MaxSharpe::default())
max_diversification_allocation(), _with_config(&c) allocate(&MaxDiversification::default()), .covariance(c)
risk_parity_allocation(), _with_config(&config) allocate(&RiskParity::default())
hrp_allocation(), _with_config(&config) allocate(&Hrp::default())
hrp_cluster_order(&config) Hrp::default().linkage(l).cluster_order(&allocator)
herc_allocation(), _with_config(&config) allocate(&Herc::default())
run_portfolio_allocation(prices) Returns::from_prices(&prices)?.allocate(&MinVariance::default().allow_short())

The config structs' settings are setters on the values, without the with_:

Removed Replacement
.with_expected_returns(dvector![..]) .expected_returns([..]), which takes a Vec<f64>, an array or a slice, such as mu.as_slice() for a DVector
.with_risk_budgets(dvector![..]) .risk_budgets([..])
.with_covariance(c) .covariance(c), or .shrinkage(λ) and .regularization(ε) directly
.with_risk_free_rate(r) .risk_free_rate(r)
.with_linkage(l), .with_clusters(k), .with_max_clusters(m) .linkage(l), .clusters(k), .max_clusters(m)

Seven other changes in 0.3.0:

0.2 0.3
CovarianceConfig::default().with_shrinkage(λ).with_regularization(ε) CovarianceConfig::default().shrinkage(λ).regularization(ε). Its fields are private: set them with these, and read them with config.lambda() and config.epsilon() instead of config.shrinkage and config.regularization.
weights.to_hashmap() weights.iter().copied().enumerate().collect::<HashMap<_, _>>(), weights.labeled().collect::<HashMap<_, _>>() by label, or weights.as_slice()
InverseVolatility::default(), InverseVariance::default() Unchanged, and now also plain InverseVolatility and InverseVariance, as with EqualWeight
PortfolioAllocator Returns, since it holds the data and the methods are values. PortfolioAllocator stays as a deprecated alias until 1.0.0, so code that names the type, such as an Allocation impl, still compiles, with a warning.
from_prices(dmatrix![..]), from_returns(dmatrix![..]) Returns::from_prices(&[[..], ..]) and Returns::new(&[[..], ..]) with rows, or from_prices_flat(&values, assets) and new_flat(&values, assets) with one row-major slice. To keep passing a DMatrix, turn on the nalgebra feature and call from_prices_matrix(m) or from_matrix(m). from_returns stays as a deprecated name for new until 1.0.0.
Hrp and Herc are Copy and Eq They're Clone and PartialEq, since they now take .covariance(..), .shrinkage(λ) and .regularization(ε). Clone a value to reuse it.
Rust 1.78 or newer Rust 1.85 or newer. cutup itself moves to edition 2024, which doesn't affect crates that depend on it.

Testing

cargo test

Besides unit tests and doctests, tests/reference.rs checks every allocation method against independent Python implementations: NumPy closed forms for minimum variance, mean-variance and max Sharpe, support enumeration for long-only minimum variance, mean-variance and max Sharpe and for max diversification (with weight bounds, and for minimum variance and mean-variance with groups, cross-checked against PyPortfolioOpt), coordinate descent for risk parity, linear programs from SciPy's HiGHS for minimum CVaR, with and without groups (cross-checked against PyPortfolioOpt and riskfolio-lib) and for the simplex solver itself, support enumeration for rebalancing with turnover caps, penalties, transaction costs and target returns, gross or net of costs (cross-checked against PyPortfolioOpt), PyPortfolioOpt for HRP and Black-Litterman, and riskfolio-lib for HERC and for HRP and HERC with shrinkage and regularization. These run on fixed datasets, including singular and ill-conditioned ones. The expected weights are committed, so cargo test doesn't need Python. See reference/README.md to regenerate them.

cargo bench times the solver and clustering methods at 10, 100 and 500 assets. docs/BENCHMARKS.md has the baseline numbers.

fuzz/ holds fuzz targets for the constructors, every allocation method and Weights, run with cargo +nightly fuzz run <target>. docs/FUZZING.md describes them.

cargo mutants checks that the tests notice small changes to the code. docs/MUTATION.md describes the setup and the mutants known to survive.

License

This project is licensed under the MIT License.

About

A Rust library for portfolio allocation strategies, providing implementations for a variety of algorithms.

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages