Skip to main content

wickra_core/indicators/
kelly_criterion.rs

1//! Rolling Kelly Criterion.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Kelly Criterion fraction.
9///
10/// Input is treated as a per-period (or per-trade) return. Over the trailing
11/// window the indicator estimates the optimal capital fraction to allocate
12/// using the **even-money** Kelly formula generalised by the payoff ratio:
13///
14/// ```text
15/// win_rate     = P(r > 0)                          over window
16/// avg_win      = mean(r for r > 0)
17/// avg_loss     = mean(−r for r < 0)
18/// payoff_ratio = avg_win / avg_loss
19/// Kelly        = win_rate − (1 − win_rate) / payoff_ratio
20/// ```
21///
22/// The output is the recommended **fraction** of capital to bet (typically
23/// `(0, 1)`; can go negative if the estimated edge is negative, in which
24/// case the position should be reversed or sized to zero). Most
25/// practitioners use a "half-Kelly" or "quarter-Kelly" multiplier in
26/// practice to reduce variance — Wickra reports raw Kelly and leaves the
27/// scaling to the caller.
28///
29/// Edge cases:
30///   * No winners and no losers ⇒ `0.0` (no information).
31///   * No losers (`payoff_ratio = ∞`) ⇒ Kelly collapses to the win rate.
32///   * No winners but losers present ⇒ Kelly = `−(1 − 0) / payoff = …`,
33///     which is negative — bet nothing (or short).
34///
35/// Each `update` is O(period).
36#[derive(Debug, Clone)]
37pub struct KellyCriterion {
38    period: usize,
39    window: VecDeque<f64>,
40}
41
42impl KellyCriterion {
43    /// Construct a new rolling Kelly Criterion.
44    ///
45    /// # Errors
46    /// Returns [`Error::PeriodZero`] if `period == 0`.
47    pub fn new(period: usize) -> Result<Self> {
48        if period == 0 {
49            return Err(Error::PeriodZero);
50        }
51        if period > crate::error::MAX_PERIOD {
52            return Err(Error::InvalidPeriod {
53                message: crate::error::PERIOD_ABOVE_MAX,
54            });
55        }
56        Ok(Self {
57            period,
58            window: VecDeque::with_capacity(period),
59        })
60    }
61
62    /// Configured window length.
63    pub const fn period(&self) -> usize {
64        self.period
65    }
66}
67
68impl Indicator for KellyCriterion {
69    type Input = f64;
70    type Output = f64;
71
72    fn update(&mut self, input: f64) -> Option<f64> {
73        if !input.is_finite() {
74            return None;
75        }
76        if self.window.len() == self.period {
77            self.window.pop_front();
78        }
79        self.window.push_back(input);
80        if self.window.len() < self.period {
81            return None;
82        }
83        let mut sum_win = 0.0_f64;
84        let mut n_win = 0_u32;
85        let mut sum_loss = 0.0_f64;
86        let mut n_loss = 0_u32;
87        for &r in &self.window {
88            if r > 0.0 {
89                sum_win += r;
90                n_win += 1;
91            } else if r < 0.0 {
92                sum_loss += -r;
93                n_loss += 1;
94            }
95        }
96        let n = self.period as f64;
97        let win_rate = f64::from(n_win) / n;
98        if n_loss == 0 {
99            // No losses in window: payoff ratio is infinite; Kelly collapses
100            // to the win rate (limit of w - (1-w)/r as r -> ∞).
101            return Some(win_rate);
102        }
103        let avg_loss = sum_loss / f64::from(n_loss);
104        if n_win == 0 {
105            // All losses: avg_win = 0 -> payoff = 0 -> -(1)/0 -> -inf.
106            // Bet nothing (or reverse); clamp to -1 for sanity.
107            return Some(-1.0);
108        }
109        let avg_win = sum_win / f64::from(n_win);
110        let payoff = avg_win / avg_loss;
111        Some(win_rate - (1.0 - win_rate) / payoff)
112    }
113
114    fn reset(&mut self) {
115        self.window.clear();
116    }
117
118    #[inline]
119    fn warmup_period(&self) -> usize {
120        self.period
121    }
122
123    #[inline]
124    fn is_ready(&self) -> bool {
125        self.window.len() == self.period
126    }
127
128    #[inline]
129    fn name(&self) -> &'static str {
130        "KellyCriterion"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    #[test]
141    fn rejects_zero_period() {
142        assert!(matches!(KellyCriterion::new(0), Err(Error::PeriodZero)));
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let k = KellyCriterion::new(10).unwrap();
148        assert_eq!(k.period(), 10);
149        assert_eq!(k.name(), "KellyCriterion");
150        assert_eq!(k.warmup_period(), 10);
151    }
152
153    #[test]
154    fn reference_value() {
155        // returns = [0.02, 0.04, -0.01, -0.02] (n=4).
156        // n_win=2, n_loss=2; win_rate = 0.5.
157        // avg_win=0.03, avg_loss=0.015, payoff=2.
158        // Kelly = 0.5 - (0.5/2) = 0.25.
159        let mut k = KellyCriterion::new(4).unwrap();
160        let out = k.batch(&[0.02, 0.04, -0.01, -0.02]);
161        assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-9);
162    }
163
164    #[test]
165    fn all_winners_returns_win_rate() {
166        let mut k = KellyCriterion::new(3).unwrap();
167        let out = k.batch(&[0.01, 0.02, 0.03]);
168        assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
169    }
170
171    #[test]
172    fn all_losers_returns_negative_one() {
173        let mut k = KellyCriterion::new(3).unwrap();
174        let out = k.batch(&[-0.01, -0.02, -0.03]);
175        assert_relative_eq!(out[2].unwrap(), -1.0, epsilon = 1e-12);
176    }
177
178    #[test]
179    fn flat_window_yields_zero() {
180        let mut k = KellyCriterion::new(3).unwrap();
181        let out = k.batch(&[0.0_f64; 3]);
182        assert_eq!(out[2], Some(0.0));
183    }
184
185    #[test]
186    fn ignores_non_finite_input() {
187        let mut k = KellyCriterion::new(3).unwrap();
188        assert_eq!(k.update(f64::NAN), None);
189        assert_eq!(k.update(f64::INFINITY), None);
190    }
191
192    #[test]
193    fn reset_clears_state() {
194        let mut k = KellyCriterion::new(3).unwrap();
195        k.batch(&[0.01, -0.02, 0.03]);
196        assert!(k.is_ready());
197        k.reset();
198        assert!(!k.is_ready());
199        assert_eq!(k.update(0.01), None);
200    }
201
202    #[test]
203    fn batch_equals_streaming() {
204        let returns: Vec<f64> = (0..40).map(|i| (f64::from(i) * 0.3).sin() * 0.01).collect();
205        let batch = KellyCriterion::new(10).unwrap().batch(&returns);
206        let mut s = KellyCriterion::new(10).unwrap();
207        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
208        assert_eq!(batch, streamed);
209    }
210}