Skip to main content

egui_charts/model/
kagi.rs

1//! Kagi chart transformation.
2//!
3//! Kagi charts display price movements as a series of connected vertical
4//! lines whose thickness changes based on trend direction:
5//!
6//! - **Thick** -- uptrend; price has risen above the prior shoulder, the
7//!   high where the last falling line began.
8//! - **Thin** -- downtrend; price has fallen below the prior waist, the
9//!   low where the last rising line began.
10//!
11//! The thickness changes at the crossing itself, so a line that crosses
12//! the shoulder or waist is drawn in two parts.
13//!
14//! A new line segment is created whenever price reverses by at least the
15//! configured [`reversal_amount`](KagiConfig::reversal_amount). Time is
16//! ignored -- only price movement matters.
17//!
18//! Use [`to_kagi_lines`] to transform a slice of [`Bar`]s.
19
20use super::PriceBasedSource;
21use crate::model::{Bar, PriceBuilder};
22use chrono::{DateTime, Utc};
23
24/// A single vertical segment in a Kagi chart.
25#[derive(Debug, Clone)]
26pub struct KagiLine {
27    /// Timestamp when this segment was created (from the source bar).
28    pub ts: DateTime<Utc>,
29    /// Price at the beginning of this segment.
30    pub start_price: f64,
31    /// Price at the end of this segment.
32    pub end_price: f64,
33    /// Line thickness at the end of the segment, indicating trend state.
34    pub thickness: KagiThickness,
35    /// Price where the segment crosses the prior shoulder or waist and takes `thickness`; the
36    /// part from `start_price` to it has the opposite thickness. `None` when the whole segment
37    /// has `thickness`.
38    pub crossing: Option<f64>,
39}
40
41/// Kagi line thickness, encoding the current trend direction.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum KagiThickness {
44    /// Thin line after a break below the previous significant low.
45    Thin,
46    /// Thick line after a break above the previous significant high.
47    Thick,
48}
49
50impl KagiLine {
51    /// Convert to a Bar for rendering compatibility
52    pub fn to_bar(&self) -> Bar {
53        Bar {
54            time: self.ts,
55            open: self.start_price,
56            high: self.start_price.max(self.end_price),
57            low: self.start_price.min(self.end_price),
58            close: self.end_price,
59            volume: 0.0, // Kagi doesn't use volume
60            colors: None,
61            payload: None,
62        }
63    }
64
65    /// Check if this line is moving up
66    pub fn is_up(&self) -> bool {
67        self.end_price > self.start_price
68    }
69}
70
71/// Configuration for the Kagi chart transformation.
72///
73/// The reversal amount can be set directly, derived from ATR, or
74/// computed as a percentage of price.
75#[derive(Debug, Clone)]
76pub struct KagiConfig {
77    /// Reversal amount - price movement needed to create a new line
78    pub reversal_amount: f64,
79    /// Price input; high/low gives continuation priority when a bar crosses both thresholds.
80    pub source: PriceBasedSource,
81}
82
83impl Default for KagiConfig {
84    fn default() -> Self {
85        Self {
86            reversal_amount: 1.0,
87            source: PriceBasedSource::Close,
88        }
89    }
90}
91
92impl KagiConfig {
93    /// Create a Kagi config with an explicit reversal amount in price units.
94    pub fn new(reversal_amount: f64) -> Self {
95        Self {
96            reversal_amount,
97            source: PriceBasedSource::Close,
98        }
99    }
100
101    /// Calculate reversal amount from ATR (Avg True Range)
102    pub fn from_atr(bars: &[Bar], period: usize, multiplier: f64) -> Self {
103        Self {
104            reversal_amount: super::BoxSizeMethod::Atr { period, multiplier }
105                .resolve(0.0, bars)
106                .unwrap_or(f64::NAN),
107            source: PriceBasedSource::Close,
108        }
109    }
110
111    /// Calculate reversal amount as percentage of price
112    pub fn from_percentage(base_price: f64, percentage: f64) -> Self {
113        Self {
114            reversal_amount: base_price.abs() * (percentage / 100.0),
115            source: PriceBasedSource::Close,
116        }
117    }
118}
119
120/// Transform bars into Kagi lines
121///
122/// Returns no lines when the reversal amount is not a positive finite number.
123///
124/// # Safety Limits
125/// - At most the newest 10,000 lines
126pub fn to_kagi_lines(bars: &[Bar], config: &KagiConfig) -> Vec<KagiLine> {
127    let reversal = config.reversal_amount;
128    if bars.is_empty() || !(reversal > 0.0 && reversal.is_finite()) {
129        return Vec::new();
130    }
131
132    let mut builder = KagiBuilder::new(reversal, config.source);
133    for bar in bars {
134        builder.push(bar);
135    }
136    builder.elements()
137}
138
139pub(crate) const MAX_LINES: usize = 10_000;
140
141/// The line in progress and the trend state.
142#[derive(Clone)]
143struct KagiState {
144    curr_price: f64,
145    line_start_price: f64,
146    line_direction_up: bool,
147    last_ts: DateTime<Utc>,
148    /// The prior shoulder of a rising line or the prior waist of a falling one, where the
149    /// line before it began; `None` on the first line.
150    level: Option<f64>,
151    /// Where the line in progress crossed `level`.
152    crossing: Option<f64>,
153    is_thick: bool,
154}
155
156impl KagiState {
157    fn line(&self) -> KagiLine {
158        KagiLine {
159            ts: self.last_ts,
160            start_price: self.line_start_price,
161            end_price: self.curr_price,
162            thickness: if self.is_thick {
163                KagiThickness::Thick
164            } else {
165                KagiThickness::Thin
166            },
167            crossing: self.crossing,
168        }
169    }
170
171    /// Starts the next line at the current price, turning against the line just closed.
172    fn reverse(&mut self, price: f64) {
173        self.level = Some(self.line_start_price);
174        self.crossing = None;
175        self.line_start_price = self.curr_price;
176        self.curr_price = price;
177        self.line_direction_up = !self.line_direction_up;
178    }
179}
180
181/// Builds Kagi lines bar by bar; `reversal` must be a positive finite number. The line in
182/// progress is kept apart from the closed lines and added by `elements`.
183#[derive(Clone)]
184pub(crate) struct KagiBuilder {
185    reversal: f64,
186    source: PriceBasedSource,
187    state: Option<KagiState>,
188    lines: Vec<KagiLine>,
189}
190
191impl KagiBuilder {
192    pub(crate) fn new(reversal: f64, source: PriceBasedSource) -> Self {
193        Self {
194            reversal,
195            source,
196            state: None,
197            lines: Vec::new(),
198        }
199    }
200}
201
202impl PriceBuilder for KagiBuilder {
203    type Element = KagiLine;
204
205    fn push(&mut self, bar: &Bar) {
206        let reversal = self.reversal;
207        let Some(st) = self.state.as_mut() else {
208            self.state = Some(KagiState {
209                curr_price: bar.close,
210                line_start_price: bar.close,
211                line_direction_up: true,
212                last_ts: bar.time,
213                level: None,
214                crossing: None,
215                is_thick: false,
216            });
217            return;
218        };
219        st.last_ts = bar.time;
220        let price = match self.source {
221            PriceBasedSource::Close => bar.close,
222            PriceBasedSource::HighLow if st.line_direction_up => {
223                if bar.high > st.curr_price {
224                    bar.high
225                } else {
226                    bar.low
227                }
228            }
229            PriceBasedSource::HighLow => {
230                if bar.low < st.curr_price {
231                    bar.low
232                } else {
233                    bar.high
234                }
235            }
236        };
237
238        let up = st.line_direction_up;
239        if up && price < st.curr_price - reversal || !up && price > st.curr_price + reversal {
240            self.lines.push(st.line());
241            st.reverse(price);
242        } else if up && price > st.curr_price || !up && price < st.curr_price {
243            st.curr_price = price;
244        } else {
245            return;
246        }
247
248        // A rising thin line turns thick above the shoulder, a falling thick line thin below
249        // the waist.
250        let up = st.line_direction_up;
251        if let Some(level) = st.level
252            && st.is_thick != up
253            && (up && st.curr_price > level || !up && st.curr_price < level)
254        {
255            st.is_thick = up;
256            st.crossing = Some(level);
257        }
258
259        // Bound memory by dropping the oldest lines; the state never reads them.
260        if self.lines.len() == 2 * MAX_LINES {
261            self.lines.drain(..MAX_LINES);
262        }
263    }
264
265    fn elements(&self) -> Vec<KagiLine> {
266        let mut lines = self.lines.clone();
267        // The line in progress, unless it has not moved
268        if let Some(st) = self
269            .state
270            .as_ref()
271            .filter(|st| st.line_start_price != st.curr_price)
272        {
273            lines.push(st.line());
274        }
275        lines.drain(..lines.len().saturating_sub(MAX_LINES));
276        lines
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use chrono::Duration;
284
285    #[test]
286    fn thickness_changes_where_a_line_crosses_the_prior_shoulder_or_waist() {
287        use KagiThickness::{Thick, Thin};
288        let start = Utc::now();
289        // 100 up to 105, down to 102, up through the 105 shoulder to 107 in one bar, down to
290        // 103 and on through the 102 waist to 101.
291        let bars: Vec<Bar> = [100.0, 105.0, 102.0, 107.0, 103.0, 101.0]
292            .into_iter()
293            .enumerate()
294            .map(|(i, close)| Bar {
295                time: start + Duration::minutes(i as i64),
296                open: close,
297                high: close,
298                low: close,
299                close,
300                volume: 0.0,
301                colors: None,
302                payload: None,
303            })
304            .collect();
305        let lines: Vec<_> = to_kagi_lines(&bars, &KagiConfig::new(1.0))
306            .into_iter()
307            .map(|l| (l.start_price, l.end_price, l.thickness, l.crossing))
308            .collect();
309        assert_eq!(
310            lines,
311            [
312                (100.0, 105.0, Thin, None),
313                (105.0, 102.0, Thin, None),
314                (102.0, 107.0, Thick, Some(105.0)),
315                (107.0, 101.0, Thin, Some(102.0)),
316            ]
317        );
318    }
319
320    #[test]
321    fn test_kagi_line_to_bar() {
322        let line = KagiLine {
323            ts: Utc::now(),
324            start_price: 100.0,
325            end_price: 105.0,
326            thickness: KagiThickness::Thick,
327            crossing: None,
328        };
329
330        let bar = line.to_bar();
331        assert_eq!(bar.open, 100.0);
332        assert_eq!(bar.close, 105.0);
333        assert_eq!(bar.high, 105.0);
334        assert_eq!(bar.low, 100.0);
335    }
336
337    #[test]
338    fn test_kagi_line_direction() {
339        let up_line = KagiLine {
340            ts: Utc::now(),
341            start_price: 100.0,
342            end_price: 105.0,
343            thickness: KagiThickness::Thick,
344            crossing: None,
345        };
346        assert!(up_line.is_up());
347
348        let down_line = KagiLine {
349            ts: Utc::now(),
350            start_price: 105.0,
351            end_price: 100.0,
352            thickness: KagiThickness::Thin,
353            crossing: None,
354        };
355        assert!(!down_line.is_up());
356    }
357}