Skip to main content

quant_metrics/
kelly.rs

1//! Kelly criterion position sizing.
2//!
3//! Pure computation — `f* = W - (1-W)/R` where:
4//! - W = win rate (fraction of winning trades)
5//! - R = average win / average loss ratio
6//!
7//! The Kelly fraction is the theoretically optimal fraction of capital to
8//! risk on each trade. Half-Kelly and quarter-Kelly are conservative
9//! variants widely used in practice.
10
11use std::fmt;
12
13use rust_decimal::Decimal;
14
15use crate::MetricsError;
16
17/// Kelly sizing mode — controls what fraction of the theoretical optimum to use.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum KellyMode {
20    /// Full Kelly — theoretical optimum. Aggressive.
21    Full,
22    /// Half Kelly — recommended for live trading. Halves variance.
23    Half,
24    /// Quarter Kelly — conservative.
25    Quarter,
26}
27
28impl KellyMode {
29    /// Scaling factor for this mode.
30    pub fn scale(&self) -> Decimal {
31        match self {
32            Self::Full => Decimal::ONE,
33            Self::Half => Decimal::new(5, 1),     // 0.5
34            Self::Quarter => Decimal::new(25, 2), // 0.25
35        }
36    }
37}
38
39impl fmt::Display for KellyMode {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::Full => write!(f, "Full"),
43            Self::Half => write!(f, "Half"),
44            Self::Quarter => write!(f, "Quarter"),
45        }
46    }
47}
48
49/// A validated Kelly fraction in the range [0.0, 1.0].
50///
51/// Represents the fraction of available capital to allocate to a trade.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
53pub struct KellyFraction(Decimal);
54
55impl KellyFraction {
56    /// The zero fraction — no allocation.
57    pub fn zero() -> Self {
58        Self(Decimal::ZERO)
59    }
60
61    /// The raw fraction value in [0.0, 1.0].
62    pub fn as_decimal(&self) -> Decimal {
63        self.0
64    }
65
66    /// Whether this fraction is zero (system has no edge).
67    pub fn is_zero(&self) -> bool {
68        self.0 == Decimal::ZERO
69    }
70}
71
72impl fmt::Display for KellyFraction {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "{:.4}", self.0)
75    }
76}
77
78/// Compute the Kelly fraction for given trade statistics.
79///
80/// Formula: `f* = W - (1-W)/R`, then scaled by mode, clamped to [0, 1].
81///
82/// # Arguments
83/// * `win_rate` — fraction of winning trades (0.0 to 1.0)
84/// * `avg_win_loss_ratio` — average win / average loss (the R in Kelly)
85/// * `mode` — Full, Half, or Quarter Kelly
86///
87/// # Returns
88/// A `KellyFraction` clamped to [0.0, 1.0]. If the system has no edge
89/// (negative raw Kelly), returns `KellyFraction::zero()`.
90pub fn compute_kelly_fraction(
91    win_rate: Decimal,
92    avg_win_loss_ratio: Decimal,
93    mode: KellyMode,
94) -> KellyFraction {
95    // Guard: if R is zero or negative, no meaningful Kelly computation
96    if avg_win_loss_ratio <= Decimal::ZERO {
97        return KellyFraction::zero();
98    }
99
100    // f* = W - (1 - W) / R
101    let loss_rate = Decimal::ONE - win_rate;
102    let raw_fraction = win_rate - loss_rate / avg_win_loss_ratio;
103
104    // If negative (no edge), clamp to zero
105    if raw_fraction <= Decimal::ZERO {
106        return KellyFraction::zero();
107    }
108
109    // Scale by mode
110    let scaled = raw_fraction * mode.scale();
111
112    // Clamp to [0, 1]
113    let clamped = if scaled > Decimal::ONE {
114        Decimal::ONE
115    } else {
116        scaled
117    };
118
119    KellyFraction(clamped)
120}
121
122/// Compute Kelly inputs (win rate as fraction, reward ratio, trade count) from PnL values.
123///
124/// Delegates to `win_rate()`, `avg_win()`, `avg_loss()` from `crate::trading`.
125///
126/// Returns `(win_rate_fraction, avg_win_loss_ratio, trade_count)`.
127pub fn compute_kelly_inputs(
128    trade_pnls: &[Decimal],
129) -> Result<(Decimal, Decimal, usize), MetricsError> {
130    if trade_pnls.is_empty() {
131        return Err(MetricsError::InsufficientData {
132            required: 1,
133            actual: 0,
134        });
135    }
136
137    // Delegate to existing trading functions — NO reimplementation
138    let wr_pct = crate::win_rate(trade_pnls)?;
139    let wr_fraction = wr_pct / Decimal::from(100);
140
141    let avg_w = crate::avg_win(trade_pnls)?;
142
143    let avg_l = match crate::avg_loss(trade_pnls) {
144        Ok(v) => v.abs(),
145        Err(MetricsError::InsufficientData { .. }) => {
146            // No losing trades → ratio is undefined
147            return Err(MetricsError::DivisionByZero {
148                context: "no losing trades — cannot compute win/loss ratio",
149            });
150        }
151        Err(e) => return Err(e),
152    };
153
154    let ratio = avg_w / avg_l;
155
156    Ok((wr_fraction, ratio, trade_pnls.len()))
157}
158
159#[cfg(test)]
160#[path = "kelly_tests.rs"]
161mod tests;