1use std::collections::HashMap;
4
5use crate::indicators::{self, Indicator};
6use crate::models::chart::{Candle, Dividend};
7
8use super::config::BacktestConfig;
9use super::error::{BacktestError, Result};
10use super::position::{Position, PositionSide, Trade};
11use super::result::{
12 BacktestResult, BenchmarkMetrics, EquityPoint, PerformanceMetrics, SignalRecord,
13};
14use super::signal::{OrderType, PendingOrder, Signal, SignalDirection};
15use super::strategy::{Strategy, StrategyContext};
16
17pub struct BacktestEngine {
21 config: BacktestConfig,
22}
23
24#[inline]
26fn needs_high_low(indicator: &Indicator) -> bool {
27 matches!(
28 indicator,
29 Indicator::Atr(_)
30 | Indicator::Supertrend { .. }
31 | Indicator::DonchianChannels(_)
32 | Indicator::Cci(_)
33 | Indicator::WilliamsR(_)
34 | Indicator::Adx(_)
35 | Indicator::Mfi(_)
36 | Indicator::Cmf(_)
37 | Indicator::Stochastic { .. }
38 | Indicator::Aroon(_)
39 | Indicator::Ichimoku { .. }
40 | Indicator::ParabolicSar { .. }
41 | Indicator::KeltnerChannels { .. }
42 | Indicator::TrueRange
43 | Indicator::ChoppinessIndex(_)
44 | Indicator::Vwap
45 | Indicator::ChaikinOscillator
46 | Indicator::AccumulationDistribution
47 | Indicator::BalanceOfPower(_)
48 | Indicator::BullBearPower(_)
49 | Indicator::ElderRay(_)
50 | Indicator::AwesomeOscillator { .. }
51 )
52}
53
54#[inline]
56fn needs_volumes(indicator: &Indicator) -> bool {
57 matches!(
58 indicator,
59 Indicator::Obv
60 | Indicator::Mfi(_)
61 | Indicator::Cmf(_)
62 | Indicator::Vwma(_)
63 | Indicator::Vwap
64 | Indicator::ChaikinOscillator
65 | Indicator::AccumulationDistribution
66 )
67}
68
69fn compute_one(
71 closes: &[f64],
72 highs: &[f64],
73 lows: &[f64],
74 volumes: &[f64],
75 opens: &[f64],
76 name: String,
77 indicator: Indicator,
78) -> Result<Vec<(String, Vec<Option<f64>>)>> {
79 let mut out = Vec::with_capacity(5);
80 match indicator {
81 Indicator::Sma(period) => {
82 out.push((name, indicators::sma(closes, period)));
83 }
84 Indicator::Ema(period) => {
85 out.push((name, indicators::ema(closes, period)));
86 }
87 Indicator::Rsi(period) => {
88 out.push((name, indicators::rsi(closes, period)?));
89 }
90 Indicator::Macd { fast, slow, signal } => {
91 let m = indicators::macd(closes, fast, slow, signal)?;
92 out.push((format!("macd_line_{fast}_{slow}_{signal}"), m.macd_line));
93 out.push((format!("macd_signal_{fast}_{slow}_{signal}"), m.signal_line));
94 out.push((
95 format!("macd_histogram_{fast}_{slow}_{signal}"),
96 m.histogram,
97 ));
98 }
99 Indicator::Bollinger { period, std_dev } => {
100 let bb = indicators::bollinger_bands(closes, period, std_dev)?;
101 out.push((format!("bollinger_upper_{period}_{std_dev}"), bb.upper));
102 out.push((format!("bollinger_middle_{period}_{std_dev}"), bb.middle));
103 out.push((format!("bollinger_lower_{period}_{std_dev}"), bb.lower));
104 }
105 Indicator::Atr(period) => {
106 out.push((name, indicators::atr(highs, lows, closes, period)?));
107 }
108 Indicator::Supertrend { period, multiplier } => {
109 let st = indicators::supertrend(highs, lows, closes, period, multiplier)?;
110 out.push((format!("supertrend_value_{period}_{multiplier}"), st.value));
111 let uptrend: Vec<Option<f64>> = st
112 .is_uptrend
113 .into_iter()
114 .map(|v| v.map(|b| if b { 1.0 } else { 0.0 }))
115 .collect();
116 out.push((format!("supertrend_uptrend_{period}_{multiplier}"), uptrend));
117 }
118 Indicator::DonchianChannels(period) => {
119 let dc = indicators::donchian_channels(highs, lows, period)?;
120 out.push((format!("donchian_upper_{period}"), dc.upper));
121 out.push((format!("donchian_middle_{period}"), dc.middle));
122 out.push((format!("donchian_lower_{period}"), dc.lower));
123 }
124 Indicator::Wma(period) => {
125 out.push((name, indicators::wma(closes, period)?));
126 }
127 Indicator::Dema(period) => {
128 out.push((name, indicators::dema(closes, period)?));
129 }
130 Indicator::Tema(period) => {
131 out.push((name, indicators::tema(closes, period)?));
132 }
133 Indicator::Hma(period) => {
134 out.push((name, indicators::hma(closes, period)?));
135 }
136 Indicator::Obv => {
137 out.push((name, indicators::obv(closes, volumes)?));
138 }
139 Indicator::Momentum(period) => {
140 out.push((name, indicators::momentum(closes, period)?));
141 }
142 Indicator::Roc(period) => {
143 out.push((name, indicators::roc(closes, period)?));
144 }
145 Indicator::Cci(period) => {
146 out.push((name, indicators::cci(highs, lows, closes, period)?));
147 }
148 Indicator::WilliamsR(period) => {
149 out.push((name, indicators::williams_r(highs, lows, closes, period)?));
150 }
151 Indicator::Adx(period) => {
152 out.push((name, indicators::adx(highs, lows, closes, period)?));
153 }
154 Indicator::Mfi(period) => {
155 out.push((name, indicators::mfi(highs, lows, closes, volumes, period)?));
156 }
157 Indicator::Cmf(period) => {
158 out.push((name, indicators::cmf(highs, lows, closes, volumes, period)?));
159 }
160 Indicator::Cmo(period) => {
161 out.push((name, indicators::cmo(closes, period)?));
162 }
163 Indicator::Vwma(period) => {
164 out.push((name, indicators::vwma(closes, volumes, period)?));
165 }
166 Indicator::Alma {
167 period,
168 offset,
169 sigma,
170 } => {
171 out.push((name, indicators::alma(closes, period, offset, sigma)?));
172 }
173 Indicator::McginleyDynamic(period) => {
174 out.push((name, indicators::mcginley_dynamic(closes, period)?));
175 }
176 Indicator::Stochastic {
177 k_period,
178 k_slow,
179 d_period,
180 } => {
181 let s = indicators::stochastic(highs, lows, closes, k_period, k_slow, d_period)?;
182 out.push((format!("stochastic_k_{k_period}_{k_slow}_{d_period}"), s.k));
183 out.push((format!("stochastic_d_{k_period}_{k_slow}_{d_period}"), s.d));
184 }
185 Indicator::StochasticRsi {
186 rsi_period,
187 stoch_period,
188 k_period,
189 d_period,
190 } => {
191 let s =
192 indicators::stochastic_rsi(closes, rsi_period, stoch_period, k_period, d_period)?;
193 out.push((
194 format!("stoch_rsi_k_{rsi_period}_{stoch_period}_{k_period}_{d_period}"),
195 s.k,
196 ));
197 out.push((
198 format!("stoch_rsi_d_{rsi_period}_{stoch_period}_{k_period}_{d_period}"),
199 s.d,
200 ));
201 }
202 Indicator::AwesomeOscillator { fast, slow } => {
203 out.push((
204 name,
205 indicators::awesome_oscillator(highs, lows, fast, slow)?,
206 ));
207 }
208 Indicator::CoppockCurve {
209 wma_period,
210 long_roc,
211 short_roc,
212 } => {
213 out.push((
214 name,
215 indicators::coppock_curve(closes, long_roc, short_roc, wma_period)?,
216 ));
217 }
218 Indicator::Aroon(period) => {
219 let a = indicators::aroon(highs, lows, period)?;
220 out.push((format!("aroon_up_{period}"), a.aroon_up));
221 out.push((format!("aroon_down_{period}"), a.aroon_down));
222 }
223 Indicator::Ichimoku {
224 conversion,
225 base,
226 lagging,
227 displacement,
228 } => {
229 let ich =
230 indicators::ichimoku(highs, lows, closes, conversion, base, lagging, displacement)?;
231 out.push((
232 format!("ichimoku_conversion_{conversion}_{base}_{lagging}_{displacement}"),
233 ich.conversion_line,
234 ));
235 out.push((
236 format!("ichimoku_base_{conversion}_{base}_{lagging}_{displacement}"),
237 ich.base_line,
238 ));
239 out.push((
240 format!("ichimoku_leading_a_{conversion}_{base}_{lagging}_{displacement}"),
241 ich.leading_span_a,
242 ));
243 out.push((
244 format!("ichimoku_leading_b_{conversion}_{base}_{lagging}_{displacement}"),
245 ich.leading_span_b,
246 ));
247 out.push((
248 format!("ichimoku_lagging_{conversion}_{base}_{lagging}_{displacement}"),
249 ich.lagging_span,
250 ));
251 }
252 Indicator::ParabolicSar { step, max } => {
253 out.push((
254 name,
255 indicators::parabolic_sar(highs, lows, closes, step, max)?,
256 ));
257 }
258 Indicator::KeltnerChannels {
259 period,
260 multiplier,
261 atr_period,
262 } => {
263 let kc =
264 indicators::keltner_channels(highs, lows, closes, period, atr_period, multiplier)?;
265 out.push((
266 format!("keltner_upper_{period}_{multiplier}_{atr_period}"),
267 kc.upper,
268 ));
269 out.push((
270 format!("keltner_middle_{period}_{multiplier}_{atr_period}"),
271 kc.middle,
272 ));
273 out.push((
274 format!("keltner_lower_{period}_{multiplier}_{atr_period}"),
275 kc.lower,
276 ));
277 }
278 Indicator::TrueRange => {
279 out.push((name, indicators::true_range(highs, lows, closes)?));
280 }
281 Indicator::ChoppinessIndex(period) => {
282 out.push((
283 name,
284 indicators::choppiness_index(highs, lows, closes, period)?,
285 ));
286 }
287 Indicator::Vwap => {
288 out.push((name, indicators::vwap(highs, lows, closes, volumes)?));
289 }
290 Indicator::ChaikinOscillator => {
291 out.push((
292 name,
293 indicators::chaikin_oscillator(highs, lows, closes, volumes)?,
294 ));
295 }
296 Indicator::AccumulationDistribution => {
297 out.push((
298 name,
299 indicators::accumulation_distribution(highs, lows, closes, volumes)?,
300 ));
301 }
302 Indicator::BalanceOfPower(period) => {
303 out.push((
304 name,
305 indicators::balance_of_power(opens, highs, lows, closes, period)?,
306 ));
307 }
308 Indicator::BullBearPower(period) => {
309 let bbp = indicators::bull_bear_power(highs, lows, closes, period)?;
310 out.push((format!("bull_power_{period}"), bbp.bull_power));
311 out.push((format!("bear_power_{period}"), bbp.bear_power));
312 }
313 Indicator::ElderRay(period) => {
314 let er = indicators::elder_ray(highs, lows, closes, period)?;
315 out.push((format!("elder_bull_{period}"), er.bull_power));
316 out.push((format!("elder_bear_{period}"), er.bear_power));
317 }
318 }
319 Ok(out)
320}
321
322pub(crate) fn compute_for_candles(
333 candles: &[Candle],
334 required: Vec<(String, Indicator)>,
335) -> Result<HashMap<String, Vec<Option<f64>>>> {
336 if required.is_empty() {
337 return Ok(HashMap::new());
338 }
339
340 let use_hl = required.iter().any(|(_, i)| needs_high_low(i));
341 let use_vol = required.iter().any(|(_, i)| needs_volumes(i));
342 let use_open = required
343 .iter()
344 .any(|(_, i)| matches!(i, Indicator::BalanceOfPower(_)));
345
346 let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();
348 let (highs, lows): (Vec<f64>, Vec<f64>) = if use_hl {
349 candles.iter().map(|c| (c.high, c.low)).unzip()
350 } else {
351 (vec![], vec![])
352 };
353 let volumes: Vec<f64> = if use_vol {
354 candles.iter().map(|c| c.volume as f64).collect()
355 } else {
356 vec![]
357 };
358 let opens: Vec<f64> = if use_open {
359 candles.iter().map(|c| c.open).collect()
360 } else {
361 vec![]
362 };
363
364 type IndPairs = Vec<(String, Vec<Option<f64>>)>;
365
366 let groups: Result<Vec<IndPairs>> = if required.len() >= 4 && candles.len() >= 1_000 {
370 use rayon::prelude::*;
371 required
372 .into_par_iter()
373 .map(|(name, ind)| compute_one(&closes, &highs, &lows, &volumes, &opens, name, ind))
374 .collect()
375 } else {
376 required
377 .into_iter()
378 .map(|(name, ind)| compute_one(&closes, &highs, &lows, &volumes, &opens, name, ind))
379 .collect()
380 };
381
382 let groups = groups?;
383 let capacity: usize = groups.iter().map(|v| v.len()).sum();
384 let mut result = HashMap::with_capacity(capacity);
385 for group in groups {
386 for (k, v) in group {
387 result.insert(k, v);
388 }
389 }
390 Ok(result)
391}
392
393impl BacktestEngine {
394 pub fn new(config: BacktestConfig) -> Self {
396 Self { config }
397 }
398
399 pub fn run<S: Strategy>(
406 &self,
407 symbol: &str,
408 candles: &[Candle],
409 strategy: S,
410 ) -> Result<BacktestResult> {
411 self.simulate(symbol, candles, strategy, &[])
412 }
413
414 pub fn run_with_dividends<S: Strategy>(
422 &self,
423 symbol: &str,
424 candles: &[Candle],
425 strategy: S,
426 dividends: &[Dividend],
427 ) -> Result<BacktestResult> {
428 self.simulate(symbol, candles, strategy, dividends)
429 }
430
431 fn simulate<S: Strategy>(
435 &self,
436 symbol: &str,
437 candles: &[Candle],
438 mut strategy: S,
439 dividends: &[Dividend],
440 ) -> Result<BacktestResult> {
441 let warmup = strategy.warmup_period();
442 if candles.len() < warmup {
443 return Err(BacktestError::insufficient_data(warmup, candles.len()));
444 }
445
446 if !dividends
448 .windows(2)
449 .all(|w| w[0].timestamp <= w[1].timestamp)
450 {
451 return Err(BacktestError::invalid_param(
452 "dividends",
453 "must be sorted by timestamp (ascending)",
454 ));
455 }
456
457 let mut indicators = self.compute_indicators(candles, &strategy)?;
459 indicators.extend(self.compute_htf_indicators(candles, &strategy)?);
460
461 strategy.setup(&indicators);
464
465 let mut equity = self.config.initial_capital;
467 let mut cash = self.config.initial_capital;
468 let mut position: Option<Position> = None;
469 let mut trades: Vec<Trade> = Vec::new();
470 let mut equity_curve: Vec<EquityPoint> = Vec::with_capacity(candles.len());
471 let mut signals: Vec<SignalRecord> = Vec::new();
472 let mut peak_equity = equity;
473 let mut hwm: Option<f64> = None;
476
477 let mut div_idx: usize = 0;
480
481 let mut pending_orders: Vec<PendingOrder> = Vec::new();
484
485 for i in 0..candles.len() {
487 let candle = &candles[i];
488
489 equity = Self::update_equity_and_curve(
490 position.as_ref(),
491 candle,
492 cash,
493 &mut peak_equity,
494 &mut equity_curve,
495 );
496
497 update_trailing_hwm(position.as_ref(), &mut hwm, candle);
498
499 self.credit_dividends(&mut position, candle, dividends, &mut div_idx);
501
502 if let Some(ref pos) = position
506 && let Some(exit_signal) = self.check_sl_tp(pos, candle, hwm)
507 {
508 let fill_price = exit_signal.price;
509 let executed = self.close_position_at(
510 &mut position,
511 &mut cash,
512 &mut trades,
513 candle,
514 fill_price,
515 &exit_signal,
516 );
517
518 signals.push(SignalRecord {
519 timestamp: candle.timestamp,
520 price: fill_price,
521 direction: SignalDirection::Exit,
522 strength: 1.0,
523 reason: exit_signal.reason.clone(),
524 executed,
525 tags: exit_signal.tags.clone(),
526 });
527
528 if executed {
529 hwm = None; continue; }
532 }
533
534 let mut filled_this_bar = false;
543 pending_orders.retain_mut(|order| {
544 if let Some(exp) = order.expires_in_bars
546 && i >= order.created_bar + exp
547 {
548 return false; }
550
551 if position.is_some() || filled_this_bar {
554 return true; }
556
557 if matches!(order.signal.direction, SignalDirection::Short)
559 && !self.config.allow_short
560 {
561 return true; }
563
564 let upgrade_to_limit = match &order.order_type {
570 OrderType::BuyStopLimit {
571 stop_price,
572 limit_price,
573 } if candle.high >= *stop_price => {
574 let trigger_fill = candle.open.max(*stop_price);
575 if trigger_fill > *limit_price {
576 Some(*limit_price) } else {
578 None }
580 }
581 _ => None,
582 };
583 if let Some(new_limit) = upgrade_to_limit {
584 order.order_type = OrderType::BuyLimit {
585 limit_price: new_limit,
586 };
587 return true; }
589
590 if let Some(fill_price) = order.order_type.try_fill(candle) {
591 let is_long = matches!(order.signal.direction, SignalDirection::Long);
592 let executed = self.open_position_at_price(
593 &mut position,
594 &mut cash,
595 candle,
596 &order.signal,
597 is_long,
598 fill_price,
599 );
600 if executed {
601 hwm = position.as_ref().map(|p| p.entry_price);
602 signals.push(SignalRecord {
603 timestamp: candle.timestamp,
604 price: fill_price,
605 direction: order.signal.direction,
606 strength: order.signal.strength.value(),
607 reason: order.signal.reason.clone(),
608 executed: true,
609 tags: order.signal.tags.clone(),
610 });
611 filled_this_bar = true;
612 return false; }
614 }
615
616 true });
618
619 if i < warmup.saturating_sub(1) {
621 continue;
622 }
623
624 let ctx = StrategyContext {
626 candles: &candles[..=i],
627 index: i,
628 position: position.as_ref(),
629 equity,
630 indicators: &indicators,
631 };
632
633 let signal = strategy.on_candle(&ctx);
635
636 if signal.is_hold() {
638 continue;
639 }
640
641 if signal.strength.value() < self.config.min_signal_strength {
643 signals.push(SignalRecord {
644 timestamp: signal.timestamp,
645 price: signal.price,
646 direction: signal.direction,
647 strength: signal.strength.value(),
648 reason: signal.reason.clone(),
649 executed: false,
650 tags: signal.tags.clone(),
651 });
652 continue;
653 }
654
655 let executed = match &signal.order_type {
661 OrderType::Market => {
662 if let Some(fill_candle) = candles.get(i + 1) {
663 self.execute_signal(
664 &signal,
665 fill_candle,
666 &mut position,
667 &mut cash,
668 &mut trades,
669 )
670 } else {
671 false
672 }
673 }
674 _ if matches!(
675 signal.direction,
676 SignalDirection::Long | SignalDirection::Short
677 ) =>
678 {
679 if matches!(signal.direction, SignalDirection::Short)
682 && !self.config.allow_short
683 {
684 false
685 } else {
686 pending_orders.push(PendingOrder {
689 order_type: signal.order_type.clone(),
690 expires_in_bars: signal.expires_in_bars,
691 created_bar: i,
692 signal: signal.clone(),
693 });
694 false
695 }
696 }
697 _ => {
698 if let Some(fill_candle) = candles.get(i + 1) {
700 self.execute_signal(
701 &signal,
702 fill_candle,
703 &mut position,
704 &mut cash,
705 &mut trades,
706 )
707 } else {
708 false
709 }
710 }
711 };
712
713 if executed
714 && position.is_some()
715 && matches!(
716 signal.direction,
717 SignalDirection::Long | SignalDirection::Short
718 )
719 {
720 hwm = position.as_ref().map(|p| p.entry_price);
721 }
722
723 if executed && position.is_none() {
725 hwm = None;
726
727 let ctx2 = StrategyContext {
731 candles: &candles[..=i],
732 index: i,
733 position: None,
734 equity,
735 indicators: &indicators,
736 };
737 let follow = strategy.on_candle(&ctx2);
738 if !follow.is_hold() && follow.strength.value() >= self.config.min_signal_strength {
739 let follow_executed = if let Some(fill_candle) = candles.get(i + 1) {
740 self.execute_signal(
741 &follow,
742 fill_candle,
743 &mut position,
744 &mut cash,
745 &mut trades,
746 )
747 } else {
748 false
749 };
750 if follow_executed && position.is_some() {
751 hwm = position.as_ref().map(|p| p.entry_price);
752 }
753 signals.push(SignalRecord {
754 timestamp: follow.timestamp,
755 price: follow.price,
756 direction: follow.direction,
757 strength: follow.strength.value(),
758 reason: follow.reason,
759 executed: follow_executed,
760 tags: follow.tags,
761 });
762 }
763 }
764
765 signals.push(SignalRecord {
766 timestamp: signal.timestamp,
767 price: signal.price,
768 direction: signal.direction,
769 strength: signal.strength.value(),
770 reason: signal.reason,
771 executed,
772 tags: signal.tags,
773 });
774 }
775
776 if self.config.close_at_end
778 && let Some(pos) = position.take()
779 {
780 let last_candle = candles
781 .last()
782 .expect("candles non-empty: position open implies loop ran");
783 let exit_price_slipped = self
784 .config
785 .apply_exit_slippage(last_candle.close, pos.is_long());
786 let exit_price = self
787 .config
788 .apply_exit_spread(exit_price_slipped, pos.is_long());
789 let exit_commission = self.config.calculate_commission(pos.quantity, exit_price);
790 let exit_tax = self
792 .config
793 .calculate_transaction_tax(exit_price * pos.quantity, !pos.is_long());
794
795 let exit_signal = Signal::exit(last_candle.timestamp, last_candle.close)
796 .with_reason("End of backtest");
797
798 let trade = pos.close_with_tax(
799 last_candle.timestamp,
800 exit_price,
801 exit_commission,
802 exit_tax,
803 exit_signal,
804 );
805 if trade.is_long() {
806 cash += trade.exit_value() - exit_commission + trade.unreinvested_dividends;
807 } else {
808 cash -=
809 trade.exit_value() + exit_commission + exit_tax - trade.unreinvested_dividends;
810 }
811 trades.push(trade);
812
813 Self::sync_terminal_equity_point(&mut equity_curve, last_candle.timestamp, cash);
814 }
815
816 let final_equity = if let Some(ref pos) = position {
818 cash + pos.current_value(
819 candles
820 .last()
821 .expect("candles non-empty: position open implies loop ran")
822 .close,
823 ) + pos.unreinvested_dividends
824 } else {
825 cash
826 };
827
828 if let Some(last_candle) = candles.last() {
829 Self::sync_terminal_equity_point(
830 &mut equity_curve,
831 last_candle.timestamp,
832 final_equity,
833 );
834 }
835
836 let executed_signals = signals.iter().filter(|s| s.executed).count();
838 let metrics = PerformanceMetrics::calculate(
839 &trades,
840 &equity_curve,
841 self.config.initial_capital,
842 signals.len(),
843 executed_signals,
844 self.config.risk_free_rate,
845 self.config.bars_per_year,
846 );
847
848 let start_timestamp = candles.first().map(|c| c.timestamp).unwrap_or(0);
849 let end_timestamp = candles.last().map(|c| c.timestamp).unwrap_or(0);
850
851 let mut diagnostics = Vec::new();
853 if trades.is_empty() {
854 if signals.is_empty() {
855 diagnostics.push(
856 "No signals were generated. Check that the strategy's warmup \
857 period is shorter than the data length and that indicator \
858 conditions can be satisfied."
859 .into(),
860 );
861 } else {
862 let short_signals = signals
863 .iter()
864 .filter(|s| matches!(s.direction, SignalDirection::Short))
865 .count();
866 if short_signals > 0 && !self.config.allow_short {
867 diagnostics.push(format!(
868 "{short_signals} short signal(s) were generated but \
869 config.allow_short is false. Enable it with \
870 BacktestConfig::builder().allow_short(true)."
871 ));
872 }
873 diagnostics.push(format!(
874 "{} signal(s) generated but none executed. Check \
875 min_signal_strength ({}) and capital requirements.",
876 signals.len(),
877 self.config.min_signal_strength
878 ));
879 }
880 }
881
882 Ok(BacktestResult {
883 symbol: symbol.to_string(),
884 strategy_name: strategy.name().to_string(),
885 config: self.config.clone(),
886 start_timestamp,
887 end_timestamp,
888 initial_capital: self.config.initial_capital,
889 final_equity,
890 metrics,
891 trades,
892 equity_curve,
893 signals,
894 open_position: position,
895 benchmark: None, diagnostics,
897 })
898 }
899
900 pub fn run_with_benchmark<S: Strategy>(
910 &self,
911 symbol: &str,
912 candles: &[Candle],
913 strategy: S,
914 dividends: &[Dividend],
915 benchmark_symbol: &str,
916 benchmark_candles: &[Candle],
917 ) -> Result<BacktestResult> {
918 let mut result = self.simulate(symbol, candles, strategy, dividends)?;
919 result.benchmark = Some(compute_benchmark_metrics(
920 benchmark_symbol,
921 candles,
922 benchmark_candles,
923 &result.equity_curve,
924 self.config.risk_free_rate,
925 self.config.bars_per_year,
926 ));
927 Ok(result)
928 }
929
930 pub(crate) fn compute_indicators<S: Strategy>(
932 &self,
933 candles: &[Candle],
934 strategy: &S,
935 ) -> Result<HashMap<String, Vec<Option<f64>>>> {
936 compute_for_candles(candles, strategy.required_indicators())
937 }
938
939 fn compute_htf_indicators<S: Strategy>(
948 &self,
949 candles: &[Candle],
950 strategy: &S,
951 ) -> Result<HashMap<String, Vec<Option<f64>>>> {
952 use std::collections::HashSet;
953
954 use super::condition::HtfIndicatorSpec;
955 use super::resample::{base_to_htf_index, resample};
956 use crate::constants::Interval;
957
958 let specs = strategy.htf_requirements();
959 if specs.is_empty() {
960 return Ok(HashMap::new());
961 }
962
963 let mut result = HashMap::new();
964
965 let mut by_interval: HashMap<(Interval, i64), Vec<HtfIndicatorSpec>> = HashMap::new();
967 for spec in specs {
968 by_interval
969 .entry((spec.interval, spec.utc_offset_secs))
970 .or_default()
971 .push(spec);
972 }
973
974 for ((interval, utc_offset_secs), specs) in by_interval {
975 let htf_candles = resample(candles, interval, utc_offset_secs);
976 if htf_candles.is_empty() {
977 continue;
978 }
979
980 let mut required: Vec<(String, crate::indicators::Indicator)> = Vec::new();
983 let mut seen_base_keys: HashSet<&str> = HashSet::new();
984 for spec in &specs {
985 if seen_base_keys.insert(&spec.base_key) {
986 required.push((spec.base_key.clone(), spec.indicator));
987 }
988 }
989
990 let htf_values = compute_for_candles(&htf_candles, required)?;
991 let mapping = base_to_htf_index(candles, &htf_candles);
992
993 for spec in &specs {
994 if let Some(htf_vec) = htf_values.get(&spec.base_key) {
995 let stretched: Vec<Option<f64>> = mapping
996 .iter()
997 .map(|htf_idx| htf_idx.and_then(|i| htf_vec.get(i).copied().flatten()))
998 .collect();
999 result.insert(spec.htf_key.clone(), stretched);
1000 }
1001 }
1002 }
1003
1004 Ok(result)
1005 }
1006
1007 fn update_equity_and_curve(
1013 position: Option<&Position>,
1014 candle: &Candle,
1015 cash: f64,
1016 peak_equity: &mut f64,
1017 equity_curve: &mut Vec<EquityPoint>,
1018 ) -> f64 {
1019 let equity = match position {
1020 Some(pos) => cash + pos.current_value(candle.close) + pos.unreinvested_dividends,
1021 None => cash,
1022 };
1023 if equity > *peak_equity {
1024 *peak_equity = equity;
1025 }
1026 let drawdown_pct = if *peak_equity > 0.0 {
1027 (*peak_equity - equity) / *peak_equity
1028 } else {
1029 0.0
1030 };
1031 equity_curve.push(EquityPoint {
1032 timestamp: candle.timestamp,
1033 equity,
1034 drawdown_pct,
1035 });
1036 equity
1037 }
1038
1039 fn credit_dividends(
1043 &self,
1044 position: &mut Option<Position>,
1045 candle: &Candle,
1046 dividends: &[Dividend],
1047 div_idx: &mut usize,
1048 ) {
1049 while *div_idx < dividends.len() && dividends[*div_idx].timestamp <= candle.timestamp {
1050 if let Some(pos) = position.as_mut() {
1051 let per_share = dividends[*div_idx].amount;
1052 let income = if pos.is_long() {
1053 per_share * pos.quantity
1054 } else {
1055 -(per_share * pos.quantity)
1056 };
1057 pos.credit_dividend(income, candle.close, self.config.reinvest_dividends);
1058 }
1059 *div_idx += 1;
1060 }
1061 }
1062
1063 fn check_sl_tp(
1086 &self,
1087 position: &Position,
1088 candle: &Candle,
1089 hwm: Option<f64>,
1090 ) -> Option<Signal> {
1091 let sl_pct = position.bracket_stop_loss_pct.or(self.config.stop_loss_pct);
1093 let tp_pct = position
1094 .bracket_take_profit_pct
1095 .or(self.config.take_profit_pct);
1096 let trail_pct = position
1097 .bracket_trailing_stop_pct
1098 .or(self.config.trailing_stop_pct);
1099
1100 if let Some(sl_pct) = sl_pct {
1102 let stop_price = if position.is_long() {
1103 position.entry_price * (1.0 - sl_pct)
1104 } else {
1105 position.entry_price * (1.0 + sl_pct)
1106 };
1107 let triggered = if position.is_long() {
1108 candle.low <= stop_price
1109 } else {
1110 candle.high >= stop_price
1111 };
1112 if triggered {
1113 let fill_price = if position.is_long() {
1116 candle.open.min(stop_price)
1117 } else {
1118 candle.open.max(stop_price)
1119 };
1120 let return_pct = position.unrealized_return_pct(fill_price);
1121 return Some(
1122 Signal::exit(candle.timestamp, fill_price)
1123 .with_reason(format!("Stop-loss triggered ({:.1}%)", return_pct)),
1124 );
1125 }
1126 }
1127
1128 if let Some(tp_pct) = tp_pct {
1130 let tp_price = if position.is_long() {
1131 position.entry_price * (1.0 + tp_pct)
1132 } else {
1133 position.entry_price * (1.0 - tp_pct)
1134 };
1135 let triggered = if position.is_long() {
1136 candle.high >= tp_price
1137 } else {
1138 candle.low <= tp_price
1139 };
1140 if triggered {
1141 let fill_price = if position.is_long() {
1143 candle.open.max(tp_price)
1144 } else {
1145 candle.open.min(tp_price)
1146 };
1147 let return_pct = position.unrealized_return_pct(fill_price);
1148 return Some(
1149 Signal::exit(candle.timestamp, fill_price)
1150 .with_reason(format!("Take-profit triggered ({:.1}%)", return_pct)),
1151 );
1152 }
1153 }
1154
1155 if let Some(trail_pct) = trail_pct
1158 && let Some(extreme) = hwm
1159 && extreme > 0.0
1160 {
1161 let trail_stop_price = if position.is_long() {
1162 extreme * (1.0 - trail_pct)
1163 } else {
1164 extreme * (1.0 + trail_pct)
1165 };
1166 let triggered = if position.is_long() {
1167 candle.low <= trail_stop_price
1168 } else {
1169 candle.high >= trail_stop_price
1170 };
1171 if triggered {
1172 let fill_price = if position.is_long() {
1173 candle.open.min(trail_stop_price)
1174 } else {
1175 candle.open.max(trail_stop_price)
1176 };
1177 let adverse_move_pct = if position.is_long() {
1178 (extreme - fill_price) / extreme
1179 } else {
1180 (fill_price - extreme) / extreme
1181 };
1182 return Some(
1183 Signal::exit(candle.timestamp, fill_price).with_reason(format!(
1184 "Trailing stop triggered ({:.1}% adverse move)",
1185 adverse_move_pct * 100.0
1186 )),
1187 );
1188 }
1189 }
1190
1191 None
1192 }
1193
1194 fn execute_signal(
1196 &self,
1197 signal: &Signal,
1198 candle: &Candle,
1199 position: &mut Option<Position>,
1200 cash: &mut f64,
1201 trades: &mut Vec<Trade>,
1202 ) -> bool {
1203 match signal.direction {
1204 SignalDirection::Long => {
1205 if position.is_some() {
1206 return false; }
1208 self.open_position(position, cash, candle, signal, true)
1209 }
1210 SignalDirection::Short => {
1211 if position.is_some() {
1212 return false; }
1214 if !self.config.allow_short {
1215 return false; }
1217 self.open_position(position, cash, candle, signal, false)
1218 }
1219 SignalDirection::Exit => {
1220 if position.is_none() {
1221 return false; }
1223 self.close_position(position, cash, trades, candle, signal)
1224 }
1225 SignalDirection::ScaleIn => self.scale_into_position(position, cash, signal, candle),
1226 SignalDirection::ScaleOut => {
1227 self.scale_out_position(position, cash, trades, signal, candle)
1228 }
1229 SignalDirection::Hold => false,
1230 }
1231 }
1232
1233 fn scale_into_position(
1239 &self,
1240 position: &mut Option<Position>,
1241 cash: &mut f64,
1242 signal: &Signal,
1243 candle: &Candle,
1244 ) -> bool {
1245 let fraction = signal.scale_fraction.unwrap_or(0.0).clamp(0.0, 1.0);
1246 if fraction <= 0.0 {
1247 return false;
1248 }
1249
1250 let pos = match position.as_mut() {
1251 Some(p) => p,
1252 None => return false,
1253 };
1254
1255 let is_long = pos.is_long();
1256 let fill_price_slipped = self.config.apply_entry_slippage(candle.open, is_long);
1257 let fill_price = self.config.apply_entry_spread(fill_price_slipped, is_long);
1258
1259 let equity = *cash + pos.current_value(candle.open) + pos.unreinvested_dividends;
1261 let additional_value = equity * fraction;
1262 let additional_qty = if fill_price > 0.0 {
1263 additional_value / fill_price
1264 } else {
1265 return false;
1266 };
1267
1268 if additional_qty <= 0.0 {
1269 return false;
1270 }
1271
1272 let commission = self.config.calculate_commission(additional_qty, fill_price);
1273 let entry_tax = self
1274 .config
1275 .calculate_transaction_tax(additional_value, is_long);
1276 let total_cost = if is_long {
1277 additional_value + commission + entry_tax
1278 } else {
1279 commission
1280 };
1281
1282 if total_cost > *cash {
1283 return false; }
1285
1286 if is_long {
1287 *cash -= additional_value + commission + entry_tax;
1288 } else {
1289 *cash += additional_value - commission;
1290 }
1291
1292 pos.scale_in(fill_price, additional_qty, commission, entry_tax);
1293 true
1294 }
1295
1296 fn scale_out_position(
1303 &self,
1304 position: &mut Option<Position>,
1305 cash: &mut f64,
1306 trades: &mut Vec<Trade>,
1307 signal: &Signal,
1308 candle: &Candle,
1309 ) -> bool {
1310 let fraction = signal.scale_fraction.unwrap_or(0.0).clamp(0.0, 1.0);
1311 if fraction <= 0.0 {
1312 return false;
1313 }
1314
1315 if fraction >= 1.0 {
1318 return self.close_position(position, cash, trades, candle, signal);
1319 }
1320
1321 let pos = match position.as_mut() {
1322 Some(p) => p,
1323 None => return false,
1324 };
1325
1326 let is_long = pos.is_long();
1327 let exit_price_slipped = self.config.apply_exit_slippage(candle.open, is_long);
1328 let exit_price = self.config.apply_exit_spread(exit_price_slipped, is_long);
1329 let qty_closed = pos.quantity * fraction;
1330 let commission = self.config.calculate_commission(qty_closed, exit_price);
1331 let exit_tax = self
1332 .config
1333 .calculate_transaction_tax(exit_price * qty_closed, !is_long);
1334
1335 let trade = pos.partial_close(
1336 fraction,
1337 candle.timestamp,
1338 exit_price,
1339 commission,
1340 exit_tax,
1341 signal.clone(),
1342 );
1343
1344 if trade.is_long() {
1349 *cash += trade.exit_value() - commission + trade.unreinvested_dividends;
1350 } else {
1351 *cash -= trade.exit_value() + commission + exit_tax - trade.unreinvested_dividends;
1352 }
1353 trades.push(trade);
1354 true
1355 }
1356
1357 fn open_position(
1359 &self,
1360 position: &mut Option<Position>,
1361 cash: &mut f64,
1362 candle: &Candle,
1363 signal: &Signal,
1364 is_long: bool,
1365 ) -> bool {
1366 self.open_position_at_price(position, cash, candle, signal, is_long, candle.open)
1367 }
1368
1369 fn open_position_at_price(
1374 &self,
1375 position: &mut Option<Position>,
1376 cash: &mut f64,
1377 candle: &Candle,
1378 signal: &Signal,
1379 is_long: bool,
1380 fill_price_raw: f64,
1381 ) -> bool {
1382 let entry_price_slipped = self.config.apply_entry_slippage(fill_price_raw, is_long);
1383 let entry_price = self.config.apply_entry_spread(entry_price_slipped, is_long);
1384 let quantity = self.config.calculate_position_size(*cash, entry_price);
1385
1386 if quantity <= 0.0 {
1387 return false; }
1389
1390 let entry_value = entry_price * quantity;
1391 let commission = self.config.calculate_commission(quantity, entry_price);
1392 let entry_tax = self.config.calculate_transaction_tax(entry_value, is_long);
1394
1395 if is_long {
1396 if entry_value + commission + entry_tax > *cash {
1397 return false; }
1399 } else if commission > *cash {
1400 return false; }
1402
1403 let side = if is_long {
1404 PositionSide::Long
1405 } else {
1406 PositionSide::Short
1407 };
1408
1409 if is_long {
1410 *cash -= entry_value + commission + entry_tax;
1411 } else {
1412 *cash += entry_value - commission;
1413 }
1414 *position = Some(Position::new_with_tax(
1415 side,
1416 candle.timestamp,
1417 entry_price,
1418 quantity,
1419 commission,
1420 entry_tax,
1421 signal.clone(),
1422 ));
1423
1424 true
1425 }
1426
1427 fn close_position(
1429 &self,
1430 position: &mut Option<Position>,
1431 cash: &mut f64,
1432 trades: &mut Vec<Trade>,
1433 candle: &Candle,
1434 signal: &Signal,
1435 ) -> bool {
1436 self.close_position_at(position, cash, trades, candle, candle.open, signal)
1437 }
1438
1439 fn close_position_at(
1444 &self,
1445 position: &mut Option<Position>,
1446 cash: &mut f64,
1447 trades: &mut Vec<Trade>,
1448 candle: &Candle,
1449 fill_price: f64,
1450 signal: &Signal,
1451 ) -> bool {
1452 let pos = match position.take() {
1453 Some(p) => p,
1454 None => return false,
1455 };
1456
1457 let exit_price_slipped = self.config.apply_exit_slippage(fill_price, pos.is_long());
1458 let exit_price = self
1459 .config
1460 .apply_exit_spread(exit_price_slipped, pos.is_long());
1461 let exit_commission = self.config.calculate_commission(pos.quantity, exit_price);
1462 let exit_tax = self
1464 .config
1465 .calculate_transaction_tax(exit_price * pos.quantity, !pos.is_long());
1466
1467 let trade = pos.close_with_tax(
1468 candle.timestamp,
1469 exit_price,
1470 exit_commission,
1471 exit_tax,
1472 signal.clone(),
1473 );
1474
1475 if trade.is_long() {
1476 *cash += trade.exit_value() - exit_commission + trade.unreinvested_dividends;
1477 } else {
1478 *cash -= trade.exit_value() + exit_commission + exit_tax - trade.unreinvested_dividends;
1479 }
1480 trades.push(trade);
1481
1482 true
1483 }
1484}
1485
1486pub(crate) fn update_trailing_hwm(
1497 position: Option<&Position>,
1498 hwm: &mut Option<f64>,
1499 candle: &Candle,
1500) {
1501 if let Some(pos) = position {
1502 *hwm = Some(match *hwm {
1503 None => {
1504 if pos.is_long() {
1505 candle.high
1506 } else {
1507 candle.low
1508 }
1509 }
1510 Some(prev) => {
1511 if pos.is_long() {
1512 prev.max(candle.high)
1513 } else {
1514 prev.min(candle.low) }
1516 }
1517 });
1518 } else {
1519 *hwm = None;
1520 }
1521}
1522
1523impl BacktestEngine {
1524 fn sync_terminal_equity_point(
1525 equity_curve: &mut Vec<EquityPoint>,
1526 timestamp: i64,
1527 equity: f64,
1528 ) {
1529 if let Some(last) = equity_curve.last_mut()
1530 && last.timestamp == timestamp
1531 {
1532 last.equity = equity;
1533 } else {
1534 equity_curve.push(EquityPoint {
1535 timestamp,
1536 equity,
1537 drawdown_pct: 0.0,
1538 });
1539 }
1540
1541 let peak = equity_curve
1542 .iter()
1543 .map(|point| point.equity)
1544 .fold(f64::NEG_INFINITY, f64::max);
1545 let drawdown = if peak.is_finite() && peak > 0.0 {
1546 (peak - equity) / peak
1547 } else {
1548 0.0
1549 };
1550
1551 if let Some(last) = equity_curve.last_mut() {
1552 last.drawdown_pct = drawdown;
1553 }
1554 }
1555}
1556
1557fn compute_benchmark_metrics(
1563 benchmark_symbol: &str,
1564 symbol_candles: &[Candle],
1565 benchmark_candles: &[Candle],
1566 equity_curve: &[EquityPoint],
1567 risk_free_rate: f64,
1568 bars_per_year: f64,
1569) -> BenchmarkMetrics {
1570 let benchmark_return_pct = buy_and_hold_return(benchmark_candles);
1572 let buy_and_hold_return_pct = buy_and_hold_return(symbol_candles);
1573
1574 if equity_curve.len() < 2 || benchmark_candles.len() < 2 {
1575 return BenchmarkMetrics {
1576 symbol: benchmark_symbol.to_string(),
1577 benchmark_return_pct,
1578 buy_and_hold_return_pct,
1579 alpha: 0.0,
1580 beta: 0.0,
1581 information_ratio: 0.0,
1582 };
1583 }
1584
1585 let strategy_returns_by_ts: Vec<(i64, f64)> = equity_curve
1586 .windows(2)
1587 .map(|w| {
1588 let prev = w[0].equity;
1589 let ret = if prev > 0.0 {
1590 (w[1].equity - prev) / prev
1591 } else {
1592 0.0
1593 };
1594 (w[1].timestamp, ret)
1595 })
1596 .collect();
1597
1598 let bench_returns_by_ts: HashMap<i64, f64> = benchmark_candles
1599 .windows(2)
1600 .map(|w| {
1601 let prev = w[0].close;
1602 let ret = if prev > 0.0 {
1603 (w[1].close - prev) / prev
1604 } else {
1605 0.0
1606 };
1607 (w[1].timestamp, ret)
1608 })
1609 .collect();
1610
1611 let mut aligned_strategy = Vec::new();
1612 let mut aligned_benchmark = Vec::new();
1613 for (ts, s_ret) in strategy_returns_by_ts {
1614 if let Some(b_ret) = bench_returns_by_ts.get(&ts) {
1615 aligned_strategy.push(s_ret);
1616 aligned_benchmark.push(*b_ret);
1617 }
1618 }
1619
1620 let beta = compute_beta(&aligned_strategy, &aligned_benchmark);
1621
1622 let strategy_ann = annualized_return_from_periodic(&aligned_strategy, bars_per_year);
1624 let bench_ann = annualized_return_from_periodic(&aligned_benchmark, bars_per_year);
1625 let rf_ann = risk_free_rate * 100.0;
1629 let alpha = strategy_ann - rf_ann - beta * (bench_ann - rf_ann);
1630
1631 let excess: Vec<f64> = aligned_strategy
1634 .iter()
1635 .zip(aligned_benchmark.iter())
1636 .map(|(si, bi)| si - bi)
1637 .collect();
1638 let ir = if excess.len() >= 2 {
1639 let n = excess.len() as f64;
1640 let mean = excess.iter().sum::<f64>() / n;
1641 let variance = excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1.0);
1643 let std_dev = variance.sqrt();
1644 if std_dev > 0.0 {
1645 (mean / std_dev) * bars_per_year.sqrt()
1646 } else {
1647 0.0
1648 }
1649 } else {
1650 0.0
1651 };
1652
1653 BenchmarkMetrics {
1654 symbol: benchmark_symbol.to_string(),
1655 benchmark_return_pct,
1656 buy_and_hold_return_pct,
1657 alpha,
1658 beta,
1659 information_ratio: ir,
1660 }
1661}
1662
1663fn buy_and_hold_return(candles: &[Candle]) -> f64 {
1665 match (candles.first(), candles.last()) {
1666 (Some(first), Some(last)) if first.close > 0.0 => {
1667 ((last.close / first.close) - 1.0) * 100.0
1668 }
1669 _ => 0.0,
1670 }
1671}
1672
1673fn annualized_return_from_periodic(periodic_returns: &[f64], bars_per_year: f64) -> f64 {
1675 let years = periodic_returns.len() as f64 / bars_per_year;
1676 if years > 0.0 {
1677 let growth = periodic_returns
1678 .iter()
1679 .fold(1.0_f64, |acc, r| acc * (1.0 + *r));
1680 if growth <= 0.0 {
1681 -100.0
1682 } else {
1683 (growth.powf(1.0 / years) - 1.0) * 100.0
1684 }
1685 } else {
1686 0.0
1687 }
1688}
1689
1690fn compute_beta(strategy_returns: &[f64], benchmark_returns: &[f64]) -> f64 {
1697 let n = strategy_returns.len();
1698 if n < 2 {
1699 return 0.0;
1700 }
1701
1702 let s_mean = strategy_returns.iter().sum::<f64>() / n as f64;
1703 let b_mean = benchmark_returns.iter().sum::<f64>() / n as f64;
1704
1705 let cov: f64 = strategy_returns
1707 .iter()
1708 .zip(benchmark_returns.iter())
1709 .map(|(s, b)| (s - s_mean) * (b - b_mean))
1710 .sum::<f64>()
1711 / (n - 1) as f64;
1712
1713 let b_var: f64 = benchmark_returns
1714 .iter()
1715 .map(|b| (b - b_mean).powi(2))
1716 .sum::<f64>()
1717 / (n - 1) as f64;
1718
1719 if b_var > 0.0 { cov / b_var } else { 0.0 }
1720}
1721
1722#[cfg(test)]
1723mod tests {
1724 use super::*;
1725 use crate::backtesting::strategy::SmaCrossover;
1726 use crate::backtesting::strategy::Strategy;
1727 use crate::indicators::Indicator;
1728
1729 #[derive(Clone)]
1730 struct EnterLongHold;
1731
1732 impl Strategy for EnterLongHold {
1733 fn name(&self) -> &str {
1734 "Enter Long Hold"
1735 }
1736
1737 fn required_indicators(&self) -> Vec<(String, Indicator)> {
1738 vec![]
1739 }
1740
1741 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
1742 if ctx.index == 0 && !ctx.has_position() {
1743 Signal::long(ctx.timestamp(), ctx.close())
1744 } else {
1745 Signal::hold()
1746 }
1747 }
1748 }
1749
1750 #[derive(Clone)]
1751 struct EnterShortHold;
1752
1753 impl Strategy for EnterShortHold {
1754 fn name(&self) -> &str {
1755 "Enter Short Hold"
1756 }
1757
1758 fn required_indicators(&self) -> Vec<(String, Indicator)> {
1759 vec![]
1760 }
1761
1762 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
1763 if ctx.index == 0 && !ctx.has_position() {
1764 Signal::short(ctx.timestamp(), ctx.close())
1765 } else {
1766 Signal::hold()
1767 }
1768 }
1769 }
1770
1771 fn make_candles(prices: &[f64]) -> Vec<Candle> {
1772 prices
1773 .iter()
1774 .enumerate()
1775 .map(|(i, &p)| Candle {
1776 timestamp: i as i64,
1777 open: p,
1778 high: p * 1.01,
1779 low: p * 0.99,
1780 close: p,
1781 volume: 1000,
1782 adj_close: Some(p),
1783 })
1784 .collect()
1785 }
1786
1787 fn make_candles_with_timestamps(prices: &[f64], timestamps: &[i64]) -> Vec<Candle> {
1788 prices
1789 .iter()
1790 .zip(timestamps.iter())
1791 .map(|(&p, &ts)| Candle {
1792 timestamp: ts,
1793 open: p,
1794 high: p * 1.01,
1795 low: p * 0.99,
1796 close: p,
1797 volume: 1000,
1798 adj_close: Some(p),
1799 })
1800 .collect()
1801 }
1802
1803 #[test]
1804 fn test_engine_basic() {
1805 let mut prices = vec![100.0; 30];
1807 for (i, price) in prices.iter_mut().enumerate().take(25).skip(15) {
1809 *price = 100.0 + (i - 15) as f64 * 2.0;
1810 }
1811 for (i, price) in prices.iter_mut().enumerate().take(30).skip(25) {
1813 *price = 118.0 - (i - 25) as f64 * 3.0;
1814 }
1815
1816 let candles = make_candles(&prices);
1817 let config = BacktestConfig::builder()
1818 .initial_capital(10_000.0)
1819 .commission_pct(0.0)
1820 .slippage_pct(0.0)
1821 .build()
1822 .unwrap();
1823
1824 let engine = BacktestEngine::new(config);
1825 let strategy = SmaCrossover::new(5, 10);
1826 let result = engine.run("TEST", &candles, strategy).unwrap();
1827
1828 assert_eq!(result.symbol, "TEST");
1829 assert_eq!(result.strategy_name, "SMA Crossover");
1830 assert!(!result.equity_curve.is_empty());
1831 }
1832
1833 #[test]
1834 fn test_stop_loss() {
1835 let mut prices = vec![100.0; 20];
1837 for (i, price) in prices.iter_mut().enumerate().take(15).skip(10) {
1839 *price = 100.0 + (i - 10) as f64 * 2.0;
1840 }
1841 for (i, price) in prices.iter_mut().enumerate().take(20).skip(15) {
1843 *price = 108.0 - (i - 15) as f64 * 10.0;
1844 }
1845
1846 let candles = make_candles(&prices);
1847 let config = BacktestConfig::builder()
1848 .initial_capital(10_000.0)
1849 .stop_loss_pct(0.05) .commission_pct(0.0)
1851 .slippage_pct(0.0)
1852 .build()
1853 .unwrap();
1854
1855 let engine = BacktestEngine::new(config);
1856 let strategy = SmaCrossover::new(3, 6);
1857 let result = engine.run("TEST", &candles, strategy).unwrap();
1858
1859 let _sl_signals: Vec<_> = result
1861 .signals
1862 .iter()
1863 .filter(|s| {
1864 s.reason
1865 .as_ref()
1866 .map(|r| r.contains("Stop-loss"))
1867 .unwrap_or(false)
1868 })
1869 .collect();
1870
1871 assert!(!result.equity_curve.is_empty());
1874 }
1875
1876 #[test]
1877 fn test_trailing_stop() {
1878 let mut prices: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
1880 prices.extend_from_slice(&[105.0, 103.0, 101.0]);
1882
1883 let candles = make_candles(&prices);
1884 let config = BacktestConfig::builder()
1885 .initial_capital(10_000.0)
1886 .trailing_stop_pct(0.10)
1887 .commission_pct(0.0)
1888 .slippage_pct(0.0)
1889 .build()
1890 .unwrap();
1891
1892 let engine = BacktestEngine::new(config);
1893 let strategy = SmaCrossover::new(3, 6);
1894 let result = engine.run("TEST", &candles, strategy).unwrap();
1895
1896 let trail_exits: Vec<_> = result
1897 .signals
1898 .iter()
1899 .filter(|s| {
1900 s.reason
1901 .as_ref()
1902 .map(|r| r.contains("Trailing stop"))
1903 .unwrap_or(false)
1904 })
1905 .collect();
1906
1907 let _ = trail_exits;
1909 assert!(!result.equity_curve.is_empty());
1910 }
1911
1912 #[test]
1913 fn test_insufficient_data() {
1914 let candles = make_candles(&[100.0, 101.0, 102.0]); let config = BacktestConfig::default();
1916 let engine = BacktestEngine::new(config);
1917 let strategy = SmaCrossover::new(10, 20); let result = engine.run("TEST", &candles, strategy);
1920 assert!(result.is_err());
1921 }
1922
1923 #[test]
1924 fn test_capm_alpha_with_risk_free_rate() {
1925 let prices: Vec<f64> = (0..60).map(|i| 100.0 + i as f64).collect();
1928 let candles = make_candles(&prices);
1929
1930 let config_no_rf = BacktestConfig::builder()
1932 .commission_pct(0.0)
1933 .slippage_pct(0.0)
1934 .risk_free_rate(0.0)
1935 .build()
1936 .unwrap();
1937 let config_with_rf = BacktestConfig::builder()
1938 .commission_pct(0.0)
1939 .slippage_pct(0.0)
1940 .risk_free_rate(0.05)
1941 .build()
1942 .unwrap();
1943
1944 let engine_no_rf = BacktestEngine::new(config_no_rf);
1945 let engine_with_rf = BacktestEngine::new(config_with_rf);
1946
1947 let result_no_rf = engine_no_rf
1949 .run_with_benchmark(
1950 "TEST",
1951 &candles,
1952 SmaCrossover::new(3, 10),
1953 &[],
1954 "BENCH",
1955 &candles,
1956 )
1957 .unwrap();
1958 let result_with_rf = engine_with_rf
1959 .run_with_benchmark(
1960 "TEST",
1961 &candles,
1962 SmaCrossover::new(3, 10),
1963 &[],
1964 "BENCH",
1965 &candles,
1966 )
1967 .unwrap();
1968
1969 let bm_no_rf = result_no_rf.benchmark.unwrap();
1970 let bm_with_rf = result_with_rf.benchmark.unwrap();
1971
1972 assert!(bm_no_rf.alpha.is_finite(), "Alpha should be finite");
1976 assert!(
1977 bm_with_rf.alpha.is_finite(),
1978 "Alpha should be finite with rf"
1979 );
1980
1981 assert!(
1985 bm_no_rf.alpha.abs() < 50.0,
1986 "Alpha should be small for identical strategy/benchmark"
1987 );
1988 assert!(
1989 bm_with_rf.alpha.abs() < 50.0,
1990 "Alpha should be small for identical strategy/benchmark with rf"
1991 );
1992 }
1993
1994 #[test]
1995 fn test_run_with_benchmark_credits_dividends() {
1996 use crate::models::chart::Dividend;
1997
1998 let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
2000 let candles = make_candles(&prices);
2001
2002 let mid_ts = candles[15].timestamp;
2004 let dividends = vec![Dividend {
2005 timestamp: mid_ts,
2006 amount: 1.0,
2007 }];
2008
2009 let config = BacktestConfig::builder()
2010 .initial_capital(10_000.0)
2011 .commission_pct(0.0)
2012 .slippage_pct(0.0)
2013 .build()
2014 .unwrap();
2015
2016 let engine = BacktestEngine::new(config);
2017 let result = engine
2018 .run_with_benchmark(
2019 "TEST",
2020 &candles,
2021 SmaCrossover::new(3, 6),
2022 ÷nds,
2023 "BENCH",
2024 &candles,
2025 )
2026 .unwrap();
2027
2028 assert!(result.benchmark.is_some());
2032 let total_div: f64 = result.trades.iter().map(|t| t.dividend_income).sum();
2033 assert!(total_div >= 0.0);
2035 }
2036
2037 #[test]
2041 fn test_commission_accounting_invariant() {
2042 let prices: Vec<f64> = (0..40)
2044 .map(|i| {
2045 if i < 30 {
2046 100.0 + i as f64
2047 } else {
2048 129.0 - (i - 30) as f64 * 5.0
2049 }
2050 })
2051 .collect();
2052 let candles = make_candles(&prices);
2053
2054 let config = BacktestConfig::builder()
2056 .initial_capital(10_000.0)
2057 .commission(5.0) .commission_pct(0.001) .slippage_pct(0.0)
2060 .close_at_end(true)
2061 .build()
2062 .unwrap();
2063
2064 let engine = BacktestEngine::new(config.clone());
2065 let result = engine
2066 .run("TEST", &candles, SmaCrossover::new(3, 6))
2067 .unwrap();
2068
2069 let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2071 let expected = config.initial_capital + sum_pnl;
2072 let actual = result.final_equity;
2073 assert!(
2074 (actual - expected).abs() < 1e-6,
2075 "Commission accounting: final_equity {actual:.6} != initial_capital + sum(pnl) {expected:.6}",
2076 );
2077 }
2078
2079 #[test]
2080 fn test_unsorted_dividends_returns_error() {
2081 use crate::models::chart::Dividend;
2082
2083 let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
2084 let candles = make_candles(&prices);
2085
2086 let dividends = vec![
2088 Dividend {
2089 timestamp: 20,
2090 amount: 1.0,
2091 },
2092 Dividend {
2093 timestamp: 10,
2094 amount: 1.0,
2095 },
2096 ];
2097
2098 let engine = BacktestEngine::new(BacktestConfig::default());
2099 let result =
2100 engine.run_with_dividends("TEST", &candles, SmaCrossover::new(3, 6), ÷nds);
2101 assert!(result.is_err());
2102 let msg = result.unwrap_err().to_string();
2103 assert!(
2104 msg.contains("sorted"),
2105 "error should mention sorting: {msg}"
2106 );
2107 }
2108
2109 #[test]
2110 fn test_short_dividend_is_liability() {
2111 use crate::models::chart::Dividend;
2112
2113 let candles = make_candles(&[100.0, 100.0, 100.0]);
2114 let dividends = vec![Dividend {
2115 timestamp: candles[1].timestamp,
2116 amount: 1.0,
2117 }];
2118
2119 let config = BacktestConfig::builder()
2120 .initial_capital(10_000.0)
2121 .allow_short(true)
2122 .commission_pct(0.0)
2123 .slippage_pct(0.0)
2124 .build()
2125 .unwrap();
2126
2127 let engine = BacktestEngine::new(config);
2128 let result = engine
2129 .run_with_dividends("TEST", &candles, EnterShortHold, ÷nds)
2130 .unwrap();
2131
2132 assert_eq!(result.trades.len(), 1);
2133 assert!(result.trades[0].dividend_income < 0.0);
2134 assert!(result.final_equity < 10_000.0);
2135 }
2136
2137 #[test]
2138 fn test_open_position_final_equity_includes_accrued_dividends() {
2139 use crate::models::chart::Dividend;
2140
2141 let candles = make_candles(&[100.0, 100.0, 100.0]);
2142 let dividends = vec![Dividend {
2143 timestamp: candles[1].timestamp,
2144 amount: 1.0,
2145 }];
2146
2147 let config = BacktestConfig::builder()
2148 .initial_capital(10_000.0)
2149 .close_at_end(false)
2150 .commission_pct(0.0)
2151 .slippage_pct(0.0)
2152 .build()
2153 .unwrap();
2154
2155 let engine = BacktestEngine::new(config);
2156 let result = engine
2157 .run_with_dividends("TEST", &candles, EnterLongHold, ÷nds)
2158 .unwrap();
2159
2160 assert!(result.open_position.is_some());
2161 assert!((result.final_equity - 10_100.0).abs() < 1e-6);
2162 let last_equity = result.equity_curve.last().map(|p| p.equity).unwrap_or(0.0);
2163 assert!((last_equity - 10_100.0).abs() < 1e-6);
2164 }
2165
2166 #[test]
2167 fn test_benchmark_beta_and_ir_require_timestamp_overlap() {
2168 let symbol_candles = make_candles_with_timestamps(&[100.0, 110.0, 120.0], &[100, 200, 300]);
2169 let benchmark_candles =
2170 make_candles_with_timestamps(&[50.0, 55.0, 60.0, 65.0], &[1000, 1100, 1200, 1300]);
2171
2172 let config = BacktestConfig::builder()
2173 .initial_capital(10_000.0)
2174 .commission_pct(0.0)
2175 .slippage_pct(0.0)
2176 .build()
2177 .unwrap();
2178
2179 let engine = BacktestEngine::new(config);
2180 let result = engine
2181 .run_with_benchmark(
2182 "TEST",
2183 &symbol_candles,
2184 EnterLongHold,
2185 &[],
2186 "BENCH",
2187 &benchmark_candles,
2188 )
2189 .unwrap();
2190
2191 let benchmark = result.benchmark.unwrap();
2192 assert!((benchmark.beta - 0.0).abs() < 1e-12);
2193 assert!((benchmark.information_ratio - 0.0).abs() < 1e-12);
2194 }
2195
2196 fn make_candle_ohlc(ts: i64, open: f64, high: f64, low: f64, close: f64) -> Candle {
2198 Candle {
2199 timestamp: ts,
2200 open,
2201 high,
2202 low,
2203 close,
2204 volume: 1000,
2205 adj_close: Some(close),
2206 }
2207 }
2208
2209 struct EnterLongBar0;
2213 impl Strategy for EnterLongBar0 {
2214 fn name(&self) -> &str {
2215 "Enter Long Bar 0"
2216 }
2217 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2218 vec![]
2219 }
2220 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2221 if ctx.index == 0 && !ctx.has_position() {
2222 Signal::long(ctx.timestamp(), ctx.close())
2223 } else {
2224 Signal::hold()
2225 }
2226 }
2227 }
2228
2229 #[test]
2230 fn test_intrabar_stop_loss_fills_at_stop_price_not_next_open() {
2231 let candles = vec![
2238 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), ];
2243
2244 let config = BacktestConfig::builder()
2245 .initial_capital(10_000.0)
2246 .stop_loss_pct(0.05) .commission_pct(0.0)
2248 .slippage_pct(0.0)
2249 .build()
2250 .unwrap();
2251
2252 let engine = BacktestEngine::new(config);
2253 let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2254
2255 let sl_trade = result.trades.iter().find(|t| {
2256 t.exit_signal
2257 .reason
2258 .as_ref()
2259 .map(|r| r.contains("Stop-loss"))
2260 .unwrap_or(false)
2261 });
2262 assert!(sl_trade.is_some(), "expected a stop-loss trade");
2263 let trade = sl_trade.unwrap();
2264
2265 assert!(
2267 (trade.exit_price - 95.0).abs() < 1e-9,
2268 "expected exit at stop price 95.0, got {:.6}",
2269 trade.exit_price
2270 );
2271 assert_eq!(
2273 trade.exit_timestamp, 2,
2274 "exit should be on bar 2 (intrabar)"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_intrabar_stop_loss_gap_down_fills_at_open() {
2280 let candles = vec![
2283 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), ];
2287
2288 let config = BacktestConfig::builder()
2289 .initial_capital(10_000.0)
2290 .stop_loss_pct(0.05) .commission_pct(0.0)
2292 .slippage_pct(0.0)
2293 .build()
2294 .unwrap();
2295
2296 let engine = BacktestEngine::new(config);
2297 let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2298
2299 let sl_trade = result
2300 .trades
2301 .iter()
2302 .find(|t| {
2303 t.exit_signal
2304 .reason
2305 .as_ref()
2306 .map(|r| r.contains("Stop-loss"))
2307 .unwrap_or(false)
2308 })
2309 .expect("expected a stop-loss trade");
2310
2311 assert!(
2313 (sl_trade.exit_price - 92.0).abs() < 1e-9,
2314 "expected gap-down fill at 92.0, got {:.6}",
2315 sl_trade.exit_price
2316 );
2317 }
2318
2319 #[test]
2320 fn test_intrabar_take_profit_fills_at_tp_price() {
2321 let candles = vec![
2324 make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0),
2325 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), ];
2329
2330 let config = BacktestConfig::builder()
2331 .initial_capital(10_000.0)
2332 .take_profit_pct(0.10) .commission_pct(0.0)
2334 .slippage_pct(0.0)
2335 .build()
2336 .unwrap();
2337
2338 let engine = BacktestEngine::new(config);
2339 let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2340
2341 let tp_trade = result
2342 .trades
2343 .iter()
2344 .find(|t| {
2345 t.exit_signal
2346 .reason
2347 .as_ref()
2348 .map(|r| r.contains("Take-profit"))
2349 .unwrap_or(false)
2350 })
2351 .expect("expected a take-profit trade");
2352
2353 assert!(
2354 (tp_trade.exit_price - 110.0).abs() < 1e-9,
2355 "expected TP fill at 110.0, got {:.6}",
2356 tp_trade.exit_price
2357 );
2358 assert_eq!(
2359 tp_trade.exit_timestamp, 2,
2360 "exit should be on bar 2 (intrabar)"
2361 );
2362 }
2363
2364 #[derive(Clone)]
2368 struct EnterScaleInExit;
2369
2370 impl Strategy for EnterScaleInExit {
2371 fn name(&self) -> &str {
2372 "EnterScaleInExit"
2373 }
2374
2375 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2376 vec![]
2377 }
2378
2379 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2380 match ctx.index {
2381 0 => Signal::long(ctx.timestamp(), ctx.close()),
2382 1 if ctx.has_position() => Signal::scale_in(0.5, ctx.timestamp(), ctx.close()),
2383 2 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2384 _ => Signal::hold(),
2385 }
2386 }
2387 }
2388
2389 #[derive(Clone)]
2391 struct EnterScaleOutExit;
2392
2393 impl Strategy for EnterScaleOutExit {
2394 fn name(&self) -> &str {
2395 "EnterScaleOutExit"
2396 }
2397
2398 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2399 vec![]
2400 }
2401
2402 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2403 match ctx.index {
2404 0 => Signal::long(ctx.timestamp(), ctx.close()),
2405 1 if ctx.has_position() => Signal::scale_out(0.5, ctx.timestamp(), ctx.close()),
2406 2 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2407 _ => Signal::hold(),
2408 }
2409 }
2410 }
2411
2412 #[test]
2413 fn test_scale_in_adds_to_position() {
2414 let prices = [100.0, 100.0, 110.0, 120.0, 120.0];
2416 let candles = make_candles(&prices);
2417
2418 let config = BacktestConfig::builder()
2419 .initial_capital(10_000.0)
2420 .commission_pct(0.0)
2421 .slippage_pct(0.0)
2422 .close_at_end(true)
2423 .build()
2424 .unwrap();
2425
2426 let engine = BacktestEngine::new(config);
2427 let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2428
2429 assert_eq!(result.trades.len(), 1);
2431 let trade = &result.trades[0];
2432 assert!(!trade.is_partial);
2433 assert!(trade.quantity > 0.0);
2435 assert!(!result.equity_curve.is_empty());
2437 let scale_signals: Vec<_> = result
2439 .signals
2440 .iter()
2441 .filter(|s| matches!(s.direction, SignalDirection::ScaleIn))
2442 .collect();
2443 assert!(!scale_signals.is_empty());
2444 }
2445
2446 #[test]
2447 fn test_scale_out_produces_partial_trade() {
2448 let prices = [100.0, 100.0, 110.0, 120.0, 120.0];
2449 let candles = make_candles(&prices);
2450
2451 let config = BacktestConfig::builder()
2452 .initial_capital(10_000.0)
2453 .commission_pct(0.0)
2454 .slippage_pct(0.0)
2455 .close_at_end(true)
2456 .build()
2457 .unwrap();
2458
2459 let engine = BacktestEngine::new(config);
2460 let result = engine.run("TEST", &candles, EnterScaleOutExit).unwrap();
2461
2462 assert!(result.trades.len() >= 2);
2464 let partial = result
2465 .trades
2466 .iter()
2467 .find(|t| t.is_partial)
2468 .expect("expected at least one partial trade");
2469 assert_eq!(partial.scale_sequence, 0);
2470
2471 let final_trade = result.trades.iter().find(|t| !t.is_partial);
2472 assert!(final_trade.is_some());
2473 }
2474
2475 #[test]
2476 fn test_scale_out_full_fraction_is_equivalent_to_exit() {
2477 #[derive(Clone)]
2479 struct EnterScaleOutFull;
2480 impl Strategy for EnterScaleOutFull {
2481 fn name(&self) -> &str {
2482 "EnterScaleOutFull"
2483 }
2484 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2485 vec![]
2486 }
2487 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2488 match ctx.index {
2489 0 => Signal::long(ctx.timestamp(), ctx.close()),
2490 1 if ctx.has_position() => Signal::scale_out(1.0, ctx.timestamp(), ctx.close()),
2491 _ => Signal::hold(),
2492 }
2493 }
2494 }
2495
2496 let prices = [100.0, 100.0, 120.0, 120.0];
2497 let candles = make_candles(&prices);
2498
2499 let config = BacktestConfig::builder()
2500 .initial_capital(10_000.0)
2501 .commission_pct(0.0)
2502 .slippage_pct(0.0)
2503 .close_at_end(false)
2504 .build()
2505 .unwrap();
2506
2507 let engine = BacktestEngine::new(config.clone());
2508 let result_scale = engine.run("TEST", &candles, EnterScaleOutFull).unwrap();
2509
2510 assert!(result_scale.open_position.is_none());
2512 assert!(!result_scale.trades.is_empty());
2513
2514 #[derive(Clone)]
2516 struct EnterThenExit;
2517 impl Strategy for EnterThenExit {
2518 fn name(&self) -> &str {
2519 "EnterThenExit"
2520 }
2521 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2522 vec![]
2523 }
2524 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2525 match ctx.index {
2526 0 => Signal::long(ctx.timestamp(), ctx.close()),
2527 1 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2528 _ => Signal::hold(),
2529 }
2530 }
2531 }
2532
2533 let engine2 = BacktestEngine::new(config);
2534 let result_exit = engine2.run("TEST", &candles, EnterThenExit).unwrap();
2535
2536 let pnl_scale: f64 = result_scale.trades.iter().map(|t| t.pnl).sum();
2537 let pnl_exit: f64 = result_exit.trades.iter().map(|t| t.pnl).sum();
2538 assert!(
2539 (pnl_scale - pnl_exit).abs() < 1e-6,
2540 "scale_out(1.0) PnL {pnl_scale:.6} should equal exit PnL {pnl_exit:.6}"
2541 );
2542 }
2543
2544 #[test]
2545 fn test_scale_in_noop_without_position() {
2546 #[derive(Clone)]
2548 struct ScaleInNoPos;
2549 impl Strategy for ScaleInNoPos {
2550 fn name(&self) -> &str {
2551 "ScaleInNoPos"
2552 }
2553 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2554 vec![]
2555 }
2556 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2557 if ctx.index == 0 {
2558 Signal::scale_in(0.5, ctx.timestamp(), ctx.close())
2559 } else {
2560 Signal::hold()
2561 }
2562 }
2563 }
2564
2565 let prices = [100.0, 100.0, 100.0];
2566 let candles = make_candles(&prices);
2567 let config = BacktestConfig::builder()
2568 .initial_capital(10_000.0)
2569 .commission_pct(0.0)
2570 .slippage_pct(0.0)
2571 .build()
2572 .unwrap();
2573
2574 let engine = BacktestEngine::new(config.clone());
2575 let result = engine.run("TEST", &candles, ScaleInNoPos).unwrap();
2576
2577 assert!(result.trades.is_empty());
2578 assert!((result.final_equity - config.initial_capital).abs() < 1e-6);
2579 }
2580
2581 #[test]
2582 fn test_scale_out_noop_without_position() {
2583 #[derive(Clone)]
2585 struct ScaleOutNoPos;
2586 impl Strategy for ScaleOutNoPos {
2587 fn name(&self) -> &str {
2588 "ScaleOutNoPos"
2589 }
2590 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2591 vec![]
2592 }
2593 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2594 if ctx.index == 0 {
2595 Signal::scale_out(0.5, ctx.timestamp(), ctx.close())
2596 } else {
2597 Signal::hold()
2598 }
2599 }
2600 }
2601
2602 let prices = [100.0, 100.0, 100.0];
2603 let candles = make_candles(&prices);
2604 let config = BacktestConfig::builder()
2605 .initial_capital(10_000.0)
2606 .commission_pct(0.0)
2607 .slippage_pct(0.0)
2608 .build()
2609 .unwrap();
2610
2611 let engine = BacktestEngine::new(config.clone());
2612 let result = engine.run("TEST", &candles, ScaleOutNoPos).unwrap();
2613
2614 assert!(result.trades.is_empty());
2615 assert!((result.final_equity - config.initial_capital).abs() < 1e-6);
2616 }
2617
2618 #[test]
2619 fn test_scale_in_pnl_uses_weighted_avg_cost_basis() {
2620 let prices = [100.0, 100.0, 100.0, 110.0, 110.0];
2630 let candles = make_candles(&prices);
2631
2632 let config = BacktestConfig::builder()
2633 .initial_capital(1_000.0)
2634 .position_size_pct(0.1) .commission_pct(0.0)
2636 .commission(0.0)
2637 .slippage_pct(0.0)
2638 .close_at_end(true)
2639 .build()
2640 .unwrap();
2641
2642 let engine = BacktestEngine::new(config.clone());
2643 let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2644
2645 let si_executed = result
2647 .signals
2648 .iter()
2649 .any(|s| matches!(s.direction, SignalDirection::ScaleIn) && s.executed);
2650 assert!(
2651 si_executed,
2652 "scale-in did not execute — test is inconclusive"
2653 );
2654
2655 let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2660 assert!(sum_pnl > 0.0, "expected a profit, got {sum_pnl:.6}");
2661 assert!(
2662 (result.final_equity - (config.initial_capital + sum_pnl)).abs() < 1e-6,
2663 "accounting invariant: final_equity={:.6}, expected={:.6}",
2664 result.final_equity,
2665 config.initial_capital + sum_pnl
2666 );
2667 }
2668
2669 #[test]
2670 fn test_accounting_invariant_holds_with_scaling() {
2671 let prices = [100.0, 100.0, 100.0, 110.0, 110.0, 120.0];
2676 let candles = make_candles(&prices);
2677
2678 let config = BacktestConfig::builder()
2679 .initial_capital(10_000.0)
2680 .position_size_pct(0.2) .commission_pct(0.001)
2682 .slippage_pct(0.0)
2683 .close_at_end(true)
2684 .build()
2685 .unwrap();
2686
2687 let engine = BacktestEngine::new(config.clone());
2688 let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2689
2690 let scale_in_executed = result
2692 .signals
2693 .iter()
2694 .any(|s| matches!(s.direction, SignalDirection::ScaleIn) && s.executed);
2695 assert!(
2696 scale_in_executed,
2697 "scale-in signal was not executed — test is inconclusive"
2698 );
2699
2700 let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2701 let expected = config.initial_capital + sum_pnl;
2702 assert!(
2703 (result.final_equity - expected).abs() < 1e-4,
2704 "accounting invariant failed: final_equity={:.6}, expected={:.6}",
2705 result.final_equity,
2706 expected
2707 );
2708 }
2709
2710 #[derive(Clone)]
2718 struct BracketLongStopLossStrategy {
2719 stop_pct: f64,
2720 }
2721 impl Strategy for BracketLongStopLossStrategy {
2722 fn name(&self) -> &str {
2723 "BracketLongStopLoss"
2724 }
2725 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2726 vec![]
2727 }
2728 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2729 if ctx.index == 0 && !ctx.has_position() {
2730 Signal::long(ctx.timestamp(), ctx.close()).stop_loss(self.stop_pct)
2731 } else {
2732 Signal::hold()
2733 }
2734 }
2735 }
2736
2737 #[derive(Clone)]
2739 struct BracketShortStopLossStrategy {
2740 stop_pct: f64,
2741 }
2742 impl Strategy for BracketShortStopLossStrategy {
2743 fn name(&self) -> &str {
2744 "BracketShortStopLoss"
2745 }
2746 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2747 vec![]
2748 }
2749 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2750 if ctx.index == 0 && !ctx.has_position() {
2751 Signal::short(ctx.timestamp(), ctx.close()).stop_loss(self.stop_pct)
2752 } else {
2753 Signal::hold()
2754 }
2755 }
2756 }
2757
2758 #[test]
2761 fn test_per_trade_stop_loss_triggers_when_set() {
2762 let prices = [100.0, 100.0, 80.0, 80.0];
2766 let mut candles = make_candles(&prices);
2767 candles[2].low = 79.2;
2768
2769 let config = BacktestConfig::builder()
2770 .initial_capital(10_000.0)
2771 .commission_pct(0.0)
2772 .slippage_pct(0.0)
2773 .close_at_end(false)
2774 .build()
2775 .unwrap();
2776
2777 let engine = BacktestEngine::new(config);
2778 let result = engine
2779 .run(
2780 "TEST",
2781 &candles,
2782 BracketLongStopLossStrategy { stop_pct: 0.05 },
2783 )
2784 .unwrap();
2785
2786 assert!(
2787 !result.trades.is_empty(),
2788 "stop-loss should have closed the position"
2789 );
2790 assert!(
2791 result.trades[0].pnl < 0.0,
2792 "stop-loss trade should be a loss"
2793 );
2794 }
2795
2796 #[test]
2797 fn test_per_trade_stop_loss_overrides_config_none() {
2798 let prices = [100.0, 100.0, 80.0, 80.0];
2800 let mut candles = make_candles(&prices);
2801 candles[2].low = 79.2;
2802
2803 let config = BacktestConfig::builder()
2804 .initial_capital(10_000.0)
2805 .commission_pct(0.0)
2806 .slippage_pct(0.0)
2807 .close_at_end(false)
2808 .build()
2809 .unwrap();
2810
2811 assert!(
2812 config.stop_loss_pct.is_none(),
2813 "config must not have a default stop-loss for this test"
2814 );
2815
2816 let engine = BacktestEngine::new(config);
2817 let result = engine
2818 .run(
2819 "TEST",
2820 &candles,
2821 BracketLongStopLossStrategy { stop_pct: 0.05 },
2822 )
2823 .unwrap();
2824
2825 assert!(
2826 !result.trades.is_empty(),
2827 "per-trade bracket stop should fire even when config stop_loss_pct is None"
2828 );
2829 }
2830
2831 #[test]
2832 fn test_per_trade_stop_loss_overrides_config_looser() {
2833 let prices = [100.0, 100.0, 97.0, 97.0];
2841 let mut candles = make_candles(&prices);
2842 candles[2].low = 93.0; let config = BacktestConfig::builder()
2845 .initial_capital(10_000.0)
2846 .commission_pct(0.0)
2847 .slippage_pct(0.0)
2848 .stop_loss_pct(0.20) .close_at_end(false)
2850 .build()
2851 .unwrap();
2852
2853 let engine = BacktestEngine::new(config);
2854 let result = engine
2855 .run(
2856 "TEST",
2857 &candles,
2858 BracketLongStopLossStrategy { stop_pct: 0.05 },
2859 )
2860 .unwrap();
2861
2862 assert!(!result.trades.is_empty());
2863 let trade = &result.trades[0];
2864 assert!(
2866 trade.exit_price > 90.0,
2867 "expected exit near 5% bracket stop ($95), got {:.2}",
2868 trade.exit_price
2869 );
2870 }
2871
2872 #[test]
2875 fn test_per_trade_short_stop_loss_triggers_when_set() {
2876 let prices = [100.0, 100.0, 112.0, 112.0];
2880 let mut candles = make_candles(&prices);
2881 candles[2].high = 112.5;
2882
2883 let config = BacktestConfig::builder()
2884 .initial_capital(10_000.0)
2885 .commission_pct(0.0)
2886 .slippage_pct(0.0)
2887 .allow_short(true)
2888 .close_at_end(false)
2889 .build()
2890 .unwrap();
2891
2892 let engine = BacktestEngine::new(config);
2893 let result = engine
2894 .run(
2895 "TEST",
2896 &candles,
2897 BracketShortStopLossStrategy { stop_pct: 0.05 },
2898 )
2899 .unwrap();
2900
2901 assert!(
2902 !result.trades.is_empty(),
2903 "short stop-loss should have closed the position"
2904 );
2905 assert!(
2906 result.trades[0].pnl < 0.0,
2907 "short stop-loss trade should be a loss (price rose against the short)"
2908 );
2909 }
2910
2911 #[test]
2912 fn test_per_trade_short_stop_loss_overrides_config_none() {
2913 let prices = [100.0, 100.0, 112.0, 112.0];
2915 let mut candles = make_candles(&prices);
2916 candles[2].high = 112.5;
2917
2918 let config = BacktestConfig::builder()
2919 .initial_capital(10_000.0)
2920 .commission_pct(0.0)
2921 .slippage_pct(0.0)
2922 .allow_short(true)
2923 .close_at_end(false)
2924 .build()
2925 .unwrap();
2926
2927 assert!(config.stop_loss_pct.is_none());
2928
2929 let engine = BacktestEngine::new(config);
2930 let result = engine
2931 .run(
2932 "TEST",
2933 &candles,
2934 BracketShortStopLossStrategy { stop_pct: 0.05 },
2935 )
2936 .unwrap();
2937
2938 assert!(
2939 !result.trades.is_empty(),
2940 "per-trade bracket stop should fire for shorts even with no config stop-loss"
2941 );
2942 }
2943
2944 #[test]
2945 fn test_per_trade_short_stop_loss_overrides_config_looser() {
2946 let prices = [100.0, 100.0, 103.0, 103.0];
2953 let mut candles = make_candles(&prices);
2954 candles[2].high = 108.0; let config = BacktestConfig::builder()
2957 .initial_capital(10_000.0)
2958 .commission_pct(0.0)
2959 .slippage_pct(0.0)
2960 .allow_short(true)
2961 .stop_loss_pct(0.20) .close_at_end(false)
2963 .build()
2964 .unwrap();
2965
2966 let engine = BacktestEngine::new(config);
2967 let result = engine
2968 .run(
2969 "TEST",
2970 &candles,
2971 BracketShortStopLossStrategy { stop_pct: 0.05 },
2972 )
2973 .unwrap();
2974
2975 assert!(!result.trades.is_empty());
2976 let trade = &result.trades[0];
2977 assert!(
2979 trade.exit_price < 115.0,
2980 "expected exit near 5% bracket stop ($105), got {:.2}",
2981 trade.exit_price
2982 );
2983 }
2984
2985 #[derive(Clone)]
2989 struct BracketLongTakeProfitStrategy {
2990 tp_pct: f64,
2991 }
2992 impl Strategy for BracketLongTakeProfitStrategy {
2993 fn name(&self) -> &str {
2994 "BracketLongTakeProfit"
2995 }
2996 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2997 vec![]
2998 }
2999 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3000 if ctx.index == 0 && !ctx.has_position() {
3001 Signal::long(ctx.timestamp(), ctx.close()).take_profit(self.tp_pct)
3002 } else {
3003 Signal::hold()
3004 }
3005 }
3006 }
3007
3008 #[derive(Clone)]
3010 struct BracketShortTakeProfitStrategy {
3011 tp_pct: f64,
3012 }
3013 impl Strategy for BracketShortTakeProfitStrategy {
3014 fn name(&self) -> &str {
3015 "BracketShortTakeProfit"
3016 }
3017 fn required_indicators(&self) -> Vec<(String, Indicator)> {
3018 vec![]
3019 }
3020 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3021 if ctx.index == 0 && !ctx.has_position() {
3022 Signal::short(ctx.timestamp(), ctx.close()).take_profit(self.tp_pct)
3023 } else {
3024 Signal::hold()
3025 }
3026 }
3027 }
3028
3029 #[test]
3030 fn test_per_trade_take_profit_triggers() {
3031 let prices = [100.0, 100.0, 120.0, 120.0];
3035 let mut candles = make_candles(&prices);
3036 candles[2].high = 121.2;
3037
3038 let config = BacktestConfig::builder()
3039 .initial_capital(10_000.0)
3040 .commission_pct(0.0)
3041 .slippage_pct(0.0)
3042 .close_at_end(false)
3043 .build()
3044 .unwrap();
3045
3046 let engine = BacktestEngine::new(config);
3047 let result = engine
3048 .run(
3049 "TEST",
3050 &candles,
3051 BracketLongTakeProfitStrategy { tp_pct: 0.10 },
3052 )
3053 .unwrap();
3054
3055 assert!(
3056 !result.trades.is_empty(),
3057 "long take-profit should have fired"
3058 );
3059 assert!(
3060 result.trades[0].pnl > 0.0,
3061 "long take-profit trade should be profitable"
3062 );
3063 }
3064
3065 #[test]
3066 fn test_per_trade_short_take_profit_triggers() {
3067 let prices = [100.0, 100.0, 85.0, 85.0];
3071 let mut candles = make_candles(&prices);
3072 candles[2].low = 84.15;
3073
3074 let config = BacktestConfig::builder()
3075 .initial_capital(10_000.0)
3076 .commission_pct(0.0)
3077 .slippage_pct(0.0)
3078 .allow_short(true)
3079 .close_at_end(false)
3080 .build()
3081 .unwrap();
3082
3083 let engine = BacktestEngine::new(config);
3084 let result = engine
3085 .run(
3086 "TEST",
3087 &candles,
3088 BracketShortTakeProfitStrategy { tp_pct: 0.10 },
3089 )
3090 .unwrap();
3091
3092 assert!(
3093 !result.trades.is_empty(),
3094 "short take-profit should have fired"
3095 );
3096 assert!(
3097 result.trades[0].pnl > 0.0,
3098 "short take-profit trade should be profitable (price fell in favor of short)"
3099 );
3100 }
3101
3102 #[derive(Clone)]
3106 struct BracketLongTrailingStopStrategy {
3107 trail_pct: f64,
3108 }
3109 impl Strategy for BracketLongTrailingStopStrategy {
3110 fn name(&self) -> &str {
3111 "BracketLongTrailingStop"
3112 }
3113 fn required_indicators(&self) -> Vec<(String, Indicator)> {
3114 vec![]
3115 }
3116 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3117 if ctx.index == 0 && !ctx.has_position() {
3118 Signal::long(ctx.timestamp(), ctx.close()).trailing_stop(self.trail_pct)
3119 } else {
3120 Signal::hold()
3121 }
3122 }
3123 }
3124
3125 #[derive(Clone)]
3127 struct BracketShortTrailingStopStrategy {
3128 trail_pct: f64,
3129 }
3130 impl Strategy for BracketShortTrailingStopStrategy {
3131 fn name(&self) -> &str {
3132 "BracketShortTrailingStop"
3133 }
3134 fn required_indicators(&self) -> Vec<(String, Indicator)> {
3135 vec![]
3136 }
3137 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3138 if ctx.index == 0 && !ctx.has_position() {
3139 Signal::short(ctx.timestamp(), ctx.close()).trailing_stop(self.trail_pct)
3140 } else {
3141 Signal::hold()
3142 }
3143 }
3144 }
3145
3146 #[test]
3147 fn test_per_trade_trailing_stop_triggers() {
3148 let prices = [100.0, 100.0, 120.0, 110.0, 110.0];
3155 let mut candles = make_candles(&prices);
3156 candles[2].high = 121.0;
3157 candles[3].low = 108.9; let config = BacktestConfig::builder()
3160 .initial_capital(10_000.0)
3161 .commission_pct(0.0)
3162 .slippage_pct(0.0)
3163 .close_at_end(false)
3164 .build()
3165 .unwrap();
3166
3167 let engine = BacktestEngine::new(config);
3168 let result = engine
3169 .run(
3170 "TEST",
3171 &candles,
3172 BracketLongTrailingStopStrategy { trail_pct: 0.05 },
3173 )
3174 .unwrap();
3175
3176 assert!(
3177 !result.trades.is_empty(),
3178 "long trailing stop should have fired"
3179 );
3180 assert!(
3181 result.trades[0].pnl > 0.0,
3182 "long trailing stop should exit in profit (entry $100, exit near $110)"
3183 );
3184 }
3185
3186 #[test]
3187 fn test_per_trade_short_trailing_stop_triggers() {
3188 let prices = [100.0, 100.0, 80.0, 88.0, 88.0];
3195 let mut candles = make_candles(&prices);
3196 candles[2].low = 79.2; let config = BacktestConfig::builder()
3199 .initial_capital(10_000.0)
3200 .commission_pct(0.0)
3201 .slippage_pct(0.0)
3202 .allow_short(true)
3203 .close_at_end(false)
3204 .build()
3205 .unwrap();
3206
3207 let engine = BacktestEngine::new(config);
3208 let result = engine
3209 .run(
3210 "TEST",
3211 &candles,
3212 BracketShortTrailingStopStrategy { trail_pct: 0.05 },
3213 )
3214 .unwrap();
3215
3216 assert!(
3217 !result.trades.is_empty(),
3218 "short trailing stop should have fired"
3219 );
3220 assert!(
3221 result.trades[0].pnl > 0.0,
3222 "short trailing stop should exit in profit (entry $100, exit near $88)"
3223 );
3224 }
3225}