1use std::collections::VecDeque;
11
12use crate::clustering::RollingRobustThreshold;
13use crate::model::Bar;
14
15use super::smoothing::Rma;
16use super::{Indicator, IndicatorAlert, IndicatorOutput};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum WyckoffBias {
22 Accumulation,
23 Distribution,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum WyckoffPhase {
28 Undefined,
30 A,
32 B,
34 C,
36 D,
38 E,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum WyckoffEventKind {
44 SecondaryTest,
45 Spring,
46 Utad,
47 SignOfStrength,
48 SignOfWeakness,
49 LastPointOfSupport,
50 LastPointOfSupply,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct WyckoffEvent {
55 pub kind: WyckoffEventKind,
56 pub price: f64,
57 pub timestamp: i64,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct WyckoffScore {
63 pub cause_score: f64,
66 pub sequence_quality: f64,
70}
71
72pub struct WyckoffStateMachine {
73 range_lookback: usize,
74 range_atr_max: f64,
75 min_range_bars: usize,
76 atr: Rma,
77 prev_close: Option<f64>,
78 volume_threshold: RollingRobustThreshold,
79 bars: VecDeque<Bar>,
80 bars_in_range: u32,
81 range_high: f64,
82 range_low: f64,
83 range_locked: bool,
84 bias: Option<WyckoffBias>,
85 phase: WyckoffPhase,
86 events: Vec<WyckoffEvent>,
87 alerts: Vec<IndicatorAlert>,
88}
89
90impl WyckoffStateMachine {
91 pub fn new(range_lookback: usize, range_atr_max: f64, min_range_bars: usize) -> Self {
92 let range_lookback = range_lookback.max(3);
93 Self {
94 range_lookback,
95 range_atr_max,
96 min_range_bars: min_range_bars.max(2),
97 atr: Rma::new(14),
98 prev_close: None,
99 volume_threshold: RollingRobustThreshold::new(range_lookback, 2.0),
107 bars: VecDeque::with_capacity(range_lookback),
108 bars_in_range: 0,
109 range_high: f64::MIN,
110 range_low: f64::MAX,
111 range_locked: false,
112 bias: None,
113 phase: WyckoffPhase::Undefined,
114 events: Vec::new(),
115 alerts: Vec::new(),
116 }
117 }
118
119 pub fn with_defaults() -> Self {
120 Self::new(20, 3.0, 6)
121 }
122
123 pub fn phase(&self) -> WyckoffPhase {
124 self.phase
125 }
126
127 pub fn bias(&self) -> Option<WyckoffBias> {
128 self.bias
129 }
130
131 pub fn events(&self) -> &[WyckoffEvent] {
133 &self.events
134 }
135
136 pub fn score(&self) -> WyckoffScore {
137 let range_width = (self.range_high - self.range_low).max(0.0);
138 let cause_score = range_width * self.bars_in_range as f64;
139
140 let expected_order = [
141 WyckoffEventKind::SecondaryTest,
142 WyckoffEventKind::Spring,
143 WyckoffEventKind::Utad,
144 WyckoffEventKind::SignOfStrength,
145 WyckoffEventKind::SignOfWeakness,
146 WyckoffEventKind::LastPointOfSupport,
147 WyckoffEventKind::LastPointOfSupply,
148 ];
149 let rank = |k: WyckoffEventKind| expected_order.iter().position(|&e| e == k).unwrap_or(0);
150
151 let mut sequence_quality = 1.0f64;
152 for pair in self.events.windows(2) {
153 if rank(pair[1].kind) < rank(pair[0].kind) {
154 sequence_quality -= 0.2;
155 }
156 }
157 sequence_quality = sequence_quality.clamp(0.0, 1.0);
158
159 WyckoffScore {
160 cause_score,
161 sequence_quality,
162 }
163 }
164
165 fn lock_range(&mut self, bias: WyckoffBias) {
166 self.range_locked = true;
167 self.bias = Some(bias);
168 self.phase = WyckoffPhase::A;
169 self.events.clear();
170 self.bars_in_range = 0;
171 }
172
173 fn unlock_range(&mut self) {
174 self.range_locked = false;
175 self.bias = None;
176 self.phase = WyckoffPhase::Undefined;
177 self.range_high = f64::MIN;
178 self.range_low = f64::MAX;
179 }
180
181 fn push_event(
182 &mut self,
183 kind: WyckoffEventKind,
184 price: f64,
185 timestamp: i64,
186 strength: f64,
187 note: &str,
188 ) {
189 self.events.push(WyckoffEvent {
190 kind,
191 price,
192 timestamp,
193 });
194 self.alerts.push(IndicatorAlert::new(
195 format!("wyckoff_{kind:?}").to_lowercase(),
196 note,
197 strength,
198 ));
199 }
200
201 fn handle_phase_ab(&mut self, bar: &Bar, atr: f64, bias: WyckoffBias) {
202 self.phase = WyckoffPhase::B;
203 let near_high = bar.high >= self.range_high - atr * 0.25 && bar.high <= self.range_high;
204 let near_low = bar.low <= self.range_low + atr * 0.25 && bar.low >= self.range_low;
205
206 let spring = bar.low < self.range_low && bar.close > self.range_low;
207 let utad = bar.high > self.range_high && bar.close < self.range_high;
208
209 if bias == WyckoffBias::Accumulation && spring {
210 self.phase = WyckoffPhase::C;
211 self.push_event(
212 WyckoffEventKind::Spring,
213 bar.low,
214 bar.timestamp,
215 0.85,
216 "Wyckoff Spring: range low swept and reclaimed",
217 );
218 } else if bias == WyckoffBias::Distribution && utad {
219 self.phase = WyckoffPhase::C;
220 self.push_event(
221 WyckoffEventKind::Utad,
222 bar.high,
223 bar.timestamp,
224 0.85,
225 "Wyckoff UTAD: range high swept and reclaimed",
226 );
227 } else if near_high || near_low {
228 self.push_event(
229 WyckoffEventKind::SecondaryTest,
230 bar.close,
231 bar.timestamp,
232 0.4,
233 "Wyckoff Secondary Test of range boundary",
234 );
235 }
236 }
237
238 fn handle_phase_c(&mut self, bar: &Bar, bias: WyckoffBias) {
239 let sos = bias == WyckoffBias::Accumulation && bar.close > self.range_high;
240 let sow = bias == WyckoffBias::Distribution && bar.close < self.range_low;
241 if sos {
242 self.phase = WyckoffPhase::D;
243 self.push_event(
244 WyckoffEventKind::SignOfStrength,
245 bar.close,
246 bar.timestamp,
247 0.8,
248 "Wyckoff Sign of Strength: closed beyond range high",
249 );
250 } else if sow {
251 self.phase = WyckoffPhase::D;
252 self.push_event(
253 WyckoffEventKind::SignOfWeakness,
254 bar.close,
255 bar.timestamp,
256 0.8,
257 "Wyckoff Sign of Weakness: closed beyond range low",
258 );
259 }
260 }
261
262 fn handle_phase_d(&mut self, bar: &Bar, atr: f64, bias: WyckoffBias) {
263 let lps = bias == WyckoffBias::Accumulation
264 && bar.low >= self.range_high - atr * 0.5
265 && bar.close > self.range_high;
266 let lpsy = bias == WyckoffBias::Distribution
267 && bar.high <= self.range_low + atr * 0.5
268 && bar.close < self.range_low;
269 if lps {
270 self.phase = WyckoffPhase::E;
271 self.push_event(
272 WyckoffEventKind::LastPointOfSupport,
273 bar.close,
274 bar.timestamp,
275 0.9,
276 "Wyckoff Last Point of Support: pullback held, Markup confirmed",
277 );
278 } else if lpsy {
279 self.phase = WyckoffPhase::E;
280 self.push_event(
281 WyckoffEventKind::LastPointOfSupply,
282 bar.close,
283 bar.timestamp,
284 0.9,
285 "Wyckoff Last Point of Supply: pullback held, Markdown confirmed",
286 );
287 } else {
288 let failed = (bias == WyckoffBias::Accumulation && bar.close < self.range_high)
290 || (bias == WyckoffBias::Distribution && bar.close > self.range_low);
291 if failed {
292 self.phase = WyckoffPhase::B;
293 }
294 }
295 }
296}
297
298impl Indicator for WyckoffStateMachine {
299 fn name(&self) -> &str {
300 "wyckoff"
301 }
302
303 fn warmup_period(&self) -> usize {
304 self.range_lookback
305 }
306
307 fn reset(&mut self) {
308 self.atr.reset();
309 self.prev_close = None;
310 self.bars.clear();
311 self.bars_in_range = 0;
312 self.unlock_range();
313 self.events.clear();
314 self.alerts.clear();
315 }
316
317 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
318 self.alerts.clear();
319
320 let tr = match self.prev_close {
321 Some(pc) => (bar.high - bar.low)
322 .max((bar.high - pc).abs())
323 .max((bar.low - pc).abs()),
324 None => bar.high - bar.low,
325 };
326 self.prev_close = Some(bar.close);
327 let atr = self.atr.update(tr);
328 let volume_band = self.volume_threshold.update(bar.volume);
329
330 self.bars.push_back(bar.clone());
331 if self.bars.len() > self.range_lookback {
332 self.bars.pop_front();
333 }
334 if self.bars.len() < self.range_lookback {
335 return None;
336 }
337 let atr = atr.filter(|a| *a > 0.0)?;
338
339 let window_high = self.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
340 let window_low = self.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
341 let width_in_atr = (window_high - window_low) / atr;
342
343 if !self.range_locked {
344 if width_in_atr <= self.range_atr_max {
345 let is_climax = volume_band.map(|b| bar.volume > b.upper).unwrap_or(false);
349 let bias = if is_climax && bar.close < bar.open {
350 WyckoffBias::Accumulation
351 } else if is_climax && bar.close > bar.open {
352 WyckoffBias::Distribution
353 } else if self.prev_close.map(|pc| bar.close < pc).unwrap_or(false) {
354 WyckoffBias::Accumulation
355 } else {
356 WyckoffBias::Distribution
357 };
358 self.range_high = window_high;
359 self.range_low = window_low;
360 self.lock_range(bias);
361 }
362 return Some(IndicatorOutput::new(0.0));
363 }
364
365 self.bars_in_range += 1;
366 let bias = self.bias.expect("range_locked implies bias is set");
367
368 if width_in_atr > self.range_atr_max * 2.0
371 && (self.bars_in_range as usize) > self.min_range_bars * 3
372 {
373 self.unlock_range();
374 return Some(IndicatorOutput::new(0.0));
375 }
376
377 match self.phase {
378 WyckoffPhase::A | WyckoffPhase::B => self.handle_phase_ab(bar, atr, bias),
379 WyckoffPhase::C => self.handle_phase_c(bar, bias),
380 WyckoffPhase::D => self.handle_phase_d(bar, atr, bias),
381 WyckoffPhase::E | WyckoffPhase::Undefined => {}
382 }
383
384 let phase_code = match self.phase {
385 WyckoffPhase::Undefined => 0.0,
386 WyckoffPhase::A => 1.0,
387 WyckoffPhase::B => 2.0,
388 WyckoffPhase::C => 3.0,
389 WyckoffPhase::D => 4.0,
390 WyckoffPhase::E => 5.0,
391 };
392 Some(IndicatorOutput::new(phase_code))
393 }
394
395 fn alerts(&self) -> Vec<IndicatorAlert> {
396 self.alerts.clone()
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn range_bars(n: usize, center: f64, half_width: f64, seed_volume: f64) -> Vec<Bar> {
405 (0..n)
406 .map(|i| {
407 let offset = ((i % 4) as f64 - 1.5) * half_width * 0.3;
408 let price = center + offset;
409 Bar::new(
410 i as i64 * 60,
411 price,
412 price + half_width * 0.3,
413 price - half_width * 0.3,
414 price,
415 seed_volume,
416 )
417 })
418 .collect()
419 }
420
421 #[test]
422 fn test_locks_range_after_contraction() {
423 let mut machine = WyckoffStateMachine::new(10, 5.0, 4);
424 for bar in range_bars(30, 100.0, 2.0, 100.0) {
425 machine.on_bar(&bar);
426 }
427 assert_ne!(machine.phase(), WyckoffPhase::Undefined);
428 }
429
430 #[test]
431 fn test_spring_transitions_to_phase_c_in_accumulation_bias() {
432 let mut machine = WyckoffStateMachine::new(10, 5.0, 4);
433 for bar in range_bars(25, 100.0, 2.0, 100.0) {
434 machine.on_bar(&bar);
435 }
436 let bias = machine.bias();
439 assert!(bias.is_some(), "range must have locked by now");
440
441 if bias == Some(WyckoffBias::Accumulation) {
442 let spring_bar = Bar::new(2000, 98.5, 99.0, 96.0, 98.8, 100.0);
443 machine.on_bar(&spring_bar);
444 assert!(machine
445 .events()
446 .iter()
447 .any(|e| e.kind == WyckoffEventKind::Spring));
448 }
449 }
450
451 #[test]
452 fn test_full_sequence_scores_high_quality() {
453 let mut machine = WyckoffStateMachine::new(8, 5.0, 3);
454 for bar in range_bars(20, 100.0, 2.0, 100.0) {
455 machine.on_bar(&bar);
456 }
457 let bias = machine.bias().expect("range must have locked");
458
459 let (spring_bar, sos_bar, lps_bar) = if bias == WyckoffBias::Accumulation {
460 (
461 Bar::new(2000, 98.5, 99.0, 96.0, 98.8, 100.0),
462 Bar::new(2060, 99.0, 106.0, 98.5, 105.5, 100.0),
463 Bar::new(2120, 105.5, 106.0, 103.0, 105.0, 100.0),
464 )
465 } else {
466 (
467 Bar::new(2000, 101.5, 104.0, 101.0, 101.2, 100.0),
468 Bar::new(2060, 101.0, 101.5, 94.0, 94.5, 100.0),
469 Bar::new(2120, 94.5, 97.0, 94.0, 95.0, 100.0),
470 )
471 };
472
473 machine.on_bar(&spring_bar);
474 machine.on_bar(&sos_bar);
475 machine.on_bar(&lps_bar);
476
477 assert_eq!(machine.phase(), WyckoffPhase::E);
478 let score = machine.score();
479 assert!(
480 score.sequence_quality > 0.5,
481 "a clean A->C->D->E sequence must score reasonably high"
482 );
483 assert!(score.cause_score > 0.0);
484 }
485
486 #[test]
494 fn test_smoke_no_panic_across_random_walk() {
495 let mut machine = WyckoffStateMachine::with_defaults();
496 let mut price = 100.0;
497 for i in 0..200 {
498 price += ((i * 37) % 7) as f64 * 0.3 - 0.9;
499 let bar = Bar::new(
500 i as i64 * 60,
501 price,
502 price + 1.0,
503 price - 1.0,
504 price,
505 100.0 + (i % 5) as f64 * 20.0,
506 );
507 machine.on_bar(&bar); }
509 }
510}