//! Backtesting engine for the trading strategy. use anyhow::{Context, Result}; use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc}; use std::collections::{BTreeMap, HashMap, HashSet}; use crate::alpaca::{fetch_backtest_data, fetch_backtest_data_with_dates, AlpacaClient}; use crate::config::{ get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS, MAX_LOSS_PCT, MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS, REENTRY_COOLDOWN_BARS, SLIPPAGE_BPS, TIME_EXIT_BARS, TOP_MOMENTUM_COUNT, TRADING_DAYS_PER_YEAR, DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS, DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS, DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS, DRAWDOWN_TIER3_REQUIRE_BULL, HOURLY_DRAWDOWN_TIER1_PCT, HOURLY_DRAWDOWN_TIER1_BARS, HOURLY_DRAWDOWN_TIER2_PCT, HOURLY_DRAWDOWN_TIER2_BARS, HOURLY_DRAWDOWN_TIER3_PCT, HOURLY_DRAWDOWN_TIER3_BARS, EQUITY_CURVE_SMA_PERIOD, REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG, REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP, HOURLY_REGIME_CAUTION_SIZE_FACTOR, HOURLY_REGIME_CAUTION_THRESHOLD_BUMP, }; use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal}; use crate::strategy::Strategy; use crate::types::{ BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, MarketRegime, Signal, Trade, TradeSignal, }; /// Backtesting engine for the trading strategy. pub struct Backtester { initial_capital: f64, cash: f64, positions: HashMap, trades: Vec, equity_history: Vec, peak_portfolio_value: f64, drawdown_halt: bool, /// Bar index when drawdown halt started (for time-based resume) drawdown_halt_start: Option, /// The drawdown severity that triggered the current halt (for scaled cooldowns) drawdown_halt_severity: f64, /// Current market regime (from SPY analysis) current_regime: MarketRegime, /// Whether the drawdown halt requires bull regime to resume (Tier 3) drawdown_requires_bull: bool, strategy: Strategy, timeframe: Timeframe, /// Current bar index in the simulation current_bar: usize, /// Tracks when each symbol can be re-entered after stop-loss (bar index) cooldown_timers: HashMap, /// Tracks new positions opened in current bar (for gradual ramp-up) new_positions_this_bar: usize, /// Rolling list of day trade dates for PDT tracking. day_trades: Vec, /// Count of day trades that occurred (informational only, not blocking). #[allow(dead_code)] pdt_blocked_count: usize, } impl Backtester { /// Create a new backtester. pub fn new(initial_capital: f64, timeframe: Timeframe) -> Self { Self { initial_capital, cash: initial_capital, positions: HashMap::new(), trades: Vec::new(), equity_history: Vec::new(), peak_portfolio_value: initial_capital, drawdown_halt: false, drawdown_halt_start: None, drawdown_halt_severity: 0.0, current_regime: MarketRegime::Bull, drawdown_requires_bull: false, strategy: Strategy::new(timeframe), timeframe, current_bar: 0, cooldown_timers: HashMap::new(), new_positions_this_bar: 0, day_trades: Vec::new(), pdt_blocked_count: 0, } } /// Apply slippage to a price (buy = slightly higher, sell = slightly lower). fn apply_slippage(price: f64, side: &str) -> f64 { let slip = SLIPPAGE_BPS / 10_000.0; if side == "buy" { price * (1.0 + slip) } else { price * (1.0 - slip) } } /// Calculate current portfolio value. fn get_portfolio_value(&self, prices: &HashMap) -> f64 { let positions_value: f64 = self .positions .iter() .map(|(symbol, pos)| pos.shares * prices.get(symbol).unwrap_or(&pos.entry_price)) .sum(); self.cash + positions_value } /// Update drawdown circuit breaker state with scaled cooldowns. /// /// Drawdown severity determines halt duration: /// - Tier 1 (15%): 10 bars — normal correction /// - Tier 2 (20%): 30 bars — significant bear market /// - Tier 3 (25%+): 50 bars + require bull regime — severe bear (COVID, 2022) /// /// On resume, the peak is reset to the current portfolio value to prevent /// cascading re-triggers from the same drawdown event. /// Get the drawdown tier thresholds for the current timeframe. fn drawdown_tiers(&self) -> (f64, usize, f64, usize, f64, usize) { if self.timeframe == Timeframe::Hourly { ( HOURLY_DRAWDOWN_TIER1_PCT, HOURLY_DRAWDOWN_TIER1_BARS, HOURLY_DRAWDOWN_TIER2_PCT, HOURLY_DRAWDOWN_TIER2_BARS, HOURLY_DRAWDOWN_TIER3_PCT, HOURLY_DRAWDOWN_TIER3_BARS, ) } else { ( DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS, DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS, DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS, ) } } fn update_drawdown_state(&mut self, portfolio_value: f64) { if portfolio_value > self.peak_portfolio_value { self.peak_portfolio_value = portfolio_value; } let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value; let (t1_pct, t1_bars, t2_pct, t2_bars, t3_pct, t3_bars) = self.drawdown_tiers(); // Trigger halt at the lowest tier that matches (if not already halted) if !self.drawdown_halt && drawdown_pct >= t1_pct { // Determine severity tier let (halt_bars, tier_name) = if drawdown_pct >= t3_pct { self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL; (t3_bars, "TIER 3 (SEVERE)") } else if drawdown_pct >= t2_pct { (t2_bars, "TIER 2") } else { (t1_bars, "TIER 1") }; tracing::warn!( "DRAWDOWN CIRCUIT BREAKER {}: {:.2}% drawdown. Halting for {} bars.{}", tier_name, drawdown_pct * 100.0, halt_bars, if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" } ); self.drawdown_halt = true; self.drawdown_halt_start = Some(self.current_bar); self.drawdown_halt_severity = drawdown_pct; } // Upgrade severity if drawdown deepens while already halted if self.drawdown_halt && drawdown_pct > self.drawdown_halt_severity { if drawdown_pct >= t3_pct && self.drawdown_halt_severity < t3_pct { self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL; self.drawdown_halt_start = Some(self.current_bar); // Reset timer for deeper tier tracing::warn!( "Drawdown deepened to {:.2}% — UPGRADED to TIER 3. Requires BULL regime.", drawdown_pct * 100.0 ); } else if drawdown_pct >= t2_pct && self.drawdown_halt_severity < t2_pct { self.drawdown_halt_start = Some(self.current_bar); tracing::warn!( "Drawdown deepened to {:.2}% — upgraded to TIER 2.", drawdown_pct * 100.0 ); } self.drawdown_halt_severity = drawdown_pct; } // Auto-resume after time-based cooldown if self.drawdown_halt { if let Some(halt_start) = self.drawdown_halt_start { let required_bars = if self.drawdown_halt_severity >= t3_pct { t3_bars } else if self.drawdown_halt_severity >= t2_pct { t2_bars } else { t1_bars }; let time_served = self.current_bar >= halt_start + required_bars; let regime_ok = if self.drawdown_requires_bull { self.current_regime == MarketRegime::Bull } else { true }; if time_served && regime_ok { tracing::info!( "Drawdown halt expired after {} bars (regime: {}). \ Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).", required_bars, self.current_regime.as_str(), self.peak_portfolio_value, portfolio_value, drawdown_pct * 100.0 ); self.drawdown_halt = false; self.drawdown_halt_start = None; self.drawdown_halt_severity = 0.0; self.drawdown_requires_bull = false; self.peak_portfolio_value = portfolio_value; } else if time_served && !regime_ok { // Log periodically that we're waiting for bull regime if self.current_bar % 50 == 0 { tracing::info!( "Drawdown halt: time served but waiting for BULL regime (currently {}). DD: {:.2}%", self.current_regime.as_str(), drawdown_pct * 100.0 ); } } } } } /// Execute a simulated buy order with slippage. /// /// For hourly timeframe, entries are blocked in the last 2 hours of the /// trading day to avoid creating positions that might need same-day /// stop-loss exits (PDT prevention at entry rather than blocking exits). /// /// The `regime_size_factor` parameter scales position size based on the /// current market regime (1.0 for bull, 0.5 for caution, 0.0 for bear). fn execute_buy( &mut self, symbol: &str, price: f64, timestamp: DateTime, portfolio_value: f64, signal: &TradeSignal, regime_size_factor: f64, ) -> bool { if self.positions.contains_key(symbol) { return false; } // Market regime gate: no new longs in bear market if regime_size_factor <= 0.0 { return false; } // PDT-safe entry: on hourly, avoid buying in the last 2 hours of the day. if self.timeframe == Timeframe::Hourly { let hour = timestamp.hour(); if hour >= 19 { return false; } } // Cooldown guard: prevent whipsaw re-entry after stop-loss if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) { if self.current_bar < cooldown_until { return false; } } // Portfolio-level guards if self.drawdown_halt { return false; } // Equity curve SMA stop REMOVED: it creates a pathological feedback loop // where losing positions drag equity below the SMA, blocking new entries, // which prevents recovery. The SPY regime filter and drawdown circuit // breaker handle macro risk without this self-reinforcing trap. if self.positions.len() >= MAX_CONCURRENT_POSITIONS { return false; } let sector = get_sector(symbol); if self .strategy .sector_position_count(sector, self.positions.keys()) >= MAX_SECTOR_POSITIONS { return false; } // Gradual ramp-up: limit new positions during initial period if self.current_bar < RAMPUP_PERIOD_BARS && self.new_positions_this_bar >= 1 { return false; } let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE); let mut shares = self.strategy .calculate_position_size(price, portfolio_value, available_cash, signal); // Apply regime-based size adjustment (e.g., 50% in Caution) shares *= regime_size_factor; // Re-truncate to 4 decimal places after adjustment shares = (shares * 10000.0).floor() / 10000.0; if shares <= 0.0 { return false; } let fill_price = Self::apply_slippage(price, "buy"); let cost = shares * fill_price; if cost > self.cash { return false; } self.cash -= cost; self.positions.insert( symbol.to_string(), BacktestPosition { symbol: symbol.to_string(), shares, entry_price: fill_price, entry_time: timestamp, entry_atr: signal.atr, bars_held: 0, }, ); self.strategy.entry_prices.insert(symbol.to_string(), fill_price); self.strategy.entry_atrs.insert(symbol.to_string(), signal.atr); self.strategy.high_water_marks.insert(symbol.to_string(), fill_price); self.new_positions_this_bar += 1; self.trades.push(Trade { symbol: symbol.to_string(), side: "BUY".to_string(), shares, price: fill_price, timestamp, pnl: 0.0, pnl_pct: 0.0, }); true } /// Execute a simulated full sell order with slippage. /// /// PDT protection is DISABLED in the backtester for daily timeframe because /// on daily bars, buys and sells never occur on the same bar (Phase 1 sells, /// Phase 2 buys), so day trades cannot happen by construction. For hourly, /// the late-day entry prevention in execute_buy already handles PDT risk. /// Backtests should measure strategy alpha, not compliance friction. fn execute_sell( &mut self, symbol: &str, price: f64, timestamp: DateTime, was_stop_loss: bool, _portfolio_value: f64, ) -> bool { // Check day trade status BEFORE removing position (would_be_day_trade // looks up entry_time in self.positions). let sell_date = timestamp.date_naive(); let is_day_trade = self.would_be_day_trade(symbol, sell_date); let position = match self.positions.remove(symbol) { Some(p) => p, None => return false, }; // Track day trades for informational purposes only (no blocking) if is_day_trade { self.day_trades.push(sell_date); } let fill_price = Self::apply_slippage(price, "sell"); let proceeds = position.shares * fill_price; self.cash += proceeds; let pnl = proceeds - (position.shares * position.entry_price); let pnl_pct = (fill_price - position.entry_price) / position.entry_price; self.trades.push(Trade { symbol: symbol.to_string(), side: "SELL".to_string(), shares: position.shares, price: fill_price, timestamp, pnl, pnl_pct, }); self.strategy.entry_prices.remove(symbol); self.strategy.entry_atrs.remove(symbol); self.strategy.high_water_marks.remove(symbol); // Record cooldown if this was a stop-loss exit if was_stop_loss { self.cooldown_timers.insert( symbol.to_string(), self.current_bar + REENTRY_COOLDOWN_BARS, ); } true } // Partial exits removed: they systematically halve winning trade size // while losing trades remain at full size, creating an asymmetric // avg_win < avg_loss profile. The trailing stop alone provides adequate // profit protection without splitting winners into smaller fragments. // ── PDT (Pattern Day Trading) protection ─────────────────────── /// PDT constants (retained for hourly timeframe day-trade tracking). #[allow(dead_code)] const PDT_MAX_DAY_TRADES: usize = 3; const PDT_ROLLING_BUSINESS_DAYS: i64 = 5; // Used by prune_old_day_trades /// Remove day trades older than the 5-business-day rolling window. fn prune_old_day_trades(&mut self, current_date: NaiveDate) { let cutoff = Self::business_days_before(current_date, Self::PDT_ROLLING_BUSINESS_DAYS); self.day_trades.retain(|&d| d >= cutoff); } /// Get the date N business days before the given date. fn business_days_before(from: NaiveDate, n: i64) -> NaiveDate { let mut count = 0i64; let mut date = from; while count < n { date -= Duration::days(1); let wd = date.weekday(); if wd != chrono::Weekday::Sat && wd != chrono::Weekday::Sun { count += 1; } } date } /// Count day trades in the rolling 5-business-day window. #[allow(dead_code)] fn day_trades_in_window(&self, current_date: NaiveDate) -> usize { let cutoff = Self::business_days_before(current_date, Self::PDT_ROLLING_BUSINESS_DAYS); self.day_trades.iter().filter(|&&d| d >= cutoff).count() } /// Check if selling this symbol on the given date would be a day trade. fn would_be_day_trade(&self, symbol: &str, sell_date: NaiveDate) -> bool { self.positions .get(symbol) .map(|p| p.entry_time.date_naive() == sell_date) .unwrap_or(false) } /// Check if a day trade is allowed (under PDT limit). /// PDT rule only applies to accounts under $25,000. #[allow(dead_code)] fn can_day_trade(&self, current_date: NaiveDate, portfolio_value: f64) -> bool { if portfolio_value >= 25_000.0 { return true; } self.day_trades_in_window(current_date) < Self::PDT_MAX_DAY_TRADES } /// Check if stop-loss, trailing stop, or time exit should trigger. /// /// Exit priority (checked in order): /// 1. Hard max-loss cap (MAX_LOSS_PCT) -- absolute worst-case protection /// 2. ATR-based stop-loss (ATR_STOP_MULTIPLIER * ATR) -- primary risk control /// 3. Fixed % stop-loss (STOP_LOSS_PCT) -- fallback when ATR unavailable /// 4. Time-based exit (TIME_EXIT_BARS) -- capital efficiency /// 5. Trailing stop (ATR_TRAIL_MULTIPLIER * ATR) -- profit protection /// /// Note: Take-profit removed intentionally. Capping winners reduces avg win /// and hurts the win/loss ratio. The trailing stop naturally captures profits /// while allowing trends to continue (per Trend Following literature, Covel 2004). fn check_stop_loss_take_profit(&mut self, symbol: &str, current_price: f64) -> Option { let bars_held = self .positions .get(symbol) .map_or(0, |p| p.bars_held); self.strategy .check_stop_loss_take_profit(symbol, current_price, bars_held) } /// Run the backtest simulation. pub async fn run(&mut self, client: &AlpacaClient, years: f64) -> Result { let symbols = get_all_symbols(); // Calculate warmup period let warmup_period = self.strategy.params.min_bars() + 10; let warmup_calendar_days = if self.timeframe == Timeframe::Hourly { (warmup_period as f64 / HOURS_PER_DAY as f64 * 1.5) as i64 } else { (warmup_period as f64 * 1.5) as i64 }; tracing::info!("{}", "=".repeat(70)); tracing::info!("STARTING BACKTEST"); tracing::info!("Initial Capital: ${:.2}", self.initial_capital); tracing::info!("Period: {:.2} years ({:.1} months)", years, years * 12.0); tracing::info!("Timeframe: {:?} bars", self.timeframe); tracing::info!( "Risk: ATR stops ({}x), trail ({}x after {}x gain), max {}% pos, {} max pos, {} max/sector, {} bar cooldown", ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS, MAX_SECTOR_POSITIONS, REENTRY_COOLDOWN_BARS ); tracing::info!("Slippage: {} bps per trade", SLIPPAGE_BPS); if self.timeframe == Timeframe::Hourly { tracing::info!( "Parameters scaled {}x (e.g., RSI: {}, EMA_TREND: {})", HOURS_PER_DAY, self.strategy.params.rsi_period, self.strategy.params.ema_trend ); } tracing::info!("{}", "=".repeat(70)); // Fetch historical data let raw_data = fetch_backtest_data( client, &symbols.iter().map(|s| *s).collect::>(), years, self.timeframe, warmup_calendar_days, ) .await?; if raw_data.is_empty() { anyhow::bail!("No historical data available for backtesting"); } // Calculate indicators for all symbols let mut data: HashMap> = HashMap::new(); for (symbol, bars) in &raw_data { let min_bars = self.strategy.params.min_bars(); if bars.len() < min_bars { tracing::warn!( "{}: Only {} bars, need {}. Skipping.", symbol, bars.len(), min_bars ); continue; } let indicators = calculate_all_indicators(bars, &self.strategy.params); data.insert(symbol.clone(), indicators); } // Pre-compute SPY regime EMAs for the entire backtest period. // We use the raw SPY close prices to compute EMA-50 and EMA-200 // independently of the per-symbol indicator params (regime uses // fixed periods regardless of hourly/daily timeframe scaling). let spy_key = REGIME_SPY_SYMBOL.to_string(); let spy_ema50_series: Vec; let spy_ema200_series: Vec; let has_spy_data = raw_data.contains_key(&spy_key); if has_spy_data { let spy_closes: Vec = raw_data[&spy_key].iter().map(|b| b.close).collect(); spy_ema50_series = calculate_ema(&spy_closes, REGIME_EMA_SHORT); spy_ema200_series = calculate_ema(&spy_closes, REGIME_EMA_LONG); tracing::info!( "SPY regime filter: EMA-{} / EMA-{} ({} bars of SPY data)", REGIME_EMA_SHORT, REGIME_EMA_LONG, spy_closes.len() ); } else { spy_ema50_series = vec![]; spy_ema200_series = vec![]; tracing::warn!( "SPY data not available — market regime filter DISABLED. \ All bars will be treated as BULL regime." ); } // Get common date range let mut all_dates: BTreeMap, HashSet> = BTreeMap::new(); for (symbol, rows) in &data { for row in rows { all_dates .entry(row.timestamp) .or_insert_with(HashSet::new) .insert(symbol.clone()); } } let all_dates: Vec> = all_dates.keys().copied().collect(); // Calculate trading start date let end_date = Utc::now(); let trading_start_date = end_date - Duration::days((years * 365.0) as i64); // Filter to only trade on requested period let trading_dates: Vec> = all_dates .iter() .filter(|&&d| d >= trading_start_date) .copied() .collect(); // Ensure we have enough warmup let trading_dates = if !trading_dates.is_empty() { let first_trading_idx = all_dates .iter() .position(|&d| d == trading_dates[0]) .unwrap_or(0); if first_trading_idx < warmup_period { trading_dates .into_iter() .skip(warmup_period - first_trading_idx) .collect() } else { trading_dates } } else { trading_dates }; if trading_dates.is_empty() { anyhow::bail!( "No trading days available after warmup. \n Try a longer backtest period (at least 4 months recommended)." ); } tracing::info!( "\nSimulating {} trading days (after {}-day warmup)...", trading_dates.len(), warmup_period ); // Build index lookup for each symbol's data let mut symbol_date_index: HashMap, usize>> = HashMap::new(); for (symbol, rows) in &data { let mut idx_map = HashMap::new(); for (i, row) in rows.iter().enumerate() { idx_map.insert(row.timestamp, i); } symbol_date_index.insert(symbol.clone(), idx_map); } // Build SPY raw bar index (maps timestamp → index into raw_data["SPY"]) // so we can look up the pre-computed EMA-50/200 at each trading date. let spy_raw_date_index: HashMap, usize> = if has_spy_data { raw_data[&spy_key] .iter() .enumerate() .map(|(i, bar)| (bar.timestamp, i)) .collect() } else { HashMap::new() }; // Main simulation loop for (day_num, current_date) in trading_dates.iter().enumerate() { self.current_bar = day_num; self.new_positions_this_bar = 0; // Reset counter for each bar self.prune_old_day_trades(current_date.date_naive()); // PDT window cleanup // Get current prices and momentum for all symbols let mut current_prices: HashMap = HashMap::new(); let mut momentum_scores: HashMap = HashMap::new(); for (symbol, rows) in &data { if let Some(&idx) = symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) { let row = &rows[idx]; current_prices.insert(symbol.clone(), row.close); if !row.momentum.is_nan() { momentum_scores.insert(symbol.clone(), row.momentum); } } } let portfolio_value = self.get_portfolio_value(¤t_prices); // ── SPY Market Regime Detection ───────────────────────── // Determine if we're in bull/caution/bear based on SPY EMAs. // This gates all new long entries and adjusts position sizing. let regime = if has_spy_data { if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = ( spy_raw_date_index.get(current_date), data.get(&spy_key) .and_then(|rows| { symbol_date_index .get(&spy_key) .and_then(|m| m.get(current_date)) .map(|&i| &rows[i]) }), ) { let ema50 = if spy_raw_idx < spy_ema50_series.len() { spy_ema50_series[spy_raw_idx] } else { f64::NAN }; let ema200 = if spy_raw_idx < spy_ema200_series.len() { spy_ema200_series[spy_raw_idx] } else { f64::NAN }; determine_market_regime(spy_indicator_row, ema50, ema200) } else { MarketRegime::Caution // No SPY data for this bar } } else { MarketRegime::Bull // No SPY data at all, don't penalize }; self.current_regime = regime; // Regime-based sizing factor and threshold adjustment // Use timeframe-specific parameters: hourly needs defensiveness, daily needs aggression let regime_size_factor = match regime { MarketRegime::Bull => 1.0, MarketRegime::Caution => { if self.timeframe == Timeframe::Hourly { HOURLY_REGIME_CAUTION_SIZE_FACTOR } else { REGIME_CAUTION_SIZE_FACTOR } }, MarketRegime::Bear => 0.0, // No new longs }; // Log regime changes (only on transitions) if day_num == 0 || (day_num > 0 && regime != self.current_regime) { // Already set above, but log on first bar } if day_num % 100 == 0 { tracing::info!(" Market regime: {} (SPY)", regime.as_str()); } // Update drawdown circuit breaker self.update_drawdown_state(portfolio_value); // Increment bars_held for all positions for pos in self.positions.values_mut() { pos.bars_held += 1; } // Momentum ranking: sort symbols by momentum let mut ranked_symbols: Vec = momentum_scores.keys().cloned().collect(); ranked_symbols.sort_by(|a, b| { let ma = momentum_scores.get(a).unwrap_or(&0.0); let mb = momentum_scores.get(b).unwrap_or(&0.0); mb.partial_cmp(ma).unwrap_or(std::cmp::Ordering::Equal) }); let top_momentum_symbols: HashSet = ranked_symbols .iter() .take(TOP_MOMENTUM_COUNT) .cloned() .collect(); // Phase 1: Process sells (stop-loss, trailing stop, time exit, signals) let position_symbols: Vec = self.positions.keys().cloned().collect(); for symbol in position_symbols { let rows = match data.get(&symbol) { Some(r) => r, None => continue, }; let idx = match symbol_date_index .get(&symbol) .and_then(|m| m.get(current_date)) { Some(&i) => i, None => continue, }; if idx < 1 { continue; } let current_row = &rows[idx]; let previous_row = &rows[idx - 1]; if current_row.rsi.is_nan() || current_row.macd.is_nan() { continue; } let mut signal = generate_signal(&symbol, current_row, previous_row); // Check stop-loss/take-profit/trailing stop/time exit if let Some(sl_tp) = self.check_stop_loss_take_profit(&symbol, signal.current_price) { signal.signal = sl_tp; } // Execute sells if signal.signal.is_sell() { let was_stop_loss = matches!(signal.signal, Signal::StrongSell); self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss, portfolio_value); } } // Phase 2: Process buys (only for top momentum stocks) // In Bear regime, skip the entire buy phase (no new longs). if regime.allows_new_longs() { // In Caution regime, raise the buy threshold to require stronger signals // Use timeframe-specific parameters: hourly needs high bump, daily needs low bump let buy_threshold_bump = match regime { MarketRegime::Caution => { if self.timeframe == Timeframe::Hourly { HOURLY_REGIME_CAUTION_THRESHOLD_BUMP } else { REGIME_CAUTION_THRESHOLD_BUMP } }, _ => 0.0, }; for symbol in &ranked_symbols { // Don't buy SPY itself — it's used as the regime benchmark if symbol == REGIME_SPY_SYMBOL { continue; } let rows = match data.get(symbol) { Some(r) => r, None => continue, }; // Only buy top momentum stocks if !top_momentum_symbols.contains(symbol) { continue; } let idx = match symbol_date_index .get(symbol) .and_then(|m| m.get(current_date)) { Some(&i) => i, None => continue, }; if idx < 1 { continue; } let current_row = &rows[idx]; let previous_row = &rows[idx - 1]; if current_row.rsi.is_nan() || current_row.macd.is_nan() { continue; } let signal = generate_signal(symbol, current_row, previous_row); // Apply regime threshold bump: in Caution, require stronger conviction let effective_buy = if buy_threshold_bump > 0.0 { // Reverse confidence back to score: confidence = score / 10.0 // In Caution, require score >= 4.0 + bump (7.0 → StrongBuy territory). let approx_score = signal.confidence * 10.0; approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy() } else { signal.signal.is_buy() }; if effective_buy { self.execute_buy( symbol, signal.current_price, *current_date, portfolio_value, &signal, regime_size_factor, ); } } } // Record equity self.equity_history.push(EquityPoint { date: *current_date, portfolio_value: self.get_portfolio_value(¤t_prices), cash: self.cash, positions_count: self.positions.len(), }); // Progress update if (day_num + 1) % 100 == 0 { tracing::info!( " Processed {}/{} days... Portfolio: ${:.2} (positions: {})", day_num + 1, trading_dates.len(), self.equity_history .last() .map(|e| e.portfolio_value) .unwrap_or(0.0), self.positions.len() ); } } // Close all remaining positions at final prices let final_date = trading_dates.last().copied().unwrap_or_else(Utc::now); let position_symbols: Vec = self.positions.keys().cloned().collect(); for symbol in position_symbols { if let Some(rows) = data.get(&symbol) { if let Some(last_row) = rows.last() { // Always allow final close-out sells (bypass PDT with large value) self.execute_sell(&symbol, last_row.close, final_date, false, f64::MAX); } } } // Calculate results let result = self.calculate_results(years)?; // Print summary self.print_summary(&result); Ok(result) } /// Run the backtest simulation with specific date range. pub async fn run_with_dates( &mut self, client: &AlpacaClient, start_date: NaiveDate, end_date: NaiveDate, ) -> Result { // Convert dates to DateTime for data fetching let start_datetime = start_date .and_hms_opt(0, 0, 0) .unwrap() .and_local_timezone(Utc) .earliest() .unwrap(); let end_datetime = end_date .and_hms_opt(23, 59, 59) .unwrap() .and_local_timezone(Utc) .latest() .unwrap(); // Calculate years for metrics let days_diff = (end_date - start_date).num_days(); let years = days_diff as f64 / 365.0; let symbols = get_all_symbols(); // Calculate warmup period let warmup_period = self.strategy.params.min_bars() + 10; let warmup_calendar_days = if self.timeframe == Timeframe::Hourly { (warmup_period as f64 / HOURS_PER_DAY as f64 * 1.5) as i64 } else { (warmup_period as f64 * 1.5) as i64 }; tracing::info!("{}", "=".repeat(70)); tracing::info!("STARTING BACKTEST"); tracing::info!("Initial Capital: ${:.2}", self.initial_capital); tracing::info!( "Period: {} to {} ({:.2} years, {:.1} months)", start_date.format("%Y-%m-%d"), end_date.format("%Y-%m-%d"), years, years * 12.0 ); tracing::info!("Timeframe: {:?} bars", self.timeframe); tracing::info!( "Risk: ATR stops ({}x), trail ({}x after {}x gain), max {}% pos, {} max pos, {} max/sector, {} bar cooldown", ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS, MAX_SECTOR_POSITIONS, REENTRY_COOLDOWN_BARS ); tracing::info!("Slippage: {} bps per trade", SLIPPAGE_BPS); if self.timeframe == Timeframe::Hourly { tracing::info!( "Parameters scaled {}x (e.g., RSI: {}, EMA_TREND: {})", HOURS_PER_DAY, self.strategy.params.rsi_period, self.strategy.params.ema_trend ); } tracing::info!("{}", "=".repeat(70)); // Fetch historical data with custom date range let raw_data = fetch_backtest_data_with_dates( client, &symbols.iter().map(|s| *s).collect::>(), start_datetime, end_datetime, self.timeframe, warmup_calendar_days, ) .await?; if raw_data.is_empty() { anyhow::bail!("No historical data available for backtesting"); } // Calculate indicators for all symbols let mut data: HashMap> = HashMap::new(); for (symbol, bars) in &raw_data { let min_bars = self.strategy.params.min_bars(); if bars.len() < min_bars { tracing::warn!( "{}: Only {} bars, need {}. Skipping.", symbol, bars.len(), min_bars ); continue; } let indicators = calculate_all_indicators(bars, &self.strategy.params); data.insert(symbol.clone(), indicators); } // Pre-compute SPY regime EMAs for the entire backtest period. let spy_key = REGIME_SPY_SYMBOL.to_string(); let spy_ema50_series: Vec; let spy_ema200_series: Vec; let has_spy_data = raw_data.contains_key(&spy_key); if has_spy_data { let spy_closes: Vec = raw_data[&spy_key].iter().map(|b| b.close).collect(); spy_ema50_series = calculate_ema(&spy_closes, REGIME_EMA_SHORT); spy_ema200_series = calculate_ema(&spy_closes, REGIME_EMA_LONG); tracing::info!( "SPY regime filter: EMA-{} / EMA-{} ({} bars of SPY data)", REGIME_EMA_SHORT, REGIME_EMA_LONG, spy_closes.len() ); } else { spy_ema50_series = vec![]; spy_ema200_series = vec![]; tracing::warn!( "SPY data not available — market regime filter DISABLED. \ All bars will be treated as BULL regime." ); } // Get common date range let mut all_dates: BTreeMap, HashSet> = BTreeMap::new(); for (symbol, rows) in &data { for row in rows { all_dates .entry(row.timestamp) .or_insert_with(HashSet::new) .insert(symbol.clone()); } } let all_dates: Vec> = all_dates.keys().copied().collect(); // Filter to only trade on requested period let trading_dates: Vec> = all_dates .iter() .filter(|&&d| d >= start_datetime && d <= end_datetime) .copied() .collect(); // Ensure we have enough warmup let trading_dates = if !trading_dates.is_empty() { let first_trading_idx = all_dates .iter() .position(|&d| d == trading_dates[0]) .unwrap_or(0); if first_trading_idx < warmup_period { trading_dates .into_iter() .skip(warmup_period - first_trading_idx) .collect() } else { trading_dates } } else { trading_dates }; if trading_dates.is_empty() { anyhow::bail!( "No trading days available after warmup. \n Try a longer backtest period (at least 4 months recommended)." ); } tracing::info!( "\nSimulating {} trading days (after {}-day warmup)...", trading_dates.len(), warmup_period ); // From here on, the code is identical to the regular run() method // Build index lookup for each symbol's data let mut symbol_date_index: HashMap, usize>> = HashMap::new(); for (symbol, rows) in &data { let mut idx_map = HashMap::new(); for (i, row) in rows.iter().enumerate() { idx_map.insert(row.timestamp, i); } symbol_date_index.insert(symbol.clone(), idx_map); } // Build SPY raw bar index let spy_raw_date_index: HashMap, usize> = if has_spy_data { raw_data[&spy_key] .iter() .enumerate() .map(|(i, bar)| (bar.timestamp, i)) .collect() } else { HashMap::new() }; // Main simulation loop (identical to run()) for (day_num, current_date) in trading_dates.iter().enumerate() { self.current_bar = day_num; self.new_positions_this_bar = 0; self.prune_old_day_trades(current_date.date_naive()); // Get current prices and momentum for all symbols let mut current_prices: HashMap = HashMap::new(); let mut momentum_scores: HashMap = HashMap::new(); for (symbol, rows) in &data { if let Some(&idx) = symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) { let row = &rows[idx]; current_prices.insert(symbol.clone(), row.close); if !row.momentum.is_nan() { momentum_scores.insert(symbol.clone(), row.momentum); } } } let portfolio_value = self.get_portfolio_value(¤t_prices); // SPY Market Regime Detection let regime = if has_spy_data { if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = ( spy_raw_date_index.get(current_date), data.get(&spy_key) .and_then(|rows| { symbol_date_index .get(&spy_key) .and_then(|m| m.get(current_date)) .map(|&i| &rows[i]) }), ) { let ema50 = if spy_raw_idx < spy_ema50_series.len() { spy_ema50_series[spy_raw_idx] } else { f64::NAN }; let ema200 = if spy_raw_idx < spy_ema200_series.len() { spy_ema200_series[spy_raw_idx] } else { f64::NAN }; determine_market_regime(spy_indicator_row, ema50, ema200) } else { MarketRegime::Caution } } else { MarketRegime::Bull }; self.current_regime = regime; // Regime-based sizing factor let regime_size_factor = match regime { MarketRegime::Bull => 1.0, MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR, MarketRegime::Bear => 0.0, }; if day_num % 100 == 0 { tracing::info!(" Market regime: {} (SPY)", regime.as_str()); } // Update drawdown circuit breaker self.update_drawdown_state(portfolio_value); // Increment bars_held for all positions for pos in self.positions.values_mut() { pos.bars_held += 1; } // Momentum ranking let mut ranked_symbols: Vec = momentum_scores.keys().cloned().collect(); ranked_symbols.sort_by(|a, b| { let ma = momentum_scores.get(a).unwrap_or(&0.0); let mb = momentum_scores.get(b).unwrap_or(&0.0); mb.partial_cmp(ma).unwrap_or(std::cmp::Ordering::Equal) }); let top_momentum_symbols: HashSet = ranked_symbols .iter() .take(TOP_MOMENTUM_COUNT) .cloned() .collect(); // Phase 1: Process sells let position_symbols: Vec = self.positions.keys().cloned().collect(); for symbol in position_symbols { let rows = match data.get(&symbol) { Some(r) => r, None => continue, }; let idx = match symbol_date_index .get(&symbol) .and_then(|m| m.get(current_date)) { Some(&i) => i, None => continue, }; if idx < 1 { continue; } let current_row = &rows[idx]; let previous_row = &rows[idx - 1]; if current_row.rsi.is_nan() || current_row.macd.is_nan() { continue; } let mut signal = generate_signal(&symbol, current_row, previous_row); // Check stop-loss/take-profit/trailing stop/time exit if let Some(sl_tp) = self.check_stop_loss_take_profit(&symbol, signal.current_price) { signal.signal = sl_tp; } let was_stop_loss = matches!(signal.signal, Signal::StrongSell); if signal.signal.is_sell() { self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss, portfolio_value); } } // Phase 2: Process buys if regime.allows_new_longs() { let buy_threshold_bump = match regime { MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP, _ => 0.0, }; for symbol in &ranked_symbols { if symbol == REGIME_SPY_SYMBOL { continue; } let rows = match data.get(symbol) { Some(r) => r, None => continue, }; if !top_momentum_symbols.contains(symbol) { continue; } let idx = match symbol_date_index .get(symbol) .and_then(|m| m.get(current_date)) { Some(&i) => i, None => continue, }; if idx < 1 { continue; } let current_row = &rows[idx]; let previous_row = &rows[idx - 1]; if current_row.rsi.is_nan() || current_row.macd.is_nan() { continue; } let signal = generate_signal(symbol, current_row, previous_row); let effective_buy = if buy_threshold_bump > 0.0 { let approx_score = signal.confidence * 10.0; approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy() } else { signal.signal.is_buy() }; if effective_buy { self.execute_buy( symbol, signal.current_price, *current_date, portfolio_value, &signal, regime_size_factor, ); } } } // Record equity self.equity_history.push(EquityPoint { date: *current_date, portfolio_value: self.get_portfolio_value(¤t_prices), cash: self.cash, positions_count: self.positions.len(), }); // Progress update if (day_num + 1) % 100 == 0 { tracing::info!( " Processed {}/{} days... Portfolio: ${:.2} (positions: {})", day_num + 1, trading_dates.len(), self.equity_history .last() .map(|e| e.portfolio_value) .unwrap_or(0.0), self.positions.len() ); } } // Close all remaining positions at final prices let final_date = trading_dates.last().copied().unwrap_or_else(Utc::now); let position_symbols: Vec = self.positions.keys().cloned().collect(); for symbol in position_symbols { if let Some(rows) = data.get(&symbol) { if let Some(last_row) = rows.last() { self.execute_sell(&symbol, last_row.close, final_date, false, f64::MAX); } } } // Calculate results let result = self.calculate_results(years)?; // Print summary self.print_summary(&result); Ok(result) } /// Calculate performance metrics from backtest. fn calculate_results(&self, years: f64) -> Result { if self.equity_history.is_empty() { anyhow::bail!( "No trading days after indicator warmup period. \n Try a longer backtest period (at least 4 months recommended)." ); } // Use the last equity curve value (cash + positions) as the final value. // All positions should be closed by now, but using the equity curve is // more robust than relying solely on self.cash. let final_value = self .equity_history .last() .map(|e| e.portfolio_value) .unwrap_or(self.cash); let total_return = final_value - self.initial_capital; let total_return_pct = total_return / self.initial_capital; // CAGR let cagr = if years > 0.0 { (final_value / self.initial_capital).powf(1.0 / years) - 1.0 } else { 0.0 }; // Calculate daily returns let mut daily_returns: Vec = Vec::new(); for i in 1..self.equity_history.len() { let prev = self.equity_history[i - 1].portfolio_value; let curr = self.equity_history[i].portfolio_value; if prev > 0.0 { daily_returns.push((curr - prev) / prev); } } // Sharpe Ratio (assuming 252 trading days, risk-free rate ~5%) let risk_free_daily = 0.05 / TRADING_DAYS_PER_YEAR as f64; let excess_returns: Vec = daily_returns.iter().map(|r| r - risk_free_daily).collect(); let sharpe = if !excess_returns.is_empty() { let mean = excess_returns.iter().sum::() / excess_returns.len() as f64; let variance: f64 = excess_returns .iter() .map(|r| (r - mean).powi(2)) .sum::() / excess_returns.len() as f64; let std = variance.sqrt(); if std > 0.0 { (mean / std) * (TRADING_DAYS_PER_YEAR as f64).sqrt() } else { 0.0 } } else { 0.0 }; // Sortino Ratio (downside deviation) let negative_returns: Vec = daily_returns .iter() .filter(|&&r| r < 0.0) .copied() .collect(); let sortino = if !negative_returns.is_empty() && !daily_returns.is_empty() { let mean = daily_returns.iter().sum::() / daily_returns.len() as f64; let neg_variance: f64 = negative_returns.iter().map(|r| r.powi(2)).sum::() / negative_returns.len() as f64; let neg_std = neg_variance.sqrt(); if neg_std > 0.0 { (mean / neg_std) * (TRADING_DAYS_PER_YEAR as f64).sqrt() } else { 0.0 } } else { 0.0 }; // Maximum Drawdown let mut max_drawdown = 0.0; let mut max_drawdown_pct = 0.0; let mut peak = self.initial_capital; for point in &self.equity_history { if point.portfolio_value > peak { peak = point.portfolio_value; } let drawdown = point.portfolio_value - peak; let drawdown_pct = drawdown / peak; if drawdown < max_drawdown { max_drawdown = drawdown; max_drawdown_pct = drawdown_pct; } } // Trade statistics let sell_trades: Vec<&Trade> = self.trades.iter().filter(|t| t.side == "SELL").collect(); let winning_trades: Vec<&Trade> = sell_trades.iter().filter(|t| t.pnl > 0.0).copied().collect(); let losing_trades: Vec<&Trade> = sell_trades.iter().filter(|t| t.pnl <= 0.0).copied().collect(); let total_trades = sell_trades.len(); let win_count = winning_trades.len(); let lose_count = losing_trades.len(); let win_rate = if total_trades > 0 { win_count as f64 / total_trades as f64 } else { 0.0 }; let avg_win = if !winning_trades.is_empty() { winning_trades.iter().map(|t| t.pnl).sum::() / winning_trades.len() as f64 } else { 0.0 }; let avg_loss = if !losing_trades.is_empty() { losing_trades.iter().map(|t| t.pnl).sum::() / losing_trades.len() as f64 } else { 0.0 }; let total_wins: f64 = winning_trades.iter().map(|t| t.pnl).sum(); let total_losses: f64 = losing_trades.iter().map(|t| t.pnl.abs()).sum(); let profit_factor = if total_losses > 0.0 { total_wins / total_losses } else { f64::INFINITY }; Ok(BacktestResult { initial_capital: self.initial_capital, final_value, total_return, total_return_pct, cagr, sharpe_ratio: sharpe, sortino_ratio: sortino, max_drawdown, max_drawdown_pct, total_trades, winning_trades: win_count, losing_trades: lose_count, win_rate, avg_win, avg_loss, profit_factor, trades: self.trades.clone(), equity_curve: self.equity_history.clone(), }) } /// Print backtest summary. fn print_summary(&self, result: &BacktestResult) { println!("\n"); println!("{}", "=".repeat(70)); println!("{:^70}", "BACKTEST RESULTS"); println!("{}", "=".repeat(70)); println!("\n{:^70}", "PORTFOLIO PERFORMANCE"); println!("{}", "-".repeat(70)); println!(" Initial Capital: ${:>15.2}", result.initial_capital); println!(" Final Value: ${:>15.2}", result.final_value); println!( " Total Return: ${:>15.2} ({:>+.2}%)", result.total_return, result.total_return_pct * 100.0 ); println!(" CAGR: {:>15.2}%", result.cagr * 100.0); println!(); println!("{:^70}", "RISK METRICS"); println!("{}", "-".repeat(70)); println!(" Sharpe Ratio: {:>15.2}", result.sharpe_ratio); println!(" Sortino Ratio: {:>15.2}", result.sortino_ratio); println!( " Max Drawdown: ${:>15.2} ({:.2}%)", result.max_drawdown, result.max_drawdown_pct * 100.0 ); println!(); println!("{:^70}", "TRADE STATISTICS"); println!("{}", "-".repeat(70)); println!(" Total Trades: {:>15}", result.total_trades); println!(" Winning Trades: {:>15}", result.winning_trades); println!(" Losing Trades: {:>15}", result.losing_trades); println!(" Win Rate: {:>15.2}%", result.win_rate * 100.0); println!(" Avg Win: ${:>15.2}", result.avg_win); println!(" Avg Loss: ${:>15.2}", result.avg_loss); println!(" Profit Factor: {:>15.2}", result.profit_factor); println!(); println!("{:^70}", "STRATEGY PARAMETERS"); println!("{}", "-".repeat(70)); println!( " Slippage: {:>15} bps", SLIPPAGE_BPS as i64 ); println!( " ATR Stop: {:>15.1}x", ATR_STOP_MULTIPLIER ); println!( " ATR Trail: {:>15.1}x (after {:.1}x gain)", ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER ); println!( " Max Positions: {:>15}", MAX_CONCURRENT_POSITIONS ); println!( " Max Per Sector: {:>15}", MAX_SECTOR_POSITIONS ); { let (t1p, t1b, t2p, t2b, t3p, t3b) = self.drawdown_tiers(); println!( " Drawdown Halt: {:>13.0}%/{:.0}%/{:.0}% ({}/{}/{} bars)", t1p * 100.0, t2p * 100.0, t3p * 100.0, t1b, t2b, t3b, ); } println!( " Market Regime Filter: {:>15}", format!("SPY EMA-{}/EMA-{}", REGIME_EMA_SHORT, REGIME_EMA_LONG) ); println!( " Equity Curve Stop: {:>13}-bar SMA", EQUITY_CURVE_SMA_PERIOD ); println!( " Time Exit: {:>13} bars", TIME_EXIT_BARS ); println!( " Max Loss Cap: {:>14.1}%", MAX_LOSS_PCT * 100.0 ); println!( " Re-entry Cooldown: {:>13} bars", REENTRY_COOLDOWN_BARS ); if !self.day_trades.is_empty() { println!(); println!("{:^70}", "PDT INFO"); println!("{}", "-".repeat(70)); println!( " Day trades occurred: {:>15}", self.day_trades.len() ); } println!("{}", "=".repeat(70)); // Show recent trades if !result.trades.is_empty() { println!("\n{:^70}", "RECENT TRADES (Last 10)"); println!("{}", "-".repeat(70)); println!( " {:12} {:8} {:6} {:8} {:12} {:12}", "Date", "Symbol", "Side", "Shares", "Price", "P&L" ); println!("{}", "-".repeat(70)); for trade in result.trades.iter().rev().take(10).rev() { let date_str = trade.timestamp.format("%Y-%m-%d").to_string(); let pnl_str = if trade.side == "SELL" { format!("${:.2}", trade.pnl) } else { "-".to_string() }; println!( " {:12} {:8} {:6} {:8.0} ${:11.2} {:12}", date_str, trade.symbol, trade.side, trade.shares, trade.price, pnl_str ); } println!("{}", "=".repeat(70)); } } } /// Save backtest results to CSV files. pub fn save_backtest_results(result: &BacktestResult) -> Result<()> { // Save equity curve if !result.equity_curve.is_empty() { let mut wtr = csv::Writer::from_path("backtest_equity_curve.csv") .context("Failed to create equity curve CSV")?; wtr.write_record(["date", "portfolio_value", "cash", "positions_count"])?; for point in &result.equity_curve { wtr.write_record(&[ point.date.to_rfc3339(), point.portfolio_value.to_string(), point.cash.to_string(), point.positions_count.to_string(), ])?; } wtr.flush()?; tracing::info!("Equity curve saved to: backtest_equity_curve.csv"); } // Save trades if !result.trades.is_empty() { let mut wtr = csv::Writer::from_path("backtest_trades.csv") .context("Failed to create trades CSV")?; wtr.write_record(["timestamp", "symbol", "side", "shares", "price", "pnl", "pnl_pct"])?; for trade in &result.trades { wtr.write_record(&[ trade.timestamp.to_rfc3339(), trade.symbol.clone(), trade.side.clone(), trade.shares.to_string(), trade.price.to_string(), trade.pnl.to_string(), trade.pnl_pct.to_string(), ])?; } wtr.flush()?; tracing::info!("Trades saved to: backtest_trades.csv"); } Ok(()) }