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