Skip to main content

hyper_strategy/
rule_engine.rs

1use crate::strategy_config::{HysteresisConfig, RegimeRule, TaRule};
2use hyper_ta::technical_analysis::TechnicalIndicators;
3use serde::{Deserialize, Serialize};
4
5// ---------------------------------------------------------------------------
6// Data types
7// ---------------------------------------------------------------------------
8
9#[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
44// ---------------------------------------------------------------------------
45// Indicator value extraction
46// ---------------------------------------------------------------------------
47
48/// Extract an indicator value from `TechnicalIndicators` by name and params.
49///
50/// Mapping rules:
51/// - "RSI" → rsi_14
52/// - "MACD" → macd_line; "MACD_signal" → macd_signal; "MACD_histogram" → macd_histogram
53/// - "SMA" with param 20 → sma_20, param 50 → sma_50
54/// - "EMA" with param 12 → ema_12, param 26 → ema_26
55/// - "BB_upper" → bb_upper; "BB_middle" → bb_middle; "BB_lower" → bb_lower
56/// - "ADX" → adx_14
57/// - "ATR" → atr_14
58/// - "STOCH_K" → stoch_k; "STOCH_D" → stoch_d
59/// - "CCI" → cci_20
60/// - "WILLIAMS_R" → williams_r_14
61/// - "OBV" → obv
62/// - "MFI" → mfi_14
63pub 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, // default BB → middle band
96        "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
139/// For cross_above / cross_below with two-param indicators (e.g., EMA 50 vs EMA 200),
140/// resolve the second indicator value using the second param.
141fn 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, &params[1..])
150}
151
152// ---------------------------------------------------------------------------
153// Condition evaluation
154// ---------------------------------------------------------------------------
155
156fn 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            // current > threshold AND prev <= prev_threshold
171            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
202// ---------------------------------------------------------------------------
203// Regime detection
204// ---------------------------------------------------------------------------
205
206/// Detect current market regime based on regime rules, with hysteresis.
207///
208/// Hysteresis logic:
209///   1. If current regime held < min_hold_secs -> ignore new regime signals
210///   2. If pending_switch exists and target matches -> confirmation_count += 1
211///   3. If confirmation_count >= hysteresis.confirmation_count -> switch
212///   4. Else record pending_switch, wait for next confirmation
213pub 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    // Evaluate all regime rules and find the matching one with highest priority
222    let mut matched_regime: Option<&str> = None;
223    let mut best_priority: Option<u32> = None;
224
225    // Sort regime rules by priority — lower number = higher priority
226    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            // For cross_above/cross_below in regime detection we need secondary values,
238            // but since we don't have prev_indicators in regime detection, we use
239            // the threshold approach: compare indicator value vs threshold directly.
240            let threshold = if cond.condition == "cross_above" || cond.condition == "cross_below" {
241                // For two-param indicators, compare first param indicator vs second param indicator
242                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, // No prev for regime detection in this simplified model
254                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 regime is same as current, clear any pending switch
269    if detected == current_state.current {
270        current_state.pending_switch = None;
271        return current_state.current.clone();
272    }
273
274    // Hysteresis check 1: min hold time
275    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    // Hysteresis check 2: confirmation counting
281    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                // Switch!
286                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            // Different target — reset pending
294            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    // No pending switch yet — start one
303    current_state.pending_switch = Some(PendingSwitch {
304        target_regime: detected.to_string(),
305        confirmation_count: 1,
306    });
307    current_state.current.clone()
308}
309
310// ---------------------------------------------------------------------------
311// Signal evaluation
312// ---------------------------------------------------------------------------
313
314/// Evaluate playbook rules against current (and optionally previous) indicators.
315pub 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            // For cross_above / cross_below, resolve threshold from secondary indicator
326            // or use the static threshold.
327            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
364// ---------------------------------------------------------------------------
365// Signal formatting for Claude
366// ---------------------------------------------------------------------------
367
368/// Format a `SignalSummary` into a human-readable string suitable for an LLM prompt.
369pub 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// ---------------------------------------------------------------------------
416// Tests
417// ---------------------------------------------------------------------------
418
419#[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    // --- Condition tests ---
475
476    #[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        // Already above before
609        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    // --- Indicator value extraction ---
667
668    #[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    // --- Regime detection ---
697
698    #[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        // now = 2000, held only 1000 secs < 3600
712        let result = detect_regime(&regime_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(&regime_rules, "neutral", &ind, &mut state, &hyst, 5000);
731        assert_eq!(result, "bear"); // not switched yet
732        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(&regime_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); // below 30 -> bear
764            i.adx_14 = Some(10.0); // not > 50, so volatile won't match
765        });
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(&regime_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)); // no rule matches -> default
797        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(&regime_rules, "neutral", &ind, &mut state, &hyst, 5000);
812        assert_eq!(result, "neutral");
813        assert!(state.pending_switch.is_none());
814    }
815
816    // --- Format signals ---
817
818    #[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    // --- Edge cases ---
858
859    #[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        // Rule should be skipped (filter_map returns None) when indicator is None
865        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        // "between" without threshold_upper should not trigger
872        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); // gt 70 when val=70 -> false
910        assert!(e[1].triggered); // gte 70 when val=70 -> true
911        assert!(!e[2].triggered); // lt 70 when val=70 -> false
912        assert!(e[3].triggered); // lte 70 when val=70 -> true
913    }
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); // boundary inclusive
923        assert!(e2[0].triggered);
924    }
925
926    #[test]
927    fn test_outside_boundary_exclusive() {
928        // value = 30.0, outside [30, 70] means < 30 or > 70 -> false (30 is not < 30)
929        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}