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 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 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}