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}