Skip to main content

portfolio_analyzer/
sd.rs

1/// SahastaDirectionality FSM — Rust implementation of the paper/ThinkScript model.
2use crate::indicators::*;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub enum GroupA {
7    Plus,
8    Minus,
9    Tilde,
10}
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub enum GroupB {
14    SemiPlus,
15    SemiMinus,
16    SemiTilde,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SDState {
21    pub group_a: GroupA,
22    pub group_b: GroupB, // only meaningful when group_a == Tilde
23    pub group_c: u8,     // 0=none, 1=***, 2=**., 3=*.., 4=..., 5=**, 6=*., 7=.., 8=*, 9=.
24}
25
26impl SDState {
27    pub fn score(&self) -> f64 {
28        // Half-continuous encoding from the paper hypothesis. Group A is
29        // definitive; Group B is consulted only for an uncertain Group A.
30        let base = match self.group_a {
31            GroupA::Plus => 1.0,
32            GroupA::Minus => 0.0,
33            GroupA::Tilde => match self.group_b {
34                GroupB::SemiPlus => 0.75,
35                GroupB::SemiMinus => 0.25,
36                GroupB::SemiTilde => 0.50,
37            },
38        };
39        let (stars, dots) = match self.group_c {
40            1 => (3, 0), // ***
41            2 => (2, 1), // **.
42            3 => (1, 2), // *..
43            4 => (0, 3), // ...
44            5 => (2, 0), // **
45            6 => (1, 1), // *.
46            7 => (0, 2), // ..
47            8 => (1, 0), // *
48            9 => (0, 1), // .
49            _ => (0, 0),
50        };
51        let star_sign = if base > 0.5 {
52            1.0
53        } else if base < 0.5 {
54            -1.0
55        } else {
56            0.0
57        };
58        let dot_sign = match self.group_a {
59            GroupA::Plus => 1.0,
60            GroupA::Minus => -1.0,
61            GroupA::Tilde => 0.0,
62        };
63        (base + stars as f64 * 0.05 * star_sign + dots as f64 * 0.025 * dot_sign).clamp(0.0, 1.0)
64    }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SDSqueezeDiagnostics {
69    pub state: SDState,
70    pub score: f64,
71    pub compression: u8,
72    pub highest_compression: u8,
73    pub is_fired: bool,
74    pub fired_start: bool,
75    pub squeeze_start: bool,
76    pub is_active_squeeze: bool,
77    pub momentum_changed: bool,
78    pub time_expired: bool,
79    pub bars_since_squeeze_start: usize,
80    pub bars_since_fired: usize,
81    pub average_total_squeeze_ticks: f64,
82    pub average_mid_squeeze_ticks: f64,
83    pub average_low_squeeze_ticks: f64,
84    pub expected_squeeze_ticks: f64,
85    pub elapsed_squeeze_ticks: f64,
86    pub remaining_squeeze_ticks: f64,
87    /// Continuous, scale-free inputs that produced the finite SD state.  They
88    /// are exposed for causal diagnostic backtests; `score` remains the
89    /// separately documented state-machine collapse.
90    pub ao_over_close: f64,
91    pub ao_delta_over_close: f64,
92    pub deriv_price_over_close: f64,
93    pub deriv_ema8_over_close: f64,
94    pub deriv_ema21_over_close: f64,
95    pub ema8_minus_ema21_over_close: f64,
96    pub close_minus_ema21_over_close: f64,
97}
98
99pub struct SahastaDirectionality {
100    bars: Vec<OHLCV>,
101    length: usize,
102}
103
104fn running_fired_age(is_fired: &[bool]) -> Vec<usize> {
105    let mut ages = Vec::with_capacity(is_fired.len());
106    let mut age = 0usize;
107    for (i, fired) in is_fired.iter().copied().enumerate() {
108        if !fired {
109            age = 0;
110        } else if i == 0 || !is_fired[i - 1] {
111            age = 1;
112        } else {
113            age += 1;
114        }
115        ages.push(age);
116    }
117    ages
118}
119
120fn classify_group_b(group_a: &GroupA, semi_plus: bool, semi_minus: bool) -> GroupB {
121    if group_a != &GroupA::Tilde {
122        GroupB::SemiTilde
123    } else if semi_plus {
124        // The chart's lexical expression checks semiPlus first.
125        GroupB::SemiPlus
126    } else if semi_minus {
127        GroupB::SemiMinus
128    } else {
129        GroupB::SemiTilde
130    }
131}
132
133impl SahastaDirectionality {
134    pub fn new(bars: Vec<OHLCV>) -> Self {
135        Self { bars, length: 21 }
136    }
137
138    pub fn compute_state(&self) -> Option<SDState> {
139        self.compute_diagnostics()
140            .map(|diagnostics| diagnostics.state)
141    }
142
143    pub fn compute_diagnostics(&self) -> Option<SDSqueezeDiagnostics> {
144        let bars = &self.bars;
145        let n = bars.len();
146        if n < self.length * 2 + 10 {
147            return None;
148        }
149
150        let close: Vec<f64> = bars.iter().map(|b| b.close).collect();
151
152        // Bollinger Bands (length=21, 2 std)
153        let bb21 = bollinger_bands(&close, 21, 2.0);
154        // Keltner Channels at factors 1.0, 1.5, 2.0. The chart and scanner
155        // ThinkScripts pass their common length (21) to all core channels.
156        let kc_hi = keltner_channels(bars, self.length, 1.0);
157        let kc_mid = keltner_channels(bars, self.length, 1.5);
158        let kc_lo = keltner_channels(bars, self.length, 2.0);
159        // Compression
160        let comp = compute_compression(&bb21, &kc_hi, &kc_mid, &kc_lo);
161
162        // EMAs
163        let ema8 = ema(&close, 8);
164        let ema21 = ema(&close, 21);
165
166        // Derivatives
167        let d_close: Vec<f64> = std::iter::once(0.0)
168            .chain(close.windows(2).map(|w| w[1] - w[0]))
169            .collect();
170        let d_ema8: Vec<f64> = std::iter::once(0.0)
171            .chain(ema8.windows(2).map(|w| w[1] - w[0]))
172            .collect();
173        let d_ema21: Vec<f64> = std::iter::once(0.0)
174            .chain(ema21.windows(2).map(|w| w[1] - w[0]))
175            .collect();
176
177        let deriv_price = smf_derivative(&d_close, 21);
178        let deriv_ema8 = smf_derivative(&d_ema8, 21);
179        let deriv_ema21 = smf_derivative(&d_ema21, 21);
180
181        // Awesome Oscillator
182        let ao = awesome_oscillator(bars);
183
184        // AO direction at current bar
185        let ao_up = ao[n - 1] > ao[n - 2];
186        let ao_down = ao[n - 1] < ao[n - 2];
187
188        // ── Squeeze state machine ──────────────────────────────────────────────
189        let mut highest_comp = vec![0u8; n];
190        let mut is_fired = vec![false; n];
191        let mut sq_start = vec![false; n];
192
193        for i in 0..n {
194            let c = comp[i];
195            let c_prev = if i > 0 { comp[i - 1] } else { 0 };
196            let hc_prev = if i > 0 { highest_comp[i - 1] } else { 0 };
197
198            let sq_start_base = c > 0 && c_prev == 0;
199            let unfired_sq = c > c_prev && c_prev < hc_prev && hc_prev > 0;
200            sq_start[i] = sq_start_base || unfired_sq;
201
202            highest_comp[i] = if sq_start[i] || c > hc_prev {
203                c
204            } else {
205                hc_prev
206            };
207            is_fired[i] = c < highest_comp[i] && highest_comp[i] > 0;
208        }
209
210        // firedStart: transition False→True
211        let mut fired_start = vec![false; n];
212        for i in 1..n {
213            fired_start[i] = is_fired[i] && !is_fired[i - 1];
214        }
215
216        // barsSinceFired
217        let bars_since_fired = running_fired_age(&is_fired);
218
219        // barsSinceSqueezeStart tracks the current compression lifecycle even
220        // before it has fired. This is the execution clock used by SD Timer.
221        let mut bars_since_squeeze_start = vec![0usize; n];
222        let mut sq_count = 0usize;
223        for i in 0..n {
224            if sq_start[i] {
225                sq_count = 1;
226            } else if comp[i] > 0 || is_fired[i] {
227                sq_count = sq_count.saturating_add(1);
228            } else {
229                sq_count = 0;
230            }
231            bars_since_squeeze_start[i] = sq_count;
232        }
233
234        // momChanged
235        let mut mom_changed = vec![false; n];
236        for i in 1..n {
237            if fired_start[i] {
238                mom_changed[i] = false;
239            } else if is_fired[i] && {
240                let a_up = ao[i] > ao[i - 1];
241                let a_down = ao[i] < ao[i - 1];
242                let p_up = i >= 2 && ao[i - 1] > ao[i - 2];
243                let p_down = i >= 2 && ao[i - 1] < ao[i - 2];
244                (a_up && !p_up) || (a_down && !p_down)
245            } {
246                mom_changed[i] = true;
247            } else {
248                mom_changed[i] = mom_changed[i - 1];
249            }
250        }
251
252        // Dynamic squeeze duration estimates over the same 125-bar window as
253        // the ThinkScript/Python implementation. These are per-timeframe values:
254        // the caller converts ticks to minutes using that timeframe's bar size.
255        let mut n_total = vec![0.0f64; n];
256        let mut n_mid = vec![0.0f64; n];
257        let mut n_low = vec![0.0f64; n];
258        let theta: Vec<f64> = (0..n)
259            .map(|i| {
260                let start = i.saturating_sub(124);
261                let w = &comp[start..=i];
262                let bars_in_compression = w.iter().filter(|&&c| c > 0).count() as f64;
263                let squeeze_starts = sq_start[start..=i].iter().filter(|&&s| s).count() as f64;
264                let total = bars_in_compression / squeeze_starts.max(1.0);
265
266                let bars_at_mid = w.iter().filter(|&&c| c == 2).count() as f64;
267                let mut entering_mid = 0.0f64;
268                for j in start..=i {
269                    if comp[j] == 2 && (j == 0 || comp[j - 1] != 2) {
270                        entering_mid += 1.0;
271                    }
272                }
273                let mid = bars_at_mid / entering_mid.max(1.0);
274
275                let bars_at_low = w.iter().filter(|&&c| c == 1).count() as f64;
276                let mut entering_low = 0.0f64;
277                for j in start..=i {
278                    if comp[j] == 1 && (j == 0 || comp[j - 1] != 1) {
279                        entering_low += 1.0;
280                    }
281                }
282                let low = bars_at_low / entering_low.max(1.0);
283
284                n_total[i] = total;
285                n_mid[i] = mid;
286                n_low[i] = low;
287
288                match comp[i] {
289                    2 => {
290                        if mid >= total / 3.0 {
291                            mid
292                        } else {
293                            low
294                        }
295                    }
296                    1 => {
297                        if low >= total / 3.0 {
298                            low
299                        } else {
300                            total
301                        }
302                    }
303                    _ => total,
304                }
305            })
306            .collect();
307
308        // isActiveSqueeze
309        let is_active: Vec<bool> = (0..n)
310            .map(|i| {
311                is_fired[i] && bars_since_fired[i] <= theta[i].ceil() as usize && !mom_changed[i]
312            })
313            .collect();
314
315        // ── Group A ────────────────────────────────────────────────────────────
316        let i = n - 1;
317        let dir_plus = is_active[i] && ao_up && deriv_ema8[i] >= 0.0;
318        let dir_minus = is_active[i] && ao_down && deriv_ema8[i] <= 0.0;
319
320        let group_a = if dir_plus {
321            GroupA::Plus
322        } else if dir_minus {
323            GroupA::Minus
324        } else {
325            GroupA::Tilde
326        };
327
328        // ── Group B (semi-directionality, only meaningful when GroupA==Tilde) ─
329        let bb20 = bollinger_bands(&close, 20, 2.0);
330        let kc20 = keltner_channels(bars, 20, 2.0);
331
332        let diff_ema21 = if ema21[i] != 0.0 {
333            (close[i] - ema21[i]).abs() / ema21[i]
334        } else {
335            0.0
336        };
337        let cond1 = diff_ema21 >= 0.005 && deriv_ema21[i] <= deriv_price[i];
338        let cond1_plus = cond1 && close[i] < ema21[i];
339        let cond1_minus = cond1 && close[i] > ema21[i];
340
341        let cond2_plus = !kc_lo.upper[i].is_nan()
342            && !bb21.upper[i].is_nan()
343            && (close[i] > kc_lo.upper[i] || close[i] > bb21.upper[i]);
344        let cond2_minus = !kc20.lower[i].is_nan()
345            && !bb20.lower[i].is_nan()
346            && (close[i] < kc20.lower[i] || close[i] < bb20.lower[i]);
347
348        let group_b = classify_group_b(
349            &group_a,
350            cond1_plus || cond2_plus,
351            cond1_minus || cond2_minus,
352        );
353
354        // ── Group C (squeeze lexicography) ────────────────────────────────────
355        let eff_highest = if is_active[i] {
356            highest_comp[i]
357        } else {
358            comp[i]
359        };
360        let c = comp[i];
361        let group_c: u8 = match (eff_highest, c) {
362            (3, 3) => 1,
363            (3, 2) => 2,
364            (3, 1) => 3,
365            (3, 0) => 4,
366            (2, 2) => 5,
367            (2, 1) => 6,
368            (2, 0) => 7,
369            (1, 1) => 8,
370            (1, 0) => 9,
371            _ => 0,
372        };
373
374        Some(SDState {
375            group_a,
376            group_b,
377            group_c,
378        })
379        .map(|state| {
380            let expected = theta[i].max(0.0);
381            let elapsed = if is_fired[i] {
382                bars_since_fired[i] as f64
383            } else {
384                bars_since_squeeze_start[i] as f64
385            };
386            let remaining = if comp[i] > 0 || is_fired[i] {
387                (expected - elapsed).max(0.0)
388            } else {
389                0.0
390            };
391            let score = state.score();
392            let scale = close[i].abs().max(f64::EPSILON);
393            SDSqueezeDiagnostics {
394                state,
395                score,
396                compression: comp[i],
397                highest_compression: highest_comp[i],
398                is_fired: is_fired[i],
399                fired_start: fired_start[i],
400                squeeze_start: sq_start[i],
401                is_active_squeeze: is_active[i],
402                momentum_changed: mom_changed[i],
403                time_expired: is_fired[i] && bars_since_fired[i] > theta[i].ceil() as usize,
404                bars_since_squeeze_start: bars_since_squeeze_start[i],
405                bars_since_fired: bars_since_fired[i],
406                average_total_squeeze_ticks: n_total[i],
407                average_mid_squeeze_ticks: n_mid[i],
408                average_low_squeeze_ticks: n_low[i],
409                expected_squeeze_ticks: expected,
410                elapsed_squeeze_ticks: elapsed,
411                remaining_squeeze_ticks: remaining,
412                ao_over_close: ao[i] / scale,
413                ao_delta_over_close: (ao[i] - ao[i - 1]) / scale,
414                deriv_price_over_close: deriv_price[i] / scale,
415                deriv_ema8_over_close: deriv_ema8[i] / scale,
416                deriv_ema21_over_close: deriv_ema21[i] / scale,
417                ema8_minus_ema21_over_close: (ema8[i] - ema21[i]) / scale,
418                close_minus_ema21_over_close: (close[i] - ema21[i]) / scale,
419            }
420        })
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn state(group_a: GroupA, group_b: GroupB, group_c: u8) -> SDState {
429        SDState {
430            group_a,
431            group_b,
432            group_c,
433        }
434    }
435
436    #[test]
437    fn paper_encoding_keeps_definitive_states_at_the_bounds() {
438        for group_c in 0..=9 {
439            assert_eq!(state(GroupA::Plus, GroupB::SemiTilde, group_c).score(), 1.0);
440            assert_eq!(
441                state(GroupA::Minus, GroupB::SemiTilde, group_c).score(),
442                0.0
443            );
444        }
445    }
446
447    #[test]
448    fn paper_encoding_amplifies_only_stars_for_semi_directionality() {
449        let stars = [0, 3, 2, 1, 0, 2, 1, 0, 1, 0];
450        for (group_c, stars) in stars.into_iter().enumerate() {
451            let push = stars as f64 * 0.05;
452            assert_eq!(
453                state(GroupA::Tilde, GroupB::SemiPlus, group_c as u8).score(),
454                0.75 + push
455            );
456            assert_eq!(
457                state(GroupA::Tilde, GroupB::SemiMinus, group_c as u8).score(),
458                0.25 - push
459            );
460            assert_eq!(
461                state(GroupA::Tilde, GroupB::SemiTilde, group_c as u8).score(),
462                0.5
463            );
464        }
465    }
466
467    #[test]
468    fn fired_clock_resets_exactly_when_thinkscript_is_fired_is_false() {
469        assert_eq!(
470            running_fired_age(&[false, true, true, false, false, true, true]),
471            vec![0, 1, 2, 0, 0, 1, 2]
472        );
473    }
474
475    #[test]
476    fn group_b_is_gated_by_group_a_and_uses_thinkscript_precedence() {
477        assert_eq!(
478            classify_group_b(&GroupA::Plus, true, true),
479            GroupB::SemiTilde
480        );
481        assert_eq!(
482            classify_group_b(&GroupA::Tilde, true, true),
483            GroupB::SemiPlus
484        );
485        assert_eq!(
486            classify_group_b(&GroupA::Tilde, false, true),
487            GroupB::SemiMinus
488        );
489    }
490}