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:
zastian-dev
2026-02-13 19:20:01 +00:00
parent edc655ca2c
commit 79816b9e2e
7 changed files with 757 additions and 13 deletions

View File

@@ -21,6 +21,10 @@ cargo run --release -- --backtest --years 3
cargo run --release -- --backtest --years 5 --capital 50000 cargo run --release -- --backtest --years 5 --capital 50000
cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly
# Run backtesting with custom date range
cargo run --release -- --backtest --start-date 2007-01-01 --end-date 2008-12-31
cargo run --release -- --backtest --start-date 2020-03-01 --end-date 2020-12-31 --timeframe hourly
# Lint and format (available via nix flake) # Lint and format (available via nix flake)
cargo clippy cargo clippy
cargo fmt cargo fmt

119
analyze_regime.py Normal file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Analyze SPY regime detection during backtest periods."""
import pandas as pd
import yfinance as yf
from datetime import datetime, timedelta
def calculate_ema(series, period):
"""Calculate EMA using pandas."""
return series.ewm(span=period, adjust=False).mean()
def determine_regime(price, ema50, ema200):
"""Replicate the Rust regime detection logic."""
if pd.isna(price) or pd.isna(ema50) or pd.isna(ema200):
return "Caution"
# Bear: price below 200 EMA AND 50 EMA below 200 EMA
if price < ema200 and ema50 < ema200:
return "Bear"
# Caution: price below 50 EMA
if price < ema50:
return "Caution"
# Bull: price above both, 50 above 200
if ema50 > ema200:
return "Bull"
# Edge case: price above both but 50 still below 200
return "Caution"
def analyze_period(start_date, end_date, period_name):
"""Analyze SPY regime for a given period."""
print(f"\n{'='*70}")
print(f"{period_name}: {start_date} to {end_date}")
print('='*70)
# Fetch SPY data with extra warmup for EMA-200
warmup_start = (datetime.strptime(start_date, '%Y-%m-%d') - timedelta(days=400)).strftime('%Y-%m-%d')
spy = yf.download('SPY', start=warmup_start, end=end_date, progress=False)
if spy.empty:
print(f"ERROR: No SPY data available for {period_name}")
return
# Calculate EMAs
spy['EMA50'] = calculate_ema(spy['Close'], 50)
spy['EMA200'] = calculate_ema(spy['Close'], 200)
# Determine regime for each day
spy['Regime'] = spy.apply(
lambda row: determine_regime(row['Close'], row['EMA50'], row['EMA200']),
axis=1
)
# Filter to actual trading period
trading_period = spy[start_date:end_date].copy()
if trading_period.empty:
print(f"ERROR: No trading data for {period_name}")
return
# Calculate SPY return
spy_start = trading_period['Close'].iloc[0]
spy_end = trading_period['Close'].iloc[-1]
spy_return = (spy_end - spy_start) / spy_start * 100
print(f"\nSPY Performance:")
print(f" Start: ${spy_start:.2f}")
print(f" End: ${spy_end:.2f}")
print(f" Return: {spy_return:+.2f}%")
# Count regime days
regime_counts = trading_period['Regime'].value_counts()
total_days = len(trading_period)
print(f"\nRegime Distribution ({total_days} trading days):")
for regime in ['Bull', 'Caution', 'Bear']:
count = regime_counts.get(regime, 0)
pct = count / total_days * 100
print(f" {regime:8s}: {count:4d} days ({pct:5.1f}%)")
# Show regime transitions
regime_changes = trading_period[trading_period['Regime'] != trading_period['Regime'].shift(1)]
if len(regime_changes) > 0:
print(f"\nRegime Transitions ({len(regime_changes)} total):")
for date, row in regime_changes.head(20).iterrows():
print(f" {date.strftime('%Y-%m-%d')}: {row['Regime']:8s} (SPY: ${row['Close']:.2f}, "
f"EMA50: ${row['EMA50']:.2f}, EMA200: ${row['EMA200']:.2f})")
if len(regime_changes) > 20:
print(f" ... and {len(regime_changes) - 20} more transitions")
# Identify problematic Bear periods during bull markets
bear_days = trading_period[trading_period['Regime'] == 'Bear']
if len(bear_days) > 0:
print(f"\n⚠️ WARNING: {len(bear_days)} days classified as BEAR:")
for date, row in bear_days.head(10).iterrows():
print(f" {date.strftime('%Y-%m-%d')}: SPY=${row['Close']:.2f}, "
f"EMA50=${row['EMA50']:.2f}, EMA200=${row['EMA200']:.2f}")
if len(bear_days) > 10:
print(f" ... and {len(bear_days) - 10} more Bear days")
# Show first and last months in detail
print(f"\nFirst Month Detail:")
first_month = trading_period.head(22)[['Close', 'EMA50', 'EMA200', 'Regime']]
for date, row in first_month.iterrows():
print(f" {date.strftime('%Y-%m-%d')}: {row['Regime']:8s} | "
f"SPY: ${row['Close']:7.2f} | EMA50: ${row['EMA50']:7.2f} | EMA200: ${row['EMA200']:7.2f}")
if __name__ == '__main__':
# Analyze 2023
analyze_period('2023-01-01', '2023-12-31', '2023 Backtest')
# Analyze 2024
analyze_period('2024-01-01', '2024-12-31', '2024 Backtest')
print(f"\n{'='*70}")
print("Analysis complete.")
print('='*70)

View File

@@ -632,3 +632,138 @@ pub async fn fetch_backtest_data(
Ok(all_data) Ok(all_data)
} }
/// Helper to fetch bars for backtesting with specific date range.
/// Similar to fetch_backtest_data but accepts explicit start/end dates.
pub async fn fetch_backtest_data_with_dates(
client: &AlpacaClient,
symbols: &[&str],
start: DateTime<Utc>,
end: DateTime<Utc>,
timeframe: Timeframe,
warmup_days: i64,
) -> Result<HashMap<String, Vec<Bar>>> {
// Add warmup period to start date
let start_with_warmup = start - Duration::days(warmup_days + 30);
// Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars
let refetch_overlap = Duration::days(2);
tracing::info!(
"Fetching data from {} to {}...",
start_with_warmup.format("%Y-%m-%d"),
end.format("%Y-%m-%d")
);
let mut all_data = HashMap::new();
let mut cache_hits = 0u32;
let mut cache_misses = 0u32;
for symbol in symbols {
let cached = load_cached_bars(symbol, timeframe);
if cached.is_empty() {
// Full fetch — no cache
cache_misses += 1;
tracing::info!(" Fetching {} (no cache)...", symbol);
match client
.get_historical_bars(symbol, timeframe, start_with_warmup, end)
.await
{
Ok(bars) => {
if !bars.is_empty() {
tracing::info!(" {}: {} bars fetched", symbol, bars.len());
save_cached_bars(symbol, timeframe, &bars);
all_data.insert(symbol.to_string(), bars);
} else {
tracing::warn!(" {}: No data", symbol);
}
}
Err(e) => {
tracing::error!(" Failed to fetch {}: {}", symbol, e);
}
}
} else {
let first_cached_ts = cached.first().unwrap().timestamp;
let last_cached_ts = cached.last().unwrap().timestamp;
let need_older = start_with_warmup < first_cached_ts;
let need_newer = last_cached_ts - refetch_overlap < end;
if !need_older && !need_newer {
cache_hits += 1;
tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len());
all_data.insert(symbol.to_string(), cached);
continue;
}
cache_hits += 1;
let mut merged = cached;
// Fetch older data if requested start is before earliest cache
if need_older {
let fetch_older_end = first_cached_ts + refetch_overlap;
tracing::info!(
" {} (fetching older: {} to {})...",
symbol,
start_with_warmup.format("%Y-%m-%d"),
fetch_older_end.format("%Y-%m-%d")
);
match client
.get_historical_bars(symbol, timeframe, start_with_warmup, fetch_older_end)
.await
{
Ok(old_bars) => {
merged = old_bars.into_iter().chain(merged).collect();
}
Err(e) => {
tracing::warn!(" {}: older fetch failed: {}", symbol, e);
}
}
}
// Fetch newer data if cache doesn't cover requested end
if need_newer {
let fetch_from = last_cached_ts - refetch_overlap;
tracing::info!(
" {} (fetching newer: {} to {})...",
symbol,
fetch_from.format("%Y-%m-%d"),
end.format("%Y-%m-%d")
);
match client
.get_historical_bars(symbol, timeframe, fetch_from, end)
.await
{
Ok(new_bars) => {
// Remove the overlap region from merged before appending
merged.retain(|b| b.timestamp < fetch_from);
merged.extend(new_bars);
}
Err(e) => {
tracing::warn!(" {}: newer fetch failed: {}", symbol, e);
}
}
}
// Dedup and sort
merged.sort_by_key(|b| b.timestamp);
merged.dedup_by_key(|b| b.timestamp);
tracing::info!(" {}: {} bars total (merged)", symbol, merged.len());
save_cached_bars(symbol, timeframe, &merged);
all_data.insert(symbol.to_string(), merged);
}
}
tracing::info!(
"Data loading complete: {} cache hits, {} full fetches, {} symbols total",
cache_hits,
cache_misses,
all_data.len()
);
Ok(all_data)
}

View File

@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc}; use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
use std::collections::{BTreeMap, HashMap, HashSet}; 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::{ use crate::config::{
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER, get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY,
@@ -885,6 +885,432 @@ impl Backtester {
Ok(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. /// Calculate performance metrics from backtest.
fn calculate_results(&self, years: f64) -> Result<BacktestResult> { fn calculate_results(&self, years: f64) -> Result<BacktestResult> {
if self.equity_history.is_empty() { if self.equity_history.is_empty() {

View File

@@ -73,11 +73,17 @@ pub const STOP_LOSS_PCT: f64 = 0.025;
pub const MAX_LOSS_PCT: f64 = 0.08; // Gap protection only — ATR stop handles normal exits pub const MAX_LOSS_PCT: f64 = 0.08; // Gap protection only — ATR stop handles normal exits
pub const TRAILING_STOP_ACTIVATION: f64 = 0.04; // Activate earlier to protect profits pub const TRAILING_STOP_ACTIVATION: f64 = 0.04; // Activate earlier to protect profits
pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // Wider trail to let winners run pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // Wider trail to let winners run
// ATR-based risk management // ATR-based risk management (DAILY timeframe - wider stops for longer-term holds)
pub const RISK_PER_TRADE: f64 = 0.015; // 1.5% risk per trade (8 positions * 1.5% = 12% worst-case) pub const RISK_PER_TRADE: f64 = 0.015; // 1.5% risk per trade (8 positions * 1.5% = 12% worst-case)
pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs (the #1 loss source) pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs on daily
pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Don't activate trail too early pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Don't activate trail too early
// ATR-based risk management (HOURLY timeframe - much tighter to prevent 70-90% losses)
// Hourly intraday noise requires stops 40-50% tighter than daily to avoid catastrophic drawdowns
pub const HOURLY_ATR_STOP_MULTIPLIER: f64 = 1.8; // Tight stops prevent -$9k NVDA disasters
pub const HOURLY_ATR_TRAIL_MULTIPLIER: f64 = 1.5; // Tight trail locks in hourly gains quickly
pub const HOURLY_ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 1.2; // Activate trail early on hourly
// Portfolio-level controls // Portfolio-level controls
pub const MAX_CONCURRENT_POSITIONS: usize = 8; // Fewer positions = higher conviction per trade pub const MAX_CONCURRENT_POSITIONS: usize = 8; // Fewer positions = higher conviction per trade
pub const MAX_SECTOR_POSITIONS: usize = 2; pub const MAX_SECTOR_POSITIONS: usize = 2;

View File

@@ -50,7 +50,8 @@ use crate::config::{Timeframe, DEFAULT_INITIAL_CAPITAL};
Backtest 6 months: invest-bot --backtest --months 6\n \ Backtest 6 months: invest-bot --backtest --months 6\n \
Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \ Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \
Custom capital: invest-bot --backtest --years 5 --capital 50000\n \ Custom capital: invest-bot --backtest --years 5 --capital 50000\n \
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly" Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly\n \
Custom date range: invest-bot --backtest --start-date 2007-01-01 --end-date 2008-12-31"
)] )]
struct Args { struct Args {
/// Run in backtest mode instead of live trading /// Run in backtest mode instead of live trading
@@ -65,6 +66,14 @@ struct Args {
#[arg(short, long, default_value_t = 0.0)] #[arg(short, long, default_value_t = 0.0)]
months: f64, months: f64,
/// Start date for backtest (YYYY-MM-DD). Overrides --years/--months if provided.
#[arg(long, value_name = "YYYY-MM-DD")]
start_date: Option<String>,
/// End date for backtest (YYYY-MM-DD). Defaults to now if not provided.
#[arg(long, value_name = "YYYY-MM-DD")]
end_date: Option<String>,
/// Initial capital for backtesting /// Initial capital for backtesting
#[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)] #[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)]
capital: f64, capital: f64,
@@ -171,14 +180,45 @@ async fn main() -> Result<()> {
} }
async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> { async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> {
// Combine years and months (default to 1 year if neither specified) use chrono::NaiveDate;
let total_years = args.years + (args.months / 12.0);
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
let client = AlpacaClient::new(api_key, api_secret)?; let client = AlpacaClient::new(api_key, api_secret)?;
let mut backtester = Backtester::new(args.capital, args.timeframe); let mut backtester = Backtester::new(args.capital, args.timeframe);
let result = backtester.run(&client, total_years).await?; let result = if args.start_date.is_some() || args.end_date.is_some() {
// Custom date range mode
let start_date = if let Some(ref s) = args.start_date {
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.context("Invalid start date format. Use YYYY-MM-DD (e.g., 2007-01-01)")?
} else {
// If no start date provided, default to 1 year before end date
let end = if let Some(ref e) = args.end_date {
NaiveDate::parse_from_str(e, "%Y-%m-%d")?
} else {
chrono::Utc::now().date_naive()
};
end - chrono::Duration::days(365)
};
let end_date = if let Some(ref e) = args.end_date {
NaiveDate::parse_from_str(e, "%Y-%m-%d")
.context("Invalid end date format. Use YYYY-MM-DD (e.g., 2008-12-31)")?
} else {
chrono::Utc::now().date_naive()
};
// Validate date range
if start_date >= end_date {
anyhow::bail!("Start date must be before end date");
}
backtester.run_with_dates(&client, start_date, end_date).await?
} else {
// Years/months mode (existing behavior)
let total_years = args.years + (args.months / 12.0);
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
backtester.run(&client, total_years).await?
};
// Save results to CSV // Save results to CSV
save_backtest_results(&result)?; save_backtest_results(&result)?;

View File

@@ -5,6 +5,7 @@ use crate::config::{
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, MAX_LOSS_PCT, MAX_POSITION_SIZE, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, MAX_LOSS_PCT, MAX_POSITION_SIZE,
MIN_ATR_PCT, RISK_PER_TRADE, STOP_LOSS_PCT, TIME_EXIT_BARS, MIN_ATR_PCT, RISK_PER_TRADE, STOP_LOSS_PCT, TIME_EXIT_BARS,
TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE, TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
HOURLY_ATR_STOP_MULTIPLIER, HOURLY_ATR_TRAIL_MULTIPLIER, HOURLY_ATR_TRAIL_ACTIVATION_MULTIPLIER,
}; };
use crate::types::{Signal, TradeSignal}; use crate::types::{Signal, TradeSignal};
@@ -14,6 +15,7 @@ pub struct Strategy {
pub high_water_marks: HashMap<String, f64>, pub high_water_marks: HashMap<String, f64>,
pub entry_atrs: HashMap<String, f64>, pub entry_atrs: HashMap<String, f64>,
pub entry_prices: HashMap<String, f64>, pub entry_prices: HashMap<String, f64>,
pub timeframe: Timeframe,
} }
impl Strategy { impl Strategy {
@@ -23,6 +25,7 @@ impl Strategy {
high_water_marks: HashMap::new(), high_water_marks: HashMap::new(),
entry_atrs: HashMap::new(), entry_atrs: HashMap::new(),
entry_prices: HashMap::new(), entry_prices: HashMap::new(),
timeframe,
} }
} }
@@ -105,8 +108,14 @@ impl Strategy {
} }
// 2. ATR-based initial stop-loss (primary risk control) // 2. ATR-based initial stop-loss (primary risk control)
// Use tighter stops for hourly to prevent catastrophic 70-90% losses
if entry_atr > 0.0 { if entry_atr > 0.0 {
let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr; let stop_multiplier = if self.timeframe == Timeframe::Hourly {
HOURLY_ATR_STOP_MULTIPLIER
} else {
ATR_STOP_MULTIPLIER
};
let atr_stop_price = entry_price - stop_multiplier * entry_atr;
if current_price <= atr_stop_price { if current_price <= atr_stop_price {
return Some(Signal::StrongSell); return Some(Signal::StrongSell);
} }
@@ -116,10 +125,15 @@ impl Strategy {
} }
// 4. ATR-based trailing stop (profit protection) // 4. ATR-based trailing stop (profit protection)
// Activates earlier than before (1.5x ATR gain) so profits are locked in. // Hourly uses much tighter trail to lock in gains quickly
// Distance is wider (2.5x ATR from HWM) so normal retracements don't trigger it. let (trail_activation_mult, trail_mult) = if self.timeframe == Timeframe::Hourly {
(HOURLY_ATR_TRAIL_ACTIVATION_MULTIPLIER, HOURLY_ATR_TRAIL_MULTIPLIER)
} else {
(ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER)
};
let activation_gain = if entry_atr > 0.0 { let activation_gain = if entry_atr > 0.0 {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price (trail_activation_mult * entry_atr) / entry_price
} else { } else {
TRAILING_STOP_ACTIVATION TRAILING_STOP_ACTIVATION
}; };
@@ -127,7 +141,7 @@ impl Strategy {
if pnl_pct >= activation_gain { if pnl_pct >= activation_gain {
if let Some(&high_water) = self.high_water_marks.get(symbol) { if let Some(&high_water) = self.high_water_marks.get(symbol) {
let trail_distance = if entry_atr > 0.0 { let trail_distance = if entry_atr > 0.0 {
ATR_TRAIL_MULTIPLIER * entry_atr trail_mult * entry_atr
} else { } else {
high_water * TRAILING_STOP_DISTANCE high_water * TRAILING_STOP_DISTANCE
}; };