1use serde::{Deserialize, Serialize};
7
8use crate::rule_engine::SignalSummary;
9use crate::strategy_config::Playbook;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct RuleDecision {
19 pub action: String,
21 pub symbol: String,
23 pub size: f64,
25 pub reasoning: String,
27 pub triggered_rules: Vec<TriggeredRule>,
29}
30
31#[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
43fn 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
71pub 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 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 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 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 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#[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"); }
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), ]);
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(); let decision = decide_from_signals(&summary, &pb, Some("buy"), Some(100.0), Some(94.0));
346 assert_eq!(decision.action, "sell"); 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(); 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}