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 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 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 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 #[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 #[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 detector.detect(&ind, 1000);
180 assert_eq!(detector.current_regime(), "neutral");
181
182 detector.detect(&ind, 2000);
184 assert_eq!(detector.current_regime(), "bull");
185 assert!(detector.regime_changed());
186 }
187
188 #[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 detector.detect(&ind, 1000);
204 detector.detect(&ind, 2000);
205 assert!(detector.regime_changed());
206 assert_eq!(detector.current_regime(), "bull");
207
208 detector.detect(&ind, 3000);
210 assert!(!detector.regime_changed());
211 assert_eq!(detector.current_regime(), "bull");
212 }
213
214 #[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 let pb = detector.current_playbook().unwrap();
228 assert_eq!(pb.system_prompt, "neutral prompt");
229
230 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 #[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 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 #[test]
267 fn test_hysteresis_prevents_rapid_switch() {
268 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 detector.detect(&bull_ind, 1000);
279 assert_eq!(detector.current_regime(), "neutral");
280 assert!(!detector.regime_changed());
281
282 detector.detect(&bull_ind, 2000);
284 assert_eq!(detector.current_regime(), "neutral");
285 assert!(!detector.regime_changed());
286
287 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 #[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 detector.detect(&bull_ind, 100);
310 assert_eq!(detector.current_regime(), "neutral");
311 assert!(!detector.regime_changed());
312
313 detector.detect(&bull_ind, 5000);
315 assert_eq!(detector.current_regime(), "neutral");
316 assert!(!detector.regime_changed());
317
318 detector.detect(&bull_ind, 6000);
320 assert_eq!(detector.current_regime(), "bull");
321 assert!(detector.regime_changed());
322 }
323
324 #[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}