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#[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
54pub 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 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 pub fn fsm_state(&self) -> &PlaybookState {
104 self.fsm.state()
105 }
106
107 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 pub async fn tick(&mut self, indicators: &TechnicalIndicators, now: u64) -> TickResult {
116 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 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 }
153
154 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 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 ®ime,
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 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 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(¶ms).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 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 #[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 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 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 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 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
433enum InPositionResult {
438 EarlyReturn(TickResult),
439 Normal(TickAction, Vec<String>),
440}
441
442fn 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
452fn determine_side(playbook: &Playbook, triggered_signals: &[String]) -> String {
454 if let Some(ref side) = playbook.side {
456 return side.clone();
457 }
458 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 "buy".to_string()
470}
471
472#[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 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 fn simple_strategy_group() -> StrategyGroup {
531 let mut playbooks = HashMap::new();
532
533 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 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 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 #[tokio::test]
636 async fn test_happy_path_full_cycle() {
637 let mut engine = new_engine();
638
639 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 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 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 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 #[tokio::test]
695 async fn test_stop_loss() {
696 let mut engine = new_engine();
697
698 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; engine.tick(&ind_entry, 1001).await; assert!(matches!(
708 engine.fsm_state(),
709 PlaybookState::InPosition { .. }
710 ));
711
712 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); });
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 #[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 let ind_tp = make_indicators(|i| {
745 i.rsi_14 = Some(35.0); i.adx_14 = Some(30.0);
747 i.sma_20 = Some(56000.0); });
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 #[tokio::test]
763 async fn test_entry_timeout() {
764 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) }
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 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 let r2 = engine.tick(&ind, 1100).await;
810 assert_eq!(r2.action, TickAction::None);
811
812 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 #[tokio::test]
827 async fn test_regime_change_force_close() {
828 let mut engine = new_engine();
829
830 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; engine.tick(&ind_entry, 101).await; assert!(matches!(
839 engine.fsm_state(),
840 PlaybookState::InPosition { .. }
841 ));
842
843 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"); assert!(!r3.regime_changed);
853
854 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 #[tokio::test]
872 async fn test_no_entry_when_zero_position_size() {
873 let mut engine = new_engine();
874
875 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; let r2 = engine.tick(&ind_switch, 200).await; assert_eq!(r2.regime, "bear");
885 assert!(engine.fsm_state() == &PlaybookState::Idle);
886
887 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 #[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 #[tokio::test]
1004 async fn test_no_playbook_returns_none() {
1005 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 #[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 #[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 if order_id.starts_with("order-") {
1111 let num: u64 = order_id
1113 .strip_prefix("order-")
1114 .unwrap()
1115 .parse()
1116 .unwrap_or(0);
1117 Ok(num <= 1) } 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 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; engine.tick(&ind, 1001).await; 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 let r2 = engine.tick(&ind_sl, 2100).await;
1166 assert_eq!(r2.action, TickAction::None);
1167
1168 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 #[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 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 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 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, trigger_window_minutes: 60,
1266 action: "pause_all".to_string(),
1267 cooldown_minutes: 9999,
1268 },
1269 };
1270
1271 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, ..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 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 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}