wickra_core/indicators/
spinning_top.rs1use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone)]
45pub struct SpinningTop {
46 body_threshold: f64,
47 has_emitted: bool,
48}
49
50impl Default for SpinningTop {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl SpinningTop {
57 pub const fn new() -> Self {
59 Self {
60 body_threshold: 0.3,
61 has_emitted: false,
62 }
63 }
64
65 pub fn with_threshold(body_threshold: f64) -> Result<Self> {
67 if !(body_threshold > 0.0 && body_threshold <= 1.0) {
68 return Err(Error::InvalidPeriod {
69 message: "spinning top body threshold must lie in (0, 1]",
70 });
71 }
72 Ok(Self {
73 body_threshold,
74 has_emitted: false,
75 })
76 }
77
78 pub fn body_threshold(&self) -> f64 {
80 self.body_threshold
81 }
82}
83
84impl Indicator for SpinningTop {
85 type Input = Candle;
86 type Output = f64;
87
88 fn update(&mut self, candle: Candle) -> Option<f64> {
89 self.has_emitted = true;
90 let range = candle.high - candle.low;
91 if range <= 0.0 {
92 return Some(0.0);
93 }
94 let body_signed = candle.close - candle.open;
95 let body = body_signed.abs();
96 if body <= 0.0 {
97 return Some(0.0);
98 }
99 if body > self.body_threshold * range {
100 return Some(0.0);
101 }
102 let upper = candle.high - candle.open.max(candle.close);
103 let lower = candle.open.min(candle.close) - candle.low;
104 if upper >= 2.0 * body && lower >= 2.0 * body {
105 Some(if body_signed > 0.0 { 1.0 } else { -1.0 })
106 } else {
107 Some(0.0)
108 }
109 }
110
111 fn reset(&mut self) {
112 self.has_emitted = false;
113 }
114
115 fn warmup_period(&self) -> usize {
116 1
117 }
118
119 fn is_ready(&self) -> bool {
120 self.has_emitted
121 }
122
123 fn name(&self) -> &'static str {
124 "SpinningTop"
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::traits::BatchExt;
132
133 fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
134 Candle::new(open, high, low, close, 1.0, ts).unwrap()
135 }
136
137 #[test]
138 fn rejects_invalid_threshold() {
139 assert!(SpinningTop::with_threshold(0.0).is_err());
140 assert!(SpinningTop::with_threshold(1.5).is_err());
141 }
142
143 #[test]
144 fn accepts_valid_threshold() {
145 let s = SpinningTop::with_threshold(0.25).unwrap();
146 assert!((s.body_threshold() - 0.25).abs() < 1e-12);
147 }
148
149 #[test]
150 fn accessors_and_metadata() {
151 let s = SpinningTop::default();
152 assert_eq!(s.name(), "SpinningTop");
153 assert_eq!(s.warmup_period(), 1);
154 assert!(!s.is_ready());
155 assert!((s.body_threshold() - 0.3).abs() < 1e-12);
156 }
157
158 #[test]
159 fn green_spinning_top_is_plus_one() {
160 let mut s = SpinningTop::new();
161 assert_eq!(s.update(c(10.0, 13.5, 7.0, 10.5, 0)), Some(1.0));
163 }
164
165 #[test]
166 fn red_spinning_top_is_minus_one() {
167 let mut s = SpinningTop::new();
168 assert_eq!(s.update(c(10.5, 13.5, 7.0, 10.0, 0)), Some(-1.0));
169 }
170
171 #[test]
172 fn marubozu_is_not_spinning() {
173 let mut s = SpinningTop::new();
174 assert_eq!(s.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
175 }
176
177 #[test]
178 fn doji_is_not_spinning() {
179 let mut s = SpinningTop::new();
181 assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
182 }
183
184 #[test]
185 fn hammer_shape_is_not_spinning_top() {
186 let mut s = SpinningTop::new();
188 assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0));
189 }
190
191 #[test]
192 fn zero_range_yields_zero() {
193 let mut s = SpinningTop::new();
194 assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
195 }
196
197 #[test]
198 fn batch_equals_streaming() {
199 let candles: Vec<Candle> = (0..40)
200 .map(|i| {
201 let base = 100.0 + i as f64;
202 c(base, base + 3.0, base - 3.0, base + 0.5, i)
203 })
204 .collect();
205 let mut a = SpinningTop::new();
206 let mut b = SpinningTop::new();
207 assert_eq!(
208 a.batch(&candles),
209 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
210 );
211 }
212
213 #[test]
214 fn reset_clears_state() {
215 let mut s = SpinningTop::new();
216 s.update(c(10.0, 13.5, 7.0, 10.5, 0));
217 assert!(s.is_ready());
218 s.reset();
219 assert!(!s.is_ready());
220 }
221}