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        if period > crate::error::MAX_PERIOD {
70            return Err(Error::InvalidPeriod {
71                message: crate::error::PERIOD_ABOVE_MAX,
72            });
73        }
74        Ok(Self {
75            period,
76            window: VecDeque::with_capacity(period),
77        })
78    }
79
80    /// Configured window of returns.
81    pub const fn period(&self) -> usize {
82        self.period
83    }
84
85    fn compute(&self) -> f64 {
86        let count = self.window.len();
87        #[allow(clippy::cast_precision_loss)]
88        let length = count as f64;
89        // Build the cumulative-equity curve and its mean.
90        let mut equity = 0.0;
91        let mut curve: Vec<f64> = Vec::with_capacity(count);
92        let mut sum_equity = 0.0;
93        for ret in &self.window {
94            equity += *ret;
95            curve.push(equity);
96            sum_equity += equity;
97        }
98        // Times are 1..=count, so Σt = count(count+1)/2 in closed form.
99        let mean_time = f64::midpoint(length, 1.0);
100        let mean_equity = sum_equity / length;
101        let mut sxx = 0.0;
102        let mut sxy = 0.0;
103        for (index, value) in curve.iter().enumerate() {
104            #[allow(clippy::cast_precision_loss)]
105            let time = (index + 1) as f64;
106            let dt = time - mean_time;
107            sxx += dt * dt;
108            sxy += dt * (value - mean_equity);
109        }
110        // sxx > 0 for count >= 2 (distinct integer times), guaranteed by period >= 3.
111        let slope = sxy / sxx;
112        let intercept = mean_equity - slope * mean_time;
113        let mut sse = 0.0;
114        for (index, value) in curve.iter().enumerate() {
115            #[allow(clippy::cast_precision_loss)]
116            let time = (index + 1) as f64;
117            let residual = value - (intercept + slope * time);
118            sse += residual * residual;
119        }
120        if sse <= 0.0 {
121            return 0.0;
122        }
123        let se_slope = (sse / (length - 2.0) / sxx).sqrt();
124        slope / se_slope
125    }
126}
127
128impl Indicator for KRatio {
129    type Input = f64;
130    type Output = f64;
131
132    #[inline]
133    fn update(&mut self, ret: f64) -> Option<f64> {
134        if !ret.is_finite() {
135            return None;
136        }
137        if self.window.len() == self.period {
138            self.window.pop_front();
139        }
140        self.window.push_back(ret);
141        if self.window.len() < self.period {
142            return None;
143        }
144        Some(self.compute())
145    }
146
147    fn reset(&mut self) {
148        self.window.clear();
149    }
150
151    #[inline]
152    fn warmup_period(&self) -> usize {
153        self.period
154    }
155
156    #[inline]
157    fn is_ready(&self) -> bool {
158        self.window.len() == self.period
159    }
160
161    #[inline]
162    fn name(&self) -> &'static str {
163        "KRatio"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171    use approx::assert_relative_eq;
172
173    #[test]
174    fn rejects_period_less_than_three() {
175        assert!(matches!(KRatio::new(2), Err(Error::InvalidPeriod { .. })));
176        assert!(matches!(KRatio::new(0), Err(Error::InvalidPeriod { .. })));
177    }
178
179    #[test]
180    fn accessors_and_metadata() {
181        let kr = KRatio::new(30).unwrap();
182        assert_eq!(kr.period(), 30);
183        assert_eq!(kr.warmup_period(), 30);
184        assert_eq!(kr.name(), "KRatio");
185        assert!(!kr.is_ready());
186    }
187
188    #[test]
189    fn reference_value() {
190        // returns [0.01, 0.02, 0.03] -> equity curve [0.01, 0.03, 0.06].
191        // slope = 0.025, SE(slope) = sqrt((1/60000)/1/2) = 1/sqrt(120000).
192        // K-Ratio = 0.025 * sqrt(120000) = 5*sqrt(3) ≈ 8.660254.
193        let mut kr = KRatio::new(3).unwrap();
194        let out = kr.batch(&[0.01, 0.02, 0.03]);
195        let expected = 0.025_f64 / (1.0_f64 / 120_000.0).sqrt();
196        assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-6);
197    }
198
199    #[test]
200    fn constant_returns_are_degenerate_zero() {
201        // A perfectly linear equity curve has zero residual scatter -> undefined.
202        let mut kr = KRatio::new(4).unwrap();
203        let last = kr.batch(&[0.01; 4]).into_iter().flatten().last().unwrap();
204        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
205    }
206
207    #[test]
208    fn rising_curve_is_positive() {
209        let mut kr = KRatio::new(5).unwrap();
210        let last = kr
211            .batch(&[0.01, 0.012, 0.009, 0.011, 0.013])
212            .into_iter()
213            .flatten()
214            .last()
215            .unwrap();
216        assert!(last > 0.0);
217    }
218
219    #[test]
220    fn ignores_non_finite_input() {
221        let mut kr = KRatio::new(3).unwrap();
222        assert_eq!(kr.update(0.01), None);
223        assert_eq!(kr.update(f64::NAN), None);
224        assert_eq!(kr.update(0.02), None);
225        assert!(kr.update(0.03).is_some());
226    }
227
228    #[test]
229    fn reset_clears_state() {
230        let mut kr = KRatio::new(3).unwrap();
231        kr.batch(&[0.01, 0.02, 0.03]);
232        assert!(kr.is_ready());
233        kr.reset();
234        assert!(!kr.is_ready());
235        assert_eq!(kr.update(0.01), None);
236    }
237
238    #[test]
239    fn batch_equals_streaming() {
240        let rets: Vec<f64> = (0..60)
241            .map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.01)
242            .collect();
243        let batch = KRatio::new(20).unwrap().batch(&rets);
244        let mut streamer = KRatio::new(20).unwrap();
245        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
246        assert_eq!(batch, streamed);
247    }
248}