Skip to main content

wickra_core/indicators/
k_ratio.rs

1//! K-Ratio (Kestner) — slope of the cumulative-return curve over the standard error of that slope.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// K-Ratio over a trailing window of `period` returns.
9///
10/// Lars Kestner's K-Ratio measures the *consistency* of an equity curve, not just
11/// its return. It builds the cumulative-return curve over the window, fits an
12/// ordinary-least-squares trend line through it against time, and divides the
13/// fitted slope by the standard error of that slope:
14///
15/// ```text
16/// equity_t = Σ_{i<=t} return_i           (cumulative curve, t = 1..period)
17/// slope, intercept = OLS(equity_t ~ t)
18/// SE(slope) = sqrt( (Σ residual² / (period − 2)) / Σ(t − t̄)² )
19/// K-Ratio   = slope / SE(slope)
20/// ```
21///
22/// A high K-Ratio means the equity curve climbs *steadily* — a steep slope with
23/// little scatter around the trend. A strategy that earns the same total return in
24/// a few lucky jumps scores lower because its residual scatter inflates the
25/// standard error. This is the original 1996 form; later Kestner revisions scale by
26/// the number of periods (`slope / (SE · period)` in 2003, `slope / (SE · √period)`
27/// in 2013) — apply that scaling downstream if you need to compare across window
28/// lengths.
29///
30/// A perfectly straight window (e.g. constant returns) has zero residual scatter,
31/// so the slope's standard error is zero and the K-Ratio is undefined; the
32/// indicator reports `0.0` in that degenerate case. The statistic therefore needs
33/// some dispersion in the returns to be meaningful.
34///
35/// The first value lands after `period` returns; each `update` re-fits the line
36/// over the window (O(period)), which is O(1) in the length of the overall series.
37///
38/// # Example
39///
40/// ```
41/// use wickra_core::{Indicator, KRatio};
42///
43/// let mut indicator = KRatio::new(30).unwrap();
44/// let mut last = None;
45/// for i in 0..60 {
46///     last = indicator.update(0.001 + (f64::from(i) * 0.3).sin() * 0.01);
47/// }
48/// assert!(last.is_some());
49/// ```
50#[derive(Debug, Clone)]
51pub struct KRatio {
52    period: usize,
53    window: VecDeque<f64>,
54}
55
56impl KRatio {
57    /// Construct a K-Ratio over `period` returns.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::InvalidPeriod`] if `period < 3` (the slope's standard error
62    /// divides by `period − 2`).
63    pub fn new(period: usize) -> Result<Self> {
64        if period < 3 {
65            return Err(Error::InvalidPeriod {
66                message: "k-ratio needs period >= 3",
67            });
68        }
69        Ok(Self {
70            period,
71            window: VecDeque::with_capacity(period),
72        })
73    }
74
75    /// Configured window of returns.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79
80    fn compute(&self) -> f64 {
81        let count = self.window.len();
82        #[allow(clippy::cast_precision_loss)]
83        let length = count as f64;
84        // Build the cumulative-equity curve and its mean.
85        let mut equity = 0.0;
86        let mut curve: Vec<f64> = Vec::with_capacity(count);
87        let mut sum_equity = 0.0;
88        for ret in &self.window {
89            equity += *ret;
90            curve.push(equity);
91            sum_equity += equity;
92        }
93        // Times are 1..=count, so Σt = count(count+1)/2 in closed form.
94        let mean_time = f64::midpoint(length, 1.0);
95        let mean_equity = sum_equity / length;
96        let mut sxx = 0.0;
97        let mut sxy = 0.0;
98        for (index, value) in curve.iter().enumerate() {
99            #[allow(clippy::cast_precision_loss)]
100            let time = (index + 1) as f64;
101            let dt = time - mean_time;
102            sxx += dt * dt;
103            sxy += dt * (value - mean_equity);
104        }
105        // sxx > 0 for count >= 2 (distinct integer times), guaranteed by period >= 3.
106        let slope = sxy / sxx;
107        let intercept = mean_equity - slope * mean_time;
108        let mut sse = 0.0;
109        for (index, value) in curve.iter().enumerate() {
110            #[allow(clippy::cast_precision_loss)]
111            let time = (index + 1) as f64;
112            let residual = value - (intercept + slope * time);
113            sse += residual * residual;
114        }
115        if sse <= 0.0 {
116            return 0.0;
117        }
118        let se_slope = (sse / (length - 2.0) / sxx).sqrt();
119        slope / se_slope
120    }
121}
122
123impl Indicator for KRatio {
124    type Input = f64;
125    type Output = f64;
126
127    fn update(&mut self, ret: f64) -> Option<f64> {
128        if !ret.is_finite() {
129            return None;
130        }
131        if self.window.len() == self.period {
132            self.window.pop_front();
133        }
134        self.window.push_back(ret);
135        if self.window.len() < self.period {
136            return None;
137        }
138        Some(self.compute())
139    }
140
141    fn reset(&mut self) {
142        self.window.clear();
143    }
144
145    fn warmup_period(&self) -> usize {
146        self.period
147    }
148
149    fn is_ready(&self) -> bool {
150        self.window.len() == self.period
151    }
152
153    fn name(&self) -> &'static str {
154        "KRatio"
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::traits::BatchExt;
162    use approx::assert_relative_eq;
163
164    #[test]
165    fn rejects_period_less_than_three() {
166        assert!(matches!(KRatio::new(2), Err(Error::InvalidPeriod { .. })));
167        assert!(matches!(KRatio::new(0), Err(Error::InvalidPeriod { .. })));
168    }
169
170    #[test]
171    fn accessors_and_metadata() {
172        let kr = KRatio::new(30).unwrap();
173        assert_eq!(kr.period(), 30);
174        assert_eq!(kr.warmup_period(), 30);
175        assert_eq!(kr.name(), "KRatio");
176        assert!(!kr.is_ready());
177    }
178
179    #[test]
180    fn reference_value() {
181        // returns [0.01, 0.02, 0.03] -> equity curve [0.01, 0.03, 0.06].
182        // slope = 0.025, SE(slope) = sqrt((1/60000)/1/2) = 1/sqrt(120000).
183        // K-Ratio = 0.025 * sqrt(120000) = 5*sqrt(3) ≈ 8.660254.
184        let mut kr = KRatio::new(3).unwrap();
185        let out = kr.batch(&[0.01, 0.02, 0.03]);
186        let expected = 0.025_f64 / (1.0_f64 / 120_000.0).sqrt();
187        assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-6);
188    }
189
190    #[test]
191    fn constant_returns_are_degenerate_zero() {
192        // A perfectly linear equity curve has zero residual scatter -> undefined.
193        let mut kr = KRatio::new(4).unwrap();
194        let last = kr.batch(&[0.01; 4]).into_iter().flatten().last().unwrap();
195        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn rising_curve_is_positive() {
200        let mut kr = KRatio::new(5).unwrap();
201        let last = kr
202            .batch(&[0.01, 0.012, 0.009, 0.011, 0.013])
203            .into_iter()
204            .flatten()
205            .last()
206            .unwrap();
207        assert!(last > 0.0);
208    }
209
210    #[test]
211    fn ignores_non_finite_input() {
212        let mut kr = KRatio::new(3).unwrap();
213        assert_eq!(kr.update(0.01), None);
214        assert_eq!(kr.update(f64::NAN), None);
215        assert_eq!(kr.update(0.02), None);
216        assert!(kr.update(0.03).is_some());
217    }
218
219    #[test]
220    fn reset_clears_state() {
221        let mut kr = KRatio::new(3).unwrap();
222        kr.batch(&[0.01, 0.02, 0.03]);
223        assert!(kr.is_ready());
224        kr.reset();
225        assert!(!kr.is_ready());
226        assert_eq!(kr.update(0.01), None);
227    }
228
229    #[test]
230    fn batch_equals_streaming() {
231        let rets: Vec<f64> = (0..60)
232            .map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.01)
233            .collect();
234        let batch = KRatio::new(20).unwrap().batch(&rets);
235        let mut streamer = KRatio::new(20).unwrap();
236        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
237        assert_eq!(batch, streamed);
238    }
239}