1use serde::{Deserialize, Serialize};
34
35#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum Side {
39 #[default]
40 None,
41 Long,
42 Short,
43}
44
45impl Side {
46 pub fn opposite(self) -> Side {
48 match self {
49 Side::Long => Side::Short,
50 Side::Short => Side::Long,
51 Side::None => Side::None,
52 }
53 }
54}
55
56#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum TrendRegime {
68 Bull,
70 Bear,
72 Idle,
74}
75
76#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
78pub struct RsiBands {
79 pub rsi_high: f64,
81 pub rsi_low: f64,
83 pub mod_high: f64,
85 pub mod_low: f64,
88}
89
90#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
92pub struct RegimeRsiParams {
93 pub bull: RsiBands,
94 pub bear: RsiBands,
95 pub idle: RsiBands,
96 pub adx_high: f64,
98 pub adx_low: f64,
100}
101
102impl Default for RegimeRsiParams {
103 fn default() -> Self {
109 Self {
110 bull: RsiBands {
111 rsi_high: 75.0,
112 rsi_low: 35.0,
113 mod_high: 5.0,
114 mod_low: -5.0,
115 },
116 bear: RsiBands {
117 rsi_high: 60.0,
118 rsi_low: 25.0,
119 mod_high: 5.0,
120 mod_low: -5.0,
121 },
122 idle: RsiBands {
123 rsi_high: 70.0,
124 rsi_low: 30.0,
125 mod_high: 3.0,
126 mod_low: -3.0,
127 },
128 adx_high: 30.0,
129 adx_low: 15.0,
130 }
131 }
132}
133
134#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
136pub struct RegimeRsiSignal {
137 pub side: Side,
138 pub regime: TrendRegime,
139 pub effective_high: f64,
141 pub effective_low: f64,
143}
144
145pub fn regime_rsi(
162 regime: TrendRegime,
163 rsi: f64,
164 adx: f64,
165 params: &RegimeRsiParams,
166) -> RegimeRsiSignal {
167 let bands = match regime {
168 TrendRegime::Bull => params.bull,
169 TrendRegime::Bear => params.bear,
170 TrendRegime::Idle => params.idle,
171 };
172 let mut effective_high = bands.rsi_high;
173 let mut effective_low = bands.rsi_low;
174 if adx > params.adx_high {
175 effective_high += bands.mod_high;
176 } else if adx < params.adx_low {
177 effective_low += bands.mod_low;
178 }
179
180 let side = if rsi > effective_high {
181 Side::Short
182 } else if rsi < effective_low {
183 Side::Long
184 } else {
185 Side::None
186 };
187 RegimeRsiSignal {
188 side,
189 regime,
190 effective_high,
191 effective_low,
192 }
193}
194
195#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
201#[serde(rename_all = "snake_case")]
202pub enum Zone {
203 High,
204 Low,
205 #[default]
206 Neutral,
207}
208
209#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
211pub struct BounceState {
212 pub zone: Zone,
213 pub duration: u32,
215 pub bounces: u32,
217 pub armed: bool,
219 pub flats: u32,
221 pub fired: bool,
223}
224
225#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
227pub struct BounceParams {
228 pub high: f64,
230 pub low: f64,
232 pub persistence: u32,
234 pub required_bounces: u32,
236 pub reset_after_flats: u32,
238}
239
240impl Default for BounceParams {
241 fn default() -> Self {
244 Self {
245 high: 70.0,
246 low: 30.0,
247 persistence: 3,
248 required_bounces: 2,
249 reset_after_flats: 50,
250 }
251 }
252}
253
254pub fn bounce_counter(prev: BounceState, oscillator: f64, p: &BounceParams) -> (Side, BounceState) {
271 let zone = if oscillator > p.high {
272 Zone::High
273 } else if oscillator < p.low {
274 Zone::Low
275 } else {
276 Zone::Neutral
277 };
278
279 let mut next = prev;
280 if zone != prev.zone {
281 if zone == Zone::Neutral {
282 next.armed = true;
283 } else if prev.armed {
284 next.bounces = prev.bounces + 1;
285 next.armed = false;
286 }
287 next.zone = zone;
288 next.duration = 1;
289 next.fired = false;
290 next.flats = if zone == Zone::Neutral {
291 prev.flats + 1
292 } else {
293 0
294 };
295 } else {
296 next.duration = prev.duration + 1;
297 if zone == Zone::Neutral {
298 next.flats = prev.flats + 1;
299 if next.flats >= p.reset_after_flats {
300 next.bounces = 0;
301 next.flats = 0;
302 next.armed = false;
303 }
304 } else {
305 next.flats = 0;
306 }
307 }
308
309 let mut side = Side::None;
310 if !next.fired
311 && next.zone != Zone::Neutral
312 && next.duration >= p.persistence
313 && next.bounces >= p.required_bounces
314 {
315 side = if next.zone == Zone::High {
316 Side::Short
317 } else {
318 Side::Long
319 };
320 next.fired = true;
321 }
322 (side, next)
323}
324
325#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
331#[serde(rename_all = "snake_case")]
332pub enum GateStatus {
333 ExecutedImmediately,
335 ExecutedAfterConfirmation,
337 Passthrough,
339 Pending,
341 Cancelled,
343}
344
345#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
347pub struct GateState {
348 pub pending: Side,
349 pub pending_price: f64,
351 pub waited: u32,
353}
354
355#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
357pub struct GateDecision {
358 pub execute: Side,
359 pub status: GateStatus,
360 pub benefit: Option<f64>,
364}
365
366pub fn signal_gate(
389 prev: GateState,
390 raw: Side,
391 fast_ma: f64,
392 slow_ma: f64,
393 price: f64,
394 max_wait: u32,
395) -> (GateDecision, GateState) {
396 let confirms = |side: Side| match side {
397 Side::Long => fast_ma >= slow_ma,
398 Side::Short => fast_ma <= slow_ma,
399 Side::None => false,
400 };
401 let cancelled = || {
402 (
403 GateDecision {
404 execute: Side::None,
405 status: GateStatus::Cancelled,
406 benefit: None,
407 },
408 GateState::default(),
409 )
410 };
411
412 if prev.pending != Side::None {
413 let waited = prev.waited.saturating_add(1);
414 if (raw != Side::None && raw != prev.pending) || waited >= max_wait {
415 return cancelled();
416 }
417 if confirms(prev.pending) {
418 let benefit = if prev.pending_price == 0.0 {
419 0.0
420 } else if prev.pending == Side::Long {
421 (prev.pending_price - price) / prev.pending_price
422 } else {
423 (price - prev.pending_price) / prev.pending_price
424 };
425 return (
426 GateDecision {
427 execute: prev.pending,
428 status: GateStatus::ExecutedAfterConfirmation,
429 benefit: Some(benefit),
430 },
431 GateState::default(),
432 );
433 }
434 return (
435 GateDecision {
436 execute: Side::None,
437 status: GateStatus::Pending,
438 benefit: None,
439 },
440 GateState { waited, ..prev },
441 );
442 }
443
444 if raw == Side::None {
445 return (
446 GateDecision {
447 execute: Side::None,
448 status: GateStatus::Passthrough,
449 benefit: None,
450 },
451 prev,
452 );
453 }
454
455 if confirms(raw) {
456 return (
457 GateDecision {
458 execute: raw,
459 status: GateStatus::ExecutedImmediately,
460 benefit: None,
461 },
462 prev,
463 );
464 }
465
466 if max_wait <= 1 {
467 return cancelled();
468 }
469 (
470 GateDecision {
471 execute: Side::None,
472 status: GateStatus::Pending,
473 benefit: None,
474 },
475 GateState {
476 pending: raw,
477 pending_price: price,
478 waited: 1,
479 },
480 )
481}
482
483#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
489pub struct ExposureState {
490 pub side: Side,
492 pub entry_price: f64,
493 pub bars_held: u32,
494}
495
496#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
498pub struct ExposureDecision {
499 pub action: Side,
501 pub forced_exit: bool,
504}
505
506pub fn max_exposure_timeout(
522 prev: ExposureState,
523 signal: Side,
524 price: f64,
525 max_bars: u32,
526) -> (ExposureDecision, ExposureState) {
527 if prev.side == Side::None {
528 if signal == Side::None {
529 return (
530 ExposureDecision {
531 action: Side::None,
532 forced_exit: false,
533 },
534 prev,
535 );
536 }
537 return (
538 ExposureDecision {
539 action: signal,
540 forced_exit: false,
541 },
542 ExposureState {
543 side: signal,
544 entry_price: price,
545 bars_held: 0,
546 },
547 );
548 }
549
550 let bars_held = prev.bars_held + 1;
551 if bars_held >= max_bars {
552 return (
553 ExposureDecision {
554 action: prev.side.opposite(),
555 forced_exit: true,
556 },
557 ExposureState::default(),
558 );
559 }
560 if signal != Side::None && signal != prev.side {
561 return (
562 ExposureDecision {
563 action: signal,
564 forced_exit: false,
565 },
566 ExposureState::default(),
567 );
568 }
569 (
570 ExposureDecision {
571 action: Side::None,
572 forced_exit: false,
573 },
574 ExposureState { bars_held, ..prev },
575 )
576}
577
578#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
584pub struct AtrBreakoutParams {
585 pub entry_multiplier: f64,
587 pub exit_multiplier: f64,
591 pub expansion_ratio: f64,
594 pub min_atr_long: f64,
596}
597
598impl Default for AtrBreakoutParams {
599 fn default() -> Self {
603 Self {
604 entry_multiplier: 2.5,
605 exit_multiplier: 1.0,
606 expansion_ratio: 1.0,
607 min_atr_long: 0.0,
608 }
609 }
610}
611
612#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
614#[serde(rename_all = "snake_case")]
615pub enum AtrBreakoutVerdict {
616 Armed,
618 FilteredNoExpansion,
620 FilteredLowVol,
622 InvalidInput,
624}
625
626#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
628pub struct AtrBreakoutLevels {
629 pub long_entry_stop: f64,
630 pub short_entry_stop: f64,
631 pub exit_stop_distance: f64,
633 pub long_stop_loss: f64,
634 pub short_stop_loss: f64,
635}
636
637#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
639pub struct AtrBreakoutSignal {
640 pub verdict: AtrBreakoutVerdict,
641 pub expansion_ratio: f64,
643 pub levels: Option<AtrBreakoutLevels>,
645}
646
647pub fn atr_breakout(
666 bar_open: f64,
667 atr_short: f64,
668 atr_long: f64,
669 p: &AtrBreakoutParams,
670) -> AtrBreakoutSignal {
671 if !bar_open.is_finite()
672 || !atr_short.is_finite()
673 || !atr_long.is_finite()
674 || atr_long <= 0.0
675 || atr_short < 0.0
676 {
677 return AtrBreakoutSignal {
678 verdict: AtrBreakoutVerdict::InvalidInput,
679 expansion_ratio: 0.0,
680 levels: None,
681 };
682 }
683 let ratio = atr_short / atr_long;
684 if atr_long < p.min_atr_long {
685 return AtrBreakoutSignal {
686 verdict: AtrBreakoutVerdict::FilteredLowVol,
687 expansion_ratio: ratio,
688 levels: None,
689 };
690 }
691 if ratio <= p.expansion_ratio {
692 return AtrBreakoutSignal {
693 verdict: AtrBreakoutVerdict::FilteredNoExpansion,
694 expansion_ratio: ratio,
695 levels: None,
696 };
697 }
698
699 let entry_distance = atr_long * p.entry_multiplier;
700 let exit_distance = atr_long * p.exit_multiplier;
701 let long_entry_stop = bar_open + entry_distance;
702 let short_entry_stop = bar_open - entry_distance;
703 AtrBreakoutSignal {
704 verdict: AtrBreakoutVerdict::Armed,
705 expansion_ratio: ratio,
706 levels: Some(AtrBreakoutLevels {
707 long_entry_stop,
708 short_entry_stop,
709 exit_stop_distance: exit_distance,
710 long_stop_loss: long_entry_stop - exit_distance,
711 short_stop_loss: short_entry_stop + exit_distance,
712 }),
713 }
714}
715
716#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
722pub struct IndexBar {
723 pub high: f64,
724 pub low: f64,
725 pub close: f64,
726 pub volume: f64,
727}
728
729#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
731pub struct DistributionParams {
732 pub down_threshold: f64,
735 pub window: usize,
737 pub invalidation_gain: f64,
739 pub volume_must_rise: bool,
741}
742
743impl Default for DistributionParams {
744 fn default() -> Self {
747 Self {
748 down_threshold: -0.002,
749 window: 25,
750 invalidation_gain: 0.05,
751 volume_must_rise: true,
752 }
753 }
754}
755
756#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
758#[serde(rename_all = "snake_case")]
759pub enum DistributionSeverity {
760 Normal,
761 Caution,
762 High,
763 Severe,
764}
765
766#[derive(Clone, Debug, Serialize, PartialEq)]
768pub struct DistributionCount {
769 pub live: usize,
771 pub last_5: usize,
772 pub last_15: usize,
773 pub last_25: usize,
774 pub invalidated: usize,
776 pub severity: DistributionSeverity,
777}
778
779pub fn distribution_days(bars: &[IndexBar], p: &DistributionParams) -> DistributionCount {
795 let mut count = DistributionCount {
796 live: 0,
797 last_5: 0,
798 last_15: 0,
799 last_25: 0,
800 invalidated: 0,
801 severity: DistributionSeverity::Normal,
802 };
803 if bars.len() < 2 {
804 return count;
805 }
806 let last = bars.len() - 1;
807
808 for i in 1..bars.len() {
809 let (bar, prev) = (bars[i], bars[i - 1]);
810 if prev.close <= 0.0 {
811 continue;
812 }
813 let change = (bar.close - prev.close) / prev.close;
814 if change > p.down_threshold {
815 continue;
816 }
817 if p.volume_must_rise && bar.volume <= prev.volume {
818 continue;
819 }
820
821 let sessions_ago = last - i;
822 let expired = sessions_ago >= p.window;
823 let invalidation_level = bar.close * (1.0 + p.invalidation_gain);
824 let invalidated = bars[i + 1..].iter().any(|b| b.close >= invalidation_level);
825
826 if invalidated {
827 count.invalidated += 1;
828 continue;
829 }
830 if expired {
831 continue;
832 }
833 count.live += 1;
834 if sessions_ago < 5 {
835 count.last_5 += 1;
836 }
837 if sessions_ago < 15 {
838 count.last_15 += 1;
839 }
840 if sessions_ago < 25 {
841 count.last_25 += 1;
842 }
843 }
844
845 count.severity = match count.live {
846 0..=2 => DistributionSeverity::Normal,
847 3..=4 => DistributionSeverity::Caution,
848 5 => DistributionSeverity::High,
849 _ => DistributionSeverity::Severe,
850 };
851 count
852}
853
854#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
856pub struct FollowThroughParams {
857 pub min_rally_day: u32,
859 pub gain_threshold: f64,
861}
862
863impl Default for FollowThroughParams {
864 fn default() -> Self {
866 Self {
867 min_rally_day: 4,
868 gain_threshold: 0.0125,
869 }
870 }
871}
872
873#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
875#[serde(rename_all = "snake_case")]
876pub enum FollowThroughPhase {
877 NoRally,
879 RallyAttempt,
881 ConfirmedUptrend,
883 Undercut,
885}
886
887#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
889pub struct FollowThroughSignal {
890 pub phase: FollowThroughPhase,
891 pub rally_start: Option<usize>,
893 pub rally_day: u32,
895 pub rally_low: Option<f64>,
897 pub follow_through: Option<usize>,
899 pub ever_confirmed: bool,
902}
903
904pub fn follow_through_day(bars: &[IndexBar], p: &FollowThroughParams) -> FollowThroughSignal {
919 let empty = FollowThroughSignal {
920 phase: FollowThroughPhase::NoRally,
921 rally_start: None,
922 rally_day: 0,
923 rally_low: None,
924 follow_through: None,
925 ever_confirmed: false,
926 };
927 if bars.len() < 2 {
928 return empty;
929 }
930
931 let mut rally_low = f64::INFINITY;
932 let mut rally_low_index: Option<usize> = None;
933 let mut in_attempt = false;
934 let mut rally_start: Option<usize> = None;
935 let mut rally_day: u32 = 0;
936 let mut confirmed = false;
937 let mut follow_through: Option<usize> = None;
938 let mut ever_confirmed = false;
939 let mut last_event_undercut = false;
940
941 for i in 0..bars.len() {
942 let bar = bars[i];
943 if bar.low < rally_low {
944 if confirmed {
947 last_event_undercut = true;
948 }
949 rally_low = bar.low;
950 rally_low_index = Some(i);
951 in_attempt = false;
952 rally_start = None;
953 rally_day = 0;
954 confirmed = false;
955 follow_through = None;
956 continue;
957 }
958 let Some(low_index) = rally_low_index else {
959 continue;
960 };
961 let prev = bars[i - 1];
962
963 if !in_attempt {
964 if i > low_index && bar.close > prev.close {
965 in_attempt = true;
966 rally_start = Some(i);
967 rally_day = 1;
968 last_event_undercut = false;
969 }
970 continue;
971 }
972
973 rally_day += 1;
974 if !confirmed && rally_day >= p.min_rally_day && prev.close > 0.0 {
975 let gain = (bar.close - prev.close) / prev.close;
976 if gain >= p.gain_threshold && bar.volume > prev.volume {
977 confirmed = true;
978 ever_confirmed = true;
979 last_event_undercut = false;
980 follow_through = Some(i);
981 }
982 }
983 }
984
985 let phase = if confirmed {
986 FollowThroughPhase::ConfirmedUptrend
987 } else if in_attempt {
988 FollowThroughPhase::RallyAttempt
989 } else if ever_confirmed && last_event_undercut {
990 FollowThroughPhase::Undercut
991 } else {
992 FollowThroughPhase::NoRally
993 };
994
995 FollowThroughSignal {
996 phase,
997 rally_start,
998 rally_day: if in_attempt { rally_day } else { 0 },
999 rally_low: if rally_low.is_finite() {
1000 Some(rally_low)
1001 } else {
1002 None
1003 },
1004 follow_through,
1005 ever_confirmed,
1006 }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 #[test]
1016 fn regime_rsi_reads_the_same_number_differently_by_regime() {
1017 let p = RegimeRsiParams::default();
1018 assert_eq!(
1020 regime_rsi(TrendRegime::Bull, 65.0, 20.0, &p).side,
1021 Side::None
1022 );
1023 assert_eq!(
1024 regime_rsi(TrendRegime::Bear, 65.0, 20.0, &p).side,
1025 Side::Short
1026 );
1027 }
1028
1029 #[test]
1030 fn a_strong_trend_widens_the_overbought_line_only() {
1031 let p = RegimeRsiParams::default();
1032 let calm = regime_rsi(TrendRegime::Bull, 78.0, 20.0, &p);
1033 let strong = regime_rsi(TrendRegime::Bull, 78.0, 40.0, &p);
1034 assert_eq!(calm.side, Side::Short, "78 clears the unmodulated 75");
1035 assert_eq!(strong.side, Side::None, "80 is the modulated line");
1036 assert_eq!(strong.effective_high, 80.0);
1037 assert_eq!(
1038 strong.effective_low, 35.0,
1039 "only one modulation applies at a time"
1040 );
1041 }
1042
1043 #[test]
1044 fn a_weak_trend_lowers_the_oversold_line_only() {
1045 let p = RegimeRsiParams::default();
1046 let weak = regime_rsi(TrendRegime::Bull, 32.0, 10.0, &p);
1047 assert_eq!(weak.effective_low, 30.0);
1048 assert_eq!(weak.effective_high, 75.0);
1049 assert_eq!(weak.side, Side::None, "32 no longer clears the moved line");
1050 assert_eq!(
1051 regime_rsi(TrendRegime::Bull, 32.0, 20.0, &p).side,
1052 Side::Long
1053 );
1054 }
1055
1056 #[test]
1057 fn idle_uses_the_symmetric_textbook_bands() {
1058 let p = RegimeRsiParams::default();
1059 let s = regime_rsi(TrendRegime::Idle, 50.0, 20.0, &p);
1060 assert_eq!((s.effective_low, s.effective_high), (30.0, 70.0));
1061 assert_eq!(s.side, Side::None);
1062 }
1063
1064 fn run_bounces(series: &[f64], p: &BounceParams) -> Vec<Side> {
1068 let mut state = BounceState::default();
1069 series
1070 .iter()
1071 .map(|&v| {
1072 let (side, next) = bounce_counter(state, v, p);
1073 state = next;
1074 side
1075 })
1076 .collect()
1077 }
1078
1079 #[test]
1080 fn a_single_touch_of_an_extreme_does_not_fire() {
1081 let p = BounceParams::default();
1082 let sides = run_bounces(&[75.0, 76.0, 77.0, 78.0, 79.0], &p);
1083 assert!(
1084 sides.iter().all(|s| *s == Side::None),
1085 "one occupancy is one bounce short of the requirement"
1086 );
1087 }
1088
1089 #[test]
1090 fn the_second_re_entry_fires_once_the_zone_persists() {
1091 let p = BounceParams::default();
1092 let sides = run_bounces(&[75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0], &p);
1094 assert_eq!(sides.iter().filter(|s| **s == Side::Short).count(), 1);
1095 assert_eq!(
1096 sides[6],
1097 Side::Short,
1098 "fires on the third bar of the second re-entry"
1099 );
1100 }
1101
1102 #[test]
1103 fn the_counter_fades_the_extreme_it_measures() {
1104 let p = BounceParams::default();
1105 let lows = run_bounces(&[25.0, 50.0, 25.0, 50.0, 25.0, 24.0, 23.0], &p);
1106 assert_eq!(lows[6], Side::Long, "a persisted oversold zone fires long");
1107 }
1108
1109 #[test]
1110 fn a_long_neutral_run_resets_the_bounce_count() {
1111 let p = BounceParams {
1112 reset_after_flats: 3,
1113 ..BounceParams::default()
1114 };
1115 let mut series = vec![75.0, 50.0, 75.0];
1116 series.extend([50.0; 5]); series.extend([75.0, 76.0, 77.0]);
1118 let sides = run_bounces(&series, &p);
1119 assert!(
1120 sides.iter().all(|s| *s == Side::None),
1121 "the amnesia window put the count back to one bounce"
1122 );
1123 }
1124
1125 #[test]
1126 fn the_counter_fires_at_most_once_per_occupancy() {
1127 let p = BounceParams::default();
1128 let sides = run_bounces(&[75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0, 78.0, 79.0], &p);
1129 assert_eq!(sides.iter().filter(|s| **s != Side::None).count(), 1);
1130 }
1131
1132 #[test]
1133 fn the_counter_is_a_pure_function_of_state_and_input() {
1134 let p = BounceParams::default();
1135 let series = [75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0];
1136 assert_eq!(run_bounces(&series, &p), run_bounces(&series, &p));
1137 }
1138
1139 #[test]
1142 fn an_aligned_signal_goes_straight_through() {
1143 let (d, s) = signal_gate(GateState::default(), Side::Long, 11.0, 10.0, 100.0, 10);
1144 assert_eq!(d.execute, Side::Long);
1145 assert_eq!(d.status, GateStatus::ExecutedImmediately);
1146 assert_eq!(s, GateState::default(), "nothing is parked");
1147 }
1148
1149 #[test]
1150 fn a_disagreeing_signal_is_parked_and_released_on_confirmation() {
1151 let (d1, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
1152 assert_eq!(d1.status, GateStatus::Pending);
1153 assert_eq!(s1.pending, Side::Long);
1154 assert_eq!(s1.pending_price, 100.0);
1155
1156 let (d2, s2) = signal_gate(s1, Side::None, 11.0, 10.0, 98.0, 10);
1157 assert_eq!(d2.execute, Side::Long);
1158 assert_eq!(d2.status, GateStatus::ExecutedAfterConfirmation);
1159 assert_eq!(
1160 d2.benefit,
1161 Some(0.02),
1162 "a long filled 2% lower is a 2% better fill"
1163 );
1164 assert_eq!(s2, GateState::default());
1165 }
1166
1167 #[test]
1168 fn waiting_that_costs_is_reported_as_a_negative_benefit() {
1169 let (_, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
1170 let (d2, _) = signal_gate(s1, Side::None, 11.0, 10.0, 105.0, 10);
1171 assert_eq!(d2.benefit, Some(-0.05));
1172 }
1173
1174 #[test]
1175 fn a_reversed_signal_cancels_the_parked_one() {
1176 let (_, s1) = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 10);
1177 let (d2, s2) = signal_gate(s1, Side::Short, 9.5, 10.0, 99.0, 10);
1178 assert_eq!(d2.status, GateStatus::Cancelled);
1179 assert_eq!(d2.execute, Side::None);
1180 assert_eq!(s2, GateState::default());
1181 }
1182
1183 #[test]
1184 fn gate_reversal_preempts_confirmation_even_when_both_mas_agree() {
1185 for (pending, raw, fast) in [
1186 (Side::Long, Side::Short, 11.0),
1187 (Side::Short, Side::Long, 9.0),
1188 (Side::Long, Side::Short, 10.0),
1189 ] {
1190 let state = GateState {
1191 pending,
1192 pending_price: 100.0,
1193 waited: 1,
1194 };
1195 let (decision, next) = signal_gate(state, raw, fast, 10.0, 98.0, 10);
1196 assert_eq!(decision.status, GateStatus::Cancelled);
1197 assert_eq!(
1198 decision.execute,
1199 Side::None,
1200 "obsolete direction must not execute"
1201 );
1202 assert_eq!(decision.benefit, None);
1203 assert_eq!(next, GateState::default());
1204 }
1205 }
1206
1207 #[test]
1208 fn gate_expiry_preempts_confirmation_at_the_exact_wait_boundary() {
1209 let state = GateState {
1210 pending: Side::Long,
1211 pending_price: 100.0,
1212 waited: 1,
1213 };
1214 let (before, _) = signal_gate(state, Side::None, 11.0, 10.0, 98.0, 3);
1215 assert_eq!(before.status, GateStatus::ExecutedAfterConfirmation);
1216 assert_eq!(before.execute, Side::Long);
1217 let (at, next) = signal_gate(
1218 GateState { waited: 2, ..state },
1219 Side::None,
1220 11.0,
1221 10.0,
1222 98.0,
1223 3,
1224 );
1225 assert_eq!(at.status, GateStatus::Cancelled);
1226 assert_eq!(at.execute, Side::None);
1227 assert_eq!(at.benefit, None);
1228 assert_eq!(next, GateState::default());
1229 }
1230
1231 #[test]
1232 fn gate_zero_or_one_wait_never_parks_an_unconfirmed_signal() {
1233 for max_wait in [0, 1] {
1234 let (decision, next) =
1235 signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, max_wait);
1236 assert_eq!(decision.status, GateStatus::Cancelled);
1237 assert_eq!(decision.execute, Side::None);
1238 assert_eq!(next, GateState::default());
1239 let (immediate, _) = signal_gate(
1240 GateState::default(),
1241 Side::Long,
1242 11.0,
1243 10.0,
1244 100.0,
1245 max_wait,
1246 );
1247 assert_eq!(immediate.status, GateStatus::ExecutedImmediately);
1248 assert_eq!(immediate.execute, Side::Long);
1249 }
1250 }
1251
1252 #[test]
1253 fn gate_wait_counter_cannot_wrap_and_renew_an_expired_signal() {
1254 let state = GateState {
1255 pending: Side::Long,
1256 pending_price: 100.0,
1257 waited: u32::MAX,
1258 };
1259 let (decision, next) = signal_gate(state, Side::None, 9.0, 10.0, 100.0, u32::MAX);
1260 assert_eq!(decision.status, GateStatus::Cancelled);
1261 assert_eq!(next, GateState::default());
1262 }
1263
1264 #[test]
1265 fn a_signal_that_waits_too_long_is_dropped() {
1266 let mut state = signal_gate(GateState::default(), Side::Long, 9.0, 10.0, 100.0, 3).1;
1268 let mut statuses = Vec::new();
1269 for _ in 0..2 {
1270 let (d, next) = signal_gate(state, Side::None, 9.0, 10.0, 100.0, 3);
1271 statuses.push(d.status);
1272 state = next;
1273 }
1274 assert_eq!(statuses, vec![GateStatus::Pending, GateStatus::Cancelled]);
1275 assert_eq!(state, GateState::default(), "the stale signal is dropped");
1276 }
1277
1278 #[test]
1279 fn no_signal_passes_through_untouched() {
1280 let (d, s) = signal_gate(GateState::default(), Side::None, 9.0, 10.0, 100.0, 10);
1281 assert_eq!(d.status, GateStatus::Passthrough);
1282 assert_eq!(s, GateState::default());
1283 }
1284
1285 #[test]
1288 fn the_time_stop_forces_an_exit_at_the_bar_limit() {
1289 let mut state = ExposureState::default();
1290 let (entry, next) = max_exposure_timeout(state, Side::Long, 100.0, 3);
1291 assert_eq!(entry.action, Side::Long);
1292 state = next;
1293
1294 let mut forced = None;
1295 for bar in 0..3 {
1296 let (d, next) = max_exposure_timeout(state, Side::None, 101.0 + bar as f64, 3);
1297 state = next;
1298 if d.forced_exit {
1299 forced = Some(d);
1300 break;
1301 }
1302 }
1303 let d = forced.expect("the clock must eventually close the position");
1304 assert_eq!(d.action, Side::Short, "a forced exit sells the long");
1305 assert_eq!(state, ExposureState::default());
1306 }
1307
1308 #[test]
1309 fn an_opposing_signal_closes_without_being_called_forced() {
1310 let (_, state) = max_exposure_timeout(ExposureState::default(), Side::Long, 100.0, 50);
1311 let (d, next) = max_exposure_timeout(state, Side::Short, 99.0, 50);
1312 assert_eq!(d.action, Side::Short);
1313 assert!(!d.forced_exit, "a thesis change is not a time stop");
1314 assert_eq!(next, ExposureState::default());
1315 }
1316
1317 #[test]
1318 fn a_same_side_signal_holds_and_advances_the_clock() {
1319 let (_, s0) = max_exposure_timeout(ExposureState::default(), Side::Long, 100.0, 50);
1320 let (d, s1) = max_exposure_timeout(s0, Side::Long, 101.0, 50);
1321 assert_eq!(d.action, Side::None);
1322 assert_eq!(s1.bars_held, 1);
1323 assert_eq!(s1.entry_price, 100.0, "the entry price is not restamped");
1324 }
1325
1326 #[test]
1327 fn flat_with_no_signal_stays_flat() {
1328 let (d, s) = max_exposure_timeout(ExposureState::default(), Side::None, 100.0, 5);
1329 assert_eq!(d.action, Side::None);
1330 assert!(!d.forced_exit);
1331 assert_eq!(s, ExposureState::default());
1332 }
1333
1334 #[test]
1337 fn expanding_volatility_arms_symmetric_stops() {
1338 let p = AtrBreakoutParams::default();
1339 let s = atr_breakout(100.0, 3.0, 2.0, &p);
1340 assert_eq!(s.verdict, AtrBreakoutVerdict::Armed);
1341 let l = s.levels.expect("armed carries levels");
1342 assert_eq!(l.long_entry_stop, 105.0);
1343 assert_eq!(l.short_entry_stop, 95.0);
1344 assert_eq!(l.exit_stop_distance, 2.0);
1345 assert_eq!(l.long_stop_loss, 103.0);
1346 assert_eq!(l.short_stop_loss, 97.0);
1347 }
1348
1349 #[test]
1350 fn contracting_volatility_does_not_arm() {
1351 let p = AtrBreakoutParams::default();
1352 let s = atr_breakout(100.0, 1.5, 2.0, &p);
1353 assert_eq!(s.verdict, AtrBreakoutVerdict::FilteredNoExpansion);
1354 assert_eq!(s.levels, None);
1355 assert_eq!(s.expansion_ratio, 0.75);
1356 }
1357
1358 #[test]
1359 fn a_quiet_market_is_filtered_before_the_expansion_test() {
1360 let p = AtrBreakoutParams {
1361 min_atr_long: 5.0,
1362 ..AtrBreakoutParams::default()
1363 };
1364 assert_eq!(
1365 atr_breakout(100.0, 9.0, 2.0, &p).verdict,
1366 AtrBreakoutVerdict::FilteredLowVol,
1367 "expanding but tiny is still untradeably quiet"
1368 );
1369 }
1370
1371 #[test]
1372 fn non_finite_or_zero_atr_is_rejected_rather_than_propagated() {
1373 let p = AtrBreakoutParams::default();
1374 for (o, short, long) in [
1375 (f64::NAN, 3.0, 2.0),
1376 (100.0, f64::INFINITY, 2.0),
1377 (100.0, 3.0, 0.0),
1378 (100.0, -1.0, 2.0),
1379 ] {
1380 let s = atr_breakout(o, short, long, &p);
1381 assert_eq!(s.verdict, AtrBreakoutVerdict::InvalidInput);
1382 assert_eq!(s.levels, None);
1383 }
1384 }
1385
1386 #[test]
1387 fn the_exit_stop_is_tighter_than_the_entry_distance_by_default() {
1388 let p = AtrBreakoutParams::default();
1389 assert!(p.exit_multiplier < p.entry_multiplier);
1390 }
1391
1392 fn bar(close: f64, volume: f64) -> IndexBar {
1395 IndexBar {
1396 high: close,
1397 low: close,
1398 close,
1399 volume,
1400 }
1401 }
1402
1403 #[test]
1404 fn a_down_close_on_rising_volume_counts() {
1405 let bars = [bar(100.0, 1000.0), bar(99.0, 1200.0)];
1406 let c = distribution_days(&bars, &DistributionParams::default());
1407 assert_eq!(c.live, 1);
1408 assert_eq!(c.last_5, 1);
1409 assert_eq!(c.severity, DistributionSeverity::Normal);
1410 }
1411
1412 #[test]
1413 fn a_down_close_on_falling_volume_does_not_count() {
1414 let bars = [bar(100.0, 1000.0), bar(99.0, 800.0)];
1415 assert_eq!(
1416 distribution_days(&bars, &DistributionParams::default()).live,
1417 0
1418 );
1419 }
1420
1421 #[test]
1422 fn a_shallow_down_close_does_not_count() {
1423 let bars = [bar(100.0, 1000.0), bar(99.9, 1200.0)];
1425 assert_eq!(
1426 distribution_days(&bars, &DistributionParams::default()).live,
1427 0
1428 );
1429 }
1430
1431 #[test]
1432 fn six_live_days_read_as_severe() {
1433 let mut bars = vec![bar(100.0, 1000.0)];
1434 let mut close = 100.0;
1435 for _ in 0..6 {
1436 close -= 1.0;
1437 bars.push(bar(close, 2000.0));
1438 bars.push(bar(close + 0.01, 1000.0)); }
1440 let c = distribution_days(&bars, &DistributionParams::default());
1441 assert_eq!(c.live, 6, "{c:?}");
1442 assert_eq!(c.severity, DistributionSeverity::Severe);
1443 }
1444
1445 #[test]
1446 fn the_rally_rule_cancels_an_absorbed_day() {
1447 let bars = [
1449 bar(100.0, 1000.0),
1450 bar(99.0, 1200.0),
1451 bar(105.0, 900.0), ];
1453 let c = distribution_days(&bars, &DistributionParams::default());
1454 assert_eq!(c.live, 0);
1455 assert_eq!(c.invalidated, 1);
1456 }
1457
1458 #[test]
1459 fn a_day_outside_the_window_expires() {
1460 let p = DistributionParams {
1461 window: 2,
1462 ..DistributionParams::default()
1463 };
1464 let bars = [
1465 bar(100.0, 1000.0),
1466 bar(99.0, 1200.0), bar(99.1, 900.0),
1468 bar(99.2, 900.0),
1469 bar(99.3, 900.0),
1470 ];
1471 let c = distribution_days(&bars, &p);
1472 assert_eq!(c.live, 0, "aged out of a two-session window");
1473 assert_eq!(c.invalidated, 0, "expiry is not invalidation");
1474 }
1475
1476 #[test]
1477 fn too_few_bars_is_a_normal_zero_count() {
1478 let c = distribution_days(&[bar(100.0, 1.0)], &DistributionParams::default());
1479 assert_eq!(c.live, 0);
1480 assert_eq!(c.severity, DistributionSeverity::Normal);
1481 }
1482
1483 fn ohlc(low: f64, close: f64, volume: f64) -> IndexBar {
1486 IndexBar {
1487 high: close,
1488 low,
1489 close,
1490 volume,
1491 }
1492 }
1493
1494 #[test]
1495 fn a_qualifying_day_four_confirms_the_attempt() {
1496 let bars = [
1497 ohlc(90.0, 91.0, 1000.0), ohlc(91.0, 92.0, 1000.0), ohlc(91.5, 92.5, 1000.0), ohlc(92.0, 93.0, 1000.0), ohlc(92.5, 95.0, 2000.0), ];
1503 let s = follow_through_day(&bars, &FollowThroughParams::default());
1504 assert_eq!(s.phase, FollowThroughPhase::ConfirmedUptrend);
1505 assert_eq!(s.follow_through, Some(4));
1506 assert!(s.ever_confirmed);
1507 assert_eq!(s.rally_start, Some(1));
1508 }
1509
1510 #[test]
1511 fn a_day_three_gain_is_too_early_to_confirm() {
1512 let bars = [
1513 ohlc(90.0, 91.0, 1000.0),
1514 ohlc(91.0, 92.0, 1000.0), ohlc(91.5, 92.5, 1000.0), ohlc(92.0, 95.0, 3000.0), ];
1518 let s = follow_through_day(&bars, &FollowThroughParams::default());
1519 assert_eq!(s.phase, FollowThroughPhase::RallyAttempt);
1520 assert_eq!(s.follow_through, None);
1521 assert!(!s.ever_confirmed);
1522 }
1523
1524 #[test]
1525 fn a_qualifying_gain_on_falling_volume_does_not_confirm() {
1526 let bars = [
1527 ohlc(90.0, 91.0, 3000.0),
1528 ohlc(91.0, 92.0, 2000.0),
1529 ohlc(91.5, 92.5, 2000.0),
1530 ohlc(92.0, 93.0, 2000.0),
1531 ohlc(92.5, 95.0, 1000.0), ];
1533 assert_eq!(
1534 follow_through_day(&bars, &FollowThroughParams::default()).phase,
1535 FollowThroughPhase::RallyAttempt
1536 );
1537 }
1538
1539 #[test]
1540 fn a_lower_low_undercuts_a_confirmed_uptrend() {
1541 let bars = [
1542 ohlc(90.0, 91.0, 1000.0),
1543 ohlc(91.0, 92.0, 1000.0),
1544 ohlc(91.5, 92.5, 1000.0),
1545 ohlc(92.0, 93.0, 1000.0),
1546 ohlc(92.5, 95.0, 2000.0), ohlc(85.0, 86.0, 1500.0), ];
1549 let s = follow_through_day(&bars, &FollowThroughParams::default());
1550 assert_eq!(s.phase, FollowThroughPhase::Undercut);
1551 assert!(s.ever_confirmed, "the event still happened");
1552 assert_eq!(s.follow_through, None, "but it is no longer live");
1553 assert_eq!(s.rally_low, Some(85.0));
1554 }
1555
1556 #[test]
1557 fn a_series_that_only_falls_never_starts_an_attempt() {
1558 let bars = [
1559 ohlc(100.0, 100.0, 1000.0),
1560 ohlc(99.0, 99.0, 1000.0),
1561 ohlc(98.0, 98.0, 1000.0),
1562 ];
1563 let s = follow_through_day(&bars, &FollowThroughParams::default());
1564 assert_eq!(s.phase, FollowThroughPhase::NoRally);
1565 assert_eq!(s.rally_start, None);
1566 assert_eq!(s.rally_day, 0);
1567 }
1568
1569 #[test]
1570 fn too_few_bars_is_no_rally() {
1571 let s = follow_through_day(&[ohlc(1.0, 1.0, 1.0)], &FollowThroughParams::default());
1572 assert_eq!(s.phase, FollowThroughPhase::NoRally);
1573 assert_eq!(s.rally_low, None);
1574 }
1575
1576 #[test]
1579 fn every_stateful_primitive_replays_identically_from_the_same_state() {
1580 let bp = BounceParams::default();
1582 let series = [75.0, 50.0, 75.0, 50.0, 75.0, 76.0, 77.0, 40.0, 25.0];
1583 let replay = |_: ()| {
1584 let mut bounce = BounceState::default();
1585 let mut gate = GateState::default();
1586 let mut exposure = ExposureState::default();
1587 let mut out = Vec::new();
1588 for (i, &v) in series.iter().enumerate() {
1589 let (side, nb) = bounce_counter(bounce, v, &bp);
1590 bounce = nb;
1591 let price = 100.0 + i as f64;
1592 let (gd, ng) = signal_gate(gate, side, v, 60.0, price, 5);
1593 gate = ng;
1594 let (ed, ne) = max_exposure_timeout(exposure, gd.execute, price, 4);
1595 exposure = ne;
1596 out.push((side, gd.status, ed.action, ed.forced_exit));
1597 }
1598 out
1599 };
1600 assert_eq!(replay(()), replay(()));
1601 }
1602}