1use 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, pub group_c: u8, }
25
26impl SDState {
27 pub fn score(&self) -> f64 {
28 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), 2 => (2, 1), 3 => (1, 2), 4 => (0, 3), 5 => (2, 0), 6 => (1, 1), 7 => (0, 2), 8 => (1, 0), 9 => (0, 1), _ => (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 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 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 let bb21 = bollinger_bands(&close, 21, 2.0);
154 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 let comp = compute_compression(&bb21, &kc_hi, &kc_mid, &kc_lo);
161
162 let ema8 = ema(&close, 8);
164 let ema21 = ema(&close, 21);
165
166 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 let ao = awesome_oscillator(bars);
183
184 let ao_up = ao[n - 1] > ao[n - 2];
186 let ao_down = ao[n - 1] < ao[n - 2];
187
188 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 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 let bars_since_fired = running_fired_age(&is_fired);
218
219 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 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 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 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 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 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 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}