Skip to main content

hyper_strategy/
strategy_templates.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::strategy_config::{HysteresisConfig, Playbook, RegimeRule, StrategyGroup, TaRule};
6
7// ---------------------------------------------------------------------------
8// Template metadata (returned by list)
9// ---------------------------------------------------------------------------
10
11#[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
21// ---------------------------------------------------------------------------
22// Catalogue
23// ---------------------------------------------------------------------------
24
25const TEMPLATE_IDS: [&str; 22] = [
26    // Original 5
27    "adaptive_trend",
28    "pure_trend_following",
29    "mean_reversion_expert",
30    "volatility_hunter",
31    "conservative",
32    // Momentum (#222)
33    "macd_momentum",
34    "rsi_momentum",
35    "rsi_50_crossover",
36    // Mean Reversion (#221)
37    "bollinger_bands",
38    "rsi_reversion",
39    "zscore_reversion",
40    "bb_confirmed",
41    // Trend Following (#220)
42    "ma_crossover",
43    "supertrend",
44    "donchian_breakout",
45    "cta_trend_following",
46    "adx_di_crossover",
47    "chandelier_exit",
48    // Volatility (#223)
49    "atr_breakout",
50    "breakout_volume",
51    "keltner_squeeze",
52    // Confluence (#227)
53    "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        // --- Momentum (#222) ---
94        "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        // --- Mean Reversion (#221) ---
116        "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        // --- Trend Following (#220) ---
145        "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        // --- Volatility (#223) ---
188        "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 (#227) ---
210        "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
221// ---------------------------------------------------------------------------
222// Template builders
223// ---------------------------------------------------------------------------
224
225pub 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        // Momentum (#222)
233        "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        // Mean Reversion (#221)
237        "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        // Trend Following (#220)
242        "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        // Volatility (#223)
249        "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 (#227)
253        "confluence" => Some(build_confluence(symbol)),
254        _ => None,
255    }
256}
257
258// ---- 1. Adaptive Trend ----
259
260fn 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
424// ---- 2. Pure Trend Following ----
425
426fn build_pure_trend_following(symbol: &str) -> StrategyGroup {
427    // Use EMA 12/26 alignment for trend direction (these are the EMAs
428    // that TechnicalIndicators actually calculates).
429    // For gt/lt with two-param EMA, the rule engine compares first param vs second param.
430    // But since evaluate_condition for "gt" uses threshold (not secondary indicator),
431    // we use SuperTrend direction instead — it's a cleaner trend signal.
432    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
551// ---- 3. Mean Reversion Expert ----
552
553fn 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
673// ---- 4. Volatility Hunter ----
674
675fn 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
808// ---- 5. Conservative ----
809
810fn 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
930// =========================================================================
931// Phase 3 Strategy Templates (#220, #221, #222, #223, #227)
932// =========================================================================
933
934// Helper to build a simple strategy group with a single "active" regime and
935// "default" fallback.  Most of the 17 new templates follow this pattern.
936fn 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
980// Helper: ADX regime filter rules used by mean reversion strategies (#218).
981fn 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
1012// ---- Momentum (#222) ----
1013
1014fn 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
1275// ---- Mean Reversion (#221) ----
1276
1277fn 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
1505// ---- Trend Following (#220) ----
1506
1507fn 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
1938// ---- Volatility (#223) ----
1939
1940fn 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
2161// ---- Confluence (#227) ----
2162
2163fn 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
2257// ---------------------------------------------------------------------------
2258// Helpers
2259// ---------------------------------------------------------------------------
2260
2261fn 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
2269// ---------------------------------------------------------------------------
2270// Public helpers (used by Tauri wrappers in the app crate)
2271// ---------------------------------------------------------------------------
2272
2273pub 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
2285// ---------------------------------------------------------------------------
2286// Validation
2287// ---------------------------------------------------------------------------
2288
2289/// Validate that all indicators referenced in a strategy group are resolvable.
2290///
2291/// Returns a list of warnings for any unsupported indicators or conditions.
2292pub 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    // Check regime rules
2355    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    // Check playbook entry/exit rules
2373    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// ---------------------------------------------------------------------------
2396// Tests
2397// ---------------------------------------------------------------------------
2398
2399#[cfg(test)]
2400mod tests {
2401    use super::*;
2402
2403    // --- template_meta ---
2404
2405    #[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    // --- build_template ---
2430
2431    #[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    // --- Adaptive Trend specifics ---
2450
2451    #[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    // --- Pure Trend Following specifics ---
2486
2487    #[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    // --- Mean Reversion Expert specifics ---
2496
2497    #[test]
2498    fn test_adaptive_trend_entry_exit_rules() {
2499        let group = build_adaptive_trend("BTC-USD");
2500
2501        // trending_up: 1 entry (buy), 1 exit (close), side=long
2502        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        // trending_down: 1 entry (sell), 1 exit (close), side=short
2514        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        // ranging: 2 entries (buy + sell), 1 exit (close), side=None
2526        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        // high_vol: no entry, no exit, max_position_size=0
2539        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    // --- Conservative specifics ---
2568
2569    #[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    // --- Volatility Hunter specifics ---
2608
2609    #[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    // --- Serialization ---
2622
2623    #[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    // --- IDs are unique ---
2646
2647    #[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    // =========================================================================
2658    // Phase 3 Strategy Template Tests (#220, #221, #222, #223, #227)
2659    // =========================================================================
2660
2661    // --- All 17 new templates build successfully ---
2662
2663    #[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    // --- Every new template has metadata ---
2696
2697    #[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    // --- Momentum strategies (#222) ---
2730
2731    #[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    // --- Mean Reversion strategies (#221) ---
2789
2790    #[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            // Active playbook should be in the "ranging" regime
2821            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    // --- Trend Following strategies (#220) ---
2890
2891    #[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        // Entry uses 20, exit uses 10
2947        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    // --- Volatility strategies (#223) ---
2996
2997    #[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    // --- Confluence (#227) ---
3042
3043    #[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        // Verify at least 3 upgraded templates have non-empty entry_rules and exit_rules
3060        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            // Find any playbook with non-empty entry_rules AND exit_rules
3082            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    // --- Serialization of all new templates ---
3170
3171    #[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    // --- All playbooks have non-empty rules in their active playbook ---
3203
3204    #[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            // Find the active playbook (not "inactive" or "default")
3228            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    // --- validate_strategy_indicators ---
3259
3260    #[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}