Skip to main content

wickra_core/indicators/
wick_ratio.rs

1//! Wick Ratio — the shadow imbalance of a bar.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Wick Ratio — the signed imbalance between the upper and lower shadows as a
7/// fraction of the bar's range.
8///
9/// ```text
10/// upper_wick  = high − max(open, close)
11/// lower_wick  = min(open, close) − low
12/// WickRatio   = (upper_wick − lower_wick) / (high − low)
13/// ```
14///
15/// The result lives in `[−1, +1]`: `+1` is a bar that is all upper shadow (a
16/// long rejection of higher prices, classic shooting-star geometry), `−1` all
17/// lower shadow (a long rejection of lower prices, hammer geometry), and `0`
18/// either a symmetric bar or a wickless one. Where
19/// [`BodySizePct`](crate::BodySizePct) measures how much of the range is body,
20/// this measures *which side* the wicks fall on — the rejection asymmetry many
21/// reversal setups depend on. A zero-range bar yields `0`.
22///
23/// This is a stateless per-bar transform: every candle produces one value.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Candle, Indicator, WickRatio};
29///
30/// let mut indicator = WickRatio::new();
31/// // upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +0.8333.
32/// let c = Candle::new(10.0, 13.0, 10.0, 10.5, 10.0, 0).unwrap();
33/// assert!((indicator.update(c).unwrap() - 2.5 / 3.0).abs() < 1e-12);
34/// ```
35#[derive(Debug, Clone, Default)]
36pub struct WickRatio {
37    has_emitted: bool,
38}
39
40impl WickRatio {
41    /// Construct a new Wick Ratio transform.
42    pub const fn new() -> Self {
43        Self { has_emitted: false }
44    }
45}
46
47impl Indicator for WickRatio {
48    type Input = Candle;
49    type Output = f64;
50
51    #[inline]
52    fn update(&mut self, candle: Candle) -> Option<f64> {
53        self.has_emitted = true;
54        let range = candle.high - candle.low;
55        let out = if range == 0.0 {
56            // A zero-range bar has no shadows to compare.
57            0.0
58        } else {
59            let body_top = candle.open.max(candle.close);
60            let body_bottom = candle.open.min(candle.close);
61            let upper_wick = candle.high - body_top;
62            let lower_wick = body_bottom - candle.low;
63            (upper_wick - lower_wick) / range
64        };
65        Some(out)
66    }
67
68    fn reset(&mut self) {
69        self.has_emitted = false;
70    }
71
72    #[inline]
73    fn warmup_period(&self) -> usize {
74        1
75    }
76
77    #[inline]
78    fn is_ready(&self) -> bool {
79        self.has_emitted
80    }
81
82    #[inline]
83    fn name(&self) -> &'static str {
84        "WickRatio"
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::traits::BatchExt;
92    use approx::assert_relative_eq;
93
94    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
95        Candle::new(open, high, low, close, 1.0, ts).unwrap()
96    }
97
98    #[test]
99    fn upper_shadow_dominates_is_positive() {
100        // upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +2.5/3.
101        let mut wr = WickRatio::new();
102        assert_relative_eq!(
103            wr.update(candle(10.0, 13.0, 10.0, 10.5, 0)).unwrap(),
104            2.5 / 3.0,
105            epsilon = 1e-12
106        );
107    }
108
109    #[test]
110    fn lower_shadow_dominates_is_negative() {
111        // Hammer: long lower shadow -> negative.
112        // open 12, close 12.5, high 13, low 9: upper 0.5, lower 3, range 4.
113        let mut wr = WickRatio::new();
114        assert_relative_eq!(
115            wr.update(candle(12.0, 13.0, 9.0, 12.5, 0)).unwrap(),
116            (0.5 - 3.0) / 4.0,
117            epsilon = 1e-12
118        );
119    }
120
121    #[test]
122    fn symmetric_wicks_are_zero() {
123        // Equal upper and lower shadows -> 0.
124        let mut wr = WickRatio::new();
125        assert_relative_eq!(
126            wr.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
127            0.0,
128            epsilon = 1e-12
129        );
130    }
131
132    #[test]
133    fn zero_range_bar_yields_zero() {
134        let mut wr = WickRatio::new();
135        assert_relative_eq!(
136            wr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
137            0.0,
138            epsilon = 1e-12
139        );
140    }
141
142    #[test]
143    fn stays_within_unit_range() {
144        let candles: Vec<Candle> = (0..100)
145            .map(|i| {
146                let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
147                let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
148                candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
149            })
150            .collect();
151        let mut wr = WickRatio::new();
152        for v in wr.batch(&candles).into_iter().flatten() {
153            assert!((-1.0..=1.0).contains(&v), "WickRatio {v} outside [-1, 1]");
154        }
155    }
156
157    #[test]
158    fn name_metadata() {
159        let wr = WickRatio::new();
160        assert_eq!(wr.name(), "WickRatio");
161    }
162
163    #[test]
164    fn emits_from_first_candle() {
165        let mut wr = WickRatio::new();
166        assert_eq!(wr.warmup_period(), 1);
167        assert!(!wr.is_ready());
168        assert!(wr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
169        assert!(wr.is_ready());
170    }
171
172    #[test]
173    fn reset_clears_state() {
174        let mut wr = WickRatio::new();
175        wr.update(candle(10.0, 11.0, 9.0, 10.0, 0));
176        assert!(wr.is_ready());
177        wr.reset();
178        assert!(!wr.is_ready());
179    }
180
181    #[test]
182    fn batch_equals_streaming() {
183        let candles: Vec<Candle> = (0..40)
184            .map(|i| {
185                let base = 100.0 + f64::from(i);
186                candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
187            })
188            .collect();
189        let mut a = WickRatio::new();
190        let mut b = WickRatio::new();
191        assert_eq!(
192            a.batch(&candles),
193            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
194        );
195    }
196}