Skip to main content

finance_query/backtesting/
engine.rs

1//! Backtest execution engine.
2
3use 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
17/// Backtest execution engine.
18///
19/// Handles indicator pre-computation, position management, and trade execution.
20pub struct BacktestEngine {
21    config: BacktestConfig,
22}
23
24/// Pre-compute a set of indicators on the given candles.
25///
26/// Accepts a list of `(key, Indicator)` pairs as returned by
27/// [`Condition::required_indicators`] and returns a map from key to
28/// the computed `Vec<Option<f64>>`. Multi-output indicators (MACD,
29/// Bollinger, Supertrend, etc.) insert additional derived keys,
30/// ignoring the supplied key for those variants.
31pub(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            // === OSCILLATORS ===
166            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            // === TREND INDICATORS ===
217            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            // === VOLATILITY ===
263            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            // === VOLUME INDICATORS ===
293            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            // === POWER/STRENGTH INDICATORS ===
312            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    /// Create a new backtest engine with the given configuration
330    pub fn new(config: BacktestConfig) -> Self {
331        Self { config }
332    }
333
334    /// Run a backtest with the given strategy on historical candle data.
335    ///
336    /// Dividend income is not included. Use [`run_with_dividends`] to account
337    /// for dividend payments during holding periods.
338    ///
339    /// [`run_with_dividends`]: Self::run_with_dividends
340    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    /// Run a backtest and credit dividend income for any dividends paid while a
350    /// position is open.
351    ///
352    /// `dividends` should be sorted by timestamp (ascending). The engine credits
353    /// each dividend whose ex-date falls on or before the current candle bar.
354    /// When [`BacktestConfig::reinvest_dividends`] is `true`, the income is also
355    /// used to notionally purchase additional shares at the ex-date close price.
356    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    // ── Core simulation ───────────────────────────────────────────────────────
367
368    /// Internal simulation core. All public `run*` methods delegate here.
369    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        // Validate dividend ordering — simulation correctness requires ascending timestamps.
382        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        // Pre-compute all required indicators (base timeframe + HTF stretched arrays)
393        let mut indicators = self.compute_indicators(candles, &strategy)?;
394        indicators.extend(self.compute_htf_indicators(candles, &strategy)?);
395
396        // Initialize state
397        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        // High-water mark for the trailing stop: tracks peak price (longs) or
405        // trough price (shorts) since entry. Reset to None when no position is open.
406        let mut hwm: Option<f64> = None;
407
408        // Dividend processing pointer: dividends must be sorted by timestamp.
409        // We advance this index forward as the simulation progresses in time.
410        let mut div_idx: usize = 0;
411
412        // Pending limit / stop orders placed by the strategy.
413        // Checked each bar before strategy signal evaluation.
414        let mut pending_orders: Vec<PendingOrder> = Vec::new();
415
416        // Main simulation loop
417        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            // Credit dividend income for any dividends ex-dated on or before this bar.
431            self.credit_dividends(&mut position, candle, dividends, &mut div_idx);
432
433            // Check stop-loss / take-profit / trailing-stop on existing position.
434            // The signal carries the intrabar fill price (stop/TP level with gap guard),
435            // so we execute on the current bar at that price — no next-bar deferral needed.
436            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; // Reset HWM when position is closed
461                    continue; // Skip strategy signal this bar
462                }
463            }
464
465            // ── Pending limit / stop orders ───────────────────────────────
466            // Check queued orders against the current bar before evaluating
467            // the strategy. This preserves the realistic ordering where a
468            // pending order placed on bar N can first fill on bar N+1.
469            //
470            // `retain_mut` preserves FIFO queue order (critical for correct
471            // order matching) while avoiding the temporary index vec and the
472            // ordering-destroying `swap_remove` used previously.
473            let mut filled_this_bar = false;
474            pending_orders.retain_mut(|order| {
475                // Expire orders past their GTC lifetime.
476                if let Some(exp) = order.expires_in_bars
477                    && i >= order.created_bar + exp
478                {
479                    return false; // drop
480                }
481
482                // Cannot fill into an existing position, or if another
483                // pending order already filled on this bar.
484                if position.is_some() || filled_this_bar {
485                    return true; // keep
486                }
487
488                // Short orders require allow_short.
489                if matches!(order.signal.direction, SignalDirection::Short)
490                    && !self.config.allow_short
491                {
492                    return true; // keep (config could change via re-run)
493                }
494
495                // BuyStopLimit state machine: if the stop price is triggered
496                // but the bar opens above the limit price the order can't fill
497                // this bar. In reality the stop has already "activated" the
498                // order, which now rests in the book as a plain limit order.
499                // Downgrade so subsequent bars treat it as a BuyLimit.
500                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) // triggered, limit not reached
508                        } else {
509                            None // triggered and fillable — handled below
510                        }
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; // keep as plain BuyLimit; skip fill this bar
519                }
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; // drop — order filled
544                    }
545                }
546
547                true // keep unfilled order
548            });
549
550            // Skip strategy signals during warmup period
551            if i < warmup.saturating_sub(1) {
552                continue;
553            }
554
555            // Build strategy context
556            let ctx = StrategyContext {
557                candles: &candles[..=i],
558                index: i,
559                position: position.as_ref(),
560                equity,
561                indicators: &indicators,
562            };
563
564            // Get strategy signal
565            let signal = strategy.on_candle(&ctx);
566
567            // Skip hold signals
568            if signal.is_hold() {
569                continue;
570            }
571
572            // Check signal strength threshold
573            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            // Market orders execute on next bar to avoid same-bar close-fill
587            // bias.  Limit and stop entry orders are queued as PendingOrders
588            // and fill on a subsequent bar when the price level is reached.
589            // Non-Market directions other than Long/Short (Exit, ScaleIn,
590            // ScaleOut) are always treated as market orders.
591            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                    // Reject short orders immediately if shorts are disabled —
611                    // no point burning queue space for orders that can never fill.
612                    if matches!(signal.direction, SignalDirection::Short)
613                        && !self.config.allow_short
614                    {
615                        false
616                    } else {
617                        // Queue as a pending order; the signal record below will
618                        // show executed: false (order placed but not yet filled).
619                        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                    // Non-market Exit / ScaleIn / ScaleOut — execute as market.
630                    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            // Reset the trailing-stop HWM whenever a position is closed
655            if executed && position.is_none() {
656                hwm = None;
657
658                // Re-evaluate strategy on the same bar after an exit so that
659                // a crossover that simultaneously closes one side and triggers
660                // the opposite entry is not lost.
661                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        // Close any open position at end if configured
708        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            // Tax on buy orders only: short covers are buys
722            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        // Final equity
748        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        // Calculate metrics
768        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        // Build diagnostics for likely misconfigurations
783        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, // Populated by run_with_benchmark when a benchmark is supplied
827            diagnostics,
828        })
829    }
830
831    /// Run a backtest and compare against a benchmark, optionally crediting dividends.
832    ///
833    /// The result's `benchmark` field is populated with buy-and-hold comparison
834    /// metrics including alpha, beta, and information ratio. The benchmark candle
835    /// slice should cover the same time period as `candles` but need not be the
836    /// same length.
837    ///
838    /// `dividends` must be sorted ascending by timestamp. Pass `&[]` to omit
839    /// dividend processing.
840    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    /// Pre-compute all indicators required by the strategy
862    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    /// Pre-compute stretched HTF indicator arrays for all `HtfCondition`s in the strategy.
871    ///
872    /// For each unique `(interval, utc_offset_secs)` pair:
873    /// - Resample the full candle history to the HTF interval
874    /// - Compute required indicators on the resampled candles
875    /// - Build a mapping from base timeframe to HTF indices
876    /// - Stretch each HTF indicator value to base timeframe length
877    /// - Store the stretched array in the result map under the `htf_key`
878    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        // Group specs by (interval, utc_offset_secs) — one resample per unique pair.
897        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            // De-duplicate indicators by base_key to avoid recomputing MACD/Bollinger
912            // etc. when multiple output keys (line, signal, histogram) are requested.
913            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    // ── Simulation helpers ────────────────────────────────────────────────────
939
940    /// Compute current equity, track peak/drawdown, and append an equity curve point.
941    ///
942    /// Returns the updated equity value.
943    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    /// Credit any dividends whose ex-date falls on or before the current candle.
971    ///
972    /// Advances `div_idx` forward so each dividend is credited exactly once.
973    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    /// Check if stop-loss, take-profit, or trailing stop should trigger intrabar.
995    ///
996    /// Uses `candle.low` / `candle.high` to detect breaches that occur during the
997    /// bar, not just at the close.  Returns an exit [`Signal`] whose `price` field
998    /// is the computed fill price (stop/TP level with a gap-guard: if the bar opens
999    /// through the level the open price is used instead so the fill is never better
1000    /// than the market).
1001    ///
1002    /// `hwm` is the intrabar high-water mark for longs (`candle.high` is
1003    /// incorporated each bar) or the low-water mark for shorts.
1004    ///
1005    /// # Exit Priority
1006    ///
1007    /// When multiple exit conditions are satisfied on the same bar, the first
1008    /// one checked wins: **stop-loss → take-profit → trailing stop**.
1009    ///
1010    /// In reality, the intrabar order of events is unknowable from OHLCV data
1011    /// alone — a bar could open through the take-profit level before touching
1012    /// the stop-loss, or vice versa.  The fixed priority errs on the side of
1013    /// pessimism (stop-loss before take-profit) for conservative simulation.
1014    /// Strategies with both SL and TP set should be aware of this ordering
1015    /// when both levels are close together relative to typical bar ranges.
1016    fn check_sl_tp(
1017        &self,
1018        position: &Position,
1019        candle: &Candle,
1020        hwm: Option<f64>,
1021    ) -> Option<Signal> {
1022        // Per-trade bracket overrides take precedence over config-level defaults.
1023        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        // Stop-loss — intrabar breach via low (long) or high (short)
1032        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                // if the bar already opened through the stop level, fill
1045                // at the open (slippage/gap) rather than the stop price.
1046                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        // Take-profit — intrabar breach via high (long) or low (short)
1060        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                // Gap guard: a gap-up open past TP gives a better fill at the open.
1073                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        // Trailing stop — checked after SL/TP so explicit levels take priority.
1087        //    `hwm` is already updated to the intrabar extreme before this call.
1088        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    /// Execute a signal, modifying position and cash
1126    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; // Already have a position
1138                }
1139                self.open_position(position, cash, candle, signal, true)
1140            }
1141            SignalDirection::Short => {
1142                if position.is_some() {
1143                    return false; // Already have a position
1144                }
1145                if !self.config.allow_short {
1146                    return false; // Shorts not allowed
1147                }
1148                self.open_position(position, cash, candle, signal, false)
1149            }
1150            SignalDirection::Exit => {
1151                if position.is_none() {
1152                    return false; // No position to exit
1153                }
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    /// Add to an existing open position (pyramid / scale in).
1165    ///
1166    /// Allocates `signal.scale_fraction` of current portfolio equity to additional
1167    /// shares at the next-bar fill price. Updates the position's weighted-average
1168    /// entry price. No-op when no position is open.
1169    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        // Allocate `fraction` of current portfolio equity to the additional tranche.
1191        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; // Not enough cash
1215        }
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    /// Partially or fully close an existing open position (scale out).
1228    ///
1229    /// Closes `signal.scale_fraction` of the current position quantity at the
1230    /// next-bar fill price.  A fraction of `1.0` is equivalent to a full
1231    /// [`Signal::exit`] and delegates to [`close_position`](Self::close_position).
1232    /// No-op when no position is open.
1233    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        // Full close — delegate to the standard exit path so all bookkeeping
1247        // (cash credit, HWM reset, re-evaluation) is handled identically.
1248        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        // `commission` and `exit_tax` here are the exit-side cash flows only.
1276        // `trade.commission` / `trade.transaction_tax` also include the proportional
1277        // entry cost slice (for P&L reporting), but those were already debited from
1278        // cash at entry time and must not be debited again here.
1279        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    /// Open a new position at `candle.open` (market fill).
1289    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    /// Open a new position at an explicit fill price.
1301    ///
1302    /// Used for pending limit/stop order fills where the computed order price
1303    /// (with gap guard) is the fill price rather than the next bar's open.
1304    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; // Not enough capital
1319        }
1320
1321        let entry_value = entry_price * quantity;
1322        let commission = self.config.calculate_commission(quantity, entry_price);
1323        // Tax on buy orders only: long entries are buys
1324        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; // Not enough capital including commission and tax
1329            }
1330        } else if commission > *cash {
1331            return false; // Not enough cash to pay entry commission
1332        }
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    /// Close an existing position at the next bar's open (used for strategy-signal exits).
1359    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    /// Close an existing position at an explicit `fill_price`.
1371    ///
1372    /// Used for intrabar SL/TP/trailing-stop exits where the fill price is the
1373    /// computed stop/TP level (with gap guard) rather than the next bar's open.
1374    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        // Tax on buy orders only: short covers are buys
1394        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
1417// ── Shared helpers ─────────────────────────────────────────────────────────────
1418
1419/// Update the trailing-stop high-water mark (peak for longs, trough for shorts).
1420///
1421/// Uses the candle's intrabar extreme (`high` for longs, `low` for shorts) so
1422/// that the trailing stop correctly reflects the best price reached during the bar,
1423/// not just the close.
1424///
1425/// Cleared to `None` when no position is open so it resets on next entry.
1426/// Also used by the portfolio engine.
1427pub(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) // trough for shorts
1446                }
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
1488/// Compute benchmark comparison metrics for a completed backtest.
1489///
1490/// `symbol_candles` are the candles for the backtested symbol (used to derive
1491/// its buy-and-hold return). `benchmark_candles` are the benchmark's candles.
1492/// `equity_curve` is used to derive strategy periodic returns for beta/IR.
1493fn 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    // Buy-and-hold returns from first to last close
1502    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    // CAPM alpha on the same aligned sample used for beta/IR.
1554    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    // Jensen's Alpha: excess strategy return over what CAPM predicts given beta.
1557    // Both strategy_ann and bench_ann are in percentage form (×100), so rf_ann is scaled
1558    // to match before applying the CAPM formula: α = R_s - R_f - β(R_b - R_f).
1559    let rf_ann = risk_free_rate * 100.0;
1560    let alpha = strategy_ann - rf_ann - beta * (bench_ann - rf_ann);
1561
1562    // Information ratio: (excess returns mean / tracking error) * sqrt(bars_per_year)
1563    // Uses sample standard deviation (n-1) for consistency with Sharpe/Sortino.
1564    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        // Sample variance (n-1)
1573        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
1594/// Buy-and-hold return from first to last candle close (percentage).
1595fn 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
1604/// Annualised return from periodic returns (fractional, e.g. 0.01 for 1%).
1605fn 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
1621/// Compute beta of `strategy_returns` relative to `benchmark_returns`.
1622///
1623/// Beta = Cov(strategy, benchmark) / Var(benchmark).
1624/// Uses sample covariance and variance (divides by n-1) to match the `risk`
1625/// module and standard financial convention. Returns 0.0 when benchmark
1626/// variance is zero or there are fewer than 2 observations.
1627fn 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    // Sample covariance and variance (n-1)
1637    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        // Price trends up then down - should trigger crossover signals
1737        let mut prices = vec![100.0; 30];
1738        // Make fast SMA cross above slow SMA around bar 15
1739        for (i, price) in prices.iter_mut().enumerate().take(25).skip(15) {
1740            *price = 100.0 + (i - 15) as f64 * 2.0;
1741        }
1742        // Then cross back down
1743        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        // Price drops significantly after entry
1767        let mut prices = vec![100.0; 20];
1768        // Trend up to trigger long entry
1769        for (i, price) in prices.iter_mut().enumerate().take(15).skip(10) {
1770            *price = 100.0 + (i - 10) as f64 * 2.0;
1771        }
1772        // Then crash
1773        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) // 5% stop loss
1781            .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        // Should have triggered stop-loss
1791        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        // May or may not trigger depending on exact timing
1803        // The important thing is the engine doesn't crash
1804        assert!(!result.equity_curve.is_empty());
1805    }
1806
1807    #[test]
1808    fn test_trailing_stop() {
1809        // Price rises to 120, then drops 10%+ → trailing stop should fire
1810        let mut prices: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
1811        // Peak is 119; now drop past 10% from peak (< 107.1)
1812        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        // Not guaranteed to fire given the specific crossover timing, but engine must not crash
1839        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]); // Only 3 candles
1846        let config = BacktestConfig::default();
1847        let engine = BacktestEngine::new(config);
1848        let strategy = SmaCrossover::new(10, 20); // Needs at least 21 candles
1849
1850        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        // When risk_free_rate = 0, alpha should equal the simplified formula.
1857        // When risk_free_rate > 0, the CAPM adjustment should reduce alpha.
1858        let prices: Vec<f64> = (0..60).map(|i| 100.0 + i as f64).collect();
1859        let candles = make_candles(&prices);
1860
1861        // Run once with rf=0 and once with rf=0.05, compare benchmark metrics
1862        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        // Use same candles for both strategy and benchmark to get beta ≈ 1
1879        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        // With identical strategy and benchmark (beta = 1), Jensen's alpha ≈ 0 always.
1904        // Both should be close to 0, but importantly they should differ when rf != 0.
1905        // This test ensures the formula uses rf — it catches the old bug where rf was ignored.
1906        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        // With beta ≈ 1 and rf=5%, CAPM alpha = R_s - 5% - 1*(R_b - 5%) = R_s - R_b.
1913        // Same formula result as rf=0 when beta=1; but the formula path is exercised.
1914        // The key check: alpha is the same sign in both (both near-zero).
1915        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        // Rising price series — long enough for SmaCrossover(3,6) to trade
1930        let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
1931        let candles = make_candles(&prices);
1932
1933        // A single dividend ex-dated mid-series
1934        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                &dividends,
1954                "BENCH",
1955                &candles,
1956            )
1957            .unwrap();
1958
1959        // Dividend income is credited only while a position is open.
1960        // If no trade happened to be open on bar 15 the income is zero;
1961        // either way the engine must not panic and the benchmark must be set.
1962        assert!(result.benchmark.is_some());
1963        let total_div: f64 = result.trades.iter().map(|t| t.dividend_income).sum();
1964        // total_dividend_income is non-negative (either credited or not, never negative)
1965        assert!(total_div >= 0.0);
1966    }
1967
1968    /// The fundamental invariant: final cash (when no position is open) must equal
1969    /// initial_capital plus the sum of all realized trade P&Ls.  This guards against
1970    /// the double-counting of commissions that existed before the fix.
1971    #[test]
1972    fn test_commission_accounting_invariant() {
1973        // Steadily rising prices so SmaCrossover(3,6) will definitely enter and exit.
1974        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        // Use both flat AND percentage commission to expose any double-counting.
1986        let config = BacktestConfig::builder()
1987            .initial_capital(10_000.0)
1988            .commission(5.0) // $5 flat fee per trade
1989            .commission_pct(0.001) // + 0.1% per trade
1990            .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        // When all positions are closed, cash == initial_capital + sum(trade pnls)
2001        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        // Intentionally unsorted
2018        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), &dividends);
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, &dividends)
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, &dividends)
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    /// Build a candle with explicit OHLC values (not derived from a single price).
2128    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    // ── Intrabar stop / take-profit tests ────────────────────────────────────
2141
2142    /// A strategy that opens a long on the first bar and holds forever.
2143    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        // Bar 0: open=100, high=101, low=99, close=100 — entry signal fires, filled on bar 1.
2163        // Bar 1: open=100, high=100, low=100, close=100 — entry fills at 100.
2164        // Bar 2: open=99, high=99, low=90, close=94 — low(90) < stop(95); fill at min(open=99, stop=95) = 95.
2165        // With close-only detection, stop would not trigger here (close=94 > stop=95*... wait)
2166        // Actually close=94 < 95 so close-only WOULD trigger, but on the NEXT bar's open (bar 3).
2167        // With intrabar detection, it triggers on bar 2 itself and fills at stop_price=95.
2168        let candles = vec![
2169            make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0), // bar 0: entry signal
2170            make_candle_ohlc(1, 100.0, 102.0, 99.0, 100.0), // bar 1: entry fill at 100
2171            make_candle_ohlc(2, 99.0, 99.0, 90.0, 94.0),    // bar 2: low=90 < stop=95 → fill at 95
2172            make_candle_ohlc(3, 94.0, 95.0, 93.0, 94.0), // bar 3: would be next-bar fill in old code
2173        ];
2174
2175        let config = BacktestConfig::builder()
2176            .initial_capital(10_000.0)
2177            .stop_loss_pct(0.05) // 5% → stop at 100 * 0.95 = 95
2178            .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        // Fill must be at the stop price (95.0), not at bar 3's open (94.0).
2197        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        // Exit must be recorded on bar 2's timestamp, not bar 3.
2203        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        // Bar 1: entry at open=100.
2212        // Bar 2: open=92 (already below stop=95) → gap guard → fill at open=92.
2213        let candles = vec![
2214            make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0), // bar 0: entry signal
2215            make_candle_ohlc(1, 100.0, 100.0, 100.0, 100.0), // bar 1: entry fill at 100
2216            make_candle_ohlc(2, 92.0, 92.0, 90.0, 90.0),    // bar 2: gap below stop → fill at 92
2217        ];
2218
2219        let config = BacktestConfig::builder()
2220            .initial_capital(10_000.0)
2221            .stop_loss_pct(0.05) // stop at 95
2222            .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        // Gap-down: open (92) < stop (95) → fill at open.
2243        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        // Bar 1: entry at 100.
2253        // Bar 2: high=112 > tp=110 → fill at 110 (not next bar's open).
2254        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), // entry fill
2257            make_candle_ohlc(2, 105.0, 112.0, 104.0, 111.0), // high > tp → fill at 110
2258            make_candle_ohlc(3, 112.0, 113.0, 111.0, 112.0), // would be next-bar fill in old code
2259        ];
2260
2261        let config = BacktestConfig::builder()
2262            .initial_capital(10_000.0)
2263            .take_profit_pct(0.10) // TP at 100 * 1.10 = 110
2264            .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    // ── Position scaling integration tests ───────────────────────────────────
2296
2297    /// Strategy: enter long on bar 0, scale in on bar 1, exit on bar 2.
2298    #[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    /// Strategy: enter long on bar 0, scale out 50% on bar 1, exit remainder on bar 2.
2321    #[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        // 4 candles: entry bar 0, fill bar 1, scale-in bar 1, fill bar 2, exit bar 2, fill bar 3
2346        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        // Exactly one closed trade (from the final exit)
2361        assert_eq!(result.trades.len(), 1);
2362        let trade = &result.trades[0];
2363        assert!(!trade.is_partial);
2364        // Position was scaled in, so quantity > initial allocation
2365        assert!(trade.quantity > 0.0);
2366        // Strategy ran; equity curve has entries
2367        assert!(!result.equity_curve.is_empty());
2368        // Scale-in signal recorded
2369        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        // Two trades: partial close + final close
2394        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        /// Strategy: enter on bar 0, scale_out(1.0) on bar 1 — should fully close.
2409        #[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        // Full scale_out(1.0) should close position, leaving no open position
2442        assert!(result_scale.open_position.is_none());
2443        assert!(!result_scale.trades.is_empty());
2444
2445        // Compare against a plain Exit strategy for identical P&L
2446        #[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        /// Strategy: scale_in on bar 0 (no position open) — should be ignored.
2478        #[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        /// Strategy: scale_out on bar 0 (no position open) — should be ignored.
2515        #[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        // Tests for issue where entry_quantity was not updated after scale_in,
2552        // causing close_with_tax to use the original (too-small) entry_quantity and
2553        // overstate gross PnL.
2554        //
2555        // Setup:
2556        //   bar 0 – long signal, fill bar 1 @ $100, buy 10 shares (position_size_pct=0.1)
2557        //   bar 1 – scale_in(0.5) signal, fill bar 2 @ $100, buy ~50% equity more
2558        //   bar 2 – exit signal, fill bar 3 @ $110
2559        //   No commission/slippage so PnL is pure price × qty arithmetic.
2560        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) // buy 10% of cash = $100 / $100 = 1 share initially
2566            .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        // Confirm the scale-in fired.
2577        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        // With no commission/slippage:
2587        //   trade.pnl  == (exit_price - entry_price) × qty_closed   (per-share basis)
2588        //              == ($110 − $100) × qty_closed
2589        // And final_equity == initial_capital + sum(pnl)
2590        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        // Verifies: final_equity == initial_capital + sum(trade.pnl) after a
2603        // scale-in followed by a full exit.  Uses position_size_pct=0.2 so that
2604        // 80% of cash remains after the initial entry, giving the scale-in
2605        // (fraction=0.5 of equity) enough room to execute.
2606        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) // 20% per entry → 80% cash left for scale-in
2612            .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        // Confirm the scale-in actually fired (scale_in signal recorded as executed).
2622        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    // ── Per-trade bracket orders (Phase 11) ──────────────────────────────────
2642
2643    // Each bracket type is tested for both Long and Short sides.
2644    // Long:  SL fires on low breach; TP fires on high breach; trail tracks HWM (peak).
2645    // Short: SL fires on high breach; TP fires on low breach; trail tracks LWM (trough).
2646
2647    /// Enters a long position on bar 0 with a per-trade stop-loss.
2648    #[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    /// Enters a short position on bar 0 with a per-trade stop-loss.
2669    #[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    // ── Long stop-loss ────────────────────────────────────────────────────────
2690
2691    #[test]
2692    fn test_per_trade_stop_loss_triggers_when_set() {
2693        // Bar 0: signal @ 100. Bar 1: fill @ open=100.
2694        // Bar 2: intrabar low=79.2. 5% stop = $95. low(79.2) <= 95 → stop fires.
2695        // Gap-down guard: fill = min(open=80, stop=95) = 80.
2696        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        // Config has no stop-loss; per-trade bracket stop of 5% should still fire.
2730        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        // Config has a loose 20% stop ($80); per-trade bracket stop of 5% ($95) fires first.
2765        // Bar 2 opens at $97 (above $95) and dips to $93 intrabar — no gap-down — so the
2766        // fill resolves to min(open=97, stop=95) = $95, proving it's the tighter bracket
2767        // that fired and not the config's $80 level.
2768        //
2769        //   5% stop  = $95 → triggers (low=93 ≤ 95), fill = min(97, 95) = 95
2770        //   20% stop = $80 → would NOT trigger (low=93 > 80)
2771        let prices = [100.0, 100.0, 97.0, 97.0];
2772        let mut candles = make_candles(&prices);
2773        candles[2].low = 93.0; // below 5% stop=95, above 20% stop=80
2774
2775        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) // loose config default
2780            .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        // Exit at the 5% bracket level ($95), not the 20% config level ($80).
2796        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    // ── Short stop-loss ───────────────────────────────────────────────────────
2804
2805    #[test]
2806    fn test_per_trade_short_stop_loss_triggers_when_set() {
2807        // Bar 0: signal short @ 100. Bar 1: fill @ open=100.
2808        // Bar 2: intrabar high=112.5. 5% stop = $105. high(112.5) >= 105 → stop fires.
2809        // Gap-up guard: fill = max(open=112, stop=105) = 112.
2810        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        // Config has no stop-loss; per-trade bracket stop of 5% should still fire for shorts.
2845        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        // Config has a loose 20% stop ($120); per-trade bracket stop of 5% ($105) fires first.
2878        // Bar 2 opens at $103 (below $105) and rises to $108 intrabar — no gap-up — so
2879        // the fill resolves to max(open=103, stop=105) = $105, not the config's $120.
2880        //
2881        //   5% stop  = $105 → triggers (high=108 ≥ 105), fill = max(103, 105) = 105
2882        //   20% stop = $120 → would NOT trigger (high=108 < 120)
2883        let prices = [100.0, 100.0, 103.0, 103.0];
2884        let mut candles = make_candles(&prices);
2885        candles[2].high = 108.0; // above 5% stop=105, below 20% stop=120
2886
2887        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) // loose config default
2893            .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        // Exit at the 5% bracket level ($105), not the 20% config level ($120).
2909        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    // ── Take-profit ───────────────────────────────────────────────────────────
2917
2918    /// Enters a long position on bar 0 with a per-trade take-profit.
2919    #[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    /// Enters a short position on bar 0 with a per-trade take-profit.
2940    #[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        // Bar 0: signal @ 100. Bar 1: fill @ open=100.
2963        // Bar 2: intrabar high=121.2. TP at 10% = $110. high(121.2) >= 110 → fires.
2964        // Gap-up guard: fill = max(open=120, tp=110) = 120.
2965        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        // Bar 0: signal short @ 100. Bar 1: fill @ open=100.
2999        // Bar 2: intrabar low=84.15. TP at 10% = $90. low(84.15) <= 90 → fires.
3000        // Gap-down guard: fill = min(open=85, tp=90) = 85.
3001        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    // ── Trailing stop ─────────────────────────────────────────────────────────
3034
3035    /// Enters a long position on bar 0 with a per-trade trailing stop.
3036    #[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    /// Enters a short position on bar 0 with a per-trade trailing stop.
3057    #[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        // Bar 0: signal @ 100.
3080        // Bar 1: fill @ open=100. HWM initialised to entry_price=100.
3081        // Bar 2: high=121.0 → HWM = max(100, 121) = 121. Trail stop = 121*(1-0.05) = 114.95.
3082        //        low=118.8 → 118.8 > 114.95 → no trigger.
3083        // Bar 3: low=108.9 → 108.9 <= 114.95 → trailing stop fires.
3084        //        fill = min(open=110, trail=114.95) = 110. pnl > 0.
3085        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; // below 5% trail from 121.0 (= 114.95)
3089
3090        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        // Bar 0: signal short @ 100.
3120        // Bar 1: fill @ open=100. LWM (trough) initialised to entry_price=100.
3121        // Bar 2: price=80, low=79.2 → LWM = min(100, 79.2) = 79.2.
3122        //        Trail stop = 79.2*(1+0.05) = 83.16. high=80.8 → 80.8 < 83.16 → no trigger.
3123        // Bar 3: price=88, high=88.88 → 88.88 >= 83.16 → trailing stop fires.
3124        //        fill = max(open=88, trail=83.16) = 88. pnl > 0 (short from 100, exit at 88).
3125        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; // drives LWM to 79.2; trail stop = 79.2 * 1.05 = 83.16
3128
3129        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}