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 provider_id: None,
1784 })
1785 .collect()
1786 }
1787
1788 fn make_candles_with_timestamps(prices: &[f64], timestamps: &[i64]) -> Vec<Candle> {
1789 prices
1790 .iter()
1791 .zip(timestamps.iter())
1792 .map(|(&p, &ts)| Candle {
1793 timestamp: ts,
1794 open: p,
1795 high: p * 1.01,
1796 low: p * 0.99,
1797 close: p,
1798 volume: 1000,
1799 adj_close: Some(p),
1800 provider_id: None,
1801 })
1802 .collect()
1803 }
1804
1805 #[test]
1806 fn test_engine_basic() {
1807 let mut prices = vec![100.0; 30];
1809 for (i, price) in prices.iter_mut().enumerate().take(25).skip(15) {
1811 *price = 100.0 + (i - 15) as f64 * 2.0;
1812 }
1813 for (i, price) in prices.iter_mut().enumerate().take(30).skip(25) {
1815 *price = 118.0 - (i - 25) as f64 * 3.0;
1816 }
1817
1818 let candles = make_candles(&prices);
1819 let config = BacktestConfig::builder()
1820 .initial_capital(10_000.0)
1821 .commission_pct(0.0)
1822 .slippage_pct(0.0)
1823 .build()
1824 .unwrap();
1825
1826 let engine = BacktestEngine::new(config);
1827 let strategy = SmaCrossover::new(5, 10);
1828 let result = engine.run("TEST", &candles, strategy).unwrap();
1829
1830 assert_eq!(result.symbol, "TEST");
1831 assert_eq!(result.strategy_name, "SMA Crossover");
1832 assert!(!result.equity_curve.is_empty());
1833 }
1834
1835 #[test]
1836 fn test_stop_loss() {
1837 let mut prices = vec![100.0; 20];
1839 for (i, price) in prices.iter_mut().enumerate().take(15).skip(10) {
1841 *price = 100.0 + (i - 10) as f64 * 2.0;
1842 }
1843 for (i, price) in prices.iter_mut().enumerate().take(20).skip(15) {
1845 *price = 108.0 - (i - 15) as f64 * 10.0;
1846 }
1847
1848 let candles = make_candles(&prices);
1849 let config = BacktestConfig::builder()
1850 .initial_capital(10_000.0)
1851 .stop_loss_pct(0.05) .commission_pct(0.0)
1853 .slippage_pct(0.0)
1854 .build()
1855 .unwrap();
1856
1857 let engine = BacktestEngine::new(config);
1858 let strategy = SmaCrossover::new(3, 6);
1859 let result = engine.run("TEST", &candles, strategy).unwrap();
1860
1861 let _sl_signals: Vec<_> = result
1863 .signals
1864 .iter()
1865 .filter(|s| {
1866 s.reason
1867 .as_ref()
1868 .map(|r| r.contains("Stop-loss"))
1869 .unwrap_or(false)
1870 })
1871 .collect();
1872
1873 assert!(!result.equity_curve.is_empty());
1876 }
1877
1878 #[test]
1879 fn test_trailing_stop() {
1880 let mut prices: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
1882 prices.extend_from_slice(&[105.0, 103.0, 101.0]);
1884
1885 let candles = make_candles(&prices);
1886 let config = BacktestConfig::builder()
1887 .initial_capital(10_000.0)
1888 .trailing_stop_pct(0.10)
1889 .commission_pct(0.0)
1890 .slippage_pct(0.0)
1891 .build()
1892 .unwrap();
1893
1894 let engine = BacktestEngine::new(config);
1895 let strategy = SmaCrossover::new(3, 6);
1896 let result = engine.run("TEST", &candles, strategy).unwrap();
1897
1898 let trail_exits: Vec<_> = result
1899 .signals
1900 .iter()
1901 .filter(|s| {
1902 s.reason
1903 .as_ref()
1904 .map(|r| r.contains("Trailing stop"))
1905 .unwrap_or(false)
1906 })
1907 .collect();
1908
1909 let _ = trail_exits;
1911 assert!(!result.equity_curve.is_empty());
1912 }
1913
1914 #[test]
1915 fn test_insufficient_data() {
1916 let candles = make_candles(&[100.0, 101.0, 102.0]); let config = BacktestConfig::default();
1918 let engine = BacktestEngine::new(config);
1919 let strategy = SmaCrossover::new(10, 20); let result = engine.run("TEST", &candles, strategy);
1922 assert!(result.is_err());
1923 }
1924
1925 #[test]
1926 fn test_capm_alpha_with_risk_free_rate() {
1927 let prices: Vec<f64> = (0..60).map(|i| 100.0 + i as f64).collect();
1930 let candles = make_candles(&prices);
1931
1932 let config_no_rf = BacktestConfig::builder()
1934 .commission_pct(0.0)
1935 .slippage_pct(0.0)
1936 .risk_free_rate(0.0)
1937 .build()
1938 .unwrap();
1939 let config_with_rf = BacktestConfig::builder()
1940 .commission_pct(0.0)
1941 .slippage_pct(0.0)
1942 .risk_free_rate(0.05)
1943 .build()
1944 .unwrap();
1945
1946 let engine_no_rf = BacktestEngine::new(config_no_rf);
1947 let engine_with_rf = BacktestEngine::new(config_with_rf);
1948
1949 let result_no_rf = engine_no_rf
1951 .run_with_benchmark(
1952 "TEST",
1953 &candles,
1954 SmaCrossover::new(3, 10),
1955 &[],
1956 "BENCH",
1957 &candles,
1958 )
1959 .unwrap();
1960 let result_with_rf = engine_with_rf
1961 .run_with_benchmark(
1962 "TEST",
1963 &candles,
1964 SmaCrossover::new(3, 10),
1965 &[],
1966 "BENCH",
1967 &candles,
1968 )
1969 .unwrap();
1970
1971 let bm_no_rf = result_no_rf.benchmark.unwrap();
1972 let bm_with_rf = result_with_rf.benchmark.unwrap();
1973
1974 assert!(bm_no_rf.alpha.is_finite(), "Alpha should be finite");
1978 assert!(
1979 bm_with_rf.alpha.is_finite(),
1980 "Alpha should be finite with rf"
1981 );
1982
1983 assert!(
1987 bm_no_rf.alpha.abs() < 50.0,
1988 "Alpha should be small for identical strategy/benchmark"
1989 );
1990 assert!(
1991 bm_with_rf.alpha.abs() < 50.0,
1992 "Alpha should be small for identical strategy/benchmark with rf"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_run_with_benchmark_credits_dividends() {
1998 use crate::models::chart::Dividend;
1999
2000 let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
2002 let candles = make_candles(&prices);
2003
2004 let mid_ts = candles[15].timestamp;
2006 let dividends = vec![Dividend {
2007 timestamp: mid_ts,
2008 amount: 1.0,
2009 provider_id: None,
2010 }];
2011
2012 let config = BacktestConfig::builder()
2013 .initial_capital(10_000.0)
2014 .commission_pct(0.0)
2015 .slippage_pct(0.0)
2016 .build()
2017 .unwrap();
2018
2019 let engine = BacktestEngine::new(config);
2020 let result = engine
2021 .run_with_benchmark(
2022 "TEST",
2023 &candles,
2024 SmaCrossover::new(3, 6),
2025 ÷nds,
2026 "BENCH",
2027 &candles,
2028 )
2029 .unwrap();
2030
2031 assert!(result.benchmark.is_some());
2035 let total_div: f64 = result.trades.iter().map(|t| t.dividend_income).sum();
2036 assert!(total_div >= 0.0);
2038 }
2039
2040 #[test]
2044 fn test_commission_accounting_invariant() {
2045 let prices: Vec<f64> = (0..40)
2047 .map(|i| {
2048 if i < 30 {
2049 100.0 + i as f64
2050 } else {
2051 129.0 - (i - 30) as f64 * 5.0
2052 }
2053 })
2054 .collect();
2055 let candles = make_candles(&prices);
2056
2057 let config = BacktestConfig::builder()
2059 .initial_capital(10_000.0)
2060 .commission(5.0) .commission_pct(0.001) .slippage_pct(0.0)
2063 .close_at_end(true)
2064 .build()
2065 .unwrap();
2066
2067 let engine = BacktestEngine::new(config.clone());
2068 let result = engine
2069 .run("TEST", &candles, SmaCrossover::new(3, 6))
2070 .unwrap();
2071
2072 let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2074 let expected = config.initial_capital + sum_pnl;
2075 let actual = result.final_equity;
2076 assert!(
2077 (actual - expected).abs() < 1e-6,
2078 "Commission accounting: final_equity {actual:.6} != initial_capital + sum(pnl) {expected:.6}",
2079 );
2080 }
2081
2082 #[test]
2083 fn test_unsorted_dividends_returns_error() {
2084 use crate::models::chart::Dividend;
2085
2086 let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
2087 let candles = make_candles(&prices);
2088
2089 let dividends = vec![
2091 Dividend {
2092 timestamp: 20,
2093 amount: 1.0,
2094 provider_id: None,
2095 },
2096 Dividend {
2097 timestamp: 10,
2098 amount: 1.0,
2099 provider_id: None,
2100 },
2101 ];
2102
2103 let engine = BacktestEngine::new(BacktestConfig::default());
2104 let result =
2105 engine.run_with_dividends("TEST", &candles, SmaCrossover::new(3, 6), ÷nds);
2106 assert!(result.is_err());
2107 let msg = result.unwrap_err().to_string();
2108 assert!(
2109 msg.contains("sorted"),
2110 "error should mention sorting: {msg}"
2111 );
2112 }
2113
2114 #[test]
2115 fn test_short_dividend_is_liability() {
2116 use crate::models::chart::Dividend;
2117
2118 let candles = make_candles(&[100.0, 100.0, 100.0]);
2119 let dividends = vec![Dividend {
2120 timestamp: candles[1].timestamp,
2121 amount: 1.0,
2122 provider_id: None,
2123 }];
2124
2125 let config = BacktestConfig::builder()
2126 .initial_capital(10_000.0)
2127 .allow_short(true)
2128 .commission_pct(0.0)
2129 .slippage_pct(0.0)
2130 .build()
2131 .unwrap();
2132
2133 let engine = BacktestEngine::new(config);
2134 let result = engine
2135 .run_with_dividends("TEST", &candles, EnterShortHold, ÷nds)
2136 .unwrap();
2137
2138 assert_eq!(result.trades.len(), 1);
2139 assert!(result.trades[0].dividend_income < 0.0);
2140 assert!(result.final_equity < 10_000.0);
2141 }
2142
2143 #[test]
2144 fn test_open_position_final_equity_includes_accrued_dividends() {
2145 use crate::models::chart::Dividend;
2146
2147 let candles = make_candles(&[100.0, 100.0, 100.0]);
2148 let dividends = vec![Dividend {
2149 timestamp: candles[1].timestamp,
2150 amount: 1.0,
2151 provider_id: None,
2152 }];
2153
2154 let config = BacktestConfig::builder()
2155 .initial_capital(10_000.0)
2156 .close_at_end(false)
2157 .commission_pct(0.0)
2158 .slippage_pct(0.0)
2159 .build()
2160 .unwrap();
2161
2162 let engine = BacktestEngine::new(config);
2163 let result = engine
2164 .run_with_dividends("TEST", &candles, EnterLongHold, ÷nds)
2165 .unwrap();
2166
2167 assert!(result.open_position.is_some());
2168 assert!((result.final_equity - 10_100.0).abs() < 1e-6);
2169 let last_equity = result.equity_curve.last().map(|p| p.equity).unwrap_or(0.0);
2170 assert!((last_equity - 10_100.0).abs() < 1e-6);
2171 }
2172
2173 #[test]
2174 fn test_benchmark_beta_and_ir_require_timestamp_overlap() {
2175 let symbol_candles = make_candles_with_timestamps(&[100.0, 110.0, 120.0], &[100, 200, 300]);
2176 let benchmark_candles =
2177 make_candles_with_timestamps(&[50.0, 55.0, 60.0, 65.0], &[1000, 1100, 1200, 1300]);
2178
2179 let config = BacktestConfig::builder()
2180 .initial_capital(10_000.0)
2181 .commission_pct(0.0)
2182 .slippage_pct(0.0)
2183 .build()
2184 .unwrap();
2185
2186 let engine = BacktestEngine::new(config);
2187 let result = engine
2188 .run_with_benchmark(
2189 "TEST",
2190 &symbol_candles,
2191 EnterLongHold,
2192 &[],
2193 "BENCH",
2194 &benchmark_candles,
2195 )
2196 .unwrap();
2197
2198 let benchmark = result.benchmark.unwrap();
2199 assert!((benchmark.beta - 0.0).abs() < 1e-12);
2200 assert!((benchmark.information_ratio - 0.0).abs() < 1e-12);
2201 }
2202
2203 fn make_candle_ohlc(ts: i64, open: f64, high: f64, low: f64, close: f64) -> Candle {
2205 Candle {
2206 timestamp: ts,
2207 open,
2208 high,
2209 low,
2210 close,
2211 volume: 1000,
2212 adj_close: Some(close),
2213 provider_id: None,
2214 }
2215 }
2216
2217 struct EnterLongBar0;
2221 impl Strategy for EnterLongBar0 {
2222 fn name(&self) -> &str {
2223 "Enter Long Bar 0"
2224 }
2225 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2226 vec![]
2227 }
2228 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2229 if ctx.index == 0 && !ctx.has_position() {
2230 Signal::long(ctx.timestamp(), ctx.close())
2231 } else {
2232 Signal::hold()
2233 }
2234 }
2235 }
2236
2237 #[test]
2238 fn test_intrabar_stop_loss_fills_at_stop_price_not_next_open() {
2239 let candles = vec![
2246 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), ];
2251
2252 let config = BacktestConfig::builder()
2253 .initial_capital(10_000.0)
2254 .stop_loss_pct(0.05) .commission_pct(0.0)
2256 .slippage_pct(0.0)
2257 .build()
2258 .unwrap();
2259
2260 let engine = BacktestEngine::new(config);
2261 let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2262
2263 let sl_trade = result.trades.iter().find(|t| {
2264 t.exit_signal
2265 .reason
2266 .as_ref()
2267 .map(|r| r.contains("Stop-loss"))
2268 .unwrap_or(false)
2269 });
2270 assert!(sl_trade.is_some(), "expected a stop-loss trade");
2271 let trade = sl_trade.unwrap();
2272
2273 assert!(
2275 (trade.exit_price - 95.0).abs() < 1e-9,
2276 "expected exit at stop price 95.0, got {:.6}",
2277 trade.exit_price
2278 );
2279 assert_eq!(
2281 trade.exit_timestamp, 2,
2282 "exit should be on bar 2 (intrabar)"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_intrabar_stop_loss_gap_down_fills_at_open() {
2288 let candles = vec![
2291 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), ];
2295
2296 let config = BacktestConfig::builder()
2297 .initial_capital(10_000.0)
2298 .stop_loss_pct(0.05) .commission_pct(0.0)
2300 .slippage_pct(0.0)
2301 .build()
2302 .unwrap();
2303
2304 let engine = BacktestEngine::new(config);
2305 let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2306
2307 let sl_trade = result
2308 .trades
2309 .iter()
2310 .find(|t| {
2311 t.exit_signal
2312 .reason
2313 .as_ref()
2314 .map(|r| r.contains("Stop-loss"))
2315 .unwrap_or(false)
2316 })
2317 .expect("expected a stop-loss trade");
2318
2319 assert!(
2321 (sl_trade.exit_price - 92.0).abs() < 1e-9,
2322 "expected gap-down fill at 92.0, got {:.6}",
2323 sl_trade.exit_price
2324 );
2325 }
2326
2327 #[test]
2328 fn test_intrabar_take_profit_fills_at_tp_price() {
2329 let candles = vec![
2332 make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0),
2333 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), ];
2337
2338 let config = BacktestConfig::builder()
2339 .initial_capital(10_000.0)
2340 .take_profit_pct(0.10) .commission_pct(0.0)
2342 .slippage_pct(0.0)
2343 .build()
2344 .unwrap();
2345
2346 let engine = BacktestEngine::new(config);
2347 let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2348
2349 let tp_trade = result
2350 .trades
2351 .iter()
2352 .find(|t| {
2353 t.exit_signal
2354 .reason
2355 .as_ref()
2356 .map(|r| r.contains("Take-profit"))
2357 .unwrap_or(false)
2358 })
2359 .expect("expected a take-profit trade");
2360
2361 assert!(
2362 (tp_trade.exit_price - 110.0).abs() < 1e-9,
2363 "expected TP fill at 110.0, got {:.6}",
2364 tp_trade.exit_price
2365 );
2366 assert_eq!(
2367 tp_trade.exit_timestamp, 2,
2368 "exit should be on bar 2 (intrabar)"
2369 );
2370 }
2371
2372 #[derive(Clone)]
2376 struct EnterScaleInExit;
2377
2378 impl Strategy for EnterScaleInExit {
2379 fn name(&self) -> &str {
2380 "EnterScaleInExit"
2381 }
2382
2383 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2384 vec![]
2385 }
2386
2387 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2388 match ctx.index {
2389 0 => Signal::long(ctx.timestamp(), ctx.close()),
2390 1 if ctx.has_position() => Signal::scale_in(0.5, ctx.timestamp(), ctx.close()),
2391 2 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2392 _ => Signal::hold(),
2393 }
2394 }
2395 }
2396
2397 #[derive(Clone)]
2399 struct EnterScaleOutExit;
2400
2401 impl Strategy for EnterScaleOutExit {
2402 fn name(&self) -> &str {
2403 "EnterScaleOutExit"
2404 }
2405
2406 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2407 vec![]
2408 }
2409
2410 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2411 match ctx.index {
2412 0 => Signal::long(ctx.timestamp(), ctx.close()),
2413 1 if ctx.has_position() => Signal::scale_out(0.5, ctx.timestamp(), ctx.close()),
2414 2 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2415 _ => Signal::hold(),
2416 }
2417 }
2418 }
2419
2420 #[test]
2421 fn test_scale_in_adds_to_position() {
2422 let prices = [100.0, 100.0, 110.0, 120.0, 120.0];
2424 let candles = make_candles(&prices);
2425
2426 let config = BacktestConfig::builder()
2427 .initial_capital(10_000.0)
2428 .commission_pct(0.0)
2429 .slippage_pct(0.0)
2430 .close_at_end(true)
2431 .build()
2432 .unwrap();
2433
2434 let engine = BacktestEngine::new(config);
2435 let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2436
2437 assert_eq!(result.trades.len(), 1);
2439 let trade = &result.trades[0];
2440 assert!(!trade.is_partial);
2441 assert!(trade.quantity > 0.0);
2443 assert!(!result.equity_curve.is_empty());
2445 let scale_signals: Vec<_> = result
2447 .signals
2448 .iter()
2449 .filter(|s| matches!(s.direction, SignalDirection::ScaleIn))
2450 .collect();
2451 assert!(!scale_signals.is_empty());
2452 }
2453
2454 #[test]
2455 fn test_scale_out_produces_partial_trade() {
2456 let prices = [100.0, 100.0, 110.0, 120.0, 120.0];
2457 let candles = make_candles(&prices);
2458
2459 let config = BacktestConfig::builder()
2460 .initial_capital(10_000.0)
2461 .commission_pct(0.0)
2462 .slippage_pct(0.0)
2463 .close_at_end(true)
2464 .build()
2465 .unwrap();
2466
2467 let engine = BacktestEngine::new(config);
2468 let result = engine.run("TEST", &candles, EnterScaleOutExit).unwrap();
2469
2470 assert!(result.trades.len() >= 2);
2472 let partial = result
2473 .trades
2474 .iter()
2475 .find(|t| t.is_partial)
2476 .expect("expected at least one partial trade");
2477 assert_eq!(partial.scale_sequence, 0);
2478
2479 let final_trade = result.trades.iter().find(|t| !t.is_partial);
2480 assert!(final_trade.is_some());
2481 }
2482
2483 #[test]
2484 fn test_scale_out_full_fraction_is_equivalent_to_exit() {
2485 #[derive(Clone)]
2487 struct EnterScaleOutFull;
2488 impl Strategy for EnterScaleOutFull {
2489 fn name(&self) -> &str {
2490 "EnterScaleOutFull"
2491 }
2492 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2493 vec![]
2494 }
2495 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2496 match ctx.index {
2497 0 => Signal::long(ctx.timestamp(), ctx.close()),
2498 1 if ctx.has_position() => Signal::scale_out(1.0, ctx.timestamp(), ctx.close()),
2499 _ => Signal::hold(),
2500 }
2501 }
2502 }
2503
2504 let prices = [100.0, 100.0, 120.0, 120.0];
2505 let candles = make_candles(&prices);
2506
2507 let config = BacktestConfig::builder()
2508 .initial_capital(10_000.0)
2509 .commission_pct(0.0)
2510 .slippage_pct(0.0)
2511 .close_at_end(false)
2512 .build()
2513 .unwrap();
2514
2515 let engine = BacktestEngine::new(config.clone());
2516 let result_scale = engine.run("TEST", &candles, EnterScaleOutFull).unwrap();
2517
2518 assert!(result_scale.open_position.is_none());
2520 assert!(!result_scale.trades.is_empty());
2521
2522 #[derive(Clone)]
2524 struct EnterThenExit;
2525 impl Strategy for EnterThenExit {
2526 fn name(&self) -> &str {
2527 "EnterThenExit"
2528 }
2529 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2530 vec![]
2531 }
2532 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2533 match ctx.index {
2534 0 => Signal::long(ctx.timestamp(), ctx.close()),
2535 1 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2536 _ => Signal::hold(),
2537 }
2538 }
2539 }
2540
2541 let engine2 = BacktestEngine::new(config);
2542 let result_exit = engine2.run("TEST", &candles, EnterThenExit).unwrap();
2543
2544 let pnl_scale: f64 = result_scale.trades.iter().map(|t| t.pnl).sum();
2545 let pnl_exit: f64 = result_exit.trades.iter().map(|t| t.pnl).sum();
2546 assert!(
2547 (pnl_scale - pnl_exit).abs() < 1e-6,
2548 "scale_out(1.0) PnL {pnl_scale:.6} should equal exit PnL {pnl_exit:.6}"
2549 );
2550 }
2551
2552 #[test]
2553 fn test_scale_in_noop_without_position() {
2554 #[derive(Clone)]
2556 struct ScaleInNoPos;
2557 impl Strategy for ScaleInNoPos {
2558 fn name(&self) -> &str {
2559 "ScaleInNoPos"
2560 }
2561 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2562 vec![]
2563 }
2564 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2565 if ctx.index == 0 {
2566 Signal::scale_in(0.5, ctx.timestamp(), ctx.close())
2567 } else {
2568 Signal::hold()
2569 }
2570 }
2571 }
2572
2573 let prices = [100.0, 100.0, 100.0];
2574 let candles = make_candles(&prices);
2575 let config = BacktestConfig::builder()
2576 .initial_capital(10_000.0)
2577 .commission_pct(0.0)
2578 .slippage_pct(0.0)
2579 .build()
2580 .unwrap();
2581
2582 let engine = BacktestEngine::new(config.clone());
2583 let result = engine.run("TEST", &candles, ScaleInNoPos).unwrap();
2584
2585 assert!(result.trades.is_empty());
2586 assert!((result.final_equity - config.initial_capital).abs() < 1e-6);
2587 }
2588
2589 #[test]
2590 fn test_scale_out_noop_without_position() {
2591 #[derive(Clone)]
2593 struct ScaleOutNoPos;
2594 impl Strategy for ScaleOutNoPos {
2595 fn name(&self) -> &str {
2596 "ScaleOutNoPos"
2597 }
2598 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2599 vec![]
2600 }
2601 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2602 if ctx.index == 0 {
2603 Signal::scale_out(0.5, ctx.timestamp(), ctx.close())
2604 } else {
2605 Signal::hold()
2606 }
2607 }
2608 }
2609
2610 let prices = [100.0, 100.0, 100.0];
2611 let candles = make_candles(&prices);
2612 let config = BacktestConfig::builder()
2613 .initial_capital(10_000.0)
2614 .commission_pct(0.0)
2615 .slippage_pct(0.0)
2616 .build()
2617 .unwrap();
2618
2619 let engine = BacktestEngine::new(config.clone());
2620 let result = engine.run("TEST", &candles, ScaleOutNoPos).unwrap();
2621
2622 assert!(result.trades.is_empty());
2623 assert!((result.final_equity - config.initial_capital).abs() < 1e-6);
2624 }
2625
2626 #[test]
2627 fn test_scale_in_pnl_uses_weighted_avg_cost_basis() {
2628 let prices = [100.0, 100.0, 100.0, 110.0, 110.0];
2638 let candles = make_candles(&prices);
2639
2640 let config = BacktestConfig::builder()
2641 .initial_capital(1_000.0)
2642 .position_size_pct(0.1) .commission_pct(0.0)
2644 .commission(0.0)
2645 .slippage_pct(0.0)
2646 .close_at_end(true)
2647 .build()
2648 .unwrap();
2649
2650 let engine = BacktestEngine::new(config.clone());
2651 let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2652
2653 let si_executed = result
2655 .signals
2656 .iter()
2657 .any(|s| matches!(s.direction, SignalDirection::ScaleIn) && s.executed);
2658 assert!(
2659 si_executed,
2660 "scale-in did not execute — test is inconclusive"
2661 );
2662
2663 let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2668 assert!(sum_pnl > 0.0, "expected a profit, got {sum_pnl:.6}");
2669 assert!(
2670 (result.final_equity - (config.initial_capital + sum_pnl)).abs() < 1e-6,
2671 "accounting invariant: final_equity={:.6}, expected={:.6}",
2672 result.final_equity,
2673 config.initial_capital + sum_pnl
2674 );
2675 }
2676
2677 #[test]
2678 fn test_accounting_invariant_holds_with_scaling() {
2679 let prices = [100.0, 100.0, 100.0, 110.0, 110.0, 120.0];
2684 let candles = make_candles(&prices);
2685
2686 let config = BacktestConfig::builder()
2687 .initial_capital(10_000.0)
2688 .position_size_pct(0.2) .commission_pct(0.001)
2690 .slippage_pct(0.0)
2691 .close_at_end(true)
2692 .build()
2693 .unwrap();
2694
2695 let engine = BacktestEngine::new(config.clone());
2696 let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2697
2698 let scale_in_executed = result
2700 .signals
2701 .iter()
2702 .any(|s| matches!(s.direction, SignalDirection::ScaleIn) && s.executed);
2703 assert!(
2704 scale_in_executed,
2705 "scale-in signal was not executed — test is inconclusive"
2706 );
2707
2708 let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2709 let expected = config.initial_capital + sum_pnl;
2710 assert!(
2711 (result.final_equity - expected).abs() < 1e-4,
2712 "accounting invariant failed: final_equity={:.6}, expected={:.6}",
2713 result.final_equity,
2714 expected
2715 );
2716 }
2717
2718 #[derive(Clone)]
2726 struct BracketLongStopLossStrategy {
2727 stop_pct: f64,
2728 }
2729 impl Strategy for BracketLongStopLossStrategy {
2730 fn name(&self) -> &str {
2731 "BracketLongStopLoss"
2732 }
2733 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2734 vec![]
2735 }
2736 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2737 if ctx.index == 0 && !ctx.has_position() {
2738 Signal::long(ctx.timestamp(), ctx.close()).stop_loss(self.stop_pct)
2739 } else {
2740 Signal::hold()
2741 }
2742 }
2743 }
2744
2745 #[derive(Clone)]
2747 struct BracketShortStopLossStrategy {
2748 stop_pct: f64,
2749 }
2750 impl Strategy for BracketShortStopLossStrategy {
2751 fn name(&self) -> &str {
2752 "BracketShortStopLoss"
2753 }
2754 fn required_indicators(&self) -> Vec<(String, Indicator)> {
2755 vec![]
2756 }
2757 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2758 if ctx.index == 0 && !ctx.has_position() {
2759 Signal::short(ctx.timestamp(), ctx.close()).stop_loss(self.stop_pct)
2760 } else {
2761 Signal::hold()
2762 }
2763 }
2764 }
2765
2766 #[test]
2769 fn test_per_trade_stop_loss_triggers_when_set() {
2770 let prices = [100.0, 100.0, 80.0, 80.0];
2774 let mut candles = make_candles(&prices);
2775 candles[2].low = 79.2;
2776
2777 let config = BacktestConfig::builder()
2778 .initial_capital(10_000.0)
2779 .commission_pct(0.0)
2780 .slippage_pct(0.0)
2781 .close_at_end(false)
2782 .build()
2783 .unwrap();
2784
2785 let engine = BacktestEngine::new(config);
2786 let result = engine
2787 .run(
2788 "TEST",
2789 &candles,
2790 BracketLongStopLossStrategy { stop_pct: 0.05 },
2791 )
2792 .unwrap();
2793
2794 assert!(
2795 !result.trades.is_empty(),
2796 "stop-loss should have closed the position"
2797 );
2798 assert!(
2799 result.trades[0].pnl < 0.0,
2800 "stop-loss trade should be a loss"
2801 );
2802 }
2803
2804 #[test]
2805 fn test_per_trade_stop_loss_overrides_config_none() {
2806 let prices = [100.0, 100.0, 80.0, 80.0];
2808 let mut candles = make_candles(&prices);
2809 candles[2].low = 79.2;
2810
2811 let config = BacktestConfig::builder()
2812 .initial_capital(10_000.0)
2813 .commission_pct(0.0)
2814 .slippage_pct(0.0)
2815 .close_at_end(false)
2816 .build()
2817 .unwrap();
2818
2819 assert!(
2820 config.stop_loss_pct.is_none(),
2821 "config must not have a default stop-loss for this test"
2822 );
2823
2824 let engine = BacktestEngine::new(config);
2825 let result = engine
2826 .run(
2827 "TEST",
2828 &candles,
2829 BracketLongStopLossStrategy { stop_pct: 0.05 },
2830 )
2831 .unwrap();
2832
2833 assert!(
2834 !result.trades.is_empty(),
2835 "per-trade bracket stop should fire even when config stop_loss_pct is None"
2836 );
2837 }
2838
2839 #[test]
2840 fn test_per_trade_stop_loss_overrides_config_looser() {
2841 let prices = [100.0, 100.0, 97.0, 97.0];
2849 let mut candles = make_candles(&prices);
2850 candles[2].low = 93.0; let config = BacktestConfig::builder()
2853 .initial_capital(10_000.0)
2854 .commission_pct(0.0)
2855 .slippage_pct(0.0)
2856 .stop_loss_pct(0.20) .close_at_end(false)
2858 .build()
2859 .unwrap();
2860
2861 let engine = BacktestEngine::new(config);
2862 let result = engine
2863 .run(
2864 "TEST",
2865 &candles,
2866 BracketLongStopLossStrategy { stop_pct: 0.05 },
2867 )
2868 .unwrap();
2869
2870 assert!(!result.trades.is_empty());
2871 let trade = &result.trades[0];
2872 assert!(
2874 trade.exit_price > 90.0,
2875 "expected exit near 5% bracket stop ($95), got {:.2}",
2876 trade.exit_price
2877 );
2878 }
2879
2880 #[test]
2883 fn test_per_trade_short_stop_loss_triggers_when_set() {
2884 let prices = [100.0, 100.0, 112.0, 112.0];
2888 let mut candles = make_candles(&prices);
2889 candles[2].high = 112.5;
2890
2891 let config = BacktestConfig::builder()
2892 .initial_capital(10_000.0)
2893 .commission_pct(0.0)
2894 .slippage_pct(0.0)
2895 .allow_short(true)
2896 .close_at_end(false)
2897 .build()
2898 .unwrap();
2899
2900 let engine = BacktestEngine::new(config);
2901 let result = engine
2902 .run(
2903 "TEST",
2904 &candles,
2905 BracketShortStopLossStrategy { stop_pct: 0.05 },
2906 )
2907 .unwrap();
2908
2909 assert!(
2910 !result.trades.is_empty(),
2911 "short stop-loss should have closed the position"
2912 );
2913 assert!(
2914 result.trades[0].pnl < 0.0,
2915 "short stop-loss trade should be a loss (price rose against the short)"
2916 );
2917 }
2918
2919 #[test]
2920 fn test_per_trade_short_stop_loss_overrides_config_none() {
2921 let prices = [100.0, 100.0, 112.0, 112.0];
2923 let mut candles = make_candles(&prices);
2924 candles[2].high = 112.5;
2925
2926 let config = BacktestConfig::builder()
2927 .initial_capital(10_000.0)
2928 .commission_pct(0.0)
2929 .slippage_pct(0.0)
2930 .allow_short(true)
2931 .close_at_end(false)
2932 .build()
2933 .unwrap();
2934
2935 assert!(config.stop_loss_pct.is_none());
2936
2937 let engine = BacktestEngine::new(config);
2938 let result = engine
2939 .run(
2940 "TEST",
2941 &candles,
2942 BracketShortStopLossStrategy { stop_pct: 0.05 },
2943 )
2944 .unwrap();
2945
2946 assert!(
2947 !result.trades.is_empty(),
2948 "per-trade bracket stop should fire for shorts even with no config stop-loss"
2949 );
2950 }
2951
2952 #[test]
2953 fn test_per_trade_short_stop_loss_overrides_config_looser() {
2954 let prices = [100.0, 100.0, 103.0, 103.0];
2961 let mut candles = make_candles(&prices);
2962 candles[2].high = 108.0; let config = BacktestConfig::builder()
2965 .initial_capital(10_000.0)
2966 .commission_pct(0.0)
2967 .slippage_pct(0.0)
2968 .allow_short(true)
2969 .stop_loss_pct(0.20) .close_at_end(false)
2971 .build()
2972 .unwrap();
2973
2974 let engine = BacktestEngine::new(config);
2975 let result = engine
2976 .run(
2977 "TEST",
2978 &candles,
2979 BracketShortStopLossStrategy { stop_pct: 0.05 },
2980 )
2981 .unwrap();
2982
2983 assert!(!result.trades.is_empty());
2984 let trade = &result.trades[0];
2985 assert!(
2987 trade.exit_price < 115.0,
2988 "expected exit near 5% bracket stop ($105), got {:.2}",
2989 trade.exit_price
2990 );
2991 }
2992
2993 #[derive(Clone)]
2997 struct BracketLongTakeProfitStrategy {
2998 tp_pct: f64,
2999 }
3000 impl Strategy for BracketLongTakeProfitStrategy {
3001 fn name(&self) -> &str {
3002 "BracketLongTakeProfit"
3003 }
3004 fn required_indicators(&self) -> Vec<(String, Indicator)> {
3005 vec![]
3006 }
3007 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3008 if ctx.index == 0 && !ctx.has_position() {
3009 Signal::long(ctx.timestamp(), ctx.close()).take_profit(self.tp_pct)
3010 } else {
3011 Signal::hold()
3012 }
3013 }
3014 }
3015
3016 #[derive(Clone)]
3018 struct BracketShortTakeProfitStrategy {
3019 tp_pct: f64,
3020 }
3021 impl Strategy for BracketShortTakeProfitStrategy {
3022 fn name(&self) -> &str {
3023 "BracketShortTakeProfit"
3024 }
3025 fn required_indicators(&self) -> Vec<(String, Indicator)> {
3026 vec![]
3027 }
3028 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3029 if ctx.index == 0 && !ctx.has_position() {
3030 Signal::short(ctx.timestamp(), ctx.close()).take_profit(self.tp_pct)
3031 } else {
3032 Signal::hold()
3033 }
3034 }
3035 }
3036
3037 #[test]
3038 fn test_per_trade_take_profit_triggers() {
3039 let prices = [100.0, 100.0, 120.0, 120.0];
3043 let mut candles = make_candles(&prices);
3044 candles[2].high = 121.2;
3045
3046 let config = BacktestConfig::builder()
3047 .initial_capital(10_000.0)
3048 .commission_pct(0.0)
3049 .slippage_pct(0.0)
3050 .close_at_end(false)
3051 .build()
3052 .unwrap();
3053
3054 let engine = BacktestEngine::new(config);
3055 let result = engine
3056 .run(
3057 "TEST",
3058 &candles,
3059 BracketLongTakeProfitStrategy { tp_pct: 0.10 },
3060 )
3061 .unwrap();
3062
3063 assert!(
3064 !result.trades.is_empty(),
3065 "long take-profit should have fired"
3066 );
3067 assert!(
3068 result.trades[0].pnl > 0.0,
3069 "long take-profit trade should be profitable"
3070 );
3071 }
3072
3073 #[test]
3074 fn test_per_trade_short_take_profit_triggers() {
3075 let prices = [100.0, 100.0, 85.0, 85.0];
3079 let mut candles = make_candles(&prices);
3080 candles[2].low = 84.15;
3081
3082 let config = BacktestConfig::builder()
3083 .initial_capital(10_000.0)
3084 .commission_pct(0.0)
3085 .slippage_pct(0.0)
3086 .allow_short(true)
3087 .close_at_end(false)
3088 .build()
3089 .unwrap();
3090
3091 let engine = BacktestEngine::new(config);
3092 let result = engine
3093 .run(
3094 "TEST",
3095 &candles,
3096 BracketShortTakeProfitStrategy { tp_pct: 0.10 },
3097 )
3098 .unwrap();
3099
3100 assert!(
3101 !result.trades.is_empty(),
3102 "short take-profit should have fired"
3103 );
3104 assert!(
3105 result.trades[0].pnl > 0.0,
3106 "short take-profit trade should be profitable (price fell in favor of short)"
3107 );
3108 }
3109
3110 #[derive(Clone)]
3114 struct BracketLongTrailingStopStrategy {
3115 trail_pct: f64,
3116 }
3117 impl Strategy for BracketLongTrailingStopStrategy {
3118 fn name(&self) -> &str {
3119 "BracketLongTrailingStop"
3120 }
3121 fn required_indicators(&self) -> Vec<(String, Indicator)> {
3122 vec![]
3123 }
3124 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3125 if ctx.index == 0 && !ctx.has_position() {
3126 Signal::long(ctx.timestamp(), ctx.close()).trailing_stop(self.trail_pct)
3127 } else {
3128 Signal::hold()
3129 }
3130 }
3131 }
3132
3133 #[derive(Clone)]
3135 struct BracketShortTrailingStopStrategy {
3136 trail_pct: f64,
3137 }
3138 impl Strategy for BracketShortTrailingStopStrategy {
3139 fn name(&self) -> &str {
3140 "BracketShortTrailingStop"
3141 }
3142 fn required_indicators(&self) -> Vec<(String, Indicator)> {
3143 vec![]
3144 }
3145 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3146 if ctx.index == 0 && !ctx.has_position() {
3147 Signal::short(ctx.timestamp(), ctx.close()).trailing_stop(self.trail_pct)
3148 } else {
3149 Signal::hold()
3150 }
3151 }
3152 }
3153
3154 #[test]
3155 fn test_per_trade_trailing_stop_triggers() {
3156 let prices = [100.0, 100.0, 120.0, 110.0, 110.0];
3163 let mut candles = make_candles(&prices);
3164 candles[2].high = 121.0;
3165 candles[3].low = 108.9; let config = BacktestConfig::builder()
3168 .initial_capital(10_000.0)
3169 .commission_pct(0.0)
3170 .slippage_pct(0.0)
3171 .close_at_end(false)
3172 .build()
3173 .unwrap();
3174
3175 let engine = BacktestEngine::new(config);
3176 let result = engine
3177 .run(
3178 "TEST",
3179 &candles,
3180 BracketLongTrailingStopStrategy { trail_pct: 0.05 },
3181 )
3182 .unwrap();
3183
3184 assert!(
3185 !result.trades.is_empty(),
3186 "long trailing stop should have fired"
3187 );
3188 assert!(
3189 result.trades[0].pnl > 0.0,
3190 "long trailing stop should exit in profit (entry $100, exit near $110)"
3191 );
3192 }
3193
3194 #[test]
3195 fn test_per_trade_short_trailing_stop_triggers() {
3196 let prices = [100.0, 100.0, 80.0, 88.0, 88.0];
3203 let mut candles = make_candles(&prices);
3204 candles[2].low = 79.2; let config = BacktestConfig::builder()
3207 .initial_capital(10_000.0)
3208 .commission_pct(0.0)
3209 .slippage_pct(0.0)
3210 .allow_short(true)
3211 .close_at_end(false)
3212 .build()
3213 .unwrap();
3214
3215 let engine = BacktestEngine::new(config);
3216 let result = engine
3217 .run(
3218 "TEST",
3219 &candles,
3220 BracketShortTrailingStopStrategy { trail_pct: 0.05 },
3221 )
3222 .unwrap();
3223
3224 assert!(
3225 !result.trades.is_empty(),
3226 "short trailing stop should have fired"
3227 );
3228 assert!(
3229 result.trades[0].pnl > 0.0,
3230 "short trailing stop should exit in profit (entry $100, exit near $88)"
3231 );
3232 }
3233}