1use crate::backtesting::strategy::{PositionExtremes, StrategyContext};
6use crate::indicators::Indicator;
7
8use super::Condition;
9
10#[derive(Debug, Clone, Copy)]
31pub struct StopLoss {
32 pub pct: f64,
34}
35
36impl StopLoss {
37 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#[inline]
76pub fn stop_loss(pct: f64) -> StopLoss {
77 StopLoss::new(pct)
78}
79
80#[derive(Debug, Clone, Copy)]
99pub struct TakeProfit {
100 pub pct: f64,
102}
103
104impl TakeProfit {
105 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#[inline]
144pub fn take_profit(pct: f64) -> TakeProfit {
145 TakeProfit::new(pct)
146}
147
148#[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#[inline]
168pub fn has_position() -> HasPosition {
169 HasPosition
170}
171
172#[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#[inline]
192pub fn no_position() -> NoPosition {
193 NoPosition
194}
195
196#[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#[inline]
216pub fn is_long() -> IsLong {
217 IsLong
218}
219
220#[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#[inline]
240pub fn is_short() -> IsShort {
241 IsShort
242}
243
244#[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#[inline]
268pub fn in_profit() -> InProfit {
269 InProfit
270}
271
272#[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#[inline]
296pub fn in_loss() -> InLoss {
297 InLoss
298}
299
300#[derive(Debug, Clone, Copy)]
302pub struct HeldForBars {
303 pub min_bars: usize,
305}
306
307impl HeldForBars {
308 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 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#[inline]
337pub fn held_for_bars(min_bars: usize) -> HeldForBars {
338 HeldForBars::new(min_bars)
339}
340
341fn 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
350fn 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#[derive(Debug, Clone, Copy)]
393pub struct TrailingStop {
394 pub trail_pct: f64,
396}
397
398impl TrailingStop {
399 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#[inline]
459pub fn trailing_stop(trail_pct: f64) -> TrailingStop {
460 TrailingStop::new(trail_pct)
461}
462
463#[derive(Debug, Clone, Copy)]
483pub struct TrailingTakeProfit {
484 pub trail_pct: f64,
486}
487
488impl TrailingTakeProfit {
489 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 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 let current_profit_pct = pos.unrealized_return_pct(ctx.close());
517
518 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#[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 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 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 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 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 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 #[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}