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
202impl Indicator for WyckoffStateMachine {
203 fn name(&self) -> &str {
204 "wyckoff"
205 }
206
207 fn warmup_period(&self) -> usize {
208 self.range_lookback
209 }
210
211 fn reset(&mut self) {
212 self.atr.reset();
213 self.prev_close = None;
214 self.bars.clear();
215 self.bars_in_range = 0;
216 self.unlock_range();
217 self.events.clear();
218 self.alerts.clear();
219 }
220
221 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
222 self.alerts.clear();
223
224 let tr = match self.prev_close {
225 Some(pc) => (bar.high - bar.low)
226 .max((bar.high - pc).abs())
227 .max((bar.low - pc).abs()),
228 None => bar.high - bar.low,
229 };
230 self.prev_close = Some(bar.close);
231 let atr = self.atr.update(tr);
232 let volume_band = self.volume_threshold.update(bar.volume);
233
234 self.bars.push_back(bar.clone());
235 if self.bars.len() > self.range_lookback {
236 self.bars.pop_front();
237 }
238 if self.bars.len() < self.range_lookback {
239 return None;
240 }
241 let atr = atr.filter(|a| *a > 0.0)?;
242
243 let window_high = self.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
244 let window_low = self.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
245 let width_in_atr = (window_high - window_low) / atr;
246
247 if !self.range_locked {
248 if width_in_atr <= self.range_atr_max {
249 let is_climax = volume_band.map(|b| bar.volume > b.upper).unwrap_or(false);
253 let bias = if is_climax && bar.close < bar.open {
254 WyckoffBias::Accumulation
255 } else if is_climax && bar.close > bar.open {
256 WyckoffBias::Distribution
257 } else if self.prev_close.map(|pc| bar.close < pc).unwrap_or(false) {
258 WyckoffBias::Accumulation
259 } else {
260 WyckoffBias::Distribution
261 };
262 self.range_high = window_high;
263 self.range_low = window_low;
264 self.lock_range(bias);
265 }
266 return Some(IndicatorOutput::new(0.0));
267 }
268
269 self.bars_in_range += 1;
270 let bias = self.bias.expect("range_locked implies bias is set");
271
272 if width_in_atr > self.range_atr_max * 2.0
275 && self.bars_in_range > self.min_range_bars as u32 * 3
276 {
277 self.unlock_range();
278 return Some(IndicatorOutput::new(0.0));
279 }
280
281 match self.phase {
282 WyckoffPhase::A | WyckoffPhase::B => {
283 self.phase = WyckoffPhase::B;
284 let near_high =
285 bar.high >= self.range_high - atr * 0.25 && bar.high <= self.range_high;
286 let near_low = bar.low <= self.range_low + atr * 0.25 && bar.low >= self.range_low;
287
288 let spring = bar.low < self.range_low && bar.close > self.range_low;
289 let utad = bar.high > self.range_high && bar.close < self.range_high;
290
291 if bias == WyckoffBias::Accumulation && spring {
292 self.phase = WyckoffPhase::C;
293 self.push_event(
294 WyckoffEventKind::Spring,
295 bar.low,
296 bar.timestamp,
297 0.85,
298 "Wyckoff Spring: range low swept and reclaimed",
299 );
300 } else if bias == WyckoffBias::Distribution && utad {
301 self.phase = WyckoffPhase::C;
302 self.push_event(
303 WyckoffEventKind::Utad,
304 bar.high,
305 bar.timestamp,
306 0.85,
307 "Wyckoff UTAD: range high swept and reclaimed",
308 );
309 } else if near_high || near_low {
310 self.push_event(
311 WyckoffEventKind::SecondaryTest,
312 bar.close,
313 bar.timestamp,
314 0.4,
315 "Wyckoff Secondary Test of range boundary",
316 );
317 }
318 }
319 WyckoffPhase::C => {
320 let sos = bias == WyckoffBias::Accumulation && bar.close > self.range_high;
321 let sow = bias == WyckoffBias::Distribution && bar.close < self.range_low;
322 if sos {
323 self.phase = WyckoffPhase::D;
324 self.push_event(
325 WyckoffEventKind::SignOfStrength,
326 bar.close,
327 bar.timestamp,
328 0.8,
329 "Wyckoff Sign of Strength: closed beyond range high",
330 );
331 } else if sow {
332 self.phase = WyckoffPhase::D;
333 self.push_event(
334 WyckoffEventKind::SignOfWeakness,
335 bar.close,
336 bar.timestamp,
337 0.8,
338 "Wyckoff Sign of Weakness: closed beyond range low",
339 );
340 }
341 }
342 WyckoffPhase::D => {
343 let lps = bias == WyckoffBias::Accumulation
344 && bar.low >= self.range_high - atr * 0.5
345 && bar.close > self.range_high;
346 let lpsy = bias == WyckoffBias::Distribution
347 && bar.high <= self.range_low + atr * 0.5
348 && bar.close < self.range_low;
349 if lps {
350 self.phase = WyckoffPhase::E;
351 self.push_event(
352 WyckoffEventKind::LastPointOfSupport,
353 bar.close,
354 bar.timestamp,
355 0.9,
356 "Wyckoff Last Point of Support: pullback held, Markup confirmed",
357 );
358 } else if lpsy {
359 self.phase = WyckoffPhase::E;
360 self.push_event(
361 WyckoffEventKind::LastPointOfSupply,
362 bar.close,
363 bar.timestamp,
364 0.9,
365 "Wyckoff Last Point of Supply: pullback held, Markdown confirmed",
366 );
367 } else {
368 let failed = (bias == WyckoffBias::Accumulation && bar.close < self.range_high)
370 || (bias == WyckoffBias::Distribution && bar.close > self.range_low);
371 if failed {
372 self.phase = WyckoffPhase::B;
373 }
374 }
375 }
376 WyckoffPhase::E | WyckoffPhase::Undefined => {}
377 }
378
379 let phase_code = match self.phase {
380 WyckoffPhase::Undefined => 0.0,
381 WyckoffPhase::A => 1.0,
382 WyckoffPhase::B => 2.0,
383 WyckoffPhase::C => 3.0,
384 WyckoffPhase::D => 4.0,
385 WyckoffPhase::E => 5.0,
386 };
387 Some(IndicatorOutput::new(phase_code))
388 }
389
390 fn alerts(&self) -> Vec<IndicatorAlert> {
391 self.alerts.clone()
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn range_bars(n: usize, center: f64, half_width: f64, seed_volume: f64) -> Vec<Bar> {
400 (0..n)
401 .map(|i| {
402 let offset = ((i % 4) as f64 - 1.5) * half_width * 0.3;
403 let price = center + offset;
404 Bar::new(
405 i as i64 * 60,
406 price,
407 price + half_width * 0.3,
408 price - half_width * 0.3,
409 price,
410 seed_volume,
411 )
412 })
413 .collect()
414 }
415
416 #[test]
417 fn test_locks_range_after_contraction() {
418 let mut machine = WyckoffStateMachine::new(10, 5.0, 4);
419 for bar in range_bars(30, 100.0, 2.0, 100.0) {
420 machine.on_bar(&bar);
421 }
422 assert_ne!(machine.phase(), WyckoffPhase::Undefined);
423 }
424
425 #[test]
426 fn test_spring_transitions_to_phase_c_in_accumulation_bias() {
427 let mut machine = WyckoffStateMachine::new(10, 5.0, 4);
428 for bar in range_bars(25, 100.0, 2.0, 100.0) {
429 machine.on_bar(&bar);
430 }
431 let bias = machine.bias();
434 assert!(bias.is_some(), "range must have locked by now");
435
436 if bias == Some(WyckoffBias::Accumulation) {
437 let spring_bar = Bar::new(2000, 98.5, 99.0, 96.0, 98.8, 100.0);
438 machine.on_bar(&spring_bar);
439 assert!(machine
440 .events()
441 .iter()
442 .any(|e| e.kind == WyckoffEventKind::Spring));
443 }
444 }
445
446 #[test]
447 fn test_full_sequence_scores_high_quality() {
448 let mut machine = WyckoffStateMachine::new(8, 5.0, 3);
449 for bar in range_bars(20, 100.0, 2.0, 100.0) {
450 machine.on_bar(&bar);
451 }
452 let bias = machine.bias().expect("range must have locked");
453
454 let (spring_bar, sos_bar, lps_bar) = if bias == WyckoffBias::Accumulation {
455 (
456 Bar::new(2000, 98.5, 99.0, 96.0, 98.8, 100.0),
457 Bar::new(2060, 99.0, 106.0, 98.5, 105.5, 100.0),
458 Bar::new(2120, 105.5, 106.0, 103.0, 105.0, 100.0),
459 )
460 } else {
461 (
462 Bar::new(2000, 101.5, 104.0, 101.0, 101.2, 100.0),
463 Bar::new(2060, 101.0, 101.5, 94.0, 94.5, 100.0),
464 Bar::new(2120, 94.5, 97.0, 94.0, 95.0, 100.0),
465 )
466 };
467
468 machine.on_bar(&spring_bar);
469 machine.on_bar(&sos_bar);
470 machine.on_bar(&lps_bar);
471
472 assert_eq!(machine.phase(), WyckoffPhase::E);
473 let score = machine.score();
474 assert!(
475 score.sequence_quality > 0.5,
476 "a clean A->C->D->E sequence must score reasonably high"
477 );
478 assert!(score.cause_score > 0.0);
479 }
480
481 #[test]
482 fn test_smoke_no_panic_across_random_walk() {
483 let mut machine = WyckoffStateMachine::with_defaults();
484 let mut price = 100.0;
485 for i in 0..200 {
486 price += ((i * 37) % 7) as f64 * 0.3 - 0.9;
487 let bar = Bar::new(
488 i as i64 * 60,
489 price,
490 price + 1.0,
491 price - 1.0,
492 price,
493 100.0 + (i % 5) as f64 * 20.0,
494 );
495 machine.on_bar(&bar);
496 }
497 }
498}