Skip to main content

wickra_core/indicators/
kagi_bars.rs

1//! Kagi bar builder — reversal-amount line segments on close prices.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::BarBuilder;
6
7/// One completed Kagi line segment (the vertical run between two reversals).
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct KagiBar {
10    /// Price where the segment began (the previous reversal point).
11    pub start: f64,
12    /// Extreme price the segment reached before reversing.
13    pub end: f64,
14    /// `+1` for a rising segment, `-1` for a falling segment.
15    pub direction: i8,
16}
17
18/// Kagi bar builder using the fixed reversal-amount method on close prices.
19///
20/// A Kagi chart is one continuous line that extends in its current direction as
21/// long as price makes new extremes, and turns when price retraces by at least
22/// `reversal` from the latest extreme. This builder emits the **completed
23/// segment** each time the line turns:
24///
25/// - The first candle seeds the start price; the first subsequent move (of any
26///   size) sets the initial direction.
27/// - While the trend holds, new extremes extend the current segment silently.
28/// - A retracement of `>= reversal` closes the current segment (returned from
29///   [`BarBuilder::update`]) and starts a new one in the opposite direction.
30///
31/// At most one segment completes per candle, so `update` returns either an empty
32/// vector or a single [`KagiBar`].
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{BarBuilder, Candle, KagiBars};
38///
39/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
40/// let mut kagi = KagiBars::new(2.0).unwrap();
41/// kagi.update(flat(10.0)); // seed
42/// kagi.update(flat(15.0)); // rise to 15
43/// let bars = kagi.update(flat(12.0)); // retrace >= 2 -> closes the up segment
44/// assert_eq!(bars.len(), 1);
45/// assert_eq!(bars[0].direction, 1);
46/// ```
47#[derive(Debug, Clone)]
48pub struct KagiBars {
49    reversal: f64,
50    dir: i8,
51    extreme: Option<f64>,
52    segment_start: f64,
53}
54
55impl KagiBars {
56    /// Construct a Kagi builder with the given reversal amount.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::InvalidPeriod`] if `reversal` is not finite and positive.
61    pub fn new(reversal: f64) -> Result<Self> {
62        if !reversal.is_finite() || reversal <= 0.0 {
63            return Err(Error::InvalidPeriod {
64                message: "reversal must be finite and positive",
65            });
66        }
67        Ok(Self {
68            reversal,
69            dir: 0,
70            extreme: None,
71            segment_start: 0.0,
72        })
73    }
74
75    /// Configured reversal amount.
76    pub const fn reversal(&self) -> f64 {
77        self.reversal
78    }
79
80    /// Current extreme price (or the seed price before any move).
81    pub const fn extreme(&self) -> Option<f64> {
82        self.extreme
83    }
84}
85
86impl BarBuilder for KagiBars {
87    type Bar = KagiBar;
88
89    fn update(&mut self, candle: Candle) -> Vec<KagiBar> {
90        let close = candle.close;
91        let Some(mut ext) = self.extreme else {
92            self.extreme = Some(close);
93            self.segment_start = close;
94            return Vec::new();
95        };
96        let mut bars = Vec::new();
97        match self.dir {
98            0 => {
99                if close > ext {
100                    self.dir = 1;
101                    ext = close;
102                } else if close < ext {
103                    self.dir = -1;
104                    ext = close;
105                }
106            }
107            1 => {
108                if close > ext {
109                    ext = close;
110                } else if close <= ext - self.reversal {
111                    bars.push(KagiBar {
112                        start: self.segment_start,
113                        end: ext,
114                        direction: 1,
115                    });
116                    self.segment_start = ext;
117                    self.dir = -1;
118                    ext = close;
119                }
120            }
121            _ => {
122                if close < ext {
123                    ext = close;
124                } else if close >= ext + self.reversal {
125                    bars.push(KagiBar {
126                        start: self.segment_start,
127                        end: ext,
128                        direction: -1,
129                    });
130                    self.segment_start = ext;
131                    self.dir = 1;
132                    ext = close;
133                }
134            }
135        }
136        self.extreme = Some(ext);
137        bars
138    }
139
140    fn reset(&mut self) {
141        self.dir = 0;
142        self.extreme = None;
143        self.segment_start = 0.0;
144    }
145
146    #[inline]
147    fn name(&self) -> &'static str {
148        "KagiBars"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use approx::assert_relative_eq;
156
157    fn flat(price: f64) -> Candle {
158        Candle::new(price, price, price, price, 1.0, 0).unwrap()
159    }
160
161    #[test]
162    fn rejects_invalid_reversal() {
163        assert!(matches!(
164            KagiBars::new(0.0),
165            Err(Error::InvalidPeriod { .. })
166        ));
167        assert!(matches!(
168            KagiBars::new(-2.0),
169            Err(Error::InvalidPeriod { .. })
170        ));
171        assert!(matches!(
172            KagiBars::new(f64::INFINITY),
173            Err(Error::InvalidPeriod { .. })
174        ));
175    }
176
177    #[test]
178    fn accessors_and_metadata() {
179        let kagi = KagiBars::new(2.0).unwrap();
180        assert_eq!(kagi.name(), "KagiBars");
181        assert_relative_eq!(kagi.reversal(), 2.0, epsilon = 1e-12);
182        assert_eq!(kagi.extreme(), None);
183    }
184
185    #[test]
186    fn seeds_then_establishes_up_direction() {
187        let mut kagi = KagiBars::new(2.0).unwrap();
188        assert!(kagi.update(flat(10.0)).is_empty()); // seed
189        assert_eq!(kagi.extreme(), Some(10.0));
190        assert!(kagi.update(flat(11.0)).is_empty()); // first move sets dir up
191        assert_eq!(kagi.extreme(), Some(11.0));
192    }
193
194    #[test]
195    fn establishes_down_direction_from_seed() {
196        let mut kagi = KagiBars::new(2.0).unwrap();
197        kagi.update(flat(10.0));
198        assert!(kagi.update(flat(9.0)).is_empty()); // first move sets dir down
199        assert_eq!(kagi.extreme(), Some(9.0));
200    }
201
202    #[test]
203    fn extends_without_emitting() {
204        let mut kagi = KagiBars::new(2.0).unwrap();
205        kagi.update(flat(10.0));
206        kagi.update(flat(11.0));
207        assert!(kagi.update(flat(15.0)).is_empty()); // new high, extend
208        assert_eq!(kagi.extreme(), Some(15.0));
209    }
210
211    #[test]
212    fn reversal_closes_up_segment() {
213        let mut kagi = KagiBars::new(2.0).unwrap();
214        kagi.update(flat(10.0));
215        kagi.update(flat(11.0));
216        kagi.update(flat(15.0));
217        let bars = kagi.update(flat(12.0)); // retrace 3 >= 2
218        assert_eq!(bars.len(), 1);
219        assert_eq!(bars[0].direction, 1);
220        assert_relative_eq!(bars[0].start, 10.0, epsilon = 1e-12);
221        assert_relative_eq!(bars[0].end, 15.0, epsilon = 1e-12);
222        assert_eq!(kagi.extreme(), Some(12.0));
223    }
224
225    #[test]
226    fn reversal_closes_down_segment() {
227        let mut kagi = KagiBars::new(2.0).unwrap();
228        kagi.update(flat(10.0));
229        kagi.update(flat(11.0));
230        kagi.update(flat(15.0));
231        kagi.update(flat(12.0)); // now dir down, segment_start 15, extreme 12
232        let bars = kagi.update(flat(20.0)); // rise 8 >= 2 -> closes down segment
233        assert_eq!(bars.len(), 1);
234        assert_eq!(bars[0].direction, -1);
235        assert_relative_eq!(bars[0].start, 15.0, epsilon = 1e-12);
236        assert_relative_eq!(bars[0].end, 12.0, epsilon = 1e-12);
237    }
238
239    #[test]
240    fn small_pullback_does_not_reverse() {
241        let mut kagi = KagiBars::new(2.0).unwrap();
242        kagi.update(flat(10.0));
243        kagi.update(flat(11.0));
244        kagi.update(flat(15.0));
245        assert!(kagi.update(flat(14.0)).is_empty()); // retrace 1 < 2
246        assert_eq!(kagi.extreme(), Some(15.0));
247    }
248
249    #[test]
250    fn down_trend_small_bounce_does_not_reverse() {
251        let mut kagi = KagiBars::new(2.0).unwrap();
252        kagi.update(flat(10.0));
253        kagi.update(flat(9.0)); // dir down
254        kagi.update(flat(5.0)); // extreme 5
255        assert!(kagi.update(flat(6.0)).is_empty()); // bounce 1 < 2
256        assert_eq!(kagi.extreme(), Some(5.0));
257    }
258
259    #[test]
260    fn reset_clears_state() {
261        let mut kagi = KagiBars::new(2.0).unwrap();
262        kagi.update(flat(10.0));
263        kagi.update(flat(15.0));
264        kagi.reset();
265        assert_eq!(kagi.extreme(), None);
266        assert!(kagi.update(flat(99.0)).is_empty());
267        assert_eq!(kagi.extreme(), Some(99.0));
268    }
269
270    #[test]
271    fn batch_collects_completed_segments() {
272        let mut kagi = KagiBars::new(2.0).unwrap();
273        let candles = [
274            flat(10.0),
275            flat(15.0),
276            flat(12.0), // closes up segment
277            flat(20.0), // closes down segment
278        ];
279        let bars = kagi.batch(&candles);
280        assert_eq!(bars.len(), 2);
281        assert_eq!(bars[0].direction, 1);
282        assert_eq!(bars[1].direction, -1);
283    }
284}