Skip to main content

wickra_core/indicators/
run_bars.rs

1//! Run bar builder (simplified López de Prado) — sample on runs of same-signed ticks.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::BarBuilder;
6
7/// One completed run bar.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct RunBar {
10    /// Open of the first candle in the bar.
11    pub open: f64,
12    /// Highest high across the bar.
13    pub high: f64,
14    /// Lowest low across the bar.
15    pub low: f64,
16    /// Close of the candle that closed the bar.
17    pub close: f64,
18    /// Length of the run that closed the bar (`== run_length`).
19    pub length: usize,
20    /// `+1` if a buy run closed the bar, `-1` if a sell run.
21    pub direction: i8,
22}
23
24/// Run bar builder — a **simplified** form of López de Prado's run bars.
25///
26/// A *run* is an uninterrupted sequence of same-signed ticks: a streak of up-ticks
27/// (a buy run) or down-ticks (a sell run), with unchanged closes extending the
28/// current run. This builder counts the current run's length and closes a bar when
29/// it reaches `run_length`; a tick in the opposite direction restarts the run from
30/// one. Where [`ImbalanceBars`](crate::ImbalanceBars) sample on the *net* signed
31/// imbalance (which oscillating flow can cancel back to zero), run bars sample on
32/// *persistence*: they fire only when the market pushes the same way without
33/// interruption, making them a cleaner sequential-trend detector.
34///
35/// **Simplification.** The full method estimates a *dynamic* expected run length
36/// from an EWMA and can weight runs by volume or traded value. This builder uses a
37/// **fixed** run-length threshold on unweighted ticks. See López de Prado (2018),
38/// ch. 2, for the adaptive estimator and weighted variants.
39///
40/// At most one bar closes per candle, so [`BarBuilder::update`] returns either an
41/// empty vector or a single [`RunBar`].
42///
43/// # Example
44///
45/// ```
46/// use wickra_core::{BarBuilder, Candle, RunBars};
47///
48/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
49/// let mut bars = RunBars::new(3).unwrap();
50/// bars.update(flat(10.0));            // seed
51/// bars.update(flat(11.0));            // run 1
52/// bars.update(flat(12.0));            // run 2
53/// let out = bars.update(flat(13.0));  // run 3 -> close
54/// assert_eq!(out.len(), 1);
55/// assert_eq!(out[0].direction, 1);
56/// ```
57#[derive(Debug, Clone)]
58pub struct RunBars {
59    run_length: usize,
60    count: usize,
61    open: f64,
62    high: f64,
63    low: f64,
64    close: f64,
65    prev_close: Option<f64>,
66    run_sign: i8,
67    run_len: usize,
68}
69
70impl RunBars {
71    /// Construct a run-bar builder that closes a bar on a run of `run_length` ticks.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`Error::PeriodZero`] if `run_length == 0`.
76    pub fn new(run_length: usize) -> Result<Self> {
77        if run_length == 0 {
78            return Err(Error::PeriodZero);
79        }
80        if run_length > crate::error::MAX_PERIOD {
81            return Err(Error::InvalidPeriod {
82                message: crate::error::PERIOD_ABOVE_MAX,
83            });
84        }
85        Ok(Self {
86            run_length,
87            count: 0,
88            open: 0.0,
89            high: 0.0,
90            low: 0.0,
91            close: 0.0,
92            prev_close: None,
93            run_sign: 0,
94            run_len: 0,
95        })
96    }
97
98    /// Configured run length that closes a bar.
99    pub const fn run_length(&self) -> usize {
100        self.run_length
101    }
102
103    /// Length of the in-progress run.
104    pub const fn run(&self) -> usize {
105        self.run_len
106    }
107}
108
109impl BarBuilder for RunBars {
110    type Bar = RunBar;
111
112    fn update(&mut self, candle: Candle) -> Vec<RunBar> {
113        if self.count == 0 {
114            self.open = candle.open;
115            self.high = candle.high;
116            self.low = candle.low;
117        } else {
118            self.high = self.high.max(candle.high);
119            self.low = self.low.min(candle.low);
120        }
121        self.close = candle.close;
122        self.count += 1;
123        if let Some(prev) = self.prev_close {
124            let directional = if candle.close > prev {
125                1
126            } else if candle.close < prev {
127                -1
128            } else {
129                0
130            };
131            if directional == 0 {
132                // A flat tick extends the current run (if one is under way).
133                if self.run_sign != 0 {
134                    self.run_len += 1;
135                }
136            } else if directional == self.run_sign {
137                self.run_len += 1;
138            } else {
139                self.run_sign = directional;
140                self.run_len = 1;
141            }
142        }
143        self.prev_close = Some(candle.close);
144        if self.run_sign == 0 || self.run_len < self.run_length {
145            return Vec::new();
146        }
147        let bar = RunBar {
148            open: self.open,
149            high: self.high,
150            low: self.low,
151            close: self.close,
152            length: self.run_len,
153            direction: self.run_sign,
154        };
155        self.count = 0;
156        self.run_sign = 0;
157        self.run_len = 0;
158        vec![bar]
159    }
160
161    fn reset(&mut self) {
162        self.count = 0;
163        self.prev_close = None;
164        self.run_sign = 0;
165        self.run_len = 0;
166    }
167
168    #[inline]
169    fn name(&self) -> &'static str {
170        "RunBars"
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn flat(price: f64) -> Candle {
179        Candle::new(price, price, price, price, 1.0, 0).unwrap()
180    }
181
182    #[test]
183    fn rejects_zero_run_length() {
184        assert!(matches!(RunBars::new(0), Err(Error::PeriodZero)));
185    }
186
187    #[test]
188    fn accessors_and_metadata() {
189        let bars = RunBars::new(5).unwrap();
190        assert_eq!(bars.run_length(), 5);
191        assert_eq!(bars.run(), 0);
192        assert_eq!(bars.name(), "RunBars");
193    }
194
195    #[test]
196    fn buy_run_closes_up_bar() {
197        let mut bars = RunBars::new(3).unwrap();
198        bars.update(flat(10.0)); // seed
199        bars.update(flat(11.0)); // run 1
200        bars.update(flat(12.0)); // run 2
201        let out = bars.update(flat(13.0)); // run 3
202        assert_eq!(out.len(), 1);
203        assert_eq!(out[0].direction, 1);
204        assert_eq!(out[0].length, 3);
205    }
206
207    #[test]
208    fn sell_run_closes_down_bar() {
209        let mut bars = RunBars::new(3).unwrap();
210        bars.update(flat(10.0));
211        bars.update(flat(9.0)); // run 1
212        bars.update(flat(8.0)); // run 2
213        let out = bars.update(flat(7.0)); // run 3
214        assert_eq!(out.len(), 1);
215        assert_eq!(out[0].direction, -1);
216    }
217
218    #[test]
219    fn opposite_tick_restarts_run() {
220        let mut bars = RunBars::new(3).unwrap();
221        bars.update(flat(10.0));
222        bars.update(flat(11.0)); // up run 1
223        bars.update(flat(12.0)); // up run 2
224        bars.update(flat(11.0)); // down -> run restarts at 1
225        assert_eq!(bars.run(), 1);
226    }
227
228    #[test]
229    fn flat_tick_extends_run() {
230        let mut bars = RunBars::new(3).unwrap();
231        bars.update(flat(10.0));
232        bars.update(flat(11.0)); // run 1
233        bars.update(flat(11.0)); // flat -> run 2
234        let out = bars.update(flat(12.0)); // run 3
235        assert_eq!(out.len(), 1);
236        assert_eq!(out[0].direction, 1);
237    }
238
239    #[test]
240    fn reset_clears_state() {
241        let mut bars = RunBars::new(3).unwrap();
242        bars.update(flat(10.0));
243        bars.update(flat(11.0));
244        bars.reset();
245        assert_eq!(bars.run(), 0);
246        assert!(bars.update(flat(50.0)).is_empty());
247    }
248
249    #[test]
250    fn batch_concatenates_completed_bars() {
251        let mut bars = RunBars::new(2).unwrap();
252        let candles = [
253            flat(10.0),
254            flat(11.0), // run 1
255            flat(12.0), // run 2 -> close
256            flat(13.0), // run 1
257            flat(14.0), // run 2 -> close
258        ];
259        let out = bars.batch(&candles);
260        assert_eq!(out.len(), 2);
261        assert!(out.iter().all(|b| b.direction == 1));
262    }
263}