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

@@ -1,59 +1,63 @@
# Quant-Rust-Strategist Memory
## Architecture Overview
- ~100-symbol universe across 14 sectors (expanded from original 50)
- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()`
- ~100-symbol universe across 14 sectors
- strategy.rs: shared logic between bot.rs and backtester.rs
- Backtester restricts buys to top momentum stocks (TOP_MOMENTUM_COUNT)
- SPY regime filter (EMA-50/200) gates new longs: Bull/Caution/Bear
- Signal thresholds: StrongBuy>=7.0, Buy>=4.0, Sell<=-4.0, StrongSell<=-7.0
## Bugs Fixed (2026-02-13)
### 1. calculate_results used self.cash instead of equity curve final value
- backtester.rs line ~686: `let final_value = self.cash` missed open positions
- Fixed: use `self.equity_history.last().portfolio_value`
## Signal Generation (2026-02-13 REWRITE)
- **OLD**: Additive "indicator soup" -- 8 indicators netted, PF 0.91, no edge
- **NEW**: Hierarchical momentum-with-trend filter:
- Gate 1: trend_bullish AND ema_bullish -- MUST pass for any buy
- Gate 2: positive momentum (ROC > 0) -- time-series momentum
- Timing: RSI-14 pullback (30-50) in confirmed uptrends
- Conviction: ADX direction, MACD histogram, volume
- Sell: trend break (price < EMA-trend) is primary exit signal
- Key insight: hierarchical gating >> additive scoring
### 2. Drawdown circuit breaker cascading re-triggers
- peak_portfolio_value was never reset after halt, causing immediate re-trigger
- 7+ triggers in 3yr = ~140 bars (19% of backtest) sitting in cash
- Fixed: reset peak to current value on halt resume
## Stop/Exit Logic (2026-02-13 FIX)
- Time exit ONLY sells losers (pnl_pct < 0). Old code force-sold winners.
- Trail activation: 1.5x ATR (was 2.0x), trail distance: 2.5x ATR (was 2.0x)
- Max loss: 8% (was 5%), TIME_EXIT_BARS: 60 (was 40)
### 3. PDT blocking sells in backtester (disabled)
- PDT sell-blocking removed from backtester; it measures strategy alpha not compliance
- Late-day entry prevention in execute_buy remains for hourly PDT defense
- would_be_day_trade was called AFTER position removal = always false (logic bug)
## Equity Curve SMA Stop: REMOVED from backtester
- Created pathological feedback loop with drawdown breaker
## PDT Implementation (2026-02-12)
- Tracks day trades in rolling 5-business-day window, max 3 allowed
- CRITICAL: Stop-loss exits must NEVER be blocked by PDT (risk mgmt > compliance)
- Late-day entry prevention: On hourly, block buys after 19:00 UTC (~last 2 hours)
- PDT blocking DISABLED in backtester (kept in bot.rs for live trading)
## Position Sizing (2026-02-13 FIX)
- Confidence scaling: 0.4 + 0.6*conf (was 0.7 + 0.3*conf)
- RISK_PER_TRADE: 1.0%, MAX_POSITIONS: 10, TOP_MOMENTUM: 10
## Current Parameters (config.rs, updated 2026-02-13)
- ATR Stop: 3.0x | Trail: 2.0x distance, 2.0x activation
- Risk: 1.2%/trade, max 25% position, 5% cash reserve, 5% max loss
- Max 7 positions, 2/sector | Drawdown halt: 15% (10 bars) | Time exit: 40
- ATR Stop: 3.0x | Trail: 2.5x distance, 1.5x activation
- Risk: 1.0%/trade, max 25% position, 5% cash reserve, 8% max loss
- Max 10 positions, 2/sector | Time exit: 60 bars (losers only)
- Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps
- Buy threshold: 4.0 (lowered from 4.5) | Momentum pool: top 20 (widened from 10)
- Daily: momentum=63, ema_trend=50 | Hourly: momentum=63, ema_trend=200
- ADX: range<20, trend>25, strong>40
- Momentum pool: top 10 (decile)
## Hourly Timeframe: DO NOT CHANGE FROM BASELINE
- Hourly IndicatorParams: momentum=63, ema_trend=200 (long lookbacks filter IEX noise)
- Shorter periods (momentum=21, ema_trend=50): CATASTROPHIC -8% loss
## Bugs Fixed (2026-02-13)
1. calculate_results used self.cash instead of equity curve final value
2. Drawdown circuit breaker cascading re-triggers (peak not reset)
3. PDT blocking sells in backtester (disabled)
## Failed Experiments (avoid repeating)
1. Tighter ATR stop (<3.0x): too many stop-outs on hourly
2. Lower buy threshold (3.5): too many weak entries (but 4.0 is fine)
3. Blocking stop-loss exits for PDT: traps capital in losers, dangerous
2. Lower buy threshold (3.5): too many weak entries (4.0 is fine)
3. Blocking stop-loss exits for PDT: traps capital in losers
4. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8
5. Shorter hourly lookbacks: catastrophic losses
6. Drawdown halt 12% with non-resetting peak: cascading re-triggers in multi-year tests
6. Drawdown halt 12% with non-resetting peak: cascading re-triggers
7. Additive indicator soup: fundamentally has no edge (PF < 1.0)
8. Time exit that dumps winners: destroys win/loss asymmetry
9. Equity curve SMA stop: correlated with drawdown breaker, blocks recovery
## Hourly Timeframe: DO NOT CHANGE FROM BASELINE
- momentum=63, ema_trend=200 (long lookbacks filter IEX noise)
## IEX Data Stochasticity
- Backtests have significant run-to-run variation from IEX data timing
- Do NOT panic about minor performance swings between runs
- Always run 2-3 times and compare ranges before concluding a change helped/hurt
- Run 2-3 times and compare ranges before concluding a change helped/hurt
## Build Notes
- `cargo build --release` compiles clean (only dead_code warnings for types.rs fields)
- `cargo build --release` compiles clean (only dead_code warnings)
- No tests exist

View File

@@ -463,8 +463,41 @@ impl AlpacaClient {
}
}
/// Get the cache file path for a given symbol and timeframe.
fn cache_path(symbol: &str, timeframe: Timeframe) -> std::path::PathBuf {
let tf_dir = match timeframe {
Timeframe::Daily => "daily",
Timeframe::Hourly => "hourly",
};
let mut path = crate::paths::BACKTEST_CACHE_DIR.clone();
path.push(tf_dir);
std::fs::create_dir_all(&path).ok();
path.push(format!("{}.json", symbol));
path
}
/// Load cached bars for a symbol. Returns empty vec on any error.
fn load_cached_bars(symbol: &str, timeframe: Timeframe) -> Vec<Bar> {
let path = cache_path(symbol, timeframe);
match std::fs::read_to_string(&path) {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Save bars to cache for a symbol.
fn save_cached_bars(symbol: &str, timeframe: Timeframe, bars: &[Bar]) {
let path = cache_path(symbol, timeframe);
if let Ok(json) = serde_json::to_string(bars) {
if let Err(e) = std::fs::write(&path, json) {
tracing::warn!("Failed to write cache for {}: {}", symbol, e);
}
}
}
/// Helper to fetch bars for backtesting with proper date handling.
/// Fetches each symbol individually to avoid API limits on multi-symbol requests.
/// Uses a disk cache to avoid re-fetching bars that were already downloaded.
pub async fn fetch_backtest_data(
client: &AlpacaClient,
symbols: &[&str],
@@ -476,6 +509,9 @@ pub async fn fetch_backtest_data(
let days = (years * 365.0) as i64 + warmup_days + 30;
let start = end - Duration::days(days);
// Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars
let refetch_overlap = Duration::days(2);
tracing::info!(
"Fetching {:.2} years of data ({} to {})...",
years,
@@ -484,11 +520,16 @@ pub async fn fetch_backtest_data(
);
let mut all_data = HashMap::new();
let mut cache_hits = 0u32;
let mut cache_misses = 0u32;
// Fetch each symbol individually like Python does
// The multi-symbol endpoint has a 10000 bar limit across ALL symbols
for symbol in symbols {
tracing::info!(" Fetching {}...", symbol);
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, end)
@@ -496,7 +537,8 @@ pub async fn fetch_backtest_data(
{
Ok(bars) => {
if !bars.is_empty() {
tracing::info!(" {}: {} bars loaded", symbol, bars.len());
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);
@@ -506,7 +548,87 @@ pub async fn fetch_backtest_data(
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 < 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.format("%Y-%m-%d"),
fetch_older_end.format("%Y-%m-%d")
);
match client
.get_historical_bars(symbol, timeframe, start, fetch_older_end)
.await
{
Ok(old_bars) => {
tracing::info!(" {}: {} older bars fetched", symbol, old_bars.len());
merged.extend(old_bars);
}
Err(e) => {
tracing::warn!(" {}: older fetch failed: {}", symbol, e);
}
}
}
// Fetch newer data from last cached - overlap
if need_newer {
let fetch_from = last_cached_ts - refetch_overlap;
tracing::info!(
" {} ({} cached, fetching newer from {})...",
symbol,
merged.len(),
fetch_from.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

@@ -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,7 +772,20 @@ impl Backtester {
}
// 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
let buy_threshold_bump = match regime {
MarketRegime::Caution => 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,
@@ -629,17 +817,32 @@ impl Backtester {
let signal = generate_signal(symbol, current_row, previous_row);
// Execute buys
if signal.signal.is_buy() {
// 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,
);
}
}
}
// Record equity
self.equity_history.push(EquityPoint {
@@ -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",

View File

@@ -10,17 +10,24 @@ use crate::alpaca::AlpacaClient;
use crate::config::{
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, BOT_CHECK_INTERVAL_SECONDS,
DRAWDOWN_HALT_BARS, HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT,
HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS,
MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
REENTRY_COOLDOWN_BARS, TOP_MOMENTUM_COUNT,
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::paths::{
LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE,
LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE,
};
use crate::strategy::Strategy;
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
use crate::types::{EquitySnapshot, MarketRegime, PositionInfo, Signal, TradeSignal};
/// Per-position metadata persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -46,6 +53,12 @@ pub struct TradingBot {
drawdown_halt: bool,
/// Cycle count 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,
/// Whether the drawdown halt requires bull regime to resume (Tier 3)
drawdown_requires_bull: bool,
/// Current market regime (from SPY analysis)
current_regime: MarketRegime,
/// Current trading cycle count
trading_cycle_count: usize,
/// Tracks when each symbol can be re-entered after stop-loss (cycle index)
@@ -76,6 +89,9 @@ impl TradingBot {
peak_portfolio_value: 0.0,
drawdown_halt: false,
drawdown_halt_start: None,
drawdown_halt_severity: 0.0,
drawdown_requires_bull: false,
current_regime: MarketRegime::Bull,
trading_cycle_count: 0,
cooldown_timers: HashMap::new(),
new_positions_this_cycle: 0,
@@ -349,34 +365,87 @@ impl TradingBot {
0.0
};
// Trigger halt if drawdown exceeds threshold
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt {
// Scaled drawdown circuit breaker (Tier 1/2/3)
if !self.drawdown_halt && drawdown_pct >= DRAWDOWN_TIER1_PCT {
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 {} cycles.",
"DRAWDOWN CIRCUIT BREAKER {}: {:.2}% drawdown. Halting for {} cycles.{}",
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.trading_cycle_count);
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.trading_cycle_count);
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.trading_cycle_count);
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.trading_cycle_count >= 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.trading_cycle_count >= 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 {} cycles. Resuming trading. \
"Drawdown halt expired after {} cycles (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.
self.drawdown_halt_severity = 0.0;
self.drawdown_requires_bull = false;
self.peak_portfolio_value = portfolio_value;
} else if time_served && !regime_ok {
tracing::info!(
"Drawdown halt: time served but waiting for BULL regime (currently {}). DD: {:.2}%",
self.current_regime.as_str(),
drawdown_pct * 100.0
);
}
}
}
@@ -499,7 +568,7 @@ impl TradingBot {
// ── Order execution ──────────────────────────────────────────────
async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal, regime_size_factor: f64) -> bool {
// Check if already holding
if let Some(qty) = self.get_position(symbol).await {
if qty > 0.0 {
@@ -556,7 +625,10 @@ impl TradingBot {
return false;
}
let shares = self.calculate_position_size(signal).await;
let mut shares = self.calculate_position_size(signal).await;
// Apply regime-based size adjustment (e.g., 50% in Caution)
shares *= regime_size_factor;
shares = (shares * 10000.0).floor() / 10000.0;
if shares <= 0.0 {
tracing::info!("{}: Insufficient funds for purchase", symbol);
return false;
@@ -692,6 +764,66 @@ impl TradingBot {
// Partial exits removed: they systematically halve winning trade size
// while losing trades remain at full size, creating unfavorable avg win/loss ratio.
// ── Market regime detection ────────────────────────────────────
/// Fetch SPY data and determine the current market regime.
async fn detect_market_regime(&self) -> MarketRegime {
let days = (REGIME_EMA_LONG as f64 * 1.5) as i64 + 30;
let end = Utc::now();
let start = end - Duration::days(days);
let bars = match self
.client
.get_historical_bars(REGIME_SPY_SYMBOL, self.timeframe, start, end)
.await
{
Ok(b) => b,
Err(e) => {
tracing::warn!("Failed to fetch SPY data for regime detection: {}. Defaulting to Caution.", e);
return MarketRegime::Caution;
}
};
if bars.len() < REGIME_EMA_LONG {
tracing::warn!("Insufficient SPY bars ({}) for regime detection. Defaulting to Caution.", bars.len());
return MarketRegime::Caution;
}
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
let ema50_series = calculate_ema(&closes, REGIME_EMA_SHORT);
let ema200_series = calculate_ema(&closes, REGIME_EMA_LONG);
let spy_indicators = calculate_all_indicators(&bars, &self.strategy.params);
if spy_indicators.is_empty() {
return MarketRegime::Caution;
}
let last_row = &spy_indicators[spy_indicators.len() - 1];
let ema50 = *ema50_series.last().unwrap_or(&f64::NAN);
let ema200 = *ema200_series.last().unwrap_or(&f64::NAN);
determine_market_regime(last_row, ema50, ema200)
}
/// Check if the equity curve is below its N-snapshot SMA (trailing equity stop).
fn equity_below_sma(&self) -> bool {
if self.equity_history.len() < EQUITY_CURVE_SMA_PERIOD {
return false;
}
// Don't block when holding zero positions — otherwise flat equity
// stays below the SMA forever and the bot can never recover.
if self.strategy.entry_prices.is_empty() {
return false;
}
let recent = &self.equity_history[self.equity_history.len() - EQUITY_CURVE_SMA_PERIOD..];
let sma: f64 = recent.iter().map(|e| e.portfolio_value).sum::<f64>()
/ EQUITY_CURVE_SMA_PERIOD as f64;
self.current_portfolio_value < sma
}
// ── Analysis ─────────────────────────────────────────────────────
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
@@ -764,6 +896,16 @@ impl TradingBot {
);
}
// Detect market regime from SPY
self.current_regime = self.detect_market_regime().await;
tracing::info!("Market regime: {} (SPY EMA-{}/EMA-{})",
self.current_regime.as_str(), REGIME_EMA_SHORT, REGIME_EMA_LONG);
if self.equity_below_sma() {
tracing::info!("Equity curve below {}-period SMA — no new entries this cycle",
EQUITY_CURVE_SMA_PERIOD);
}
// Increment bars_held once per trading cycle (matches backtester's per-bar increment)
for meta in self.position_meta.values_mut() {
meta.bars_held += 1;
@@ -843,13 +985,52 @@ impl TradingBot {
);
// Phase 3: Process buys in momentum-ranked order (highest momentum first)
// Gate by market regime and equity curve stop
if !self.current_regime.allows_new_longs() {
tracing::info!("BEAR regime — skipping all buys this cycle");
} else if self.equity_below_sma() {
tracing::info!("Equity below SMA — skipping all buys this cycle");
} else {
let regime_size_factor = match self.current_regime {
MarketRegime::Bull => 1.0,
MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR,
MarketRegime::Bear => 0.0, // unreachable due to allows_new_longs check
};
let buy_threshold_bump = match self.current_regime {
MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP,
_ => 0.0,
};
if regime_size_factor < 1.0 {
tracing::info!(
"CAUTION regime — position sizing at {:.0}%, buy threshold +{:.1}",
regime_size_factor * 100.0,
buy_threshold_bump,
);
}
for signal in &ranked_signals {
if !top_momentum_symbols.contains(&signal.symbol) {
continue;
}
if signal.signal.is_buy() {
self.execute_buy(&signal.symbol, signal).await;
// Skip SPY itself — it's the regime benchmark
if signal.symbol == REGIME_SPY_SYMBOL {
continue;
}
// Apply regime threshold bump in Caution
let effective_buy = if buy_threshold_bump > 0.0 {
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(&signal.symbol, signal, regime_size_factor).await;
}
}
}

View File

@@ -14,7 +14,7 @@ pub const SP500_ENERGY: &[&str] = &["XOM", "CVX", "COP", "SLB", "OXY", "EOG", "M
pub const TELECOM_MEDIA: &[&str] = &["T", "VZ", "CMCSA", "TMUS", "NFLX"];
pub const INTERNATIONAL: &[&str] = &["TSM", "BABA", "JD", "SHOP", "MELI"];
pub const MATERIALS: &[&str] = &["FCX", "NEM", "LIN", "APD", "SHW"];
/// Get all symbols in the trading universe (~100 stocks).
/// Get all symbols in the trading universe (~100 stocks + SPY for regime).
pub fn get_all_symbols() -> Vec<&'static str> {
let mut symbols = Vec::new();
symbols.extend_from_slice(MAG7);
@@ -31,6 +31,8 @@ pub fn get_all_symbols() -> Vec<&'static str> {
symbols.extend_from_slice(TELECOM_MEDIA);
symbols.extend_from_slice(INTERNATIONAL);
symbols.extend_from_slice(MATERIALS);
// SPY is included for market regime detection (never traded directly)
symbols.push(REGIME_SPY_SYMBOL);
// Deduplicate (NFLX appears in both GROWTH_TECH and TELECOM_MEDIA)
symbols.sort();
symbols.dedup();
@@ -40,10 +42,6 @@ pub fn get_all_symbols() -> Vec<&'static str> {
// RSI-14 for trend assessment, RSI-2 for mean-reversion entries (Connors)
pub const RSI_PERIOD: usize = 14;
pub const RSI_SHORT_PERIOD: usize = 2; // Connors RSI-2 for mean reversion
pub const RSI_OVERSOLD: f64 = 30.0;
pub const RSI_OVERBOUGHT: f64 = 70.0;
pub const RSI2_OVERSOLD: f64 = 10.0; // Extreme oversold for mean reversion entries
pub const RSI2_OVERBOUGHT: f64 = 90.0; // Extreme overbought for mean reversion exits
pub const MACD_FAST: usize = 12;
pub const MACD_SLOW: usize = 26;
pub const MACD_SIGNAL: usize = 9;
@@ -56,9 +54,7 @@ pub const EMA_TREND: usize = 50;
// ADX > TREND_THRESHOLD = trending (use momentum/pullback)
// Between = transition zone (reduce size, be cautious)
pub const ADX_PERIOD: usize = 14;
pub const ADX_RANGE_THRESHOLD: f64 = 20.0; // Below this = range-bound
pub const ADX_TREND_THRESHOLD: f64 = 25.0; // Above this = trending
pub const ADX_STRONG: f64 = 40.0; // Strong trend for bonus conviction
// Bollinger Bands
pub const BB_PERIOD: usize = 20;
pub const BB_STD: f64 = 2.0;
@@ -69,28 +65,73 @@ pub const MIN_ATR_PCT: f64 = 0.005;
pub const VOLUME_MA_PERIOD: usize = 20;
pub const VOLUME_THRESHOLD: f64 = 0.8;
// Momentum Ranking
pub const TOP_MOMENTUM_COUNT: usize = 20; // ~20% of universe for cross-sectional momentum
pub const TOP_MOMENTUM_COUNT: usize = 10; // Top decile: Jegadeesh-Titman (1993) strongest effect
// Risk Management
pub const MAX_POSITION_SIZE: f64 = 0.25; // Slightly larger for concentrated bets
pub const MIN_CASH_RESERVE: f64 = 0.05;
pub const STOP_LOSS_PCT: f64 = 0.025;
pub const MAX_LOSS_PCT: f64 = 0.05; // Wider max loss — let mean reversion work
pub const TRAILING_STOP_ACTIVATION: f64 = 0.06;
pub const TRAILING_STOP_DISTANCE: f64 = 0.04;
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_DISTANCE: f64 = 0.05; // Wider trail to let winners run
// ATR-based risk management
pub const RISK_PER_TRADE: f64 = 0.012; // More aggressive sizing for higher conviction
pub const RISK_PER_TRADE: f64 = 0.01; // Conservative per-trade risk, compensated by more positions
pub const ATR_STOP_MULTIPLIER: f64 = 3.0; // Wider stops — research shows tighter stops hurt
pub const ATR_TRAIL_MULTIPLIER: f64 = 2.0; // Wider trail to let winners run
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Activate after 2x ATR gain
pub const ATR_TRAIL_MULTIPLIER: f64 = 2.5; // Wide trail from HWM so winners have room to breathe
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 1.5; // Activate earlier (1.5x ATR gain) to protect profits
// Portfolio-level controls
pub const MAX_CONCURRENT_POSITIONS: usize = 7; // More positions for diversification
pub const MAX_CONCURRENT_POSITIONS: usize = 10; // More diversification reduces idiosyncratic risk
pub const MAX_SECTOR_POSITIONS: usize = 2;
pub const MAX_DRAWDOWN_HALT: f64 = 0.15; // 15% drawdown trigger (markets routinely correct 10-15%)
pub const DRAWDOWN_HALT_BARS: usize = 10; // Shorter cooldown: 10 bars to resume after halt
// Old single-tier drawdown constants (replaced by tiered system below)
// pub const MAX_DRAWDOWN_HALT: f64 = 0.15;
// pub const DRAWDOWN_HALT_BARS: usize = 10;
// Time-based exit
pub const TIME_EXIT_BARS: usize = 40; // Longer patience for mean reversion
pub const TIME_EXIT_BARS: usize = 60; // Patient — now only exits losers, winners use trailing stop
pub const REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown
pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up
// ═══════════════════════════════════════════════════════════════════════
// Market Regime Filter (SPY-based)
// ═══════════════════════════════════════════════════════════════════════
// Uses SPY as a broad market proxy to detect bull/caution/bear regimes.
// Based on the dual moving average framework (Faber 2007, "A Quantitative
// Approach to Tactical Asset Allocation"): price vs 200-day SMA is the
// single most effective regime filter in academic literature.
//
// Bull: SPY > EMA-200 AND EMA-50 > EMA-200 → trade normally
// Caution: SPY < EMA-50 but SPY > EMA-200 → reduce size, raise thresholds
// Bear: SPY < EMA-200 AND EMA-50 < EMA-200 → no new buys, manage exits only
pub const REGIME_SPY_SYMBOL: &str = "SPY";
pub const REGIME_EMA_SHORT: usize = 50; // Fast regime EMA
pub const REGIME_EMA_LONG: usize = 200; // Slow regime EMA (the "golden cross" line)
/// In Caution regime, multiply position size by this factor (50% reduction).
pub const REGIME_CAUTION_SIZE_FACTOR: f64 = 0.5;
/// In Caution regime, add this to buy thresholds (require stronger signals).
pub const REGIME_CAUTION_THRESHOLD_BUMP: f64 = 2.0;
// ═══════════════════════════════════════════════════════════════════════
// Scaled Drawdown Circuit Breaker
// ═══════════════════════════════════════════════════════════════════════
// The old fixed 10-bar cooldown is inadequate for real bear markets.
// Scale the halt duration with severity so that deeper drawdowns force
// longer cooling periods. At 25%+ DD, also require bull regime to resume.
pub const DRAWDOWN_TIER1_PCT: f64 = 0.15; // 15% → 10 bars
pub const DRAWDOWN_TIER1_BARS: usize = 10;
pub const DRAWDOWN_TIER2_PCT: f64 = 0.20; // 20% → 30 bars
pub const DRAWDOWN_TIER2_BARS: usize = 30;
pub const DRAWDOWN_TIER3_PCT: f64 = 0.25; // 25%+ → 50 bars + require bull regime
pub const DRAWDOWN_TIER3_BARS: usize = 50;
/// If true, after a Tier 3 drawdown (>=25%), require bull market regime
/// before resuming new entries even after the bar cooldown expires.
pub const DRAWDOWN_TIER3_REQUIRE_BULL: bool = true;
// ═══════════════════════════════════════════════════════════════════════
// Trailing Equity Curve Stop
// ═══════════════════════════════════════════════════════════════════════
// If the portfolio equity drops below its own N-bar moving average, stop
// all new entries. This is a secondary defense independent of the drawdown
// breaker. Uses a 200-bar SMA of the equity curve (roughly 200 trading
// days for daily, ~29 trading days for hourly).
pub const EQUITY_CURVE_SMA_PERIOD: usize = 50; // Shorter window so bot can recover
// Backtester slippage
pub const SLIPPAGE_BPS: f64 = 10.0;
// Trading intervals

View File

@@ -1,8 +1,7 @@
//! Technical indicator calculations.
use crate::config::{
IndicatorParams, ADX_RANGE_THRESHOLD, ADX_STRONG, ADX_TREND_THRESHOLD, BB_STD,
RSI2_OVERBOUGHT, RSI2_OVERSOLD, RSI_OVERBOUGHT, RSI_OVERSOLD, VOLUME_THRESHOLD,
IndicatorParams, ADX_TREND_THRESHOLD, BB_STD, VOLUME_THRESHOLD,
};
use crate::types::{Bar, IndicatorRow, Signal, TradeSignal};
@@ -423,25 +422,78 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
rows
}
/// Generate trading signal using regime-adaptive dual strategy.
/// Determine the broad market regime from SPY indicator data.
///
/// REGIME DETECTION (via ADX):
/// - ADX < 20: Range-bound → use Connors RSI-2 mean reversion
/// - ADX > 25: Trending → use momentum pullback entries
/// - 20-25: Transition → require extra confirmation
/// This is the single most important risk filter in the system. During the
/// 2020 COVID crash (SPY fell ~34% in 23 trading days) and the 2022 bear
/// market (SPY fell ~25% over 9 months), SPY spent the majority of those
/// periods below its 200-day EMA with EMA-50 < EMA-200. This filter would
/// have prevented most long entries during those drawdowns.
///
/// MEAN REVERSION (ranging markets):
/// - Buy when RSI-2 < 10 AND price above 200 EMA (long-term uptrend filter)
/// - Sell when RSI-2 > 90 (take profit at mean)
/// - Bollinger Band extremes add conviction
/// The three regimes map to position-sizing multipliers:
/// - Bull (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
/// - Caution (SPY < EMA-50, SPY > EMA-200): half size, raised thresholds
/// - Bear (SPY < EMA-200, EMA-50 < EMA-200): no new longs
pub fn determine_market_regime(spy_row: &IndicatorRow, spy_ema50: f64, spy_ema200: f64) -> crate::types::MarketRegime {
use crate::types::MarketRegime;
let price = spy_row.close;
// All three EMAs must be valid
if spy_ema50.is_nan() || spy_ema200.is_nan() || price <= 0.0 {
// Default to Caution when we lack data (conservative)
return MarketRegime::Caution;
}
// Bear: price below 200 EMA AND 50 EMA below 200 EMA (death cross)
if price < spy_ema200 && spy_ema50 < spy_ema200 {
return MarketRegime::Bear;
}
// Caution: price below 50 EMA (short-term weakness) but still above 200
if price < spy_ema50 {
return MarketRegime::Caution;
}
// Bull: price above both, 50 above 200 (golden cross)
if spy_ema50 > spy_ema200 {
return MarketRegime::Bull;
}
// Edge case: price above both EMAs but 50 still below 200 (recovery)
// Treat as Caution — the golden cross hasn't confirmed yet
MarketRegime::Caution
}
/// Generate trading signal using hierarchical momentum-with-trend strategy.
///
/// TREND FOLLOWING (trending markets):
/// - Buy pullbacks in uptrends: RSI-14 dips + EMA support + MACD confirming
/// - Sell when trend breaks: EMA crossover down + momentum loss
/// - Strong trend bonus for high ADX
/// This replaces the previous additive "indicator soup" approach. The academic
/// evidence for momentum is robust (Jegadeesh & Titman 1993, Moskowitz et al.
/// 2012, Asness et al. 2013 "Value and Momentum Everywhere"). Rather than
/// netting 8 indicators against each other, we use a hierarchical filter:
///
/// LAYER 1 (GATE): Trend confirmation
/// - Price must be above EMA-trend (Faber 2007 trend filter)
/// - EMA-short must be above EMA-long (trend alignment)
/// Without both, no buy signal is generated.
///
/// LAYER 2 (ENTRY): Momentum + pullback timing
/// - Positive momentum (ROC > 0): time-series momentum filter
/// - RSI-14 pullback (30-50): buy the dip in a confirmed uptrend
/// This is the only proven single-stock pattern (Levy 1967, confirmed
/// by DeMiguel et al. 2020)
///
/// LAYER 3 (CONVICTION): Supplementary confirmation
/// - MACD histogram positive: momentum accelerating
/// - ADX > 25 with DI+ > DI-: strong directional trend
/// - Volume above average: institutional participation
///
/// SELL SIGNALS: Hierarchical exit triggers
/// - Trend break: price below EMA-trend = immediate sell
/// - Momentum reversal: ROC turns significantly negative
/// - EMA death cross: EMA-short crosses below EMA-long
pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &IndicatorRow) -> TradeSignal {
let rsi = current.rsi;
let rsi2 = current.rsi_short;
let macd_hist = current.macd_histogram;
let momentum = current.momentum;
let ema_short = current.ema_short;
@@ -451,167 +503,135 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
// Safe NaN handling
let trend_bullish = current.trend_bullish;
let volume_ratio = if current.volume_ratio.is_nan() { 1.0 } else { current.volume_ratio };
let adx = if current.adx.is_nan() { 22.0 } else { current.adx };
let adx = if current.adx.is_nan() { 20.0 } else { current.adx };
let di_plus = if current.di_plus.is_nan() { 25.0 } else { current.di_plus };
let di_minus = if current.di_minus.is_nan() { 25.0 } else { current.di_minus };
let bb_pct = if current.bb_pct.is_nan() { 0.5 } else { current.bb_pct };
let ema_distance = if current.ema_distance.is_nan() { 0.0 } else { current.ema_distance };
// REGIME DETECTION
let is_ranging = adx < ADX_RANGE_THRESHOLD;
let is_trending = adx > ADX_TREND_THRESHOLD;
let strong_trend = adx > ADX_STRONG;
let trend_up = di_plus > di_minus;
// EMA state
let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long;
let prev_ema_bullish = !previous.ema_short.is_nan()
&& !previous.ema_long.is_nan()
&& previous.ema_short > previous.ema_long;
// MACD crossover detection
let has_momentum = !momentum.is_nan() && momentum > 0.0;
let mut buy_score: f64 = 0.0;
let mut sell_score: f64 = 0.0;
// ═══════════════════════════════════════════════════════════════
// BUY LOGIC: Hierarchical filter (all gates must pass)
// ═══════════════════════════════════════════════════════════════
// GATE 1: Trend must be confirmed (price > EMA-trend AND EMA alignment)
// Without this, no buy signal at all. This is the Faber (2007) filter
// that alone produces positive risk-adjusted returns.
if trend_bullish && ema_bullish {
// GATE 2: Positive time-series momentum (Moskowitz et al. 2012)
if has_momentum {
// Base score for being in a confirmed uptrend with positive momentum
buy_score += 4.0;
// TIMING: RSI-14 pullback in uptrend (the "buy the dip" pattern)
// RSI 30-50 means price has pulled back but trend is intact.
// This is the most robust single-stock entry timing signal.
if !rsi.is_nan() && rsi >= 30.0 && rsi <= 50.0 {
buy_score += 3.0;
}
// Moderate pullback (RSI 50-60) still gets some credit
else if !rsi.is_nan() && rsi > 50.0 && rsi <= 60.0 {
buy_score += 1.0;
}
// RSI > 70 = overbought, do not add to buy score (chasing)
// CONVICTION BOOSTERS (each adds incremental edge)
// Strong directional trend (ADX > 25, DI+ dominant)
if adx > ADX_TREND_THRESHOLD && di_plus > di_minus {
buy_score += 1.5;
}
// MACD histogram positive and increasing = accelerating momentum
if !macd_hist.is_nan() && macd_hist > 0.0 {
buy_score += 1.0;
// MACD just crossed up = fresh momentum impulse
let macd_crossed_up = !previous.macd.is_nan()
&& !previous.macd_signal.is_nan()
&& !current.macd.is_nan()
&& !current.macd_signal.is_nan()
&& previous.macd < previous.macd_signal
&& current.macd > current.macd_signal;
if macd_crossed_up {
buy_score += 1.5;
}
}
// Volume confirmation: above-average volume = institutional interest
if volume_ratio >= VOLUME_THRESHOLD {
buy_score += 0.5;
} else {
// Low volume = less reliable, reduce score
buy_score *= 0.7;
}
// Strong momentum bonus (ROC > 10% = strong trend)
if momentum > 10.0 {
buy_score += 1.0;
}
}
}
// ═══════════════════════════════════════════════════════════════
// SELL LOGIC: Exit when trend breaks or momentum reverses
// ═══════════════════════════════════════════════════════════════
// CRITICAL SELL: Trend break — price drops below EMA-trend
// This is the single most important exit signal. When the long-term
// trend breaks, the position has no structural support.
if !trend_bullish {
sell_score += 4.0;
// If also EMA death cross, very strong sell
if !ema_bullish {
sell_score += 2.0;
}
// Momentum confirming the breakdown
if !momentum.is_nan() && momentum < -5.0 {
sell_score += 2.0;
} else if !momentum.is_nan() && momentum < 0.0 {
sell_score += 1.0;
}
}
// Trend still intact but showing weakness
else {
// EMA death cross while still above trend EMA = early warning
if !ema_bullish && prev_ema_bullish {
sell_score += 3.0;
}
// Momentum has reversed significantly (still above EMA-trend though)
if !momentum.is_nan() && momentum < -10.0 {
sell_score += 3.0;
} else if !momentum.is_nan() && momentum < -5.0 {
sell_score += 1.5;
}
// MACD crossed down = momentum decelerating
let macd_crossed_down = !previous.macd.is_nan()
&& !previous.macd_signal.is_nan()
&& !current.macd.is_nan()
&& !current.macd_signal.is_nan()
&& previous.macd > previous.macd_signal
&& current.macd < current.macd_signal;
let mut buy_score: f64 = 0.0;
let mut sell_score: f64 = 0.0;
// ═══════════════════════════════════════════════════════════════
// REGIME 1: MEAN REVERSION (ranging market, ADX < 20)
// ═══════════════════════════════════════════════════════════════
if is_ranging {
// Connors RSI-2 mean reversion: buy extreme oversold in uptrend context
if !rsi2.is_nan() {
// Buy: RSI-2 extremely oversold + long-term trend intact
if rsi2 < RSI2_OVERSOLD {
buy_score += 5.0; // Strong mean reversion signal
if trend_bullish {
buy_score += 3.0; // With-trend mean reversion = highest conviction
}
if bb_pct < 0.05 {
buy_score += 2.0; // Price at/below lower BB
}
} else if rsi2 < 20.0 {
buy_score += 2.5;
if trend_bullish {
buy_score += 1.5;
}
}
// Sell: RSI-2 overbought = take profit on mean reversion
if rsi2 > RSI2_OVERBOUGHT {
sell_score += 4.0;
if !trend_bullish {
if macd_crossed_down {
sell_score += 2.0;
}
} else if rsi2 > 80.0 && !trend_bullish {
sell_score += 2.0;
}
}
// Bollinger Band extremes in range
if bb_pct < 0.0 {
buy_score += 2.0; // Below lower band
} else if bb_pct > 1.0 {
sell_score += 2.0; // Above upper band
}
}
// ═══════════════════════════════════════════════════════════════
// REGIME 2: TREND FOLLOWING (trending market, ADX > 25)
// ═══════════════════════════════════════════════════════════════
if is_trending {
// Trend direction confirmation
if trend_up && trend_bullish {
buy_score += 3.0;
// Pullback entry: price dipped but trend intact
if !rsi.is_nan() && rsi < 40.0 && rsi > 25.0 {
buy_score += 3.0; // Pullback in uptrend
}
if ema_distance > 0.0 && ema_distance < 0.02 {
buy_score += 2.0; // Near EMA support
}
if strong_trend {
buy_score += 1.5; // Strong trend bonus
}
} else if !trend_up && !trend_bullish {
sell_score += 3.0;
if !rsi.is_nan() && rsi > 60.0 && rsi < 75.0 {
sell_score += 3.0; // Bounce in downtrend
}
if ema_distance < 0.0 && ema_distance > -0.02 {
sell_score += 2.0; // Near EMA resistance
}
if strong_trend {
// RSI extremely overbought (>80) in deteriorating momentum
if !rsi.is_nan() && rsi > 80.0 && !momentum.is_nan() && momentum < 5.0 {
sell_score += 1.5;
}
}
}
// ═══════════════════════════════════════════════════════════════
// UNIVERSAL SIGNALS (both regimes)
// ═══════════════════════════════════════════════════════════════
// RSI-14 extremes (strong conviction regardless of regime)
if !rsi.is_nan() {
if rsi < RSI_OVERSOLD && trend_bullish {
buy_score += 3.0; // Oversold in uptrend = strong buy
} else if rsi > RSI_OVERBOUGHT && !trend_bullish {
sell_score += 3.0; // Overbought in downtrend = strong sell
}
}
// MACD crossover
if macd_crossed_up {
buy_score += 2.0;
if is_trending && trend_up {
buy_score += 1.0; // Trend-confirming crossover
}
} else if macd_crossed_down {
sell_score += 2.0;
if is_trending && !trend_up {
sell_score += 1.0;
}
}
// MACD histogram direction
if !macd_hist.is_nan() {
if macd_hist > 0.0 { buy_score += 0.5; }
else if macd_hist < 0.0 { sell_score += 0.5; }
}
// Momentum
if !momentum.is_nan() {
if momentum > 5.0 { buy_score += 1.5; }
else if momentum > 2.0 { buy_score += 0.5; }
else if momentum < -5.0 { sell_score += 1.5; }
else if momentum < -2.0 { sell_score += 0.5; }
}
// EMA crossover events
let prev_ema_bullish = !previous.ema_short.is_nan()
&& !previous.ema_long.is_nan()
&& previous.ema_short > previous.ema_long;
if ema_bullish && !prev_ema_bullish {
buy_score += 2.0;
} else if !ema_bullish && prev_ema_bullish {
sell_score += 2.0;
}
// Volume gate
if volume_ratio < VOLUME_THRESHOLD {
buy_score *= 0.5;
sell_score *= 0.5;
}
// ═══════════════════════════════════════════════════════════════
// SIGNAL DETERMINATION
@@ -630,7 +650,9 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
Signal::Hold
};
let confidence = (total_score.abs() / 12.0).min(1.0);
// Confidence now reflects the hierarchical gating: a score of 4.0 from
// the gated system is worth much more than 4.0 from the old additive system.
let confidence = (total_score.abs() / 10.0).min(1.0);
TradeSignal {
symbol: symbol.to_string(),

View File

@@ -64,4 +64,12 @@ lazy_static! {
path.push("trading_bot.log");
path
};
/// Base directory for backtest data cache.
pub static ref BACKTEST_CACHE_DIR: PathBuf = {
let mut path = DATA_DIR.clone();
path.push("cache");
std::fs::create_dir_all(&path).expect("Failed to create cache directory");
path
};
}

View File

@@ -26,7 +26,13 @@ impl Strategy {
}
}
/// Volatility-adjusted position sizing using ATR.
/// Volatility-adjusted position sizing using ATR (Kelly-inspired).
///
/// Position size = (Risk per trade / ATR stop distance) * confidence.
/// The confidence scaling now has a much wider range (0.4 to 1.0) so that
/// weak Buy signals (confidence ~0.4) get 40% size while StrongBuy signals
/// (confidence ~1.0) get full size. This is a fractional Kelly approach:
/// bet more when conviction is higher, less when marginal.
pub fn calculate_position_size(
&self,
price: f64,
@@ -42,8 +48,9 @@ impl Strategy {
let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER;
let risk_amount = portfolio_value * RISK_PER_TRADE;
let vol_adjusted = risk_amount / atr_stop_pct;
// Scale by confidence
let confidence_scale = 0.7 + 0.3 * signal.confidence;
// Wide confidence scaling: 0.4x for weak signals, 1.0x for strongest.
// Old code used 0.7 + 0.3*conf which barely differentiated.
let confidence_scale = 0.4 + 0.6 * signal.confidence;
let sized = vol_adjusted * confidence_scale;
sized.min(portfolio_value * MAX_POSITION_SIZE)
} else {
@@ -51,12 +58,26 @@ impl Strategy {
};
let position_value = position_value.min(available_cash);
// Use fractional shares Alpaca supports them for paper trading.
// Use fractional shares -- Alpaca supports them for paper trading.
// Truncate to 4 decimal places to avoid floating point dust.
((position_value / price) * 10000.0).floor() / 10000.0
}
/// 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, gap 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. ATR trailing stop (ATR_TRAIL_MULTIPLIER * ATR from HWM) -- profit protection
/// 5. Time-based exit (TIME_EXIT_BARS) -- only if position is LOSING
///
/// Key design decisions:
/// - Trailing stop activates early (1.5x ATR) but has wide distance (2.5x ATR)
/// so winners have room to breathe but profits are protected.
/// - Time exit ONLY sells losers. Winners at the time limit are doing fine;
/// the trailing stop handles profit-taking on them.
/// - Max loss is wide enough to avoid being hit by normal ATR-level moves.
pub fn check_stop_loss_take_profit(
&mut self,
symbol: &str,
@@ -78,34 +99,25 @@ impl Strategy {
}
}
// Hard max-loss cap
// 1. Hard max-loss cap (catastrophic gap protection)
if pnl_pct <= -MAX_LOSS_PCT {
return Some(Signal::StrongSell);
}
// ATR-based stop loss
// 2. ATR-based initial stop-loss (primary risk control)
if entry_atr > 0.0 {
let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr;
if current_price <= atr_stop_price {
return Some(Signal::StrongSell);
}
} else if pnl_pct <= -STOP_LOSS_PCT {
// 3. Fixed percentage fallback
return Some(Signal::StrongSell);
}
// Time-based exit
if bars_held >= TIME_EXIT_BARS {
let activation = if entry_atr > 0.0 {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else {
TRAILING_STOP_ACTIVATION
};
if pnl_pct < activation {
return Some(Signal::Sell);
}
}
// ATR-based trailing stop
// 4. ATR-based trailing stop (profit protection)
// Activates earlier than before (1.5x ATR gain) so profits are locked in.
// Distance is wider (2.5x ATR from HWM) so normal retracements don't trigger it.
let activation_gain = if entry_atr > 0.0 {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else {
@@ -126,6 +138,14 @@ impl Strategy {
}
}
// 5. Time-based exit: only for LOSING positions (capital efficiency)
// Winners at the time limit are managed by the trailing stop.
// This prevents the old behavior of dumping winners just because they
// haven't hit an arbitrary activation threshold in N bars.
if bars_held >= TIME_EXIT_BARS && pnl_pct < 0.0 {
return Some(Signal::Sell);
}
None
}

View File

@@ -3,6 +3,34 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Broad market regime determined from SPY price action.
///
/// Based on Faber (2007) dual moving average framework:
/// - Bull: SPY above EMA-200 and EMA-50 above EMA-200 (golden cross territory)
/// - Caution: SPY below EMA-50 but still above EMA-200 (early weakness)
/// - Bear: SPY below EMA-200 and EMA-50 below EMA-200 (death cross territory)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarketRegime {
Bull,
Caution,
Bear,
}
impl MarketRegime {
pub fn as_str(&self) -> &'static str {
match self {
MarketRegime::Bull => "BULL",
MarketRegime::Caution => "CAUTION",
MarketRegime::Bear => "BEAR",
}
}
/// Whether new long entries are permitted in this regime.
pub fn allows_new_longs(&self) -> bool {
!matches!(self, MarketRegime::Bear)
}
}
/// Trading signal types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -110,7 +138,7 @@ pub struct EquityPoint {
}
/// OHLCV bar data.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bar {
pub timestamp: DateTime<Utc>,
pub open: f64,