1use crate::strategy_config::{HysteresisConfig, RegimeRule, TaRule};
2use hyper_ta::technical_analysis::TechnicalIndicators;
3use serde::{Deserialize, Serialize};
4
5#[derive(Serialize, Deserialize, Clone, Debug)]
10#[serde(rename_all = "camelCase")]
11pub struct RegimeState {
12 pub current: String,
13 pub since: u64,
14 pub pending_switch: Option<PendingSwitch>,
15}
16
17#[derive(Serialize, Deserialize, Clone, Debug)]
18#[serde(rename_all = "camelCase")]
19pub struct PendingSwitch {
20 pub target_regime: String,
21 pub confirmation_count: u32,
22}
23
24#[derive(Serialize, Deserialize, Clone, Debug)]
25#[serde(rename_all = "camelCase")]
26pub struct RuleEvaluation {
27 pub rule: TaRule,
28 pub current_value: f64,
29 pub triggered: bool,
30 pub signal: String,
31}
32
33#[derive(Serialize, Deserialize, Clone, Debug)]
34#[serde(rename_all = "camelCase")]
35pub struct SignalSummary {
36 pub symbol: String,
37 pub timestamp: u64,
38 pub current_regime: String,
39 pub regime_changed: bool,
40 pub evaluations: Vec<RuleEvaluation>,
41 pub triggered_signals: Vec<String>,
42}
43
44pub fn get_indicator_value(
64 indicators: &TechnicalIndicators,
65 name: &str,
66 params: &[f64],
67) -> Option<f64> {
68 let upper = name.to_uppercase();
69 match upper.as_str() {
70 "RSI" => indicators.rsi_14,
71 "MACD" | "MACD_LINE" => indicators.macd_line,
72 "MACD_SIGNAL" => indicators.macd_signal,
73 "MACD_HISTOGRAM" => indicators.macd_histogram,
74 "SMA" => {
75 let period = params.first().copied().unwrap_or(20.0) as u32;
76 match period {
77 20 => indicators.sma_20,
78 50 => indicators.sma_50,
79 _ => None,
80 }
81 }
82 "EMA" => {
83 let period = params.first().copied().unwrap_or(12.0) as u32;
84 match period {
85 12 => indicators.ema_12,
86 20 => indicators.ema_20,
87 26 => indicators.ema_26,
88 50 => indicators.ema_50,
89 _ => None,
90 }
91 }
92 "BB_UPPER" => indicators.bb_upper,
93 "BB_MIDDLE" => indicators.bb_middle,
94 "BB_LOWER" => indicators.bb_lower,
95 "BB" => indicators.bb_middle, "ADX" => indicators.adx_14,
97 "ATR" => indicators.atr_14,
98 "STOCH_K" => indicators.stoch_k,
99 "STOCH_D" => indicators.stoch_d,
100 "CCI" => indicators.cci_20,
101 "WILLIAMS_R" => indicators.williams_r_14,
102 "OBV" => indicators.obv,
103 "MFI" => indicators.mfi_14,
104 "ROC" => indicators.roc_12,
105 "DONCHIAN_UPPER" => {
106 let period = params.first().copied().unwrap_or(20.0) as u32;
107 match period {
108 10 => indicators.donchian_upper_10,
109 _ => indicators.donchian_upper_20,
110 }
111 }
112 "DONCHIAN_LOWER" => {
113 let period = params.first().copied().unwrap_or(20.0) as u32;
114 match period {
115 10 => indicators.donchian_lower_10,
116 _ => indicators.donchian_lower_20,
117 }
118 }
119 "ZSCORE" => indicators.close_zscore_20,
120 "VOL_ZSCORE" => indicators.volume_zscore_20,
121 "HV" => {
122 let period = params.first().copied().unwrap_or(20.0) as u32;
123 match period {
124 60 => indicators.hv_60,
125 _ => indicators.hv_20,
126 }
127 }
128 "KC_UPPER" => indicators.kc_upper_20,
129 "KC_LOWER" => indicators.kc_lower_20,
130 "SUPERTREND" => indicators.supertrend_value,
131 "SUPERTREND_DIR" => indicators.supertrend_direction,
132 "VWAP" => indicators.vwap,
133 "PLUS_DI" => indicators.plus_di_14,
134 "MINUS_DI" => indicators.minus_di_14,
135 _ => None,
136 }
137}
138
139fn get_secondary_indicator_value(
142 indicators: &TechnicalIndicators,
143 name: &str,
144 params: &[f64],
145) -> Option<f64> {
146 if params.len() < 2 {
147 return None;
148 }
149 get_indicator_value(indicators, name, ¶ms[1..])
150}
151
152fn evaluate_condition(
157 condition: &str,
158 current: f64,
159 threshold: f64,
160 threshold_upper: Option<f64>,
161 prev_value: Option<f64>,
162 prev_threshold: Option<f64>,
163) -> bool {
164 match condition {
165 "gt" => current > threshold,
166 "lt" => current < threshold,
167 "gte" => current >= threshold,
168 "lte" => current <= threshold,
169 "cross_above" => {
170 if let (Some(pv), Some(pt)) = (prev_value, prev_threshold) {
172 current > threshold && pv <= pt
173 } else {
174 false
175 }
176 }
177 "cross_below" => {
178 if let (Some(pv), Some(pt)) = (prev_value, prev_threshold) {
179 current < threshold && pv >= pt
180 } else {
181 false
182 }
183 }
184 "between" => {
185 if let Some(upper) = threshold_upper {
186 current >= threshold && current <= upper
187 } else {
188 false
189 }
190 }
191 "outside" => {
192 if let Some(upper) = threshold_upper {
193 current < threshold || current > upper
194 } else {
195 false
196 }
197 }
198 _ => false,
199 }
200}
201
202pub fn detect_regime(
214 regime_rules: &[RegimeRule],
215 default_regime: &str,
216 indicators: &TechnicalIndicators,
217 current_state: &mut RegimeState,
218 hysteresis: &HysteresisConfig,
219 now: u64,
220) -> String {
221 let mut matched_regime: Option<&str> = None;
223 let mut best_priority: Option<u32> = None;
224
225 let mut sorted_rules: Vec<&RegimeRule> = regime_rules.iter().collect();
227 sorted_rules.sort_by_key(|r| r.priority);
228
229 for rule in &sorted_rules {
230 let all_conditions_met = rule.conditions.iter().all(|cond| {
231 let val = get_indicator_value(indicators, &cond.indicator, &cond.params);
232 let val = match val {
233 Some(v) => v,
234 None => return false,
235 };
236
237 let threshold = if cond.condition == "cross_above" || cond.condition == "cross_below" {
241 get_secondary_indicator_value(indicators, &cond.indicator, &cond.params)
243 .unwrap_or(cond.threshold)
244 } else {
245 cond.threshold
246 };
247
248 evaluate_condition(
249 &cond.condition,
250 val,
251 threshold,
252 cond.threshold_upper,
253 None, None,
255 )
256 });
257
258 if all_conditions_met {
259 if best_priority.is_none() || rule.priority < best_priority.unwrap() {
260 matched_regime = Some(&rule.regime);
261 best_priority = Some(rule.priority);
262 }
263 }
264 }
265
266 let detected = matched_regime.unwrap_or(default_regime);
267
268 if detected == current_state.current {
270 current_state.pending_switch = None;
271 return current_state.current.clone();
272 }
273
274 let held_secs = now.saturating_sub(current_state.since);
276 if held_secs < hysteresis.min_hold_secs {
277 return current_state.current.clone();
278 }
279
280 if let Some(ref mut pending) = current_state.pending_switch {
282 if pending.target_regime == detected {
283 pending.confirmation_count += 1;
284 if pending.confirmation_count >= hysteresis.confirmation_count {
285 current_state.current = detected.to_string();
287 current_state.since = now;
288 current_state.pending_switch = None;
289 return current_state.current.clone();
290 }
291 return current_state.current.clone();
292 } else {
293 current_state.pending_switch = Some(PendingSwitch {
295 target_regime: detected.to_string(),
296 confirmation_count: 1,
297 });
298 return current_state.current.clone();
299 }
300 }
301
302 current_state.pending_switch = Some(PendingSwitch {
304 target_regime: detected.to_string(),
305 confirmation_count: 1,
306 });
307 current_state.current.clone()
308}
309
310pub fn evaluate_rules(
316 rules: &[TaRule],
317 indicators: &TechnicalIndicators,
318 prev_indicators: Option<&TechnicalIndicators>,
319) -> Vec<RuleEvaluation> {
320 rules
321 .iter()
322 .filter_map(|rule| {
323 let current_val = get_indicator_value(indicators, &rule.indicator, &rule.params)?;
324
325 let threshold = if rule.condition == "cross_above" || rule.condition == "cross_below" {
328 get_secondary_indicator_value(indicators, &rule.indicator, &rule.params)
329 .unwrap_or(rule.threshold)
330 } else {
331 rule.threshold
332 };
333
334 let prev_value = prev_indicators
335 .and_then(|pi| get_indicator_value(pi, &rule.indicator, &rule.params));
336
337 let prev_threshold = prev_indicators.and_then(|pi| {
338 if rule.condition == "cross_above" || rule.condition == "cross_below" {
339 get_secondary_indicator_value(pi, &rule.indicator, &rule.params)
340 } else {
341 Some(rule.threshold)
342 }
343 });
344
345 let triggered = evaluate_condition(
346 &rule.condition,
347 current_val,
348 threshold,
349 rule.threshold_upper,
350 prev_value,
351 prev_threshold,
352 );
353
354 Some(RuleEvaluation {
355 rule: rule.clone(),
356 current_value: current_val,
357 triggered,
358 signal: rule.signal.clone(),
359 })
360 })
361 .collect()
362}
363
364pub fn format_signals_for_claude(summary: &SignalSummary) -> String {
370 let mut lines = Vec::new();
371 lines.push(format!("=== Signal Report for {} ===", summary.symbol));
372 lines.push(format!("Timestamp: {}", summary.timestamp));
373 lines.push(format!("Current Regime: {}", summary.current_regime));
374 lines.push(format!(
375 "Regime Changed: {}",
376 if summary.regime_changed { "YES" } else { "NO" }
377 ));
378 lines.push(String::new());
379
380 if summary.evaluations.is_empty() {
381 lines.push("No rules evaluated.".to_string());
382 } else {
383 lines.push(format!("Rule Evaluations ({}):", summary.evaluations.len()));
384 for eval in &summary.evaluations {
385 let status = if eval.triggered {
386 "TRIGGERED"
387 } else {
388 "not triggered"
389 };
390 lines.push(format!(
391 " - {} {} {}: value={:.4} [{}] -> {}",
392 eval.rule.indicator,
393 eval.rule.condition,
394 eval.rule.threshold,
395 eval.current_value,
396 status,
397 eval.signal,
398 ));
399 }
400 }
401
402 lines.push(String::new());
403 if summary.triggered_signals.is_empty() {
404 lines.push("No signals triggered.".to_string());
405 } else {
406 lines.push(format!(
407 "Triggered Signals: {}",
408 summary.triggered_signals.join(", ")
409 ));
410 }
411
412 lines.join("\n")
413}
414
415#[cfg(test)]
420mod tests {
421 use super::*;
422
423 fn make_indicators(overrides: impl FnOnce(&mut TechnicalIndicators)) -> TechnicalIndicators {
424 let mut ind = TechnicalIndicators::empty();
425 overrides(&mut ind);
426 ind
427 }
428
429 fn make_regime_state(current: &str, since: u64) -> RegimeState {
430 RegimeState {
431 current: current.to_string(),
432 since,
433 pending_switch: None,
434 }
435 }
436
437 fn make_rule(
438 indicator: &str,
439 params: Vec<f64>,
440 condition: &str,
441 threshold: f64,
442 signal: &str,
443 ) -> TaRule {
444 TaRule {
445 indicator: indicator.to_string(),
446 params,
447 condition: condition.to_string(),
448 threshold,
449 threshold_upper: None,
450 signal: signal.to_string(),
451 action: None,
452 }
453 }
454
455 fn make_rule_with_upper(
456 indicator: &str,
457 params: Vec<f64>,
458 condition: &str,
459 threshold: f64,
460 threshold_upper: f64,
461 signal: &str,
462 ) -> TaRule {
463 TaRule {
464 indicator: indicator.to_string(),
465 params,
466 condition: condition.to_string(),
467 threshold,
468 threshold_upper: Some(threshold_upper),
469 signal: signal.to_string(),
470 action: None,
471 }
472 }
473
474 #[test]
477 fn test_condition_gt() {
478 let ind = make_indicators(|i| i.rsi_14 = Some(75.0));
479 let rules = vec![make_rule("RSI", vec![14.0], "gt", 70.0, "overbought")];
480 let evals = evaluate_rules(&rules, &ind, None);
481 assert_eq!(evals.len(), 1);
482 assert!(evals[0].triggered);
483 }
484
485 #[test]
486 fn test_condition_gt_not_triggered() {
487 let ind = make_indicators(|i| i.rsi_14 = Some(65.0));
488 let rules = vec![make_rule("RSI", vec![14.0], "gt", 70.0, "overbought")];
489 let evals = evaluate_rules(&rules, &ind, None);
490 assert!(!evals[0].triggered);
491 }
492
493 #[test]
494 fn test_condition_lt() {
495 let ind = make_indicators(|i| i.rsi_14 = Some(25.0));
496 let rules = vec![make_rule("RSI", vec![14.0], "lt", 30.0, "oversold")];
497 let evals = evaluate_rules(&rules, &ind, None);
498 assert!(evals[0].triggered);
499 }
500
501 #[test]
502 fn test_condition_lt_not_triggered() {
503 let ind = make_indicators(|i| i.rsi_14 = Some(35.0));
504 let rules = vec![make_rule("RSI", vec![14.0], "lt", 30.0, "oversold")];
505 let evals = evaluate_rules(&rules, &ind, None);
506 assert!(!evals[0].triggered);
507 }
508
509 #[test]
510 fn test_condition_gte() {
511 let ind = make_indicators(|i| i.rsi_14 = Some(70.0));
512 let rules = vec![make_rule("RSI", vec![14.0], "gte", 70.0, "overbought")];
513 let evals = evaluate_rules(&rules, &ind, None);
514 assert!(evals[0].triggered);
515 }
516
517 #[test]
518 fn test_condition_lte() {
519 let ind = make_indicators(|i| i.rsi_14 = Some(30.0));
520 let rules = vec![make_rule("RSI", vec![14.0], "lte", 30.0, "oversold")];
521 let evals = evaluate_rules(&rules, &ind, None);
522 assert!(evals[0].triggered);
523 }
524
525 #[test]
526 fn test_condition_between() {
527 let ind = make_indicators(|i| i.rsi_14 = Some(50.0));
528 let rules = vec![make_rule_with_upper(
529 "RSI",
530 vec![14.0],
531 "between",
532 30.0,
533 70.0,
534 "neutral",
535 )];
536 let evals = evaluate_rules(&rules, &ind, None);
537 assert!(evals[0].triggered);
538 }
539
540 #[test]
541 fn test_condition_between_not_triggered() {
542 let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
543 let rules = vec![make_rule_with_upper(
544 "RSI",
545 vec![14.0],
546 "between",
547 30.0,
548 70.0,
549 "neutral",
550 )];
551 let evals = evaluate_rules(&rules, &ind, None);
552 assert!(!evals[0].triggered);
553 }
554
555 #[test]
556 fn test_condition_outside() {
557 let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
558 let rules = vec![make_rule_with_upper(
559 "RSI",
560 vec![14.0],
561 "outside",
562 30.0,
563 70.0,
564 "extreme",
565 )];
566 let evals = evaluate_rules(&rules, &ind, None);
567 assert!(evals[0].triggered);
568 }
569
570 #[test]
571 fn test_condition_outside_not_triggered() {
572 let ind = make_indicators(|i| i.rsi_14 = Some(50.0));
573 let rules = vec![make_rule_with_upper(
574 "RSI",
575 vec![14.0],
576 "outside",
577 30.0,
578 70.0,
579 "extreme",
580 )];
581 let evals = evaluate_rules(&rules, &ind, None);
582 assert!(!evals[0].triggered);
583 }
584
585 #[test]
586 fn test_condition_cross_above() {
587 let prev = make_indicators(|i| {
588 i.ema_12 = Some(98.0);
589 i.ema_26 = Some(100.0);
590 });
591 let curr = make_indicators(|i| {
592 i.ema_12 = Some(101.0);
593 i.ema_26 = Some(100.0);
594 });
595 let rules = vec![make_rule(
596 "EMA",
597 vec![12.0, 26.0],
598 "cross_above",
599 0.0,
600 "golden_cross",
601 )];
602 let evals = evaluate_rules(&rules, &curr, Some(&prev));
603 assert!(evals[0].triggered);
604 }
605
606 #[test]
607 fn test_condition_cross_above_not_triggered() {
608 let prev = make_indicators(|i| {
610 i.ema_12 = Some(102.0);
611 i.ema_26 = Some(100.0);
612 });
613 let curr = make_indicators(|i| {
614 i.ema_12 = Some(103.0);
615 i.ema_26 = Some(100.0);
616 });
617 let rules = vec![make_rule(
618 "EMA",
619 vec![12.0, 26.0],
620 "cross_above",
621 0.0,
622 "golden_cross",
623 )];
624 let evals = evaluate_rules(&rules, &curr, Some(&prev));
625 assert!(!evals[0].triggered);
626 }
627
628 #[test]
629 fn test_condition_cross_below() {
630 let prev = make_indicators(|i| {
631 i.ema_12 = Some(101.0);
632 i.ema_26 = Some(100.0);
633 });
634 let curr = make_indicators(|i| {
635 i.ema_12 = Some(99.0);
636 i.ema_26 = Some(100.0);
637 });
638 let rules = vec![make_rule(
639 "EMA",
640 vec![12.0, 26.0],
641 "cross_below",
642 0.0,
643 "death_cross",
644 )];
645 let evals = evaluate_rules(&rules, &curr, Some(&prev));
646 assert!(evals[0].triggered);
647 }
648
649 #[test]
650 fn test_cross_above_no_prev_returns_false() {
651 let curr = make_indicators(|i| {
652 i.ema_12 = Some(101.0);
653 i.ema_26 = Some(100.0);
654 });
655 let rules = vec![make_rule(
656 "EMA",
657 vec![12.0, 26.0],
658 "cross_above",
659 0.0,
660 "golden_cross",
661 )];
662 let evals = evaluate_rules(&rules, &curr, None);
663 assert!(!evals[0].triggered);
664 }
665
666 #[test]
669 fn test_get_indicator_various() {
670 let ind = make_indicators(|i| {
671 i.rsi_14 = Some(55.0);
672 i.macd_line = Some(1.5);
673 i.bb_upper = Some(110.0);
674 i.adx_14 = Some(25.0);
675 i.atr_14 = Some(3.0);
676 i.sma_20 = Some(100.0);
677 i.sma_50 = Some(99.0);
678 });
679 assert_eq!(get_indicator_value(&ind, "RSI", &[14.0]), Some(55.0));
680 assert_eq!(get_indicator_value(&ind, "MACD", &[]), Some(1.5));
681 assert_eq!(get_indicator_value(&ind, "BB_upper", &[]), Some(110.0));
682 assert_eq!(get_indicator_value(&ind, "ADX", &[14.0]), Some(25.0));
683 assert_eq!(get_indicator_value(&ind, "ATR", &[14.0]), Some(3.0));
684 assert_eq!(get_indicator_value(&ind, "SMA", &[20.0]), Some(100.0));
685 assert_eq!(get_indicator_value(&ind, "SMA", &[50.0]), Some(99.0));
686 assert_eq!(get_indicator_value(&ind, "UNKNOWN_IND", &[]), None);
687 }
688
689 #[test]
690 fn test_get_indicator_case_insensitive() {
691 let ind = make_indicators(|i| i.rsi_14 = Some(42.0));
692 assert_eq!(get_indicator_value(&ind, "rsi", &[14.0]), Some(42.0));
693 assert_eq!(get_indicator_value(&ind, "Rsi", &[14.0]), Some(42.0));
694 }
695
696 #[test]
699 fn test_regime_stays_when_min_hold_not_met() {
700 let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
701 let regime_rules = vec![RegimeRule {
702 regime: "bull".to_string(),
703 conditions: vec![make_rule("RSI", vec![14.0], "gt", 70.0, "overbought")],
704 priority: 1,
705 }];
706 let hyst = HysteresisConfig {
707 min_hold_secs: 3600,
708 confirmation_count: 2,
709 };
710 let mut state = make_regime_state("bear", 1000);
711 let result = detect_regime(®ime_rules, "neutral", &ind, &mut state, &hyst, 2000);
713 assert_eq!(result, "bear");
714 assert!(state.pending_switch.is_none());
715 }
716
717 #[test]
718 fn test_regime_pending_switch_starts() {
719 let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
720 let regime_rules = vec![RegimeRule {
721 regime: "bull".to_string(),
722 conditions: vec![make_rule("RSI", vec![14.0], "gt", 70.0, "overbought")],
723 priority: 1,
724 }];
725 let hyst = HysteresisConfig {
726 min_hold_secs: 100,
727 confirmation_count: 3,
728 };
729 let mut state = make_regime_state("bear", 0);
730 let result = detect_regime(®ime_rules, "neutral", &ind, &mut state, &hyst, 5000);
731 assert_eq!(result, "bear"); assert!(state.pending_switch.is_some());
733 assert_eq!(state.pending_switch.as_ref().unwrap().target_regime, "bull");
734 assert_eq!(state.pending_switch.as_ref().unwrap().confirmation_count, 1);
735 }
736
737 #[test]
738 fn test_regime_switches_after_confirmations() {
739 let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
740 let regime_rules = vec![RegimeRule {
741 regime: "bull".to_string(),
742 conditions: vec![make_rule("RSI", vec![14.0], "gt", 70.0, "overbought")],
743 priority: 1,
744 }];
745 let hyst = HysteresisConfig {
746 min_hold_secs: 100,
747 confirmation_count: 3,
748 };
749 let mut state = make_regime_state("bear", 0);
750 state.pending_switch = Some(PendingSwitch {
751 target_regime: "bull".to_string(),
752 confirmation_count: 2,
753 });
754 let result = detect_regime(®ime_rules, "neutral", &ind, &mut state, &hyst, 5000);
755 assert_eq!(result, "bull");
756 assert!(state.pending_switch.is_none());
757 assert_eq!(state.since, 5000);
758 }
759
760 #[test]
761 fn test_regime_pending_switch_resets_on_different_target() {
762 let ind = make_indicators(|i| {
763 i.rsi_14 = Some(20.0); i.adx_14 = Some(10.0); });
766 let regime_rules = vec![
767 RegimeRule {
768 regime: "bull".to_string(),
769 conditions: vec![make_rule("RSI", vec![14.0], "gt", 70.0, "x")],
770 priority: 1,
771 },
772 RegimeRule {
773 regime: "bear".to_string(),
774 conditions: vec![make_rule("RSI", vec![14.0], "lt", 30.0, "x")],
775 priority: 2,
776 },
777 ];
778 let hyst = HysteresisConfig {
779 min_hold_secs: 100,
780 confirmation_count: 3,
781 };
782 let mut state = make_regime_state("neutral", 0);
783 state.pending_switch = Some(PendingSwitch {
784 target_regime: "bull".to_string(),
785 confirmation_count: 2,
786 });
787 let result = detect_regime(®ime_rules, "neutral", &ind, &mut state, &hyst, 5000);
788 assert_eq!(result, "neutral");
789 let pending = state.pending_switch.as_ref().unwrap();
790 assert_eq!(pending.target_regime, "bear");
791 assert_eq!(pending.confirmation_count, 1);
792 }
793
794 #[test]
795 fn test_regime_clears_pending_when_detected_matches_current() {
796 let ind = make_indicators(|i| i.rsi_14 = Some(50.0)); let regime_rules = vec![RegimeRule {
798 regime: "bull".to_string(),
799 conditions: vec![make_rule("RSI", vec![14.0], "gt", 70.0, "x")],
800 priority: 1,
801 }];
802 let hyst = HysteresisConfig {
803 min_hold_secs: 100,
804 confirmation_count: 3,
805 };
806 let mut state = make_regime_state("neutral", 0);
807 state.pending_switch = Some(PendingSwitch {
808 target_regime: "bull".to_string(),
809 confirmation_count: 2,
810 });
811 let result = detect_regime(®ime_rules, "neutral", &ind, &mut state, &hyst, 5000);
812 assert_eq!(result, "neutral");
813 assert!(state.pending_switch.is_none());
814 }
815
816 #[test]
819 fn test_format_signals_for_claude() {
820 let summary = SignalSummary {
821 symbol: "BTC-USD".to_string(),
822 timestamp: 1700000000,
823 current_regime: "bull".to_string(),
824 regime_changed: true,
825 evaluations: vec![RuleEvaluation {
826 rule: make_rule("RSI", vec![14.0], "gt", 70.0, "overbought"),
827 current_value: 75.0,
828 triggered: true,
829 signal: "overbought".to_string(),
830 }],
831 triggered_signals: vec!["overbought".to_string()],
832 };
833 let output = format_signals_for_claude(&summary);
834 assert!(output.contains("BTC-USD"));
835 assert!(output.contains("bull"));
836 assert!(output.contains("YES"));
837 assert!(output.contains("TRIGGERED"));
838 assert!(output.contains("overbought"));
839 }
840
841 #[test]
842 fn test_format_signals_no_triggers() {
843 let summary = SignalSummary {
844 symbol: "ETH-USD".to_string(),
845 timestamp: 1700000000,
846 current_regime: "neutral".to_string(),
847 regime_changed: false,
848 evaluations: vec![],
849 triggered_signals: vec![],
850 };
851 let output = format_signals_for_claude(&summary);
852 assert!(output.contains("NO"));
853 assert!(output.contains("No signals triggered"));
854 assert!(output.contains("No rules evaluated"));
855 }
856
857 #[test]
860 fn test_evaluate_rule_missing_indicator_skipped() {
861 let ind = TechnicalIndicators::empty();
862 let rules = vec![make_rule("RSI", vec![14.0], "gt", 70.0, "overbought")];
863 let evals = evaluate_rules(&rules, &ind, None);
864 assert_eq!(evals.len(), 0);
866 }
867
868 #[test]
869 fn test_between_without_threshold_upper_returns_false() {
870 let ind = make_indicators(|i| i.rsi_14 = Some(50.0));
871 let rules = vec![make_rule("RSI", vec![14.0], "between", 30.0, "neutral")];
873 let evals = evaluate_rules(&rules, &ind, None);
874 assert!(!evals[0].triggered);
875 }
876
877 #[test]
878 fn test_unknown_condition_returns_false() {
879 let ind = make_indicators(|i| i.rsi_14 = Some(50.0));
880 let rules = vec![make_rule("RSI", vec![14.0], "foobar", 30.0, "x")];
881 let evals = evaluate_rules(&rules, &ind, None);
882 assert!(!evals[0].triggered);
883 }
884
885 #[test]
886 fn test_multiple_rules_mixed_results() {
887 let ind = make_indicators(|i| {
888 i.rsi_14 = Some(75.0);
889 i.adx_14 = Some(20.0);
890 });
891 let rules = vec![
892 make_rule("RSI", vec![14.0], "gt", 70.0, "overbought"),
893 make_rule("ADX", vec![14.0], "gt", 25.0, "strong_trend"),
894 ];
895 let evals = evaluate_rules(&rules, &ind, None);
896 assert_eq!(evals.len(), 2);
897 assert!(evals[0].triggered);
898 assert!(!evals[1].triggered);
899 }
900
901 #[test]
902 fn test_boundary_values_gte_lte() {
903 let ind = make_indicators(|i| i.rsi_14 = Some(70.0));
904 let r1 = make_rule("RSI", vec![14.0], "gt", 70.0, "x");
905 let r2 = make_rule("RSI", vec![14.0], "gte", 70.0, "x");
906 let r3 = make_rule("RSI", vec![14.0], "lt", 70.0, "x");
907 let r4 = make_rule("RSI", vec![14.0], "lte", 70.0, "x");
908 let e = evaluate_rules(&[r1, r2, r3, r4], &ind, None);
909 assert!(!e[0].triggered); assert!(e[1].triggered); assert!(!e[2].triggered); assert!(e[3].triggered); }
914
915 #[test]
916 fn test_between_boundary_inclusive() {
917 let lower = make_indicators(|i| i.rsi_14 = Some(30.0));
918 let upper = make_indicators(|i| i.rsi_14 = Some(70.0));
919 let rule = make_rule_with_upper("RSI", vec![14.0], "between", 30.0, 70.0, "x");
920 let e1 = evaluate_rules(&[rule.clone()], &lower, None);
921 let e2 = evaluate_rules(&[rule], &upper, None);
922 assert!(e1[0].triggered); assert!(e2[0].triggered);
924 }
925
926 #[test]
927 fn test_outside_boundary_exclusive() {
928 let at_lower = make_indicators(|i| i.rsi_14 = Some(30.0));
930 let below_lower = make_indicators(|i| i.rsi_14 = Some(29.9));
931 let rule = make_rule_with_upper("RSI", vec![14.0], "outside", 30.0, 70.0, "x");
932 let e1 = evaluate_rules(&[rule.clone()], &at_lower, None);
933 let e2 = evaluate_rules(&[rule], &below_lower, None);
934 assert!(!e1[0].triggered);
935 assert!(e2[0].triggered);
936 }
937}