wickra_core/indicators/
belt_hold.rs1use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
41pub struct BeltHold {
42 shadow_tolerance: f64,
43 has_emitted: bool,
44}
45
46impl Default for BeltHold {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl BeltHold {
53 pub const fn new() -> Self {
55 Self {
56 shadow_tolerance: 0.05,
57 has_emitted: false,
58 }
59 }
60
61 pub fn with_tolerance(shadow_tolerance: f64) -> Result<Self> {
65 if !(0.0..1.0).contains(&shadow_tolerance) {
66 return Err(Error::InvalidPeriod {
67 message: "belt-hold shadow tolerance must lie in [0, 1)",
68 });
69 }
70 Ok(Self {
71 shadow_tolerance,
72 has_emitted: false,
73 })
74 }
75
76 pub fn shadow_tolerance(&self) -> f64 {
78 self.shadow_tolerance
79 }
80}
81
82impl Indicator for BeltHold {
83 type Input = Candle;
84 type Output = f64;
85
86 #[inline]
87 fn update(&mut self, candle: Candle) -> Option<f64> {
88 self.has_emitted = true;
89 let range = candle.high - candle.low;
90 if range <= 0.0 {
91 return Some(0.0);
92 }
93 let body = candle.close - candle.open;
94 if body.abs() < 0.5 * range {
95 return Some(0.0);
96 }
97 let tol = self.shadow_tolerance * range;
98 if body > 0.0 && candle.open - candle.low <= tol {
100 return Some(1.0);
101 }
102 if body < 0.0 && candle.high - candle.open <= tol {
104 return Some(-1.0);
105 }
106 Some(0.0)
107 }
108
109 fn reset(&mut self) {
110 self.has_emitted = false;
111 }
112
113 #[inline]
114 fn warmup_period(&self) -> usize {
115 1
116 }
117
118 #[inline]
119 fn is_ready(&self) -> bool {
120 self.has_emitted
121 }
122
123 #[inline]
124 fn name(&self) -> &'static str {
125 "BeltHold"
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::traits::BatchExt;
133
134 fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
135 Candle::new(open, high, low, close, 1.0, ts).unwrap()
136 }
137
138 #[test]
139 fn rejects_invalid_tolerance() {
140 assert!(BeltHold::with_tolerance(-0.01).is_err());
141 assert!(BeltHold::with_tolerance(1.0).is_err());
142 }
143
144 #[test]
145 fn accepts_valid_tolerance() {
146 let t = BeltHold::with_tolerance(0.0).unwrap();
147 assert!((t.shadow_tolerance() - 0.0).abs() < 1e-12);
148 }
149
150 #[test]
151 fn accessors_and_metadata() {
152 let t = BeltHold::default();
153 assert_eq!(t.name(), "BeltHold");
154 assert_eq!(t.warmup_period(), 1);
155 assert!(!t.is_ready());
156 assert!((t.shadow_tolerance() - 0.05).abs() < 1e-12);
157 }
158
159 #[test]
160 fn bullish_belt_hold_is_plus_one() {
161 let mut t = BeltHold::new();
162 assert_eq!(t.update(c(10.0, 12.0, 10.0, 11.5, 0)), Some(1.0));
163 }
164
165 #[test]
166 fn bearish_belt_hold_is_minus_one() {
167 let mut t = BeltHold::new();
168 assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.5, 0)), Some(-1.0));
169 }
170
171 #[test]
172 fn opening_shadow_yields_zero() {
173 let mut t = BeltHold::new();
174 assert_eq!(t.update(c(10.5, 12.0, 10.0, 11.5, 0)), Some(0.0));
176 }
177
178 #[test]
179 fn short_body_yields_zero() {
180 let mut t = BeltHold::new();
181 assert_eq!(t.update(c(10.0, 12.0, 10.0, 10.5, 0)), Some(0.0));
183 }
184
185 #[test]
186 fn zero_range_yields_zero() {
187 let mut t = BeltHold::new();
188 assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
189 }
190
191 #[test]
192 fn batch_equals_streaming() {
193 let candles: Vec<Candle> = (0..40)
194 .map(|i| {
195 let base = 100.0 + i as f64;
196 c(base, base + 2.0, base, base + 1.8, i)
197 })
198 .collect();
199 let mut a = BeltHold::new();
200 let mut b = BeltHold::new();
201 assert_eq!(
202 a.batch(&candles),
203 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
204 );
205 }
206
207 #[test]
208 fn reset_clears_state() {
209 let mut t = BeltHold::new();
210 t.update(c(10.0, 12.0, 10.0, 11.5, 0));
211 assert!(t.is_ready());
212 t.reset();
213 assert!(!t.is_ready());
214 }
215}