just a checkpoint

This commit is contained in:
zastian-dev
2026-02-13 16:28:42 +00:00
parent 798c3eafd5
commit 73cc7a3a66
9 changed files with 958 additions and 317 deletions

View File

@@ -7,16 +7,23 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
use crate::config::{
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, DRAWDOWN_HALT_BARS, HOURS_PER_DAY,
MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT, MAX_LOSS_PCT, MAX_POSITION_SIZE,
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,
EQUITY_CURVE_SMA_PERIOD,
REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG,
REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP,
};
use crate::indicators::{calculate_all_indicators, generate_signal};
use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal};
use crate::strategy::Strategy;
use crate::types::{
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade, TradeSignal,
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, MarketRegime, Signal, Trade, TradeSignal,
};
/// Backtesting engine for the trading strategy.
@@ -30,6 +37,12 @@ pub struct Backtester {
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
@@ -57,6 +70,9 @@ impl Backtester {
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,
@@ -87,12 +103,15 @@ impl Backtester {
self.cash + positions_value
}
/// Update drawdown circuit breaker state.
/// Uses time-based halt: pause for DRAWDOWN_HALT_BARS after trigger, then auto-resume.
/// On resume, the peak is reset to the current portfolio value to prevent cascading
/// re-triggers from the same drawdown event. Without this reset, a partial recovery
/// followed by a minor dip re-triggers the halt, causing the bot to spend excessive
/// time in cash (observed: 7+ triggers in a 3-year backtest = ~140 bars lost).
/// 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.
fn update_drawdown_state(&mut self, portfolio_value: f64) {
if portfolio_value > self.peak_portfolio_value {
self.peak_portfolio_value = portfolio_value;
@@ -100,36 +119,91 @@ impl Backtester {
let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value;
// Trigger halt if drawdown exceeds threshold
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt {
// Trigger halt at the lowest tier that matches (if not already halted)
if !self.drawdown_halt && drawdown_pct >= DRAWDOWN_TIER1_PCT {
// Determine severity tier
let (halt_bars, tier_name) = if drawdown_pct >= DRAWDOWN_TIER3_PCT {
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
(DRAWDOWN_TIER3_BARS, "TIER 3 (SEVERE)")
} else if drawdown_pct >= DRAWDOWN_TIER2_PCT {
(DRAWDOWN_TIER2_BARS, "TIER 2")
} else {
(DRAWDOWN_TIER1_BARS, "TIER 1")
};
tracing::warn!(
"DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} bars.",
"DRAWDOWN CIRCUIT BREAKER {}: {:.2}% drawdown. Halting for {} bars.{}",
tier_name,
drawdown_pct * 100.0,
MAX_DRAWDOWN_HALT * 100.0,
DRAWDOWN_HALT_BARS
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 >= DRAWDOWN_TIER3_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER3_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 >= DRAWDOWN_TIER2_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER2_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 {
if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS {
let required_bars = if self.drawdown_halt_severity >= DRAWDOWN_TIER3_PCT {
DRAWDOWN_TIER3_BARS
} else if self.drawdown_halt_severity >= DRAWDOWN_TIER2_PCT {
DRAWDOWN_TIER2_BARS
} else {
DRAWDOWN_TIER1_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. Resuming trading. \
"Drawdown halt expired after {} bars (regime: {}). \
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
DRAWDOWN_HALT_BARS,
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;
// Reset peak to current value to prevent cascading re-triggers.
// The previous peak is no longer relevant after a halt — measuring
// drawdown from it would immediately re-trigger on any minor dip.
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
);
}
}
}
}
@@ -140,6 +214,9 @@ impl Backtester {
/// 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,
@@ -147,19 +224,20 @@ impl Backtester {
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.
// This prevents positions that might need a same-day stop-loss exit.
// Market hours are roughly 9:30-16:00 ET; avoid entries after 14:00 ET.
if self.timeframe == Timeframe::Hourly {
let hour = timestamp.hour();
// IEX timestamps are in UTC; ET = UTC-5 in winter, UTC-4 in summer.
// 14:00 ET = 19:00 UTC (winter) or 18:00 UTC (summer).
// Conservative: block entries after 19:00 UTC (covers both).
if hour >= 19 {
return false;
}
@@ -168,7 +246,7 @@ impl Backtester {
// 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; // Still in cooldown period
return false;
}
}
@@ -177,6 +255,11 @@ impl Backtester {
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;
}
@@ -196,9 +279,15 @@ impl Backtester {
}
let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE);
let shares =
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;
}
@@ -214,7 +303,7 @@ impl Backtester {
symbol.to_string(),
BacktestPosition {
symbol: symbol.to_string(),
shares: shares,
shares,
entry_price: fill_price,
entry_time: timestamp,
entry_atr: signal.atr,
@@ -229,7 +318,7 @@ impl Backtester {
self.trades.push(Trade {
symbol: symbol.to_string(),
side: "BUY".to_string(),
shares: shares,
shares,
price: fill_price,
timestamp,
pnl: 0.0,
@@ -444,6 +533,32 @@ impl Backtester {
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 {
@@ -508,6 +623,18 @@ impl Backtester {
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;
@@ -532,6 +659,54 @@ impl Backtester {
let portfolio_value = self.get_portfolio_value(&current_prices);
// ── SPY Market Regime Detection ─────────────────────────
// Determine if we're in bull/caution/bear based on SPY EMAs.
// This gates all new long entries and adjusts position sizing.
let regime = if has_spy_data {
if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = (
spy_raw_date_index.get(current_date),
data.get(&spy_key)
.and_then(|rows| {
symbol_date_index
.get(&spy_key)
.and_then(|m| m.get(current_date))
.map(|&i| &rows[i])
}),
) {
let ema50 = if spy_raw_idx < spy_ema50_series.len() {
spy_ema50_series[spy_raw_idx]
} else {
f64::NAN
};
let ema200 = if spy_raw_idx < spy_ema200_series.len() {
spy_ema200_series[spy_raw_idx]
} else {
f64::NAN
};
determine_market_regime(spy_indicator_row, ema50, ema200)
} else {
MarketRegime::Caution // No SPY data for this bar
}
} else {
MarketRegime::Bull // No SPY data at all, don't penalize
};
self.current_regime = regime;
// Regime-based sizing factor and threshold adjustment
let regime_size_factor = match regime {
MarketRegime::Bull => 1.0,
MarketRegime::Caution => 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);
@@ -597,47 +772,75 @@ impl Backtester {
}
// Phase 2: Process buys (only for top momentum stocks)
for symbol in &ranked_symbols {
let rows = match data.get(symbol) {
Some(r) => r,
None => continue,
// 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
let buy_threshold_bump = match regime {
MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP,
_ => 0.0,
};
// Only buy top momentum stocks
if !top_momentum_symbols.contains(symbol) {
continue;
}
for symbol in &ranked_symbols {
// Don't buy SPY itself — it's used as the regime benchmark
if symbol == REGIME_SPY_SYMBOL {
continue;
}
let idx = match symbol_date_index
.get(symbol)
.and_then(|m| m.get(current_date))
{
Some(&i) => i,
None => continue,
};
let rows = match data.get(symbol) {
Some(r) => r,
None => continue,
};
if idx < 1 {
continue;
}
// Only buy top momentum stocks
if !top_momentum_symbols.contains(symbol) {
continue;
}
let current_row = &rows[idx];
let previous_row = &rows[idx - 1];
let idx = match symbol_date_index
.get(symbol)
.and_then(|m| m.get(current_date))
{
Some(&i) => i,
None => continue,
};
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
continue;
}
if idx < 1 {
continue;
}
let signal = generate_signal(symbol, current_row, previous_row);
let current_row = &rows[idx];
let previous_row = &rows[idx - 1];
// Execute buys
if signal.signal.is_buy() {
self.execute_buy(
symbol,
signal.current_price,
*current_date,
portfolio_value,
&signal,
);
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 {
// Re-evaluate: the signal score is buy_score - sell_score.
// We need to check if the score exceeds the bumped threshold.
// Since we don't have the raw score, use confidence as a proxy:
// confidence = score.abs() / 12.0, so score = confidence * 12.0
// A Buy signal means score >= 4.0 (our threshold).
// In Caution, we require score >= 4.0 + bump (6.0 → StrongBuy territory).
let approx_score = signal.confidence * 12.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,
);
}
}
}
@@ -899,9 +1102,21 @@ impl Backtester {
MAX_SECTOR_POSITIONS
);
println!(
" Drawdown Halt: {:>14.0}% ({} bar cooldown)",
MAX_DRAWDOWN_HALT * 100.0,
DRAWDOWN_HALT_BARS
" Drawdown Halt: {:>13.0}%/{:.0}%/{:.0}% ({}/{}/{} bars)",
DRAWDOWN_TIER1_PCT * 100.0,
DRAWDOWN_TIER2_PCT * 100.0,
DRAWDOWN_TIER3_PCT * 100.0,
DRAWDOWN_TIER1_BARS,
DRAWDOWN_TIER2_BARS,
DRAWDOWN_TIER3_BARS,
);
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",