1673 lines
63 KiB
Rust
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(¤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<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(¤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<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(¤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<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(¤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<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(())
|
|
} |