Skip to main content

kestrel_chartkit/indicator/
wyckoff.rs

1//! Wyckoff accumulation/distribution state machine: range-lock detection, Phases A-E, Spring/UTAD,
2//! SOS/SOW/LPS/LPSY events, sequence validation, and Cause/Quality scoring.
3//!
4//! This is a codified heuristic interpretation of the textbook Wyckoff method (range-lock via ATR-
5//! relative contraction, climax via robust volume outlier, Spring/UTAD via pierce-and-reclaim —
6//! structurally the same pattern as [`super::smart_money_structure::LiquidityPoolEngine`]'s stop-
7//! hunt classification), not a claim of canonical/definitive Wyckoff analysis: real chart reading
8//! involves judgment this state machine approximates with fixed, documented, testable rules.
9
10use std::collections::VecDeque;
11
12use crate::clustering::RollingRobustThreshold;
13use crate::model::Bar;
14
15use super::smoothing::Rma;
16use super::{Indicator, IndicatorAlert, IndicatorOutput};
17
18/// Which side of the cycle this range is developing into: accumulation (basing before markup) or
19/// distribution (topping before markdown).
20#[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    /// No range locked yet.
29    Undefined,
30    /// Range just locked (Preliminary Support/Supply + Climax context).
31    A,
32    /// Range building: repeated tests of the boundaries (Secondary Tests).
33    B,
34    /// A Spring (accumulation) or UTAD (distribution) has occurred: the decisive test.
35    C,
36    /// A Sign of Strength/Weakness breakout past the *opposite* boundary has occurred.
37    D,
38    /// A Last Point of Support/Supply held: trend confirmed (Markup/Markdown).
39    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/// Heuristic scoring of the developing range.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct WyckoffScore {
63    /// Proportional to range width x bars spent in range (the point-and-figure "cause" building
64    /// over time) — larger implies a larger implied subsequent move once the range resolves.
65    pub cause_score: f64,
66    /// `1.0` if the full A -> B -> C -> D -> E sequence was observed in order with no
67    /// contradicting event (e.g. a Spring followed by a UTAD before any SOS); penalized for gaps
68    /// or out-of-order events.
69    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            // Must stay derived from the same `range_lookback` as the bar deque below: the deque
100            // gates when a range becomes lock-eligible (`bars.len() == self.range_lookback`), and
101            // the climax-driven bias assignment only fires if the volume-outlier window is warm
102            // by that same bar. A separately floored window here (e.g. a hardcoded minimum higher
103            // than `range_lookback`) would make the range lock-eligible before climax detection is
104            // armed, silently forcing every small-`range_lookback` configuration onto the
105            // non-climax fallback bias path regardless of actual volume behavior.
106            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    /// Full event history observed for the current (or most recently completed) range.
132    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            // Breakout failed to hold: back inside the range invalidates Phase D.
289            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                // A climax (outlier volume) just inside/before this contraction picks the bias:
346                // a high-volume down move contracting into a base implies accumulation, a
347                // high-volume up move implies distribution.
348                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        // Range invalidated: price wandered far beyond both original boundaries without a clean
369        // Phase D/E resolution.
370        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        // Force accumulation bias deterministically for the test by feeding a down climax first
437        // is fragile; instead assert on whichever bias formed and drive the matching event.
438        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    /// Deliberately a robustness/no-panic check only (finding 07): this pseudo-random walk is not
487    /// a controlled schematic, so it makes no claim about which phase or event should result --
488    /// the previous `bars_in_range > 0 || phase() != Undefined` assertion was weak busywork that
489    /// didn't verify anything specific and gave a false impression of coverage. Concrete
490    /// phase/event-sequence assertions belong to the deterministic schematic scenario tests
491    /// (`test_full_sequence_scores_high_quality` above, and `test_scenario_wyckoff*` in
492    /// `tests/scenario_reference_structure.rs`), not here.
493    #[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); // must not panic
508        }
509    }
510}