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/// Returns true if the indicator needs high/low price series.
25#[inline]
26fn needs_high_low(indicator: &Indicator) -> bool {
27    matches!(
28        indicator,
29        Indicator::Atr(_)
30            | Indicator::Supertrend { .. }
31            | Indicator::DonchianChannels(_)
32            | Indicator::Cci(_)
33            | Indicator::WilliamsR(_)
34            | Indicator::Adx(_)
35            | Indicator::Mfi(_)
36            | Indicator::Cmf(_)
37            | Indicator::Stochastic { .. }
38            | Indicator::Aroon(_)
39            | Indicator::Ichimoku { .. }
40            | Indicator::ParabolicSar { .. }
41            | Indicator::KeltnerChannels { .. }
42            | Indicator::TrueRange
43            | Indicator::ChoppinessIndex(_)
44            | Indicator::Vwap
45            | Indicator::ChaikinOscillator
46            | Indicator::AccumulationDistribution
47            | Indicator::BalanceOfPower(_)
48            | Indicator::BullBearPower(_)
49            | Indicator::ElderRay(_)
50            | Indicator::AwesomeOscillator { .. }
51    )
52}
53
54/// Returns true if the indicator needs the volume series.
55#[inline]
56fn needs_volumes(indicator: &Indicator) -> bool {
57    matches!(
58        indicator,
59        Indicator::Obv
60            | Indicator::Mfi(_)
61            | Indicator::Cmf(_)
62            | Indicator::Vwma(_)
63            | Indicator::Vwap
64            | Indicator::ChaikinOscillator
65            | Indicator::AccumulationDistribution
66    )
67}
68
69/// Compute a single indicator and return all resulting (key, values) pairs.
70fn compute_one(
71    closes: &[f64],
72    highs: &[f64],
73    lows: &[f64],
74    volumes: &[f64],
75    opens: &[f64],
76    name: String,
77    indicator: Indicator,
78) -> Result<Vec<(String, Vec<Option<f64>>)>> {
79    let mut out = Vec::with_capacity(5);
80    match indicator {
81        Indicator::Sma(period) => {
82            out.push((name, indicators::sma(closes, period)));
83        }
84        Indicator::Ema(period) => {
85            out.push((name, indicators::ema(closes, period)));
86        }
87        Indicator::Rsi(period) => {
88            out.push((name, indicators::rsi(closes, period)?));
89        }
90        Indicator::Macd { fast, slow, signal } => {
91            let m = indicators::macd(closes, fast, slow, signal)?;
92            out.push((format!("macd_line_{fast}_{slow}_{signal}"), m.macd_line));
93            out.push((format!("macd_signal_{fast}_{slow}_{signal}"), m.signal_line));
94            out.push((
95                format!("macd_histogram_{fast}_{slow}_{signal}"),
96                m.histogram,
97            ));
98        }
99        Indicator::Bollinger { period, std_dev } => {
100            let bb = indicators::bollinger_bands(closes, period, std_dev)?;
101            out.push((format!("bollinger_upper_{period}_{std_dev}"), bb.upper));
102            out.push((format!("bollinger_middle_{period}_{std_dev}"), bb.middle));
103            out.push((format!("bollinger_lower_{period}_{std_dev}"), bb.lower));
104        }
105        Indicator::Atr(period) => {
106            out.push((name, indicators::atr(highs, lows, closes, period)?));
107        }
108        Indicator::Supertrend { period, multiplier } => {
109            let st = indicators::supertrend(highs, lows, closes, period, multiplier)?;
110            out.push((format!("supertrend_value_{period}_{multiplier}"), st.value));
111            let uptrend: Vec<Option<f64>> = st
112                .is_uptrend
113                .into_iter()
114                .map(|v| v.map(|b| if b { 1.0 } else { 0.0 }))
115                .collect();
116            out.push((format!("supertrend_uptrend_{period}_{multiplier}"), uptrend));
117        }
118        Indicator::DonchianChannels(period) => {
119            let dc = indicators::donchian_channels(highs, lows, period)?;
120            out.push((format!("donchian_upper_{period}"), dc.upper));
121            out.push((format!("donchian_middle_{period}"), dc.middle));
122            out.push((format!("donchian_lower_{period}"), dc.lower));
123        }
124        Indicator::Wma(period) => {
125            out.push((name, indicators::wma(closes, period)?));
126        }
127        Indicator::Dema(period) => {
128            out.push((name, indicators::dema(closes, period)?));
129        }
130        Indicator::Tema(period) => {
131            out.push((name, indicators::tema(closes, period)?));
132        }
133        Indicator::Hma(period) => {
134            out.push((name, indicators::hma(closes, period)?));
135        }
136        Indicator::Obv => {
137            out.push((name, indicators::obv(closes, volumes)?));
138        }
139        Indicator::Momentum(period) => {
140            out.push((name, indicators::momentum(closes, period)?));
141        }
142        Indicator::Roc(period) => {
143            out.push((name, indicators::roc(closes, period)?));
144        }
145        Indicator::Cci(period) => {
146            out.push((name, indicators::cci(highs, lows, closes, period)?));
147        }
148        Indicator::WilliamsR(period) => {
149            out.push((name, indicators::williams_r(highs, lows, closes, period)?));
150        }
151        Indicator::Adx(period) => {
152            out.push((name, indicators::adx(highs, lows, closes, period)?));
153        }
154        Indicator::Mfi(period) => {
155            out.push((name, indicators::mfi(highs, lows, closes, volumes, period)?));
156        }
157        Indicator::Cmf(period) => {
158            out.push((name, indicators::cmf(highs, lows, closes, volumes, period)?));
159        }
160        Indicator::Cmo(period) => {
161            out.push((name, indicators::cmo(closes, period)?));
162        }
163        Indicator::Vwma(period) => {
164            out.push((name, indicators::vwma(closes, volumes, period)?));
165        }
166        Indicator::Alma {
167            period,
168            offset,
169            sigma,
170        } => {
171            out.push((name, indicators::alma(closes, period, offset, sigma)?));
172        }
173        Indicator::McginleyDynamic(period) => {
174            out.push((name, indicators::mcginley_dynamic(closes, period)?));
175        }
176        Indicator::Stochastic {
177            k_period,
178            k_slow,
179            d_period,
180        } => {
181            let s = indicators::stochastic(highs, lows, closes, k_period, k_slow, d_period)?;
182            out.push((format!("stochastic_k_{k_period}_{k_slow}_{d_period}"), s.k));
183            out.push((format!("stochastic_d_{k_period}_{k_slow}_{d_period}"), s.d));
184        }
185        Indicator::StochasticRsi {
186            rsi_period,
187            stoch_period,
188            k_period,
189            d_period,
190        } => {
191            let s =
192                indicators::stochastic_rsi(closes, rsi_period, stoch_period, k_period, d_period)?;
193            out.push((
194                format!("stoch_rsi_k_{rsi_period}_{stoch_period}_{k_period}_{d_period}"),
195                s.k,
196            ));
197            out.push((
198                format!("stoch_rsi_d_{rsi_period}_{stoch_period}_{k_period}_{d_period}"),
199                s.d,
200            ));
201        }
202        Indicator::AwesomeOscillator { fast, slow } => {
203            out.push((
204                name,
205                indicators::awesome_oscillator(highs, lows, fast, slow)?,
206            ));
207        }
208        Indicator::CoppockCurve {
209            wma_period,
210            long_roc,
211            short_roc,
212        } => {
213            out.push((
214                name,
215                indicators::coppock_curve(closes, long_roc, short_roc, wma_period)?,
216            ));
217        }
218        Indicator::Aroon(period) => {
219            let a = indicators::aroon(highs, lows, period)?;
220            out.push((format!("aroon_up_{period}"), a.aroon_up));
221            out.push((format!("aroon_down_{period}"), a.aroon_down));
222        }
223        Indicator::Ichimoku {
224            conversion,
225            base,
226            lagging,
227            displacement,
228        } => {
229            let ich =
230                indicators::ichimoku(highs, lows, closes, conversion, base, lagging, displacement)?;
231            out.push((
232                format!("ichimoku_conversion_{conversion}_{base}_{lagging}_{displacement}"),
233                ich.conversion_line,
234            ));
235            out.push((
236                format!("ichimoku_base_{conversion}_{base}_{lagging}_{displacement}"),
237                ich.base_line,
238            ));
239            out.push((
240                format!("ichimoku_leading_a_{conversion}_{base}_{lagging}_{displacement}"),
241                ich.leading_span_a,
242            ));
243            out.push((
244                format!("ichimoku_leading_b_{conversion}_{base}_{lagging}_{displacement}"),
245                ich.leading_span_b,
246            ));
247            out.push((
248                format!("ichimoku_lagging_{conversion}_{base}_{lagging}_{displacement}"),
249                ich.lagging_span,
250            ));
251        }
252        Indicator::ParabolicSar { step, max } => {
253            out.push((
254                name,
255                indicators::parabolic_sar(highs, lows, closes, step, max)?,
256            ));
257        }
258        Indicator::KeltnerChannels {
259            period,
260            multiplier,
261            atr_period,
262        } => {
263            let kc =
264                indicators::keltner_channels(highs, lows, closes, period, atr_period, multiplier)?;
265            out.push((
266                format!("keltner_upper_{period}_{multiplier}_{atr_period}"),
267                kc.upper,
268            ));
269            out.push((
270                format!("keltner_middle_{period}_{multiplier}_{atr_period}"),
271                kc.middle,
272            ));
273            out.push((
274                format!("keltner_lower_{period}_{multiplier}_{atr_period}"),
275                kc.lower,
276            ));
277        }
278        Indicator::TrueRange => {
279            out.push((name, indicators::true_range(highs, lows, closes)?));
280        }
281        Indicator::ChoppinessIndex(period) => {
282            out.push((
283                name,
284                indicators::choppiness_index(highs, lows, closes, period)?,
285            ));
286        }
287        Indicator::Vwap => {
288            out.push((name, indicators::vwap(highs, lows, closes, volumes)?));
289        }
290        Indicator::ChaikinOscillator => {
291            out.push((
292                name,
293                indicators::chaikin_oscillator(highs, lows, closes, volumes)?,
294            ));
295        }
296        Indicator::AccumulationDistribution => {
297            out.push((
298                name,
299                indicators::accumulation_distribution(highs, lows, closes, volumes)?,
300            ));
301        }
302        Indicator::BalanceOfPower(period) => {
303            out.push((
304                name,
305                indicators::balance_of_power(opens, highs, lows, closes, period)?,
306            ));
307        }
308        Indicator::BullBearPower(period) => {
309            let bbp = indicators::bull_bear_power(highs, lows, closes, period)?;
310            out.push((format!("bull_power_{period}"), bbp.bull_power));
311            out.push((format!("bear_power_{period}"), bbp.bear_power));
312        }
313        Indicator::ElderRay(period) => {
314            let er = indicators::elder_ray(highs, lows, closes, period)?;
315            out.push((format!("elder_bull_{period}"), er.bull_power));
316            out.push((format!("elder_bear_{period}"), er.bear_power));
317        }
318    }
319    Ok(out)
320}
321
322/// Pre-compute a set of indicators on the given candles.
323///
324/// Accepts a list of `(key, Indicator)` pairs as returned by
325/// [`Condition::required_indicators`] and returns a map from key to
326/// the computed `Vec<Option<f64>>`. Multi-output indicators (MACD,
327/// Bollinger, Supertrend, etc.) insert additional derived keys,
328/// ignoring the supplied key for those variants.
329///
330/// When 4 or more indicators are requested, computation runs in parallel
331/// using rayon's thread pool.
332pub(crate) fn compute_for_candles(
333    candles: &[Candle],
334    required: Vec<(String, Indicator)>,
335) -> Result<HashMap<String, Vec<Option<f64>>>> {
336    if required.is_empty() {
337        return Ok(HashMap::new());
338    }
339
340    let use_hl = required.iter().any(|(_, i)| needs_high_low(i));
341    let use_vol = required.iter().any(|(_, i)| needs_volumes(i));
342    let use_open = required
343        .iter()
344        .any(|(_, i)| matches!(i, Indicator::BalanceOfPower(_)));
345
346    // Extract price series upfront (single pass each, cache-friendly).
347    let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();
348    let (highs, lows): (Vec<f64>, Vec<f64>) = if use_hl {
349        candles.iter().map(|c| (c.high, c.low)).unzip()
350    } else {
351        (vec![], vec![])
352    };
353    let volumes: Vec<f64> = if use_vol {
354        candles.iter().map(|c| c.volume as f64).collect()
355    } else {
356        vec![]
357    };
358    let opens: Vec<f64> = if use_open {
359        candles.iter().map(|c| c.open).collect()
360    } else {
361        vec![]
362    };
363
364    type IndPairs = Vec<(String, Vec<Option<f64>>)>;
365
366    // Parallelise only when both the indicator count and candle count are large
367    // enough that rayon task-dispatch overhead is outweighed by the savings.
368    // Empirically: ≥4 indicators AND ≥1 000 candles.
369    let groups: Result<Vec<IndPairs>> = if required.len() >= 4 && candles.len() >= 1_000 {
370        use rayon::prelude::*;
371        required
372            .into_par_iter()
373            .map(|(name, ind)| compute_one(&closes, &highs, &lows, &volumes, &opens, name, ind))
374            .collect()
375    } else {
376        required
377            .into_iter()
378            .map(|(name, ind)| compute_one(&closes, &highs, &lows, &volumes, &opens, name, ind))
379            .collect()
380    };
381
382    let groups = groups?;
383    let capacity: usize = groups.iter().map(|v| v.len()).sum();
384    let mut result = HashMap::with_capacity(capacity);
385    for group in groups {
386        for (k, v) in group {
387            result.insert(k, v);
388        }
389    }
390    Ok(result)
391}
392
393impl BacktestEngine {
394    /// Create a new backtest engine with the given configuration
395    pub fn new(config: BacktestConfig) -> Self {
396        Self { config }
397    }
398
399    /// Run a backtest with the given strategy on historical candle data.
400    ///
401    /// Dividend income is not included. Use [`run_with_dividends`] to account
402    /// for dividend payments during holding periods.
403    ///
404    /// [`run_with_dividends`]: Self::run_with_dividends
405    pub fn run<S: Strategy>(
406        &self,
407        symbol: &str,
408        candles: &[Candle],
409        strategy: S,
410    ) -> Result<BacktestResult> {
411        self.simulate(symbol, candles, strategy, &[])
412    }
413
414    /// Run a backtest and credit dividend income for any dividends paid while a
415    /// position is open.
416    ///
417    /// `dividends` should be sorted by timestamp (ascending). The engine credits
418    /// each dividend whose ex-date falls on or before the current candle bar.
419    /// When [`BacktestConfig::reinvest_dividends`] is `true`, the income is also
420    /// used to notionally purchase additional shares at the ex-date close price.
421    pub fn run_with_dividends<S: Strategy>(
422        &self,
423        symbol: &str,
424        candles: &[Candle],
425        strategy: S,
426        dividends: &[Dividend],
427    ) -> Result<BacktestResult> {
428        self.simulate(symbol, candles, strategy, dividends)
429    }
430
431    // ── Core simulation ───────────────────────────────────────────────────────
432
433    /// Internal simulation core. All public `run*` methods delegate here.
434    fn simulate<S: Strategy>(
435        &self,
436        symbol: &str,
437        candles: &[Candle],
438        mut strategy: S,
439        dividends: &[Dividend],
440    ) -> Result<BacktestResult> {
441        let warmup = strategy.warmup_period();
442        if candles.len() < warmup {
443            return Err(BacktestError::insufficient_data(warmup, candles.len()));
444        }
445
446        // Validate dividend ordering — simulation correctness requires ascending timestamps.
447        if !dividends
448            .windows(2)
449            .all(|w| w[0].timestamp <= w[1].timestamp)
450        {
451            return Err(BacktestError::invalid_param(
452                "dividends",
453                "must be sorted by timestamp (ascending)",
454            ));
455        }
456
457        // Pre-compute all required indicators (base timeframe + HTF stretched arrays)
458        let mut indicators = self.compute_indicators(candles, &strategy)?;
459        indicators.extend(self.compute_htf_indicators(candles, &strategy)?);
460
461        // Let the strategy cache direct pointers into the indicator map, eliminating
462        // per-bar HashMap lookups in on_candle.
463        strategy.setup(&indicators);
464
465        // Initialize state
466        let mut equity = self.config.initial_capital;
467        let mut cash = self.config.initial_capital;
468        let mut position: Option<Position> = None;
469        let mut trades: Vec<Trade> = Vec::new();
470        let mut equity_curve: Vec<EquityPoint> = Vec::with_capacity(candles.len());
471        let mut signals: Vec<SignalRecord> = Vec::new();
472        let mut peak_equity = equity;
473        // High-water mark for the trailing stop: tracks peak price (longs) or
474        // trough price (shorts) since entry. Reset to None when no position is open.
475        let mut hwm: Option<f64> = None;
476
477        // Dividend processing pointer: dividends must be sorted by timestamp.
478        // We advance this index forward as the simulation progresses in time.
479        let mut div_idx: usize = 0;
480
481        // Pending limit / stop orders placed by the strategy.
482        // Checked each bar before strategy signal evaluation.
483        let mut pending_orders: Vec<PendingOrder> = Vec::new();
484
485        // Main simulation loop
486        for i in 0..candles.len() {
487            let candle = &candles[i];
488
489            equity = Self::update_equity_and_curve(
490                position.as_ref(),
491                candle,
492                cash,
493                &mut peak_equity,
494                &mut equity_curve,
495            );
496
497            update_trailing_hwm(position.as_ref(), &mut hwm, candle);
498
499            // Credit dividend income for any dividends ex-dated on or before this bar.
500            self.credit_dividends(&mut position, candle, dividends, &mut div_idx);
501
502            // Check stop-loss / take-profit / trailing-stop on existing position.
503            // The signal carries the intrabar fill price (stop/TP level with gap guard),
504            // so we execute on the current bar at that price — no next-bar deferral needed.
505            if let Some(ref pos) = position
506                && let Some(exit_signal) = self.check_sl_tp(pos, candle, hwm)
507            {
508                let fill_price = exit_signal.price;
509                let executed = self.close_position_at(
510                    &mut position,
511                    &mut cash,
512                    &mut trades,
513                    candle,
514                    fill_price,
515                    &exit_signal,
516                );
517
518                signals.push(SignalRecord {
519                    timestamp: candle.timestamp,
520                    price: fill_price,
521                    direction: SignalDirection::Exit,
522                    strength: 1.0,
523                    reason: exit_signal.reason.clone(),
524                    executed,
525                    tags: exit_signal.tags.clone(),
526                });
527
528                if executed {
529                    hwm = None; // Reset HWM when position is closed
530                    continue; // Skip strategy signal this bar
531                }
532            }
533
534            // ── Pending limit / stop orders ───────────────────────────────
535            // Check queued orders against the current bar before evaluating
536            // the strategy. This preserves the realistic ordering where a
537            // pending order placed on bar N can first fill on bar N+1.
538            //
539            // `retain_mut` preserves FIFO queue order (critical for correct
540            // order matching) while avoiding the temporary index vec and the
541            // ordering-destroying `swap_remove` used previously.
542            let mut filled_this_bar = false;
543            pending_orders.retain_mut(|order| {
544                // Expire orders past their GTC lifetime.
545                if let Some(exp) = order.expires_in_bars
546                    && i >= order.created_bar + exp
547                {
548                    return false; // drop
549                }
550
551                // Cannot fill into an existing position, or if another
552                // pending order already filled on this bar.
553                if position.is_some() || filled_this_bar {
554                    return true; // keep
555                }
556
557                // Short orders require allow_short.
558                if matches!(order.signal.direction, SignalDirection::Short)
559                    && !self.config.allow_short
560                {
561                    return true; // keep (config could change via re-run)
562                }
563
564                // BuyStopLimit state machine: if the stop price is triggered
565                // but the bar opens above the limit price the order can't fill
566                // this bar. In reality the stop has already "activated" the
567                // order, which now rests in the book as a plain limit order.
568                // Downgrade so subsequent bars treat it as a BuyLimit.
569                let upgrade_to_limit = match &order.order_type {
570                    OrderType::BuyStopLimit {
571                        stop_price,
572                        limit_price,
573                    } if candle.high >= *stop_price => {
574                        let trigger_fill = candle.open.max(*stop_price);
575                        if trigger_fill > *limit_price {
576                            Some(*limit_price) // triggered, limit not reached
577                        } else {
578                            None // triggered and fillable — handled below
579                        }
580                    }
581                    _ => None,
582                };
583                if let Some(new_limit) = upgrade_to_limit {
584                    order.order_type = OrderType::BuyLimit {
585                        limit_price: new_limit,
586                    };
587                    return true; // keep as plain BuyLimit; skip fill this bar
588                }
589
590                if let Some(fill_price) = order.order_type.try_fill(candle) {
591                    let is_long = matches!(order.signal.direction, SignalDirection::Long);
592                    let executed = self.open_position_at_price(
593                        &mut position,
594                        &mut cash,
595                        candle,
596                        &order.signal,
597                        is_long,
598                        fill_price,
599                    );
600                    if executed {
601                        hwm = position.as_ref().map(|p| p.entry_price);
602                        signals.push(SignalRecord {
603                            timestamp: candle.timestamp,
604                            price: fill_price,
605                            direction: order.signal.direction,
606                            strength: order.signal.strength.value(),
607                            reason: order.signal.reason.clone(),
608                            executed: true,
609                            tags: order.signal.tags.clone(),
610                        });
611                        filled_this_bar = true;
612                        return false; // drop — order filled
613                    }
614                }
615
616                true // keep unfilled order
617            });
618
619            // Skip strategy signals during warmup period
620            if i < warmup.saturating_sub(1) {
621                continue;
622            }
623
624            // Build strategy context
625            let ctx = StrategyContext {
626                candles: &candles[..=i],
627                index: i,
628                position: position.as_ref(),
629                equity,
630                indicators: &indicators,
631            };
632
633            // Get strategy signal
634            let signal = strategy.on_candle(&ctx);
635
636            // Skip hold signals
637            if signal.is_hold() {
638                continue;
639            }
640
641            // Check signal strength threshold
642            if signal.strength.value() < self.config.min_signal_strength {
643                signals.push(SignalRecord {
644                    timestamp: signal.timestamp,
645                    price: signal.price,
646                    direction: signal.direction,
647                    strength: signal.strength.value(),
648                    reason: signal.reason.clone(),
649                    executed: false,
650                    tags: signal.tags.clone(),
651                });
652                continue;
653            }
654
655            // Market orders execute on next bar to avoid same-bar close-fill
656            // bias.  Limit and stop entry orders are queued as PendingOrders
657            // and fill on a subsequent bar when the price level is reached.
658            // Non-Market directions other than Long/Short (Exit, ScaleIn,
659            // ScaleOut) are always treated as market orders.
660            let executed = match &signal.order_type {
661                OrderType::Market => {
662                    if let Some(fill_candle) = candles.get(i + 1) {
663                        self.execute_signal(
664                            &signal,
665                            fill_candle,
666                            &mut position,
667                            &mut cash,
668                            &mut trades,
669                        )
670                    } else {
671                        false
672                    }
673                }
674                _ if matches!(
675                    signal.direction,
676                    SignalDirection::Long | SignalDirection::Short
677                ) =>
678                {
679                    // Reject short orders immediately if shorts are disabled —
680                    // no point burning queue space for orders that can never fill.
681                    if matches!(signal.direction, SignalDirection::Short)
682                        && !self.config.allow_short
683                    {
684                        false
685                    } else {
686                        // Queue as a pending order; the signal record below will
687                        // show executed: false (order placed but not yet filled).
688                        pending_orders.push(PendingOrder {
689                            order_type: signal.order_type.clone(),
690                            expires_in_bars: signal.expires_in_bars,
691                            created_bar: i,
692                            signal: signal.clone(),
693                        });
694                        false
695                    }
696                }
697                _ => {
698                    // Non-market Exit / ScaleIn / ScaleOut — execute as market.
699                    if let Some(fill_candle) = candles.get(i + 1) {
700                        self.execute_signal(
701                            &signal,
702                            fill_candle,
703                            &mut position,
704                            &mut cash,
705                            &mut trades,
706                        )
707                    } else {
708                        false
709                    }
710                }
711            };
712
713            if executed
714                && position.is_some()
715                && matches!(
716                    signal.direction,
717                    SignalDirection::Long | SignalDirection::Short
718                )
719            {
720                hwm = position.as_ref().map(|p| p.entry_price);
721            }
722
723            // Reset the trailing-stop HWM whenever a position is closed
724            if executed && position.is_none() {
725                hwm = None;
726
727                // Re-evaluate strategy on the same bar after an exit so that
728                // a crossover that simultaneously closes one side and triggers
729                // the opposite entry is not lost.
730                let ctx2 = StrategyContext {
731                    candles: &candles[..=i],
732                    index: i,
733                    position: None,
734                    equity,
735                    indicators: &indicators,
736                };
737                let follow = strategy.on_candle(&ctx2);
738                if !follow.is_hold() && follow.strength.value() >= self.config.min_signal_strength {
739                    let follow_executed = if let Some(fill_candle) = candles.get(i + 1) {
740                        self.execute_signal(
741                            &follow,
742                            fill_candle,
743                            &mut position,
744                            &mut cash,
745                            &mut trades,
746                        )
747                    } else {
748                        false
749                    };
750                    if follow_executed && position.is_some() {
751                        hwm = position.as_ref().map(|p| p.entry_price);
752                    }
753                    signals.push(SignalRecord {
754                        timestamp: follow.timestamp,
755                        price: follow.price,
756                        direction: follow.direction,
757                        strength: follow.strength.value(),
758                        reason: follow.reason,
759                        executed: follow_executed,
760                        tags: follow.tags,
761                    });
762                }
763            }
764
765            signals.push(SignalRecord {
766                timestamp: signal.timestamp,
767                price: signal.price,
768                direction: signal.direction,
769                strength: signal.strength.value(),
770                reason: signal.reason,
771                executed,
772                tags: signal.tags,
773            });
774        }
775
776        // Close any open position at end if configured
777        if self.config.close_at_end
778            && let Some(pos) = position.take()
779        {
780            let last_candle = candles
781                .last()
782                .expect("candles non-empty: position open implies loop ran");
783            let exit_price_slipped = self
784                .config
785                .apply_exit_slippage(last_candle.close, pos.is_long());
786            let exit_price = self
787                .config
788                .apply_exit_spread(exit_price_slipped, pos.is_long());
789            let exit_commission = self.config.calculate_commission(pos.quantity, exit_price);
790            // Tax on buy orders only: short covers are buys
791            let exit_tax = self
792                .config
793                .calculate_transaction_tax(exit_price * pos.quantity, !pos.is_long());
794
795            let exit_signal = Signal::exit(last_candle.timestamp, last_candle.close)
796                .with_reason("End of backtest");
797
798            let trade = pos.close_with_tax(
799                last_candle.timestamp,
800                exit_price,
801                exit_commission,
802                exit_tax,
803                exit_signal,
804            );
805            if trade.is_long() {
806                cash += trade.exit_value() - exit_commission + trade.unreinvested_dividends;
807            } else {
808                cash -=
809                    trade.exit_value() + exit_commission + exit_tax - trade.unreinvested_dividends;
810            }
811            trades.push(trade);
812
813            Self::sync_terminal_equity_point(&mut equity_curve, last_candle.timestamp, cash);
814        }
815
816        // Final equity
817        let final_equity = if let Some(ref pos) = position {
818            cash + pos.current_value(
819                candles
820                    .last()
821                    .expect("candles non-empty: position open implies loop ran")
822                    .close,
823            ) + pos.unreinvested_dividends
824        } else {
825            cash
826        };
827
828        if let Some(last_candle) = candles.last() {
829            Self::sync_terminal_equity_point(
830                &mut equity_curve,
831                last_candle.timestamp,
832                final_equity,
833            );
834        }
835
836        // Calculate metrics
837        let executed_signals = signals.iter().filter(|s| s.executed).count();
838        let metrics = PerformanceMetrics::calculate(
839            &trades,
840            &equity_curve,
841            self.config.initial_capital,
842            signals.len(),
843            executed_signals,
844            self.config.risk_free_rate,
845            self.config.bars_per_year,
846        );
847
848        let start_timestamp = candles.first().map(|c| c.timestamp).unwrap_or(0);
849        let end_timestamp = candles.last().map(|c| c.timestamp).unwrap_or(0);
850
851        // Build diagnostics for likely misconfigurations
852        let mut diagnostics = Vec::new();
853        if trades.is_empty() {
854            if signals.is_empty() {
855                diagnostics.push(
856                    "No signals were generated. Check that the strategy's warmup \
857                     period is shorter than the data length and that indicator \
858                     conditions can be satisfied."
859                        .into(),
860                );
861            } else {
862                let short_signals = signals
863                    .iter()
864                    .filter(|s| matches!(s.direction, SignalDirection::Short))
865                    .count();
866                if short_signals > 0 && !self.config.allow_short {
867                    diagnostics.push(format!(
868                        "{short_signals} short signal(s) were generated but \
869                         config.allow_short is false. Enable it with \
870                         BacktestConfig::builder().allow_short(true)."
871                    ));
872                }
873                diagnostics.push(format!(
874                    "{} signal(s) generated but none executed. Check \
875                     min_signal_strength ({}) and capital requirements.",
876                    signals.len(),
877                    self.config.min_signal_strength
878                ));
879            }
880        }
881
882        Ok(BacktestResult {
883            symbol: symbol.to_string(),
884            strategy_name: strategy.name().to_string(),
885            config: self.config.clone(),
886            start_timestamp,
887            end_timestamp,
888            initial_capital: self.config.initial_capital,
889            final_equity,
890            metrics,
891            trades,
892            equity_curve,
893            signals,
894            open_position: position,
895            benchmark: None, // Populated by run_with_benchmark when a benchmark is supplied
896            diagnostics,
897        })
898    }
899
900    /// Run a backtest and compare against a benchmark, optionally crediting dividends.
901    ///
902    /// The result's `benchmark` field is populated with buy-and-hold comparison
903    /// metrics including alpha, beta, and information ratio. The benchmark candle
904    /// slice should cover the same time period as `candles` but need not be the
905    /// same length.
906    ///
907    /// `dividends` must be sorted ascending by timestamp. Pass `&[]` to omit
908    /// dividend processing.
909    pub fn run_with_benchmark<S: Strategy>(
910        &self,
911        symbol: &str,
912        candles: &[Candle],
913        strategy: S,
914        dividends: &[Dividend],
915        benchmark_symbol: &str,
916        benchmark_candles: &[Candle],
917    ) -> Result<BacktestResult> {
918        let mut result = self.simulate(symbol, candles, strategy, dividends)?;
919        result.benchmark = Some(compute_benchmark_metrics(
920            benchmark_symbol,
921            candles,
922            benchmark_candles,
923            &result.equity_curve,
924            self.config.risk_free_rate,
925            self.config.bars_per_year,
926        ));
927        Ok(result)
928    }
929
930    /// Pre-compute all indicators required by the strategy
931    pub(crate) fn compute_indicators<S: Strategy>(
932        &self,
933        candles: &[Candle],
934        strategy: &S,
935    ) -> Result<HashMap<String, Vec<Option<f64>>>> {
936        compute_for_candles(candles, strategy.required_indicators())
937    }
938
939    /// Pre-compute stretched HTF indicator arrays for all `HtfCondition`s in the strategy.
940    ///
941    /// For each unique `(interval, utc_offset_secs)` pair:
942    /// - Resample the full candle history to the HTF interval
943    /// - Compute required indicators on the resampled candles
944    /// - Build a mapping from base timeframe to HTF indices
945    /// - Stretch each HTF indicator value to base timeframe length
946    /// - Store the stretched array in the result map under the `htf_key`
947    fn compute_htf_indicators<S: Strategy>(
948        &self,
949        candles: &[Candle],
950        strategy: &S,
951    ) -> Result<HashMap<String, Vec<Option<f64>>>> {
952        use std::collections::HashSet;
953
954        use super::condition::HtfIndicatorSpec;
955        use super::resample::{base_to_htf_index, resample};
956        use crate::constants::Interval;
957
958        let specs = strategy.htf_requirements();
959        if specs.is_empty() {
960            return Ok(HashMap::new());
961        }
962
963        let mut result = HashMap::new();
964
965        // Group specs by (interval, utc_offset_secs) — one resample per unique pair.
966        let mut by_interval: HashMap<(Interval, i64), Vec<HtfIndicatorSpec>> = HashMap::new();
967        for spec in specs {
968            by_interval
969                .entry((spec.interval, spec.utc_offset_secs))
970                .or_default()
971                .push(spec);
972        }
973
974        for ((interval, utc_offset_secs), specs) in by_interval {
975            let htf_candles = resample(candles, interval, utc_offset_secs);
976            if htf_candles.is_empty() {
977                continue;
978            }
979
980            // De-duplicate indicators by base_key to avoid recomputing MACD/Bollinger
981            // etc. when multiple output keys (line, signal, histogram) are requested.
982            let mut required: Vec<(String, crate::indicators::Indicator)> = Vec::new();
983            let mut seen_base_keys: HashSet<&str> = HashSet::new();
984            for spec in &specs {
985                if seen_base_keys.insert(&spec.base_key) {
986                    required.push((spec.base_key.clone(), spec.indicator));
987                }
988            }
989
990            let htf_values = compute_for_candles(&htf_candles, required)?;
991            let mapping = base_to_htf_index(candles, &htf_candles);
992
993            for spec in &specs {
994                if let Some(htf_vec) = htf_values.get(&spec.base_key) {
995                    let stretched: Vec<Option<f64>> = mapping
996                        .iter()
997                        .map(|htf_idx| htf_idx.and_then(|i| htf_vec.get(i).copied().flatten()))
998                        .collect();
999                    result.insert(spec.htf_key.clone(), stretched);
1000                }
1001            }
1002        }
1003
1004        Ok(result)
1005    }
1006
1007    // ── Simulation helpers ────────────────────────────────────────────────────
1008
1009    /// Compute current equity, track peak/drawdown, and append an equity curve point.
1010    ///
1011    /// Returns the updated equity value.
1012    fn update_equity_and_curve(
1013        position: Option<&Position>,
1014        candle: &Candle,
1015        cash: f64,
1016        peak_equity: &mut f64,
1017        equity_curve: &mut Vec<EquityPoint>,
1018    ) -> f64 {
1019        let equity = match position {
1020            Some(pos) => cash + pos.current_value(candle.close) + pos.unreinvested_dividends,
1021            None => cash,
1022        };
1023        if equity > *peak_equity {
1024            *peak_equity = equity;
1025        }
1026        let drawdown_pct = if *peak_equity > 0.0 {
1027            (*peak_equity - equity) / *peak_equity
1028        } else {
1029            0.0
1030        };
1031        equity_curve.push(EquityPoint {
1032            timestamp: candle.timestamp,
1033            equity,
1034            drawdown_pct,
1035        });
1036        equity
1037    }
1038
1039    /// Credit any dividends whose ex-date falls on or before the current candle.
1040    ///
1041    /// Advances `div_idx` forward so each dividend is credited exactly once.
1042    fn credit_dividends(
1043        &self,
1044        position: &mut Option<Position>,
1045        candle: &Candle,
1046        dividends: &[Dividend],
1047        div_idx: &mut usize,
1048    ) {
1049        while *div_idx < dividends.len() && dividends[*div_idx].timestamp <= candle.timestamp {
1050            if let Some(pos) = position.as_mut() {
1051                let per_share = dividends[*div_idx].amount;
1052                let income = if pos.is_long() {
1053                    per_share * pos.quantity
1054                } else {
1055                    -(per_share * pos.quantity)
1056                };
1057                pos.credit_dividend(income, candle.close, self.config.reinvest_dividends);
1058            }
1059            *div_idx += 1;
1060        }
1061    }
1062
1063    /// Check if stop-loss, take-profit, or trailing stop should trigger intrabar.
1064    ///
1065    /// Uses `candle.low` / `candle.high` to detect breaches that occur during the
1066    /// bar, not just at the close.  Returns an exit [`Signal`] whose `price` field
1067    /// is the computed fill price (stop/TP level with a gap-guard: if the bar opens
1068    /// through the level the open price is used instead so the fill is never better
1069    /// than the market).
1070    ///
1071    /// `hwm` is the intrabar high-water mark for longs (`candle.high` is
1072    /// incorporated each bar) or the low-water mark for shorts.
1073    ///
1074    /// # Exit Priority
1075    ///
1076    /// When multiple exit conditions are satisfied on the same bar, the first
1077    /// one checked wins: **stop-loss → take-profit → trailing stop**.
1078    ///
1079    /// In reality, the intrabar order of events is unknowable from OHLCV data
1080    /// alone — a bar could open through the take-profit level before touching
1081    /// the stop-loss, or vice versa.  The fixed priority errs on the side of
1082    /// pessimism (stop-loss before take-profit) for conservative simulation.
1083    /// Strategies with both SL and TP set should be aware of this ordering
1084    /// when both levels are close together relative to typical bar ranges.
1085    fn check_sl_tp(
1086        &self,
1087        position: &Position,
1088        candle: &Candle,
1089        hwm: Option<f64>,
1090    ) -> Option<Signal> {
1091        // Per-trade bracket overrides take precedence over config-level defaults.
1092        let sl_pct = position.bracket_stop_loss_pct.or(self.config.stop_loss_pct);
1093        let tp_pct = position
1094            .bracket_take_profit_pct
1095            .or(self.config.take_profit_pct);
1096        let trail_pct = position
1097            .bracket_trailing_stop_pct
1098            .or(self.config.trailing_stop_pct);
1099
1100        // Stop-loss — intrabar breach via low (long) or high (short)
1101        if let Some(sl_pct) = sl_pct {
1102            let stop_price = if position.is_long() {
1103                position.entry_price * (1.0 - sl_pct)
1104            } else {
1105                position.entry_price * (1.0 + sl_pct)
1106            };
1107            let triggered = if position.is_long() {
1108                candle.low <= stop_price
1109            } else {
1110                candle.high >= stop_price
1111            };
1112            if triggered {
1113                // if the bar already opened through the stop level, fill
1114                // at the open (slippage/gap) rather than the stop price.
1115                let fill_price = if position.is_long() {
1116                    candle.open.min(stop_price)
1117                } else {
1118                    candle.open.max(stop_price)
1119                };
1120                let return_pct = position.unrealized_return_pct(fill_price);
1121                return Some(
1122                    Signal::exit(candle.timestamp, fill_price)
1123                        .with_reason(format!("Stop-loss triggered ({:.1}%)", return_pct)),
1124                );
1125            }
1126        }
1127
1128        // Take-profit — intrabar breach via high (long) or low (short)
1129        if let Some(tp_pct) = tp_pct {
1130            let tp_price = if position.is_long() {
1131                position.entry_price * (1.0 + tp_pct)
1132            } else {
1133                position.entry_price * (1.0 - tp_pct)
1134            };
1135            let triggered = if position.is_long() {
1136                candle.high >= tp_price
1137            } else {
1138                candle.low <= tp_price
1139            };
1140            if triggered {
1141                // Gap guard: a gap-up open past TP gives a better fill at the open.
1142                let fill_price = if position.is_long() {
1143                    candle.open.max(tp_price)
1144                } else {
1145                    candle.open.min(tp_price)
1146                };
1147                let return_pct = position.unrealized_return_pct(fill_price);
1148                return Some(
1149                    Signal::exit(candle.timestamp, fill_price)
1150                        .with_reason(format!("Take-profit triggered ({:.1}%)", return_pct)),
1151                );
1152            }
1153        }
1154
1155        // Trailing stop — checked after SL/TP so explicit levels take priority.
1156        //    `hwm` is already updated to the intrabar extreme before this call.
1157        if let Some(trail_pct) = trail_pct
1158            && let Some(extreme) = hwm
1159            && extreme > 0.0
1160        {
1161            let trail_stop_price = if position.is_long() {
1162                extreme * (1.0 - trail_pct)
1163            } else {
1164                extreme * (1.0 + trail_pct)
1165            };
1166            let triggered = if position.is_long() {
1167                candle.low <= trail_stop_price
1168            } else {
1169                candle.high >= trail_stop_price
1170            };
1171            if triggered {
1172                let fill_price = if position.is_long() {
1173                    candle.open.min(trail_stop_price)
1174                } else {
1175                    candle.open.max(trail_stop_price)
1176                };
1177                let adverse_move_pct = if position.is_long() {
1178                    (extreme - fill_price) / extreme
1179                } else {
1180                    (fill_price - extreme) / extreme
1181                };
1182                return Some(
1183                    Signal::exit(candle.timestamp, fill_price).with_reason(format!(
1184                        "Trailing stop triggered ({:.1}% adverse move)",
1185                        adverse_move_pct * 100.0
1186                    )),
1187                );
1188            }
1189        }
1190
1191        None
1192    }
1193
1194    /// Execute a signal, modifying position and cash
1195    fn execute_signal(
1196        &self,
1197        signal: &Signal,
1198        candle: &Candle,
1199        position: &mut Option<Position>,
1200        cash: &mut f64,
1201        trades: &mut Vec<Trade>,
1202    ) -> bool {
1203        match signal.direction {
1204            SignalDirection::Long => {
1205                if position.is_some() {
1206                    return false; // Already have a position
1207                }
1208                self.open_position(position, cash, candle, signal, true)
1209            }
1210            SignalDirection::Short => {
1211                if position.is_some() {
1212                    return false; // Already have a position
1213                }
1214                if !self.config.allow_short {
1215                    return false; // Shorts not allowed
1216                }
1217                self.open_position(position, cash, candle, signal, false)
1218            }
1219            SignalDirection::Exit => {
1220                if position.is_none() {
1221                    return false; // No position to exit
1222                }
1223                self.close_position(position, cash, trades, candle, signal)
1224            }
1225            SignalDirection::ScaleIn => self.scale_into_position(position, cash, signal, candle),
1226            SignalDirection::ScaleOut => {
1227                self.scale_out_position(position, cash, trades, signal, candle)
1228            }
1229            SignalDirection::Hold => false,
1230        }
1231    }
1232
1233    /// Add to an existing open position (pyramid / scale in).
1234    ///
1235    /// Allocates `signal.scale_fraction` of current portfolio equity to additional
1236    /// shares at the next-bar fill price. Updates the position's weighted-average
1237    /// entry price. No-op when no position is open.
1238    fn scale_into_position(
1239        &self,
1240        position: &mut Option<Position>,
1241        cash: &mut f64,
1242        signal: &Signal,
1243        candle: &Candle,
1244    ) -> bool {
1245        let fraction = signal.scale_fraction.unwrap_or(0.0).clamp(0.0, 1.0);
1246        if fraction <= 0.0 {
1247            return false;
1248        }
1249
1250        let pos = match position.as_mut() {
1251            Some(p) => p,
1252            None => return false,
1253        };
1254
1255        let is_long = pos.is_long();
1256        let fill_price_slipped = self.config.apply_entry_slippage(candle.open, is_long);
1257        let fill_price = self.config.apply_entry_spread(fill_price_slipped, is_long);
1258
1259        // Allocate `fraction` of current portfolio equity to the additional tranche.
1260        let equity = *cash + pos.current_value(candle.open) + pos.unreinvested_dividends;
1261        let additional_value = equity * fraction;
1262        let additional_qty = if fill_price > 0.0 {
1263            additional_value / fill_price
1264        } else {
1265            return false;
1266        };
1267
1268        if additional_qty <= 0.0 {
1269            return false;
1270        }
1271
1272        let commission = self.config.calculate_commission(additional_qty, fill_price);
1273        let entry_tax = self
1274            .config
1275            .calculate_transaction_tax(additional_value, is_long);
1276        let total_cost = if is_long {
1277            additional_value + commission + entry_tax
1278        } else {
1279            commission
1280        };
1281
1282        if total_cost > *cash {
1283            return false; // Not enough cash
1284        }
1285
1286        if is_long {
1287            *cash -= additional_value + commission + entry_tax;
1288        } else {
1289            *cash += additional_value - commission;
1290        }
1291
1292        pos.scale_in(fill_price, additional_qty, commission, entry_tax);
1293        true
1294    }
1295
1296    /// Partially or fully close an existing open position (scale out).
1297    ///
1298    /// Closes `signal.scale_fraction` of the current position quantity at the
1299    /// next-bar fill price.  A fraction of `1.0` is equivalent to a full
1300    /// [`Signal::exit`] and delegates to [`close_position`](Self::close_position).
1301    /// No-op when no position is open.
1302    fn scale_out_position(
1303        &self,
1304        position: &mut Option<Position>,
1305        cash: &mut f64,
1306        trades: &mut Vec<Trade>,
1307        signal: &Signal,
1308        candle: &Candle,
1309    ) -> bool {
1310        let fraction = signal.scale_fraction.unwrap_or(0.0).clamp(0.0, 1.0);
1311        if fraction <= 0.0 {
1312            return false;
1313        }
1314
1315        // Full close — delegate to the standard exit path so all bookkeeping
1316        // (cash credit, HWM reset, re-evaluation) is handled identically.
1317        if fraction >= 1.0 {
1318            return self.close_position(position, cash, trades, candle, signal);
1319        }
1320
1321        let pos = match position.as_mut() {
1322            Some(p) => p,
1323            None => return false,
1324        };
1325
1326        let is_long = pos.is_long();
1327        let exit_price_slipped = self.config.apply_exit_slippage(candle.open, is_long);
1328        let exit_price = self.config.apply_exit_spread(exit_price_slipped, is_long);
1329        let qty_closed = pos.quantity * fraction;
1330        let commission = self.config.calculate_commission(qty_closed, exit_price);
1331        let exit_tax = self
1332            .config
1333            .calculate_transaction_tax(exit_price * qty_closed, !is_long);
1334
1335        let trade = pos.partial_close(
1336            fraction,
1337            candle.timestamp,
1338            exit_price,
1339            commission,
1340            exit_tax,
1341            signal.clone(),
1342        );
1343
1344        // `commission` and `exit_tax` here are the exit-side cash flows only.
1345        // `trade.commission` / `trade.transaction_tax` also include the proportional
1346        // entry cost slice (for P&L reporting), but those were already debited from
1347        // cash at entry time and must not be debited again here.
1348        if trade.is_long() {
1349            *cash += trade.exit_value() - commission + trade.unreinvested_dividends;
1350        } else {
1351            *cash -= trade.exit_value() + commission + exit_tax - trade.unreinvested_dividends;
1352        }
1353        trades.push(trade);
1354        true
1355    }
1356
1357    /// Open a new position at `candle.open` (market fill).
1358    fn open_position(
1359        &self,
1360        position: &mut Option<Position>,
1361        cash: &mut f64,
1362        candle: &Candle,
1363        signal: &Signal,
1364        is_long: bool,
1365    ) -> bool {
1366        self.open_position_at_price(position, cash, candle, signal, is_long, candle.open)
1367    }
1368
1369    /// Open a new position at an explicit fill price.
1370    ///
1371    /// Used for pending limit/stop order fills where the computed order price
1372    /// (with gap guard) is the fill price rather than the next bar's open.
1373    fn open_position_at_price(
1374        &self,
1375        position: &mut Option<Position>,
1376        cash: &mut f64,
1377        candle: &Candle,
1378        signal: &Signal,
1379        is_long: bool,
1380        fill_price_raw: f64,
1381    ) -> bool {
1382        let entry_price_slipped = self.config.apply_entry_slippage(fill_price_raw, is_long);
1383        let entry_price = self.config.apply_entry_spread(entry_price_slipped, is_long);
1384        let quantity = self.config.calculate_position_size(*cash, entry_price);
1385
1386        if quantity <= 0.0 {
1387            return false; // Not enough capital
1388        }
1389
1390        let entry_value = entry_price * quantity;
1391        let commission = self.config.calculate_commission(quantity, entry_price);
1392        // Tax on buy orders only: long entries are buys
1393        let entry_tax = self.config.calculate_transaction_tax(entry_value, is_long);
1394
1395        if is_long {
1396            if entry_value + commission + entry_tax > *cash {
1397                return false; // Not enough capital including commission and tax
1398            }
1399        } else if commission > *cash {
1400            return false; // Not enough cash to pay entry commission
1401        }
1402
1403        let side = if is_long {
1404            PositionSide::Long
1405        } else {
1406            PositionSide::Short
1407        };
1408
1409        if is_long {
1410            *cash -= entry_value + commission + entry_tax;
1411        } else {
1412            *cash += entry_value - commission;
1413        }
1414        *position = Some(Position::new_with_tax(
1415            side,
1416            candle.timestamp,
1417            entry_price,
1418            quantity,
1419            commission,
1420            entry_tax,
1421            signal.clone(),
1422        ));
1423
1424        true
1425    }
1426
1427    /// Close an existing position at the next bar's open (used for strategy-signal exits).
1428    fn close_position(
1429        &self,
1430        position: &mut Option<Position>,
1431        cash: &mut f64,
1432        trades: &mut Vec<Trade>,
1433        candle: &Candle,
1434        signal: &Signal,
1435    ) -> bool {
1436        self.close_position_at(position, cash, trades, candle, candle.open, signal)
1437    }
1438
1439    /// Close an existing position at an explicit `fill_price`.
1440    ///
1441    /// Used for intrabar SL/TP/trailing-stop exits where the fill price is the
1442    /// computed stop/TP level (with gap guard) rather than the next bar's open.
1443    fn close_position_at(
1444        &self,
1445        position: &mut Option<Position>,
1446        cash: &mut f64,
1447        trades: &mut Vec<Trade>,
1448        candle: &Candle,
1449        fill_price: f64,
1450        signal: &Signal,
1451    ) -> bool {
1452        let pos = match position.take() {
1453            Some(p) => p,
1454            None => return false,
1455        };
1456
1457        let exit_price_slipped = self.config.apply_exit_slippage(fill_price, pos.is_long());
1458        let exit_price = self
1459            .config
1460            .apply_exit_spread(exit_price_slipped, pos.is_long());
1461        let exit_commission = self.config.calculate_commission(pos.quantity, exit_price);
1462        // Tax on buy orders only: short covers are buys
1463        let exit_tax = self
1464            .config
1465            .calculate_transaction_tax(exit_price * pos.quantity, !pos.is_long());
1466
1467        let trade = pos.close_with_tax(
1468            candle.timestamp,
1469            exit_price,
1470            exit_commission,
1471            exit_tax,
1472            signal.clone(),
1473        );
1474
1475        if trade.is_long() {
1476            *cash += trade.exit_value() - exit_commission + trade.unreinvested_dividends;
1477        } else {
1478            *cash -= trade.exit_value() + exit_commission + exit_tax - trade.unreinvested_dividends;
1479        }
1480        trades.push(trade);
1481
1482        true
1483    }
1484}
1485
1486// ── Shared helpers ─────────────────────────────────────────────────────────────
1487
1488/// Update the trailing-stop high-water mark (peak for longs, trough for shorts).
1489///
1490/// Uses the candle's intrabar extreme (`high` for longs, `low` for shorts) so
1491/// that the trailing stop correctly reflects the best price reached during the bar,
1492/// not just the close.
1493///
1494/// Cleared to `None` when no position is open so it resets on next entry.
1495/// Also used by the portfolio engine.
1496pub(crate) fn update_trailing_hwm(
1497    position: Option<&Position>,
1498    hwm: &mut Option<f64>,
1499    candle: &Candle,
1500) {
1501    if let Some(pos) = position {
1502        *hwm = Some(match *hwm {
1503            None => {
1504                if pos.is_long() {
1505                    candle.high
1506                } else {
1507                    candle.low
1508                }
1509            }
1510            Some(prev) => {
1511                if pos.is_long() {
1512                    prev.max(candle.high)
1513                } else {
1514                    prev.min(candle.low) // trough for shorts
1515                }
1516            }
1517        });
1518    } else {
1519        *hwm = None;
1520    }
1521}
1522
1523impl BacktestEngine {
1524    fn sync_terminal_equity_point(
1525        equity_curve: &mut Vec<EquityPoint>,
1526        timestamp: i64,
1527        equity: f64,
1528    ) {
1529        if let Some(last) = equity_curve.last_mut()
1530            && last.timestamp == timestamp
1531        {
1532            last.equity = equity;
1533        } else {
1534            equity_curve.push(EquityPoint {
1535                timestamp,
1536                equity,
1537                drawdown_pct: 0.0,
1538            });
1539        }
1540
1541        let peak = equity_curve
1542            .iter()
1543            .map(|point| point.equity)
1544            .fold(f64::NEG_INFINITY, f64::max);
1545        let drawdown = if peak.is_finite() && peak > 0.0 {
1546            (peak - equity) / peak
1547        } else {
1548            0.0
1549        };
1550
1551        if let Some(last) = equity_curve.last_mut() {
1552            last.drawdown_pct = drawdown;
1553        }
1554    }
1555}
1556
1557/// Compute benchmark comparison metrics for a completed backtest.
1558///
1559/// `symbol_candles` are the candles for the backtested symbol (used to derive
1560/// its buy-and-hold return). `benchmark_candles` are the benchmark's candles.
1561/// `equity_curve` is used to derive strategy periodic returns for beta/IR.
1562fn compute_benchmark_metrics(
1563    benchmark_symbol: &str,
1564    symbol_candles: &[Candle],
1565    benchmark_candles: &[Candle],
1566    equity_curve: &[EquityPoint],
1567    risk_free_rate: f64,
1568    bars_per_year: f64,
1569) -> BenchmarkMetrics {
1570    // Buy-and-hold returns from first to last close
1571    let benchmark_return_pct = buy_and_hold_return(benchmark_candles);
1572    let buy_and_hold_return_pct = buy_and_hold_return(symbol_candles);
1573
1574    if equity_curve.len() < 2 || benchmark_candles.len() < 2 {
1575        return BenchmarkMetrics {
1576            symbol: benchmark_symbol.to_string(),
1577            benchmark_return_pct,
1578            buy_and_hold_return_pct,
1579            alpha: 0.0,
1580            beta: 0.0,
1581            information_ratio: 0.0,
1582        };
1583    }
1584
1585    let strategy_returns_by_ts: Vec<(i64, f64)> = equity_curve
1586        .windows(2)
1587        .map(|w| {
1588            let prev = w[0].equity;
1589            let ret = if prev > 0.0 {
1590                (w[1].equity - prev) / prev
1591            } else {
1592                0.0
1593            };
1594            (w[1].timestamp, ret)
1595        })
1596        .collect();
1597
1598    let bench_returns_by_ts: HashMap<i64, f64> = benchmark_candles
1599        .windows(2)
1600        .map(|w| {
1601            let prev = w[0].close;
1602            let ret = if prev > 0.0 {
1603                (w[1].close - prev) / prev
1604            } else {
1605                0.0
1606            };
1607            (w[1].timestamp, ret)
1608        })
1609        .collect();
1610
1611    let mut aligned_strategy = Vec::new();
1612    let mut aligned_benchmark = Vec::new();
1613    for (ts, s_ret) in strategy_returns_by_ts {
1614        if let Some(b_ret) = bench_returns_by_ts.get(&ts) {
1615            aligned_strategy.push(s_ret);
1616            aligned_benchmark.push(*b_ret);
1617        }
1618    }
1619
1620    let beta = compute_beta(&aligned_strategy, &aligned_benchmark);
1621
1622    // CAPM alpha on the same aligned sample used for beta/IR.
1623    let strategy_ann = annualized_return_from_periodic(&aligned_strategy, bars_per_year);
1624    let bench_ann = annualized_return_from_periodic(&aligned_benchmark, bars_per_year);
1625    // Jensen's Alpha: excess strategy return over what CAPM predicts given beta.
1626    // Both strategy_ann and bench_ann are in percentage form (×100), so rf_ann is scaled
1627    // to match before applying the CAPM formula: α = R_s - R_f - β(R_b - R_f).
1628    let rf_ann = risk_free_rate * 100.0;
1629    let alpha = strategy_ann - rf_ann - beta * (bench_ann - rf_ann);
1630
1631    // Information ratio: (excess returns mean / tracking error) * sqrt(bars_per_year)
1632    // Uses sample standard deviation (n-1) for consistency with Sharpe/Sortino.
1633    let excess: Vec<f64> = aligned_strategy
1634        .iter()
1635        .zip(aligned_benchmark.iter())
1636        .map(|(si, bi)| si - bi)
1637        .collect();
1638    let ir = if excess.len() >= 2 {
1639        let n = excess.len() as f64;
1640        let mean = excess.iter().sum::<f64>() / n;
1641        // Sample variance (n-1)
1642        let variance = excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1.0);
1643        let std_dev = variance.sqrt();
1644        if std_dev > 0.0 {
1645            (mean / std_dev) * bars_per_year.sqrt()
1646        } else {
1647            0.0
1648        }
1649    } else {
1650        0.0
1651    };
1652
1653    BenchmarkMetrics {
1654        symbol: benchmark_symbol.to_string(),
1655        benchmark_return_pct,
1656        buy_and_hold_return_pct,
1657        alpha,
1658        beta,
1659        information_ratio: ir,
1660    }
1661}
1662
1663/// Buy-and-hold return from first to last candle close (percentage).
1664fn buy_and_hold_return(candles: &[Candle]) -> f64 {
1665    match (candles.first(), candles.last()) {
1666        (Some(first), Some(last)) if first.close > 0.0 => {
1667            ((last.close / first.close) - 1.0) * 100.0
1668        }
1669        _ => 0.0,
1670    }
1671}
1672
1673/// Annualised return from periodic returns (fractional, e.g. 0.01 for 1%).
1674fn annualized_return_from_periodic(periodic_returns: &[f64], bars_per_year: f64) -> f64 {
1675    let years = periodic_returns.len() as f64 / bars_per_year;
1676    if years > 0.0 {
1677        let growth = periodic_returns
1678            .iter()
1679            .fold(1.0_f64, |acc, r| acc * (1.0 + *r));
1680        if growth <= 0.0 {
1681            -100.0
1682        } else {
1683            (growth.powf(1.0 / years) - 1.0) * 100.0
1684        }
1685    } else {
1686        0.0
1687    }
1688}
1689
1690/// Compute beta of `strategy_returns` relative to `benchmark_returns`.
1691///
1692/// Beta = Cov(strategy, benchmark) / Var(benchmark).
1693/// Uses sample covariance and variance (divides by n-1) to match the `risk`
1694/// module and standard financial convention. Returns 0.0 when benchmark
1695/// variance is zero or there are fewer than 2 observations.
1696fn compute_beta(strategy_returns: &[f64], benchmark_returns: &[f64]) -> f64 {
1697    let n = strategy_returns.len();
1698    if n < 2 {
1699        return 0.0;
1700    }
1701
1702    let s_mean = strategy_returns.iter().sum::<f64>() / n as f64;
1703    let b_mean = benchmark_returns.iter().sum::<f64>() / n as f64;
1704
1705    // Sample covariance and variance (n-1)
1706    let cov: f64 = strategy_returns
1707        .iter()
1708        .zip(benchmark_returns.iter())
1709        .map(|(s, b)| (s - s_mean) * (b - b_mean))
1710        .sum::<f64>()
1711        / (n - 1) as f64;
1712
1713    let b_var: f64 = benchmark_returns
1714        .iter()
1715        .map(|b| (b - b_mean).powi(2))
1716        .sum::<f64>()
1717        / (n - 1) as f64;
1718
1719    if b_var > 0.0 { cov / b_var } else { 0.0 }
1720}
1721
1722#[cfg(test)]
1723mod tests {
1724    use super::*;
1725    use crate::backtesting::strategy::SmaCrossover;
1726    use crate::backtesting::strategy::Strategy;
1727    use crate::indicators::Indicator;
1728
1729    #[derive(Clone)]
1730    struct EnterLongHold;
1731
1732    impl Strategy for EnterLongHold {
1733        fn name(&self) -> &str {
1734            "Enter Long Hold"
1735        }
1736
1737        fn required_indicators(&self) -> Vec<(String, Indicator)> {
1738            vec![]
1739        }
1740
1741        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
1742            if ctx.index == 0 && !ctx.has_position() {
1743                Signal::long(ctx.timestamp(), ctx.close())
1744            } else {
1745                Signal::hold()
1746            }
1747        }
1748    }
1749
1750    #[derive(Clone)]
1751    struct EnterShortHold;
1752
1753    impl Strategy for EnterShortHold {
1754        fn name(&self) -> &str {
1755            "Enter Short Hold"
1756        }
1757
1758        fn required_indicators(&self) -> Vec<(String, Indicator)> {
1759            vec![]
1760        }
1761
1762        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
1763            if ctx.index == 0 && !ctx.has_position() {
1764                Signal::short(ctx.timestamp(), ctx.close())
1765            } else {
1766                Signal::hold()
1767            }
1768        }
1769    }
1770
1771    fn make_candles(prices: &[f64]) -> Vec<Candle> {
1772        prices
1773            .iter()
1774            .enumerate()
1775            .map(|(i, &p)| Candle {
1776                timestamp: i as i64,
1777                open: p,
1778                high: p * 1.01,
1779                low: p * 0.99,
1780                close: p,
1781                volume: 1000,
1782                adj_close: Some(p),
1783            })
1784            .collect()
1785    }
1786
1787    fn make_candles_with_timestamps(prices: &[f64], timestamps: &[i64]) -> Vec<Candle> {
1788        prices
1789            .iter()
1790            .zip(timestamps.iter())
1791            .map(|(&p, &ts)| Candle {
1792                timestamp: ts,
1793                open: p,
1794                high: p * 1.01,
1795                low: p * 0.99,
1796                close: p,
1797                volume: 1000,
1798                adj_close: Some(p),
1799            })
1800            .collect()
1801    }
1802
1803    #[test]
1804    fn test_engine_basic() {
1805        // Price trends up then down - should trigger crossover signals
1806        let mut prices = vec![100.0; 30];
1807        // Make fast SMA cross above slow SMA around bar 15
1808        for (i, price) in prices.iter_mut().enumerate().take(25).skip(15) {
1809            *price = 100.0 + (i - 15) as f64 * 2.0;
1810        }
1811        // Then cross back down
1812        for (i, price) in prices.iter_mut().enumerate().take(30).skip(25) {
1813            *price = 118.0 - (i - 25) as f64 * 3.0;
1814        }
1815
1816        let candles = make_candles(&prices);
1817        let config = BacktestConfig::builder()
1818            .initial_capital(10_000.0)
1819            .commission_pct(0.0)
1820            .slippage_pct(0.0)
1821            .build()
1822            .unwrap();
1823
1824        let engine = BacktestEngine::new(config);
1825        let strategy = SmaCrossover::new(5, 10);
1826        let result = engine.run("TEST", &candles, strategy).unwrap();
1827
1828        assert_eq!(result.symbol, "TEST");
1829        assert_eq!(result.strategy_name, "SMA Crossover");
1830        assert!(!result.equity_curve.is_empty());
1831    }
1832
1833    #[test]
1834    fn test_stop_loss() {
1835        // Price drops significantly after entry
1836        let mut prices = vec![100.0; 20];
1837        // Trend up to trigger long entry
1838        for (i, price) in prices.iter_mut().enumerate().take(15).skip(10) {
1839            *price = 100.0 + (i - 10) as f64 * 2.0;
1840        }
1841        // Then crash
1842        for (i, price) in prices.iter_mut().enumerate().take(20).skip(15) {
1843            *price = 108.0 - (i - 15) as f64 * 10.0;
1844        }
1845
1846        let candles = make_candles(&prices);
1847        let config = BacktestConfig::builder()
1848            .initial_capital(10_000.0)
1849            .stop_loss_pct(0.05) // 5% stop loss
1850            .commission_pct(0.0)
1851            .slippage_pct(0.0)
1852            .build()
1853            .unwrap();
1854
1855        let engine = BacktestEngine::new(config);
1856        let strategy = SmaCrossover::new(3, 6);
1857        let result = engine.run("TEST", &candles, strategy).unwrap();
1858
1859        // Should have triggered stop-loss
1860        let _sl_signals: Vec<_> = result
1861            .signals
1862            .iter()
1863            .filter(|s| {
1864                s.reason
1865                    .as_ref()
1866                    .map(|r| r.contains("Stop-loss"))
1867                    .unwrap_or(false)
1868            })
1869            .collect();
1870
1871        // May or may not trigger depending on exact timing
1872        // The important thing is the engine doesn't crash
1873        assert!(!result.equity_curve.is_empty());
1874    }
1875
1876    #[test]
1877    fn test_trailing_stop() {
1878        // Price rises to 120, then drops 10%+ → trailing stop should fire
1879        let mut prices: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
1880        // Peak is 119; now drop past 10% from peak (< 107.1)
1881        prices.extend_from_slice(&[105.0, 103.0, 101.0]);
1882
1883        let candles = make_candles(&prices);
1884        let config = BacktestConfig::builder()
1885            .initial_capital(10_000.0)
1886            .trailing_stop_pct(0.10)
1887            .commission_pct(0.0)
1888            .slippage_pct(0.0)
1889            .build()
1890            .unwrap();
1891
1892        let engine = BacktestEngine::new(config);
1893        let strategy = SmaCrossover::new(3, 6);
1894        let result = engine.run("TEST", &candles, strategy).unwrap();
1895
1896        let trail_exits: Vec<_> = result
1897            .signals
1898            .iter()
1899            .filter(|s| {
1900                s.reason
1901                    .as_ref()
1902                    .map(|r| r.contains("Trailing stop"))
1903                    .unwrap_or(false)
1904            })
1905            .collect();
1906
1907        // Not guaranteed to fire given the specific crossover timing, but engine must not crash
1908        let _ = trail_exits;
1909        assert!(!result.equity_curve.is_empty());
1910    }
1911
1912    #[test]
1913    fn test_insufficient_data() {
1914        let candles = make_candles(&[100.0, 101.0, 102.0]); // Only 3 candles
1915        let config = BacktestConfig::default();
1916        let engine = BacktestEngine::new(config);
1917        let strategy = SmaCrossover::new(10, 20); // Needs at least 21 candles
1918
1919        let result = engine.run("TEST", &candles, strategy);
1920        assert!(result.is_err());
1921    }
1922
1923    #[test]
1924    fn test_capm_alpha_with_risk_free_rate() {
1925        // When risk_free_rate = 0, alpha should equal the simplified formula.
1926        // When risk_free_rate > 0, the CAPM adjustment should reduce alpha.
1927        let prices: Vec<f64> = (0..60).map(|i| 100.0 + i as f64).collect();
1928        let candles = make_candles(&prices);
1929
1930        // Run once with rf=0 and once with rf=0.05, compare benchmark metrics
1931        let config_no_rf = BacktestConfig::builder()
1932            .commission_pct(0.0)
1933            .slippage_pct(0.0)
1934            .risk_free_rate(0.0)
1935            .build()
1936            .unwrap();
1937        let config_with_rf = BacktestConfig::builder()
1938            .commission_pct(0.0)
1939            .slippage_pct(0.0)
1940            .risk_free_rate(0.05)
1941            .build()
1942            .unwrap();
1943
1944        let engine_no_rf = BacktestEngine::new(config_no_rf);
1945        let engine_with_rf = BacktestEngine::new(config_with_rf);
1946
1947        // Use same candles for both strategy and benchmark to get beta ≈ 1
1948        let result_no_rf = engine_no_rf
1949            .run_with_benchmark(
1950                "TEST",
1951                &candles,
1952                SmaCrossover::new(3, 10),
1953                &[],
1954                "BENCH",
1955                &candles,
1956            )
1957            .unwrap();
1958        let result_with_rf = engine_with_rf
1959            .run_with_benchmark(
1960                "TEST",
1961                &candles,
1962                SmaCrossover::new(3, 10),
1963                &[],
1964                "BENCH",
1965                &candles,
1966            )
1967            .unwrap();
1968
1969        let bm_no_rf = result_no_rf.benchmark.unwrap();
1970        let bm_with_rf = result_with_rf.benchmark.unwrap();
1971
1972        // With identical strategy and benchmark (beta = 1), Jensen's alpha ≈ 0 always.
1973        // Both should be close to 0, but importantly they should differ when rf != 0.
1974        // This test ensures the formula uses rf — it catches the old bug where rf was ignored.
1975        assert!(bm_no_rf.alpha.is_finite(), "Alpha should be finite");
1976        assert!(
1977            bm_with_rf.alpha.is_finite(),
1978            "Alpha should be finite with rf"
1979        );
1980
1981        // With beta ≈ 1 and rf=5%, CAPM alpha = R_s - 5% - 1*(R_b - 5%) = R_s - R_b.
1982        // Same formula result as rf=0 when beta=1; but the formula path is exercised.
1983        // The key check: alpha is the same sign in both (both near-zero).
1984        assert!(
1985            bm_no_rf.alpha.abs() < 50.0,
1986            "Alpha should be small for identical strategy/benchmark"
1987        );
1988        assert!(
1989            bm_with_rf.alpha.abs() < 50.0,
1990            "Alpha should be small for identical strategy/benchmark with rf"
1991        );
1992    }
1993
1994    #[test]
1995    fn test_run_with_benchmark_credits_dividends() {
1996        use crate::models::chart::Dividend;
1997
1998        // Rising price series — long enough for SmaCrossover(3,6) to trade
1999        let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
2000        let candles = make_candles(&prices);
2001
2002        // A single dividend ex-dated mid-series
2003        let mid_ts = candles[15].timestamp;
2004        let dividends = vec![Dividend {
2005            timestamp: mid_ts,
2006            amount: 1.0,
2007        }];
2008
2009        let config = BacktestConfig::builder()
2010            .initial_capital(10_000.0)
2011            .commission_pct(0.0)
2012            .slippage_pct(0.0)
2013            .build()
2014            .unwrap();
2015
2016        let engine = BacktestEngine::new(config);
2017        let result = engine
2018            .run_with_benchmark(
2019                "TEST",
2020                &candles,
2021                SmaCrossover::new(3, 6),
2022                &dividends,
2023                "BENCH",
2024                &candles,
2025            )
2026            .unwrap();
2027
2028        // Dividend income is credited only while a position is open.
2029        // If no trade happened to be open on bar 15 the income is zero;
2030        // either way the engine must not panic and the benchmark must be set.
2031        assert!(result.benchmark.is_some());
2032        let total_div: f64 = result.trades.iter().map(|t| t.dividend_income).sum();
2033        // total_dividend_income is non-negative (either credited or not, never negative)
2034        assert!(total_div >= 0.0);
2035    }
2036
2037    /// The fundamental invariant: final cash (when no position is open) must equal
2038    /// initial_capital plus the sum of all realized trade P&Ls.  This guards against
2039    /// the double-counting of commissions that existed before the fix.
2040    #[test]
2041    fn test_commission_accounting_invariant() {
2042        // Steadily rising prices so SmaCrossover(3,6) will definitely enter and exit.
2043        let prices: Vec<f64> = (0..40)
2044            .map(|i| {
2045                if i < 30 {
2046                    100.0 + i as f64
2047                } else {
2048                    129.0 - (i - 30) as f64 * 5.0
2049                }
2050            })
2051            .collect();
2052        let candles = make_candles(&prices);
2053
2054        // Use both flat AND percentage commission to expose any double-counting.
2055        let config = BacktestConfig::builder()
2056            .initial_capital(10_000.0)
2057            .commission(5.0) // $5 flat fee per trade
2058            .commission_pct(0.001) // + 0.1% per trade
2059            .slippage_pct(0.0)
2060            .close_at_end(true)
2061            .build()
2062            .unwrap();
2063
2064        let engine = BacktestEngine::new(config.clone());
2065        let result = engine
2066            .run("TEST", &candles, SmaCrossover::new(3, 6))
2067            .unwrap();
2068
2069        // When all positions are closed, cash == initial_capital + sum(trade pnls)
2070        let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2071        let expected = config.initial_capital + sum_pnl;
2072        let actual = result.final_equity;
2073        assert!(
2074            (actual - expected).abs() < 1e-6,
2075            "Commission accounting: final_equity {actual:.6} != initial_capital + sum(pnl) {expected:.6}",
2076        );
2077    }
2078
2079    #[test]
2080    fn test_unsorted_dividends_returns_error() {
2081        use crate::models::chart::Dividend;
2082
2083        let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
2084        let candles = make_candles(&prices);
2085
2086        // Intentionally unsorted
2087        let dividends = vec![
2088            Dividend {
2089                timestamp: 20,
2090                amount: 1.0,
2091            },
2092            Dividend {
2093                timestamp: 10,
2094                amount: 1.0,
2095            },
2096        ];
2097
2098        let engine = BacktestEngine::new(BacktestConfig::default());
2099        let result =
2100            engine.run_with_dividends("TEST", &candles, SmaCrossover::new(3, 6), &dividends);
2101        assert!(result.is_err());
2102        let msg = result.unwrap_err().to_string();
2103        assert!(
2104            msg.contains("sorted"),
2105            "error should mention sorting: {msg}"
2106        );
2107    }
2108
2109    #[test]
2110    fn test_short_dividend_is_liability() {
2111        use crate::models::chart::Dividend;
2112
2113        let candles = make_candles(&[100.0, 100.0, 100.0]);
2114        let dividends = vec![Dividend {
2115            timestamp: candles[1].timestamp,
2116            amount: 1.0,
2117        }];
2118
2119        let config = BacktestConfig::builder()
2120            .initial_capital(10_000.0)
2121            .allow_short(true)
2122            .commission_pct(0.0)
2123            .slippage_pct(0.0)
2124            .build()
2125            .unwrap();
2126
2127        let engine = BacktestEngine::new(config);
2128        let result = engine
2129            .run_with_dividends("TEST", &candles, EnterShortHold, &dividends)
2130            .unwrap();
2131
2132        assert_eq!(result.trades.len(), 1);
2133        assert!(result.trades[0].dividend_income < 0.0);
2134        assert!(result.final_equity < 10_000.0);
2135    }
2136
2137    #[test]
2138    fn test_open_position_final_equity_includes_accrued_dividends() {
2139        use crate::models::chart::Dividend;
2140
2141        let candles = make_candles(&[100.0, 100.0, 100.0]);
2142        let dividends = vec![Dividend {
2143            timestamp: candles[1].timestamp,
2144            amount: 1.0,
2145        }];
2146
2147        let config = BacktestConfig::builder()
2148            .initial_capital(10_000.0)
2149            .close_at_end(false)
2150            .commission_pct(0.0)
2151            .slippage_pct(0.0)
2152            .build()
2153            .unwrap();
2154
2155        let engine = BacktestEngine::new(config);
2156        let result = engine
2157            .run_with_dividends("TEST", &candles, EnterLongHold, &dividends)
2158            .unwrap();
2159
2160        assert!(result.open_position.is_some());
2161        assert!((result.final_equity - 10_100.0).abs() < 1e-6);
2162        let last_equity = result.equity_curve.last().map(|p| p.equity).unwrap_or(0.0);
2163        assert!((last_equity - 10_100.0).abs() < 1e-6);
2164    }
2165
2166    #[test]
2167    fn test_benchmark_beta_and_ir_require_timestamp_overlap() {
2168        let symbol_candles = make_candles_with_timestamps(&[100.0, 110.0, 120.0], &[100, 200, 300]);
2169        let benchmark_candles =
2170            make_candles_with_timestamps(&[50.0, 55.0, 60.0, 65.0], &[1000, 1100, 1200, 1300]);
2171
2172        let config = BacktestConfig::builder()
2173            .initial_capital(10_000.0)
2174            .commission_pct(0.0)
2175            .slippage_pct(0.0)
2176            .build()
2177            .unwrap();
2178
2179        let engine = BacktestEngine::new(config);
2180        let result = engine
2181            .run_with_benchmark(
2182                "TEST",
2183                &symbol_candles,
2184                EnterLongHold,
2185                &[],
2186                "BENCH",
2187                &benchmark_candles,
2188            )
2189            .unwrap();
2190
2191        let benchmark = result.benchmark.unwrap();
2192        assert!((benchmark.beta - 0.0).abs() < 1e-12);
2193        assert!((benchmark.information_ratio - 0.0).abs() < 1e-12);
2194    }
2195
2196    /// Build a candle with explicit OHLC values (not derived from a single price).
2197    fn make_candle_ohlc(ts: i64, open: f64, high: f64, low: f64, close: f64) -> Candle {
2198        Candle {
2199            timestamp: ts,
2200            open,
2201            high,
2202            low,
2203            close,
2204            volume: 1000,
2205            adj_close: Some(close),
2206        }
2207    }
2208
2209    // ── Intrabar stop / take-profit tests ────────────────────────────────────
2210
2211    /// A strategy that opens a long on the first bar and holds forever.
2212    struct EnterLongBar0;
2213    impl Strategy for EnterLongBar0 {
2214        fn name(&self) -> &str {
2215            "Enter Long Bar 0"
2216        }
2217        fn required_indicators(&self) -> Vec<(String, Indicator)> {
2218            vec![]
2219        }
2220        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2221            if ctx.index == 0 && !ctx.has_position() {
2222                Signal::long(ctx.timestamp(), ctx.close())
2223            } else {
2224                Signal::hold()
2225            }
2226        }
2227    }
2228
2229    #[test]
2230    fn test_intrabar_stop_loss_fills_at_stop_price_not_next_open() {
2231        // Bar 0: open=100, high=101, low=99, close=100 — entry signal fires, filled on bar 1.
2232        // Bar 1: open=100, high=100, low=100, close=100 — entry fills at 100.
2233        // Bar 2: open=99, high=99, low=90, close=94 — low(90) < stop(95); fill at min(open=99, stop=95) = 95.
2234        // With close-only detection, stop would not trigger here (close=94 > stop=95*... wait)
2235        // Actually close=94 < 95 so close-only WOULD trigger, but on the NEXT bar's open (bar 3).
2236        // With intrabar detection, it triggers on bar 2 itself and fills at stop_price=95.
2237        let candles = vec![
2238            make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0), // bar 0: entry signal
2239            make_candle_ohlc(1, 100.0, 102.0, 99.0, 100.0), // bar 1: entry fill at 100
2240            make_candle_ohlc(2, 99.0, 99.0, 90.0, 94.0),    // bar 2: low=90 < stop=95 → fill at 95
2241            make_candle_ohlc(3, 94.0, 95.0, 93.0, 94.0), // bar 3: would be next-bar fill in old code
2242        ];
2243
2244        let config = BacktestConfig::builder()
2245            .initial_capital(10_000.0)
2246            .stop_loss_pct(0.05) // 5% → stop at 100 * 0.95 = 95
2247            .commission_pct(0.0)
2248            .slippage_pct(0.0)
2249            .build()
2250            .unwrap();
2251
2252        let engine = BacktestEngine::new(config);
2253        let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2254
2255        let sl_trade = result.trades.iter().find(|t| {
2256            t.exit_signal
2257                .reason
2258                .as_ref()
2259                .map(|r| r.contains("Stop-loss"))
2260                .unwrap_or(false)
2261        });
2262        assert!(sl_trade.is_some(), "expected a stop-loss trade");
2263        let trade = sl_trade.unwrap();
2264
2265        // Fill must be at the stop price (95.0), not at bar 3's open (94.0).
2266        assert!(
2267            (trade.exit_price - 95.0).abs() < 1e-9,
2268            "expected exit at stop price 95.0, got {:.6}",
2269            trade.exit_price
2270        );
2271        // Exit must be recorded on bar 2's timestamp, not bar 3.
2272        assert_eq!(
2273            trade.exit_timestamp, 2,
2274            "exit should be on bar 2 (intrabar)"
2275        );
2276    }
2277
2278    #[test]
2279    fn test_intrabar_stop_loss_gap_down_fills_at_open() {
2280        // Bar 1: entry at open=100.
2281        // Bar 2: open=92 (already below stop=95) → gap guard → fill at open=92.
2282        let candles = vec![
2283            make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0), // bar 0: entry signal
2284            make_candle_ohlc(1, 100.0, 100.0, 100.0, 100.0), // bar 1: entry fill at 100
2285            make_candle_ohlc(2, 92.0, 92.0, 90.0, 90.0),    // bar 2: gap below stop → fill at 92
2286        ];
2287
2288        let config = BacktestConfig::builder()
2289            .initial_capital(10_000.0)
2290            .stop_loss_pct(0.05) // stop at 95
2291            .commission_pct(0.0)
2292            .slippage_pct(0.0)
2293            .build()
2294            .unwrap();
2295
2296        let engine = BacktestEngine::new(config);
2297        let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2298
2299        let sl_trade = result
2300            .trades
2301            .iter()
2302            .find(|t| {
2303                t.exit_signal
2304                    .reason
2305                    .as_ref()
2306                    .map(|r| r.contains("Stop-loss"))
2307                    .unwrap_or(false)
2308            })
2309            .expect("expected a stop-loss trade");
2310
2311        // Gap-down: open (92) < stop (95) → fill at open.
2312        assert!(
2313            (sl_trade.exit_price - 92.0).abs() < 1e-9,
2314            "expected gap-down fill at 92.0, got {:.6}",
2315            sl_trade.exit_price
2316        );
2317    }
2318
2319    #[test]
2320    fn test_intrabar_take_profit_fills_at_tp_price() {
2321        // Bar 1: entry at 100.
2322        // Bar 2: high=112 > tp=110 → fill at 110 (not next bar's open).
2323        let candles = vec![
2324            make_candle_ohlc(0, 100.0, 101.0, 99.0, 100.0),
2325            make_candle_ohlc(1, 100.0, 100.0, 100.0, 100.0), // entry fill
2326            make_candle_ohlc(2, 105.0, 112.0, 104.0, 111.0), // high > tp → fill at 110
2327            make_candle_ohlc(3, 112.0, 113.0, 111.0, 112.0), // would be next-bar fill in old code
2328        ];
2329
2330        let config = BacktestConfig::builder()
2331            .initial_capital(10_000.0)
2332            .take_profit_pct(0.10) // TP at 100 * 1.10 = 110
2333            .commission_pct(0.0)
2334            .slippage_pct(0.0)
2335            .build()
2336            .unwrap();
2337
2338        let engine = BacktestEngine::new(config);
2339        let result = engine.run("TEST", &candles, EnterLongBar0).unwrap();
2340
2341        let tp_trade = result
2342            .trades
2343            .iter()
2344            .find(|t| {
2345                t.exit_signal
2346                    .reason
2347                    .as_ref()
2348                    .map(|r| r.contains("Take-profit"))
2349                    .unwrap_or(false)
2350            })
2351            .expect("expected a take-profit trade");
2352
2353        assert!(
2354            (tp_trade.exit_price - 110.0).abs() < 1e-9,
2355            "expected TP fill at 110.0, got {:.6}",
2356            tp_trade.exit_price
2357        );
2358        assert_eq!(
2359            tp_trade.exit_timestamp, 2,
2360            "exit should be on bar 2 (intrabar)"
2361        );
2362    }
2363
2364    // ── Position scaling integration tests ───────────────────────────────────
2365
2366    /// Strategy: enter long on bar 0, scale in on bar 1, exit on bar 2.
2367    #[derive(Clone)]
2368    struct EnterScaleInExit;
2369
2370    impl Strategy for EnterScaleInExit {
2371        fn name(&self) -> &str {
2372            "EnterScaleInExit"
2373        }
2374
2375        fn required_indicators(&self) -> Vec<(String, Indicator)> {
2376            vec![]
2377        }
2378
2379        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2380            match ctx.index {
2381                0 => Signal::long(ctx.timestamp(), ctx.close()),
2382                1 if ctx.has_position() => Signal::scale_in(0.5, ctx.timestamp(), ctx.close()),
2383                2 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2384                _ => Signal::hold(),
2385            }
2386        }
2387    }
2388
2389    /// Strategy: enter long on bar 0, scale out 50% on bar 1, exit remainder on bar 2.
2390    #[derive(Clone)]
2391    struct EnterScaleOutExit;
2392
2393    impl Strategy for EnterScaleOutExit {
2394        fn name(&self) -> &str {
2395            "EnterScaleOutExit"
2396        }
2397
2398        fn required_indicators(&self) -> Vec<(String, Indicator)> {
2399            vec![]
2400        }
2401
2402        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2403            match ctx.index {
2404                0 => Signal::long(ctx.timestamp(), ctx.close()),
2405                1 if ctx.has_position() => Signal::scale_out(0.5, ctx.timestamp(), ctx.close()),
2406                2 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2407                _ => Signal::hold(),
2408            }
2409        }
2410    }
2411
2412    #[test]
2413    fn test_scale_in_adds_to_position() {
2414        // 4 candles: entry bar 0, fill bar 1, scale-in bar 1, fill bar 2, exit bar 2, fill bar 3
2415        let prices = [100.0, 100.0, 110.0, 120.0, 120.0];
2416        let candles = make_candles(&prices);
2417
2418        let config = BacktestConfig::builder()
2419            .initial_capital(10_000.0)
2420            .commission_pct(0.0)
2421            .slippage_pct(0.0)
2422            .close_at_end(true)
2423            .build()
2424            .unwrap();
2425
2426        let engine = BacktestEngine::new(config);
2427        let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2428
2429        // Exactly one closed trade (from the final exit)
2430        assert_eq!(result.trades.len(), 1);
2431        let trade = &result.trades[0];
2432        assert!(!trade.is_partial);
2433        // Position was scaled in, so quantity > initial allocation
2434        assert!(trade.quantity > 0.0);
2435        // Strategy ran; equity curve has entries
2436        assert!(!result.equity_curve.is_empty());
2437        // Scale-in signal recorded
2438        let scale_signals: Vec<_> = result
2439            .signals
2440            .iter()
2441            .filter(|s| matches!(s.direction, SignalDirection::ScaleIn))
2442            .collect();
2443        assert!(!scale_signals.is_empty());
2444    }
2445
2446    #[test]
2447    fn test_scale_out_produces_partial_trade() {
2448        let prices = [100.0, 100.0, 110.0, 120.0, 120.0];
2449        let candles = make_candles(&prices);
2450
2451        let config = BacktestConfig::builder()
2452            .initial_capital(10_000.0)
2453            .commission_pct(0.0)
2454            .slippage_pct(0.0)
2455            .close_at_end(true)
2456            .build()
2457            .unwrap();
2458
2459        let engine = BacktestEngine::new(config);
2460        let result = engine.run("TEST", &candles, EnterScaleOutExit).unwrap();
2461
2462        // Two trades: partial close + final close
2463        assert!(result.trades.len() >= 2);
2464        let partial = result
2465            .trades
2466            .iter()
2467            .find(|t| t.is_partial)
2468            .expect("expected at least one partial trade");
2469        assert_eq!(partial.scale_sequence, 0);
2470
2471        let final_trade = result.trades.iter().find(|t| !t.is_partial);
2472        assert!(final_trade.is_some());
2473    }
2474
2475    #[test]
2476    fn test_scale_out_full_fraction_is_equivalent_to_exit() {
2477        /// Strategy: enter on bar 0, scale_out(1.0) on bar 1 — should fully close.
2478        #[derive(Clone)]
2479        struct EnterScaleOutFull;
2480        impl Strategy for EnterScaleOutFull {
2481            fn name(&self) -> &str {
2482                "EnterScaleOutFull"
2483            }
2484            fn required_indicators(&self) -> Vec<(String, Indicator)> {
2485                vec![]
2486            }
2487            fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2488                match ctx.index {
2489                    0 => Signal::long(ctx.timestamp(), ctx.close()),
2490                    1 if ctx.has_position() => Signal::scale_out(1.0, ctx.timestamp(), ctx.close()),
2491                    _ => Signal::hold(),
2492                }
2493            }
2494        }
2495
2496        let prices = [100.0, 100.0, 120.0, 120.0];
2497        let candles = make_candles(&prices);
2498
2499        let config = BacktestConfig::builder()
2500            .initial_capital(10_000.0)
2501            .commission_pct(0.0)
2502            .slippage_pct(0.0)
2503            .close_at_end(false)
2504            .build()
2505            .unwrap();
2506
2507        let engine = BacktestEngine::new(config.clone());
2508        let result_scale = engine.run("TEST", &candles, EnterScaleOutFull).unwrap();
2509
2510        // Full scale_out(1.0) should close position, leaving no open position
2511        assert!(result_scale.open_position.is_none());
2512        assert!(!result_scale.trades.is_empty());
2513
2514        // Compare against a plain Exit strategy for identical P&L
2515        #[derive(Clone)]
2516        struct EnterThenExit;
2517        impl Strategy for EnterThenExit {
2518            fn name(&self) -> &str {
2519                "EnterThenExit"
2520            }
2521            fn required_indicators(&self) -> Vec<(String, Indicator)> {
2522                vec![]
2523            }
2524            fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2525                match ctx.index {
2526                    0 => Signal::long(ctx.timestamp(), ctx.close()),
2527                    1 if ctx.has_position() => Signal::exit(ctx.timestamp(), ctx.close()),
2528                    _ => Signal::hold(),
2529                }
2530            }
2531        }
2532
2533        let engine2 = BacktestEngine::new(config);
2534        let result_exit = engine2.run("TEST", &candles, EnterThenExit).unwrap();
2535
2536        let pnl_scale: f64 = result_scale.trades.iter().map(|t| t.pnl).sum();
2537        let pnl_exit: f64 = result_exit.trades.iter().map(|t| t.pnl).sum();
2538        assert!(
2539            (pnl_scale - pnl_exit).abs() < 1e-6,
2540            "scale_out(1.0) PnL {pnl_scale:.6} should equal exit PnL {pnl_exit:.6}"
2541        );
2542    }
2543
2544    #[test]
2545    fn test_scale_in_noop_without_position() {
2546        /// Strategy: scale_in on bar 0 (no position open) — should be ignored.
2547        #[derive(Clone)]
2548        struct ScaleInNoPos;
2549        impl Strategy for ScaleInNoPos {
2550            fn name(&self) -> &str {
2551                "ScaleInNoPos"
2552            }
2553            fn required_indicators(&self) -> Vec<(String, Indicator)> {
2554                vec![]
2555            }
2556            fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2557                if ctx.index == 0 {
2558                    Signal::scale_in(0.5, ctx.timestamp(), ctx.close())
2559                } else {
2560                    Signal::hold()
2561                }
2562            }
2563        }
2564
2565        let prices = [100.0, 100.0, 100.0];
2566        let candles = make_candles(&prices);
2567        let config = BacktestConfig::builder()
2568            .initial_capital(10_000.0)
2569            .commission_pct(0.0)
2570            .slippage_pct(0.0)
2571            .build()
2572            .unwrap();
2573
2574        let engine = BacktestEngine::new(config.clone());
2575        let result = engine.run("TEST", &candles, ScaleInNoPos).unwrap();
2576
2577        assert!(result.trades.is_empty());
2578        assert!((result.final_equity - config.initial_capital).abs() < 1e-6);
2579    }
2580
2581    #[test]
2582    fn test_scale_out_noop_without_position() {
2583        /// Strategy: scale_out on bar 0 (no position open) — should be ignored.
2584        #[derive(Clone)]
2585        struct ScaleOutNoPos;
2586        impl Strategy for ScaleOutNoPos {
2587            fn name(&self) -> &str {
2588                "ScaleOutNoPos"
2589            }
2590            fn required_indicators(&self) -> Vec<(String, Indicator)> {
2591                vec![]
2592            }
2593            fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2594                if ctx.index == 0 {
2595                    Signal::scale_out(0.5, ctx.timestamp(), ctx.close())
2596                } else {
2597                    Signal::hold()
2598                }
2599            }
2600        }
2601
2602        let prices = [100.0, 100.0, 100.0];
2603        let candles = make_candles(&prices);
2604        let config = BacktestConfig::builder()
2605            .initial_capital(10_000.0)
2606            .commission_pct(0.0)
2607            .slippage_pct(0.0)
2608            .build()
2609            .unwrap();
2610
2611        let engine = BacktestEngine::new(config.clone());
2612        let result = engine.run("TEST", &candles, ScaleOutNoPos).unwrap();
2613
2614        assert!(result.trades.is_empty());
2615        assert!((result.final_equity - config.initial_capital).abs() < 1e-6);
2616    }
2617
2618    #[test]
2619    fn test_scale_in_pnl_uses_weighted_avg_cost_basis() {
2620        // Tests for issue where entry_quantity was not updated after scale_in,
2621        // causing close_with_tax to use the original (too-small) entry_quantity and
2622        // overstate gross PnL.
2623        //
2624        // Setup:
2625        //   bar 0 – long signal, fill bar 1 @ $100, buy 10 shares (position_size_pct=0.1)
2626        //   bar 1 – scale_in(0.5) signal, fill bar 2 @ $100, buy ~50% equity more
2627        //   bar 2 – exit signal, fill bar 3 @ $110
2628        //   No commission/slippage so PnL is pure price × qty arithmetic.
2629        let prices = [100.0, 100.0, 100.0, 110.0, 110.0];
2630        let candles = make_candles(&prices);
2631
2632        let config = BacktestConfig::builder()
2633            .initial_capital(1_000.0)
2634            .position_size_pct(0.1) // buy 10% of cash = $100 / $100 = 1 share initially
2635            .commission_pct(0.0)
2636            .commission(0.0)
2637            .slippage_pct(0.0)
2638            .close_at_end(true)
2639            .build()
2640            .unwrap();
2641
2642        let engine = BacktestEngine::new(config.clone());
2643        let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2644
2645        // Confirm the scale-in fired.
2646        let si_executed = result
2647            .signals
2648            .iter()
2649            .any(|s| matches!(s.direction, SignalDirection::ScaleIn) && s.executed);
2650        assert!(
2651            si_executed,
2652            "scale-in did not execute — test is inconclusive"
2653        );
2654
2655        // With no commission/slippage:
2656        //   trade.pnl  == (exit_price - entry_price) × qty_closed   (per-share basis)
2657        //              == ($110 − $100) × qty_closed
2658        // And final_equity == initial_capital + sum(pnl)
2659        let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2660        assert!(sum_pnl > 0.0, "expected a profit, got {sum_pnl:.6}");
2661        assert!(
2662            (result.final_equity - (config.initial_capital + sum_pnl)).abs() < 1e-6,
2663            "accounting invariant: final_equity={:.6}, expected={:.6}",
2664            result.final_equity,
2665            config.initial_capital + sum_pnl
2666        );
2667    }
2668
2669    #[test]
2670    fn test_accounting_invariant_holds_with_scaling() {
2671        // Verifies: final_equity == initial_capital + sum(trade.pnl) after a
2672        // scale-in followed by a full exit.  Uses position_size_pct=0.2 so that
2673        // 80% of cash remains after the initial entry, giving the scale-in
2674        // (fraction=0.5 of equity) enough room to execute.
2675        let prices = [100.0, 100.0, 100.0, 110.0, 110.0, 120.0];
2676        let candles = make_candles(&prices);
2677
2678        let config = BacktestConfig::builder()
2679            .initial_capital(10_000.0)
2680            .position_size_pct(0.2) // 20% per entry → 80% cash left for scale-in
2681            .commission_pct(0.001)
2682            .slippage_pct(0.0)
2683            .close_at_end(true)
2684            .build()
2685            .unwrap();
2686
2687        let engine = BacktestEngine::new(config.clone());
2688        let result = engine.run("TEST", &candles, EnterScaleInExit).unwrap();
2689
2690        // Confirm the scale-in actually fired (scale_in signal recorded as executed).
2691        let scale_in_executed = result
2692            .signals
2693            .iter()
2694            .any(|s| matches!(s.direction, SignalDirection::ScaleIn) && s.executed);
2695        assert!(
2696            scale_in_executed,
2697            "scale-in signal was not executed — test is inconclusive"
2698        );
2699
2700        let sum_pnl: f64 = result.trades.iter().map(|t| t.pnl).sum();
2701        let expected = config.initial_capital + sum_pnl;
2702        assert!(
2703            (result.final_equity - expected).abs() < 1e-4,
2704            "accounting invariant failed: final_equity={:.6}, expected={:.6}",
2705            result.final_equity,
2706            expected
2707        );
2708    }
2709
2710    // ── Per-trade bracket orders (Phase 11) ──────────────────────────────────
2711
2712    // Each bracket type is tested for both Long and Short sides.
2713    // Long:  SL fires on low breach; TP fires on high breach; trail tracks HWM (peak).
2714    // Short: SL fires on high breach; TP fires on low breach; trail tracks LWM (trough).
2715
2716    /// Enters a long position on bar 0 with a per-trade stop-loss.
2717    #[derive(Clone)]
2718    struct BracketLongStopLossStrategy {
2719        stop_pct: f64,
2720    }
2721    impl Strategy for BracketLongStopLossStrategy {
2722        fn name(&self) -> &str {
2723            "BracketLongStopLoss"
2724        }
2725        fn required_indicators(&self) -> Vec<(String, Indicator)> {
2726            vec![]
2727        }
2728        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2729            if ctx.index == 0 && !ctx.has_position() {
2730                Signal::long(ctx.timestamp(), ctx.close()).stop_loss(self.stop_pct)
2731            } else {
2732                Signal::hold()
2733            }
2734        }
2735    }
2736
2737    /// Enters a short position on bar 0 with a per-trade stop-loss.
2738    #[derive(Clone)]
2739    struct BracketShortStopLossStrategy {
2740        stop_pct: f64,
2741    }
2742    impl Strategy for BracketShortStopLossStrategy {
2743        fn name(&self) -> &str {
2744            "BracketShortStopLoss"
2745        }
2746        fn required_indicators(&self) -> Vec<(String, Indicator)> {
2747            vec![]
2748        }
2749        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
2750            if ctx.index == 0 && !ctx.has_position() {
2751                Signal::short(ctx.timestamp(), ctx.close()).stop_loss(self.stop_pct)
2752            } else {
2753                Signal::hold()
2754            }
2755        }
2756    }
2757
2758    // ── Long stop-loss ────────────────────────────────────────────────────────
2759
2760    #[test]
2761    fn test_per_trade_stop_loss_triggers_when_set() {
2762        // Bar 0: signal @ 100. Bar 1: fill @ open=100.
2763        // Bar 2: intrabar low=79.2. 5% stop = $95. low(79.2) <= 95 → stop fires.
2764        // Gap-down guard: fill = min(open=80, stop=95) = 80.
2765        let prices = [100.0, 100.0, 80.0, 80.0];
2766        let mut candles = make_candles(&prices);
2767        candles[2].low = 79.2;
2768
2769        let config = BacktestConfig::builder()
2770            .initial_capital(10_000.0)
2771            .commission_pct(0.0)
2772            .slippage_pct(0.0)
2773            .close_at_end(false)
2774            .build()
2775            .unwrap();
2776
2777        let engine = BacktestEngine::new(config);
2778        let result = engine
2779            .run(
2780                "TEST",
2781                &candles,
2782                BracketLongStopLossStrategy { stop_pct: 0.05 },
2783            )
2784            .unwrap();
2785
2786        assert!(
2787            !result.trades.is_empty(),
2788            "stop-loss should have closed the position"
2789        );
2790        assert!(
2791            result.trades[0].pnl < 0.0,
2792            "stop-loss trade should be a loss"
2793        );
2794    }
2795
2796    #[test]
2797    fn test_per_trade_stop_loss_overrides_config_none() {
2798        // Config has no stop-loss; per-trade bracket stop of 5% should still fire.
2799        let prices = [100.0, 100.0, 80.0, 80.0];
2800        let mut candles = make_candles(&prices);
2801        candles[2].low = 79.2;
2802
2803        let config = BacktestConfig::builder()
2804            .initial_capital(10_000.0)
2805            .commission_pct(0.0)
2806            .slippage_pct(0.0)
2807            .close_at_end(false)
2808            .build()
2809            .unwrap();
2810
2811        assert!(
2812            config.stop_loss_pct.is_none(),
2813            "config must not have a default stop-loss for this test"
2814        );
2815
2816        let engine = BacktestEngine::new(config);
2817        let result = engine
2818            .run(
2819                "TEST",
2820                &candles,
2821                BracketLongStopLossStrategy { stop_pct: 0.05 },
2822            )
2823            .unwrap();
2824
2825        assert!(
2826            !result.trades.is_empty(),
2827            "per-trade bracket stop should fire even when config stop_loss_pct is None"
2828        );
2829    }
2830
2831    #[test]
2832    fn test_per_trade_stop_loss_overrides_config_looser() {
2833        // Config has a loose 20% stop ($80); per-trade bracket stop of 5% ($95) fires first.
2834        // Bar 2 opens at $97 (above $95) and dips to $93 intrabar — no gap-down — so the
2835        // fill resolves to min(open=97, stop=95) = $95, proving it's the tighter bracket
2836        // that fired and not the config's $80 level.
2837        //
2838        //   5% stop  = $95 → triggers (low=93 ≤ 95), fill = min(97, 95) = 95
2839        //   20% stop = $80 → would NOT trigger (low=93 > 80)
2840        let prices = [100.0, 100.0, 97.0, 97.0];
2841        let mut candles = make_candles(&prices);
2842        candles[2].low = 93.0; // below 5% stop=95, above 20% stop=80
2843
2844        let config = BacktestConfig::builder()
2845            .initial_capital(10_000.0)
2846            .commission_pct(0.0)
2847            .slippage_pct(0.0)
2848            .stop_loss_pct(0.20) // loose config default
2849            .close_at_end(false)
2850            .build()
2851            .unwrap();
2852
2853        let engine = BacktestEngine::new(config);
2854        let result = engine
2855            .run(
2856                "TEST",
2857                &candles,
2858                BracketLongStopLossStrategy { stop_pct: 0.05 },
2859            )
2860            .unwrap();
2861
2862        assert!(!result.trades.is_empty());
2863        let trade = &result.trades[0];
2864        // Exit at the 5% bracket level ($95), not the 20% config level ($80).
2865        assert!(
2866            trade.exit_price > 90.0,
2867            "expected exit near 5% bracket stop ($95), got {:.2}",
2868            trade.exit_price
2869        );
2870    }
2871
2872    // ── Short stop-loss ───────────────────────────────────────────────────────
2873
2874    #[test]
2875    fn test_per_trade_short_stop_loss_triggers_when_set() {
2876        // Bar 0: signal short @ 100. Bar 1: fill @ open=100.
2877        // Bar 2: intrabar high=112.5. 5% stop = $105. high(112.5) >= 105 → stop fires.
2878        // Gap-up guard: fill = max(open=112, stop=105) = 112.
2879        let prices = [100.0, 100.0, 112.0, 112.0];
2880        let mut candles = make_candles(&prices);
2881        candles[2].high = 112.5;
2882
2883        let config = BacktestConfig::builder()
2884            .initial_capital(10_000.0)
2885            .commission_pct(0.0)
2886            .slippage_pct(0.0)
2887            .allow_short(true)
2888            .close_at_end(false)
2889            .build()
2890            .unwrap();
2891
2892        let engine = BacktestEngine::new(config);
2893        let result = engine
2894            .run(
2895                "TEST",
2896                &candles,
2897                BracketShortStopLossStrategy { stop_pct: 0.05 },
2898            )
2899            .unwrap();
2900
2901        assert!(
2902            !result.trades.is_empty(),
2903            "short stop-loss should have closed the position"
2904        );
2905        assert!(
2906            result.trades[0].pnl < 0.0,
2907            "short stop-loss trade should be a loss (price rose against the short)"
2908        );
2909    }
2910
2911    #[test]
2912    fn test_per_trade_short_stop_loss_overrides_config_none() {
2913        // Config has no stop-loss; per-trade bracket stop of 5% should still fire for shorts.
2914        let prices = [100.0, 100.0, 112.0, 112.0];
2915        let mut candles = make_candles(&prices);
2916        candles[2].high = 112.5;
2917
2918        let config = BacktestConfig::builder()
2919            .initial_capital(10_000.0)
2920            .commission_pct(0.0)
2921            .slippage_pct(0.0)
2922            .allow_short(true)
2923            .close_at_end(false)
2924            .build()
2925            .unwrap();
2926
2927        assert!(config.stop_loss_pct.is_none());
2928
2929        let engine = BacktestEngine::new(config);
2930        let result = engine
2931            .run(
2932                "TEST",
2933                &candles,
2934                BracketShortStopLossStrategy { stop_pct: 0.05 },
2935            )
2936            .unwrap();
2937
2938        assert!(
2939            !result.trades.is_empty(),
2940            "per-trade bracket stop should fire for shorts even with no config stop-loss"
2941        );
2942    }
2943
2944    #[test]
2945    fn test_per_trade_short_stop_loss_overrides_config_looser() {
2946        // Config has a loose 20% stop ($120); per-trade bracket stop of 5% ($105) fires first.
2947        // Bar 2 opens at $103 (below $105) and rises to $108 intrabar — no gap-up — so
2948        // the fill resolves to max(open=103, stop=105) = $105, not the config's $120.
2949        //
2950        //   5% stop  = $105 → triggers (high=108 ≥ 105), fill = max(103, 105) = 105
2951        //   20% stop = $120 → would NOT trigger (high=108 < 120)
2952        let prices = [100.0, 100.0, 103.0, 103.0];
2953        let mut candles = make_candles(&prices);
2954        candles[2].high = 108.0; // above 5% stop=105, below 20% stop=120
2955
2956        let config = BacktestConfig::builder()
2957            .initial_capital(10_000.0)
2958            .commission_pct(0.0)
2959            .slippage_pct(0.0)
2960            .allow_short(true)
2961            .stop_loss_pct(0.20) // loose config default
2962            .close_at_end(false)
2963            .build()
2964            .unwrap();
2965
2966        let engine = BacktestEngine::new(config);
2967        let result = engine
2968            .run(
2969                "TEST",
2970                &candles,
2971                BracketShortStopLossStrategy { stop_pct: 0.05 },
2972            )
2973            .unwrap();
2974
2975        assert!(!result.trades.is_empty());
2976        let trade = &result.trades[0];
2977        // Exit at the 5% bracket level ($105), not the 20% config level ($120).
2978        assert!(
2979            trade.exit_price < 115.0,
2980            "expected exit near 5% bracket stop ($105), got {:.2}",
2981            trade.exit_price
2982        );
2983    }
2984
2985    // ── Take-profit ───────────────────────────────────────────────────────────
2986
2987    /// Enters a long position on bar 0 with a per-trade take-profit.
2988    #[derive(Clone)]
2989    struct BracketLongTakeProfitStrategy {
2990        tp_pct: f64,
2991    }
2992    impl Strategy for BracketLongTakeProfitStrategy {
2993        fn name(&self) -> &str {
2994            "BracketLongTakeProfit"
2995        }
2996        fn required_indicators(&self) -> Vec<(String, Indicator)> {
2997            vec![]
2998        }
2999        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3000            if ctx.index == 0 && !ctx.has_position() {
3001                Signal::long(ctx.timestamp(), ctx.close()).take_profit(self.tp_pct)
3002            } else {
3003                Signal::hold()
3004            }
3005        }
3006    }
3007
3008    /// Enters a short position on bar 0 with a per-trade take-profit.
3009    #[derive(Clone)]
3010    struct BracketShortTakeProfitStrategy {
3011        tp_pct: f64,
3012    }
3013    impl Strategy for BracketShortTakeProfitStrategy {
3014        fn name(&self) -> &str {
3015            "BracketShortTakeProfit"
3016        }
3017        fn required_indicators(&self) -> Vec<(String, Indicator)> {
3018            vec![]
3019        }
3020        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3021            if ctx.index == 0 && !ctx.has_position() {
3022                Signal::short(ctx.timestamp(), ctx.close()).take_profit(self.tp_pct)
3023            } else {
3024                Signal::hold()
3025            }
3026        }
3027    }
3028
3029    #[test]
3030    fn test_per_trade_take_profit_triggers() {
3031        // Bar 0: signal @ 100. Bar 1: fill @ open=100.
3032        // Bar 2: intrabar high=121.2. TP at 10% = $110. high(121.2) >= 110 → fires.
3033        // Gap-up guard: fill = max(open=120, tp=110) = 120.
3034        let prices = [100.0, 100.0, 120.0, 120.0];
3035        let mut candles = make_candles(&prices);
3036        candles[2].high = 121.2;
3037
3038        let config = BacktestConfig::builder()
3039            .initial_capital(10_000.0)
3040            .commission_pct(0.0)
3041            .slippage_pct(0.0)
3042            .close_at_end(false)
3043            .build()
3044            .unwrap();
3045
3046        let engine = BacktestEngine::new(config);
3047        let result = engine
3048            .run(
3049                "TEST",
3050                &candles,
3051                BracketLongTakeProfitStrategy { tp_pct: 0.10 },
3052            )
3053            .unwrap();
3054
3055        assert!(
3056            !result.trades.is_empty(),
3057            "long take-profit should have fired"
3058        );
3059        assert!(
3060            result.trades[0].pnl > 0.0,
3061            "long take-profit trade should be profitable"
3062        );
3063    }
3064
3065    #[test]
3066    fn test_per_trade_short_take_profit_triggers() {
3067        // Bar 0: signal short @ 100. Bar 1: fill @ open=100.
3068        // Bar 2: intrabar low=84.15. TP at 10% = $90. low(84.15) <= 90 → fires.
3069        // Gap-down guard: fill = min(open=85, tp=90) = 85.
3070        let prices = [100.0, 100.0, 85.0, 85.0];
3071        let mut candles = make_candles(&prices);
3072        candles[2].low = 84.15;
3073
3074        let config = BacktestConfig::builder()
3075            .initial_capital(10_000.0)
3076            .commission_pct(0.0)
3077            .slippage_pct(0.0)
3078            .allow_short(true)
3079            .close_at_end(false)
3080            .build()
3081            .unwrap();
3082
3083        let engine = BacktestEngine::new(config);
3084        let result = engine
3085            .run(
3086                "TEST",
3087                &candles,
3088                BracketShortTakeProfitStrategy { tp_pct: 0.10 },
3089            )
3090            .unwrap();
3091
3092        assert!(
3093            !result.trades.is_empty(),
3094            "short take-profit should have fired"
3095        );
3096        assert!(
3097            result.trades[0].pnl > 0.0,
3098            "short take-profit trade should be profitable (price fell in favor of short)"
3099        );
3100    }
3101
3102    // ── Trailing stop ─────────────────────────────────────────────────────────
3103
3104    /// Enters a long position on bar 0 with a per-trade trailing stop.
3105    #[derive(Clone)]
3106    struct BracketLongTrailingStopStrategy {
3107        trail_pct: f64,
3108    }
3109    impl Strategy for BracketLongTrailingStopStrategy {
3110        fn name(&self) -> &str {
3111            "BracketLongTrailingStop"
3112        }
3113        fn required_indicators(&self) -> Vec<(String, Indicator)> {
3114            vec![]
3115        }
3116        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3117            if ctx.index == 0 && !ctx.has_position() {
3118                Signal::long(ctx.timestamp(), ctx.close()).trailing_stop(self.trail_pct)
3119            } else {
3120                Signal::hold()
3121            }
3122        }
3123    }
3124
3125    /// Enters a short position on bar 0 with a per-trade trailing stop.
3126    #[derive(Clone)]
3127    struct BracketShortTrailingStopStrategy {
3128        trail_pct: f64,
3129    }
3130    impl Strategy for BracketShortTrailingStopStrategy {
3131        fn name(&self) -> &str {
3132            "BracketShortTrailingStop"
3133        }
3134        fn required_indicators(&self) -> Vec<(String, Indicator)> {
3135            vec![]
3136        }
3137        fn on_candle(&self, ctx: &StrategyContext) -> Signal {
3138            if ctx.index == 0 && !ctx.has_position() {
3139                Signal::short(ctx.timestamp(), ctx.close()).trailing_stop(self.trail_pct)
3140            } else {
3141                Signal::hold()
3142            }
3143        }
3144    }
3145
3146    #[test]
3147    fn test_per_trade_trailing_stop_triggers() {
3148        // Bar 0: signal @ 100.
3149        // Bar 1: fill @ open=100. HWM initialised to entry_price=100.
3150        // Bar 2: high=121.0 → HWM = max(100, 121) = 121. Trail stop = 121*(1-0.05) = 114.95.
3151        //        low=118.8 → 118.8 > 114.95 → no trigger.
3152        // Bar 3: low=108.9 → 108.9 <= 114.95 → trailing stop fires.
3153        //        fill = min(open=110, trail=114.95) = 110. pnl > 0.
3154        let prices = [100.0, 100.0, 120.0, 110.0, 110.0];
3155        let mut candles = make_candles(&prices);
3156        candles[2].high = 121.0;
3157        candles[3].low = 108.9; // below 5% trail from 121.0 (= 114.95)
3158
3159        let config = BacktestConfig::builder()
3160            .initial_capital(10_000.0)
3161            .commission_pct(0.0)
3162            .slippage_pct(0.0)
3163            .close_at_end(false)
3164            .build()
3165            .unwrap();
3166
3167        let engine = BacktestEngine::new(config);
3168        let result = engine
3169            .run(
3170                "TEST",
3171                &candles,
3172                BracketLongTrailingStopStrategy { trail_pct: 0.05 },
3173            )
3174            .unwrap();
3175
3176        assert!(
3177            !result.trades.is_empty(),
3178            "long trailing stop should have fired"
3179        );
3180        assert!(
3181            result.trades[0].pnl > 0.0,
3182            "long trailing stop should exit in profit (entry $100, exit near $110)"
3183        );
3184    }
3185
3186    #[test]
3187    fn test_per_trade_short_trailing_stop_triggers() {
3188        // Bar 0: signal short @ 100.
3189        // Bar 1: fill @ open=100. LWM (trough) initialised to entry_price=100.
3190        // Bar 2: price=80, low=79.2 → LWM = min(100, 79.2) = 79.2.
3191        //        Trail stop = 79.2*(1+0.05) = 83.16. high=80.8 → 80.8 < 83.16 → no trigger.
3192        // Bar 3: price=88, high=88.88 → 88.88 >= 83.16 → trailing stop fires.
3193        //        fill = max(open=88, trail=83.16) = 88. pnl > 0 (short from 100, exit at 88).
3194        let prices = [100.0, 100.0, 80.0, 88.0, 88.0];
3195        let mut candles = make_candles(&prices);
3196        candles[2].low = 79.2; // drives LWM to 79.2; trail stop = 79.2 * 1.05 = 83.16
3197
3198        let config = BacktestConfig::builder()
3199            .initial_capital(10_000.0)
3200            .commission_pct(0.0)
3201            .slippage_pct(0.0)
3202            .allow_short(true)
3203            .close_at_end(false)
3204            .build()
3205            .unwrap();
3206
3207        let engine = BacktestEngine::new(config);
3208        let result = engine
3209            .run(
3210                "TEST",
3211                &candles,
3212                BracketShortTrailingStopStrategy { trail_pct: 0.05 },
3213            )
3214            .unwrap();
3215
3216        assert!(
3217            !result.trades.is_empty(),
3218            "short trailing stop should have fired"
3219        );
3220        assert!(
3221            result.trades[0].pnl > 0.0,
3222            "short trailing stop should exit in profit (entry $100, exit near $88)"
3223        );
3224    }
3225}