Skip to main content

hyper_playbook/
regime.rs

1use hyper_strategy::rule_engine::{detect_regime, RegimeState};
2use hyper_strategy::strategy_config::{Playbook, StrategyGroup};
3use hyper_ta::technical_analysis::TechnicalIndicators;
4
5pub struct RegimeDetector {
6    strategy_group: StrategyGroup,
7    regime_state: RegimeState,
8    current_regime: String,
9    previous_regime: Option<String>,
10    changed: bool,
11}
12
13impl RegimeDetector {
14    pub fn new(strategy_group: StrategyGroup) -> Self {
15        let default = strategy_group.default_regime.clone();
16        Self {
17            strategy_group,
18            regime_state: RegimeState {
19                current: default.clone(),
20                since: 0,
21                pending_switch: None,
22            },
23            current_regime: default,
24            previous_regime: None,
25            changed: false,
26        }
27    }
28
29    /// Detect regime from current indicators. Returns the regime name.
30    pub fn detect(&mut self, indicators: &TechnicalIndicators, now: u64) -> &str {
31        let new_regime = detect_regime(
32            &self.strategy_group.regime_rules,
33            &self.strategy_group.default_regime,
34            indicators,
35            &mut self.regime_state,
36            &self.strategy_group.hysteresis,
37            now,
38        );
39        self.changed = new_regime != self.current_regime;
40        if self.changed {
41            self.previous_regime = Some(self.current_regime.clone());
42            self.current_regime = new_regime;
43        }
44        &self.current_regime
45    }
46
47    pub fn current_regime(&self) -> &str {
48        &self.current_regime
49    }
50
51    pub fn regime_changed(&self) -> bool {
52        self.changed
53    }
54
55    pub fn previous_regime(&self) -> Option<&str> {
56        self.previous_regime.as_deref()
57    }
58
59    /// Look up the playbook for the current regime.
60    pub fn current_playbook(&self) -> Option<&Playbook> {
61        self.strategy_group.playbooks.get(&self.current_regime)
62    }
63
64    pub fn strategy_group(&self) -> &StrategyGroup {
65        &self.strategy_group
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use hyper_strategy::strategy_config::{
73        HysteresisConfig, Playbook, RegimeRule, StrategyGroup, TaRule,
74    };
75    use std::collections::HashMap;
76
77    fn make_ta_rule(
78        indicator: &str,
79        params: Vec<f64>,
80        condition: &str,
81        threshold: f64,
82        signal: &str,
83    ) -> TaRule {
84        TaRule {
85            indicator: indicator.to_string(),
86            params,
87            condition: condition.to_string(),
88            threshold,
89            threshold_upper: None,
90            signal: signal.to_string(),
91            action: None,
92        }
93    }
94
95    fn make_playbook(prompt: &str) -> Playbook {
96        Playbook {
97            rules: vec![],
98            entry_rules: vec![],
99            exit_rules: vec![],
100            system_prompt: prompt.to_string(),
101            max_position_size: 1000.0,
102            stop_loss_pct: Some(5.0),
103            take_profit_pct: Some(10.0),
104            timeout_secs: None,
105            side: None,
106        }
107    }
108
109    /// Build a simple StrategyGroup with 2 regime rules (bull + bear) and default "neutral".
110    fn make_strategy_group(hysteresis: HysteresisConfig) -> StrategyGroup {
111        let mut playbooks = HashMap::new();
112        playbooks.insert("bull".to_string(), make_playbook("bull prompt"));
113        playbooks.insert("bear".to_string(), make_playbook("bear prompt"));
114        playbooks.insert("neutral".to_string(), make_playbook("neutral prompt"));
115
116        StrategyGroup {
117            id: "sg-test".to_string(),
118            name: "Test Group".to_string(),
119            vault_address: None,
120            is_active: true,
121            created_at: "2026-01-01T00:00:00Z".to_string(),
122            symbol: "BTC-USD".to_string(),
123            interval_secs: 300,
124            regime_rules: vec![
125                RegimeRule {
126                    regime: "bull".to_string(),
127                    conditions: vec![make_ta_rule("RSI", vec![14.0], "gt", 70.0, "overbought")],
128                    priority: 1,
129                },
130                RegimeRule {
131                    regime: "bear".to_string(),
132                    conditions: vec![make_ta_rule("RSI", vec![14.0], "lt", 30.0, "oversold")],
133                    priority: 2,
134                },
135            ],
136            default_regime: "neutral".to_string(),
137            hysteresis,
138            playbooks,
139        }
140    }
141
142    fn make_indicators(f: impl FnOnce(&mut TechnicalIndicators)) -> TechnicalIndicators {
143        let mut ind = TechnicalIndicators::empty();
144        f(&mut ind);
145        ind
146    }
147
148    // -----------------------------------------------------------------------
149    // Test: initial regime is default
150    // -----------------------------------------------------------------------
151
152    #[test]
153    fn test_initial_regime_is_default() {
154        let sg = make_strategy_group(HysteresisConfig {
155            min_hold_secs: 0,
156            confirmation_count: 1,
157        });
158        let detector = RegimeDetector::new(sg);
159        assert_eq!(detector.current_regime(), "neutral");
160        assert!(!detector.regime_changed());
161        assert!(detector.previous_regime().is_none());
162    }
163
164    // -----------------------------------------------------------------------
165    // Test: indicators trigger regime change -> regime_changed() is true
166    // -----------------------------------------------------------------------
167
168    #[test]
169    fn test_regime_change_detected() {
170        let sg = make_strategy_group(HysteresisConfig {
171            min_hold_secs: 0,
172            confirmation_count: 1,
173        });
174        let mut detector = RegimeDetector::new(sg);
175
176        let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
177
178        // First call: starts pending switch (count=1), not switched yet
179        detector.detect(&ind, 1000);
180        assert_eq!(detector.current_regime(), "neutral");
181
182        // Second call: increments count to 2 >= 1, switch happens
183        detector.detect(&ind, 2000);
184        assert_eq!(detector.current_regime(), "bull");
185        assert!(detector.regime_changed());
186    }
187
188    // -----------------------------------------------------------------------
189    // Test: same regime detected again -> regime_changed() is false
190    // -----------------------------------------------------------------------
191
192    #[test]
193    fn test_same_regime_not_changed() {
194        let sg = make_strategy_group(HysteresisConfig {
195            min_hold_secs: 0,
196            confirmation_count: 1,
197        });
198        let mut detector = RegimeDetector::new(sg);
199
200        let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
201
202        // Two calls to complete the switch
203        detector.detect(&ind, 1000);
204        detector.detect(&ind, 2000);
205        assert!(detector.regime_changed());
206        assert_eq!(detector.current_regime(), "bull");
207
208        // Third detect: same bull indicators -> no change
209        detector.detect(&ind, 3000);
210        assert!(!detector.regime_changed());
211        assert_eq!(detector.current_regime(), "bull");
212    }
213
214    // -----------------------------------------------------------------------
215    // Test: current_playbook() returns correct playbook
216    // -----------------------------------------------------------------------
217
218    #[test]
219    fn test_current_playbook_returns_correct() {
220        let sg = make_strategy_group(HysteresisConfig {
221            min_hold_secs: 0,
222            confirmation_count: 1,
223        });
224        let mut detector = RegimeDetector::new(sg);
225
226        // Default regime is neutral
227        let pb = detector.current_playbook().unwrap();
228        assert_eq!(pb.system_prompt, "neutral prompt");
229
230        // Switch to bull (two calls needed for hysteresis)
231        let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
232        detector.detect(&ind, 1000);
233        detector.detect(&ind, 2000);
234
235        let pb = detector.current_playbook().unwrap();
236        assert_eq!(pb.system_prompt, "bull prompt");
237    }
238
239    // -----------------------------------------------------------------------
240    // Test: previous_regime() tracks the old regime after change
241    // -----------------------------------------------------------------------
242
243    #[test]
244    fn test_previous_regime_tracked() {
245        let sg = make_strategy_group(HysteresisConfig {
246            min_hold_secs: 0,
247            confirmation_count: 1,
248        });
249        let mut detector = RegimeDetector::new(sg);
250
251        assert!(detector.previous_regime().is_none());
252
253        // Switch neutral -> bull (two calls for hysteresis)
254        let ind = make_indicators(|i| i.rsi_14 = Some(80.0));
255        detector.detect(&ind, 1000);
256        detector.detect(&ind, 2000);
257
258        assert_eq!(detector.previous_regime(), Some("neutral"));
259        assert_eq!(detector.current_regime(), "bull");
260    }
261
262    // -----------------------------------------------------------------------
263    // Test: hysteresis prevents rapid switching
264    // -----------------------------------------------------------------------
265
266    #[test]
267    fn test_hysteresis_prevents_rapid_switch() {
268        // Require 3 confirmations
269        let sg = make_strategy_group(HysteresisConfig {
270            min_hold_secs: 0,
271            confirmation_count: 3,
272        });
273        let mut detector = RegimeDetector::new(sg);
274
275        let bull_ind = make_indicators(|i| i.rsi_14 = Some(80.0));
276
277        // First detect: pending switch starts (count=1), not yet switched
278        detector.detect(&bull_ind, 1000);
279        assert_eq!(detector.current_regime(), "neutral");
280        assert!(!detector.regime_changed());
281
282        // Second detect: count=2, still not switched
283        detector.detect(&bull_ind, 2000);
284        assert_eq!(detector.current_regime(), "neutral");
285        assert!(!detector.regime_changed());
286
287        // Third detect: count=3 >= 3, switch happens
288        detector.detect(&bull_ind, 3000);
289        assert_eq!(detector.current_regime(), "bull");
290        assert!(detector.regime_changed());
291        assert_eq!(detector.previous_regime(), Some("neutral"));
292    }
293
294    // -----------------------------------------------------------------------
295    // Test: min_hold_secs prevents premature regime change
296    // -----------------------------------------------------------------------
297
298    #[test]
299    fn test_min_hold_secs_prevents_change() {
300        let sg = make_strategy_group(HysteresisConfig {
301            min_hold_secs: 3600,
302            confirmation_count: 1,
303        });
304        let mut detector = RegimeDetector::new(sg);
305
306        let bull_ind = make_indicators(|i| i.rsi_14 = Some(80.0));
307
308        // Detect at t=100, regime since=0, held=100 < 3600 -> no switch
309        detector.detect(&bull_ind, 100);
310        assert_eq!(detector.current_regime(), "neutral");
311        assert!(!detector.regime_changed());
312
313        // Detect at t=5000, held=5000 > 3600 -> pending switch starts (count=1)
314        detector.detect(&bull_ind, 5000);
315        assert_eq!(detector.current_regime(), "neutral");
316        assert!(!detector.regime_changed());
317
318        // Detect at t=6000 -> count=2 >= 1 -> switch
319        detector.detect(&bull_ind, 6000);
320        assert_eq!(detector.current_regime(), "bull");
321        assert!(detector.regime_changed());
322    }
323
324    // -----------------------------------------------------------------------
325    // Test: strategy_group() accessor
326    // -----------------------------------------------------------------------
327
328    #[test]
329    fn test_strategy_group_accessor() {
330        let sg = make_strategy_group(HysteresisConfig {
331            min_hold_secs: 0,
332            confirmation_count: 1,
333        });
334        let detector = RegimeDetector::new(sg);
335        assert_eq!(detector.strategy_group().id, "sg-test");
336        assert_eq!(detector.strategy_group().symbol, "BTC-USD");
337    }
338}