use crate::backtesting::config::BacktestConfig;
use crate::backtesting::position::Position;
use crate::backtesting::signal::Signal;
use crate::backtesting::strategy::PositionExtremes;
use crate::models::chart::Candle;
#[inline]
pub(crate) fn check_sl_tp(
position: &Position,
candle: &Candle,
hwm: Option<f64>,
config: &BacktestConfig,
) -> Option<Signal> {
let sl_pct = position.bracket_stop_loss_pct.or(config.stop_loss_pct);
let tp_pct = position.bracket_take_profit_pct.or(config.take_profit_pct);
let trail_pct = position
.bracket_trailing_stop_pct
.or(config.trailing_stop_pct);
if let Some(sl_pct) = sl_pct {
let stop_price = if position.is_long() {
position.entry_price * (1.0 - sl_pct)
} else {
position.entry_price * (1.0 + sl_pct)
};
let triggered = if position.is_long() {
candle.low <= stop_price
} else {
candle.high >= stop_price
};
if triggered {
let fill_price = if position.is_long() {
candle.open.min(stop_price)
} else {
candle.open.max(stop_price)
};
let return_pct = position.unrealized_return_pct(fill_price);
return Some(
Signal::exit(candle.timestamp, fill_price)
.with_reason(format!("Stop-loss triggered ({:.1}%)", return_pct)),
);
}
}
if let Some(tp_pct) = tp_pct {
let tp_price = if position.is_long() {
position.entry_price * (1.0 + tp_pct)
} else {
position.entry_price * (1.0 - tp_pct)
};
let triggered = if position.is_long() {
candle.high >= tp_price
} else {
candle.low <= tp_price
};
if triggered {
let fill_price = if position.is_long() {
candle.open.max(tp_price)
} else {
candle.open.min(tp_price)
};
let return_pct = position.unrealized_return_pct(fill_price);
return Some(
Signal::exit(candle.timestamp, fill_price)
.with_reason(format!("Take-profit triggered ({:.1}%)", return_pct)),
);
}
}
if let Some(trail_pct) = trail_pct
&& let Some(extreme) = hwm
&& extreme > 0.0
{
let trail_stop_price = if position.is_long() {
extreme * (1.0 - trail_pct)
} else {
extreme * (1.0 + trail_pct)
};
let triggered = if position.is_long() {
candle.low <= trail_stop_price
} else {
candle.high >= trail_stop_price
};
if triggered {
let fill_price = if position.is_long() {
candle.open.min(trail_stop_price)
} else {
candle.open.max(trail_stop_price)
};
let adverse_move_pct = if position.is_long() {
(extreme - fill_price) / extreme
} else {
(fill_price - extreme) / extreme
};
return Some(
Signal::exit(candle.timestamp, fill_price).with_reason(format!(
"Trailing stop triggered ({:.1}% adverse move)",
adverse_move_pct * 100.0
)),
);
}
}
None
}
#[inline]
pub(crate) fn update_position_extremes(
position: Option<&Position>,
extremes: &mut Option<PositionExtremes>,
candle: &Candle,
) {
if position.is_none() {
*extremes = None;
return;
}
match extremes {
Some(e) => e.update(candle),
None => *extremes = Some(PositionExtremes::new(candle)),
}
}
#[inline]
pub(crate) fn update_trailing_hwm(
position: Option<&Position>,
hwm: &mut Option<f64>,
candle: &Candle,
) {
if let Some(pos) = position {
*hwm = Some(match *hwm {
None => {
if pos.is_long() {
candle.high
} else {
candle.low
}
}
Some(prev) => {
if pos.is_long() {
prev.max(candle.high)
} else {
prev.min(candle.low) }
}
});
} else {
*hwm = None;
}
}
#[cfg(test)]
mod tests {
use super::super::BacktestEngine;
use crate::backtesting::config::BacktestConfig;
use crate::backtesting::engine::fixtures::*;
#[test]
fn test_intrabar_stop_loss_fills_at_stop_price_not_next_open() {
let candles = vec![
make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0), make_candle_ohlc(1, 100.0, 102.0, 99.0, 100.0), make_candle_ohlc(2, 99.0, 99.0, 90.0, 94.0), make_candle_ohlc(3, 94.0, 95.0, 93.0, 94.0), ];
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.stop_loss_pct(0.05) .commission_pct(0.0)
.slippage_pct(0.0)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
let sl_trade = result.trades.iter().find(|t| {
t.exit_signal
.reason
.as_ref()
.map(|r| r.contains("Stop-loss"))
.unwrap_or(false)
});
assert!(sl_trade.is_some(), "expected a stop-loss trade");
let trade = sl_trade.unwrap();
assert!(
(trade.exit_price - 95.0).abs() < 1e-9,
"expected exit at stop price 95.0, got {:.6}",
trade.exit_price
);
assert_eq!(
trade.exit_timestamp, 2,
"exit should be on bar 2 (intrabar)"
);
}
#[test]
fn test_intrabar_stop_loss_gap_down_fills_at_open() {
let candles = vec![
make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0), make_candle_ohlc(1, 100.0, 100.0, 100.0, 100.0), make_candle_ohlc(2, 92.0, 92.0, 90.0, 90.0), ];
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.stop_loss_pct(0.05) .commission_pct(0.0)
.slippage_pct(0.0)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
let sl_trade = result
.trades
.iter()
.find(|t| {
t.exit_signal
.reason
.as_ref()
.map(|r| r.contains("Stop-loss"))
.unwrap_or(false)
})
.expect("expected a stop-loss trade");
assert!(
(sl_trade.exit_price - 92.0).abs() < 1e-9,
"expected gap-down fill at 92.0, got {:.6}",
sl_trade.exit_price
);
}
#[test]
fn test_intrabar_take_profit_fills_at_tp_price() {
let candles = vec![
make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0),
make_candle_ohlc(1, 100.0, 100.0, 100.0, 100.0), make_candle_ohlc(2, 105.0, 112.0, 104.0, 111.0), make_candle_ohlc(3, 112.0, 113.0, 111.0, 112.0), ];
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.take_profit_pct(0.10) .commission_pct(0.0)
.slippage_pct(0.0)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
let tp_trade = result
.trades
.iter()
.find(|t| {
t.exit_signal
.reason
.as_ref()
.map(|r| r.contains("Take-profit"))
.unwrap_or(false)
})
.expect("expected a take-profit trade");
assert!(
(tp_trade.exit_price - 110.0).abs() < 1e-9,
"expected TP fill at 110.0, got {:.6}",
tp_trade.exit_price
);
assert_eq!(
tp_trade.exit_timestamp, 2,
"exit should be on bar 2 (intrabar)"
);
}
#[test]
fn test_per_trade_stop_loss_triggers_when_set() {
let prices = [100.0, 100.0, 80.0, 80.0];
let mut candles = make_candles(&prices);
candles[2].low = 79.2;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketLongStopLossStrategy { stop_pct: 0.05 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"stop-loss should have closed the position"
);
assert!(
result.trades[0].pnl < 0.0,
"stop-loss trade should be a loss"
);
}
#[test]
fn test_per_trade_stop_loss_overrides_config_none() {
let prices = [100.0, 100.0, 80.0, 80.0];
let mut candles = make_candles(&prices);
candles[2].low = 79.2;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.close_at_end(false)
.build()
.unwrap();
assert!(
config.stop_loss_pct.is_none(),
"config must not have a default stop-loss for this test"
);
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketLongStopLossStrategy { stop_pct: 0.05 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"per-trade bracket stop should fire even when config stop_loss_pct is None"
);
}
#[test]
fn test_per_trade_stop_loss_overrides_config_looser() {
let prices = [100.0, 100.0, 97.0, 97.0];
let mut candles = make_candles(&prices);
candles[2].low = 93.0;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.stop_loss_pct(0.20) .close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketLongStopLossStrategy { stop_pct: 0.05 },
)
.unwrap();
assert!(!result.trades.is_empty());
let trade = &result.trades[0];
assert!(
trade.exit_price > 90.0,
"expected exit near 5% bracket stop ($95), got {:.2}",
trade.exit_price
);
}
#[test]
fn test_per_trade_short_stop_loss_triggers_when_set() {
let prices = [100.0, 100.0, 112.0, 112.0];
let mut candles = make_candles(&prices);
candles[2].high = 112.5;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.allow_short(true)
.close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketShortStopLossStrategy { stop_pct: 0.05 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"short stop-loss should have closed the position"
);
assert!(
result.trades[0].pnl < 0.0,
"short stop-loss trade should be a loss (price rose against the short)"
);
}
#[test]
fn test_per_trade_short_stop_loss_overrides_config_none() {
let prices = [100.0, 100.0, 112.0, 112.0];
let mut candles = make_candles(&prices);
candles[2].high = 112.5;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.allow_short(true)
.close_at_end(false)
.build()
.unwrap();
assert!(config.stop_loss_pct.is_none());
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketShortStopLossStrategy { stop_pct: 0.05 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"per-trade bracket stop should fire for shorts even with no config stop-loss"
);
}
#[test]
fn test_per_trade_short_stop_loss_overrides_config_looser() {
let prices = [100.0, 100.0, 103.0, 103.0];
let mut candles = make_candles(&prices);
candles[2].high = 108.0;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.allow_short(true)
.stop_loss_pct(0.20) .close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketShortStopLossStrategy { stop_pct: 0.05 },
)
.unwrap();
assert!(!result.trades.is_empty());
let trade = &result.trades[0];
assert!(
trade.exit_price < 115.0,
"expected exit near 5% bracket stop ($105), got {:.2}",
trade.exit_price
);
}
#[test]
fn test_per_trade_take_profit_triggers() {
let prices = [100.0, 100.0, 120.0, 120.0];
let mut candles = make_candles(&prices);
candles[2].high = 121.2;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketLongTakeProfitStrategy { tp_pct: 0.10 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"long take-profit should have fired"
);
assert!(
result.trades[0].pnl > 0.0,
"long take-profit trade should be profitable"
);
}
#[test]
fn test_per_trade_short_take_profit_triggers() {
let prices = [100.0, 100.0, 85.0, 85.0];
let mut candles = make_candles(&prices);
candles[2].low = 84.15;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.allow_short(true)
.close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketShortTakeProfitStrategy { tp_pct: 0.10 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"short take-profit should have fired"
);
assert!(
result.trades[0].pnl > 0.0,
"short take-profit trade should be profitable (price fell in favor of short)"
);
}
#[test]
fn test_per_trade_trailing_stop_triggers() {
let prices = [100.0, 100.0, 120.0, 110.0, 110.0];
let mut candles = make_candles(&prices);
candles[2].high = 121.0;
candles[3].low = 108.9;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketLongTrailingStopStrategy { trail_pct: 0.05 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"long trailing stop should have fired"
);
assert!(
result.trades[0].pnl > 0.0,
"long trailing stop should exit in profit (entry $100, exit near $110)"
);
}
#[test]
fn test_per_trade_short_trailing_stop_triggers() {
let prices = [100.0, 100.0, 80.0, 88.0, 88.0];
let mut candles = make_candles(&prices);
candles[2].low = 79.2;
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.allow_short(true)
.close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run(
"TEST",
&candles,
BracketShortTrailingStopStrategy { trail_pct: 0.05 },
)
.unwrap();
assert!(
!result.trades.is_empty(),
"short trailing stop should have fired"
);
assert!(
result.trades[0].pnl > 0.0,
"short trailing stop should exit in profit (entry $100, exit near $88)"
);
}
#[test]
fn trailing_stop_armed_by_same_bar_high_does_not_fire_on_same_bar_low() {
let candles = vec![
make_candle_ohlc(0, 100.0, 100.0, 100.0, 100.0),
make_candle_ohlc(1, 100.0, 100.0, 100.0, 100.0),
make_candle_ohlc(2, 100.0, 110.0, 99.0, 109.0),
];
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.trailing_stop_pct(0.05)
.close_at_end(false)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine.run("TEST", &candles, EnterLongHold).unwrap();
assert!(
result.trades.is_empty(),
"a bar's own high must not arm a trail that its own low then fires"
);
assert!(result.open_position.is_some());
}
}