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