1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10pub enum StopEvaluationMode {
11 #[default]
13 CloseOnly,
14 OhlcTouched,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
23pub struct StopConfig {
24 pub stop_loss_pct: Option<f64>,
26 pub take_profit_pct: Option<f64>,
28 pub trailing_stop_pct: Option<f64>,
30 #[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#[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#[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 pub exit_price: f64,
79}
80
81pub 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
90pub 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
125pub 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 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}