Skip to main content

hyper_playbook/
engine.rs

1use serde::{Deserialize, Serialize};
2
3use hyper_strategy::rule_engine::evaluate_rules;
4use hyper_strategy::strategy_config::{Playbook, StrategyGroup};
5use hyper_ta::technical_analysis::TechnicalIndicators;
6use motosan_ta_stream::snapshot::TaSnapshot;
7
8use crate::executor::{OrderExecutor, PlaybookOrderParams, RiskCheckedOrderExecutor};
9use crate::fsm::{PlaybookFsm, PlaybookState};
10use crate::regime::RegimeDetector;
11
12use hyper_risk::risk::RiskConfig;
13
14// ---------------------------------------------------------------------------
15// TickAction / TickResult
16// ---------------------------------------------------------------------------
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "snake_case", tag = "type")]
20pub enum TickAction {
21    None,
22    OrderPlaced {
23        order_id: String,
24        side: String,
25        size: f64,
26    },
27    OrderFilled {
28        position_id: String,
29        entry_price: f64,
30    },
31    OrderCancelled {
32        order_id: String,
33        reason: String,
34    },
35    PositionClosed {
36        reason: String,
37    },
38    ForceClose {
39        reason: String,
40    },
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct TickResult {
46    pub regime: String,
47    pub regime_changed: bool,
48    pub previous_regime: Option<String>,
49    pub fsm_state: String,
50    pub action: TickAction,
51    pub triggered_rules: Vec<String>,
52}
53
54// ---------------------------------------------------------------------------
55// PlaybookEngine
56// ---------------------------------------------------------------------------
57
58pub struct PlaybookEngine {
59    symbol: String,
60    regime_detector: RegimeDetector,
61    fsm: PlaybookFsm,
62    executor: Box<dyn OrderExecutor>,
63    prev_indicators: Option<TechnicalIndicators>,
64    default_timeout_secs: u64,
65}
66
67impl PlaybookEngine {
68    pub fn new(
69        symbol: String,
70        strategy_group: StrategyGroup,
71        executor: Box<dyn OrderExecutor>,
72    ) -> Self {
73        Self {
74            symbol,
75            regime_detector: RegimeDetector::new(strategy_group),
76            fsm: PlaybookFsm::new(),
77            executor,
78            prev_indicators: None,
79            default_timeout_secs: 300,
80        }
81    }
82
83    /// Create an engine that wraps the executor with risk checks.
84    ///
85    /// If `risk_config` is `Some`, the executor is wrapped in a
86    /// [`RiskCheckedOrderExecutor`] so every order goes through pre-trade
87    /// risk validation. If `None`, the inner executor is used directly
88    /// (identical to [`Self::new`]).
89    pub fn new_with_risk_config(
90        symbol: String,
91        strategy_group: StrategyGroup,
92        executor: Box<dyn OrderExecutor>,
93        risk_config: Option<RiskConfig>,
94    ) -> Self {
95        let final_executor: Box<dyn OrderExecutor> = match risk_config {
96            Some(config) => Box::new(RiskCheckedOrderExecutor::with_config(executor, config)),
97            None => executor,
98        };
99        Self::new(symbol, strategy_group, final_executor)
100    }
101
102    /// Return current FSM state (for external inspection / tests).
103    pub fn fsm_state(&self) -> &PlaybookState {
104        self.fsm.state()
105    }
106
107    /// Tick with a `TaSnapshot` (new API). Converts to `TechnicalIndicators`
108    /// internally via `snapshot_to_indicators`.
109    pub async fn tick_snapshot(&mut self, snapshot: &TaSnapshot, now: u64) -> TickResult {
110        let indicators = hyper_ta::dynamic::snapshot_to_indicators(snapshot);
111        self.tick(&indicators, now).await
112    }
113
114    /// Core tick — called once per interval.
115    pub async fn tick(&mut self, indicators: &TechnicalIndicators, now: u64) -> TickResult {
116        // 1. Detect regime
117        let regime = self.regime_detector.detect(indicators, now).to_string();
118        let regime_changed = self.regime_detector.regime_changed();
119        let previous_regime = self
120            .regime_detector
121            .previous_regime()
122            .map(|s| s.to_string());
123
124        // 2. Handle regime change — force-close if new regime has no playbook
125        if regime_changed {
126            if let PlaybookState::InPosition { position_id, .. } = self.fsm.state().clone() {
127                let new_pb = self.regime_detector.current_playbook();
128                let should_force_close =
129                    new_pb.is_none() || new_pb.map(|p| p.max_position_size == 0.0).unwrap_or(false);
130                if should_force_close {
131                    if let Ok(order_id) = self
132                        .executor
133                        .close_position(&position_id, "regime_change")
134                        .await
135                    {
136                        let _ = self.fsm.enter_pending_exit(order_id, now);
137                    }
138                    self.prev_indicators = Some(indicators.clone());
139                    return TickResult {
140                        regime,
141                        regime_changed,
142                        previous_regime,
143                        fsm_state: format!("{:?}", self.fsm.state()),
144                        action: TickAction::ForceClose {
145                            reason: "regime_change".into(),
146                        },
147                        triggered_rules: vec![],
148                    };
149                }
150            }
151            // Idle / Pending states: no special action on regime change
152        }
153
154        // 3. Get current playbook
155        let playbook = match self.regime_detector.current_playbook() {
156            Some(pb) => pb.clone(),
157            None => {
158                self.prev_indicators = Some(indicators.clone());
159                return TickResult {
160                    regime,
161                    regime_changed,
162                    previous_regime,
163                    fsm_state: format!("{:?}", self.fsm.state()),
164                    action: TickAction::None,
165                    triggered_rules: vec![],
166                };
167            }
168        };
169
170        // 4. FSM evaluate based on current state
171        let (action, triggered_rules) = match self.fsm.state().clone() {
172            PlaybookState::Idle => self.handle_idle(&playbook, indicators, now).await,
173            PlaybookState::PendingEntry {
174                order_id,
175                placed_at,
176            } => {
177                self.handle_pending_entry(&playbook, indicators, &order_id, placed_at, now)
178                    .await
179            }
180            PlaybookState::InPosition {
181                position_id,
182                entry_price,
183            } => {
184                let result = self
185                    .handle_in_position(
186                        &playbook,
187                        indicators,
188                        &position_id,
189                        entry_price,
190                        now,
191                        &regime,
192                        regime_changed,
193                        &previous_regime,
194                    )
195                    .await;
196                match result {
197                    InPositionResult::EarlyReturn(tick_result) => {
198                        self.prev_indicators = Some(indicators.clone());
199                        return tick_result;
200                    }
201                    InPositionResult::Normal(action, triggered) => (action, triggered),
202                }
203            }
204            PlaybookState::PendingExit {
205                order_id,
206                placed_at,
207            } => {
208                self.handle_pending_exit(&playbook, &order_id, placed_at, now)
209                    .await
210            }
211        };
212
213        // 5. Store prev indicators for cross-over detection
214        self.prev_indicators = Some(indicators.clone());
215
216        TickResult {
217            regime,
218            regime_changed,
219            previous_regime,
220            fsm_state: format!("{:?}", self.fsm.state()),
221            action,
222            triggered_rules,
223        }
224    }
225
226    // -- Idle --
227
228    async fn handle_idle(
229        &mut self,
230        playbook: &Playbook,
231        indicators: &TechnicalIndicators,
232        now: u64,
233    ) -> (TickAction, Vec<String>) {
234        let evals = evaluate_rules(
235            playbook.effective_entry_rules(),
236            indicators,
237            self.prev_indicators.as_ref(),
238        );
239        let triggered: Vec<String> = evals
240            .iter()
241            .filter(|e| e.triggered)
242            .map(|e| e.signal.clone())
243            .collect();
244
245        if !triggered.is_empty() && playbook.max_position_size > 0.0 {
246            let side = determine_side(playbook, &triggered);
247            let params = PlaybookOrderParams {
248                symbol: self.symbol.clone(),
249                side: side.clone(),
250                size: playbook.max_position_size,
251                price: None,
252                reduce_only: false,
253            };
254            match self.executor.place_order(&params).await {
255                Ok(order_id) => {
256                    let _ = self.fsm.enter_pending_entry(order_id.clone(), now);
257                    (
258                        TickAction::OrderPlaced {
259                            order_id,
260                            side,
261                            size: playbook.max_position_size,
262                        },
263                        triggered,
264                    )
265                }
266                Err(_) => (TickAction::None, triggered),
267            }
268        } else {
269            (TickAction::None, triggered)
270        }
271    }
272
273    // -- PendingEntry --
274
275    async fn handle_pending_entry(
276        &mut self,
277        playbook: &Playbook,
278        indicators: &TechnicalIndicators,
279        order_id: &str,
280        placed_at: u64,
281        now: u64,
282    ) -> (TickAction, Vec<String>) {
283        let timeout = playbook.timeout_secs.unwrap_or(self.default_timeout_secs);
284        if now - placed_at > timeout {
285            let _ = self.executor.cancel_order(order_id).await;
286            let _ = self.fsm.cancel_entry();
287            return (
288                TickAction::OrderCancelled {
289                    order_id: order_id.to_string(),
290                    reason: "timeout".into(),
291                },
292                vec![],
293            );
294        }
295        match self.executor.is_filled(order_id).await {
296            Ok(true) => {
297                let price = current_price(indicators);
298                let position_id = format!("pos-{}", order_id);
299                let _ = self.fsm.confirm_entry(position_id.clone(), price);
300                (
301                    TickAction::OrderFilled {
302                        position_id,
303                        entry_price: price,
304                    },
305                    vec![],
306                )
307            }
308            _ => (TickAction::None, vec![]),
309        }
310    }
311
312    // -- InPosition --
313
314    #[allow(clippy::too_many_arguments)]
315    async fn handle_in_position(
316        &mut self,
317        playbook: &Playbook,
318        indicators: &TechnicalIndicators,
319        position_id: &str,
320        entry_price: f64,
321        now: u64,
322        regime: &str,
323        regime_changed: bool,
324        previous_regime: &Option<String>,
325    ) -> InPositionResult {
326        let price = current_price(indicators);
327
328        // Check hard stop-loss
329        if let Some(sl_pct) = playbook.stop_loss_pct {
330            let sl_price = entry_price * (1.0 - sl_pct / 100.0);
331            if price <= sl_price {
332                if let Ok(oid) = self.executor.close_position(position_id, "stop_loss").await {
333                    let _ = self.fsm.enter_pending_exit(oid, now);
334                    return InPositionResult::EarlyReturn(TickResult {
335                        regime: regime.to_string(),
336                        regime_changed,
337                        previous_regime: previous_regime.clone(),
338                        fsm_state: format!("{:?}", self.fsm.state()),
339                        action: TickAction::PositionClosed {
340                            reason: "stop_loss".into(),
341                        },
342                        triggered_rules: vec![],
343                    });
344                }
345            }
346        }
347
348        // Check hard take-profit
349        if let Some(tp_pct) = playbook.take_profit_pct {
350            let tp_price = entry_price * (1.0 + tp_pct / 100.0);
351            if price >= tp_price {
352                if let Ok(oid) = self
353                    .executor
354                    .close_position(position_id, "take_profit")
355                    .await
356                {
357                    let _ = self.fsm.enter_pending_exit(oid, now);
358                    return InPositionResult::EarlyReturn(TickResult {
359                        regime: regime.to_string(),
360                        regime_changed,
361                        previous_regime: previous_regime.clone(),
362                        fsm_state: format!("{:?}", self.fsm.state()),
363                        action: TickAction::PositionClosed {
364                            reason: "take_profit".into(),
365                        },
366                        triggered_rules: vec![],
367                    });
368                }
369            }
370        }
371
372        // Evaluate exit rules
373        let evals = evaluate_rules(
374            playbook.effective_exit_rules(),
375            indicators,
376            self.prev_indicators.as_ref(),
377        );
378        let triggered: Vec<String> = evals
379            .iter()
380            .filter(|e| e.triggered)
381            .map(|e| e.signal.clone())
382            .collect();
383        if !triggered.is_empty() {
384            if let Ok(oid) = self.executor.close_position(position_id, "exit_rule").await {
385                let _ = self.fsm.enter_pending_exit(oid, now);
386                return InPositionResult::Normal(
387                    TickAction::PositionClosed {
388                        reason: "exit_rule".into(),
389                    },
390                    triggered,
391                );
392            }
393        }
394
395        InPositionResult::Normal(TickAction::None, vec![])
396    }
397
398    // -- PendingExit --
399
400    async fn handle_pending_exit(
401        &mut self,
402        playbook: &Playbook,
403        order_id: &str,
404        placed_at: u64,
405        now: u64,
406    ) -> (TickAction, Vec<String>) {
407        let timeout = playbook.timeout_secs.unwrap_or(self.default_timeout_secs);
408        if now - placed_at > timeout {
409            self.fsm.force_idle();
410            return (
411                TickAction::OrderCancelled {
412                    order_id: order_id.to_string(),
413                    reason: "exit_timeout".into(),
414                },
415                vec![],
416            );
417        }
418        match self.executor.is_filled(order_id).await {
419            Ok(true) => {
420                let _ = self.fsm.confirm_exit();
421                (
422                    TickAction::PositionClosed {
423                        reason: "exit_filled".into(),
424                    },
425                    vec![],
426                )
427            }
428            _ => (TickAction::None, vec![]),
429        }
430    }
431}
432
433// ---------------------------------------------------------------------------
434// Helpers
435// ---------------------------------------------------------------------------
436
437enum InPositionResult {
438    EarlyReturn(TickResult),
439    Normal(TickAction, Vec<String>),
440}
441
442/// Extract a "current price" from indicators. Uses sma_20 as a reasonable
443/// proxy since `TechnicalIndicators` does not carry a raw close field.
444fn current_price(indicators: &TechnicalIndicators) -> f64 {
445    indicators
446        .sma_20
447        .or(indicators.ema_12)
448        .or(indicators.bb_middle)
449        .unwrap_or(0.0)
450}
451
452/// Determine order side from playbook config or from signal names.
453fn determine_side(playbook: &Playbook, triggered_signals: &[String]) -> String {
454    // 1. Explicit playbook side
455    if let Some(ref side) = playbook.side {
456        return side.clone();
457    }
458    // 2. Infer from signal names
459    for sig in triggered_signals {
460        let lower = sig.to_lowercase();
461        if lower.contains("buy") || lower.contains("long") || lower.contains("bull") {
462            return "buy".to_string();
463        }
464        if lower.contains("sell") || lower.contains("short") || lower.contains("bear") {
465            return "sell".to_string();
466        }
467    }
468    // 3. Default
469    "buy".to_string()
470}
471
472// ---------------------------------------------------------------------------
473// Tests
474// ---------------------------------------------------------------------------
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::executor::PaperOrderExecutor;
480    use hyper_strategy::strategy_config::{
481        HysteresisConfig, Playbook, RegimeRule, StrategyGroup, TaRule,
482    };
483    use std::collections::HashMap;
484
485    // -- Helpers --
486
487    fn make_ta_rule(
488        indicator: &str,
489        params: Vec<f64>,
490        condition: &str,
491        threshold: f64,
492        signal: &str,
493    ) -> TaRule {
494        TaRule {
495            indicator: indicator.to_string(),
496            params,
497            condition: condition.to_string(),
498            threshold,
499            threshold_upper: None,
500            signal: signal.to_string(),
501            action: None,
502        }
503    }
504
505    fn make_ta_rule_between(
506        indicator: &str,
507        params: Vec<f64>,
508        lo: f64,
509        hi: f64,
510        signal: &str,
511    ) -> TaRule {
512        TaRule {
513            indicator: indicator.to_string(),
514            params,
515            condition: "between".to_string(),
516            threshold: lo,
517            threshold_upper: Some(hi),
518            signal: signal.to_string(),
519            action: None,
520        }
521    }
522
523    /// Build a simple strategy group with:
524    /// - regime rules: ADX > 50 => "bull", ADX < 10 => "bear"  (uses ADX, not RSI)
525    /// - default regime: "neutral"
526    /// - playbooks for bull, bear, neutral
527    /// - hysteresis: confirmation_count=1, min_hold_secs=0 (fast switch for tests)
528    ///
529    /// Entry/exit rules use RSI, which is independent from the ADX-based regime rules.
530    fn simple_strategy_group() -> StrategyGroup {
531        let mut playbooks = HashMap::new();
532
533        // bull playbook: entry when RSI > 60, exit when RSI < 40
534        playbooks.insert(
535            "bull".to_string(),
536            Playbook {
537                rules: vec![],
538                entry_rules: vec![make_ta_rule("RSI", vec![14.0], "gt", 60.0, "buy_momentum")],
539                exit_rules: vec![make_ta_rule("RSI", vec![14.0], "lt", 40.0, "momentum_lost")],
540                system_prompt: "bull".into(),
541                max_position_size: 1000.0,
542                stop_loss_pct: Some(5.0),
543                take_profit_pct: Some(10.0),
544                timeout_secs: Some(600),
545                side: Some("buy".into()),
546            },
547        );
548
549        // bear playbook: has zero max_position_size (no trading allowed)
550        playbooks.insert(
551            "bear".to_string(),
552            Playbook {
553                rules: vec![],
554                entry_rules: vec![],
555                exit_rules: vec![],
556                system_prompt: "bear".into(),
557                max_position_size: 0.0,
558                stop_loss_pct: Some(3.0),
559                take_profit_pct: None,
560                timeout_secs: None,
561                side: None,
562            },
563        );
564
565        // neutral playbook: entry when RSI < 30, exit when RSI between 45-55
566        playbooks.insert(
567            "neutral".to_string(),
568            Playbook {
569                rules: vec![],
570                entry_rules: vec![make_ta_rule("RSI", vec![14.0], "lt", 30.0, "oversold_buy")],
571                exit_rules: vec![make_ta_rule_between(
572                    "RSI",
573                    vec![14.0],
574                    45.0,
575                    55.0,
576                    "rsi_neutral_exit",
577                )],
578                system_prompt: "neutral".into(),
579                max_position_size: 500.0,
580                stop_loss_pct: Some(5.0),
581                take_profit_pct: Some(10.0),
582                timeout_secs: Some(300),
583                side: None,
584            },
585        );
586
587        StrategyGroup {
588            id: "sg-test".into(),
589            name: "Test".into(),
590            vault_address: None,
591            is_active: true,
592            created_at: "2026-01-01T00:00:00Z".into(),
593            symbol: "BTC-USD".into(),
594            interval_secs: 300,
595            regime_rules: vec![
596                RegimeRule {
597                    regime: "bull".into(),
598                    conditions: vec![make_ta_rule("ADX", vec![14.0], "gt", 50.0, "strong_bull")],
599                    priority: 1,
600                },
601                RegimeRule {
602                    regime: "bear".into(),
603                    conditions: vec![make_ta_rule("ADX", vec![14.0], "lt", 10.0, "weak_bear")],
604                    priority: 2,
605                },
606            ],
607            default_regime: "neutral".into(),
608            hysteresis: HysteresisConfig {
609                min_hold_secs: 0,
610                confirmation_count: 1,
611            },
612            playbooks,
613        }
614    }
615
616    fn make_indicators(f: impl FnOnce(&mut TechnicalIndicators)) -> TechnicalIndicators {
617        let mut ind = TechnicalIndicators::empty();
618        f(&mut ind);
619        ind
620    }
621
622    fn new_engine() -> PlaybookEngine {
623        PlaybookEngine::new(
624            "BTC-USD".into(),
625            simple_strategy_group(),
626            Box::new(PaperOrderExecutor::new()),
627        )
628    }
629
630    // -----------------------------------------------------------------------
631    // 1. Happy path: Idle -> entry -> PendingEntry -> filled -> InPosition
632    //    -> exit rule -> PendingExit -> filled -> Idle
633    // -----------------------------------------------------------------------
634
635    #[tokio::test]
636    async fn test_happy_path_full_cycle() {
637        let mut engine = new_engine();
638
639        // Default regime is "neutral" (ADX between 10-50).
640        // Entry rule: RSI < 30.
641        // ADX=30 keeps us in neutral regime.
642        let ind = make_indicators(|i| {
643            i.rsi_14 = Some(25.0);
644            i.adx_14 = Some(30.0);
645            i.sma_20 = Some(50000.0);
646        });
647        let r = engine.tick(&ind, 1000).await;
648        assert_eq!(r.regime, "neutral");
649        assert!(!r.triggered_rules.is_empty(), "should trigger oversold_buy");
650        assert!(
651            matches!(r.action, TickAction::OrderPlaced { .. }),
652            "should place order, got {:?}",
653            r.action
654        );
655
656        // PaperOrderExecutor always fills instantly, so next tick should fill
657        let r2 = engine.tick(&ind, 1001).await;
658        assert!(
659            matches!(r2.action, TickAction::OrderFilled { .. }),
660            "should be filled, got {:?}",
661            r2.action
662        );
663
664        // Now InPosition. RSI between 45-55 triggers exit.
665        let ind_exit = make_indicators(|i| {
666            i.rsi_14 = Some(50.0);
667            i.adx_14 = Some(30.0);
668            i.sma_20 = Some(51000.0);
669        });
670        let r3 = engine.tick(&ind_exit, 2000).await;
671        assert_eq!(
672            r3.action,
673            TickAction::PositionClosed {
674                reason: "exit_rule".into()
675            }
676        );
677        assert!(r3.triggered_rules.contains(&"rsi_neutral_exit".to_string()));
678
679        // PendingExit fills next tick
680        let r4 = engine.tick(&ind_exit, 2001).await;
681        assert_eq!(
682            r4.action,
683            TickAction::PositionClosed {
684                reason: "exit_filled".into()
685            }
686        );
687        assert!(engine.fsm_state() == &PlaybookState::Idle);
688    }
689
690    // -----------------------------------------------------------------------
691    // 2. Stop loss
692    // -----------------------------------------------------------------------
693
694    #[tokio::test]
695    async fn test_stop_loss() {
696        let mut engine = new_engine();
697
698        // Enter position via neutral playbook (RSI < 30, ADX=30 stays neutral)
699        let ind_entry = make_indicators(|i| {
700            i.rsi_14 = Some(25.0);
701            i.adx_14 = Some(30.0);
702            i.sma_20 = Some(50000.0);
703        });
704        engine.tick(&ind_entry, 1000).await; // place order
705        engine.tick(&ind_entry, 1001).await; // fill
706
707        assert!(matches!(
708            engine.fsm_state(),
709            PlaybookState::InPosition { .. }
710        ));
711
712        // Price drops to trigger SL (5% => 47500)
713        let ind_sl = make_indicators(|i| {
714            i.rsi_14 = Some(35.0);
715            i.adx_14 = Some(30.0);
716            i.sma_20 = Some(47000.0); // below 47500
717        });
718        let r = engine.tick(&ind_sl, 3000).await;
719        assert_eq!(
720            r.action,
721            TickAction::PositionClosed {
722                reason: "stop_loss".into()
723            }
724        );
725    }
726
727    // -----------------------------------------------------------------------
728    // 3. Take profit
729    // -----------------------------------------------------------------------
730
731    #[tokio::test]
732    async fn test_take_profit() {
733        let mut engine = new_engine();
734
735        let ind_entry = make_indicators(|i| {
736            i.rsi_14 = Some(25.0);
737            i.adx_14 = Some(30.0);
738            i.sma_20 = Some(50000.0);
739        });
740        engine.tick(&ind_entry, 1000).await;
741        engine.tick(&ind_entry, 1001).await;
742
743        // Price rises to trigger TP (10% => 55000)
744        let ind_tp = make_indicators(|i| {
745            i.rsi_14 = Some(35.0); // no exit rule triggered
746            i.adx_14 = Some(30.0);
747            i.sma_20 = Some(56000.0); // above 55000
748        });
749        let r = engine.tick(&ind_tp, 3000).await;
750        assert_eq!(
751            r.action,
752            TickAction::PositionClosed {
753                reason: "take_profit".into()
754            }
755        );
756    }
757
758    // -----------------------------------------------------------------------
759    // 4. Entry timeout
760    // -----------------------------------------------------------------------
761
762    #[tokio::test]
763    async fn test_entry_timeout() {
764        // We need an executor that does NOT auto-fill
765        use crate::executor::ExecutionError;
766        use async_trait::async_trait;
767
768        struct SlowExecutor;
769
770        #[async_trait]
771        impl OrderExecutor for SlowExecutor {
772            async fn place_order(
773                &self,
774                _params: &PlaybookOrderParams,
775            ) -> Result<String, ExecutionError> {
776                Ok("slow-order-1".into())
777            }
778            async fn cancel_order(&self, _order_id: &str) -> Result<(), ExecutionError> {
779                Ok(())
780            }
781            async fn is_filled(&self, _order_id: &str) -> Result<bool, ExecutionError> {
782                Ok(false) // never fills
783            }
784            async fn close_position(
785                &self,
786                _position_id: &str,
787                _reason: &str,
788            ) -> Result<String, ExecutionError> {
789                Ok("close-1".into())
790            }
791        }
792
793        let mut engine = PlaybookEngine::new(
794            "BTC-USD".into(),
795            simple_strategy_group(),
796            Box::new(SlowExecutor),
797        );
798
799        // Trigger entry (neutral playbook, RSI < 30, ADX=30 stays neutral)
800        let ind = make_indicators(|i| {
801            i.rsi_14 = Some(25.0);
802            i.adx_14 = Some(30.0);
803            i.sma_20 = Some(50000.0);
804        });
805        let r = engine.tick(&ind, 1000).await;
806        assert!(matches!(r.action, TickAction::OrderPlaced { .. }));
807
808        // Not filled yet, no timeout (placed_at=1000, timeout=300, now=1100 => 100 < 300)
809        let r2 = engine.tick(&ind, 1100).await;
810        assert_eq!(r2.action, TickAction::None);
811
812        // Timeout (now=1301, 1301-1000=301 > 300)
813        let r3 = engine.tick(&ind, 1301).await;
814        assert!(
815            matches!(r3.action, TickAction::OrderCancelled { ref reason, .. } if reason == "timeout"),
816            "expected timeout cancel, got {:?}",
817            r3.action
818        );
819        assert!(engine.fsm_state() == &PlaybookState::Idle);
820    }
821
822    // -----------------------------------------------------------------------
823    // 5. Regime change force close
824    // -----------------------------------------------------------------------
825
826    #[tokio::test]
827    async fn test_regime_change_force_close() {
828        let mut engine = new_engine();
829
830        // Enter position in neutral regime (RSI < 30, ADX=30)
831        let ind_entry = make_indicators(|i| {
832            i.rsi_14 = Some(25.0);
833            i.adx_14 = Some(30.0);
834            i.sma_20 = Some(50000.0);
835        });
836        engine.tick(&ind_entry, 100).await; // place order
837        engine.tick(&ind_entry, 101).await; // fill
838        assert!(matches!(
839            engine.fsm_state(),
840            PlaybookState::InPosition { .. }
841        ));
842
843        // Now switch to bear regime (ADX < 10) over 2 ticks
844        // Tick 3: ADX=5 => bear detected, starts pending switch
845        let ind_bear = make_indicators(|i| {
846            i.rsi_14 = Some(35.0);
847            i.adx_14 = Some(5.0);
848            i.sma_20 = Some(50000.0);
849        });
850        let r3 = engine.tick(&ind_bear, 200).await;
851        assert_eq!(r3.regime, "neutral"); // pending switch started, not yet switched
852        assert!(!r3.regime_changed);
853
854        // Tick 4: ADX=5 again => bear confirmed, regime changes
855        // InPosition + bear(max_position_size=0) => force close
856        let r4 = engine.tick(&ind_bear, 300).await;
857        assert_eq!(r4.regime, "bear");
858        assert!(r4.regime_changed);
859        assert_eq!(
860            r4.action,
861            TickAction::ForceClose {
862                reason: "regime_change".into()
863            }
864        );
865    }
866
867    // -----------------------------------------------------------------------
868    // 6. No entry when max_position_size = 0
869    // -----------------------------------------------------------------------
870
871    #[tokio::test]
872    async fn test_no_entry_when_zero_position_size() {
873        let mut engine = new_engine();
874
875        // Switch to bear regime (ADX < 10). Use RSI=50 so no entry rule triggers
876        // during the regime switch.
877        let ind_switch = make_indicators(|i| {
878            i.rsi_14 = Some(50.0);
879            i.adx_14 = Some(5.0);
880            i.sma_20 = Some(50000.0);
881        });
882        engine.tick(&ind_switch, 100).await; // pending switch starts
883        let r2 = engine.tick(&ind_switch, 200).await; // switch to bear
884        assert_eq!(r2.regime, "bear");
885        assert!(engine.fsm_state() == &PlaybookState::Idle);
886
887        // Now in bear regime with max_position_size=0.
888        // Even with RSI < 30, bear playbook has no entry rules and zero size.
889        let ind = make_indicators(|i| {
890            i.rsi_14 = Some(20.0);
891            i.adx_14 = Some(5.0);
892            i.sma_20 = Some(50000.0);
893        });
894        let r3 = engine.tick(&ind, 300).await;
895        assert_eq!(r3.action, TickAction::None);
896        assert!(engine.fsm_state() == &PlaybookState::Idle);
897    }
898
899    // -----------------------------------------------------------------------
900    // 7. determine_side()
901    // -----------------------------------------------------------------------
902
903    #[test]
904    fn test_determine_side_from_playbook() {
905        let pb = Playbook {
906            rules: vec![],
907            entry_rules: vec![],
908            exit_rules: vec![],
909            system_prompt: "".into(),
910            max_position_size: 100.0,
911            stop_loss_pct: None,
912            take_profit_pct: None,
913            timeout_secs: None,
914            side: Some("sell".into()),
915        };
916        assert_eq!(determine_side(&pb, &["some_signal".into()]), "sell");
917    }
918
919    #[test]
920    fn test_determine_side_from_signal_buy() {
921        let pb = Playbook {
922            rules: vec![],
923            entry_rules: vec![],
924            exit_rules: vec![],
925            system_prompt: "".into(),
926            max_position_size: 100.0,
927            stop_loss_pct: None,
928            take_profit_pct: None,
929            timeout_secs: None,
930            side: None,
931        };
932        assert_eq!(determine_side(&pb, &["oversold_buy".into()]), "buy");
933    }
934
935    #[test]
936    fn test_determine_side_from_signal_sell() {
937        let pb = Playbook {
938            rules: vec![],
939            entry_rules: vec![],
940            exit_rules: vec![],
941            system_prompt: "".into(),
942            max_position_size: 100.0,
943            stop_loss_pct: None,
944            take_profit_pct: None,
945            timeout_secs: None,
946            side: None,
947        };
948        assert_eq!(determine_side(&pb, &["overbought_sell".into()]), "sell");
949    }
950
951    #[test]
952    fn test_determine_side_from_signal_long() {
953        let pb = Playbook {
954            rules: vec![],
955            entry_rules: vec![],
956            exit_rules: vec![],
957            system_prompt: "".into(),
958            max_position_size: 100.0,
959            stop_loss_pct: None,
960            take_profit_pct: None,
961            timeout_secs: None,
962            side: None,
963        };
964        assert_eq!(determine_side(&pb, &["go_long".into()]), "buy");
965    }
966
967    #[test]
968    fn test_determine_side_from_signal_short() {
969        let pb = Playbook {
970            rules: vec![],
971            entry_rules: vec![],
972            exit_rules: vec![],
973            system_prompt: "".into(),
974            max_position_size: 100.0,
975            stop_loss_pct: None,
976            take_profit_pct: None,
977            timeout_secs: None,
978            side: None,
979        };
980        assert_eq!(determine_side(&pb, &["go_short".into()]), "sell");
981    }
982
983    #[test]
984    fn test_determine_side_default() {
985        let pb = Playbook {
986            rules: vec![],
987            entry_rules: vec![],
988            exit_rules: vec![],
989            system_prompt: "".into(),
990            max_position_size: 100.0,
991            stop_loss_pct: None,
992            take_profit_pct: None,
993            timeout_secs: None,
994            side: None,
995        };
996        assert_eq!(determine_side(&pb, &["some_neutral_signal".into()]), "buy");
997    }
998
999    // -----------------------------------------------------------------------
1000    // 8. No playbook for regime => TickAction::None
1001    // -----------------------------------------------------------------------
1002
1003    #[tokio::test]
1004    async fn test_no_playbook_returns_none() {
1005        // Create a strategy group where the default regime has no playbook
1006        let sg = StrategyGroup {
1007            id: "sg-no-pb".into(),
1008            name: "Test".into(),
1009            vault_address: None,
1010            is_active: true,
1011            created_at: "2026-01-01T00:00:00Z".into(),
1012            symbol: "BTC-USD".into(),
1013            interval_secs: 300,
1014            regime_rules: vec![],
1015            default_regime: "unknown_regime".into(),
1016            hysteresis: HysteresisConfig {
1017                min_hold_secs: 0,
1018                confirmation_count: 1,
1019            },
1020            playbooks: HashMap::new(),
1021        };
1022        let mut engine =
1023            PlaybookEngine::new("BTC-USD".into(), sg, Box::new(PaperOrderExecutor::new()));
1024        let ind = make_indicators(|i| i.rsi_14 = Some(50.0));
1025        let r = engine.tick(&ind, 1000).await;
1026        assert_eq!(r.action, TickAction::None);
1027    }
1028
1029    // -----------------------------------------------------------------------
1030    // 9. TickAction / TickResult serde roundtrip
1031    // -----------------------------------------------------------------------
1032
1033    #[test]
1034    fn test_tick_action_serde_none() {
1035        let a = TickAction::None;
1036        let json = serde_json::to_string(&a).unwrap();
1037        let back: TickAction = serde_json::from_str(&json).unwrap();
1038        assert_eq!(a, back);
1039    }
1040
1041    #[test]
1042    fn test_tick_action_serde_order_placed() {
1043        let a = TickAction::OrderPlaced {
1044            order_id: "o-1".into(),
1045            side: "buy".into(),
1046            size: 100.0,
1047        };
1048        let json = serde_json::to_string(&a).unwrap();
1049        let back: TickAction = serde_json::from_str(&json).unwrap();
1050        assert_eq!(a, back);
1051    }
1052
1053    #[test]
1054    fn test_tick_result_serde() {
1055        let tr = TickResult {
1056            regime: "bull".into(),
1057            regime_changed: true,
1058            previous_regime: Some("neutral".into()),
1059            fsm_state: "Idle".into(),
1060            action: TickAction::None,
1061            triggered_rules: vec!["signal_a".into()],
1062        };
1063        let json = serde_json::to_string(&tr).unwrap();
1064        let back: TickResult = serde_json::from_str(&json).unwrap();
1065        assert_eq!(back.regime, "bull");
1066        assert!(back.regime_changed);
1067        assert_eq!(back.previous_regime, Some("neutral".to_string()));
1068    }
1069
1070    // -----------------------------------------------------------------------
1071    // 10. Exit timeout in PendingExit resets to Idle
1072    // -----------------------------------------------------------------------
1073
1074    #[tokio::test]
1075    async fn test_exit_timeout() {
1076        use crate::executor::ExecutionError;
1077        use async_trait::async_trait;
1078
1079        struct SlowCloseExecutor {
1080            counter: std::sync::atomic::AtomicU64,
1081        }
1082
1083        impl SlowCloseExecutor {
1084            fn new() -> Self {
1085                Self {
1086                    counter: std::sync::atomic::AtomicU64::new(1),
1087                }
1088            }
1089            fn next_id(&self) -> String {
1090                let n = self
1091                    .counter
1092                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1093                format!("order-{}", n)
1094            }
1095        }
1096
1097        #[async_trait]
1098        impl OrderExecutor for SlowCloseExecutor {
1099            async fn place_order(
1100                &self,
1101                _params: &PlaybookOrderParams,
1102            ) -> Result<String, ExecutionError> {
1103                Ok(self.next_id())
1104            }
1105            async fn cancel_order(&self, _order_id: &str) -> Result<(), ExecutionError> {
1106                Ok(())
1107            }
1108            async fn is_filled(&self, order_id: &str) -> Result<bool, ExecutionError> {
1109                // Entry fills, but exit does not
1110                if order_id.starts_with("order-") {
1111                    // First order (entry) fills; close orders don't
1112                    let num: u64 = order_id
1113                        .strip_prefix("order-")
1114                        .unwrap()
1115                        .parse()
1116                        .unwrap_or(0);
1117                    Ok(num <= 1) // only order-1 fills
1118                } else {
1119                    Ok(false)
1120                }
1121            }
1122            async fn close_position(
1123                &self,
1124                _position_id: &str,
1125                _reason: &str,
1126            ) -> Result<String, ExecutionError> {
1127                Ok(self.next_id())
1128            }
1129        }
1130
1131        let mut engine = PlaybookEngine::new(
1132            "BTC-USD".into(),
1133            simple_strategy_group(),
1134            Box::new(SlowCloseExecutor::new()),
1135        );
1136
1137        // Enter position (ADX=30 stays neutral)
1138        let ind = make_indicators(|i| {
1139            i.rsi_14 = Some(25.0);
1140            i.adx_14 = Some(30.0);
1141            i.sma_20 = Some(50000.0);
1142        });
1143        engine.tick(&ind, 1000).await; // place entry
1144        engine.tick(&ind, 1001).await; // fill entry
1145
1146        // Trigger exit via stop loss (5% of 50000 = 47500)
1147        let ind_sl = make_indicators(|i| {
1148            i.rsi_14 = Some(35.0);
1149            i.adx_14 = Some(30.0);
1150            i.sma_20 = Some(47000.0);
1151        });
1152        let r = engine.tick(&ind_sl, 2000).await;
1153        assert_eq!(
1154            r.action,
1155            TickAction::PositionClosed {
1156                reason: "stop_loss".into()
1157            }
1158        );
1159        assert!(matches!(
1160            engine.fsm_state(),
1161            PlaybookState::PendingExit { .. }
1162        ));
1163
1164        // Exit not filled, no timeout yet
1165        let r2 = engine.tick(&ind_sl, 2100).await;
1166        assert_eq!(r2.action, TickAction::None);
1167
1168        // Exit timeout (neutral timeout_secs=300)
1169        let r3 = engine.tick(&ind_sl, 2400).await;
1170        assert!(
1171            matches!(r3.action, TickAction::OrderCancelled { ref reason, .. } if reason == "exit_timeout"),
1172            "expected exit_timeout, got {:?}",
1173            r3.action
1174        );
1175        assert!(engine.fsm_state() == &PlaybookState::Idle);
1176    }
1177
1178    // -----------------------------------------------------------------------
1179    // 11. new_with_risk_config wraps executor with risk checks
1180    // -----------------------------------------------------------------------
1181
1182    #[tokio::test]
1183    async fn test_new_with_risk_config_permissive() {
1184        use hyper_risk::risk::{
1185            AnomalyDetection, CircuitBreaker, DailyLossLimits, PositionLimits, RiskConfig,
1186        };
1187
1188        // Permissive config — orders should pass through
1189        let config = RiskConfig {
1190            position_limits: PositionLimits {
1191                enabled: true,
1192                max_total_position: 1_000_000.0,
1193                max_per_symbol: 500_000.0,
1194            },
1195            daily_loss_limits: DailyLossLimits {
1196                enabled: false,
1197                max_daily_loss: 100_000.0,
1198                max_daily_loss_percent: 50.0,
1199            },
1200            anomaly_detection: AnomalyDetection {
1201                enabled: true,
1202                max_order_size: 1_000_000.0,
1203                max_orders_per_minute: 100,
1204                block_duplicate_orders: false,
1205            },
1206            circuit_breaker: CircuitBreaker {
1207                enabled: false,
1208                trigger_loss: 100_000.0,
1209                trigger_window_minutes: 60,
1210                action: "pause_all".to_string(),
1211                cooldown_minutes: 30,
1212            },
1213        };
1214
1215        let mut engine = PlaybookEngine::new_with_risk_config(
1216            "BTC-USD".into(),
1217            simple_strategy_group(),
1218            Box::new(PaperOrderExecutor::new()),
1219            Some(config),
1220        );
1221
1222        // Neutral regime, RSI < 30 triggers entry
1223        let ind = make_indicators(|i| {
1224            i.rsi_14 = Some(25.0);
1225            i.adx_14 = Some(30.0);
1226            i.sma_20 = Some(50000.0);
1227        });
1228        let r = engine.tick(&ind, 1000).await;
1229        assert!(
1230            matches!(r.action, TickAction::OrderPlaced { .. }),
1231            "permissive risk config should allow order, got {:?}",
1232            r.action
1233        );
1234    }
1235
1236    #[tokio::test]
1237    async fn test_new_with_risk_config_circuit_breaker_blocks_order() {
1238        use crate::executor::RiskCheckedOrderExecutor;
1239        use hyper_risk::risk::{
1240            AccountState, AnomalyDetection, CircuitBreaker, DailyLossLimits, PositionLimits,
1241            RiskConfig,
1242        };
1243
1244        // Circuit breaker with a very low trigger so it trips on our account state
1245        let config = RiskConfig {
1246            position_limits: PositionLimits {
1247                enabled: false,
1248                max_total_position: 1_000_000.0,
1249                max_per_symbol: 500_000.0,
1250            },
1251            daily_loss_limits: DailyLossLimits {
1252                enabled: false,
1253                max_daily_loss: 100_000.0,
1254                max_daily_loss_percent: 50.0,
1255            },
1256            anomaly_detection: AnomalyDetection {
1257                enabled: false,
1258                max_order_size: 1_000_000.0,
1259                max_orders_per_minute: 100,
1260                block_duplicate_orders: false,
1261            },
1262            circuit_breaker: CircuitBreaker {
1263                enabled: true,
1264                trigger_loss: 1.0, // very low — trips immediately
1265                trigger_window_minutes: 60,
1266                action: "pause_all".to_string(),
1267                cooldown_minutes: 9999,
1268            },
1269        };
1270
1271        // Build RiskCheckedOrderExecutor directly so we can set account state
1272        let inner = Box::new(PaperOrderExecutor::new());
1273        let risk_executor = RiskCheckedOrderExecutor::with_config(inner, config);
1274        risk_executor.update_account_state(AccountState {
1275            windowed_loss: 10.0, // exceeds trigger_loss of $1
1276            ..AccountState::default()
1277        });
1278
1279        let mut engine = PlaybookEngine::new(
1280            "BTC-USD".into(),
1281            simple_strategy_group(),
1282            Box::new(risk_executor),
1283        );
1284
1285        // Neutral regime, RSI < 30 triggers entry — but circuit breaker blocks
1286        let ind = make_indicators(|i| {
1287            i.rsi_14 = Some(25.0);
1288            i.adx_14 = Some(30.0);
1289            i.sma_20 = Some(50000.0);
1290        });
1291        let r = engine.tick(&ind, 1000).await;
1292        assert_eq!(
1293            r.action,
1294            TickAction::None,
1295            "tripped circuit breaker should block order"
1296        );
1297        assert!(engine.fsm_state() == &PlaybookState::Idle);
1298    }
1299
1300    #[tokio::test]
1301    async fn test_new_with_risk_config_none_uses_inner_directly() {
1302        // None risk config => same as PlaybookEngine::new
1303        let mut engine = PlaybookEngine::new_with_risk_config(
1304            "BTC-USD".into(),
1305            simple_strategy_group(),
1306            Box::new(PaperOrderExecutor::new()),
1307            None,
1308        );
1309
1310        let ind = make_indicators(|i| {
1311            i.rsi_14 = Some(25.0);
1312            i.adx_14 = Some(30.0);
1313            i.sma_20 = Some(50000.0);
1314        });
1315        let r = engine.tick(&ind, 1000).await;
1316        assert!(
1317            matches!(r.action, TickAction::OrderPlaced { .. }),
1318            "None risk config should use inner executor directly, got {:?}",
1319            r.action
1320        );
1321    }
1322}