Skip to main content

finance_query/backtesting/condition/
threshold.rs

1//! Threshold-based conditions for position management.
2//!
3//! This module provides conditions for stop-loss, take-profit, and trailing stops.
4
5use crate::backtesting::strategy::StrategyContext;
6use crate::indicators::Indicator;
7
8use super::Condition;
9
10/// Condition: position P/L is at or below the stop-loss threshold.
11///
12/// # Execution Model
13///
14/// This condition evaluates at **bar close**: it fires when the closing price
15/// implies a loss ≥ `pct`. The resulting exit signal is deferred to the **next
16/// bar's open** (identical to all strategy-signal exits).
17///
18/// For intrabar detection (fill same bar at `min(open, stop_level)`), use
19/// [`BacktestConfig::stop_loss_pct`] instead. A −10% intraday move that closes
20/// at −3% will be caught by the config field but missed by this condition.
21///
22/// # Example
23///
24/// ```ignore
25/// use finance_query::backtesting::condition::*;
26///
27/// let exit = stop_loss(0.05); // Exit if loss >= 5% at bar close
28/// ```
29#[derive(Debug, Clone, Copy)]
30pub struct StopLoss {
31    /// Stop-loss percentage (e.g., 0.05 for 5%)
32    pub pct: f64,
33}
34
35impl StopLoss {
36    /// Create a new stop-loss condition.
37    ///
38    /// # Arguments
39    ///
40    /// * `pct` - Stop-loss percentage (e.g., 0.05 for 5%)
41    pub fn new(pct: f64) -> Self {
42        Self { pct }
43    }
44}
45
46impl Condition for StopLoss {
47    fn evaluate(&self, ctx: &StrategyContext) -> bool {
48        if let Some(pos) = ctx.position {
49            let pnl_pct = pos.unrealized_return_pct(ctx.close()) / 100.0;
50            pnl_pct <= -self.pct
51        } else {
52            false
53        }
54    }
55
56    fn required_indicators(&self) -> Vec<(String, Indicator)> {
57        vec![]
58    }
59
60    fn description(&self) -> String {
61        format!("stop loss at {:.1}%", self.pct * 100.0)
62    }
63}
64
65/// Create a stop-loss condition.
66///
67/// # Example
68///
69/// ```ignore
70/// use finance_query::backtesting::condition::*;
71///
72/// let exit = rsi(14).above(70.0).or(stop_loss(0.05));
73/// ```
74#[inline]
75pub fn stop_loss(pct: f64) -> StopLoss {
76    StopLoss::new(pct)
77}
78
79/// Condition: position P/L is at or above the take-profit threshold.
80///
81/// # Execution Model
82///
83/// This condition evaluates at **bar close**: it fires when the closing price
84/// implies a gain ≥ `pct`. The resulting exit signal is deferred to the **next
85/// bar's open** (identical to all strategy-signal exits).
86///
87/// For intrabar detection (fill same bar at `max(open, target_level)`), use
88/// [`BacktestConfig::take_profit_pct`] instead.
89///
90/// # Example
91///
92/// ```ignore
93/// use finance_query::backtesting::condition::*;
94///
95/// let exit = take_profit(0.10); // Exit if gain >= 10% at bar close
96/// ```
97#[derive(Debug, Clone, Copy)]
98pub struct TakeProfit {
99    /// Take-profit percentage (e.g., 0.10 for 10%)
100    pub pct: f64,
101}
102
103impl TakeProfit {
104    /// Create a new take-profit condition.
105    ///
106    /// # Arguments
107    ///
108    /// * `pct` - Take-profit percentage (e.g., 0.10 for 10%)
109    pub fn new(pct: f64) -> Self {
110        Self { pct }
111    }
112}
113
114impl Condition for TakeProfit {
115    fn evaluate(&self, ctx: &StrategyContext) -> bool {
116        if let Some(pos) = ctx.position {
117            let pnl_pct = pos.unrealized_return_pct(ctx.close()) / 100.0;
118            pnl_pct >= self.pct
119        } else {
120            false
121        }
122    }
123
124    fn required_indicators(&self) -> Vec<(String, Indicator)> {
125        vec![]
126    }
127
128    fn description(&self) -> String {
129        format!("take profit at {:.1}%", self.pct * 100.0)
130    }
131}
132
133/// Create a take-profit condition.
134///
135/// # Example
136///
137/// ```ignore
138/// use finance_query::backtesting::condition::*;
139///
140/// let exit = rsi(14).above(70.0).or(take_profit(0.15));
141/// ```
142#[inline]
143pub fn take_profit(pct: f64) -> TakeProfit {
144    TakeProfit::new(pct)
145}
146
147/// Condition: check if we have any position.
148#[derive(Debug, Clone, Copy)]
149pub struct HasPosition;
150
151impl Condition for HasPosition {
152    fn evaluate(&self, ctx: &StrategyContext) -> bool {
153        ctx.has_position()
154    }
155
156    fn required_indicators(&self) -> Vec<(String, Indicator)> {
157        vec![]
158    }
159
160    fn description(&self) -> String {
161        "has position".to_string()
162    }
163}
164
165/// Create a condition that checks if we have any position.
166#[inline]
167pub fn has_position() -> HasPosition {
168    HasPosition
169}
170
171/// Condition: check if we have no position.
172#[derive(Debug, Clone, Copy)]
173pub struct NoPosition;
174
175impl Condition for NoPosition {
176    fn evaluate(&self, ctx: &StrategyContext) -> bool {
177        !ctx.has_position()
178    }
179
180    fn required_indicators(&self) -> Vec<(String, Indicator)> {
181        vec![]
182    }
183
184    fn description(&self) -> String {
185        "no position".to_string()
186    }
187}
188
189/// Create a condition that checks if we have no position.
190#[inline]
191pub fn no_position() -> NoPosition {
192    NoPosition
193}
194
195/// Condition: check if we have a long position.
196#[derive(Debug, Clone, Copy)]
197pub struct IsLong;
198
199impl Condition for IsLong {
200    fn evaluate(&self, ctx: &StrategyContext) -> bool {
201        ctx.is_long()
202    }
203
204    fn required_indicators(&self) -> Vec<(String, Indicator)> {
205        vec![]
206    }
207
208    fn description(&self) -> String {
209        "is long".to_string()
210    }
211}
212
213/// Create a condition that checks if we have a long position.
214#[inline]
215pub fn is_long() -> IsLong {
216    IsLong
217}
218
219/// Condition: check if we have a short position.
220#[derive(Debug, Clone, Copy)]
221pub struct IsShort;
222
223impl Condition for IsShort {
224    fn evaluate(&self, ctx: &StrategyContext) -> bool {
225        ctx.is_short()
226    }
227
228    fn required_indicators(&self) -> Vec<(String, Indicator)> {
229        vec![]
230    }
231
232    fn description(&self) -> String {
233        "is short".to_string()
234    }
235}
236
237/// Create a condition that checks if we have a short position.
238#[inline]
239pub fn is_short() -> IsShort {
240    IsShort
241}
242
243/// Condition: position P/L is positive (in profit).
244#[derive(Debug, Clone, Copy)]
245pub struct InProfit;
246
247impl Condition for InProfit {
248    fn evaluate(&self, ctx: &StrategyContext) -> bool {
249        if let Some(pos) = ctx.position {
250            pos.unrealized_return_pct(ctx.close()) > 0.0
251        } else {
252            false
253        }
254    }
255
256    fn required_indicators(&self) -> Vec<(String, Indicator)> {
257        vec![]
258    }
259
260    fn description(&self) -> String {
261        "in profit".to_string()
262    }
263}
264
265/// Create a condition that checks if position is profitable.
266#[inline]
267pub fn in_profit() -> InProfit {
268    InProfit
269}
270
271/// Condition: position P/L is negative (in loss).
272#[derive(Debug, Clone, Copy)]
273pub struct InLoss;
274
275impl Condition for InLoss {
276    fn evaluate(&self, ctx: &StrategyContext) -> bool {
277        if let Some(pos) = ctx.position {
278            pos.unrealized_return_pct(ctx.close()) < 0.0
279        } else {
280            false
281        }
282    }
283
284    fn required_indicators(&self) -> Vec<(String, Indicator)> {
285        vec![]
286    }
287
288    fn description(&self) -> String {
289        "in loss".to_string()
290    }
291}
292
293/// Create a condition that checks if position is at a loss.
294#[inline]
295pub fn in_loss() -> InLoss {
296    InLoss
297}
298
299/// Condition: position has been held for at least N bars.
300#[derive(Debug, Clone, Copy)]
301pub struct HeldForBars {
302    /// Minimum number of bars the position must be held
303    pub min_bars: usize,
304}
305
306impl HeldForBars {
307    /// Create a new held-for-bars condition.
308    pub fn new(min_bars: usize) -> Self {
309        Self { min_bars }
310    }
311}
312
313impl Condition for HeldForBars {
314    fn evaluate(&self, ctx: &StrategyContext) -> bool {
315        if let Some(pos) = ctx.position {
316            // Count bars since entry
317            let entry_idx = ctx
318                .candles
319                .iter()
320                .position(|c| c.timestamp >= pos.entry_timestamp)
321                .unwrap_or(0);
322            let bars_held = ctx.index.saturating_sub(entry_idx);
323            bars_held >= self.min_bars
324        } else {
325            false
326        }
327    }
328
329    fn required_indicators(&self) -> Vec<(String, Indicator)> {
330        vec![]
331    }
332
333    fn description(&self) -> String {
334        format!("held for {} bars", self.min_bars)
335    }
336}
337
338/// Create a condition that checks if position has been held for at least N bars.
339#[inline]
340pub fn held_for_bars(min_bars: usize) -> HeldForBars {
341    HeldForBars::new(min_bars)
342}
343
344/// Condition: trailing stop triggered when price retraces from peak/trough.
345///
346/// For long positions: tracks the highest price since entry and triggers
347/// when price falls by `trail_pct` from that high.
348///
349/// For short positions: tracks the lowest price since entry and triggers
350/// when price rises by `trail_pct` from that low.
351///
352/// # Execution Model
353///
354/// The peak/trough is computed from bar **highs/lows** since entry, but the
355/// trigger test uses the **bar close**. The exit signal is deferred to the
356/// **next bar's open** (identical to all strategy-signal exits).
357///
358/// For intrabar enforcement, use [`BacktestConfig::trailing_stop_pct`] instead,
359/// which fills on the same bar when the trailing level is breached intraday.
360///
361/// # Example
362///
363/// ```ignore
364/// use finance_query::backtesting::condition::*;
365///
366/// // Exit if price drops 3% from highest point since entry
367/// let exit = trailing_stop(0.03);
368/// ```
369#[derive(Debug, Clone, Copy)]
370pub struct TrailingStop {
371    /// Trail percentage (e.g., 0.03 for 3%)
372    pub trail_pct: f64,
373}
374
375impl TrailingStop {
376    /// Create a new trailing stop condition.
377    ///
378    /// # Arguments
379    ///
380    /// * `trail_pct` - Trail percentage (e.g., 0.03 for 3%)
381    pub fn new(trail_pct: f64) -> Self {
382        Self { trail_pct }
383    }
384}
385
386impl Condition for TrailingStop {
387    fn evaluate(&self, ctx: &StrategyContext) -> bool {
388        if let Some(pos) = ctx.position {
389            // Find the entry index
390            let entry_idx = ctx
391                .candles
392                .iter()
393                .position(|c| c.timestamp >= pos.entry_timestamp)
394                .unwrap_or(0);
395
396            // Compute peak/trough from entry to current candle (inclusive)
397            let current_close = ctx.close();
398
399            match pos.side {
400                crate::backtesting::position::PositionSide::Long => {
401                    // For long: find highest high since entry
402                    let peak = ctx.candles[entry_idx..=ctx.index]
403                        .iter()
404                        .map(|c| c.high)
405                        .fold(f64::NEG_INFINITY, f64::max);
406
407                    // Trigger if current price is trail_pct below peak
408                    current_close <= peak * (1.0 - self.trail_pct)
409                }
410                crate::backtesting::position::PositionSide::Short => {
411                    // For short: find lowest low since entry
412                    let trough = ctx.candles[entry_idx..=ctx.index]
413                        .iter()
414                        .map(|c| c.low)
415                        .fold(f64::INFINITY, f64::min);
416
417                    // Trigger if current price is trail_pct above trough
418                    current_close >= trough * (1.0 + self.trail_pct)
419                }
420            }
421        } else {
422            false
423        }
424    }
425
426    fn required_indicators(&self) -> Vec<(String, Indicator)> {
427        vec![]
428    }
429
430    fn description(&self) -> String {
431        format!("trailing stop at {:.1}%", self.trail_pct * 100.0)
432    }
433}
434
435/// Create a trailing stop condition.
436///
437/// The trailing stop tracks the best price (highest for longs, lowest for shorts)
438/// since position entry and triggers when price retraces by the specified percentage.
439///
440/// # Example
441///
442/// ```ignore
443/// use finance_query::backtesting::condition::*;
444///
445/// // Exit if price drops 3% from the highest point since entry
446/// let exit = trailing_stop(0.03);
447/// ```
448#[inline]
449pub fn trailing_stop(trail_pct: f64) -> TrailingStop {
450    TrailingStop::new(trail_pct)
451}
452
453/// Condition: trailing take-profit triggered when profit retraces from peak.
454///
455/// For long positions: tracks the highest profit since entry and triggers
456/// when profit falls by `trail_pct` from that peak profit.
457///
458/// For short positions: tracks the highest profit since entry and triggers
459/// when profit falls by `trail_pct` from that peak profit.
460///
461/// This is useful for locking in gains - it only triggers after you've been
462/// in profit and then profit starts declining.
463///
464/// # Example
465///
466/// ```ignore
467/// use finance_query::backtesting::condition::*;
468///
469/// // Exit if profit drops 2% from highest profit achieved
470/// let exit = trailing_take_profit(0.02);
471/// ```
472#[derive(Debug, Clone, Copy)]
473pub struct TrailingTakeProfit {
474    /// Trail percentage from peak profit (e.g., 0.02 for 2%)
475    pub trail_pct: f64,
476}
477
478impl TrailingTakeProfit {
479    /// Create a new trailing take-profit condition.
480    ///
481    /// # Arguments
482    ///
483    /// * `trail_pct` - Trail percentage from peak profit (e.g., 0.02 for 2%)
484    pub fn new(trail_pct: f64) -> Self {
485        Self { trail_pct }
486    }
487}
488
489impl Condition for TrailingTakeProfit {
490    fn evaluate(&self, ctx: &StrategyContext) -> bool {
491        if let Some(pos) = ctx.position {
492            // Find the entry index
493            let entry_idx = ctx
494                .candles
495                .iter()
496                .position(|c| c.timestamp >= pos.entry_timestamp)
497                .unwrap_or(0);
498
499            // Compute peak profit from entry to current
500            let peak_profit_pct = ctx.candles[entry_idx..=ctx.index]
501                .iter()
502                .map(|c| pos.unrealized_return_pct(c.close))
503                .fold(f64::NEG_INFINITY, f64::max);
504
505            // Only trigger if we've been in profit and current profit is below peak by trail_pct
506            let current_profit_pct = pos.unrealized_return_pct(ctx.close());
507
508            // Convert trail_pct to percentage points (e.g., 0.02 -> 2.0 percentage points)
509            let trail_threshold = self.trail_pct * 100.0;
510
511            peak_profit_pct > 0.0 && current_profit_pct <= peak_profit_pct - trail_threshold
512        } else {
513            false
514        }
515    }
516
517    fn required_indicators(&self) -> Vec<(String, Indicator)> {
518        vec![]
519    }
520
521    fn description(&self) -> String {
522        format!("trailing take profit at {:.1}%", self.trail_pct * 100.0)
523    }
524}
525
526/// Create a trailing take-profit condition.
527///
528/// This condition tracks the peak profit since entry and triggers when
529/// profit drops by the specified percentage from that peak. It only triggers
530/// after the position has been in profit.
531///
532/// # Example
533///
534/// ```ignore
535/// use finance_query::backtesting::condition::*;
536///
537/// // Exit if profit drops 2% from peak profit
538/// let exit = trailing_take_profit(0.02);
539/// ```
540#[inline]
541pub fn trailing_take_profit(trail_pct: f64) -> TrailingTakeProfit {
542    TrailingTakeProfit::new(trail_pct)
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_stop_loss_description() {
551        let sl = stop_loss(0.05);
552        assert_eq!(sl.description(), "stop loss at 5.0%");
553    }
554
555    #[test]
556    fn test_take_profit_description() {
557        let tp = take_profit(0.10);
558        assert_eq!(tp.description(), "take profit at 10.0%");
559    }
560
561    #[test]
562    fn test_position_conditions_descriptions() {
563        assert_eq!(has_position().description(), "has position");
564        assert_eq!(no_position().description(), "no position");
565        assert_eq!(is_long().description(), "is long");
566        assert_eq!(is_short().description(), "is short");
567        assert_eq!(in_profit().description(), "in profit");
568        assert_eq!(in_loss().description(), "in loss");
569    }
570
571    #[test]
572    fn test_held_for_bars_description() {
573        let hfb = held_for_bars(5);
574        assert_eq!(hfb.description(), "held for 5 bars");
575    }
576
577    #[test]
578    fn test_trailing_stop_description() {
579        let ts = trailing_stop(0.03);
580        assert_eq!(ts.description(), "trailing stop at 3.0%");
581    }
582
583    #[test]
584    fn test_trailing_take_profit_description() {
585        let ttp = trailing_take_profit(0.02);
586        assert_eq!(ttp.description(), "trailing take profit at 2.0%");
587    }
588
589    #[test]
590    fn test_no_indicators_required() {
591        assert!(stop_loss(0.05).required_indicators().is_empty());
592        assert!(take_profit(0.10).required_indicators().is_empty());
593        assert!(has_position().required_indicators().is_empty());
594        assert!(no_position().required_indicators().is_empty());
595        assert!(trailing_stop(0.03).required_indicators().is_empty());
596        assert!(trailing_take_profit(0.02).required_indicators().is_empty());
597    }
598}