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::{PositionExtremes, 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`](crate::backtesting::BacktestConfig::stop_loss_pct)
20/// instead. A −10% intraday move that closes
21/// at −3% will be caught by the config field but missed by this condition.
22///
23/// # Example
24///
25/// ```ignore
26/// use finance_query::backtesting::condition::*;
27///
28/// let exit = stop_loss(0.05); // Exit if loss >= 5% at bar close
29/// ```
30#[derive(Debug, Clone, Copy)]
31pub struct StopLoss {
32    /// Stop-loss percentage (e.g., 0.05 for 5%)
33    pub pct: f64,
34}
35
36impl StopLoss {
37    /// Create a new stop-loss condition.
38    ///
39    /// # Arguments
40    ///
41    /// * `pct` - Stop-loss percentage (e.g., 0.05 for 5%)
42    pub fn new(pct: f64) -> Self {
43        Self { pct }
44    }
45}
46
47impl Condition for StopLoss {
48    fn evaluate(&self, ctx: &StrategyContext) -> bool {
49        if let Some(pos) = ctx.position {
50            let pnl_pct = pos.unrealized_return_pct(ctx.close()) / 100.0;
51            pnl_pct <= -self.pct
52        } else {
53            false
54        }
55    }
56
57    fn required_indicators(&self) -> Vec<(String, Indicator)> {
58        vec![]
59    }
60
61    fn description(&self) -> String {
62        format!("stop loss at {:.1}%", self.pct * 100.0)
63    }
64}
65
66/// Create a stop-loss condition.
67///
68/// # Example
69///
70/// ```ignore
71/// use finance_query::backtesting::condition::*;
72///
73/// let exit = rsi(14).above(70.0).or(stop_loss(0.05));
74/// ```
75#[inline]
76pub fn stop_loss(pct: f64) -> StopLoss {
77    StopLoss::new(pct)
78}
79
80/// Condition: position P/L is at or above the take-profit threshold.
81///
82/// # Execution Model
83///
84/// This condition evaluates at **bar close**: it fires when the closing price
85/// implies a gain ≥ `pct`. The resulting exit signal is deferred to the **next
86/// bar's open** (identical to all strategy-signal exits).
87///
88/// For intrabar detection (fill same bar at `max(open, target_level)`), use
89/// [`BacktestConfig::take_profit_pct`](crate::backtesting::BacktestConfig::take_profit_pct) instead.
90///
91/// # Example
92///
93/// ```ignore
94/// use finance_query::backtesting::condition::*;
95///
96/// let exit = take_profit(0.10); // Exit if gain >= 10% at bar close
97/// ```
98#[derive(Debug, Clone, Copy)]
99pub struct TakeProfit {
100    /// Take-profit percentage (e.g., 0.10 for 10%)
101    pub pct: f64,
102}
103
104impl TakeProfit {
105    /// Create a new take-profit condition.
106    ///
107    /// # Arguments
108    ///
109    /// * `pct` - Take-profit percentage (e.g., 0.10 for 10%)
110    pub fn new(pct: f64) -> Self {
111        Self { pct }
112    }
113}
114
115impl Condition for TakeProfit {
116    fn evaluate(&self, ctx: &StrategyContext) -> bool {
117        if let Some(pos) = ctx.position {
118            let pnl_pct = pos.unrealized_return_pct(ctx.close()) / 100.0;
119            pnl_pct >= self.pct
120        } else {
121            false
122        }
123    }
124
125    fn required_indicators(&self) -> Vec<(String, Indicator)> {
126        vec![]
127    }
128
129    fn description(&self) -> String {
130        format!("take profit at {:.1}%", self.pct * 100.0)
131    }
132}
133
134/// Create a take-profit condition.
135///
136/// # Example
137///
138/// ```ignore
139/// use finance_query::backtesting::condition::*;
140///
141/// let exit = rsi(14).above(70.0).or(take_profit(0.15));
142/// ```
143#[inline]
144pub fn take_profit(pct: f64) -> TakeProfit {
145    TakeProfit::new(pct)
146}
147
148/// Condition: check if we have any position.
149#[derive(Debug, Clone, Copy)]
150pub struct HasPosition;
151
152impl Condition for HasPosition {
153    fn evaluate(&self, ctx: &StrategyContext) -> bool {
154        ctx.has_position()
155    }
156
157    fn required_indicators(&self) -> Vec<(String, Indicator)> {
158        vec![]
159    }
160
161    fn description(&self) -> String {
162        "has position".to_string()
163    }
164}
165
166/// Create a condition that checks if we have any position.
167#[inline]
168pub fn has_position() -> HasPosition {
169    HasPosition
170}
171
172/// Condition: check if we have no position.
173#[derive(Debug, Clone, Copy)]
174pub struct NoPosition;
175
176impl Condition for NoPosition {
177    fn evaluate(&self, ctx: &StrategyContext) -> bool {
178        !ctx.has_position()
179    }
180
181    fn required_indicators(&self) -> Vec<(String, Indicator)> {
182        vec![]
183    }
184
185    fn description(&self) -> String {
186        "no position".to_string()
187    }
188}
189
190/// Create a condition that checks if we have no position.
191#[inline]
192pub fn no_position() -> NoPosition {
193    NoPosition
194}
195
196/// Condition: check if we have a long position.
197#[derive(Debug, Clone, Copy)]
198pub struct IsLong;
199
200impl Condition for IsLong {
201    fn evaluate(&self, ctx: &StrategyContext) -> bool {
202        ctx.is_long()
203    }
204
205    fn required_indicators(&self) -> Vec<(String, Indicator)> {
206        vec![]
207    }
208
209    fn description(&self) -> String {
210        "is long".to_string()
211    }
212}
213
214/// Create a condition that checks if we have a long position.
215#[inline]
216pub fn is_long() -> IsLong {
217    IsLong
218}
219
220/// Condition: check if we have a short position.
221#[derive(Debug, Clone, Copy)]
222pub struct IsShort;
223
224impl Condition for IsShort {
225    fn evaluate(&self, ctx: &StrategyContext) -> bool {
226        ctx.is_short()
227    }
228
229    fn required_indicators(&self) -> Vec<(String, Indicator)> {
230        vec![]
231    }
232
233    fn description(&self) -> String {
234        "is short".to_string()
235    }
236}
237
238/// Create a condition that checks if we have a short position.
239#[inline]
240pub fn is_short() -> IsShort {
241    IsShort
242}
243
244/// Condition: position P/L is positive (in profit).
245#[derive(Debug, Clone, Copy)]
246pub struct InProfit;
247
248impl Condition for InProfit {
249    fn evaluate(&self, ctx: &StrategyContext) -> bool {
250        if let Some(pos) = ctx.position {
251            pos.unrealized_return_pct(ctx.close()) > 0.0
252        } else {
253            false
254        }
255    }
256
257    fn required_indicators(&self) -> Vec<(String, Indicator)> {
258        vec![]
259    }
260
261    fn description(&self) -> String {
262        "in profit".to_string()
263    }
264}
265
266/// Create a condition that checks if position is profitable.
267#[inline]
268pub fn in_profit() -> InProfit {
269    InProfit
270}
271
272/// Condition: position P/L is negative (in loss).
273#[derive(Debug, Clone, Copy)]
274pub struct InLoss;
275
276impl Condition for InLoss {
277    fn evaluate(&self, ctx: &StrategyContext) -> bool {
278        if let Some(pos) = ctx.position {
279            pos.unrealized_return_pct(ctx.close()) < 0.0
280        } else {
281            false
282        }
283    }
284
285    fn required_indicators(&self) -> Vec<(String, Indicator)> {
286        vec![]
287    }
288
289    fn description(&self) -> String {
290        "in loss".to_string()
291    }
292}
293
294/// Create a condition that checks if position is at a loss.
295#[inline]
296pub fn in_loss() -> InLoss {
297    InLoss
298}
299
300/// Condition: position has been held for at least N bars.
301#[derive(Debug, Clone, Copy)]
302pub struct HeldForBars {
303    /// Minimum number of bars the position must be held
304    pub min_bars: usize,
305}
306
307impl HeldForBars {
308    /// Create a new held-for-bars condition.
309    pub fn new(min_bars: usize) -> Self {
310        Self { min_bars }
311    }
312}
313
314impl Condition for HeldForBars {
315    fn evaluate(&self, ctx: &StrategyContext) -> bool {
316        if let Some(pos) = ctx.position {
317            // Count bars since entry
318            let entry_idx = entry_index(ctx.candles, pos.entry_timestamp);
319            let bars_held = ctx.index.saturating_sub(entry_idx);
320            bars_held >= self.min_bars
321        } else {
322            false
323        }
324    }
325
326    fn required_indicators(&self) -> Vec<(String, Indicator)> {
327        vec![]
328    }
329
330    fn description(&self) -> String {
331        format!("held for {} bars", self.min_bars)
332    }
333}
334
335/// Create a condition that checks if position has been held for at least N bars.
336#[inline]
337pub fn held_for_bars(min_bars: usize) -> HeldForBars {
338    HeldForBars::new(min_bars)
339}
340
341/// Index of the first candle at or after the position's entry.
342///
343/// Candles are sorted ascending, so this is a binary search; the `== len` arm
344/// reproduces the `.unwrap_or(0)` of the linear scan it replaced.
345fn entry_index(candles: &[crate::models::chart::Candle], entry_timestamp: i64) -> usize {
346    let ep = candles.partition_point(|c| c.timestamp < entry_timestamp);
347    if ep == candles.len() { 0 } else { ep }
348}
349
350/// Extremes since entry for the open position in `ctx`.
351///
352/// The engine folds these once per bar and hands them over on the context, so
353/// every trailing condition on a position reads one running value rather than
354/// rescanning the candle history itself. Contexts built outside the engine's bar
355/// loop carry no extremes, so those fall back to a scan from the entry bar.
356fn position_extremes(
357    ctx: &StrategyContext,
358    pos: &crate::backtesting::position::Position,
359) -> PositionExtremes {
360    ctx.extremes.copied().unwrap_or_else(|| {
361        let entry_idx = entry_index(ctx.candles, pos.entry_timestamp);
362        PositionExtremes::from_candles(&ctx.candles[entry_idx..=ctx.index])
363            .unwrap_or_else(|| PositionExtremes::new(ctx.current_candle()))
364    })
365}
366
367/// Condition: trailing stop triggered when price retraces from peak/trough.
368///
369/// For long positions: tracks the highest price since entry and triggers
370/// when price falls by `trail_pct` from that high.
371///
372/// For short positions: tracks the lowest price since entry and triggers
373/// when price rises by `trail_pct` from that low.
374///
375/// # Execution Model
376///
377/// The peak/trough is computed from bar **highs/lows** since entry, but the
378/// trigger test uses the **bar close**. The exit signal is deferred to the
379/// **next bar's open** (identical to all strategy-signal exits).
380///
381/// For intrabar enforcement, use [`BacktestConfig::trailing_stop_pct`](crate::backtesting::BacktestConfig::trailing_stop_pct) instead,
382/// which fills on the same bar when the trailing level is breached intraday.
383///
384/// # Example
385///
386/// ```ignore
387/// use finance_query::backtesting::condition::*;
388///
389/// // Exit if price drops 3% from highest point since entry
390/// let exit = trailing_stop(0.03);
391/// ```
392#[derive(Debug, Clone, Copy)]
393pub struct TrailingStop {
394    /// Trail percentage (e.g., 0.03 for 3%)
395    pub trail_pct: f64,
396}
397
398impl TrailingStop {
399    /// Create a new trailing stop condition.
400    ///
401    /// # Arguments
402    ///
403    /// * `trail_pct` - Trail percentage (e.g., 0.03 for 3%)
404    pub fn new(trail_pct: f64) -> Self {
405        Self { trail_pct }
406    }
407}
408
409impl Condition for TrailingStop {
410    fn evaluate(&self, ctx: &StrategyContext) -> bool {
411        if let Some(pos) = ctx.position {
412            let current_close = ctx.close();
413            let PositionExtremes {
414                high: peak,
415                low: trough,
416                ..
417            } = position_extremes(ctx, pos);
418
419            match pos.side {
420                crate::backtesting::position::PositionSide::Long => {
421                    current_close <= peak * (1.0 - self.trail_pct)
422                }
423                crate::backtesting::position::PositionSide::Short => {
424                    current_close >= trough * (1.0 + self.trail_pct)
425                }
426            }
427        } else {
428            false
429        }
430    }
431
432    fn required_indicators(&self) -> Vec<(String, Indicator)> {
433        vec![]
434    }
435
436    fn description(&self) -> String {
437        format!("trailing stop at {:.1}%", self.trail_pct * 100.0)
438    }
439
440    fn tracks_position_extremes(&self) -> bool {
441        true
442    }
443}
444
445/// Create a trailing stop condition.
446///
447/// The trailing stop tracks the best price (highest for longs, lowest for shorts)
448/// since position entry and triggers when price retraces by the specified percentage.
449///
450/// # Example
451///
452/// ```ignore
453/// use finance_query::backtesting::condition::*;
454///
455/// // Exit if price drops 3% from the highest point since entry
456/// let exit = trailing_stop(0.03);
457/// ```
458#[inline]
459pub fn trailing_stop(trail_pct: f64) -> TrailingStop {
460    TrailingStop::new(trail_pct)
461}
462
463/// Condition: trailing take-profit triggered when profit retraces from peak.
464///
465/// For long positions: tracks the highest profit since entry and triggers
466/// when profit falls by `trail_pct` from that peak profit.
467///
468/// For short positions: tracks the highest profit since entry and triggers
469/// when profit falls by `trail_pct` from that peak profit.
470///
471/// This is useful for locking in gains - it only triggers after you've been
472/// in profit and then profit starts declining.
473///
474/// # Example
475///
476/// ```ignore
477/// use finance_query::backtesting::condition::*;
478///
479/// // Exit if profit drops 2% from highest profit achieved
480/// let exit = trailing_take_profit(0.02);
481/// ```
482#[derive(Debug, Clone, Copy)]
483pub struct TrailingTakeProfit {
484    /// Trail percentage from peak profit (e.g., 0.02 for 2%)
485    pub trail_pct: f64,
486}
487
488impl TrailingTakeProfit {
489    /// Create a new trailing take-profit condition.
490    ///
491    /// # Arguments
492    ///
493    /// * `trail_pct` - Trail percentage from peak profit (e.g., 0.02 for 2%)
494    pub fn new(trail_pct: f64) -> Self {
495        Self { trail_pct }
496    }
497}
498
499impl Condition for TrailingTakeProfit {
500    fn evaluate(&self, ctx: &StrategyContext) -> bool {
501        if let Some(pos) = ctx.position {
502            let PositionExtremes {
503                close_high,
504                close_low,
505                ..
506            } = position_extremes(ctx, pos);
507            // unrealized_return_pct is monotone in price, so the peak profit is
508            // reached at the window's extreme close for the position's side.
509            let best_close = match pos.side {
510                crate::backtesting::position::PositionSide::Long => close_high,
511                crate::backtesting::position::PositionSide::Short => close_low,
512            };
513            let peak_profit_pct = pos.unrealized_return_pct(best_close);
514
515            // Only trigger if we've been in profit and current profit is below peak by trail_pct
516            let current_profit_pct = pos.unrealized_return_pct(ctx.close());
517
518            // Convert trail_pct to percentage points (e.g., 0.02 -> 2.0 percentage points)
519            let trail_threshold = self.trail_pct * 100.0;
520
521            peak_profit_pct > 0.0 && current_profit_pct <= peak_profit_pct - trail_threshold
522        } else {
523            false
524        }
525    }
526
527    fn required_indicators(&self) -> Vec<(String, Indicator)> {
528        vec![]
529    }
530
531    fn description(&self) -> String {
532        format!("trailing take profit at {:.1}%", self.trail_pct * 100.0)
533    }
534
535    fn tracks_position_extremes(&self) -> bool {
536        true
537    }
538}
539
540/// Create a trailing take-profit condition.
541///
542/// This condition tracks the peak profit since entry and triggers when
543/// profit drops by the specified percentage from that peak. It only triggers
544/// after the position has been in profit.
545///
546/// # Example
547///
548/// ```ignore
549/// use finance_query::backtesting::condition::*;
550///
551/// // Exit if profit drops 2% from peak profit
552/// let exit = trailing_take_profit(0.02);
553/// ```
554#[inline]
555pub fn trailing_take_profit(trail_pct: f64) -> TrailingTakeProfit {
556    TrailingTakeProfit::new(trail_pct)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn ramp(n: usize) -> Vec<crate::models::chart::Candle> {
564        (0..n)
565            .map(|i| {
566                let px = 100.0 + i as f64;
567                crate::models::chart::Candle {
568                    timestamp: 1_600_000_000 + i as i64 * 86400,
569                    open: px,
570                    high: px * 1.02,
571                    low: px * 0.98,
572                    close: px,
573                    volume: 1_000,
574                    ..Default::default()
575                }
576            })
577            .collect()
578    }
579
580    #[test]
581    fn entry_index_matches_linear_scan() {
582        let candles = ramp(200);
583        let probes = [
584            0i64,
585            candles[0].timestamp,
586            candles[7].timestamp,
587            candles[7].timestamp + 1,
588            candles[199].timestamp,
589            candles[199].timestamp + 10_000,
590        ];
591        for entry_ts in probes {
592            let linear = candles
593                .iter()
594                .position(|c| c.timestamp >= entry_ts)
595                .unwrap_or(0);
596            let ep = candles.partition_point(|c| c.timestamp < entry_ts);
597            let binary = if ep == candles.len() { 0 } else { ep };
598            assert_eq!(linear, binary, "mismatch for entry_ts={entry_ts}");
599        }
600    }
601
602    fn peak_then_drop(n: usize) -> Vec<crate::models::chart::Candle> {
603        (0..n)
604            .map(|i| {
605                let half = n / 2;
606                let px = if i < half {
607                    100.0 + i as f64
608                } else {
609                    100.0 + half as f64 - (i - half) as f64
610                };
611                crate::models::chart::Candle {
612                    timestamp: 1_600_000_000 + i as i64 * 86400,
613                    open: px,
614                    high: px * 1.02,
615                    low: px * 0.98,
616                    close: px,
617                    volume: 1_000,
618                    ..Default::default()
619                }
620            })
621            .collect()
622    }
623
624    #[test]
625    fn trailing_stop_exit_bars_are_stable() {
626        use crate::backtesting::refs::*;
627        use crate::backtesting::{BacktestConfig, BacktestEngine, StrategyBuilder};
628
629        let candles = peak_then_drop(400);
630        let strat = StrategyBuilder::new("t")
631            .entry(price().above(0.0))
632            .exit(trailing_stop(0.03))
633            .build();
634        let result = BacktestEngine::new(BacktestConfig::default())
635            .run("TEST", &candles, strat)
636            .unwrap();
637        let actual: Vec<(i64, i64)> = result
638            .trades
639            .iter()
640            .map(|t| (t.entry_timestamp, t.exit_timestamp))
641            .collect();
642        let expected: Vec<(i64, i64)> = vec![
643            (1600172800, 1617712000),
644            (1617712000, 1618144000),
645            (1618144000, 1618576000),
646            (1618576000, 1619008000),
647            (1619008000, 1619353600),
648            (1619353600, 1619699200),
649            (1619699200, 1620044800),
650            (1620044800, 1620390400),
651            (1620390400, 1620736000),
652            (1620736000, 1621081600),
653            (1621081600, 1621427200),
654            (1621427200, 1621772800),
655            (1621772800, 1622118400),
656            (1622118400, 1622464000),
657            (1622464000, 1622809600),
658            (1622809600, 1623155200),
659            (1623155200, 1623500800),
660            (1623500800, 1623846400),
661            (1623846400, 1624192000),
662            (1624192000, 1624537600),
663            (1624537600, 1624883200),
664            (1624883200, 1625228800),
665            (1625228800, 1625574400),
666            (1625574400, 1625920000),
667            (1625920000, 1626265600),
668            (1626265600, 1626611200),
669            (1626611200, 1626956800),
670            (1626956800, 1627216000),
671            (1627216000, 1627475200),
672            (1627475200, 1627734400),
673            (1627734400, 1627993600),
674            (1627993600, 1628252800),
675            (1628252800, 1628512000),
676            (1628512000, 1628771200),
677            (1628771200, 1629030400),
678            (1629030400, 1629289600),
679            (1629289600, 1629548800),
680            (1629548800, 1629808000),
681            (1629808000, 1630067200),
682            (1630067200, 1630326400),
683            (1630326400, 1630585600),
684            (1630585600, 1630844800),
685            (1630844800, 1631104000),
686            (1631104000, 1631363200),
687            (1631363200, 1631622400),
688            (1631622400, 1631881600),
689            (1631881600, 1632140800),
690            (1632140800, 1632400000),
691            (1632400000, 1632659200),
692            (1632659200, 1632918400),
693            (1632918400, 1633177600),
694            (1633177600, 1633436800),
695            (1633436800, 1633696000),
696            (1633696000, 1633955200),
697            (1633955200, 1634214400),
698            (1634214400, 1634473600),
699            (1634473600, 1634473600),
700        ];
701        assert_eq!(actual, expected);
702    }
703
704    #[test]
705    fn trailing_conditions_stay_copy() {
706        // The peak lives on the position, not the condition, so these carry no
707        // state and remain plain `Copy` value types.
708        fn assert_copy<T: Copy>(_: &T) {}
709        assert_copy(&TrailingStop::new(0.05));
710        assert_copy(&TrailingTakeProfit::new(0.05));
711
712        let ts = TrailingStop::new(0.05);
713        let a = ts;
714        let b = ts;
715        assert_eq!(a.trail_pct, b.trail_pct);
716    }
717
718    #[test]
719    fn only_trailing_strategies_opt_into_extremes_tracking() {
720        // The engine folds extremes per bar only when this reports true, so a
721        // trailing condition that loses the flag silently falls back to the
722        // O(bars²) rescan this PR removed — and a strategy without one would
723        // otherwise pay for a value nothing reads.
724        use crate::backtesting::refs::*;
725        use crate::backtesting::strategy::{Strategy, StrategyBuilder};
726
727        assert!(TrailingStop::new(0.05).tracks_position_extremes());
728        assert!(TrailingTakeProfit::new(0.05).tracks_position_extremes());
729        assert!(!stop_loss(0.05).tracks_position_extremes());
730
731        // Composites have to carry it through, in either position.
732        assert!(
733            stop_loss(0.05)
734                .or(trailing_stop(0.03))
735                .tracks_position_extremes()
736        );
737        assert!(
738            trailing_stop(0.03)
739                .and(in_profit())
740                .tracks_position_extremes()
741        );
742        assert!(
743            !stop_loss(0.05)
744                .or(take_profit(0.1))
745                .tracks_position_extremes()
746        );
747
748        // ...and so does the strategy built from them.
749        let trailing = StrategyBuilder::new("trailing")
750            .entry(price().above(0.0))
751            .exit(trailing_stop(0.03))
752            .build();
753        assert!(trailing.tracks_position_extremes());
754
755        let plain = StrategyBuilder::new("plain")
756            .entry(price().above(0.0))
757            .exit(stop_loss(0.05))
758            .build();
759        assert!(
760            !plain.tracks_position_extremes(),
761            "a strategy with no trailing condition must not pay for the tracking"
762        );
763    }
764
765    #[test]
766    fn context_extremes_match_a_scan_from_entry() {
767        // Conditions read the engine's running extremes when present and fall
768        // back to scanning from the entry bar when they aren't. Both paths must
769        // produce the same verdict.
770        let candles = ramp(30);
771        let entry_idx = 5usize;
772        let index = 20usize;
773        let position = crate::backtesting::position::Position::new(
774            crate::backtesting::position::PositionSide::Long,
775            candles[entry_idx].timestamp,
776            candles[entry_idx].close,
777            10.0,
778            0.0,
779            crate::backtesting::signal::Signal::long(
780                candles[entry_idx].timestamp,
781                candles[entry_idx].close,
782            ),
783        );
784        let indicators = std::collections::HashMap::new();
785        let scanned = PositionExtremes::from_candles(&candles[entry_idx..=index]).unwrap();
786
787        for pct in [0.001, 0.01, 0.05, 0.5] {
788            let cond = TrailingStop::new(pct);
789            let tp = TrailingTakeProfit::new(pct);
790            let with = StrategyContext {
791                candles: &candles[..=index],
792                index,
793                position: Some(&position),
794                equity: 10_000.0,
795                indicators: &indicators,
796                extremes: Some(&scanned),
797                indicator_index: None,
798            };
799            let without = StrategyContext {
800                candles: &candles[..=index],
801                index,
802                position: Some(&position),
803                equity: 10_000.0,
804                indicators: &indicators,
805                extremes: None,
806                indicator_index: None,
807            };
808            assert_eq!(
809                cond.evaluate(&with),
810                cond.evaluate(&without),
811                "trailing stop disagreed at {pct}"
812            );
813            assert_eq!(
814                tp.evaluate(&with),
815                tp.evaluate(&without),
816                "trailing take-profit disagreed at {pct}"
817            );
818        }
819    }
820
821    #[test]
822    fn test_stop_loss_description() {
823        let sl = stop_loss(0.05);
824        assert_eq!(sl.description(), "stop loss at 5.0%");
825    }
826
827    #[test]
828    fn test_take_profit_description() {
829        let tp = take_profit(0.10);
830        assert_eq!(tp.description(), "take profit at 10.0%");
831    }
832
833    #[test]
834    fn test_position_conditions_descriptions() {
835        assert_eq!(has_position().description(), "has position");
836        assert_eq!(no_position().description(), "no position");
837        assert_eq!(is_long().description(), "is long");
838        assert_eq!(is_short().description(), "is short");
839        assert_eq!(in_profit().description(), "in profit");
840        assert_eq!(in_loss().description(), "in loss");
841    }
842
843    #[test]
844    fn test_held_for_bars_description() {
845        let hfb = held_for_bars(5);
846        assert_eq!(hfb.description(), "held for 5 bars");
847    }
848
849    #[test]
850    fn test_trailing_stop_description() {
851        let ts = trailing_stop(0.03);
852        assert_eq!(ts.description(), "trailing stop at 3.0%");
853    }
854
855    #[test]
856    fn test_trailing_take_profit_description() {
857        let ttp = trailing_take_profit(0.02);
858        assert_eq!(ttp.description(), "trailing take profit at 2.0%");
859    }
860
861    #[test]
862    fn test_no_indicators_required() {
863        assert!(stop_loss(0.05).required_indicators().is_empty());
864        assert!(take_profit(0.10).required_indicators().is_empty());
865        assert!(has_position().required_indicators().is_empty());
866        assert!(no_position().required_indicators().is_empty());
867        assert!(trailing_stop(0.03).required_indicators().is_empty());
868        assert!(trailing_take_profit(0.02).required_indicators().is_empty());
869    }
870
871    /// The `TrailingTakeProfit` fast path folds closes into a single extreme and
872    /// evaluates `unrealized_return_pct` once, instead of evaluating it per bar
873    /// and folding the results. That is only exact because the function is
874    /// monotone in price — increasing for longs, decreasing for shorts. Pin it.
875    #[test]
876    fn peak_profit_from_extreme_close_matches_per_bar_fold() {
877        use crate::backtesting::position::{Position, PositionSide};
878        use crate::backtesting::signal::Signal;
879
880        let closes: Vec<f64> = (0..300)
881            .map(|i| 100.0 + (i as f64 * 0.37).sin() * 25.0 + (i as f64 * 0.011))
882            .collect();
883
884        for side in [PositionSide::Long, PositionSide::Short] {
885            for entry_price in [1.0_f64, 87.5, 100.0, 133.25] {
886                let pos = Position::new(
887                    side,
888                    1_600_000_000,
889                    entry_price,
890                    7.0,
891                    0.0,
892                    Signal::long(1_600_000_000, entry_price),
893                );
894
895                let per_bar = closes
896                    .iter()
897                    .map(|&c| pos.unrealized_return_pct(c))
898                    .fold(f64::NEG_INFINITY, f64::max);
899
900                let extreme = match side {
901                    PositionSide::Long => closes.iter().copied().fold(f64::NEG_INFINITY, f64::max),
902                    PositionSide::Short => closes.iter().copied().fold(f64::INFINITY, f64::min),
903                };
904                let single = pos.unrealized_return_pct(extreme);
905
906                assert_eq!(
907                    per_bar, single,
908                    "side={side:?} entry={entry_price}: fold-of-f != f-of-extreme"
909                );
910            }
911        }
912    }
913}