Skip to main content

quantwave_core/indicators/
kagi.rs

1//! Kagi line construction (price → alternating up/down lines).
2//!
3//! A Kagi chart discards time and draws a single connected line that keeps
4//! extending in the current direction while price advances, and reverses only
5//! when price retraces from the running extreme by at least the reversal amount
6//! `H`. Each completed *line* runs between two consecutive turning points (true
7//! price extrema — the tops/"shoulders" and bottoms/"waists").
8//!
9//! Reversal construction (threshold `H`, extrema as turning points) follows:
10//!
11//!   Bogomolov (2013), "Pairs trading based on statistical variability of the
12//!   spread process", Quantitative Finance 13(9): 1411–1430 — which formalises
13//!   the Kagi (and Renko) constructions as model-free, time-independent samplers
14//!   of a price path driven by a single threshold `H`: an up-move reverses at the
15//!   first price `<= running_max − H`, a down-move at the first `>= running_min + H`.
16//!
17//! The yin/yang `thickness` follows the classic charting convention (Nison,
18//! "Beyond Candlesticks", 1994): the line turns **yang** (`+1`, thick/bullish)
19//! when price rises above the prior shoulder, and **yin** (`-1`, thin/bearish)
20//! when it falls below the prior waist; it holds its state in between. This is
21//! the machine-readable Kagi signal, kept separate from any visualization.
22//!
23//! Two surfaces kept in parity (see the proptest):
24//! - streaming: [`KagiBuilder::next`] pushes one price, returns a line when the
25//!   current direction reverses (else `None`);
26//! - batch: [`kagi_batch`] folds the builder over a price slice.
27
28/// One completed Kagi line, running between two consecutive turning points.
29/// `direction` is +1 (up) / -1 (down); `thickness` is +1 (yang), -1 (yin), or 0
30/// (undetermined — no prior shoulder/waist has been broken yet).
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct KagiLine {
33    /// Start turning point (the previous extreme the line departs from).
34    pub open: f64,
35    /// End turning point (the extreme where the reversal was confirmed).
36    pub close: f64,
37    pub direction: i8,
38    pub thickness: i8,
39}
40
41/// Stateful Kagi builder with a fixed reversal amount `H`.
42#[derive(Debug, Clone)]
43pub struct KagiBuilder {
44    reversal: f64,
45    /// Start price of the current line (previous turning point, or the anchor).
46    start: f64,
47    /// Running extreme of the current line (max while up, min while down).
48    extreme: f64,
49    /// Current trend: +1 up, -1 down, 0 undetermined (before the first `H`-move).
50    direction: i8,
51    /// Most recent top turning point (set when an up-line completes).
52    shoulder: Option<f64>,
53    /// Most recent bottom turning point (set when a down-line completes).
54    waist: Option<f64>,
55    /// Current yin/yang state; carries over until a shoulder/waist break flips it.
56    thickness: i8,
57    initialized: bool,
58}
59
60impl KagiBuilder {
61    /// Create a builder. `reversal` (`H`) must be strictly positive.
62    pub fn new(reversal: f64) -> Self {
63        Self {
64            reversal,
65            start: 0.0,
66            extreme: 0.0,
67            direction: 0,
68            shoulder: None,
69            waist: None,
70            thickness: 0,
71            initialized: false,
72        }
73    }
74
75    /// Update the yin/yang state from a price crossing the prior shoulder/waist.
76    fn update_thickness(&mut self, price: f64) {
77        if let Some(s) = self.shoulder
78            && price > s
79        {
80            self.thickness = 1;
81        }
82        if let Some(w) = self.waist
83            && price < w
84        {
85            self.thickness = -1;
86        }
87    }
88
89    /// Push one price; return a completed line if this price confirmed a reversal
90    /// of the current direction, else `None`.
91    pub fn next(&mut self, price: f64) -> Option<KagiLine> {
92        if self.reversal <= 0.0 || !price.is_finite() {
93            return None;
94        }
95        if !self.initialized {
96            self.start = price;
97            self.extreme = price;
98            self.initialized = true;
99            return None;
100        }
101        if self.direction == 0 {
102            // Undetermined: the first `H`-move from the anchor fixes the trend.
103            if price >= self.start + self.reversal {
104                self.direction = 1;
105                self.extreme = price;
106            } else if price <= self.start - self.reversal {
107                self.direction = -1;
108                self.extreme = price;
109            }
110            self.update_thickness(price);
111            return None;
112        }
113        if self.direction == 1 {
114            if price > self.extreme {
115                // Line still advancing: thickness tracks the price making the high.
116                self.extreme = price;
117                self.update_thickness(price);
118            } else if price <= self.extreme - self.reversal {
119                // Up-line completes at its top (a new shoulder); a down-line opens.
120                // `thickness` is the state at the top, before the triggering price
121                // (which belongs to the new down-line) touches it.
122                let line = KagiLine {
123                    open: self.start,
124                    close: self.extreme,
125                    direction: 1,
126                    thickness: self.thickness,
127                };
128                self.shoulder = Some(self.extreme);
129                self.start = self.extreme;
130                self.direction = -1;
131                self.extreme = price;
132                self.update_thickness(price);
133                return Some(line);
134            }
135        } else {
136            if price < self.extreme {
137                self.extreme = price;
138                self.update_thickness(price);
139            } else if price >= self.extreme + self.reversal {
140                // Down-line completes at its bottom (a new waist); an up-line opens.
141                let line = KagiLine {
142                    open: self.start,
143                    close: self.extreme,
144                    direction: -1,
145                    thickness: self.thickness,
146                };
147                self.waist = Some(self.extreme);
148                self.start = self.extreme;
149                self.direction = 1;
150                self.extreme = price;
151                self.update_thickness(price);
152                return Some(line);
153            }
154        }
155        None
156    }
157}
158
159/// Batch Kagi: fold [`KagiBuilder`] over a price slice, returning completed lines.
160pub fn kagi_batch(prices: &[f64], reversal: f64) -> Vec<KagiLine> {
161    let mut b = KagiBuilder::new(reversal);
162    let mut out = Vec::new();
163    for &p in prices {
164        if let Some(line) = b.next(p) {
165            out.push(line);
166        }
167    }
168    out
169}
170
171/// ATR-reversal Kagi: reversal amount is `multiplier * atr` (a single
172/// representative ATR), matching the ATR box convention of the other bar types.
173pub fn kagi_atr_batch(prices: &[f64], atr: f64, multiplier: f64) -> Vec<KagiLine> {
174    kagi_batch(prices, atr * multiplier)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use proptest::prelude::*;
181
182    #[test]
183    fn gold_single_reversal() {
184        // Anchor 10, H=2. Rise to 14 (up-line), retrace to 11 (<= 14-2) → the
185        // up-line [10,14] completes; nothing else confirmed yet.
186        let lines = kagi_batch(&[10.0, 12.0, 14.0, 11.0], 2.0);
187        assert_eq!(lines.len(), 1);
188        assert_eq!(lines[0].open, 10.0);
189        assert_eq!(lines[0].close, 14.0);
190        assert_eq!(lines[0].direction, 1);
191    }
192
193    #[test]
194    fn gold_alternating_directions() {
195        // Up to 14, down to 10 (<=14-2 reverses; new bottom 10), up to 14 again
196        // (>=10+2 reverses). Lines: up [10,14], down [14,10]. Directions alternate.
197        let lines = kagi_batch(&[10.0, 14.0, 10.0, 14.0], 2.0);
198        let dirs: Vec<i8> = lines.iter().map(|l| l.direction).collect();
199        assert_eq!(dirs, vec![1, -1]);
200        assert_eq!(lines[0].open, 10.0);
201        assert_eq!(lines[0].close, 14.0);
202        assert_eq!(lines[1].open, 14.0);
203        assert_eq!(lines[1].close, 10.0);
204    }
205
206    #[test]
207    fn gold_no_reversal_within_threshold() {
208        // Small wiggles never retrace a full H from the extreme → no completed line.
209        assert!(kagi_batch(&[10.0, 11.0, 10.2, 11.5, 10.8], 2.0).is_empty());
210    }
211
212    #[test]
213    fn gold_yang_on_higher_high() {
214        // First up-line [10,14] sets shoulder=14 (thickness 0, no prior shoulder).
215        // Reverse down to 11 → down-line [14,11], waist=11. Rise: crossing 14
216        // (prior shoulder) turns the line yang; peak 17 then retrace to 14 (<=17-2)
217        // completes an up-line with thickness +1 (yang).
218        let lines = kagi_batch(&[10.0, 14.0, 11.0, 17.0, 14.0], 2.0);
219        assert_eq!(lines.len(), 3);
220        assert_eq!(lines[0].thickness, 0); // no prior shoulder to break
221        assert_eq!(lines[1].direction, -1);
222        assert_eq!(lines[2].direction, 1);
223        assert_eq!(lines[2].thickness, 1); // broke above prior shoulder 14 → yang
224    }
225
226    proptest! {
227        // Streaming (one at a time) must equal batch (fold).
228        #[test]
229        fn streaming_equals_batch(
230            prices in prop::collection::vec(0.0f64..1000.0, 1..200),
231            reversal in 0.5f64..50.0,
232        ) {
233            let batch = kagi_batch(&prices, reversal);
234            let mut b = KagiBuilder::new(reversal);
235            let mut streamed = Vec::new();
236            for &p in &prices {
237                if let Some(line) = b.next(p) {
238                    streamed.push(line);
239                }
240            }
241            prop_assert_eq!(streamed, batch);
242        }
243
244        // Consecutive lines strictly alternate direction, each connected end-to-
245        // start, and each spans at least the reversal amount.
246        #[test]
247        fn lines_alternate_and_connect(
248            prices in prop::collection::vec(0.0f64..1000.0, 1..200),
249            reversal in 0.5f64..50.0,
250        ) {
251            let lines = kagi_batch(&prices, reversal);
252            for w in lines.windows(2) {
253                prop_assert_eq!(w[0].direction, -w[1].direction);
254                prop_assert_eq!(w[0].close, w[1].open); // connected turning point
255            }
256            for l in &lines {
257                let span = l.close - l.open;
258                prop_assert!((span.abs()) >= reversal - 1e-9);
259                prop_assert_eq!(span.signum() as i8, l.direction);
260                prop_assert!(l.thickness >= -1 && l.thickness <= 1);
261            }
262        }
263    }
264}