Skip to main content

portfolio_analyzer/sd2/
classic.rs

1//! # Classic SD (v1), every bar at once
2//!
3//! `sd.rs` answers "what is SD *right now*?" by recomputing everything for
4//! the last bar.  A backtest asks that question thousands of times per
5//! stock, so this module runs the same logic once over the whole history and
6//! returns one state per bar.  It is the **baseline** every v2 idea has to
7//! beat, so it deliberately copies v1, warts and all (single 21-bar
8//! compression, AO-only momentum, a one-bar AO colour change kills the move).
9//!
10//! `tests::matches_sd_rs_on_the_last_bar` pins it to `sd.rs`.
11
12use super::primitives::*;
13use super::squeeze::{group_c, run_clock, ClockSeries};
14use crate::indicators::{
15    awesome_oscillator, bollinger_bands, compute_compression, keltner_channels, smf_derivative,
16};
17use crate::sd::{GroupA, GroupB, SDState};
18
19/// Per-bar classic SD.
20pub struct ClassicSeries {
21    pub comp: Vec<u8>,
22    pub clock: ClockSeries,
23    pub ao: Vec<f64>,
24    pub states: Vec<SDState>,
25    pub score: Vec<f64>,
26}
27
28pub fn classic_series(bars: &[OHLCV]) -> ClassicSeries {
29    let n = bars.len();
30    let close: Vec<f64> = bars.iter().map(|b| b.close).collect();
31    let bb21 = bollinger_bands(&close, 21, 2.0);
32    let kc_hi = keltner_channels(bars, 21, 1.0);
33    let kc_mid = keltner_channels(bars, 21, 1.5);
34    let kc_lo = keltner_channels(bars, 21, 2.0);
35    let comp = compute_compression(&bb21, &kc_hi, &kc_mid, &kc_lo);
36    let ao = awesome_oscillator(bars);
37    let up = |i: usize| i >= 1 && ao[i] > ao[i - 1];
38    let down = |i: usize| i >= 1 && ao[i] < ao[i - 1];
39    let flip: Vec<bool> = (0..n)
40        .map(|i| i >= 1 && ((up(i) && !up(i - 1)) || (down(i) && !down(i - 1))))
41        .collect();
42    let clock = run_clock(&comp, &flip);
43
44    let ema8 = ema(&close, 8);
45    let ema21 = ema(&close, 21);
46    let d = |x: &[f64]| -> Vec<f64> {
47        std::iter::once(0.0).chain(x.windows(2).map(|w| w[1] - w[0])).collect()
48    };
49    let deriv_price = smf_derivative(&d(&close), 21);
50    let deriv_ema8 = smf_derivative(&d(&ema8), 21);
51    let deriv_ema21 = smf_derivative(&d(&ema21), 21);
52    let bb20 = bollinger_bands(&close, 20, 2.0);
53    let kc20 = keltner_channels(bars, 20, 2.0);
54
55    let mut states = Vec::with_capacity(n);
56    for i in 0..n {
57        let active = clock.is_active[i];
58        let group_a = if active && up(i) && deriv_ema8[i] >= 0.0 {
59            GroupA::Plus
60        } else if active && down(i) && deriv_ema8[i] <= 0.0 {
61            GroupA::Minus
62        } else {
63            GroupA::Tilde
64        };
65        let dist = if ema21[i] != 0.0 { (close[i] - ema21[i]).abs() / ema21[i] } else { 0.0 };
66        let cond1 = dist >= 0.005 && deriv_ema21[i] <= deriv_price[i];
67        let plus = (cond1 && close[i] < ema21[i])
68            || (!kc_lo.upper[i].is_nan()
69                && !bb21.upper[i].is_nan()
70                && (close[i] > kc_lo.upper[i] || close[i] > bb21.upper[i]));
71        let minus = (cond1 && close[i] > ema21[i])
72            || (!kc20.lower[i].is_nan()
73                && !bb20.lower[i].is_nan()
74                && (close[i] < kc20.lower[i] || close[i] < bb20.lower[i]));
75        let group_b = if group_a != GroupA::Tilde {
76            GroupB::SemiTilde
77        } else if plus {
78            GroupB::SemiPlus
79        } else if minus {
80            GroupB::SemiMinus
81        } else {
82            GroupB::SemiTilde
83        };
84        let eff = if active { clock.highest_comp[i] } else { comp[i] };
85        states.push(SDState { group_a, group_b, group_c: group_c(eff, comp[i]) });
86    }
87    let score = states.iter().map(SDState::score).collect();
88    ClassicSeries { comp, clock, ao, states, score }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::sd::SahastaDirectionality;
95
96    fn synthetic(n: usize) -> Vec<OHLCV> {
97        (0..n)
98            .map(|i| {
99                let x = i as f64;
100                let c = 100.0 + 8.0 * (x / 17.0).sin() * (x / 90.0).cos() + 0.05 * x;
101                OHLCV { open: c - 0.3, high: c + 0.9 + 0.4 * (x / 5.0).sin().abs(), low: c - 1.0, close: c, volume: 1.0 }
102            })
103            .collect()
104    }
105
106    #[test]
107    fn matches_sd_rs_on_the_last_bar() {
108        let bars = synthetic(400);
109        let series = classic_series(&bars);
110        for end in [120, 200, 287, 333, 400] {
111            let reference = SahastaDirectionality::new(bars[..end].to_vec()).compute_state().unwrap();
112            let ours = &series.states[end - 1];
113            assert_eq!(reference.group_a, ours.group_a, "group A at {end}");
114            assert_eq!(reference.group_b, ours.group_b, "group B at {end}");
115            assert_eq!(reference.group_c, ours.group_c, "group C at {end}");
116        }
117    }
118}