1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::strategy_config::{HysteresisConfig, Playbook, RegimeRule, StrategyGroup, TaRule};
6
7#[derive(Serialize, Deserialize, Clone, Debug)]
12#[serde(rename_all = "camelCase")]
13pub struct StrategyTemplate {
14 pub id: String,
15 pub name: String,
16 pub description: String,
17 pub difficulty: String,
18 pub tags: Vec<String>,
19}
20
21const TEMPLATE_IDS: [&str; 22] = [
26 "adaptive_trend",
28 "pure_trend_following",
29 "mean_reversion_expert",
30 "volatility_hunter",
31 "conservative",
32 "macd_momentum",
34 "rsi_momentum",
35 "rsi_50_crossover",
36 "bollinger_bands",
38 "rsi_reversion",
39 "zscore_reversion",
40 "bb_confirmed",
41 "ma_crossover",
43 "supertrend",
44 "donchian_breakout",
45 "cta_trend_following",
46 "adx_di_crossover",
47 "chandelier_exit",
48 "atr_breakout",
50 "breakout_volume",
51 "keltner_squeeze",
52 "confluence",
54];
55
56fn template_meta(id: &str) -> Option<StrategyTemplate> {
57 match id {
58 "adaptive_trend" => Some(StrategyTemplate {
59 id: "adaptive_trend".into(),
60 name: "Adaptive Trend".into(),
61 description: "Regime-aware strategy that adapts to trending, ranging, and high-volatility markets. Recommended for beginners.".into(),
62 difficulty: "beginner".into(),
63 tags: vec!["trend".into(), "adaptive".into(), "recommended".into()],
64 }),
65 "pure_trend_following" => Some(StrategyTemplate {
66 id: "pure_trend_following".into(),
67 name: "Pure Trend Following".into(),
68 description: "Only operates in trending regimes. Sits out when the market is not trending.".into(),
69 difficulty: "intermediate".into(),
70 tags: vec!["trend".into(), "momentum".into()],
71 }),
72 "mean_reversion_expert" => Some(StrategyTemplate {
73 id: "mean_reversion_expert".into(),
74 name: "Mean Reversion Expert".into(),
75 description: "Operates exclusively in ranging markets using BB + RSI double confirmation.".into(),
76 difficulty: "intermediate".into(),
77 tags: vec!["mean_reversion".into(), "ranging".into()],
78 }),
79 "volatility_hunter" => Some(StrategyTemplate {
80 id: "volatility_hunter".into(),
81 name: "Volatility Hunter".into(),
82 description: "Focuses on high-vol to low-vol transitions and Bollinger Band squeeze breakouts.".into(),
83 difficulty: "advanced".into(),
84 tags: vec!["volatility".into(), "breakout".into()],
85 }),
86 "conservative" => Some(StrategyTemplate {
87 id: "conservative".into(),
88 name: "Conservative".into(),
89 description: "Small positions across all regimes with strict stop losses. Designed for risk-averse traders and newbies.".into(),
90 difficulty: "beginner".into(),
91 tags: vec!["conservative".into(), "low_risk".into()],
92 }),
93 "macd_momentum" => Some(StrategyTemplate {
95 id: "macd_momentum".into(),
96 name: "MACD Momentum".into(),
97 description: "MACD line crosses signal line for momentum entries. Simple and effective for trending assets.".into(),
98 difficulty: "beginner".into(),
99 tags: vec!["momentum".into(), "macd".into()],
100 }),
101 "rsi_momentum" => Some(StrategyTemplate {
102 id: "rsi_momentum".into(),
103 name: "RSI + MACD Momentum".into(),
104 description: "Combines RSI directional bias with MACD histogram confirmation for momentum trades.".into(),
105 difficulty: "beginner".into(),
106 tags: vec!["momentum".into(), "rsi".into(), "macd".into()],
107 }),
108 "rsi_50_crossover" => Some(StrategyTemplate {
109 id: "rsi_50_crossover".into(),
110 name: "RSI 50 Crossover".into(),
111 description: "RSI crossing the 50 midline with EMA trend confirmation for momentum shifts.".into(),
112 difficulty: "intermediate".into(),
113 tags: vec!["momentum".into(), "rsi".into(), "ema".into()],
114 }),
115 "bollinger_bands" => Some(StrategyTemplate {
117 id: "bollinger_bands".into(),
118 name: "Bollinger Band Reversion".into(),
119 description: "Buy at lower BB + RSI oversold, sell at upper BB + RSI overbought. ADX filter prevents trading in trends.".into(),
120 difficulty: "intermediate".into(),
121 tags: vec!["mean_reversion".into(), "bollinger".into(), "rsi".into()],
122 }),
123 "rsi_reversion" => Some(StrategyTemplate {
124 id: "rsi_reversion".into(),
125 name: "RSI Extreme Reversion".into(),
126 description: "Trades extreme RSI levels (25/75) for high-confidence mean reversion setups.".into(),
127 difficulty: "beginner".into(),
128 tags: vec!["mean_reversion".into(), "rsi".into()],
129 }),
130 "zscore_reversion" => Some(StrategyTemplate {
131 id: "zscore_reversion".into(),
132 name: "Z-Score Reversion".into(),
133 description: "Statistical mean reversion using price Z-score at +/- 2 standard deviations.".into(),
134 difficulty: "intermediate".into(),
135 tags: vec!["mean_reversion".into(), "zscore".into(), "statistical".into()],
136 }),
137 "bb_confirmed" => Some(StrategyTemplate {
138 id: "bb_confirmed".into(),
139 name: "Volume-Confirmed BB Bounce".into(),
140 description: "Bollinger Band bounce confirmed by RSI oversold AND volume spike for higher conviction entries.".into(),
141 difficulty: "advanced".into(),
142 tags: vec!["mean_reversion".into(), "bollinger".into(), "volume".into()],
143 }),
144 "ma_crossover" => Some(StrategyTemplate {
146 id: "ma_crossover".into(),
147 name: "MA Crossover + ADX".into(),
148 description: "EMA 12/26 crossover filtered by ADX > 20 for trend confirmation.".into(),
149 difficulty: "beginner".into(),
150 tags: vec!["trend_following".into(), "ema".into(), "adx".into()],
151 }),
152 "supertrend" => Some(StrategyTemplate {
153 id: "supertrend".into(),
154 name: "SuperTrend".into(),
155 description: "Follows SuperTrend indicator direction for clean trend entries and exits.".into(),
156 difficulty: "beginner".into(),
157 tags: vec!["trend_following".into(), "supertrend".into()],
158 }),
159 "donchian_breakout" => Some(StrategyTemplate {
160 id: "donchian_breakout".into(),
161 name: "Donchian Breakout".into(),
162 description: "Classic turtle-style breakout: enter on 20-period Donchian break, exit on 10-period channel.".into(),
163 difficulty: "intermediate".into(),
164 tags: vec!["trend_following".into(), "breakout".into(), "donchian".into()],
165 }),
166 "cta_trend_following" => Some(StrategyTemplate {
167 id: "cta_trend_following".into(),
168 name: "CTA Trend Following".into(),
169 description: "Institutional-style SMA 20/50 crossover with strong ADX > 25 filter.".into(),
170 difficulty: "intermediate".into(),
171 tags: vec!["trend_following".into(), "sma".into(), "cta".into()],
172 }),
173 "adx_di_crossover" => Some(StrategyTemplate {
174 id: "adx_di_crossover".into(),
175 name: "ADX +DI/-DI Crossover".into(),
176 description: "Trades directional index crossovers when ADX confirms trend strength.".into(),
177 difficulty: "intermediate".into(),
178 tags: vec!["trend_following".into(), "adx".into(), "directional".into()],
179 }),
180 "chandelier_exit" => Some(StrategyTemplate {
181 id: "chandelier_exit".into(),
182 name: "Chandelier Exit".into(),
183 description: "Trend entry above SMA 50 with ATR-based chandelier exit for trailing stops.".into(),
184 difficulty: "advanced".into(),
185 tags: vec!["trend_following".into(), "atr".into(), "trailing".into()],
186 }),
187 "atr_breakout" => Some(StrategyTemplate {
189 id: "atr_breakout".into(),
190 name: "ATR Breakout".into(),
191 description: "Enters on large ATR-multiple moves from previous close, capturing volatility expansion.".into(),
192 difficulty: "intermediate".into(),
193 tags: vec!["volatility".into(), "atr".into(), "breakout".into()],
194 }),
195 "breakout_volume" => Some(StrategyTemplate {
196 id: "breakout_volume".into(),
197 name: "Volume-Confirmed Breakout".into(),
198 description: "Donchian channel breakout confirmed by volume Z-score spike for higher conviction.".into(),
199 difficulty: "intermediate".into(),
200 tags: vec!["volatility".into(), "donchian".into(), "volume".into()],
201 }),
202 "keltner_squeeze" => Some(StrategyTemplate {
203 id: "keltner_squeeze".into(),
204 name: "Keltner Squeeze Breakout".into(),
205 description: "Detects BB inside KC (volatility squeeze) and trades the directional breakout.".into(),
206 difficulty: "advanced".into(),
207 tags: vec!["volatility".into(), "keltner".into(), "bollinger".into(), "squeeze".into()],
208 }),
209 "confluence" => Some(StrategyTemplate {
211 id: "confluence".into(),
212 name: "Triple Confluence".into(),
213 description: "Requires SuperTrend + RSI + ADX all agreeing for high-conviction directional entries.".into(),
214 difficulty: "advanced".into(),
215 tags: vec!["composite".into(), "confluence".into(), "supertrend".into(), "rsi".into()],
216 }),
217 _ => None,
218 }
219}
220
221pub fn build_template(template_id: &str, symbol: &str) -> Option<StrategyGroup> {
226 match template_id {
227 "adaptive_trend" => Some(build_adaptive_trend(symbol)),
228 "pure_trend_following" => Some(build_pure_trend_following(symbol)),
229 "mean_reversion_expert" => Some(build_mean_reversion_expert(symbol)),
230 "volatility_hunter" => Some(build_volatility_hunter(symbol)),
231 "conservative" => Some(build_conservative(symbol)),
232 "macd_momentum" => Some(build_macd_momentum(symbol)),
234 "rsi_momentum" => Some(build_rsi_momentum(symbol)),
235 "rsi_50_crossover" => Some(build_rsi_50_crossover(symbol)),
236 "bollinger_bands" => Some(build_bollinger_bands(symbol)),
238 "rsi_reversion" => Some(build_rsi_reversion(symbol)),
239 "zscore_reversion" => Some(build_zscore_reversion(symbol)),
240 "bb_confirmed" => Some(build_bb_confirmed(symbol)),
241 "ma_crossover" => Some(build_ma_crossover(symbol)),
243 "supertrend" => Some(build_supertrend(symbol)),
244 "donchian_breakout" => Some(build_donchian_breakout(symbol)),
245 "cta_trend_following" => Some(build_cta_trend_following(symbol)),
246 "adx_di_crossover" => Some(build_adx_di_crossover(symbol)),
247 "chandelier_exit" => Some(build_chandelier_exit(symbol)),
248 "atr_breakout" => Some(build_atr_breakout(symbol)),
250 "breakout_volume" => Some(build_breakout_volume(symbol)),
251 "keltner_squeeze" => Some(build_keltner_squeeze(symbol)),
252 "confluence" => Some(build_confluence(symbol)),
254 _ => None,
255 }
256}
257
258fn build_adaptive_trend(symbol: &str) -> StrategyGroup {
261 let regime_rules = vec![
262 RegimeRule {
263 regime: "trending_up".into(),
264 conditions: vec![
265 TaRule {
266 indicator: "ADX".into(),
267 params: vec![14.0],
268 condition: "gt".into(),
269 threshold: 25.0,
270 threshold_upper: None,
271 signal: "strong_trend".into(),
272 action: None,
273 },
274 TaRule {
275 indicator: "EMA".into(),
276 params: vec![12.0, 26.0],
277 condition: "gt".into(),
278 threshold: 0.0,
279 threshold_upper: None,
280 signal: "ema20_above_ema50".into(),
281 action: None,
282 },
283 ],
284 priority: 1,
285 },
286 RegimeRule {
287 regime: "trending_down".into(),
288 conditions: vec![
289 TaRule {
290 indicator: "ADX".into(),
291 params: vec![14.0],
292 condition: "gt".into(),
293 threshold: 25.0,
294 threshold_upper: None,
295 signal: "strong_trend".into(),
296 action: None,
297 },
298 TaRule {
299 indicator: "EMA".into(),
300 params: vec![12.0, 26.0],
301 condition: "lt".into(),
302 threshold: 0.0,
303 threshold_upper: None,
304 signal: "ema20_below_ema50".into(),
305 action: None,
306 },
307 ],
308 priority: 2,
309 },
310 RegimeRule {
311 regime: "high_vol".into(),
312 conditions: vec![TaRule {
313 indicator: "ATR".into(),
314 params: vec![14.0],
315 condition: "gt_pct".into(),
316 threshold: 5.0,
317 threshold_upper: None,
318 signal: "atr_pct_above_5".into(),
319 action: None,
320 }],
321 priority: 3,
322 },
323 RegimeRule {
324 regime: "ranging".into(),
325 conditions: vec![TaRule {
326 indicator: "ADX".into(),
327 params: vec![14.0],
328 condition: "lt".into(),
329 threshold: 20.0,
330 threshold_upper: None,
331 signal: "weak_trend".into(),
332 action: None,
333 }],
334 priority: 4,
335 },
336 ];
337
338 let mut playbooks = HashMap::new();
339 playbooks.insert(
340 "trending_up".into(),
341 Playbook {
342 rules: vec![],
343 entry_rules: vec![
344 TaRule { indicator: "EMA".into(), params: vec![20.0], condition: "price_above".into(), threshold: 0.0, threshold_upper: None, signal: "buy_dip_ema20".into(), action: Some("buy".into()) },
345 ],
346 exit_rules: vec![
347 TaRule { indicator: "EMA".into(), params: vec![20.0], condition: "price_below".into(), threshold: 0.0, threshold_upper: None, signal: "trend_broken".into(), action: Some("close".into()) },
348 ],
349 system_prompt: "You are a trend-following trading agent. The market is in an uptrend (ADX>25, EMA20>EMA50). Follow the trend — buy dips to EMA20, hold winners, and trail stops.".into(),
350 max_position_size: 1000.0,
351 stop_loss_pct: Some(5.0),
352 take_profit_pct: Some(15.0),
353 timeout_secs: Some(3600),
354 side: Some("long".into()),
355 },
356 );
357 playbooks.insert(
358 "trending_down".into(),
359 Playbook {
360 rules: vec![],
361 entry_rules: vec![
362 TaRule { indicator: "EMA".into(), params: vec![20.0], condition: "price_below".into(), threshold: 0.0, threshold_upper: None, signal: "sell_rally_ema20".into(), action: Some("sell".into()) },
363 ],
364 exit_rules: vec![
365 TaRule { indicator: "EMA".into(), params: vec![20.0], condition: "price_above".into(), threshold: 0.0, threshold_upper: None, signal: "downtrend_broken".into(), action: Some("close".into()) },
366 ],
367 system_prompt: "You are a conservative short-side agent. The market is in a downtrend (ADX>25, EMA20<EMA50). Use small positions for short trades at resistance, keep tight stops.".into(),
368 max_position_size: 500.0,
369 stop_loss_pct: Some(3.0),
370 take_profit_pct: Some(8.0),
371 timeout_secs: Some(3600),
372 side: Some("short".into()),
373 },
374 );
375 playbooks.insert(
376 "ranging".into(),
377 Playbook {
378 rules: vec![],
379 entry_rules: vec![
380 TaRule { indicator: "RSI".into(), params: vec![14.0], condition: "lt".into(), threshold: 35.0, threshold_upper: None, signal: "oversold_buy".into(), action: Some("buy".into()) },
381 TaRule { indicator: "RSI".into(), params: vec![14.0], condition: "gt".into(), threshold: 65.0, threshold_upper: None, signal: "overbought_sell".into(), action: Some("sell".into()) },
382 ],
383 exit_rules: vec![
384 TaRule { indicator: "RSI".into(), params: vec![14.0], condition: "between".into(), threshold: 40.0, threshold_upper: Some(60.0), signal: "rsi_neutral_exit".into(), action: Some("close".into()) },
385 ],
386 system_prompt: "You are a mean-reversion agent. The market is ranging (ADX<20). Buy oversold bounces, sell overbought reversals. Use BB and RSI for entries.".into(),
387 max_position_size: 800.0,
388 stop_loss_pct: Some(4.0),
389 take_profit_pct: Some(6.0),
390 timeout_secs: Some(3600),
391 side: None,
392 },
393 );
394 playbooks.insert(
395 "high_vol".into(),
396 Playbook {
397 rules: vec![],
398 system_prompt: "You are a risk management agent. Volatility is extremely high (ATR% > 5%). Do NOT open new positions. Close any existing positions if possible. Wait for volatility to subside.".into(),
399 max_position_size: 0.0,
400 stop_loss_pct: Some(2.0),
401 take_profit_pct: None,
402 entry_rules: vec![],
403 exit_rules: vec![],
404 timeout_secs: None,
405 side: None,
406 },
407 );
408
409 StrategyGroup {
410 id: format!("tpl-adaptive-trend-{}", uuid_stub()),
411 name: "Adaptive Trend".into(),
412 vault_address: None,
413 is_active: false,
414 created_at: now_iso(),
415 symbol: symbol.into(),
416 interval_secs: 300,
417 regime_rules,
418 default_regime: "ranging".into(),
419 hysteresis: HysteresisConfig::default(),
420 playbooks,
421 }
422}
423
424fn build_pure_trend_following(symbol: &str) -> StrategyGroup {
427 let regime_rules = vec![
433 RegimeRule {
434 regime: "trending_up".into(),
435 conditions: vec![TaRule {
436 indicator: "SUPERTREND_DIR".into(),
437 params: vec![],
438 condition: "gt".into(),
439 threshold: 0.0,
440 threshold_upper: None,
441 signal: "supertrend_bullish".into(),
442 action: None,
443 }],
444 priority: 1,
445 },
446 RegimeRule {
447 regime: "trending_down".into(),
448 conditions: vec![TaRule {
449 indicator: "SUPERTREND_DIR".into(),
450 params: vec![],
451 condition: "lt".into(),
452 threshold: 0.0,
453 threshold_upper: None,
454 signal: "supertrend_bearish".into(),
455 action: None,
456 }],
457 priority: 2,
458 },
459 ];
460
461 let mut playbooks = HashMap::new();
462 playbooks.insert(
463 "trending_up".into(),
464 Playbook {
465 rules: vec![],
466 entry_rules: vec![TaRule {
467 indicator: "MACD".into(),
468 params: vec![12.0, 26.0, 9.0],
469 condition: "gt".into(),
470 threshold: 0.0,
471 threshold_upper: None,
472 signal: "macd_bullish".into(), action: Some("buy".into()),
473 }],
474 exit_rules: vec![TaRule {
475 indicator: "MACD".into(),
476 params: vec![12.0, 26.0, 9.0],
477 condition: "lt".into(),
478 threshold: 0.0,
479 threshold_upper: None,
480 signal: "macd_bearish_exit".into(), action: Some("close".into()),
481 }],
482 system_prompt: "You are a pure trend-following agent. The market is trending up. Only take long positions in the direction of the trend. Use MACD for timing entries.".into(),
483 max_position_size: 1200.0,
484 stop_loss_pct: Some(5.0),
485 take_profit_pct: Some(20.0),
486 timeout_secs: Some(3600),
487 side: Some("long".into()),
488 },
489 );
490 playbooks.insert(
491 "trending_down".into(),
492 Playbook {
493 rules: vec![],
494 entry_rules: vec![TaRule {
495 indicator: "MACD".into(),
496 params: vec![12.0, 26.0, 9.0],
497 condition: "lt".into(),
498 threshold: 0.0,
499 threshold_upper: None,
500 signal: "macd_bearish".into(), action: Some("sell".into()),
501 }],
502 exit_rules: vec![TaRule {
503 indicator: "MACD".into(),
504 params: vec![12.0, 26.0, 9.0],
505 condition: "gt".into(),
506 threshold: 0.0,
507 threshold_upper: None,
508 signal: "macd_bullish_exit".into(), action: Some("close".into()),
509 }],
510 system_prompt: "You are a pure trend-following agent. The market is trending down. Only take short positions in the direction of the trend. Use MACD for timing entries.".into(),
511 max_position_size: 1200.0,
512 stop_loss_pct: Some(5.0),
513 take_profit_pct: Some(20.0),
514 timeout_secs: Some(3600),
515 side: Some("short".into()),
516 },
517 );
518 playbooks.insert(
519 "other".into(),
520 Playbook {
521 rules: vec![],
522 system_prompt: "No clear trend detected. Do NOT open any new positions. Wait for a trending regime to appear.".into(),
523 max_position_size: 0.0,
524 stop_loss_pct: None,
525 take_profit_pct: None,
526 entry_rules: vec![],
527 exit_rules: vec![],
528 timeout_secs: None,
529 side: None,
530 },
531 );
532
533 StrategyGroup {
534 id: format!("tpl-pure-trend-{}", uuid_stub()),
535 name: "Pure Trend Following".into(),
536 vault_address: None,
537 is_active: false,
538 created_at: now_iso(),
539 symbol: symbol.into(),
540 interval_secs: 300,
541 regime_rules,
542 default_regime: "other".into(),
543 hysteresis: HysteresisConfig {
544 min_hold_secs: 0,
545 confirmation_count: 1,
546 },
547 playbooks,
548 }
549}
550
551fn build_mean_reversion_expert(symbol: &str) -> StrategyGroup {
554 let regime_rules = vec![
555 RegimeRule {
556 regime: "ranging".into(),
557 conditions: vec![TaRule {
558 indicator: "ADX".into(),
559 params: vec![14.0],
560 condition: "lt".into(),
561 threshold: 20.0,
562 threshold_upper: None,
563 signal: "weak_trend".into(),
564 action: None,
565 }],
566 priority: 1,
567 },
568 RegimeRule {
569 regime: "trending".into(),
570 conditions: vec![TaRule {
571 indicator: "ADX".into(),
572 params: vec![14.0],
573 condition: "gt".into(),
574 threshold: 25.0,
575 threshold_upper: None,
576 signal: "strong_trend".into(),
577 action: None,
578 }],
579 priority: 2,
580 },
581 ];
582
583 let mut playbooks = HashMap::new();
584 playbooks.insert(
585 "ranging".into(),
586 Playbook {
587 rules: vec![],
588 entry_rules: vec![
589 TaRule {
590 indicator: "BB".into(),
591 params: vec![20.0, 2.0],
592 condition: "price_below_lower".into(),
593 threshold: 0.0,
594 threshold_upper: None,
595 signal: "below_lower_band".into(), action: Some("buy".into()),
596 },
597 TaRule {
598 indicator: "RSI".into(),
599 params: vec![14.0],
600 condition: "lt".into(),
601 threshold: 35.0,
602 threshold_upper: None,
603 signal: "rsi_oversold".into(), action: Some("buy".into()),
604 },
605 TaRule {
606 indicator: "BB".into(),
607 params: vec![20.0, 2.0],
608 condition: "price_above_upper".into(),
609 threshold: 0.0,
610 threshold_upper: None,
611 signal: "above_upper_band".into(), action: Some("sell".into()),
612 },
613 TaRule {
614 indicator: "RSI".into(),
615 params: vec![14.0],
616 condition: "gt".into(),
617 threshold: 65.0,
618 threshold_upper: None,
619 signal: "rsi_overbought".into(), action: Some("sell".into()),
620 },
621 ],
622 exit_rules: vec![
623 TaRule {
624 indicator: "RSI".into(),
625 params: vec![14.0],
626 condition: "between".into(),
627 threshold: 40.0,
628 threshold_upper: Some(60.0),
629 signal: "rsi_neutral_exit".into(), action: Some("close".into()),
630 },
631 ],
632 system_prompt: "You are a mean-reversion expert. The market is ranging (ADX<20). Use BB + RSI double confirmation: buy when price is below lower BB AND RSI<35; sell when price is above upper BB AND RSI>65. Target the BB middle line for exits.".into(),
633 max_position_size: 1000.0,
634 stop_loss_pct: Some(3.0),
635 take_profit_pct: Some(5.0),
636 timeout_secs: Some(3600),
637 side: None,
638 },
639 );
640 playbooks.insert(
641 "trending".into(),
642 Playbook {
643 rules: vec![],
644 system_prompt: "The market is trending (ADX>25). Mean reversion is dangerous in trending markets. Do NOT open new positions. Wait for ADX to drop below 20.".into(),
645 max_position_size: 0.0,
646 stop_loss_pct: None,
647 take_profit_pct: None,
648 entry_rules: vec![],
649 exit_rules: vec![],
650 timeout_secs: None,
651 side: None,
652 },
653 );
654
655 StrategyGroup {
656 id: format!("tpl-mean-reversion-{}", uuid_stub()),
657 name: "Mean Reversion Expert".into(),
658 vault_address: None,
659 is_active: false,
660 created_at: now_iso(),
661 symbol: symbol.into(),
662 interval_secs: 300,
663 regime_rules,
664 default_regime: "trending".into(),
665 hysteresis: HysteresisConfig {
666 min_hold_secs: 1800,
667 confirmation_count: 2,
668 },
669 playbooks,
670 }
671}
672
673fn build_volatility_hunter(symbol: &str) -> StrategyGroup {
676 let regime_rules = vec![
677 RegimeRule {
678 regime: "high_vol".into(),
679 conditions: vec![TaRule {
680 indicator: "ATR".into(),
681 params: vec![14.0],
682 condition: "gt_pct".into(),
683 threshold: 5.0,
684 threshold_upper: None,
685 signal: "atr_high".into(),
686 action: None,
687 }],
688 priority: 1,
689 },
690 RegimeRule {
691 regime: "squeeze".into(),
692 conditions: vec![TaRule {
693 indicator: "BB".into(),
694 params: vec![20.0, 2.0],
695 condition: "bandwidth_lt".into(),
696 threshold: 4.0,
697 threshold_upper: None,
698 signal: "bb_squeeze".into(),
699 action: None,
700 }],
701 priority: 2,
702 },
703 RegimeRule {
704 regime: "low_vol".into(),
705 conditions: vec![TaRule {
706 indicator: "ATR".into(),
707 params: vec![14.0],
708 condition: "lt_pct".into(),
709 threshold: 2.0,
710 threshold_upper: None,
711 signal: "atr_low".into(),
712 action: None,
713 }],
714 priority: 3,
715 },
716 ];
717
718 let mut playbooks = HashMap::new();
719 playbooks.insert(
720 "high_vol".into(),
721 Playbook {
722 rules: vec![],
723 entry_rules: vec![TaRule {
724 indicator: "ATR".into(),
725 params: vec![14.0],
726 condition: "decreasing".into(),
727 threshold: 0.0,
728 threshold_upper: None,
729 signal: "vol_contracting".into(), action: Some("buy".into()),
730 }],
731 exit_rules: vec![TaRule {
732 indicator: "ATR".into(),
733 params: vec![14.0],
734 condition: "increasing".into(),
735 threshold: 0.0,
736 threshold_upper: None,
737 signal: "vol_expanding_exit".into(), action: Some("close".into()),
738 }],
739 system_prompt: "You are a volatility hunter. Volatility is high. Watch for signs of contraction (ATR declining). Prepare to enter on the transition to lower volatility — this is where squeeze breakouts happen.".into(),
740 max_position_size: 500.0,
741 stop_loss_pct: Some(4.0),
742 take_profit_pct: Some(12.0),
743 timeout_secs: Some(3600),
744 side: Some("long".into()),
745 },
746 );
747 playbooks.insert(
748 "squeeze".into(),
749 Playbook {
750 rules: vec![],
751 entry_rules: vec![TaRule {
752 indicator: "BB".into(),
753 params: vec![20.0, 2.0],
754 condition: "breakout".into(),
755 threshold: 0.0,
756 threshold_upper: None,
757 signal: "bb_breakout".into(), action: Some("buy".into()),
758 }],
759 exit_rules: vec![TaRule {
760 indicator: "BB".into(),
761 params: vec![20.0, 2.0],
762 condition: "bandwidth_gt".into(),
763 threshold: 6.0,
764 threshold_upper: None,
765 signal: "bb_expansion_exit".into(), action: Some("close".into()),
766 }],
767 system_prompt: "You are a volatility hunter. Bollinger Bands are in a squeeze (bandwidth < 4%). Enter on the breakout direction — if price breaks above upper band go long, if below lower band go short. Use tight stop at the opposite band.".into(),
768 max_position_size: 1500.0,
769 stop_loss_pct: Some(3.0),
770 take_profit_pct: Some(10.0),
771 timeout_secs: Some(3600),
772 side: None,
773 },
774 );
775 playbooks.insert(
776 "low_vol".into(),
777 Playbook {
778 rules: vec![],
779 system_prompt: "Volatility is low and stable. No squeeze detected. Wait for volatility expansion or squeeze setup. Do not force trades.".into(),
780 max_position_size: 0.0,
781 stop_loss_pct: None,
782 take_profit_pct: None,
783 entry_rules: vec![],
784 exit_rules: vec![],
785 timeout_secs: None,
786 side: None,
787 },
788 );
789
790 StrategyGroup {
791 id: format!("tpl-vol-hunter-{}", uuid_stub()),
792 name: "Volatility Hunter".into(),
793 vault_address: None,
794 is_active: false,
795 created_at: now_iso(),
796 symbol: symbol.into(),
797 interval_secs: 300,
798 regime_rules,
799 default_regime: "low_vol".into(),
800 hysteresis: HysteresisConfig {
801 min_hold_secs: 1800,
802 confirmation_count: 2,
803 },
804 playbooks,
805 }
806}
807
808fn build_conservative(symbol: &str) -> StrategyGroup {
811 let regime_rules = vec![
812 RegimeRule {
813 regime: "trending_up".into(),
814 conditions: vec![TaRule {
815 indicator: "ADX".into(),
816 params: vec![14.0],
817 condition: "gt".into(),
818 threshold: 25.0,
819 threshold_upper: None,
820 signal: "strong_trend".into(),
821 action: None,
822 }],
823 priority: 1,
824 },
825 RegimeRule {
826 regime: "ranging".into(),
827 conditions: vec![TaRule {
828 indicator: "ADX".into(),
829 params: vec![14.0],
830 condition: "lt".into(),
831 threshold: 20.0,
832 threshold_upper: None,
833 signal: "weak_trend".into(),
834 action: None,
835 }],
836 priority: 2,
837 },
838 ];
839
840 let mut playbooks = HashMap::new();
841 playbooks.insert(
842 "trending_up".into(),
843 Playbook {
844 rules: vec![],
845 entry_rules: vec![TaRule {
846 indicator: "RSI".into(),
847 params: vec![14.0],
848 condition: "between".into(),
849 threshold: 40.0,
850 threshold_upper: Some(60.0),
851 signal: "rsi_neutral".into(), action: Some("buy".into()),
852 }],
853 exit_rules: vec![TaRule {
854 indicator: "RSI".into(),
855 params: vec![14.0],
856 condition: "gt".into(),
857 threshold: 70.0,
858 threshold_upper: None,
859 signal: "rsi_overbought_exit".into(), action: Some("close".into()),
860 }],
861 system_prompt: "You are a conservative trading agent. RISK CONTROL is your top priority. Use very small position sizes (max 2% of portfolio). Always set strict stop losses at 2%. Only take high-confidence setups. When in doubt, stay out. Prefer capital preservation over profit.".into(),
862 max_position_size: 200.0,
863 stop_loss_pct: Some(2.0),
864 take_profit_pct: Some(4.0),
865 timeout_secs: Some(3600),
866 side: Some("long".into()),
867 },
868 );
869 playbooks.insert(
870 "ranging".into(),
871 Playbook {
872 rules: vec![],
873 entry_rules: vec![TaRule {
874 indicator: "RSI".into(),
875 params: vec![14.0],
876 condition: "between".into(),
877 threshold: 35.0,
878 threshold_upper: Some(65.0),
879 signal: "rsi_safe_zone".into(), action: Some("buy".into()),
880 }],
881 exit_rules: vec![TaRule {
882 indicator: "RSI".into(),
883 params: vec![14.0],
884 condition: "gt".into(),
885 threshold: 65.0,
886 threshold_upper: None,
887 signal: "rsi_exit_zone".into(), action: Some("close".into()),
888 }],
889 system_prompt: "You are a conservative trading agent in a ranging market. RISK CONTROL is your top priority. Use minimal position sizes. Only trade clear support/resistance bounces with RSI confirmation. Set 2% stop loss on every trade. Skip any trade you are not 90% confident about.".into(),
890 max_position_size: 150.0,
891 stop_loss_pct: Some(2.0),
892 take_profit_pct: Some(3.0),
893 timeout_secs: Some(3600),
894 side: Some("long".into()),
895 },
896 );
897 playbooks.insert(
898 "default".into(),
899 Playbook {
900 rules: vec![],
901 system_prompt: "You are a conservative trading agent. Market regime is unclear. Do NOT open new positions. Protect existing capital. Set tight stops on any open positions. Wait for a clearer setup.".into(),
902 max_position_size: 100.0,
903 stop_loss_pct: Some(2.0),
904 take_profit_pct: None,
905 entry_rules: vec![],
906 exit_rules: vec![],
907 timeout_secs: None,
908 side: None,
909 },
910 );
911
912 StrategyGroup {
913 id: format!("tpl-conservative-{}", uuid_stub()),
914 name: "Conservative".into(),
915 vault_address: None,
916 is_active: false,
917 created_at: now_iso(),
918 symbol: symbol.into(),
919 interval_secs: 600,
920 regime_rules,
921 default_regime: "default".into(),
922 hysteresis: HysteresisConfig {
923 min_hold_secs: 7200,
924 confirmation_count: 4,
925 },
926 playbooks,
927 }
928}
929
930fn build_simple_strategy(
937 id_prefix: &str,
938 name: &str,
939 symbol: &str,
940 regime_rules: Vec<RegimeRule>,
941 default_regime: &str,
942 active_playbook_name: &str,
943 active_playbook: Playbook,
944 interval_secs: u64,
945) -> StrategyGroup {
946 let mut playbooks = HashMap::new();
947 playbooks.insert(active_playbook_name.into(), active_playbook);
948 playbooks.insert(
949 "inactive".into(),
950 Playbook {
951 rules: vec![],
952 system_prompt: format!(
953 "Market conditions are not suitable for the {} strategy. Do NOT open new positions. Wait for the right regime.",
954 name
955 ),
956 max_position_size: 0.0,
957 stop_loss_pct: None,
958 take_profit_pct: None,
959 entry_rules: vec![],
960 exit_rules: vec![],
961 timeout_secs: None,
962 side: None,
963 },
964 );
965 StrategyGroup {
966 id: format!("tpl-{}-{}", id_prefix, uuid_stub()),
967 name: name.into(),
968 vault_address: None,
969 is_active: false,
970 created_at: now_iso(),
971 symbol: symbol.into(),
972 interval_secs,
973 regime_rules,
974 default_regime: default_regime.into(),
975 hysteresis: HysteresisConfig::default(),
976 playbooks,
977 }
978}
979
980fn adx_regime_filter() -> Vec<RegimeRule> {
982 vec![
983 RegimeRule {
984 regime: "ranging".into(),
985 conditions: vec![TaRule {
986 indicator: "ADX".into(),
987 params: vec![14.0],
988 condition: "lt".into(),
989 threshold: 25.0,
990 threshold_upper: None,
991 signal: "adx_low_trend".into(),
992 action: None,
993 }],
994 priority: 1,
995 },
996 RegimeRule {
997 regime: "trending".into(),
998 conditions: vec![TaRule {
999 indicator: "ADX".into(),
1000 params: vec![14.0],
1001 condition: "gte".into(),
1002 threshold: 25.0,
1003 threshold_upper: None,
1004 signal: "adx_strong_trend".into(),
1005 action: None,
1006 }],
1007 priority: 2,
1008 },
1009 ]
1010}
1011
1012fn build_macd_momentum(symbol: &str) -> StrategyGroup {
1015 let mut playbooks = HashMap::new();
1016 playbooks.insert(
1017 "active".into(),
1018 Playbook {
1019 rules: vec![],
1020 entry_rules: vec![
1021 TaRule {
1022 indicator: "MACD_HISTOGRAM".into(),
1023 params: vec![],
1024 condition: "gt".into(),
1025 threshold: 0.0,
1026 threshold_upper: None,
1027 signal: "macd_hist_positive_buy".into(), action: Some("buy".into()),
1028 },
1029 TaRule {
1030 indicator: "MACD_HISTOGRAM".into(),
1031 params: vec![],
1032 condition: "lt".into(),
1033 threshold: 0.0,
1034 threshold_upper: None,
1035 signal: "macd_hist_negative_sell".into(), action: Some("sell".into()),
1036 },
1037 ],
1038 exit_rules: vec![
1039 TaRule {
1040 indicator: "MACD_HISTOGRAM".into(),
1041 params: vec![],
1042 condition: "lt".into(),
1043 threshold: 0.0,
1044 threshold_upper: None,
1045 signal: "macd_hist_negative_exit".into(), action: Some("close".into()),
1046 },
1047 TaRule {
1048 indicator: "MACD_HISTOGRAM".into(),
1049 params: vec![],
1050 condition: "gt".into(),
1051 threshold: 0.0,
1052 threshold_upper: None,
1053 signal: "macd_hist_positive_exit".into(), action: Some("close".into()),
1054 },
1055 ],
1056 system_prompt: "You are a MACD momentum agent. Go LONG when MACD histogram is positive (macd_hist_positive_buy). Go SHORT when histogram is negative (macd_hist_negative_sell). Exit longs on macd_hist_negative_exit, exit shorts on macd_hist_positive_exit. ATR 1.5x stop loss, ATR 3.0x take profit.".into(),
1057 max_position_size: 1000.0,
1058 stop_loss_pct: Some(4.0),
1059 take_profit_pct: Some(8.0),
1060 timeout_secs: Some(3600),
1061 side: None,
1062 },
1063 );
1064 playbooks.insert(
1065 "inactive".into(),
1066 Playbook {
1067 rules: vec![],
1068 system_prompt: "No clear momentum setup. Do NOT open new positions.".into(),
1069 max_position_size: 0.0,
1070 stop_loss_pct: None,
1071 take_profit_pct: None,
1072 entry_rules: vec![],
1073 exit_rules: vec![],
1074 timeout_secs: None,
1075 side: None,
1076 },
1077 );
1078 StrategyGroup {
1079 id: format!("tpl-macd-mom-{}", uuid_stub()),
1080 name: "MACD Momentum".into(),
1081 vault_address: None,
1082 is_active: false,
1083 created_at: now_iso(),
1084 symbol: symbol.into(),
1085 interval_secs: 300,
1086 regime_rules: vec![],
1087 default_regime: "active".into(),
1088 hysteresis: HysteresisConfig::default(),
1089 playbooks,
1090 }
1091}
1092
1093fn build_rsi_momentum(symbol: &str) -> StrategyGroup {
1094 let mut playbooks = HashMap::new();
1095 playbooks.insert(
1096 "active".into(),
1097 Playbook {
1098 rules: vec![],
1099 entry_rules: vec![
1100 TaRule {
1101 indicator: "RSI".into(),
1102 params: vec![14.0],
1103 condition: "gt".into(),
1104 threshold: 60.0,
1105 threshold_upper: None,
1106 signal: "rsi_bullish_momentum".into(), action: Some("buy".into()),
1107 },
1108 TaRule {
1109 indicator: "MACD_HISTOGRAM".into(),
1110 params: vec![],
1111 condition: "gt".into(),
1112 threshold: 0.0,
1113 threshold_upper: None,
1114 signal: "macd_hist_positive".into(), action: Some("buy".into()),
1115 },
1116 TaRule {
1117 indicator: "RSI".into(),
1118 params: vec![14.0],
1119 condition: "lt".into(),
1120 threshold: 40.0,
1121 threshold_upper: None,
1122 signal: "rsi_bearish_momentum".into(), action: Some("sell".into()),
1123 },
1124 TaRule {
1125 indicator: "MACD_HISTOGRAM".into(),
1126 params: vec![],
1127 condition: "lt".into(),
1128 threshold: 0.0,
1129 threshold_upper: None,
1130 signal: "macd_hist_negative".into(), action: Some("sell".into()),
1131 },
1132 ],
1133 exit_rules: vec![
1134 TaRule {
1135 indicator: "RSI".into(),
1136 params: vec![14.0],
1137 condition: "lt".into(),
1138 threshold: 50.0,
1139 threshold_upper: None,
1140 signal: "rsi_momentum_exit_long".into(), action: Some("close".into()),
1141 },
1142 TaRule {
1143 indicator: "RSI".into(),
1144 params: vec![14.0],
1145 condition: "gt".into(),
1146 threshold: 50.0,
1147 threshold_upper: None,
1148 signal: "rsi_momentum_exit_short".into(), action: Some("close".into()),
1149 },
1150 ],
1151 system_prompt: "You are an RSI + MACD momentum agent. Go LONG when RSI > 60 AND MACD histogram > 0 (both rsi_bullish_momentum and macd_hist_positive triggered). Go SHORT when RSI < 40 AND MACD histogram < 0 (both rsi_bearish_momentum and macd_hist_negative triggered). Exit longs when rsi_momentum_exit_long fires. Exit shorts when rsi_momentum_exit_short fires. ATR 1.5x SL / 3.0x TP.".into(),
1152 max_position_size: 1000.0,
1153 stop_loss_pct: Some(4.0),
1154 take_profit_pct: Some(8.0),
1155 timeout_secs: Some(3600),
1156 side: None,
1157 },
1158 );
1159 playbooks.insert(
1160 "inactive".into(),
1161 Playbook {
1162 rules: vec![],
1163 system_prompt: "No momentum setup. Do NOT trade.".into(),
1164 max_position_size: 0.0,
1165 stop_loss_pct: None,
1166 take_profit_pct: None,
1167 entry_rules: vec![],
1168 exit_rules: vec![],
1169 timeout_secs: None,
1170 side: None,
1171 },
1172 );
1173 StrategyGroup {
1174 id: format!("tpl-rsi-mom-{}", uuid_stub()),
1175 name: "RSI + MACD Momentum".into(),
1176 vault_address: None,
1177 is_active: false,
1178 created_at: now_iso(),
1179 symbol: symbol.into(),
1180 interval_secs: 300,
1181 regime_rules: vec![],
1182 default_regime: "active".into(),
1183 hysteresis: HysteresisConfig::default(),
1184 playbooks,
1185 }
1186}
1187
1188fn build_rsi_50_crossover(symbol: &str) -> StrategyGroup {
1189 let mut playbooks = HashMap::new();
1190 playbooks.insert(
1191 "active".into(),
1192 Playbook {
1193 rules: vec![],
1194 entry_rules: vec![
1195 TaRule {
1196 indicator: "RSI".into(),
1197 params: vec![14.0],
1198 condition: "cross_above".into(),
1199 threshold: 50.0,
1200 threshold_upper: None,
1201 signal: "rsi_cross_above_50".into(), action: Some("buy".into()),
1202 },
1203 TaRule {
1204 indicator: "EMA".into(),
1205 params: vec![12.0, 26.0],
1206 condition: "cross_above".into(),
1207 threshold: 0.0,
1208 threshold_upper: None,
1209 signal: "ema12_above_ema26".into(), action: Some("buy".into()),
1210 },
1211 TaRule {
1212 indicator: "RSI".into(),
1213 params: vec![14.0],
1214 condition: "cross_below".into(),
1215 threshold: 50.0,
1216 threshold_upper: None,
1217 signal: "rsi_cross_below_50".into(), action: Some("sell".into()),
1218 },
1219 TaRule {
1220 indicator: "EMA".into(),
1221 params: vec![12.0, 26.0],
1222 condition: "cross_below".into(),
1223 threshold: 0.0,
1224 threshold_upper: None,
1225 signal: "ema12_below_ema26".into(), action: Some("sell".into()),
1226 },
1227 ],
1228 exit_rules: vec![
1229 TaRule {
1230 indicator: "RSI".into(),
1231 params: vec![14.0],
1232 condition: "between".into(),
1233 threshold: 45.0,
1234 threshold_upper: Some(55.0),
1235 signal: "rsi_neutral_exit".into(), action: Some("close".into()),
1236 },
1237 ],
1238 system_prompt: "You are an RSI 50 crossover agent. Go LONG when RSI crosses above 50 (rsi_cross_above_50) AND EMA 12 > EMA 26 (ema12_above_ema26). Go SHORT when RSI crosses below 50 (rsi_cross_below_50) AND EMA 12 < EMA 26 (ema12_below_ema26). Exit when RSI returns to 45-55 neutral zone (rsi_neutral_exit). ATR 1.5x SL / 3.0x TP.".into(),
1239 max_position_size: 1000.0,
1240 stop_loss_pct: Some(4.0),
1241 take_profit_pct: Some(8.0),
1242 timeout_secs: Some(3600),
1243 side: None,
1244 },
1245 );
1246 playbooks.insert(
1247 "inactive".into(),
1248 Playbook {
1249 rules: vec![],
1250 system_prompt: "No momentum crossover setup. Do NOT trade.".into(),
1251 max_position_size: 0.0,
1252 stop_loss_pct: None,
1253 take_profit_pct: None,
1254 entry_rules: vec![],
1255 exit_rules: vec![],
1256 timeout_secs: None,
1257 side: None,
1258 },
1259 );
1260 StrategyGroup {
1261 id: format!("tpl-rsi50-{}", uuid_stub()),
1262 name: "RSI 50 Crossover".into(),
1263 vault_address: None,
1264 is_active: false,
1265 created_at: now_iso(),
1266 symbol: symbol.into(),
1267 interval_secs: 300,
1268 regime_rules: vec![],
1269 default_regime: "active".into(),
1270 hysteresis: HysteresisConfig::default(),
1271 playbooks,
1272 }
1273}
1274
1275fn build_bollinger_bands(symbol: &str) -> StrategyGroup {
1278 build_simple_strategy(
1279 "bb-rev",
1280 "Bollinger Band Reversion",
1281 symbol,
1282 adx_regime_filter(),
1283 "trending",
1284 "ranging",
1285 Playbook {
1286 rules: vec![],
1287 entry_rules: vec![
1288 TaRule {
1289 indicator: "BB_LOWER".into(),
1290 params: vec![20.0, 2.0],
1291 condition: "gt".into(),
1292 threshold: 0.0,
1293 threshold_upper: None,
1294 signal: "close_below_bb_lower".into(), action: Some("buy".into()),
1295 },
1296 TaRule {
1297 indicator: "RSI".into(),
1298 params: vec![14.0],
1299 condition: "lt".into(),
1300 threshold: 35.0,
1301 threshold_upper: None,
1302 signal: "rsi_oversold".into(), action: Some("buy".into()),
1303 },
1304 TaRule {
1305 indicator: "BB_UPPER".into(),
1306 params: vec![20.0, 2.0],
1307 condition: "lt".into(),
1308 threshold: 0.0,
1309 threshold_upper: None,
1310 signal: "close_above_bb_upper".into(), action: Some("sell".into()),
1311 },
1312 TaRule {
1313 indicator: "RSI".into(),
1314 params: vec![14.0],
1315 condition: "gt".into(),
1316 threshold: 65.0,
1317 threshold_upper: None,
1318 signal: "rsi_overbought".into(), action: Some("sell".into()),
1319 },
1320 ],
1321 exit_rules: vec![
1322 TaRule {
1323 indicator: "RSI".into(),
1324 params: vec![14.0],
1325 condition: "between".into(),
1326 threshold: 40.0,
1327 threshold_upper: Some(60.0),
1328 signal: "rsi_neutral_exit".into(), action: Some("close".into()),
1329 },
1330 ],
1331 system_prompt: "You are a Bollinger Band mean reversion agent (ADX-filtered). LONG when close is below lower BB AND RSI < 35 (close_below_bb_lower + rsi_oversold). SHORT when close is above upper BB AND RSI > 65 (close_above_bb_upper + rsi_overbought). Exit at BB middle / RSI neutral. ATR 1.0x SL, 1.5x TP. Only active in ranging markets (ADX < 25).".into(),
1332 max_position_size: 800.0,
1333 stop_loss_pct: Some(3.0),
1334 take_profit_pct: Some(5.0),
1335 timeout_secs: Some(3600),
1336 side: None,
1337 },
1338 300,
1339 )
1340}
1341
1342fn build_rsi_reversion(symbol: &str) -> StrategyGroup {
1343 build_simple_strategy(
1344 "rsi-rev",
1345 "RSI Extreme Reversion",
1346 symbol,
1347 adx_regime_filter(),
1348 "trending",
1349 "ranging",
1350 Playbook {
1351 rules: vec![],
1352 entry_rules: vec![
1353 TaRule {
1354 indicator: "RSI".into(),
1355 params: vec![14.0],
1356 condition: "lt".into(),
1357 threshold: 25.0,
1358 threshold_upper: None,
1359 signal: "rsi_extreme_oversold".into(), action: Some("buy".into()),
1360 },
1361 TaRule {
1362 indicator: "RSI".into(),
1363 params: vec![14.0],
1364 condition: "gt".into(),
1365 threshold: 75.0,
1366 threshold_upper: None,
1367 signal: "rsi_extreme_overbought".into(), action: Some("sell".into()),
1368 },
1369 ],
1370 exit_rules: vec![
1371 TaRule {
1372 indicator: "RSI".into(),
1373 params: vec![14.0],
1374 condition: "between".into(),
1375 threshold: 40.0,
1376 threshold_upper: Some(60.0),
1377 signal: "rsi_mean_exit".into(), action: Some("close".into()),
1378 },
1379 ],
1380 system_prompt: "You are an RSI extreme reversion agent (ADX-filtered). LONG on extreme oversold RSI < 25 (rsi_extreme_oversold). SHORT on extreme overbought RSI > 75 (rsi_extreme_overbought). Exit when RSI returns to 40-60 (rsi_mean_exit). ATR 1.0x SL, 1.5x TP. Only trade in ranging markets.".into(),
1381 max_position_size: 800.0,
1382 stop_loss_pct: Some(3.0),
1383 take_profit_pct: Some(5.0),
1384 timeout_secs: Some(3600),
1385 side: None,
1386 },
1387 300,
1388 )
1389}
1390
1391fn build_zscore_reversion(symbol: &str) -> StrategyGroup {
1392 build_simple_strategy(
1393 "zscore-rev",
1394 "Z-Score Reversion",
1395 symbol,
1396 adx_regime_filter(),
1397 "trending",
1398 "ranging",
1399 Playbook {
1400 rules: vec![],
1401 entry_rules: vec![
1402 TaRule {
1403 indicator: "ZSCORE".into(),
1404 params: vec![20.0],
1405 condition: "lt".into(),
1406 threshold: -2.0,
1407 threshold_upper: None,
1408 signal: "zscore_oversold".into(), action: Some("buy".into()),
1409 },
1410 TaRule {
1411 indicator: "ZSCORE".into(),
1412 params: vec![20.0],
1413 condition: "gt".into(),
1414 threshold: 2.0,
1415 threshold_upper: None,
1416 signal: "zscore_overbought".into(), action: Some("sell".into()),
1417 },
1418 ],
1419 exit_rules: vec![
1420 TaRule {
1421 indicator: "ZSCORE".into(),
1422 params: vec![20.0],
1423 condition: "between".into(),
1424 threshold: -0.5,
1425 threshold_upper: Some(0.5),
1426 signal: "zscore_mean_exit".into(), action: Some("close".into()),
1427 },
1428 ],
1429 system_prompt: "You are a Z-score mean reversion agent (ADX-filtered). LONG when Z-score < -2.0 (zscore_oversold). SHORT when Z-score > 2.0 (zscore_overbought). Exit when Z-score returns to -0.5 to 0.5 (zscore_mean_exit). ATR 1.0x SL, 1.5x TP. Only active in ranging markets.".into(),
1430 max_position_size: 800.0,
1431 stop_loss_pct: Some(3.0),
1432 take_profit_pct: Some(5.0),
1433 timeout_secs: Some(3600),
1434 side: None,
1435 },
1436 300,
1437 )
1438}
1439
1440fn build_bb_confirmed(symbol: &str) -> StrategyGroup {
1441 build_simple_strategy(
1442 "bb-conf",
1443 "Volume-Confirmed BB Bounce",
1444 symbol,
1445 adx_regime_filter(),
1446 "trending",
1447 "ranging",
1448 Playbook {
1449 rules: vec![],
1450 entry_rules: vec![
1451 TaRule {
1452 indicator: "BB_LOWER".into(),
1453 params: vec![20.0, 2.0],
1454 condition: "gt".into(),
1455 threshold: 0.0,
1456 threshold_upper: None,
1457 signal: "close_below_bb_lower".into(), action: Some("buy".into()),
1458 },
1459 TaRule {
1460 indicator: "RSI".into(),
1461 params: vec![14.0],
1462 condition: "lt".into(),
1463 threshold: 35.0,
1464 threshold_upper: None,
1465 signal: "rsi_oversold_moderate".into(), action: Some("buy".into()),
1466 },
1467 TaRule {
1468 indicator: "VOL_ZSCORE".into(),
1469 params: vec![20.0],
1470 condition: "gt".into(),
1471 threshold: 1.5,
1472 threshold_upper: None,
1473 signal: "volume_spike".into(), action: Some("buy".into()),
1474 },
1475 ],
1476 exit_rules: vec![
1477 TaRule {
1478 indicator: "RSI".into(),
1479 params: vec![14.0],
1480 condition: "gt".into(),
1481 threshold: 50.0,
1482 threshold_upper: None,
1483 signal: "rsi_above_50_exit".into(), action: Some("close".into()),
1484 },
1485 TaRule {
1486 indicator: "BB_UPPER".into(),
1487 params: vec![20.0, 2.0],
1488 condition: "lt".into(),
1489 threshold: 0.0,
1490 threshold_upper: None,
1491 signal: "price_above_bb_middle_exit".into(), action: Some("close".into()),
1492 },
1493 ],
1494 system_prompt: "You are a volume-confirmed BB bounce agent (ADX-filtered). LONG only when ALL three conditions are met: close below lower BB (close_below_bb_lower) + RSI < 35 (rsi_oversold_moderate) + volume Z-score > 1.5 (volume_spike). This is a long-only strategy. Exit when RSI > 50 or price above BB middle. ATR 1.0x SL, 1.5x TP. Only trade in ranging markets.".into(),
1495 max_position_size: 1000.0,
1496 stop_loss_pct: Some(3.0),
1497 take_profit_pct: Some(5.0),
1498 timeout_secs: Some(3600),
1499 side: Some("long".into()),
1500 },
1501 300,
1502 )
1503}
1504
1505fn build_ma_crossover(symbol: &str) -> StrategyGroup {
1508 let regime_rules = vec![
1509 RegimeRule {
1510 regime: "trending".into(),
1511 conditions: vec![TaRule {
1512 indicator: "ADX".into(),
1513 params: vec![14.0],
1514 condition: "gt".into(),
1515 threshold: 20.0,
1516 threshold_upper: None,
1517 signal: "adx_trending".into(),
1518 action: None,
1519 }],
1520 priority: 1,
1521 },
1522 RegimeRule {
1523 regime: "ranging".into(),
1524 conditions: vec![TaRule {
1525 indicator: "ADX".into(),
1526 params: vec![14.0],
1527 condition: "lte".into(),
1528 threshold: 20.0,
1529 threshold_upper: None,
1530 signal: "adx_not_trending".into(),
1531 action: None,
1532 }],
1533 priority: 2,
1534 },
1535 ];
1536
1537 build_simple_strategy(
1538 "ma-cross",
1539 "MA Crossover + ADX",
1540 symbol,
1541 regime_rules,
1542 "ranging",
1543 "trending",
1544 Playbook {
1545 rules: vec![],
1546 entry_rules: vec![
1547 TaRule {
1548 indicator: "EMA".into(),
1549 params: vec![12.0, 26.0],
1550 condition: "cross_above".into(),
1551 threshold: 0.0,
1552 threshold_upper: None,
1553 signal: "ema_golden_cross".into(), action: Some("buy".into()),
1554 },
1555 TaRule {
1556 indicator: "EMA".into(),
1557 params: vec![12.0, 26.0],
1558 condition: "cross_below".into(),
1559 threshold: 0.0,
1560 threshold_upper: None,
1561 signal: "ema_death_cross".into(), action: Some("sell".into()),
1562 },
1563 ],
1564 exit_rules: vec![
1565 TaRule {
1566 indicator: "EMA".into(),
1567 params: vec![12.0, 26.0],
1568 condition: "cross_below".into(),
1569 threshold: 0.0,
1570 threshold_upper: None,
1571 signal: "ema_exit_long".into(), action: Some("close".into()),
1572 },
1573 TaRule {
1574 indicator: "EMA".into(),
1575 params: vec![12.0, 26.0],
1576 condition: "cross_above".into(),
1577 threshold: 0.0,
1578 threshold_upper: None,
1579 signal: "ema_exit_short".into(), action: Some("close".into()),
1580 },
1581 ],
1582 system_prompt: "You are an MA crossover trend agent. Only active when ADX > 20. LONG on EMA 12/26 golden cross (ema_golden_cross). SHORT on EMA 12/26 death cross (ema_death_cross). Exit longs on ema_exit_long, shorts on ema_exit_short. ATR 2.0x SL, 4.0x TP with trailing stop.".into(),
1583 max_position_size: 1200.0,
1584 stop_loss_pct: Some(5.0),
1585 take_profit_pct: Some(15.0),
1586 timeout_secs: Some(3600),
1587 side: None,
1588 },
1589 300,
1590 )
1591}
1592
1593fn build_supertrend(symbol: &str) -> StrategyGroup {
1594 let mut playbooks = HashMap::new();
1595 playbooks.insert(
1596 "active".into(),
1597 Playbook {
1598 rules: vec![],
1599 entry_rules: vec![
1600 TaRule {
1601 indicator: "SUPERTREND_DIR".into(),
1602 params: vec![10.0, 3.0],
1603 condition: "gt".into(),
1604 threshold: 0.0,
1605 threshold_upper: None,
1606 signal: "supertrend_bullish".into(), action: Some("buy".into()),
1607 },
1608 ],
1609 exit_rules: vec![
1610 TaRule {
1611 indicator: "SUPERTREND_DIR".into(),
1612 params: vec![10.0, 3.0],
1613 condition: "lt".into(),
1614 threshold: 0.0,
1615 threshold_upper: None,
1616 signal: "supertrend_bearish".into(), action: Some("close".into()),
1617 },
1618 ],
1619 system_prompt: "You are a SuperTrend agent. LONG when SuperTrend direction is bullish (supertrend_bullish, value > 0). Exit when bearish (supertrend_bearish, value < 0). The SuperTrend itself acts as a trailing stop. ATR 2.0x SL, 4.0x TP.".into(),
1620 max_position_size: 1200.0,
1621 stop_loss_pct: Some(5.0),
1622 take_profit_pct: Some(15.0),
1623 timeout_secs: Some(3600),
1624 side: Some("long".into()),
1625 },
1626 );
1627 playbooks.insert(
1628 "inactive".into(),
1629 Playbook {
1630 rules: vec![],
1631 system_prompt: "No clear SuperTrend signal. Wait.".into(),
1632 max_position_size: 0.0,
1633 stop_loss_pct: None,
1634 take_profit_pct: None,
1635 entry_rules: vec![],
1636 exit_rules: vec![],
1637 timeout_secs: None,
1638 side: None,
1639 },
1640 );
1641 StrategyGroup {
1642 id: format!("tpl-supertrend-{}", uuid_stub()),
1643 name: "SuperTrend".into(),
1644 vault_address: None,
1645 is_active: false,
1646 created_at: now_iso(),
1647 symbol: symbol.into(),
1648 interval_secs: 300,
1649 regime_rules: vec![],
1650 default_regime: "active".into(),
1651 hysteresis: HysteresisConfig::default(),
1652 playbooks,
1653 }
1654}
1655
1656fn build_donchian_breakout(symbol: &str) -> StrategyGroup {
1657 let mut playbooks = HashMap::new();
1658 playbooks.insert(
1659 "active".into(),
1660 Playbook {
1661 rules: vec![],
1662 entry_rules: vec![
1663 TaRule {
1664 indicator: "DONCHIAN_UPPER".into(),
1665 params: vec![20.0],
1666 condition: "lt".into(),
1667 threshold: 0.0,
1668 threshold_upper: None,
1669 signal: "donchian_breakout_up".into(), action: Some("buy".into()),
1670 },
1671 ],
1672 exit_rules: vec![
1673 TaRule {
1674 indicator: "DONCHIAN_LOWER".into(),
1675 params: vec![10.0],
1676 condition: "gt".into(),
1677 threshold: 0.0,
1678 threshold_upper: None,
1679 signal: "donchian_exit_long".into(), action: Some("close".into()),
1680 },
1681 ],
1682 system_prompt: "You are a Donchian breakout (turtle) agent. LONG when close breaks above 20-period Donchian upper channel (donchian_breakout_up). Exit longs at 10-period lower channel (donchian_exit_long). ATR 2.0x SL, 4.0x TP.".into(),
1683 max_position_size: 1200.0,
1684 stop_loss_pct: Some(5.0),
1685 take_profit_pct: Some(15.0),
1686 timeout_secs: Some(3600),
1687 side: Some("long".into()),
1688 },
1689 );
1690 playbooks.insert(
1691 "inactive".into(),
1692 Playbook {
1693 rules: vec![],
1694 system_prompt: "No Donchian breakout. Wait.".into(),
1695 max_position_size: 0.0,
1696 stop_loss_pct: None,
1697 take_profit_pct: None,
1698 entry_rules: vec![],
1699 exit_rules: vec![],
1700 timeout_secs: None,
1701 side: None,
1702 },
1703 );
1704 StrategyGroup {
1705 id: format!("tpl-donchian-{}", uuid_stub()),
1706 name: "Donchian Breakout".into(),
1707 vault_address: None,
1708 is_active: false,
1709 created_at: now_iso(),
1710 symbol: symbol.into(),
1711 interval_secs: 300,
1712 regime_rules: vec![],
1713 default_regime: "active".into(),
1714 hysteresis: HysteresisConfig::default(),
1715 playbooks,
1716 }
1717}
1718
1719fn build_cta_trend_following(symbol: &str) -> StrategyGroup {
1720 let regime_rules = vec![
1721 RegimeRule {
1722 regime: "strong_trend".into(),
1723 conditions: vec![TaRule {
1724 indicator: "ADX".into(),
1725 params: vec![14.0],
1726 condition: "gt".into(),
1727 threshold: 25.0,
1728 threshold_upper: None,
1729 signal: "adx_strong".into(),
1730 action: None,
1731 }],
1732 priority: 1,
1733 },
1734 RegimeRule {
1735 regime: "weak".into(),
1736 conditions: vec![TaRule {
1737 indicator: "ADX".into(),
1738 params: vec![14.0],
1739 condition: "lte".into(),
1740 threshold: 25.0,
1741 threshold_upper: None,
1742 signal: "adx_weak".into(),
1743 action: None,
1744 }],
1745 priority: 2,
1746 },
1747 ];
1748
1749 build_simple_strategy(
1750 "cta-trend",
1751 "CTA Trend Following",
1752 symbol,
1753 regime_rules,
1754 "weak",
1755 "strong_trend",
1756 Playbook {
1757 rules: vec![],
1758 entry_rules: vec![
1759 TaRule {
1760 indicator: "SMA".into(),
1761 params: vec![12.0, 26.0],
1762 condition: "cross_above".into(),
1763 threshold: 0.0,
1764 threshold_upper: None,
1765 signal: "sma_golden_cross".into(), action: Some("buy".into()),
1766 },
1767 ],
1768 exit_rules: vec![
1769 TaRule {
1770 indicator: "SMA".into(),
1771 params: vec![12.0, 26.0],
1772 condition: "cross_below".into(),
1773 threshold: 0.0,
1774 threshold_upper: None,
1775 signal: "sma_death_cross".into(), action: Some("close".into()),
1776 },
1777 ],
1778 system_prompt: "You are a CTA-style trend following agent. Only active when ADX > 25 (strong trend). LONG when SMA 20 crosses above SMA 50 (sma_golden_cross). Exit when SMA 20 crosses below SMA 50 (sma_death_cross). ATR 2.0x SL, 4.0x TP.".into(),
1779 max_position_size: 1200.0,
1780 stop_loss_pct: Some(5.0),
1781 take_profit_pct: Some(15.0),
1782 timeout_secs: Some(3600),
1783 side: Some("long".into()),
1784 },
1785 300,
1786 )
1787}
1788
1789fn build_adx_di_crossover(symbol: &str) -> StrategyGroup {
1790 let regime_rules = vec![
1791 RegimeRule {
1792 regime: "trending".into(),
1793 conditions: vec![TaRule {
1794 indicator: "ADX".into(),
1795 params: vec![14.0],
1796 condition: "gt".into(),
1797 threshold: 20.0,
1798 threshold_upper: None,
1799 signal: "adx_trending".into(),
1800 action: None,
1801 }],
1802 priority: 1,
1803 },
1804 RegimeRule {
1805 regime: "weak".into(),
1806 conditions: vec![TaRule {
1807 indicator: "ADX".into(),
1808 params: vec![14.0],
1809 condition: "lte".into(),
1810 threshold: 20.0,
1811 threshold_upper: None,
1812 signal: "adx_weak".into(),
1813 action: None,
1814 }],
1815 priority: 2,
1816 },
1817 ];
1818
1819 build_simple_strategy(
1820 "adx-di",
1821 "ADX +DI/-DI Crossover",
1822 symbol,
1823 regime_rules,
1824 "weak",
1825 "trending",
1826 Playbook {
1827 rules: vec![],
1828 entry_rules: vec![
1829 TaRule {
1830 indicator: "PLUS_DI".into(),
1831 params: vec![14.0, 14.0],
1832 condition: "cross_above".into(),
1833 threshold: 0.0,
1834 threshold_upper: None,
1835 signal: "plus_di_cross_above".into(), action: Some("buy".into()),
1836 },
1837 TaRule {
1838 indicator: "ADX".into(),
1839 params: vec![14.0],
1840 condition: "gt".into(),
1841 threshold: 25.0,
1842 threshold_upper: None,
1843 signal: "adx_confirms_trend".into(), action: Some("buy".into()),
1844 },
1845 ],
1846 exit_rules: vec![
1847 TaRule {
1848 indicator: "PLUS_DI".into(),
1849 params: vec![14.0, 14.0],
1850 condition: "cross_below".into(),
1851 threshold: 0.0,
1852 threshold_upper: None,
1853 signal: "plus_di_cross_below".into(), action: Some("close".into()),
1854 },
1855 ],
1856 system_prompt: "You are an ADX directional index crossover agent. Only active when ADX > 20. LONG when +DI crosses above -DI (plus_di_cross_above) with ADX > 25 confirmation (adx_confirms_trend). Exit when -DI crosses above +DI (plus_di_cross_below). ATR 2.0x SL, 4.0x TP.".into(),
1857 max_position_size: 1200.0,
1858 stop_loss_pct: Some(5.0),
1859 take_profit_pct: Some(15.0),
1860 timeout_secs: Some(3600),
1861 side: Some("long".into()),
1862 },
1863 300,
1864 )
1865}
1866
1867fn build_chandelier_exit(symbol: &str) -> StrategyGroup {
1868 let mut playbooks = HashMap::new();
1869 playbooks.insert(
1870 "active".into(),
1871 Playbook {
1872 rules: vec![],
1873 entry_rules: vec![
1874 TaRule {
1875 indicator: "SMA".into(),
1876 params: vec![50.0],
1877 condition: "lt".into(),
1878 threshold: 0.0,
1879 threshold_upper: None,
1880 signal: "close_above_sma50".into(), action: Some("buy".into()),
1881 },
1882 TaRule {
1883 indicator: "ATR".into(),
1884 params: vec![14.0],
1885 condition: "gt".into(),
1886 threshold: 0.0,
1887 threshold_upper: None,
1888 signal: "atr_available".into(), action: Some("buy".into()),
1889 },
1890 ],
1891 exit_rules: vec![
1892 TaRule {
1893 indicator: "SMA".into(),
1894 params: vec![50.0],
1895 condition: "gt".into(),
1896 threshold: 0.0,
1897 threshold_upper: None,
1898 signal: "close_below_sma50_exit".into(), action: Some("close".into()),
1899 },
1900 ],
1901 system_prompt: "You are a chandelier exit trend agent. LONG entry when close is above SMA 50 (close_above_sma50). Use ATR * 3.0 as trailing stop distance from the highest high since entry (chandelier exit). Exit when close drops below SMA 50 (close_below_sma50_exit) or when chandelier trailing stop is hit. This is a long-only trend following strategy. ATR 2.0x SL, 4.0x TP.".into(),
1902 max_position_size: 1200.0,
1903 stop_loss_pct: Some(5.0),
1904 take_profit_pct: Some(15.0),
1905 timeout_secs: Some(3600),
1906 side: Some("long".into()),
1907 },
1908 );
1909 playbooks.insert(
1910 "inactive".into(),
1911 Playbook {
1912 rules: vec![],
1913 system_prompt: "Close below SMA 50. Not a trend entry setup. Wait.".into(),
1914 max_position_size: 0.0,
1915 stop_loss_pct: None,
1916 take_profit_pct: None,
1917 entry_rules: vec![],
1918 exit_rules: vec![],
1919 timeout_secs: None,
1920 side: None,
1921 },
1922 );
1923 StrategyGroup {
1924 id: format!("tpl-chandelier-{}", uuid_stub()),
1925 name: "Chandelier Exit".into(),
1926 vault_address: None,
1927 is_active: false,
1928 created_at: now_iso(),
1929 symbol: symbol.into(),
1930 interval_secs: 300,
1931 regime_rules: vec![],
1932 default_regime: "active".into(),
1933 hysteresis: HysteresisConfig::default(),
1934 playbooks,
1935 }
1936}
1937
1938fn build_atr_breakout(symbol: &str) -> StrategyGroup {
1941 let mut playbooks = HashMap::new();
1942 playbooks.insert(
1943 "active".into(),
1944 Playbook {
1945 rules: vec![],
1946 entry_rules: vec![
1947 TaRule {
1948 indicator: "ROC".into(),
1949 params: vec![1.0],
1950 condition: "gt".into(),
1951 threshold: 3.0,
1952 threshold_upper: None,
1953 signal: "atr_breakout_up".into(), action: Some("buy".into()),
1954 },
1955 TaRule {
1956 indicator: "ATR".into(),
1957 params: vec![14.0],
1958 condition: "gt".into(),
1959 threshold: 0.0,
1960 threshold_upper: None,
1961 signal: "atr_nonzero".into(), action: Some("buy".into()),
1962 },
1963 ],
1964 exit_rules: vec![
1965 TaRule {
1966 indicator: "ROC".into(),
1967 params: vec![1.0],
1968 condition: "between".into(),
1969 threshold: -1.0,
1970 threshold_upper: Some(1.0),
1971 signal: "roc_flat_exit".into(), action: Some("close".into()),
1972 },
1973 ],
1974 system_prompt: "You are an ATR breakout agent. LONG when price makes a large upward move exceeding 2x ATR (atr_breakout_up, approximated by ROC > 3%) with ATR confirmation (atr_nonzero). Exit when momentum fades (roc_flat_exit, ROC between -1% and 1%). ATR 2.0x SL, 3.5x TP.".into(),
1975 max_position_size: 1000.0,
1976 stop_loss_pct: Some(5.0),
1977 take_profit_pct: Some(12.0),
1978 timeout_secs: Some(3600),
1979 side: Some("long".into()),
1980 },
1981 );
1982 playbooks.insert(
1983 "inactive".into(),
1984 Playbook {
1985 rules: vec![],
1986 system_prompt: "No ATR breakout detected. Wait.".into(),
1987 max_position_size: 0.0,
1988 stop_loss_pct: None,
1989 take_profit_pct: None,
1990 entry_rules: vec![],
1991 exit_rules: vec![],
1992 timeout_secs: None,
1993 side: None,
1994 },
1995 );
1996 StrategyGroup {
1997 id: format!("tpl-atr-brk-{}", uuid_stub()),
1998 name: "ATR Breakout".into(),
1999 vault_address: None,
2000 is_active: false,
2001 created_at: now_iso(),
2002 symbol: symbol.into(),
2003 interval_secs: 300,
2004 regime_rules: vec![],
2005 default_regime: "active".into(),
2006 hysteresis: HysteresisConfig::default(),
2007 playbooks,
2008 }
2009}
2010
2011fn build_breakout_volume(symbol: &str) -> StrategyGroup {
2012 let mut playbooks = HashMap::new();
2013 playbooks.insert(
2014 "active".into(),
2015 Playbook {
2016 rules: vec![],
2017 entry_rules: vec![
2018 TaRule {
2019 indicator: "DONCHIAN_UPPER".into(),
2020 params: vec![20.0],
2021 condition: "lt".into(),
2022 threshold: 0.0,
2023 threshold_upper: None,
2024 signal: "donchian_breakout_up".into(), action: Some("buy".into()),
2025 },
2026 TaRule {
2027 indicator: "VOL_ZSCORE".into(),
2028 params: vec![20.0],
2029 condition: "gt".into(),
2030 threshold: 1.5,
2031 threshold_upper: None,
2032 signal: "volume_confirmed".into(), action: Some("buy".into()),
2033 },
2034 ],
2035 exit_rules: vec![
2036 TaRule {
2037 indicator: "DONCHIAN_LOWER".into(),
2038 params: vec![10.0],
2039 condition: "gt".into(),
2040 threshold: 0.0,
2041 threshold_upper: None,
2042 signal: "donchian_exit".into(), action: Some("close".into()),
2043 },
2044 ],
2045 system_prompt: "You are a volume-confirmed breakout agent. LONG only when close breaks above Donchian 20 upper channel (donchian_breakout_up) AND volume Z-score > 1.5 (volume_confirmed). Both must trigger. This is a long-only strategy. Exit at Donchian 10 lower (donchian_exit). ATR 2.0x SL, 3.5x TP.".into(),
2046 max_position_size: 1000.0,
2047 stop_loss_pct: Some(5.0),
2048 take_profit_pct: Some(12.0),
2049 timeout_secs: Some(3600),
2050 side: Some("long".into()),
2051 },
2052 );
2053 playbooks.insert(
2054 "inactive".into(),
2055 Playbook {
2056 rules: vec![],
2057 system_prompt: "No volume-confirmed breakout. Wait.".into(),
2058 max_position_size: 0.0,
2059 stop_loss_pct: None,
2060 take_profit_pct: None,
2061 entry_rules: vec![],
2062 exit_rules: vec![],
2063 timeout_secs: None,
2064 side: None,
2065 },
2066 );
2067 StrategyGroup {
2068 id: format!("tpl-brk-vol-{}", uuid_stub()),
2069 name: "Volume-Confirmed Breakout".into(),
2070 vault_address: None,
2071 is_active: false,
2072 created_at: now_iso(),
2073 symbol: symbol.into(),
2074 interval_secs: 300,
2075 regime_rules: vec![],
2076 default_regime: "active".into(),
2077 hysteresis: HysteresisConfig::default(),
2078 playbooks,
2079 }
2080}
2081
2082fn build_keltner_squeeze(symbol: &str) -> StrategyGroup {
2083 let mut playbooks = HashMap::new();
2084 playbooks.insert(
2085 "active".into(),
2086 Playbook {
2087 rules: vec![],
2088 entry_rules: vec![
2089 TaRule {
2090 indicator: "BB_UPPER".into(),
2091 params: vec![20.0, 2.0],
2092 condition: "lt".into(),
2093 threshold: 0.0,
2094 threshold_upper: None,
2095 signal: "squeeze_detected".into(), action: Some("buy".into()),
2096 },
2097 TaRule {
2098 indicator: "KC_UPPER".into(),
2099 params: vec![20.0],
2100 condition: "gt".into(),
2101 threshold: 0.0,
2102 threshold_upper: None,
2103 signal: "kc_upper_available".into(), action: Some("buy".into()),
2104 },
2105 TaRule {
2106 indicator: "BB_UPPER".into(),
2107 params: vec![20.0, 2.0],
2108 condition: "lt".into(),
2109 threshold: 0.0,
2110 threshold_upper: None,
2111 signal: "squeeze_breakout_up".into(), action: Some("buy".into()),
2112 },
2113 ],
2114 exit_rules: vec![
2115 TaRule {
2116 indicator: "RSI".into(),
2117 params: vec![14.0],
2118 condition: "between".into(),
2119 threshold: 40.0,
2120 threshold_upper: Some(60.0),
2121 signal: "squeeze_exit".into(), action: Some("close".into()),
2122 },
2123 ],
2124 system_prompt: "You are a Keltner squeeze breakout agent. First detect a squeeze: BB upper < KC upper (squeeze_detected + kc_upper_available). Then trade the breakout direction. LONG on squeeze_breakout_up (close breaks above BB upper). Exit when RSI returns to 40-60 (squeeze_exit). ATR 2.0x SL, 3.5x TP.".into(),
2125 max_position_size: 1000.0,
2126 stop_loss_pct: Some(5.0),
2127 take_profit_pct: Some(12.0),
2128 timeout_secs: Some(3600),
2129 side: Some("long".into()),
2130 },
2131 );
2132 playbooks.insert(
2133 "inactive".into(),
2134 Playbook {
2135 rules: vec![],
2136 system_prompt: "No Keltner squeeze detected. Wait for squeeze setup.".into(),
2137 max_position_size: 0.0,
2138 stop_loss_pct: None,
2139 take_profit_pct: None,
2140 entry_rules: vec![],
2141 exit_rules: vec![],
2142 timeout_secs: None,
2143 side: None,
2144 },
2145 );
2146 StrategyGroup {
2147 id: format!("tpl-kc-squeeze-{}", uuid_stub()),
2148 name: "Keltner Squeeze Breakout".into(),
2149 vault_address: None,
2150 is_active: false,
2151 created_at: now_iso(),
2152 symbol: symbol.into(),
2153 interval_secs: 300,
2154 regime_rules: vec![],
2155 default_regime: "active".into(),
2156 hysteresis: HysteresisConfig::default(),
2157 playbooks,
2158 }
2159}
2160
2161fn build_confluence(symbol: &str) -> StrategyGroup {
2164 let regime_rules = vec![
2165 RegimeRule {
2166 regime: "strong_trend".into(),
2167 conditions: vec![TaRule {
2168 indicator: "ADX".into(),
2169 params: vec![14.0],
2170 condition: "gt".into(),
2171 threshold: 25.0,
2172 threshold_upper: None,
2173 signal: "adx_strong".into(),
2174 action: None,
2175 }],
2176 priority: 1,
2177 },
2178 RegimeRule {
2179 regime: "weak".into(),
2180 conditions: vec![TaRule {
2181 indicator: "ADX".into(),
2182 params: vec![14.0],
2183 condition: "lte".into(),
2184 threshold: 25.0,
2185 threshold_upper: None,
2186 signal: "adx_weak".into(),
2187 action: None,
2188 }],
2189 priority: 2,
2190 },
2191 ];
2192
2193 build_simple_strategy(
2194 "confluence",
2195 "Triple Confluence",
2196 symbol,
2197 regime_rules,
2198 "weak",
2199 "strong_trend",
2200 Playbook {
2201 rules: vec![],
2202 entry_rules: vec![
2203 TaRule {
2204 indicator: "SUPERTREND_DIR".into(),
2205 params: vec![10.0, 3.0],
2206 condition: "gt".into(),
2207 threshold: 0.0,
2208 threshold_upper: None,
2209 signal: "supertrend_bullish".into(), action: Some("buy".into()),
2210 },
2211 TaRule {
2212 indicator: "RSI".into(),
2213 params: vec![14.0],
2214 condition: "gt".into(),
2215 threshold: 50.0,
2216 threshold_upper: None,
2217 signal: "rsi_bullish".into(), action: Some("buy".into()),
2218 },
2219 TaRule {
2220 indicator: "ADX".into(),
2221 params: vec![14.0],
2222 condition: "gt".into(),
2223 threshold: 25.0,
2224 threshold_upper: None,
2225 signal: "adx_strong_confirm".into(), action: Some("buy".into()),
2226 },
2227 ],
2228 exit_rules: vec![
2229 TaRule {
2230 indicator: "RSI".into(),
2231 params: vec![14.0],
2232 condition: "between".into(),
2233 threshold: 45.0,
2234 threshold_upper: Some(55.0),
2235 signal: "confluence_weakening".into(), action: Some("close".into()),
2236 },
2237 TaRule {
2238 indicator: "SUPERTREND_DIR".into(),
2239 params: vec![10.0, 3.0],
2240 condition: "lt".into(),
2241 threshold: 0.0,
2242 threshold_upper: None,
2243 signal: "supertrend_bearish".into(), action: Some("close".into()),
2244 },
2245 ],
2246 system_prompt: "You are a triple confluence agent. LONG only when ALL three agree: SuperTrend bullish (supertrend_bullish) + RSI > 50 (rsi_bullish) + ADX > 25 (adx_strong_confirm). Exit when confluence weakens (confluence_weakening) or SuperTrend turns bearish (supertrend_bearish). High conviction only. ATR 2.0x SL, 4.0x TP with trailing stop.".into(),
2247 max_position_size: 1500.0,
2248 stop_loss_pct: Some(5.0),
2249 take_profit_pct: Some(15.0),
2250 timeout_secs: Some(3600),
2251 side: Some("long".into()),
2252 },
2253 300,
2254 )
2255}
2256
2257fn uuid_stub() -> String {
2262 uuid::Uuid::new_v4().to_string()[..8].to_string()
2263}
2264
2265fn now_iso() -> String {
2266 chrono::Utc::now().to_rfc3339()
2267}
2268
2269pub fn list_all_templates() -> Vec<StrategyTemplate> {
2274 TEMPLATE_IDS
2275 .iter()
2276 .filter_map(|id| template_meta(id))
2277 .collect()
2278}
2279
2280pub fn apply_template(template_id: &str, symbol: &str) -> Result<StrategyGroup, String> {
2281 build_template(template_id, symbol)
2282 .ok_or_else(|| format!("Unknown template id: '{}'", template_id))
2283}
2284
2285pub fn validate_strategy_indicators(sg: &StrategyGroup) -> Vec<String> {
2293 let mut warnings = Vec::new();
2294 let supported_indicators = [
2295 "RSI",
2296 "MACD",
2297 "MACD_LINE",
2298 "MACD_SIGNAL",
2299 "MACD_HISTOGRAM",
2300 "EMA",
2301 "SMA",
2302 "ADX",
2303 "ATR",
2304 "BB",
2305 "BB_UPPER",
2306 "BB_LOWER",
2307 "STOCHASTIC",
2308 "SUPERTREND",
2309 "SUPERTREND_DIR",
2310 "SUPERTREND_VALUE",
2311 "CCI",
2312 "WILLIAMS_R",
2313 "OBV",
2314 "MFI",
2315 "ROC",
2316 "DONCHIAN_UPPER",
2317 "DONCHIAN_LOWER",
2318 "KELTNER_UPPER",
2319 "KELTNER_LOWER",
2320 "KC_UPPER",
2321 "KC_LOWER",
2322 "HV",
2323 "ZSCORE",
2324 "VOL_ZSCORE",
2325 "VWAP",
2326 "DI_PLUS",
2327 "DI_MINUS",
2328 "PLUS_DI",
2329 "MINUS_DI",
2330 ];
2331
2332 let supported_conditions = [
2333 "gt",
2334 "lt",
2335 "gte",
2336 "lte",
2337 "cross_above",
2338 "cross_below",
2339 "between",
2340 "outside",
2341 "gt_pct",
2342 "lt_pct",
2343 "price_above",
2344 "price_below",
2345 "price_below_lower",
2346 "price_above_upper",
2347 "bandwidth_lt",
2348 "bandwidth_gt",
2349 "decreasing",
2350 "increasing",
2351 "breakout",
2352 ];
2353
2354 for rule in &sg.regime_rules {
2356 for cond in &rule.conditions {
2357 if !supported_indicators.contains(&cond.indicator.to_uppercase().as_str()) {
2358 warnings.push(format!(
2359 "Unsupported indicator '{}' in regime rule '{}'",
2360 cond.indicator, rule.regime
2361 ));
2362 }
2363 if !supported_conditions.contains(&cond.condition.as_str()) {
2364 warnings.push(format!(
2365 "Unsupported condition '{}' in regime rule '{}'",
2366 cond.condition, rule.regime
2367 ));
2368 }
2369 }
2370 }
2371
2372 for (name, pb) in &sg.playbooks {
2374 for rule in pb.effective_entry_rules() {
2375 if !supported_indicators.contains(&rule.indicator.to_uppercase().as_str()) {
2376 warnings.push(format!(
2377 "Unsupported indicator '{}' in playbook '{}' entry rules",
2378 rule.indicator, name
2379 ));
2380 }
2381 }
2382 for rule in pb.effective_exit_rules() {
2383 if !supported_indicators.contains(&rule.indicator.to_uppercase().as_str()) {
2384 warnings.push(format!(
2385 "Unsupported indicator '{}' in playbook '{}' exit rules",
2386 rule.indicator, name
2387 ));
2388 }
2389 }
2390 }
2391
2392 warnings
2393}
2394
2395#[cfg(test)]
2400mod tests {
2401 use super::*;
2402
2403 #[test]
2406 fn test_all_template_ids_have_metadata() {
2407 for id in &TEMPLATE_IDS {
2408 let meta = template_meta(id);
2409 assert!(meta.is_some(), "Missing metadata for template '{}'", id);
2410 let meta = meta.unwrap();
2411 assert_eq!(meta.id, *id);
2412 assert!(!meta.name.is_empty());
2413 assert!(!meta.description.is_empty());
2414 assert!(!meta.difficulty.is_empty());
2415 assert!(!meta.tags.is_empty());
2416 }
2417 }
2418
2419 #[test]
2420 fn test_unknown_template_meta_returns_none() {
2421 assert!(template_meta("nonexistent").is_none());
2422 }
2423
2424 #[test]
2425 fn test_template_count() {
2426 assert_eq!(TEMPLATE_IDS.len(), 22);
2427 }
2428
2429 #[test]
2432 fn test_build_all_templates_succeed() {
2433 for id in &TEMPLATE_IDS {
2434 let group = build_template(id, "BTC-USD");
2435 assert!(group.is_some(), "build_template failed for '{}'", id);
2436 let group = group.unwrap();
2437 assert_eq!(group.symbol, "BTC-USD");
2438 assert!(!group.is_active, "Template should not be active by default");
2439 assert!(group.vault_address.is_none());
2440 assert!(!group.playbooks.is_empty());
2441 }
2442 }
2443
2444 #[test]
2445 fn test_build_unknown_template_returns_none() {
2446 assert!(build_template("invalid_id", "BTC-USD").is_none());
2447 }
2448
2449 #[test]
2452 fn test_adaptive_trend_has_four_regimes() {
2453 let group = build_adaptive_trend("ETH-USD");
2454 assert_eq!(group.regime_rules.len(), 4);
2455 let regime_names: Vec<&str> = group
2456 .regime_rules
2457 .iter()
2458 .map(|r| r.regime.as_str())
2459 .collect();
2460 assert!(regime_names.contains(&"trending_up"));
2461 assert!(regime_names.contains(&"trending_down"));
2462 assert!(regime_names.contains(&"ranging"));
2463 assert!(regime_names.contains(&"high_vol"));
2464 }
2465
2466 #[test]
2467 fn test_adaptive_trend_playbooks_match_regimes() {
2468 let group = build_adaptive_trend("BTC-USD");
2469 assert!(group.playbooks.contains_key("trending_up"));
2470 assert!(group.playbooks.contains_key("trending_down"));
2471 assert!(group.playbooks.contains_key("ranging"));
2472 assert!(group.playbooks.contains_key("high_vol"));
2473 }
2474
2475 #[test]
2476 fn test_adaptive_trend_high_vol_zero_position() {
2477 let group = build_adaptive_trend("BTC-USD");
2478 let hv = group.playbooks.get("high_vol").unwrap();
2479 assert_eq!(
2480 hv.max_position_size, 0.0,
2481 "high_vol should have zero position size"
2482 );
2483 }
2484
2485 #[test]
2488 fn test_pure_trend_other_regime_no_position() {
2489 let group = build_pure_trend_following("BTC-USD");
2490 let other = group.playbooks.get("other").unwrap();
2491 assert_eq!(other.max_position_size, 0.0);
2492 assert_eq!(group.default_regime, "other");
2493 }
2494
2495 #[test]
2498 fn test_adaptive_trend_entry_exit_rules() {
2499 let group = build_adaptive_trend("BTC-USD");
2500
2501 let tu = group.playbooks.get("trending_up").unwrap();
2503 assert!(tu.rules.is_empty(), "legacy rules should be empty");
2504 assert_eq!(tu.entry_rules.len(), 1);
2505 assert_eq!(tu.entry_rules[0].signal, "buy_dip_ema20");
2506 assert_eq!(tu.entry_rules[0].action.as_deref(), Some("buy"));
2507 assert_eq!(tu.exit_rules.len(), 1);
2508 assert_eq!(tu.exit_rules[0].signal, "trend_broken");
2509 assert_eq!(tu.exit_rules[0].action.as_deref(), Some("close"));
2510 assert_eq!(tu.side.as_deref(), Some("long"));
2511 assert_eq!(tu.timeout_secs, Some(3600));
2512
2513 let td = group.playbooks.get("trending_down").unwrap();
2515 assert!(td.rules.is_empty());
2516 assert_eq!(td.entry_rules.len(), 1);
2517 assert_eq!(td.entry_rules[0].signal, "sell_rally_ema20");
2518 assert_eq!(td.entry_rules[0].action.as_deref(), Some("sell"));
2519 assert_eq!(td.exit_rules.len(), 1);
2520 assert_eq!(td.exit_rules[0].signal, "downtrend_broken");
2521 assert_eq!(td.exit_rules[0].action.as_deref(), Some("close"));
2522 assert_eq!(td.side.as_deref(), Some("short"));
2523 assert_eq!(td.timeout_secs, Some(3600));
2524
2525 let rg = group.playbooks.get("ranging").unwrap();
2527 assert!(rg.rules.is_empty());
2528 assert_eq!(rg.entry_rules.len(), 2);
2529 assert_eq!(rg.entry_rules[0].signal, "oversold_buy");
2530 assert_eq!(rg.entry_rules[1].signal, "overbought_sell");
2531 assert_eq!(rg.exit_rules.len(), 1);
2532 assert_eq!(rg.exit_rules[0].signal, "rsi_neutral_exit");
2533 assert_eq!(rg.exit_rules[0].condition, "between");
2534 assert_eq!(rg.exit_rules[0].threshold_upper, Some(60.0));
2535 assert!(rg.side.is_none());
2536 assert_eq!(rg.timeout_secs, Some(3600));
2537
2538 let hv = group.playbooks.get("high_vol").unwrap();
2540 assert!(hv.rules.is_empty());
2541 assert!(hv.entry_rules.is_empty());
2542 assert!(hv.exit_rules.is_empty());
2543 assert_eq!(hv.max_position_size, 0.0);
2544 assert!(hv.side.is_none());
2545 assert!(hv.timeout_secs.is_none());
2546 }
2547
2548 #[test]
2549 fn test_mean_reversion_ranging_has_bb_and_rsi() {
2550 let group = build_mean_reversion_expert("BTC-USD");
2551 let ranging = group.playbooks.get("ranging").unwrap();
2552 let indicators: Vec<&str> = ranging
2553 .entry_rules
2554 .iter()
2555 .map(|r| r.indicator.as_str())
2556 .collect();
2557 assert!(
2558 indicators.contains(&"BB"),
2559 "ranging playbook should have BB rule"
2560 );
2561 assert!(
2562 indicators.contains(&"RSI"),
2563 "ranging playbook should have RSI rule"
2564 );
2565 }
2566
2567 #[test]
2570 fn test_conservative_small_positions() {
2571 let group = build_conservative("BTC-USD");
2572 for (_, playbook) in &group.playbooks {
2573 assert!(
2574 playbook.max_position_size <= 200.0,
2575 "Conservative should have small positions, got {}",
2576 playbook.max_position_size
2577 );
2578 }
2579 }
2580
2581 #[test]
2582 fn test_conservative_strict_stop_loss() {
2583 let group = build_conservative("BTC-USD");
2584 for (_, playbook) in &group.playbooks {
2585 assert_eq!(
2586 playbook.stop_loss_pct,
2587 Some(2.0),
2588 "Conservative should have 2% stop loss"
2589 );
2590 }
2591 }
2592
2593 #[test]
2594 fn test_conservative_prompt_mentions_risk() {
2595 let group = build_conservative("BTC-USD");
2596 for (_, playbook) in &group.playbooks {
2597 let prompt_lower = playbook.system_prompt.to_lowercase();
2598 assert!(
2599 prompt_lower.contains("risk")
2600 || prompt_lower.contains("conservative")
2601 || prompt_lower.contains("protect"),
2602 "Conservative prompt should mention risk control"
2603 );
2604 }
2605 }
2606
2607 #[test]
2610 fn test_volatility_hunter_has_squeeze_regime() {
2611 let group = build_volatility_hunter("BTC-USD");
2612 let regime_names: Vec<&str> = group
2613 .regime_rules
2614 .iter()
2615 .map(|r| r.regime.as_str())
2616 .collect();
2617 assert!(regime_names.contains(&"squeeze"));
2618 assert!(group.playbooks.contains_key("squeeze"));
2619 }
2620
2621 #[test]
2624 fn test_template_serialization_roundtrip() {
2625 for id in &TEMPLATE_IDS {
2626 let group = build_template(id, "SOL-USD").unwrap();
2627 let json = serde_json::to_string(&group).unwrap();
2628 let parsed: StrategyGroup = serde_json::from_str(&json).unwrap();
2629 assert_eq!(parsed.name, group.name);
2630 assert_eq!(parsed.symbol, "SOL-USD");
2631 assert_eq!(parsed.regime_rules.len(), group.regime_rules.len());
2632 assert_eq!(parsed.playbooks.len(), group.playbooks.len());
2633 }
2634 }
2635
2636 #[test]
2637 fn test_template_meta_serialization() {
2638 let meta = template_meta("adaptive_trend").unwrap();
2639 let json = serde_json::to_string(&meta).unwrap();
2640 let parsed: StrategyTemplate = serde_json::from_str(&json).unwrap();
2641 assert_eq!(parsed.id, "adaptive_trend");
2642 assert_eq!(parsed.difficulty, "beginner");
2643 }
2644
2645 #[test]
2648 fn test_generated_ids_are_unique() {
2649 let g1 = build_template("adaptive_trend", "BTC-USD").unwrap();
2650 let g2 = build_template("adaptive_trend", "BTC-USD").unwrap();
2651 assert_ne!(
2652 g1.id, g2.id,
2653 "Each generated template should have a unique ID"
2654 );
2655 }
2656
2657 #[test]
2664 fn test_build_all_new_templates_succeed() {
2665 let new_ids = [
2666 "macd_momentum",
2667 "rsi_momentum",
2668 "rsi_50_crossover",
2669 "bollinger_bands",
2670 "rsi_reversion",
2671 "zscore_reversion",
2672 "bb_confirmed",
2673 "ma_crossover",
2674 "supertrend",
2675 "donchian_breakout",
2676 "cta_trend_following",
2677 "adx_di_crossover",
2678 "chandelier_exit",
2679 "atr_breakout",
2680 "breakout_volume",
2681 "keltner_squeeze",
2682 "confluence",
2683 ];
2684 for id in &new_ids {
2685 let group = build_template(id, "BTC-USD");
2686 assert!(group.is_some(), "build_template failed for '{}'", id);
2687 let group = group.unwrap();
2688 assert_eq!(group.symbol, "BTC-USD");
2689 assert!(!group.is_active);
2690 assert!(group.vault_address.is_none());
2691 assert!(!group.playbooks.is_empty());
2692 }
2693 }
2694
2695 #[test]
2698 fn test_all_new_templates_have_metadata() {
2699 let new_ids = [
2700 "macd_momentum",
2701 "rsi_momentum",
2702 "rsi_50_crossover",
2703 "bollinger_bands",
2704 "rsi_reversion",
2705 "zscore_reversion",
2706 "bb_confirmed",
2707 "ma_crossover",
2708 "supertrend",
2709 "donchian_breakout",
2710 "cta_trend_following",
2711 "adx_di_crossover",
2712 "chandelier_exit",
2713 "atr_breakout",
2714 "breakout_volume",
2715 "keltner_squeeze",
2716 "confluence",
2717 ];
2718 for id in &new_ids {
2719 let meta = template_meta(id);
2720 assert!(meta.is_some(), "Missing metadata for '{}'", id);
2721 let meta = meta.unwrap();
2722 assert_eq!(meta.id, *id);
2723 assert!(!meta.name.is_empty());
2724 assert!(!meta.description.is_empty());
2725 assert!(!meta.tags.is_empty());
2726 }
2727 }
2728
2729 #[test]
2732 fn test_macd_momentum_has_entry_and_exit_rules() {
2733 let group = build_macd_momentum("BTC-USD");
2734 let active = group.playbooks.get("active").unwrap();
2735 assert!(active.rules.is_empty(), "legacy rules should be empty");
2736 assert!(
2737 active.entry_rules.len() >= 2,
2738 "MACD momentum should have entry rules"
2739 );
2740 assert!(
2741 active.exit_rules.len() >= 2,
2742 "MACD momentum should have exit rules"
2743 );
2744 let entry_signals: Vec<&str> = active
2745 .entry_rules
2746 .iter()
2747 .map(|r| r.signal.as_str())
2748 .collect();
2749 assert!(entry_signals.contains(&"macd_hist_positive_buy"));
2750 assert!(entry_signals.contains(&"macd_hist_negative_sell"));
2751 let exit_signals: Vec<&str> = active
2752 .exit_rules
2753 .iter()
2754 .map(|r| r.signal.as_str())
2755 .collect();
2756 assert!(exit_signals.contains(&"macd_hist_negative_exit"));
2757 assert!(exit_signals.contains(&"macd_hist_positive_exit"));
2758 }
2759
2760 #[test]
2761 fn test_rsi_momentum_has_dual_confirmation() {
2762 let group = build_rsi_momentum("BTC-USD");
2763 let active = group.playbooks.get("active").unwrap();
2764 let signals: Vec<&str> = active
2765 .entry_rules
2766 .iter()
2767 .map(|r| r.signal.as_str())
2768 .collect();
2769 assert!(signals.contains(&"rsi_bullish_momentum"));
2770 assert!(signals.contains(&"macd_hist_positive"));
2771 assert!(signals.contains(&"rsi_bearish_momentum"));
2772 assert!(signals.contains(&"macd_hist_negative"));
2773 }
2774
2775 #[test]
2776 fn test_rsi_50_crossover_has_ema_confirmation() {
2777 let group = build_rsi_50_crossover("ETH-USD");
2778 let active = group.playbooks.get("active").unwrap();
2779 let signals: Vec<&str> = active
2780 .entry_rules
2781 .iter()
2782 .map(|r| r.signal.as_str())
2783 .collect();
2784 assert!(signals.contains(&"rsi_cross_above_50"));
2785 assert!(signals.contains(&"ema12_above_ema26"));
2786 }
2787
2788 #[test]
2791 fn test_mean_reversion_strategies_have_adx_regime_filter() {
2792 let mr_ids = [
2793 "bollinger_bands",
2794 "rsi_reversion",
2795 "zscore_reversion",
2796 "bb_confirmed",
2797 ];
2798 for id in &mr_ids {
2799 let group = build_template(id, "BTC-USD").unwrap();
2800 assert!(
2801 !group.regime_rules.is_empty(),
2802 "{} should have ADX regime rules",
2803 id
2804 );
2805 let regime_names: Vec<&str> = group
2806 .regime_rules
2807 .iter()
2808 .map(|r| r.regime.as_str())
2809 .collect();
2810 assert!(
2811 regime_names.contains(&"ranging"),
2812 "{} should have 'ranging' regime",
2813 id
2814 );
2815 assert!(
2816 regime_names.contains(&"trending"),
2817 "{} should have 'trending' regime",
2818 id
2819 );
2820 assert!(
2822 group.playbooks.contains_key("ranging"),
2823 "{} should have 'ranging' playbook",
2824 id
2825 );
2826 }
2827 }
2828
2829 #[test]
2830 fn test_bollinger_bands_has_bb_and_rsi_rules() {
2831 let group = build_bollinger_bands("BTC-USD");
2832 let ranging = group.playbooks.get("ranging").unwrap();
2833 let indicators: Vec<&str> = ranging
2834 .entry_rules
2835 .iter()
2836 .map(|r| r.indicator.as_str())
2837 .collect();
2838 assert!(indicators.contains(&"BB_LOWER"));
2839 assert!(indicators.contains(&"BB_UPPER"));
2840 assert!(indicators.contains(&"RSI"));
2841 }
2842
2843 #[test]
2844 fn test_rsi_reversion_uses_extreme_thresholds() {
2845 let group = build_rsi_reversion("BTC-USD");
2846 let ranging = group.playbooks.get("ranging").unwrap();
2847 let rsi_rules: Vec<&TaRule> = ranging
2848 .entry_rules
2849 .iter()
2850 .filter(|r| r.indicator == "RSI" && r.condition != "between")
2851 .collect();
2852 let thresholds: Vec<f64> = rsi_rules.iter().map(|r| r.threshold).collect();
2853 assert!(thresholds.contains(&25.0), "Should use RSI 25 for oversold");
2854 assert!(
2855 thresholds.contains(&75.0),
2856 "Should use RSI 75 for overbought"
2857 );
2858 }
2859
2860 #[test]
2861 fn test_zscore_reversion_uses_2_std() {
2862 let group = build_zscore_reversion("BTC-USD");
2863 let ranging = group.playbooks.get("ranging").unwrap();
2864 let zscore_rules: Vec<&TaRule> = ranging
2865 .entry_rules
2866 .iter()
2867 .filter(|r| r.indicator == "ZSCORE" && r.condition != "between")
2868 .collect();
2869 let thresholds: Vec<f64> = zscore_rules.iter().map(|r| r.threshold).collect();
2870 assert!(thresholds.contains(&-2.0));
2871 assert!(thresholds.contains(&2.0));
2872 }
2873
2874 #[test]
2875 fn test_bb_confirmed_has_volume_rule() {
2876 let group = build_bb_confirmed("BTC-USD");
2877 let ranging = group.playbooks.get("ranging").unwrap();
2878 let indicators: Vec<&str> = ranging
2879 .entry_rules
2880 .iter()
2881 .map(|r| r.indicator.as_str())
2882 .collect();
2883 assert!(
2884 indicators.contains(&"VOL_ZSCORE"),
2885 "bb_confirmed should require volume confirmation"
2886 );
2887 }
2888
2889 #[test]
2892 fn test_ma_crossover_has_adx_filter() {
2893 let group = build_ma_crossover("BTC-USD");
2894 assert!(
2895 !group.regime_rules.is_empty(),
2896 "ma_crossover needs ADX regime filter"
2897 );
2898 let adx_rule = &group.regime_rules[0].conditions[0];
2899 assert_eq!(adx_rule.indicator, "ADX");
2900 }
2901
2902 #[test]
2903 fn test_supertrend_uses_direction() {
2904 let group = build_supertrend("BTC-USD");
2905 let active = group.playbooks.get("active").unwrap();
2906 let entry_indicators: Vec<&str> = active
2907 .entry_rules
2908 .iter()
2909 .map(|r| r.indicator.as_str())
2910 .collect();
2911 let exit_indicators: Vec<&str> = active
2912 .exit_rules
2913 .iter()
2914 .map(|r| r.indicator.as_str())
2915 .collect();
2916 assert!(
2917 entry_indicators.contains(&"SUPERTREND_DIR")
2918 || exit_indicators.contains(&"SUPERTREND_DIR")
2919 );
2920 }
2921
2922 #[test]
2923 fn test_donchian_breakout_uses_20_and_10_periods() {
2924 let group = build_donchian_breakout("BTC-USD");
2925 let active = group.playbooks.get("active").unwrap();
2926 let entry_params: Vec<(&str, f64)> = active
2927 .entry_rules
2928 .iter()
2929 .map(|r| {
2930 (
2931 r.indicator.as_str(),
2932 r.params.first().copied().unwrap_or(0.0),
2933 )
2934 })
2935 .collect();
2936 let exit_params: Vec<(&str, f64)> = active
2937 .exit_rules
2938 .iter()
2939 .map(|r| {
2940 (
2941 r.indicator.as_str(),
2942 r.params.first().copied().unwrap_or(0.0),
2943 )
2944 })
2945 .collect();
2946 assert!(entry_params
2948 .iter()
2949 .any(|(ind, p)| ind.starts_with("DONCHIAN") && *p == 20.0));
2950 assert!(exit_params
2951 .iter()
2952 .any(|(ind, p)| ind.starts_with("DONCHIAN") && *p == 10.0));
2953 }
2954
2955 #[test]
2956 fn test_cta_trend_has_sma_crossover() {
2957 let group = build_cta_trend_following("BTC-USD");
2958 let pb = group.playbooks.get("strong_trend").unwrap();
2959 let entry_signals: Vec<&str> = pb.entry_rules.iter().map(|r| r.signal.as_str()).collect();
2960 let exit_signals: Vec<&str> = pb.exit_rules.iter().map(|r| r.signal.as_str()).collect();
2961 assert!(entry_signals.contains(&"sma_golden_cross"));
2962 assert!(exit_signals.contains(&"sma_death_cross"));
2963 }
2964
2965 #[test]
2966 fn test_adx_di_crossover_uses_plus_di() {
2967 let group = build_adx_di_crossover("BTC-USD");
2968 let pb = group.playbooks.get("trending").unwrap();
2969 let indicators: Vec<&str> = pb
2970 .entry_rules
2971 .iter()
2972 .map(|r| r.indicator.as_str())
2973 .collect();
2974 assert!(indicators.contains(&"PLUS_DI"));
2975 }
2976
2977 #[test]
2978 fn test_chandelier_exit_uses_sma_and_atr() {
2979 let group = build_chandelier_exit("BTC-USD");
2980 let active = group.playbooks.get("active").unwrap();
2981 let entry_indicators: Vec<&str> = active
2982 .entry_rules
2983 .iter()
2984 .map(|r| r.indicator.as_str())
2985 .collect();
2986 let exit_indicators: Vec<&str> = active
2987 .exit_rules
2988 .iter()
2989 .map(|r| r.indicator.as_str())
2990 .collect();
2991 assert!(entry_indicators.contains(&"SMA") || exit_indicators.contains(&"SMA"));
2992 assert!(entry_indicators.contains(&"ATR"));
2993 }
2994
2995 #[test]
2998 fn test_atr_breakout_has_entry_and_exit() {
2999 let group = build_atr_breakout("BTC-USD");
3000 let active = group.playbooks.get("active").unwrap();
3001 let entry_signals: Vec<&str> = active
3002 .entry_rules
3003 .iter()
3004 .map(|r| r.signal.as_str())
3005 .collect();
3006 let exit_signals: Vec<&str> = active
3007 .exit_rules
3008 .iter()
3009 .map(|r| r.signal.as_str())
3010 .collect();
3011 assert!(entry_signals.contains(&"atr_breakout_up"));
3012 assert!(exit_signals.contains(&"roc_flat_exit"));
3013 }
3014
3015 #[test]
3016 fn test_breakout_volume_has_donchian_and_volume() {
3017 let group = build_breakout_volume("BTC-USD");
3018 let active = group.playbooks.get("active").unwrap();
3019 let indicators: Vec<&str> = active
3020 .entry_rules
3021 .iter()
3022 .map(|r| r.indicator.as_str())
3023 .collect();
3024 assert!(indicators.contains(&"DONCHIAN_UPPER"));
3025 assert!(indicators.contains(&"VOL_ZSCORE"));
3026 }
3027
3028 #[test]
3029 fn test_keltner_squeeze_has_bb_and_kc() {
3030 let group = build_keltner_squeeze("BTC-USD");
3031 let active = group.playbooks.get("active").unwrap();
3032 let indicators: Vec<&str> = active
3033 .entry_rules
3034 .iter()
3035 .map(|r| r.indicator.as_str())
3036 .collect();
3037 assert!(indicators.contains(&"BB_UPPER"));
3038 assert!(indicators.contains(&"KC_UPPER"));
3039 }
3040
3041 #[test]
3044 fn test_confluence_has_triple_confirmation() {
3045 let group = build_confluence("BTC-USD");
3046 let pb = group.playbooks.get("strong_trend").unwrap();
3047 let indicators: Vec<&str> = pb
3048 .entry_rules
3049 .iter()
3050 .map(|r| r.indicator.as_str())
3051 .collect();
3052 assert!(indicators.contains(&"SUPERTREND_DIR"));
3053 assert!(indicators.contains(&"RSI"));
3054 assert!(indicators.contains(&"ADX"));
3055 }
3056
3057 #[test]
3058 fn test_upgraded_templates_have_entry_exit_rules() {
3059 let upgraded_ids = [
3061 "supertrend",
3062 "bb_confirmed",
3063 "donchian_breakout",
3064 "adx_di_crossover",
3065 "ma_crossover",
3066 "cta_trend_following",
3067 "keltner_squeeze",
3068 "macd_momentum",
3069 "zscore_reversion",
3070 "atr_breakout",
3071 "breakout_volume",
3072 "confluence",
3073 "pure_trend_following",
3074 "mean_reversion_expert",
3075 "volatility_hunter",
3076 "conservative",
3077 ];
3078 let mut count = 0;
3079 for id in &upgraded_ids {
3080 let group = build_template(id, "BTC-USD").unwrap();
3081 let has_entry_exit = group
3083 .playbooks
3084 .values()
3085 .any(|pb| !pb.entry_rules.is_empty() && !pb.exit_rules.is_empty());
3086 if has_entry_exit {
3087 count += 1;
3088 }
3089 }
3090 assert!(
3091 count >= 3,
3092 "At least 3 upgraded templates should have non-empty entry_rules and exit_rules, got {}",
3093 count
3094 );
3095 }
3096
3097 #[test]
3098 fn test_upgraded_templates_have_timeout_secs() {
3099 let upgraded_ids = [
3100 "supertrend",
3101 "bb_confirmed",
3102 "donchian_breakout",
3103 "macd_momentum",
3104 "confluence",
3105 "ma_crossover",
3106 ];
3107 for id in &upgraded_ids {
3108 let group = build_template(id, "BTC-USD").unwrap();
3109 let active_pb = group
3110 .playbooks
3111 .values()
3112 .find(|pb| !pb.entry_rules.is_empty());
3113 assert!(
3114 active_pb.is_some(),
3115 "{} should have an active playbook with entry_rules",
3116 id
3117 );
3118 let pb = active_pb.unwrap();
3119 assert_eq!(
3120 pb.timeout_secs,
3121 Some(3600),
3122 "{} should have timeout_secs=3600",
3123 id
3124 );
3125 }
3126 }
3127
3128 #[test]
3129 fn test_upgraded_templates_legacy_rules_empty() {
3130 let upgraded_ids = [
3131 "supertrend",
3132 "bb_confirmed",
3133 "donchian_breakout",
3134 "adx_di_crossover",
3135 "ma_crossover",
3136 "cta_trend_following",
3137 "keltner_squeeze",
3138 "macd_momentum",
3139 "zscore_reversion",
3140 "atr_breakout",
3141 "breakout_volume",
3142 "confluence",
3143 ];
3144 for id in &upgraded_ids {
3145 let group = build_template(id, "BTC-USD").unwrap();
3146 for (name, pb) in &group.playbooks {
3147 assert!(
3148 pb.rules.is_empty(),
3149 "{}/{} should have empty legacy rules",
3150 id,
3151 name
3152 );
3153 }
3154 }
3155 }
3156
3157 #[test]
3158 fn test_confluence_requires_adx_regime() {
3159 let group = build_confluence("BTC-USD");
3160 assert!(!group.regime_rules.is_empty());
3161 let regime_names: Vec<&str> = group
3162 .regime_rules
3163 .iter()
3164 .map(|r| r.regime.as_str())
3165 .collect();
3166 assert!(regime_names.contains(&"strong_trend"));
3167 }
3168
3169 #[test]
3172 fn test_new_template_serialization_roundtrip() {
3173 let new_ids = [
3174 "macd_momentum",
3175 "rsi_momentum",
3176 "rsi_50_crossover",
3177 "bollinger_bands",
3178 "rsi_reversion",
3179 "zscore_reversion",
3180 "bb_confirmed",
3181 "ma_crossover",
3182 "supertrend",
3183 "donchian_breakout",
3184 "cta_trend_following",
3185 "adx_di_crossover",
3186 "chandelier_exit",
3187 "atr_breakout",
3188 "breakout_volume",
3189 "keltner_squeeze",
3190 "confluence",
3191 ];
3192 for id in &new_ids {
3193 let group = build_template(id, "SOL-USD").unwrap();
3194 let json = serde_json::to_string(&group).unwrap();
3195 let parsed: StrategyGroup = serde_json::from_str(&json).unwrap();
3196 assert_eq!(parsed.name, group.name);
3197 assert_eq!(parsed.symbol, "SOL-USD");
3198 assert_eq!(parsed.playbooks.len(), group.playbooks.len());
3199 }
3200 }
3201
3202 #[test]
3205 fn test_all_new_templates_have_rules() {
3206 let new_ids = [
3207 "macd_momentum",
3208 "rsi_momentum",
3209 "rsi_50_crossover",
3210 "bollinger_bands",
3211 "rsi_reversion",
3212 "zscore_reversion",
3213 "bb_confirmed",
3214 "ma_crossover",
3215 "supertrend",
3216 "donchian_breakout",
3217 "cta_trend_following",
3218 "adx_di_crossover",
3219 "chandelier_exit",
3220 "atr_breakout",
3221 "breakout_volume",
3222 "keltner_squeeze",
3223 "confluence",
3224 ];
3225 for id in &new_ids {
3226 let group = build_template(id, "BTC-USD").unwrap();
3227 let active_pb = group
3229 .playbooks
3230 .iter()
3231 .find(|(k, _)| *k != "inactive" && *k != "default")
3232 .map(|(_, v)| v);
3233 assert!(active_pb.is_some(), "{} should have an active playbook", id);
3234 let pb = active_pb.unwrap();
3235 assert!(
3236 !pb.entry_rules.is_empty(),
3237 "{} active playbook should have entry_rules",
3238 id
3239 );
3240 assert!(
3241 !pb.exit_rules.is_empty(),
3242 "{} active playbook should have exit_rules",
3243 id
3244 );
3245 assert!(
3246 pb.rules.is_empty(),
3247 "{} active playbook legacy rules should be empty",
3248 id
3249 );
3250 assert!(
3251 !pb.system_prompt.is_empty(),
3252 "{} active playbook should have a system prompt",
3253 id
3254 );
3255 }
3256 }
3257
3258 #[test]
3261 fn all_templates_have_valid_indicators() {
3262 let templates = list_all_templates();
3263 for template in &templates {
3264 if let Some(sg) = build_template(&template.id, "BTC-PERP") {
3265 let warnings = validate_strategy_indicators(&sg);
3266 assert!(
3267 warnings.is_empty(),
3268 "Template '{}' has invalid indicators: {:?}",
3269 template.id,
3270 warnings
3271 );
3272 }
3273 }
3274 }
3275}