use crate::error::GeomError;
use crate::linalg::Matrix;
pub fn returns_from_prices(prices: &[f64]) -> Result<Vec<f64>, GeomError> {
if prices.len() < 2 || prices.iter().any(|p| !(*p > 0.0) || !p.is_finite()) {
return Err(GeomError::InvalidArgument("returns_from_prices: bad price series"));
}
Ok(prices.windows(2).map(|w| w[1] / w[0] - 1.0).collect())
}
pub fn log_returns(prices: &[f64]) -> Result<Vec<f64>, GeomError> {
if prices.len() < 2 || prices.iter().any(|p| !(*p > 0.0) || !p.is_finite()) {
return Err(GeomError::InvalidArgument("log_returns: bad price series"));
}
Ok(prices.windows(2).map(|w| (w[1] / w[0]).ln()).collect())
}
fn check_covariance(cov: &Matrix) -> Result<usize, GeomError> {
let n = cov.rows;
if n == 0 || cov.cols != n {
return Err(GeomError::InvalidArgument("the covariance matrix must be square"));
}
for i in 0..n {
if !(cov.get(i, i) > 0.0) {
return Err(GeomError::InvalidArgument("an asset has non-positive variance"));
}
for j in 0..n {
if !cov.get(i, j).is_finite() {
return Err(GeomError::InvalidArgument("a covariance is not finite"));
}
if (cov.get(i, j) - cov.get(j, i)).abs() > 1e-9 * cov.get(i, i).max(cov.get(j, j)) {
return Err(GeomError::InvalidArgument("the covariance matrix is not symmetric"));
}
}
}
Ok(n)
}
pub fn portfolio_variance(cov: &Matrix, weights: &[f64]) -> Result<f64, GeomError> {
let n = check_covariance(cov)?;
if weights.len() != n {
return Err(GeomError::InvalidArgument("the weights do not match the covariance matrix"));
}
let mut total = 0.0;
for i in 0..n {
for j in 0..n {
total += weights[i] * weights[j] * cov.get(i, j);
}
}
Ok(total)
}
fn solve_covariance(cov: &Matrix, b: &[f64]) -> Result<Vec<f64>, GeomError> {
crate::linalg::solve(cov, b)
.map_err(|_| GeomError::Degenerate("the covariance matrix is singular"))
}
pub fn min_variance_weights(cov: &Matrix) -> Result<Vec<f64>, GeomError> {
let n = check_covariance(cov)?;
let ones = vec![1.0; n];
let solved = solve_covariance(cov, &ones)?;
let total: f64 = solved.iter().sum();
if total.abs() < 1e-300 {
return Err(GeomError::Degenerate("the minimum-variance weights do not normalise"));
}
Ok(solved.into_iter().map(|x| x / total).collect())
}
pub fn tangency_portfolio(
mu: &[f64],
cov: &Matrix,
risk_free: f64,
) -> Result<Vec<f64>, GeomError> {
let n = check_covariance(cov)?;
if mu.len() != n || mu.iter().any(|m| !m.is_finite()) || !risk_free.is_finite() {
return Err(GeomError::InvalidArgument("tangency_portfolio: bad expected returns"));
}
let excess: Vec<f64> = mu.iter().map(|m| m - risk_free).collect();
let solved = solve_covariance(cov, &excess)?;
let total: f64 = solved.iter().sum();
if total.abs() < 1e-12 {
return Err(GeomError::Degenerate("the excess returns do not determine a tangency"));
}
Ok(solved.into_iter().map(|x| x / total).collect())
}
pub fn markowitz_frontier(
mu: &[f64],
cov: &Matrix,
points: usize,
) -> Result<Vec<(f64, f64, Vec<f64>)>, GeomError> {
let n = check_covariance(cov)?;
if mu.len() != n || mu.iter().any(|m| !m.is_finite()) {
return Err(GeomError::InvalidArgument("markowitz_frontier: bad expected returns"));
}
if !(2..=10_000).contains(&points) {
return Err(GeomError::InvalidArgument("markowitz_frontier: bad point count"));
}
let ones = vec![1.0; n];
let inv_ones = solve_covariance(cov, &ones)?;
let inv_mu = solve_covariance(cov, mu)?;
let a: f64 = mu.iter().zip(inv_mu.iter()).map(|(m, x)| m * x).sum();
let b: f64 = mu.iter().zip(inv_ones.iter()).map(|(m, x)| m * x).sum();
let c: f64 = inv_ones.iter().sum();
let determinant = a * c - b * b;
if !(determinant > 1e-300) || !(c > 0.0) {
return Err(GeomError::Degenerate(
"the expected returns do not span a frontier: they are all equal or the matrix is ill-conditioned",
));
}
let smallest = b / c;
let largest = mu.iter().fold(f64::NEG_INFINITY, |x, y| x.max(*y));
let top = if largest > smallest { largest } else { smallest + 1.0 };
let mut out = Vec::with_capacity(points);
for step in 0..points {
let target = smallest + (top - smallest) * step as f64 / (points - 1) as f64;
let lambda = (c * target - b) / determinant;
let gamma = (a - b * target) / determinant;
let weights: Vec<f64> =
(0..n).map(|i| lambda * inv_mu[i] + gamma * inv_ones[i]).collect();
let variance = portfolio_variance(cov, &weights)?;
out.push((variance.max(0.0).sqrt(), target, weights));
}
Ok(out)
}
pub fn risk_parity_weights(cov: &Matrix) -> Result<Vec<f64>, GeomError> {
let n = check_covariance(cov)?;
let mut weights = vec![1.0 / n as f64; n];
for _ in 0..10_000 {
let mut marginal = vec![0.0; n];
for i in 0..n {
for j in 0..n {
marginal[i] += cov.get(i, j) * weights[j];
}
}
if marginal.iter().any(|m| !(*m > 0.0)) {
return Err(GeomError::Degenerate("a marginal risk contribution went non-positive"));
}
let updated: Vec<f64> = (0..n).map(|i| (weights[i] / marginal[i]).sqrt()).collect();
let total: f64 = updated.iter().sum();
let normalised: Vec<f64> = updated.into_iter().map(|w| w / total).collect();
let moved: f64 =
normalised.iter().zip(weights.iter()).map(|(a, b)| (a - b).abs()).sum();
weights = normalised;
if moved < 1e-14 {
return Ok(weights);
}
}
Err(GeomError::Degenerate("risk parity did not converge"))
}
pub fn risk_contributions(cov: &Matrix, weights: &[f64]) -> Result<Vec<f64>, GeomError> {
let n = check_covariance(cov)?;
if weights.len() != n {
return Err(GeomError::InvalidArgument("the weights do not match the matrix"));
}
let variance = portfolio_variance(cov, weights)?;
if !(variance > 0.0) {
return Err(GeomError::Degenerate("the portfolio has no variance to attribute"));
}
Ok((0..n)
.map(|i| {
let marginal: f64 = (0..n).map(|j| cov.get(i, j) * weights[j]).sum();
weights[i] * marginal / variance
})
.collect())
}
fn mean_and_deviation(values: &[f64]) -> Result<(f64, f64), GeomError> {
if values.len() < 2 || values.iter().any(|v| !v.is_finite()) {
return Err(GeomError::InvalidArgument("at least two finite observations are required"));
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Ok((mean, variance.sqrt()))
}
pub fn sharpe(returns: &[f64], risk_free: f64) -> Result<f64, GeomError> {
if !risk_free.is_finite() {
return Err(GeomError::InvalidArgument("the risk-free rate is not finite"));
}
let excess: Vec<f64> = returns.iter().map(|r| r - risk_free).collect();
let (mean, deviation) = mean_and_deviation(&excess)?;
if !(deviation > 0.0) {
return Err(GeomError::Degenerate("the returns have no variation"));
}
Ok(mean / deviation)
}
pub fn sortino(returns: &[f64], risk_free: f64, target: f64) -> Result<f64, GeomError> {
if returns.len() < 2 || returns.iter().any(|r| !r.is_finite()) {
return Err(GeomError::InvalidArgument("sortino: bad returns"));
}
if !risk_free.is_finite() || !target.is_finite() {
return Err(GeomError::InvalidArgument("sortino: bad rate or target"));
}
let n = returns.len() as f64;
let mean = returns.iter().map(|r| r - risk_free).sum::<f64>() / n;
let downside =
(returns.iter().map(|r| (r - target).min(0.0).powi(2)).sum::<f64>() / n).sqrt();
if !(downside > 0.0) {
return Err(GeomError::Degenerate("the series never fell below its target"));
}
Ok(mean / downside)
}
pub fn max_drawdown(prices: &[f64]) -> Result<f64, GeomError> {
if prices.len() < 2 || prices.iter().any(|p| !(*p > 0.0) || !p.is_finite()) {
return Err(GeomError::InvalidArgument("max_drawdown: bad price series"));
}
let mut peak = prices[0];
let mut worst = 0.0f64;
for price in prices {
peak = peak.max(*price);
worst = worst.max((peak - price) / peak);
}
Ok(worst)
}
pub fn calmar(prices: &[f64], periods_per_year: f64) -> Result<f64, GeomError> {
if !(periods_per_year > 0.0) || !periods_per_year.is_finite() {
return Err(GeomError::InvalidArgument("calmar: bad period count"));
}
let drawdown = max_drawdown(prices)?;
if !(drawdown > 0.0) {
return Err(GeomError::Degenerate("the series never fell, so there is nothing to divide by"));
}
let periods = (prices.len() - 1) as f64;
let growth = prices[prices.len() - 1] / prices[0];
let annual = growth.powf(periods_per_year / periods) - 1.0;
Ok(annual / drawdown)
}
pub fn information_ratio(portfolio: &[f64], benchmark: &[f64]) -> Result<f64, GeomError> {
if portfolio.len() != benchmark.len() {
return Err(GeomError::InvalidArgument("the two series must have the same length"));
}
let active: Vec<f64> = portfolio.iter().zip(benchmark.iter()).map(|(p, b)| p - b).collect();
let (mean, deviation) = mean_and_deviation(&active)?;
if !(deviation > 0.0) {
return Err(GeomError::Degenerate("the portfolio tracks the benchmark exactly"));
}
Ok(mean / deviation)
}
pub fn capm_beta(asset: &[f64], market: &[f64]) -> Result<(f64, f64), GeomError> {
if asset.len() != market.len() || asset.len() < 2 {
return Err(GeomError::InvalidArgument("capm_beta: mismatched or too-short series"));
}
if asset.iter().chain(market.iter()).any(|x| !x.is_finite()) {
return Err(GeomError::InvalidArgument("capm_beta: a value is not finite"));
}
let n = asset.len() as f64;
let mean_asset = asset.iter().sum::<f64>() / n;
let mean_market = market.iter().sum::<f64>() / n;
let covariance: f64 = asset
.iter()
.zip(market.iter())
.map(|(a, m)| (a - mean_asset) * (m - mean_market))
.sum::<f64>()
/ (n - 1.0);
let variance: f64 =
market.iter().map(|m| (m - mean_market).powi(2)).sum::<f64>() / (n - 1.0);
if !(variance > 0.0) {
return Err(GeomError::Degenerate("the market series has no variation"));
}
let beta = covariance / variance;
Ok((mean_asset - beta * mean_market, beta))
}
pub fn kelly_fraction(p: f64, b: f64) -> Result<f64, GeomError> {
if !(0.0..=1.0).contains(&p) || !(b > 0.0) || !b.is_finite() {
return Err(GeomError::InvalidArgument("kelly_fraction: bad probability or payout"));
}
Ok(p - (1.0 - p) / b)
}
pub fn kelly_continuous(mu: f64, sigma: f64, risk_free: f64) -> Result<f64, GeomError> {
if !(sigma > 0.0) || ![mu, sigma, risk_free].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("kelly_continuous: bad parameters"));
}
Ok((mu - risk_free) / (sigma * sigma))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_covariance() -> Matrix {
let vols = [0.15f64, 0.22, 0.30];
let corr = [[1.0, 0.3, 0.1], [0.3, 1.0, 0.5], [0.1, 0.5, 1.0]];
let mut cov = Matrix::zeros(3, 3);
for i in 0..3 {
for j in 0..3 {
cov.set(i, j, vols[i] * vols[j] * corr[i][j]);
}
}
cov
}
#[test]
fn a_log_return_is_the_logarithm_of_one_plus_the_simple_one() {
let prices = [100.0, 110.0, 99.0, 123.75];
let simple = returns_from_prices(&prices).unwrap();
let logs = log_returns(&prices).unwrap();
assert_eq!(simple.len(), 3);
assert!((simple[0] - 0.1).abs() < 1e-15);
assert!((simple[1] - -0.1).abs() < 1e-15);
assert!((simple[2] - 0.25).abs() < 1e-15);
for (r, l) in simple.iter().zip(logs.iter()) {
assert!((l - (1.0 + r).ln()).abs() < 1e-15);
assert!(*l <= r + 1e-15);
}
let total: f64 = logs.iter().sum();
assert!((total.exp() - prices[3] / prices[0]).abs() < 1e-13);
assert!(returns_from_prices(&[100.0]).is_err());
assert!(log_returns(&[100.0, 0.0]).is_err());
assert!(returns_from_prices(&[100.0, -5.0]).is_err());
}
#[test]
fn up_fifty_then_down_fifty_is_a_loss_the_arithmetic_mean_hides() {
let prices = [100.0, 150.0, 75.0];
let simple = returns_from_prices(&prices).unwrap();
let arithmetic: f64 = simple.iter().sum::<f64>() / 2.0;
assert!(arithmetic.abs() < 1e-15, "the arithmetic mean was {arithmetic}");
assert!((prices[2] / prices[0] - 0.75).abs() < 1e-15, "a quarter of the value is gone");
let logs = log_returns(&prices).unwrap();
assert!((logs.iter().sum::<f64>() - 0.75f64.ln()).abs() < 1e-15);
}
#[test]
fn the_minimum_variance_portfolio_really_is_the_minimum() {
let cov = sample_covariance();
let weights = min_variance_weights(&cov).unwrap();
assert!((weights.iter().sum::<f64>() - 1.0).abs() < 1e-12);
let base = portfolio_variance(&cov, &weights).unwrap();
for direction in [[1.0, -1.0, 0.0], [0.0, 1.0, -1.0], [1.0, 0.5, -1.5]] {
for size in [0.01f64, -0.01, 0.1, -0.1] {
let moved: Vec<f64> =
(0..3).map(|i| weights[i] + size * direction[i]).collect();
assert!((moved.iter().sum::<f64>() - 1.0).abs() < 1e-12);
let variance = portfolio_variance(&cov, &moved).unwrap();
assert!(variance > base, "moving by {size} lowered the variance");
let excess = variance - base;
let tenth = {
let smaller: Vec<f64> =
(0..3).map(|i| weights[i] + 0.1 * size * direction[i]).collect();
portfolio_variance(&cov, &smaller).unwrap() - base
};
assert!(
(excess / tenth - 100.0).abs() < 1e-6,
"the excess did not scale quadratically: {}",
excess / tenth
);
}
}
}
#[test]
fn independent_assets_get_minimum_variance_weights_in_inverse_variance() {
let variances = [0.01f64, 0.04, 0.25];
let mut cov = Matrix::zeros(3, 3);
for (i, v) in variances.iter().enumerate() {
cov.set(i, i, *v);
}
let weights = min_variance_weights(&cov).unwrap();
let total: f64 = variances.iter().map(|v| 1.0 / v).sum();
for (i, v) in variances.iter().enumerate() {
assert!(
(weights[i] - (1.0 / v) / total).abs() < 1e-12,
"asset {i} got {} not {}",
weights[i],
(1.0 / v) / total
);
}
}
#[test]
fn the_tangency_portfolio_has_the_highest_sharpe_ratio_there_is() {
let cov = sample_covariance();
let mu = [0.06f64, 0.09, 0.12];
let risk_free = 0.02;
let weights = tangency_portfolio(&mu, &cov, risk_free).unwrap();
assert!((weights.iter().sum::<f64>() - 1.0).abs() < 1e-12);
let ratio = |w: &[f64]| {
let ret: f64 = w.iter().zip(mu.iter()).map(|(x, m)| x * m).sum();
(ret - risk_free) / portfolio_variance(&cov, w).unwrap().sqrt()
};
let best = ratio(&weights);
assert!(best > 0.0);
for direction in [[1.0, -1.0, 0.0], [0.0, 1.0, -1.0], [-1.0, 2.0, -1.0]] {
for size in [0.02f64, -0.02, 0.2, -0.2] {
let moved: Vec<f64> = (0..3).map(|i| weights[i] + size * direction[i]).collect();
assert!(ratio(&moved) < best, "moving by {size} raised the Sharpe ratio");
}
}
}
#[test]
fn every_frontier_point_is_the_least_variance_at_its_own_return() {
let cov = sample_covariance();
let mu = [0.06f64, 0.09, 0.12];
let frontier = markowitz_frontier(&mu, &cov, 9).unwrap();
assert_eq!(frontier.len(), 9);
let minimum = min_variance_weights(&cov).unwrap();
for (a, b) in frontier[0].2.iter().zip(minimum.iter()) {
assert!((a - b).abs() < 1e-10, "the frontier does not start at the minimum");
}
let neutral = [mu[1] - mu[2], mu[2] - mu[0], mu[0] - mu[1]];
for (deviation, target, weights) in &frontier {
assert!((weights.iter().sum::<f64>() - 1.0).abs() < 1e-10);
let achieved: f64 = weights.iter().zip(mu.iter()).map(|(w, m)| w * m).sum();
assert!((achieved - target).abs() < 1e-10, "it returned {achieved} not {target}");
let variance = portfolio_variance(cov_ref(&cov), weights).unwrap();
assert!((variance.sqrt() - deviation).abs() < 1e-12);
for size in [0.05f64, -0.05] {
let moved: Vec<f64> =
(0..3).map(|i| weights[i] + size * neutral[i]).collect();
let shifted: f64 = moved.iter().zip(mu.iter()).map(|(w, m)| w * m).sum();
assert!((shifted - target).abs() < 1e-10, "the move changed the return");
assert!(
portfolio_variance(&cov, &moved).unwrap() > variance,
"a same-return portfolio had less variance"
);
}
}
for pair in frontier.windows(2) {
assert!(pair[1].1 > pair[0].1, "the return did not rise");
assert!(pair[1].0 > pair[0].0, "the risk did not rise with it");
}
}
fn cov_ref(m: &Matrix) -> &Matrix {
m
}
#[test]
fn risk_parity_gives_every_asset_the_same_share_of_the_risk() {
let cov = sample_covariance();
let weights = risk_parity_weights(&cov).unwrap();
assert!((weights.iter().sum::<f64>() - 1.0).abs() < 1e-12);
assert!(weights.iter().all(|w| *w > 0.0), "a weight went negative");
let shares = risk_contributions(&cov, &weights).unwrap();
for share in &shares {
assert!((share - 1.0 / 3.0).abs() < 1e-9, "a share was {share}");
}
assert!((shares.iter().sum::<f64>() - 1.0).abs() < 1e-12);
let minimum = min_variance_weights(&cov).unwrap();
let apart: f64 =
weights.iter().zip(minimum.iter()).map(|(a, b)| (a - b).abs()).sum();
assert!(apart > 0.1, "risk parity landed on the minimum-variance weights");
assert!(
portfolio_variance(&cov, &weights).unwrap()
> portfolio_variance(&cov, &minimum).unwrap()
);
}
#[test]
fn independent_assets_get_risk_parity_weights_in_inverse_volatility() {
let vols = [0.1f64, 0.2, 0.5];
let mut cov = Matrix::zeros(3, 3);
for (i, v) in vols.iter().enumerate() {
cov.set(i, i, v * v);
}
let weights = risk_parity_weights(&cov).unwrap();
let total: f64 = vols.iter().map(|v| 1.0 / v).sum();
for (i, v) in vols.iter().enumerate() {
assert!(
(weights[i] - (1.0 / v) / total).abs() < 1e-9,
"asset {i} got {} not {}",
weights[i],
(1.0 / v) / total
);
}
}
#[test]
fn the_portfolio_builders_refuse_a_matrix_that_is_not_a_covariance() {
let mut asymmetric = sample_covariance();
asymmetric.set(0, 1, 0.9);
assert!(min_variance_weights(&asymmetric).is_err());
let mut negative = Matrix::zeros(2, 2);
negative.set(0, 0, -1.0);
negative.set(1, 1, 1.0);
assert!(min_variance_weights(&negative).is_err());
let cov = sample_covariance();
assert!(tangency_portfolio(&[0.05, 0.06], &cov, 0.02).is_err());
assert!(markowitz_frontier(&[0.06, 0.09, 0.12], &cov, 1).is_err());
assert!(markowitz_frontier(&[0.07, 0.07, 0.07], &cov, 5).is_err());
let mut orthogonal = Matrix::zeros(2, 2);
orthogonal.set(0, 0, 0.04);
orthogonal.set(1, 1, 0.04);
assert!(tangency_portfolio(&[0.05, -0.01], &orthogonal, 0.02).is_err());
assert!(portfolio_variance(&cov, &[1.0, 0.0]).is_err());
assert!(risk_contributions(&cov, &[0.0, 0.0, 0.0]).is_err());
}
#[test]
fn the_performance_ratios_are_the_quantities_they_are_named_after() {
let returns = [0.02f64, -0.01, 0.03, 0.00, 0.01];
let mean = 0.01;
let deviation = {
let ss: f64 = returns.iter().map(|r| (r - mean).powi(2)).sum();
(ss / 4.0).sqrt()
};
assert!((sharpe(&returns, 0.0).unwrap() - mean / deviation).abs() < 1e-15);
assert!(
(sharpe(&returns, 0.005).unwrap() - (mean - 0.005) / deviation).abs() < 1e-15
);
let skewed = [0.10f64, -0.01, 0.08, -0.02, 0.09];
assert!(sortino(&skewed, 0.0, 0.0).unwrap() > sharpe(&skewed, 0.0).unwrap());
assert!(sharpe(&[0.01, 0.01, 0.01], 0.0).is_err(), "no variation, no ratio");
assert!(sortino(&[0.01, 0.02], 0.0, 0.0).is_err(), "never below target");
}
#[test]
fn a_drawdown_is_a_property_of_the_path_and_not_of_its_ends() {
let smooth = [100.0f64, 105.0, 110.0, 115.0, 120.0];
let rough = [100.0f64, 150.0, 60.0, 90.0, 120.0];
assert!(max_drawdown(&smooth).unwrap() < 1e-15, "a rising series has no drawdown");
assert!((max_drawdown(&rough).unwrap() - 0.6).abs() < 1e-15);
assert_eq!(smooth[4], rough[4]);
assert!(calmar(&smooth, 252.0).is_err(), "no drawdown, nothing to divide by");
let calm = calmar(&rough, 4.0).unwrap();
let growth = (120.0f64 / 100.0).powf(4.0 / 4.0) - 1.0;
assert!((calm - growth / 0.6).abs() < 1e-12, "got {calm}");
assert!(max_drawdown(&[100.0]).is_err());
}
#[test]
fn the_regression_recovers_a_beta_that_was_put_there_on_purpose() {
let market = [0.01f64, -0.02, 0.03, 0.00, 0.015, -0.005, 0.02];
for (alpha, beta) in [(0.001f64, 1.5f64), (0.0, 0.4), (-0.002, -0.8)] {
let asset: Vec<f64> = market.iter().map(|m| alpha + beta * m).collect();
let (a, b) = capm_beta(&asset, &market).unwrap();
assert!((b - beta).abs() < 1e-12, "beta came back {b} not {beta}");
assert!((a - alpha).abs() < 1e-12, "alpha came back {a} not {alpha}");
}
let (a, b) = capm_beta(&market, &market).unwrap();
assert!((b - 1.0).abs() < 1e-14 && a.abs() < 1e-16);
assert!(capm_beta(&market, &[0.01; 7]).is_err(), "a flat market has no beta");
assert!(capm_beta(&market, &market[..3]).is_err());
}
#[test]
fn the_information_ratio_is_the_sharpe_ratio_of_the_active_position() {
let portfolio = [0.02f64, -0.01, 0.03, 0.00, 0.01];
let benchmark = [0.01f64, -0.02, 0.02, 0.01, 0.00];
let active: Vec<f64> =
portfolio.iter().zip(benchmark.iter()).map(|(p, b)| p - b).collect();
let ratio = information_ratio(&portfolio, &benchmark).unwrap();
assert!((ratio - sharpe(&active, 0.0).unwrap()).abs() < 1e-15);
assert!(information_ratio(&portfolio, &portfolio).is_err());
assert!(information_ratio(&portfolio, &benchmark[..3]).is_err());
}
#[test]
fn staking_twice_the_kelly_fraction_earns_nothing_over_the_risk_free_rate() {
let (mu, sigma, risk_free) = (0.10, 0.20, 0.02);
let kelly = kelly_continuous(mu, sigma, risk_free).unwrap();
assert!((kelly - 2.0).abs() < 1e-15, "the fraction was {kelly}");
let growth = |f: f64| risk_free + f * (mu - risk_free) - 0.5 * f * f * sigma * sigma;
assert!(growth(kelly) > growth(0.0));
assert!((growth(2.0 * kelly) - risk_free).abs() < 1e-15);
assert!(growth(2.5 * kelly) < risk_free);
let excess = growth(kelly) - risk_free;
assert!(((growth(0.5 * kelly) - risk_free) / excess - 0.75).abs() < 1e-12);
assert!((kelly_fraction(0.6, 1.0).unwrap() - 0.2).abs() < 1e-15);
assert!(kelly_fraction(0.5, 1.0).unwrap().abs() < 1e-15);
assert!(kelly_fraction(0.4, 1.0).unwrap() < 0.0, "a losing bet should be refused");
assert!(kelly_fraction(0.3, 4.0).unwrap() > 0.0);
assert!(kelly_fraction(1.5, 1.0).is_err());
assert!(kelly_continuous(0.1, 0.0, 0.02).is_err());
}
}