Skip to main content

hyper_strategy/
rule_executor.rs

1//! Deterministic order decision engine.
2//!
3//! Maps triggered rule signals to concrete order actions (buy/sell/close/hold).
4//! No AI involved — purely rule-based.
5
6use serde::{Deserialize, Serialize};
7
8use crate::rule_engine::SignalSummary;
9use crate::strategy_config::Playbook;
10
11// ---------------------------------------------------------------------------
12// Types
13// ---------------------------------------------------------------------------
14
15/// A deterministic order decision produced by the rule engine.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct RuleDecision {
19    /// "buy", "sell", "close", or "hold"
20    pub action: String,
21    /// Trading symbol
22    pub symbol: String,
23    /// Position size (from playbook max_position_size)
24    pub size: f64,
25    /// Human-readable explanation of why this decision was made
26    pub reasoning: String,
27    /// Which rules triggered this decision
28    pub triggered_rules: Vec<TriggeredRule>,
29}
30
31/// A single rule that contributed to the decision.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct TriggeredRule {
35    pub indicator: String,
36    pub condition: String,
37    pub value: f64,
38    pub threshold: f64,
39    pub signal: String,
40    pub action: String,
41}
42
43// ---------------------------------------------------------------------------
44// Decision logic
45// ---------------------------------------------------------------------------
46
47/// Infer action from signal name when no explicit action is set.
48fn infer_action(signal: &str) -> &'static str {
49    let s = signal.to_lowercase();
50    if s.contains("buy")
51        || s.contains("long")
52        || s.contains("oversold")
53        || s.contains("bullish")
54        || s.contains("golden")
55    {
56        "buy"
57    } else if s.contains("sell")
58        || s.contains("short")
59        || s.contains("overbought")
60        || s.contains("bearish")
61        || s.contains("death")
62    {
63        "sell"
64    } else if s.contains("close") || s.contains("exit") || s.contains("flat") {
65        "close"
66    } else {
67        "hold"
68    }
69}
70
71/// Produce a deterministic order decision from evaluated signals.
72///
73/// Logic:
74/// 1. Collect all triggered rules with their actions.
75/// 2. Count buy vs sell votes.
76/// 3. Majority wins. Ties → hold.
77/// 4. Check stop-loss / take-profit if position exists.
78pub fn decide_from_signals(
79    signal_summary: &SignalSummary,
80    playbook: &Playbook,
81    current_position_side: Option<&str>,
82    current_entry_price: Option<f64>,
83    current_price: Option<f64>,
84) -> RuleDecision {
85    // Check stop-loss / take-profit first (highest priority)
86    if let (Some(side), Some(entry), Some(price)) =
87        (current_position_side, current_entry_price, current_price)
88    {
89        let pnl_pct = if side == "buy" {
90            (price - entry) / entry * 100.0
91        } else {
92            (entry - price) / entry * 100.0
93        };
94
95        if let Some(sl) = playbook.stop_loss_pct {
96            if pnl_pct <= -sl {
97                let action = if side == "buy" { "sell" } else { "buy" };
98                return RuleDecision {
99                    action: action.to_string(),
100                    symbol: signal_summary.symbol.clone(),
101                    size: playbook.max_position_size,
102                    reasoning: format!(
103                        "Stop-loss triggered: PnL {:.2}% exceeded -{:.1}% limit",
104                        pnl_pct, sl
105                    ),
106                    triggered_rules: vec![TriggeredRule {
107                        indicator: "stop_loss".to_string(),
108                        condition: "pnl_below".to_string(),
109                        value: pnl_pct,
110                        threshold: -sl,
111                        signal: "stop_loss".to_string(),
112                        action: action.to_string(),
113                    }],
114                };
115            }
116        }
117
118        if let Some(tp) = playbook.take_profit_pct {
119            if pnl_pct >= tp {
120                let action = if side == "buy" { "sell" } else { "buy" };
121                return RuleDecision {
122                    action: action.to_string(),
123                    symbol: signal_summary.symbol.clone(),
124                    size: playbook.max_position_size,
125                    reasoning: format!(
126                        "Take-profit triggered: PnL {:.2}% exceeded +{:.1}% target",
127                        pnl_pct, tp
128                    ),
129                    triggered_rules: vec![TriggeredRule {
130                        indicator: "take_profit".to_string(),
131                        condition: "pnl_above".to_string(),
132                        value: pnl_pct,
133                        threshold: tp,
134                        signal: "take_profit".to_string(),
135                        action: action.to_string(),
136                    }],
137                };
138            }
139        }
140    }
141
142    let mut buy_votes = 0u32;
143    let mut sell_votes = 0u32;
144    let mut triggered = Vec::new();
145
146    for eval in &signal_summary.evaluations {
147        if !eval.triggered {
148            continue;
149        }
150
151        let action = eval
152            .rule
153            .action
154            .as_deref()
155            .unwrap_or_else(|| infer_action(&eval.signal));
156
157        match action {
158            "buy" => buy_votes += 1,
159            "sell" => sell_votes += 1,
160            "close" => {
161                // Close counts against the current position direction
162                match current_position_side {
163                    Some("buy") => sell_votes += 1,
164                    Some("sell") => buy_votes += 1,
165                    _ => {}
166                }
167            }
168            _ => {}
169        }
170
171        triggered.push(TriggeredRule {
172            indicator: eval.rule.indicator.clone(),
173            condition: eval.rule.condition.clone(),
174            value: eval.current_value,
175            threshold: eval.rule.threshold,
176            signal: eval.signal.clone(),
177            action: action.to_string(),
178        });
179    }
180
181    // No triggered rules → hold
182    if triggered.is_empty() {
183        return RuleDecision {
184            action: "hold".to_string(),
185            symbol: signal_summary.symbol.clone(),
186            size: 0.0,
187            reasoning: "No rules triggered — holding current position".to_string(),
188            triggered_rules: vec![],
189        };
190    }
191
192    // Majority vote
193    let (action, reasoning) = if buy_votes > sell_votes {
194        (
195            "buy",
196            format!(
197                "{} buy signal(s) vs {} sell signal(s)",
198                buy_votes, sell_votes
199            ),
200        )
201    } else if sell_votes > buy_votes {
202        (
203            "sell",
204            format!(
205                "{} sell signal(s) vs {} buy signal(s)",
206                sell_votes, buy_votes
207            ),
208        )
209    } else {
210        (
211            "hold",
212            format!("Tie: {} buy vs {} sell — holding", buy_votes, sell_votes),
213        )
214    };
215
216    let size = if action == "hold" {
217        0.0
218    } else {
219        playbook.max_position_size
220    };
221
222    RuleDecision {
223        action: action.to_string(),
224        symbol: signal_summary.symbol.clone(),
225        size,
226        reasoning,
227        triggered_rules: triggered,
228    }
229}
230
231// ---------------------------------------------------------------------------
232// Tests
233// ---------------------------------------------------------------------------
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::rule_engine::RuleEvaluation;
239    use crate::strategy_config::TaRule;
240
241    fn make_eval(
242        signal: &str,
243        action: Option<&str>,
244        triggered: bool,
245        value: f64,
246    ) -> RuleEvaluation {
247        RuleEvaluation {
248            rule: TaRule {
249                indicator: "RSI".to_string(),
250                params: vec![14.0],
251                condition: "lt".to_string(),
252                threshold: 30.0,
253                threshold_upper: None,
254                signal: signal.to_string(),
255                action: action.map(|a| a.to_string()),
256            },
257            current_value: value,
258            triggered,
259            signal: signal.to_string(),
260        }
261    }
262
263    fn make_summary(evaluations: Vec<RuleEvaluation>) -> SignalSummary {
264        let triggered_signals = evaluations
265            .iter()
266            .filter(|e| e.triggered)
267            .map(|e| e.signal.clone())
268            .collect();
269        SignalSummary {
270            symbol: "BTC-PERP".to_string(),
271            timestamp: 1000,
272            current_regime: "neutral".to_string(),
273            regime_changed: false,
274            evaluations,
275            triggered_signals,
276        }
277    }
278
279    fn make_playbook() -> Playbook {
280        Playbook {
281            rules: vec![],
282            entry_rules: vec![],
283            exit_rules: vec![],
284            system_prompt: String::new(),
285            max_position_size: 100.0,
286            stop_loss_pct: Some(5.0),
287            take_profit_pct: Some(10.0),
288            timeout_secs: None,
289            side: None,
290        }
291    }
292
293    #[test]
294    fn test_no_triggered_rules_returns_hold() {
295        let summary = make_summary(vec![make_eval("oversold", Some("buy"), false, 45.0)]);
296        let decision = decide_from_signals(&summary, &make_playbook(), None, None, None);
297        assert_eq!(decision.action, "hold");
298        assert!(decision.triggered_rules.is_empty());
299    }
300
301    #[test]
302    fn test_single_buy_signal() {
303        let summary = make_summary(vec![make_eval("oversold", Some("buy"), true, 25.0)]);
304        let decision = decide_from_signals(&summary, &make_playbook(), None, None, None);
305        assert_eq!(decision.action, "buy");
306        assert_eq!(decision.size, 100.0);
307        assert_eq!(decision.triggered_rules.len(), 1);
308    }
309
310    #[test]
311    fn test_majority_vote() {
312        let summary = make_summary(vec![
313            make_eval("oversold", Some("buy"), true, 25.0),
314            make_eval("bullish", Some("buy"), true, 1.0),
315            make_eval("overbought", Some("sell"), true, 75.0),
316        ]);
317        let decision = decide_from_signals(&summary, &make_playbook(), None, None, None);
318        assert_eq!(decision.action, "buy"); // 2 buy vs 1 sell
319    }
320
321    #[test]
322    fn test_tie_returns_hold() {
323        let summary = make_summary(vec![
324            make_eval("oversold", Some("buy"), true, 25.0),
325            make_eval("overbought", Some("sell"), true, 75.0),
326        ]);
327        let decision = decide_from_signals(&summary, &make_playbook(), None, None, None);
328        assert_eq!(decision.action, "hold");
329    }
330
331    #[test]
332    fn test_infer_action_from_signal_name() {
333        let summary = make_summary(vec![
334            make_eval("oversold", None, true, 25.0), // inferred as "buy"
335        ]);
336        let decision = decide_from_signals(&summary, &make_playbook(), None, None, None);
337        assert_eq!(decision.action, "buy");
338    }
339
340    #[test]
341    fn test_stop_loss_triggers() {
342        let summary = make_summary(vec![make_eval("bullish", Some("buy"), true, 60.0)]);
343        let pb = make_playbook(); // stop_loss_pct = 5.0
344                                  // Long position, price dropped 6%
345        let decision = decide_from_signals(&summary, &pb, Some("buy"), Some(100.0), Some(94.0));
346        assert_eq!(decision.action, "sell"); // stop loss overrides buy signal
347        assert!(decision.reasoning.contains("Stop-loss"));
348    }
349
350    #[test]
351    fn test_take_profit_triggers() {
352        let summary = make_summary(vec![]);
353        let pb = make_playbook(); // take_profit_pct = 10.0
354                                  // Long position, price up 11%
355        let decision = decide_from_signals(&summary, &pb, Some("buy"), Some(100.0), Some(111.0));
356        assert_eq!(decision.action, "sell");
357        assert!(decision.reasoning.contains("Take-profit"));
358    }
359}