Skip to main content

quantwave_backtest/
stops.rs

1//! Shared stop-loss / take-profit / trailing evaluation (quantwave-m8z5).
2//!
3//! Supports close-only (legacy) and OHLC touched-exit modes.
4//! Sources: polars-backtest `touched_exit`, RaptorBT stop semantics (clean-room).
5
6use serde::{Deserialize, Serialize};
7
8/// How stop/target levels are evaluated against each bar.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10pub enum StopEvaluationMode {
11    /// Legacy: all checks use bar close (and trailing ratchets on close).
12    #[default]
13    CloseOnly,
14    /// Intrabar: SL/trailing use low (long) / high (short); TP uses high (long) / low (short);
15    /// trailing ratchets on bar high (long) / low (short). SL checked before TP when both touch.
16    OhlcTouched,
17}
18
19/// Fixed / trailing stop and take-profit knobs (RaptorBT-inspired, clean-room).
20///
21/// Percentages are fractions of entry price for long positions (e.g. `0.02` = 2%).
22#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
23pub struct StopConfig {
24    /// Fixed stop-loss below entry (long): exit when price breaches entry * (1 - pct).
25    pub stop_loss_pct: Option<f64>,
26    /// Fixed take-profit above entry: exit when price breaches entry * (1 + pct).
27    pub take_profit_pct: Option<f64>,
28    /// Trailing stop from peak (long uses bar high in OHLC mode, close otherwise).
29    pub trailing_stop_pct: Option<f64>,
30    /// How stops are evaluated against each bar. Default `CloseOnly` preserves v0.6 behavior.
31    #[serde(default)]
32    pub stop_evaluation: StopEvaluationMode,
33}
34
35impl StopConfig {
36    pub fn has_stops(&self) -> bool {
37        self.stop_loss_pct.is_some()
38            || self.take_profit_pct.is_some()
39            || self.trailing_stop_pct.is_some()
40    }
41}
42
43/// OHLC slice for one bar (missing high/low fall back to close).
44#[derive(Debug, Clone, Copy)]
45pub struct OhlcBar {
46    pub close: f64,
47    pub high: Option<f64>,
48    pub low: Option<f64>,
49}
50
51impl OhlcBar {
52    fn high_px(&self) -> f64 {
53        self.high.unwrap_or(self.close)
54    }
55
56    fn low_px(&self) -> f64 {
57        self.low.unwrap_or(self.close)
58    }
59}
60
61/// Mutable trailing-stop state while a position is open.
62#[derive(Debug, Clone, Default)]
63pub struct StopPositionState {
64    pub trailing_stop_level: Option<f64>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum StopExitKind {
69    TakeProfit,
70    StopLoss,
71    TrailingStop,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct StopExit {
76    pub kind: StopExitKind,
77    /// Raw exit price before slippage (stop level when touched in OHLC mode).
78    pub exit_price: f64,
79}
80
81/// Initial trailing level at position entry.
82pub fn trailing_level_at_entry(entry_price: f64, is_long: bool, trail_pct: f64) -> f64 {
83    if is_long {
84        entry_price * (1.0 - trail_pct)
85    } else {
86        entry_price * (1.0 + trail_pct)
87    }
88}
89
90/// Ratchet trailing stop using the current bar, updating `state`.
91pub fn ratchet_trailing(
92    stop_config: &StopConfig,
93    bar: OhlcBar,
94    is_long: bool,
95    state: &mut StopPositionState,
96) {
97    let Some(trail_pct) = stop_config.trailing_stop_pct else {
98        return;
99    };
100    let ratchet_px = match stop_config.stop_evaluation {
101        StopEvaluationMode::CloseOnly => bar.close,
102        StopEvaluationMode::OhlcTouched => {
103            if is_long {
104                bar.high_px()
105            } else {
106                bar.low_px()
107            }
108        }
109    };
110    if is_long {
111        let new_level = ratchet_px * (1.0 - trail_pct);
112        state.trailing_stop_level = Some(match state.trailing_stop_level {
113            Some(prev) => prev.max(new_level),
114            None => new_level,
115        });
116    } else {
117        let new_level = ratchet_px * (1.0 + trail_pct);
118        state.trailing_stop_level = Some(match state.trailing_stop_level {
119            Some(prev) => prev.min(new_level),
120            None => new_level,
121        });
122    }
123}
124
125/// Evaluate stops for an open position. Returns `Some(StopExit)` when a stop fires.
126pub fn evaluate_stops(
127    stop_config: &StopConfig,
128    bar: OhlcBar,
129    is_long: bool,
130    entry_price: f64,
131    state: &mut StopPositionState,
132) -> Option<StopExit> {
133    if !stop_config.has_stops() {
134        return None;
135    }
136
137    ratchet_trailing(stop_config, bar, is_long, state);
138
139    let close = bar.close;
140    let high = bar.high_px();
141    let low = bar.low_px();
142    let mode = stop_config.stop_evaluation;
143
144    if is_long {
145        evaluate_stops_long(stop_config, mode, close, high, low, entry_price, state)
146    } else {
147        evaluate_stops_short(stop_config, mode, close, high, low, entry_price, state)
148    }
149}
150
151fn evaluate_stops_long(
152    stop_config: &StopConfig,
153    mode: StopEvaluationMode,
154    close: f64,
155    high: f64,
156    low: f64,
157    entry_price: f64,
158    state: &StopPositionState,
159) -> Option<StopExit> {
160    match mode {
161        StopEvaluationMode::CloseOnly => {
162            if let Some(tp) = stop_config.take_profit_pct {
163                if close >= entry_price * (1.0 + tp) {
164                    return Some(StopExit {
165                        kind: StopExitKind::TakeProfit,
166                        exit_price: close,
167                    });
168                }
169            }
170            let mut effective_stop = f64::NEG_INFINITY;
171            if let Some(sl) = stop_config.stop_loss_pct {
172                effective_stop = entry_price * (1.0 - sl);
173            }
174            if let Some(level) = state.trailing_stop_level {
175                effective_stop = effective_stop.max(level);
176            }
177            if effective_stop > f64::NEG_INFINITY && close <= effective_stop {
178                let kind = if stop_config.stop_loss_pct.is_some()
179                    && close <= entry_price * (1.0 - stop_config.stop_loss_pct.unwrap())
180                {
181                    StopExitKind::StopLoss
182                } else {
183                    StopExitKind::TrailingStop
184                };
185                return Some(StopExit {
186                    kind,
187                    exit_price: close,
188                });
189            }
190        }
191        StopEvaluationMode::OhlcTouched => {
192            // Conservative intrabar ordering: SL / trailing before TP.
193            if let Some(sl) = stop_config.stop_loss_pct {
194                let level = entry_price * (1.0 - sl);
195                if low <= level {
196                    return Some(StopExit {
197                        kind: StopExitKind::StopLoss,
198                        exit_price: level,
199                    });
200                }
201            }
202            if let Some(level) = state.trailing_stop_level {
203                if low <= level {
204                    return Some(StopExit {
205                        kind: StopExitKind::TrailingStop,
206                        exit_price: level,
207                    });
208                }
209            }
210            if let Some(tp) = stop_config.take_profit_pct {
211                let level = entry_price * (1.0 + tp);
212                if high >= level {
213                    return Some(StopExit {
214                        kind: StopExitKind::TakeProfit,
215                        exit_price: level,
216                    });
217                }
218            }
219        }
220    }
221    None
222}
223
224fn evaluate_stops_short(
225    stop_config: &StopConfig,
226    mode: StopEvaluationMode,
227    close: f64,
228    high: f64,
229    low: f64,
230    entry_price: f64,
231    state: &StopPositionState,
232) -> Option<StopExit> {
233    match mode {
234        StopEvaluationMode::CloseOnly => {
235            if let Some(tp) = stop_config.take_profit_pct {
236                if close <= entry_price * (1.0 - tp) {
237                    return Some(StopExit {
238                        kind: StopExitKind::TakeProfit,
239                        exit_price: close,
240                    });
241                }
242            }
243            let mut effective_stop = f64::INFINITY;
244            if let Some(sl) = stop_config.stop_loss_pct {
245                effective_stop = entry_price * (1.0 + sl);
246            }
247            if let Some(level) = state.trailing_stop_level {
248                effective_stop = effective_stop.min(level);
249            }
250            if effective_stop < f64::INFINITY && close >= effective_stop {
251                let kind = if stop_config.stop_loss_pct.is_some()
252                    && close >= entry_price * (1.0 + stop_config.stop_loss_pct.unwrap())
253                {
254                    StopExitKind::StopLoss
255                } else {
256                    StopExitKind::TrailingStop
257                };
258                return Some(StopExit {
259                    kind,
260                    exit_price: close,
261                });
262            }
263        }
264        StopEvaluationMode::OhlcTouched => {
265            if let Some(sl) = stop_config.stop_loss_pct {
266                let level = entry_price * (1.0 + sl);
267                if high >= level {
268                    return Some(StopExit {
269                        kind: StopExitKind::StopLoss,
270                        exit_price: level,
271                    });
272                }
273            }
274            if let Some(level) = state.trailing_stop_level {
275                if high >= level {
276                    return Some(StopExit {
277                        kind: StopExitKind::TrailingStop,
278                        exit_price: level,
279                    });
280                }
281            }
282            if let Some(tp) = stop_config.take_profit_pct {
283                let level = entry_price * (1.0 - tp);
284                if low <= level {
285                    return Some(StopExit {
286                        kind: StopExitKind::TakeProfit,
287                        exit_price: level,
288                    });
289                }
290            }
291        }
292    }
293    None
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn ohlc_stop_loss_wick_exits_at_level() {
302        let cfg = StopConfig {
303            stop_loss_pct: Some(0.02),
304            stop_evaluation: StopEvaluationMode::OhlcTouched,
305            ..Default::default()
306        };
307        let mut state = StopPositionState::default();
308        let bar = OhlcBar {
309            close: 99.0,
310            high: Some(100.0),
311            low: Some(96.0),
312        };
313        let exit = evaluate_stops(&cfg, bar, true, 100.0, &mut state).unwrap();
314        assert_eq!(exit.kind, StopExitKind::StopLoss);
315        assert!((exit.exit_price - 98.0).abs() < 1e-9);
316    }
317
318    #[test]
319    fn close_only_stop_waits_for_close_breach() {
320        let cfg = StopConfig {
321            stop_loss_pct: Some(0.02),
322            stop_evaluation: StopEvaluationMode::CloseOnly,
323            ..Default::default()
324        };
325        let mut state = StopPositionState::default();
326        let bar = OhlcBar {
327            close: 99.0,
328            high: Some(100.0),
329            low: Some(96.0),
330        };
331        assert!(evaluate_stops(&cfg, bar, true, 100.0, &mut state).is_none());
332    }
333}