use crate::portfolio::optimizer::CovarianceMatrix;
#[derive(Debug, Clone)]
pub struct KellyInput {
pub win_probability: f64,
pub win_return: f64,
pub loss_return: f64,
pub bankroll: f64,
}
#[derive(Debug, Clone)]
pub struct KellyResult {
pub fraction: f64,
pub position_size_usd: f64,
pub max_loss_usd: f64,
pub expected_growth: f64,
}
pub fn full_kelly(input: &KellyInput) -> KellyResult {
let b = input.win_return;
let p = input.win_probability.clamp(0.0, 1.0);
let q = 1.0 - p;
let edge = b * p - q;
let fraction = if edge <= 0.0 || b <= 0.0 {
0.0
} else {
(edge / b).clamp(0.0, 1.0)
};
build_result(input, fraction)
}
pub fn fractional_kelly(input: &KellyInput, fraction: f64) -> KellyResult {
let full = full_kelly(input);
let f = fraction.clamp(0.0, 1.0);
let adjusted = full.fraction * f;
build_result(input, adjusted)
}
fn build_result(input: &KellyInput, fraction: f64) -> KellyResult {
let position_size_usd = fraction * input.bankroll;
let max_loss_usd = position_size_usd * input.loss_return;
let p = input.win_probability.clamp(0.0, 1.0);
let q = 1.0 - p;
let b = input.win_return;
let expected_growth = if fraction <= 0.0 {
0.0
} else {
let win_term = 1.0 + b * fraction;
let loss_term = 1.0 - fraction * input.loss_return;
if win_term <= 0.0 || loss_term <= 0.0 {
f64::NEG_INFINITY
} else {
p * win_term.ln() + q * loss_term.ln()
}
};
KellyResult {
fraction,
position_size_usd,
max_loss_usd,
expected_growth,
}
}
pub struct KellyPortfolio;
impl KellyPortfolio {
pub fn allocate(
assets: &[KellyInput],
correlations: &CovarianceMatrix,
max_total: f64,
) -> Vec<f64> {
let n = assets.len();
if n == 0 {
return vec![];
}
let raw: Vec<f64> = assets.iter().map(|a| full_kelly(a).fraction).collect();
let mut penalised: Vec<f64> = (0..n)
.map(|i| {
let penalty: f64 = (0..n)
.filter(|&j| j != i)
.map(|j| correlations.get(i, j).abs() * raw[j])
.sum();
raw[i] / (1.0 + penalty)
})
.collect();
let total: f64 = penalised.iter().sum();
if total > max_total && total > 0.0 {
let scale = max_total / total;
for f in penalised.iter_mut() {
*f *= scale;
}
}
penalised
}
}
#[cfg(test)]
mod tests {
use super::*;
fn btc_input() -> KellyInput {
KellyInput {
win_probability: 0.6,
win_return: 1.0, loss_return: 1.0, bankroll: 10_000.0,
}
}
#[test]
fn full_kelly_classic_example() {
let r = full_kelly(&btc_input());
assert!((r.fraction - 0.20).abs() < 1e-10);
}
#[test]
fn full_kelly_position_size_usd() {
let r = full_kelly(&btc_input());
assert!((r.position_size_usd - 2_000.0).abs() < 1e-6);
}
#[test]
fn full_kelly_max_loss_usd() {
let r = full_kelly(&btc_input());
assert!((r.max_loss_usd - 2_000.0).abs() < 1e-6);
}
#[test]
fn full_kelly_negative_edge_returns_zero() {
let input = KellyInput {
win_probability: 0.3,
win_return: 1.0,
loss_return: 1.0,
bankroll: 10_000.0,
};
let r = full_kelly(&input);
assert_eq!(r.fraction, 0.0);
assert_eq!(r.position_size_usd, 0.0);
}
#[test]
fn full_kelly_zero_edge_returns_zero() {
let input = KellyInput {
win_probability: 0.5,
win_return: 1.0,
loss_return: 1.0,
bankroll: 5_000.0,
};
let r = full_kelly(&input);
assert_eq!(r.fraction, 0.0);
}
#[test]
fn full_kelly_expected_growth_positive_edge() {
let r = full_kelly(&btc_input());
assert!(r.expected_growth > 0.0, "growth = {}", r.expected_growth);
}
#[test]
fn full_kelly_expected_growth_zero_fraction() {
let input = KellyInput {
win_probability: 0.3,
win_return: 1.0,
loss_return: 1.0,
bankroll: 10_000.0,
};
let r = full_kelly(&input);
assert_eq!(r.expected_growth, 0.0);
}
#[test]
fn full_kelly_high_odds_asset() {
let input = KellyInput {
win_probability: 0.3,
win_return: 4.0,
loss_return: 1.0,
bankroll: 1_000.0,
};
let r = full_kelly(&input);
assert!((r.fraction - 0.125).abs() < 1e-10);
}
#[test]
fn half_kelly_is_half_of_full() {
let full = full_kelly(&btc_input()).fraction;
let half = fractional_kelly(&btc_input(), 0.5).fraction;
assert!((half - full * 0.5).abs() < 1e-10);
}
#[test]
fn fractional_kelly_zero_fraction_gives_zero() {
let r = fractional_kelly(&btc_input(), 0.0);
assert_eq!(r.fraction, 0.0);
assert_eq!(r.position_size_usd, 0.0);
}
#[test]
fn fractional_kelly_one_fraction_equals_full() {
let full = full_kelly(&btc_input()).fraction;
let frac = fractional_kelly(&btc_input(), 1.0).fraction;
assert!((frac - full).abs() < 1e-10);
}
#[test]
fn fractional_kelly_fraction_clamped_above_one() {
let full = full_kelly(&btc_input()).fraction;
let frac = fractional_kelly(&btc_input(), 2.0).fraction; assert!((frac - full).abs() < 1e-10);
}
#[test]
fn fractional_kelly_negative_edge_still_zero() {
let input = KellyInput {
win_probability: 0.3,
win_return: 1.0,
loss_return: 1.0,
bankroll: 10_000.0,
};
let r = fractional_kelly(&input, 0.5);
assert_eq!(r.fraction, 0.0);
}
#[test]
fn portfolio_allocate_single_asset() {
let assets = vec![btc_input()];
let cov = CovarianceMatrix::new(vec!["BTC".into()]);
let alloc = KellyPortfolio::allocate(&assets, &cov, 1.0);
assert_eq!(alloc.len(), 1);
assert!((alloc[0] - 0.20).abs() < 1e-10);
}
#[test]
fn portfolio_allocate_total_below_max() {
let assets = vec![
btc_input(),
KellyInput { win_probability: 0.55, win_return: 1.0, loss_return: 1.0, bankroll: 10_000.0 },
];
let cov = CovarianceMatrix::new(vec!["BTC".into(), "ETH".into()]);
let alloc = KellyPortfolio::allocate(&assets, &cov, 0.5);
let total: f64 = alloc.iter().sum();
assert!(total <= 0.5 + 1e-10, "total = {total}");
}
#[test]
fn portfolio_allocate_correlated_assets_penalised() {
let a = KellyInput { win_probability: 0.6, win_return: 1.0, loss_return: 1.0, bankroll: 10_000.0 };
let b = a.clone();
let mut cov = CovarianceMatrix::new(vec!["A".into(), "B".into()]);
cov.set(0, 1, 0.9);
let alloc = KellyPortfolio::allocate(&[a.clone(), b], &cov, 1.0);
let uncorrelated_alloc = KellyPortfolio::allocate(&[a], &CovarianceMatrix::new(vec!["A".into()]), 1.0);
assert!(alloc[0] < uncorrelated_alloc[0] + 1e-10);
}
#[test]
fn portfolio_allocate_empty_returns_empty() {
let cov = CovarianceMatrix::new(vec![]);
let alloc = KellyPortfolio::allocate(&[], &cov, 1.0);
assert!(alloc.is_empty());
}
#[test]
fn portfolio_allocate_negative_edge_asset_gets_zero() {
let bad = KellyInput { win_probability: 0.3, win_return: 1.0, loss_return: 1.0, bankroll: 10_000.0 };
let good = btc_input();
let cov = CovarianceMatrix::new(vec!["BAD".into(), "BTC".into()]);
let alloc = KellyPortfolio::allocate(&[bad, good], &cov, 1.0);
assert_eq!(alloc[0], 0.0, "bad asset should get zero allocation");
assert!(alloc[1] > 0.0, "good asset should get positive allocation");
}
}