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                && close >= entry_price * (1.0 + tp)
164            {
165                return Some(StopExit {
166                    kind: StopExitKind::TakeProfit,
167                    exit_price: close,
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
179                    .stop_loss_pct
180                    .is_some_and(|sl| close <= entry_price * (1.0 - sl))
181                {
182                    StopExitKind::StopLoss
183                } else {
184                    StopExitKind::TrailingStop
185                };
186                return Some(StopExit {
187                    kind,
188                    exit_price: close,
189                });
190            }
191        }
192        StopEvaluationMode::OhlcTouched => {
193            // Conservative intrabar ordering: SL / trailing before TP.
194            if let Some(sl) = stop_config.stop_loss_pct {
195                let level = entry_price * (1.0 - sl);
196                if low <= level {
197                    return Some(StopExit {
198                        kind: StopExitKind::StopLoss,
199                        exit_price: level,
200                    });
201                }
202            }
203            if let Some(level) = state.trailing_stop_level
204                && low <= level
205            {
206                return Some(StopExit {
207                    kind: StopExitKind::TrailingStop,
208                    exit_price: level,
209                });
210            }
211            if let Some(tp) = stop_config.take_profit_pct {
212                let level = entry_price * (1.0 + tp);
213                if high >= level {
214                    return Some(StopExit {
215                        kind: StopExitKind::TakeProfit,
216                        exit_price: level,
217                    });
218                }
219            }
220        }
221    }
222    None
223}
224
225fn evaluate_stops_short(
226    stop_config: &StopConfig,
227    mode: StopEvaluationMode,
228    close: f64,
229    high: f64,
230    low: f64,
231    entry_price: f64,
232    state: &StopPositionState,
233) -> Option<StopExit> {
234    match mode {
235        StopEvaluationMode::CloseOnly => {
236            if let Some(tp) = stop_config.take_profit_pct
237                && close <= entry_price * (1.0 - tp)
238            {
239                return Some(StopExit {
240                    kind: StopExitKind::TakeProfit,
241                    exit_price: close,
242                });
243            }
244            let mut effective_stop = f64::INFINITY;
245            if let Some(sl) = stop_config.stop_loss_pct {
246                effective_stop = entry_price * (1.0 + sl);
247            }
248            if let Some(level) = state.trailing_stop_level {
249                effective_stop = effective_stop.min(level);
250            }
251            if effective_stop < f64::INFINITY && close >= effective_stop {
252                let kind = if stop_config
253                    .stop_loss_pct
254                    .is_some_and(|sl| close >= entry_price * (1.0 + sl))
255                {
256                    StopExitKind::StopLoss
257                } else {
258                    StopExitKind::TrailingStop
259                };
260                return Some(StopExit {
261                    kind,
262                    exit_price: close,
263                });
264            }
265        }
266        StopEvaluationMode::OhlcTouched => {
267            if let Some(sl) = stop_config.stop_loss_pct {
268                let level = entry_price * (1.0 + sl);
269                if high >= level {
270                    return Some(StopExit {
271                        kind: StopExitKind::StopLoss,
272                        exit_price: level,
273                    });
274                }
275            }
276            if let Some(level) = state.trailing_stop_level
277                && high >= level
278            {
279                return Some(StopExit {
280                    kind: StopExitKind::TrailingStop,
281                    exit_price: level,
282                });
283            }
284            if let Some(tp) = stop_config.take_profit_pct {
285                let level = entry_price * (1.0 - tp);
286                if low <= level {
287                    return Some(StopExit {
288                        kind: StopExitKind::TakeProfit,
289                        exit_price: level,
290                    });
291                }
292            }
293        }
294    }
295    None
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn ohlc_stop_loss_wick_exits_at_level() {
304        let cfg = StopConfig {
305            stop_loss_pct: Some(0.02),
306            stop_evaluation: StopEvaluationMode::OhlcTouched,
307            ..Default::default()
308        };
309        let mut state = StopPositionState::default();
310        let bar = OhlcBar {
311            close: 99.0,
312            high: Some(100.0),
313            low: Some(96.0),
314        };
315        let exit = evaluate_stops(&cfg, bar, true, 100.0, &mut state).unwrap();
316        assert_eq!(exit.kind, StopExitKind::StopLoss);
317        assert!((exit.exit_price - 98.0).abs() < 1e-9);
318    }
319
320    #[test]
321    fn close_only_stop_waits_for_close_breach() {
322        let cfg = StopConfig {
323            stop_loss_pct: Some(0.02),
324            stop_evaluation: StopEvaluationMode::CloseOnly,
325            ..Default::default()
326        };
327        let mut state = StopPositionState::default();
328        let bar = OhlcBar {
329            close: 99.0,
330            high: Some(100.0),
331            low: Some(96.0),
332        };
333        assert!(evaluate_stops(&cfg, bar, true, 100.0, &mut state).is_none());
334    }
335}