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 #[inline]
89 fn update(&mut self, candle: Candle) -> Option<f64> {
90 self.has_emitted = true;
91 let range = candle.high - candle.low;
92 if range <= 0.0 {
93 return Some(0.0);
94 }
95 let body_signed = candle.close - candle.open;
96 let body = body_signed.abs();
97 if body <= 0.0 {
98 return Some(0.0);
99 }
100 if body > self.body_threshold * range {
101 return Some(0.0);
102 }
103 let upper = candle.high - candle.open.max(candle.close);
104 let lower = candle.open.min(candle.close) - candle.low;
105 if upper >= 2.0 * body && lower >= 2.0 * body {
106 Some(if body_signed > 0.0 { 1.0 } else { -1.0 })
107 } else {
108 Some(0.0)
109 }
110 }
111
112 fn reset(&mut self) {
113 self.has_emitted = false;
114 }
115
116 #[inline]
117 fn warmup_period(&self) -> usize {
118 1
119 }
120
121 #[inline]
122 fn is_ready(&self) -> bool {
123 self.has_emitted
124 }
125
126 #[inline]
127 fn name(&self) -> &'static str {
128 "SpinningTop"
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::traits::BatchExt;
136
137 fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
138 Candle::new(open, high, low, close, 1.0, ts).unwrap()
139 }
140
141 #[test]
142 fn rejects_invalid_threshold() {
143 assert!(SpinningTop::with_threshold(0.0).is_err());
144 assert!(SpinningTop::with_threshold(1.5).is_err());
145 }
146
147 #[test]
148 fn accepts_valid_threshold() {
149 let s = SpinningTop::with_threshold(0.25).unwrap();
150 assert!((s.body_threshold() - 0.25).abs() < 1e-12);
151 }
152
153 #[test]
154 fn accessors_and_metadata() {
155 let s = SpinningTop::default();
156 assert_eq!(s.name(), "SpinningTop");
157 assert_eq!(s.warmup_period(), 1);
158 assert!(!s.is_ready());
159 assert!((s.body_threshold() - 0.3).abs() < 1e-12);
160 }
161
162 #[test]
163 fn green_spinning_top_is_plus_one() {
164 let mut s = SpinningTop::new();
165 assert_eq!(s.update(c(10.0, 13.5, 7.0, 10.5, 0)), Some(1.0));
167 }
168
169 #[test]
170 fn red_spinning_top_is_minus_one() {
171 let mut s = SpinningTop::new();
172 assert_eq!(s.update(c(10.5, 13.5, 7.0, 10.0, 0)), Some(-1.0));
173 }
174
175 #[test]
176 fn marubozu_is_not_spinning() {
177 let mut s = SpinningTop::new();
178 assert_eq!(s.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
179 }
180
181 #[test]
182 fn doji_is_not_spinning() {
183 let mut s = SpinningTop::new();
185 assert_eq!(s.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(0.0));
186 }
187
188 #[test]
189 fn hammer_shape_is_not_spinning_top() {
190 let mut s = SpinningTop::new();
192 assert_eq!(s.update(c(10.0, 10.6, 5.0, 10.5, 0)), Some(0.0));
193 }
194
195 #[test]
196 fn zero_range_yields_zero() {
197 let mut s = SpinningTop::new();
198 assert_eq!(s.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
199 }
200
201 #[test]
202 fn batch_equals_streaming() {
203 let candles: Vec<Candle> = (0..40)
204 .map(|i| {
205 let base = 100.0 + i as f64;
206 c(base, base + 3.0, base - 3.0, base + 0.5, i)
207 })
208 .collect();
209 let mut a = SpinningTop::new();
210 let mut b = SpinningTop::new();
211 assert_eq!(
212 a.batch(&candles),
213 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
214 );
215 }
216
217 #[test]
218 fn reset_clears_state() {
219 let mut s = SpinningTop::new();
220 s.update(c(10.0, 13.5, 7.0, 10.5, 0));
221 assert!(s.is_ready());
222 s.reset();
223 assert!(!s.is_ready());
224 }
225}