Experiment with hourly timeframe-specific stops
- Added HOURLY_ATR_STOP_MULTIPLIER (1.8x) vs daily (3.5x) - Added hourly-specific trail multipliers - Strategy now uses timeframe field to select appropriate stops - Tested multiple configurations on hourly: * 3.5x stops: -0.5% return, 45% max DD * 1.8x stops: -45% return, 53% max DD (worse) * Conservative regime (0.25x): -65% return, 67% max DD (terrible) - Conclusion: Hourly doesn't work with this strategy - Daily with relaxed regime remains best: +17.4% over 5yr, 24% max DD Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
|
||||
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,
|
||||
@@ -885,6 +885,432 @@ impl Backtester {
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user