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    fn name(&self) -> &'static str {
147        "KagiBars"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use approx::assert_relative_eq;
155
156    fn flat(price: f64) -> Candle {
157        Candle::new(price, price, price, price, 1.0, 0).unwrap()
158    }
159
160    #[test]
161    fn rejects_invalid_reversal() {
162        assert!(matches!(
163            KagiBars::new(0.0),
164            Err(Error::InvalidPeriod { .. })
165        ));
166        assert!(matches!(
167            KagiBars::new(-2.0),
168            Err(Error::InvalidPeriod { .. })
169        ));
170        assert!(matches!(
171            KagiBars::new(f64::INFINITY),
172            Err(Error::InvalidPeriod { .. })
173        ));
174    }
175
176    #[test]
177    fn accessors_and_metadata() {
178        let kagi = KagiBars::new(2.0).unwrap();
179        assert_eq!(kagi.name(), "KagiBars");
180        assert_relative_eq!(kagi.reversal(), 2.0, epsilon = 1e-12);
181        assert_eq!(kagi.extreme(), None);
182    }
183
184    #[test]
185    fn seeds_then_establishes_up_direction() {
186        let mut kagi = KagiBars::new(2.0).unwrap();
187        assert!(kagi.update(flat(10.0)).is_empty()); // seed
188        assert_eq!(kagi.extreme(), Some(10.0));
189        assert!(kagi.update(flat(11.0)).is_empty()); // first move sets dir up
190        assert_eq!(kagi.extreme(), Some(11.0));
191    }
192
193    #[test]
194    fn establishes_down_direction_from_seed() {
195        let mut kagi = KagiBars::new(2.0).unwrap();
196        kagi.update(flat(10.0));
197        assert!(kagi.update(flat(9.0)).is_empty()); // first move sets dir down
198        assert_eq!(kagi.extreme(), Some(9.0));
199    }
200
201    #[test]
202    fn extends_without_emitting() {
203        let mut kagi = KagiBars::new(2.0).unwrap();
204        kagi.update(flat(10.0));
205        kagi.update(flat(11.0));
206        assert!(kagi.update(flat(15.0)).is_empty()); // new high, extend
207        assert_eq!(kagi.extreme(), Some(15.0));
208    }
209
210    #[test]
211    fn reversal_closes_up_segment() {
212        let mut kagi = KagiBars::new(2.0).unwrap();
213        kagi.update(flat(10.0));
214        kagi.update(flat(11.0));
215        kagi.update(flat(15.0));
216        let bars = kagi.update(flat(12.0)); // retrace 3 >= 2
217        assert_eq!(bars.len(), 1);
218        assert_eq!(bars[0].direction, 1);
219        assert_relative_eq!(bars[0].start, 10.0, epsilon = 1e-12);
220        assert_relative_eq!(bars[0].end, 15.0, epsilon = 1e-12);
221        assert_eq!(kagi.extreme(), Some(12.0));
222    }
223
224    #[test]
225    fn reversal_closes_down_segment() {
226        let mut kagi = KagiBars::new(2.0).unwrap();
227        kagi.update(flat(10.0));
228        kagi.update(flat(11.0));
229        kagi.update(flat(15.0));
230        kagi.update(flat(12.0)); // now dir down, segment_start 15, extreme 12
231        let bars = kagi.update(flat(20.0)); // rise 8 >= 2 -> closes down segment
232        assert_eq!(bars.len(), 1);
233        assert_eq!(bars[0].direction, -1);
234        assert_relative_eq!(bars[0].start, 15.0, epsilon = 1e-12);
235        assert_relative_eq!(bars[0].end, 12.0, epsilon = 1e-12);
236    }
237
238    #[test]
239    fn small_pullback_does_not_reverse() {
240        let mut kagi = KagiBars::new(2.0).unwrap();
241        kagi.update(flat(10.0));
242        kagi.update(flat(11.0));
243        kagi.update(flat(15.0));
244        assert!(kagi.update(flat(14.0)).is_empty()); // retrace 1 < 2
245        assert_eq!(kagi.extreme(), Some(15.0));
246    }
247
248    #[test]
249    fn down_trend_small_bounce_does_not_reverse() {
250        let mut kagi = KagiBars::new(2.0).unwrap();
251        kagi.update(flat(10.0));
252        kagi.update(flat(9.0)); // dir down
253        kagi.update(flat(5.0)); // extreme 5
254        assert!(kagi.update(flat(6.0)).is_empty()); // bounce 1 < 2
255        assert_eq!(kagi.extreme(), Some(5.0));
256    }
257
258    #[test]
259    fn reset_clears_state() {
260        let mut kagi = KagiBars::new(2.0).unwrap();
261        kagi.update(flat(10.0));
262        kagi.update(flat(15.0));
263        kagi.reset();
264        assert_eq!(kagi.extreme(), None);
265        assert!(kagi.update(flat(99.0)).is_empty());
266        assert_eq!(kagi.extreme(), Some(99.0));
267    }
268
269    #[test]
270    fn batch_collects_completed_segments() {
271        let mut kagi = KagiBars::new(2.0).unwrap();
272        let candles = [
273            flat(10.0),
274            flat(15.0),
275            flat(12.0), // closes up segment
276            flat(20.0), // closes down segment
277        ];
278        let bars = kagi.batch(&candles);
279        assert_eq!(bars.len(), 2);
280        assert_eq!(bars[0].direction, 1);
281        assert_eq!(bars[1].direction, -1);
282    }
283}