Files
vibe-invest/src/backtester.rs
zastian-dev 0e820852fa new best
2026-02-13 20:04:32 +00:00

1673 lines
63 KiB
Rust

//! 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<String, BacktestPosition>,
trades: Vec<Trade>,
equity_history: Vec<EquityPoint>,
peak_portfolio_value: f64,
drawdown_halt: bool,
/// Bar index when drawdown halt started (for time-based resume)
drawdown_halt_start: Option<usize>,
/// 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<String, usize>,
/// 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<NaiveDate>,
/// 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<String, f64>) -> 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<Utc>,
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<Utc>,
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<Signal> {
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<BacktestResult> {
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::<Vec<_>>(),
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<String, Vec<IndicatorRow>> = 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<f64>;
let spy_ema200_series: Vec<f64>;
let has_spy_data = raw_data.contains_key(&spy_key);
if has_spy_data {
let spy_closes: Vec<f64> = 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<DateTime<Utc>, HashSet<String>> = 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<DateTime<Utc>> = 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<DateTime<Utc>> = 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<String, HashMap<DateTime<Utc>, 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<DateTime<Utc>, 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<String, f64> = HashMap::new();
let mut momentum_scores: HashMap<String, f64> = 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(&current_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<String> = 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<String> = ranked_symbols
.iter()
.take(TOP_MOMENTUM_COUNT)
.cloned()
.collect();
// Phase 1: Process sells (stop-loss, trailing stop, time exit, signals)
let position_symbols: Vec<String> = 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(&current_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<String> = 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<BacktestResult> {
// Convert dates to DateTime<Utc> 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::<Vec<_>>(),
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<String, Vec<IndicatorRow>> = 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<f64>;
let spy_ema200_series: Vec<f64>;
let has_spy_data = raw_data.contains_key(&spy_key);
if has_spy_data {
let spy_closes: Vec<f64> = 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<DateTime<Utc>, HashSet<String>> = 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<DateTime<Utc>> = all_dates.keys().copied().collect();
// Filter to only trade on requested period
let trading_dates: Vec<DateTime<Utc>> = 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<String, HashMap<DateTime<Utc>, 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<DateTime<Utc>, 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<String, f64> = HashMap::new();
let mut momentum_scores: HashMap<String, f64> = 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(&current_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<String> = 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<String> = ranked_symbols
.iter()
.take(TOP_MOMENTUM_COUNT)
.cloned()
.collect();
// Phase 1: Process sells
let position_symbols: Vec<String> = 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(&current_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<String> = 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<BacktestResult> {
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<f64> = 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<f64> =
daily_returns.iter().map(|r| r - risk_free_daily).collect();
let sharpe = if !excess_returns.is_empty() {
let mean = excess_returns.iter().sum::<f64>() / excess_returns.len() as f64;
let variance: f64 = excess_returns
.iter()
.map(|r| (r - mean).powi(2))
.sum::<f64>()
/ 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<f64> = 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::<f64>() / daily_returns.len() as f64;
let neg_variance: f64 = negative_returns.iter().map(|r| r.powi(2)).sum::<f64>()
/ 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::<f64>() / winning_trades.len() as f64
} else {
0.0
};
let avg_loss = if !losing_trades.is_empty() {
losing_trades.iter().map(|t| t.pnl).sum::<f64>() / 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(())
}