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        Ok(Self {
81            run_length,
82            count: 0,
83            open: 0.0,
84            high: 0.0,
85            low: 0.0,
86            close: 0.0,
87            prev_close: None,
88            run_sign: 0,
89            run_len: 0,
90        })
91    }
92
93    /// Configured run length that closes a bar.
94    pub const fn run_length(&self) -> usize {
95        self.run_length
96    }
97
98    /// Length of the in-progress run.
99    pub const fn run(&self) -> usize {
100        self.run_len
101    }
102}
103
104impl BarBuilder for RunBars {
105    type Bar = RunBar;
106
107    fn update(&mut self, candle: Candle) -> Vec<RunBar> {
108        if self.count == 0 {
109            self.open = candle.open;
110            self.high = candle.high;
111            self.low = candle.low;
112        } else {
113            self.high = self.high.max(candle.high);
114            self.low = self.low.min(candle.low);
115        }
116        self.close = candle.close;
117        self.count += 1;
118        if let Some(prev) = self.prev_close {
119            let directional = if candle.close > prev {
120                1
121            } else if candle.close < prev {
122                -1
123            } else {
124                0
125            };
126            if directional == 0 {
127                // A flat tick extends the current run (if one is under way).
128                if self.run_sign != 0 {
129                    self.run_len += 1;
130                }
131            } else if directional == self.run_sign {
132                self.run_len += 1;
133            } else {
134                self.run_sign = directional;
135                self.run_len = 1;
136            }
137        }
138        self.prev_close = Some(candle.close);
139        if self.run_sign == 0 || self.run_len < self.run_length {
140            return Vec::new();
141        }
142        let bar = RunBar {
143            open: self.open,
144            high: self.high,
145            low: self.low,
146            close: self.close,
147            length: self.run_len,
148            direction: self.run_sign,
149        };
150        self.count = 0;
151        self.run_sign = 0;
152        self.run_len = 0;
153        vec![bar]
154    }
155
156    fn reset(&mut self) {
157        self.count = 0;
158        self.prev_close = None;
159        self.run_sign = 0;
160        self.run_len = 0;
161    }
162
163    fn name(&self) -> &'static str {
164        "RunBars"
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn flat(price: f64) -> Candle {
173        Candle::new(price, price, price, price, 1.0, 0).unwrap()
174    }
175
176    #[test]
177    fn rejects_zero_run_length() {
178        assert!(matches!(RunBars::new(0), Err(Error::PeriodZero)));
179    }
180
181    #[test]
182    fn accessors_and_metadata() {
183        let bars = RunBars::new(5).unwrap();
184        assert_eq!(bars.run_length(), 5);
185        assert_eq!(bars.run(), 0);
186        assert_eq!(bars.name(), "RunBars");
187    }
188
189    #[test]
190    fn buy_run_closes_up_bar() {
191        let mut bars = RunBars::new(3).unwrap();
192        bars.update(flat(10.0)); // seed
193        bars.update(flat(11.0)); // run 1
194        bars.update(flat(12.0)); // run 2
195        let out = bars.update(flat(13.0)); // run 3
196        assert_eq!(out.len(), 1);
197        assert_eq!(out[0].direction, 1);
198        assert_eq!(out[0].length, 3);
199    }
200
201    #[test]
202    fn sell_run_closes_down_bar() {
203        let mut bars = RunBars::new(3).unwrap();
204        bars.update(flat(10.0));
205        bars.update(flat(9.0)); // run 1
206        bars.update(flat(8.0)); // run 2
207        let out = bars.update(flat(7.0)); // run 3
208        assert_eq!(out.len(), 1);
209        assert_eq!(out[0].direction, -1);
210    }
211
212    #[test]
213    fn opposite_tick_restarts_run() {
214        let mut bars = RunBars::new(3).unwrap();
215        bars.update(flat(10.0));
216        bars.update(flat(11.0)); // up run 1
217        bars.update(flat(12.0)); // up run 2
218        bars.update(flat(11.0)); // down -> run restarts at 1
219        assert_eq!(bars.run(), 1);
220    }
221
222    #[test]
223    fn flat_tick_extends_run() {
224        let mut bars = RunBars::new(3).unwrap();
225        bars.update(flat(10.0));
226        bars.update(flat(11.0)); // run 1
227        bars.update(flat(11.0)); // flat -> run 2
228        let out = bars.update(flat(12.0)); // run 3
229        assert_eq!(out.len(), 1);
230        assert_eq!(out[0].direction, 1);
231    }
232
233    #[test]
234    fn reset_clears_state() {
235        let mut bars = RunBars::new(3).unwrap();
236        bars.update(flat(10.0));
237        bars.update(flat(11.0));
238        bars.reset();
239        assert_eq!(bars.run(), 0);
240        assert!(bars.update(flat(50.0)).is_empty());
241    }
242
243    #[test]
244    fn batch_concatenates_completed_bars() {
245        let mut bars = RunBars::new(2).unwrap();
246        let candles = [
247            flat(10.0),
248            flat(11.0), // run 1
249            flat(12.0), // run 2 -> close
250            flat(13.0), // run 1
251            flat(14.0), // run 2 -> close
252        ];
253        let out = bars.batch(&candles);
254        assert_eq!(out.len(), 2);
255        assert!(out.iter().all(|b| b.direction == 1));
256    }
257}