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