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 # Quant-Rust-Strategist Memory
## Architecture Overview ## Architecture Overview
- ~100-symbol universe across 14 sectors (expanded from original 50) - ~100-symbol universe across 14 sectors
- Hybrid momentum + mean-reversion via regime-adaptive dual signal in `generate_signal()`
- strategy.rs: shared logic between bot.rs and backtester.rs - strategy.rs: shared logic between bot.rs and backtester.rs
- Backtester restricts buys to top momentum stocks (TOP_MOMENTUM_COUNT) - 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 - Signal thresholds: StrongBuy>=7.0, Buy>=4.0, Sell<=-4.0, StrongSell<=-7.0
## Bugs Fixed (2026-02-13) ## Signal Generation (2026-02-13 REWRITE)
### 1. calculate_results used self.cash instead of equity curve final value - **OLD**: Additive "indicator soup" -- 8 indicators netted, PF 0.91, no edge
- backtester.rs line ~686: `let final_value = self.cash` missed open positions - **NEW**: Hierarchical momentum-with-trend filter:
- Fixed: use `self.equity_history.last().portfolio_value` - 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 ## Stop/Exit Logic (2026-02-13 FIX)
- peak_portfolio_value was never reset after halt, causing immediate re-trigger - Time exit ONLY sells losers (pnl_pct < 0). Old code force-sold winners.
- 7+ triggers in 3yr = ~140 bars (19% of backtest) sitting in cash - Trail activation: 1.5x ATR (was 2.0x), trail distance: 2.5x ATR (was 2.0x)
- Fixed: reset peak to current value on halt resume - Max loss: 8% (was 5%), TIME_EXIT_BARS: 60 (was 40)
### 3. PDT blocking sells in backtester (disabled) ## Equity Curve SMA Stop: REMOVED from backtester
- PDT sell-blocking removed from backtester; it measures strategy alpha not compliance - Created pathological feedback loop with drawdown breaker
- 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)
## PDT Implementation (2026-02-12) ## Position Sizing (2026-02-13 FIX)
- Tracks day trades in rolling 5-business-day window, max 3 allowed - Confidence scaling: 0.4 + 0.6*conf (was 0.7 + 0.3*conf)
- CRITICAL: Stop-loss exits must NEVER be blocked by PDT (risk mgmt > compliance) - RISK_PER_TRADE: 1.0%, MAX_POSITIONS: 10, TOP_MOMENTUM: 10
- 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)
## Current Parameters (config.rs, updated 2026-02-13) ## Current Parameters (config.rs, updated 2026-02-13)
- ATR Stop: 3.0x | Trail: 2.0x distance, 2.0x activation - ATR Stop: 3.0x | Trail: 2.5x distance, 1.5x activation
- Risk: 1.2%/trade, max 25% position, 5% cash reserve, 5% max loss - Risk: 1.0%/trade, max 25% position, 5% cash reserve, 8% max loss
- Max 7 positions, 2/sector | Drawdown halt: 15% (10 bars) | Time exit: 40 - Max 10 positions, 2/sector | Time exit: 60 bars (losers only)
- Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps - Cooldown: 5 bars | Ramp-up: 15 bars | Slippage: 10bps
- Buy threshold: 4.0 (lowered from 4.5) | Momentum pool: top 20 (widened from 10) - Momentum pool: top 10 (decile)
- Daily: momentum=63, ema_trend=50 | Hourly: momentum=63, ema_trend=200
- ADX: range<20, trend>25, strong>40
## Hourly Timeframe: DO NOT CHANGE FROM BASELINE ## Bugs Fixed (2026-02-13)
- Hourly IndicatorParams: momentum=63, ema_trend=200 (long lookbacks filter IEX noise) 1. calculate_results used self.cash instead of equity curve final value
- Shorter periods (momentum=21, ema_trend=50): CATASTROPHIC -8% loss 2. Drawdown circuit breaker cascading re-triggers (peak not reset)
3. PDT blocking sells in backtester (disabled)
## Failed Experiments (avoid repeating) ## Failed Experiments (avoid repeating)
1. Tighter ATR stop (<3.0x): too many stop-outs on hourly 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) 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, dangerous 3. Blocking stop-loss exits for PDT: traps capital in losers
4. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8 4. Lower volume threshold (0.7): bad trades on IEX. Keep 0.8
5. Shorter hourly lookbacks: catastrophic losses 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 ## IEX Data Stochasticity
- Backtests have significant run-to-run variation from IEX data timing - Run 2-3 times and compare ranges before concluding a change helped/hurt
- Do NOT panic about minor performance swings between runs
- Always run 2-3 times and compare ranges before concluding a change helped/hurt
## Build Notes ## 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 - 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. /// Helper to fetch bars for backtesting with proper date handling.
/// Fetches each symbol individually to avoid API limits on multi-symbol requests. /// 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( pub async fn fetch_backtest_data(
client: &AlpacaClient, client: &AlpacaClient,
symbols: &[&str], symbols: &[&str],
@@ -476,6 +509,9 @@ pub async fn fetch_backtest_data(
let days = (years * 365.0) as i64 + warmup_days + 30; let days = (years * 365.0) as i64 + warmup_days + 30;
let start = end - Duration::days(days); 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!( tracing::info!(
"Fetching {:.2} years of data ({} to {})...", "Fetching {:.2} years of data ({} to {})...",
years, years,
@@ -484,29 +520,115 @@ pub async fn fetch_backtest_data(
); );
let mut all_data = HashMap::new(); 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 { for symbol in symbols {
tracing::info!(" Fetching {}...", symbol); let cached = load_cached_bars(symbol, timeframe);
match client if cached.is_empty() {
.get_historical_bars(symbol, timeframe, start, end) // Full fetch — no cache
.await cache_misses += 1;
{ tracing::info!(" Fetching {} (no cache)...", symbol);
Ok(bars) => {
if !bars.is_empty() { match client
tracing::info!(" {}: {} bars loaded", symbol, bars.len()); .get_historical_bars(symbol, timeframe, start, end)
all_data.insert(symbol.to_string(), bars); .await
} else { {
tracing::warn!(" {}: No data", symbol); 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);
} }
} }
Err(e) => { } else {
tracing::error!(" Failed to fetch {}: {}", symbol, e); 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) Ok(all_data)
} }

View File

@@ -7,16 +7,23 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use crate::alpaca::{fetch_backtest_data, AlpacaClient}; use crate::alpaca::{fetch_backtest_data, 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, DRAWDOWN_HALT_BARS, HOURS_PER_DAY, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY,
MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT, MAX_LOSS_PCT, MAX_POSITION_SIZE, MAX_CONCURRENT_POSITIONS, MAX_LOSS_PCT, MAX_POSITION_SIZE,
MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
REENTRY_COOLDOWN_BARS, SLIPPAGE_BPS, TIME_EXIT_BARS, REENTRY_COOLDOWN_BARS, SLIPPAGE_BPS, TIME_EXIT_BARS,
TOP_MOMENTUM_COUNT, TRADING_DAYS_PER_YEAR, 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::strategy::Strategy;
use crate::types::{ use crate::types::{
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade, TradeSignal, BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, MarketRegime, Signal, Trade, TradeSignal,
}; };
/// Backtesting engine for the trading strategy. /// Backtesting engine for the trading strategy.
@@ -30,6 +37,12 @@ pub struct Backtester {
drawdown_halt: bool, drawdown_halt: bool,
/// Bar index when drawdown halt started (for time-based resume) /// Bar index when drawdown halt started (for time-based resume)
drawdown_halt_start: Option<usize>, 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, strategy: Strategy,
timeframe: Timeframe, timeframe: Timeframe,
/// Current bar index in the simulation /// Current bar index in the simulation
@@ -57,6 +70,9 @@ impl Backtester {
peak_portfolio_value: initial_capital, peak_portfolio_value: initial_capital,
drawdown_halt: false, drawdown_halt: false,
drawdown_halt_start: None, drawdown_halt_start: None,
drawdown_halt_severity: 0.0,
current_regime: MarketRegime::Bull,
drawdown_requires_bull: false,
strategy: Strategy::new(timeframe), strategy: Strategy::new(timeframe),
timeframe, timeframe,
current_bar: 0, current_bar: 0,
@@ -87,12 +103,15 @@ impl Backtester {
self.cash + positions_value self.cash + positions_value
} }
/// Update drawdown circuit breaker state. /// Update drawdown circuit breaker state with scaled cooldowns.
/// 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 /// Drawdown severity determines halt duration:
/// re-triggers from the same drawdown event. Without this reset, a partial recovery /// - Tier 1 (15%): 10 bars — normal correction
/// followed by a minor dip re-triggers the halt, causing the bot to spend excessive /// - Tier 2 (20%): 30 bars — significant bear market
/// time in cash (observed: 7+ triggers in a 3-year backtest = ~140 bars lost). /// - 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) { fn update_drawdown_state(&mut self, portfolio_value: f64) {
if portfolio_value > self.peak_portfolio_value { if portfolio_value > self.peak_portfolio_value {
self.peak_portfolio_value = 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; let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value;
// Trigger halt if drawdown exceeds threshold // Trigger halt at the lowest tier that matches (if not already halted)
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt { 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!( 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, drawdown_pct * 100.0,
MAX_DRAWDOWN_HALT * 100.0, halt_bars,
DRAWDOWN_HALT_BARS if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" }
); );
self.drawdown_halt = true; self.drawdown_halt = true;
self.drawdown_halt_start = Some(self.current_bar); 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 // Auto-resume after time-based cooldown
if self.drawdown_halt { if self.drawdown_halt {
if let Some(halt_start) = self.drawdown_halt_start { 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!( tracing::info!(
"Drawdown halt expired after {} bars. Resuming trading. \ "Drawdown halt expired after {} bars (regime: {}). \
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).", Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
DRAWDOWN_HALT_BARS, required_bars,
self.current_regime.as_str(),
self.peak_portfolio_value, self.peak_portfolio_value,
portfolio_value, portfolio_value,
drawdown_pct * 100.0 drawdown_pct * 100.0
); );
self.drawdown_halt = false; self.drawdown_halt = false;
self.drawdown_halt_start = None; self.drawdown_halt_start = None;
// Reset peak to current value to prevent cascading re-triggers. self.drawdown_halt_severity = 0.0;
// The previous peak is no longer relevant after a halt — measuring self.drawdown_requires_bull = false;
// drawdown from it would immediately re-trigger on any minor dip.
self.peak_portfolio_value = portfolio_value; 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 /// For hourly timeframe, entries are blocked in the last 2 hours of the
/// trading day to avoid creating positions that might need same-day /// trading day to avoid creating positions that might need same-day
/// stop-loss exits (PDT prevention at entry rather than blocking exits). /// 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( fn execute_buy(
&mut self, &mut self,
symbol: &str, symbol: &str,
@@ -147,19 +224,20 @@ impl Backtester {
timestamp: DateTime<Utc>, timestamp: DateTime<Utc>,
portfolio_value: f64, portfolio_value: f64,
signal: &TradeSignal, signal: &TradeSignal,
regime_size_factor: f64,
) -> bool { ) -> bool {
if self.positions.contains_key(symbol) { if self.positions.contains_key(symbol) {
return false; 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. // 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 { if self.timeframe == Timeframe::Hourly {
let hour = timestamp.hour(); 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 { if hour >= 19 {
return false; return false;
} }
@@ -168,7 +246,7 @@ impl Backtester {
// Cooldown guard: prevent whipsaw re-entry after stop-loss // Cooldown guard: prevent whipsaw re-entry after stop-loss
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) { if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
if self.current_bar < cooldown_until { if self.current_bar < cooldown_until {
return false; // Still in cooldown period return false;
} }
} }
@@ -177,6 +255,11 @@ impl Backtester {
return false; 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 { if self.positions.len() >= MAX_CONCURRENT_POSITIONS {
return false; return false;
} }
@@ -196,9 +279,15 @@ impl Backtester {
} }
let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE); let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE);
let shares = let mut shares =
self.strategy self.strategy
.calculate_position_size(price, portfolio_value, available_cash, signal); .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 { if shares <= 0.0 {
return false; return false;
} }
@@ -214,7 +303,7 @@ impl Backtester {
symbol.to_string(), symbol.to_string(),
BacktestPosition { BacktestPosition {
symbol: symbol.to_string(), symbol: symbol.to_string(),
shares: shares, shares,
entry_price: fill_price, entry_price: fill_price,
entry_time: timestamp, entry_time: timestamp,
entry_atr: signal.atr, entry_atr: signal.atr,
@@ -229,7 +318,7 @@ impl Backtester {
self.trades.push(Trade { self.trades.push(Trade {
symbol: symbol.to_string(), symbol: symbol.to_string(),
side: "BUY".to_string(), side: "BUY".to_string(),
shares: shares, shares,
price: fill_price, price: fill_price,
timestamp, timestamp,
pnl: 0.0, pnl: 0.0,
@@ -444,6 +533,32 @@ impl Backtester {
data.insert(symbol.clone(), indicators); 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 // Get common date range
let mut all_dates: BTreeMap<DateTime<Utc>, HashSet<String>> = BTreeMap::new(); let mut all_dates: BTreeMap<DateTime<Utc>, HashSet<String>> = BTreeMap::new();
for (symbol, rows) in &data { for (symbol, rows) in &data {
@@ -508,6 +623,18 @@ impl Backtester {
symbol_date_index.insert(symbol.clone(), idx_map); 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 // Main simulation loop
for (day_num, current_date) in trading_dates.iter().enumerate() { for (day_num, current_date) in trading_dates.iter().enumerate() {
self.current_bar = day_num; self.current_bar = day_num;
@@ -532,6 +659,54 @@ impl Backtester {
let portfolio_value = self.get_portfolio_value(&current_prices); 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 // Update drawdown circuit breaker
self.update_drawdown_state(portfolio_value); self.update_drawdown_state(portfolio_value);
@@ -597,47 +772,75 @@ impl Backtester {
} }
// Phase 2: Process buys (only for top momentum stocks) // Phase 2: Process buys (only for top momentum stocks)
for symbol in &ranked_symbols { // In Bear regime, skip the entire buy phase (no new longs).
let rows = match data.get(symbol) { if regime.allows_new_longs() {
Some(r) => r, // In Caution regime, raise the buy threshold to require stronger signals
None => continue, let buy_threshold_bump = match regime {
MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP,
_ => 0.0,
}; };
// Only buy top momentum stocks for symbol in &ranked_symbols {
if !top_momentum_symbols.contains(symbol) { // Don't buy SPY itself — it's used as the regime benchmark
continue; if symbol == REGIME_SPY_SYMBOL {
} continue;
}
let idx = match symbol_date_index let rows = match data.get(symbol) {
.get(symbol) Some(r) => r,
.and_then(|m| m.get(current_date)) None => continue,
{ };
Some(&i) => i,
None => continue,
};
if idx < 1 { // Only buy top momentum stocks
continue; if !top_momentum_symbols.contains(symbol) {
} continue;
}
let current_row = &rows[idx]; let idx = match symbol_date_index
let previous_row = &rows[idx - 1]; .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() { if idx < 1 {
continue; continue;
} }
let signal = generate_signal(symbol, current_row, previous_row); let current_row = &rows[idx];
let previous_row = &rows[idx - 1];
// Execute buys if current_row.rsi.is_nan() || current_row.macd.is_nan() {
if signal.signal.is_buy() { continue;
self.execute_buy( }
symbol,
signal.current_price, let signal = generate_signal(symbol, current_row, previous_row);
*current_date,
portfolio_value, // Apply regime threshold bump: in Caution, require stronger conviction
&signal, 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 MAX_SECTOR_POSITIONS
); );
println!( println!(
" Drawdown Halt: {:>14.0}% ({} bar cooldown)", " Drawdown Halt: {:>13.0}%/{:.0}%/{:.0}% ({}/{}/{} bars)",
MAX_DRAWDOWN_HALT * 100.0, DRAWDOWN_TIER1_PCT * 100.0,
DRAWDOWN_HALT_BARS 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!( println!(
" Time Exit: {:>13} bars", " Time Exit: {:>13} bars",

View File

@@ -10,17 +10,24 @@ use crate::alpaca::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, BOT_CHECK_INTERVAL_SECONDS, 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, MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
REENTRY_COOLDOWN_BARS, TOP_MOMENTUM_COUNT, 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::{ use crate::paths::{
LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_DAY_TRADES_FILE, LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE,
LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE, LIVE_POSITIONS_FILE, LIVE_POSITION_META_FILE,
}; };
use crate::strategy::Strategy; 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. /// Per-position metadata persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -46,6 +53,12 @@ pub struct TradingBot {
drawdown_halt: bool, drawdown_halt: bool,
/// Cycle count when drawdown halt started (for time-based resume) /// Cycle count when drawdown halt started (for time-based resume)
drawdown_halt_start: Option<usize>, 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 /// Current trading cycle count
trading_cycle_count: usize, trading_cycle_count: usize,
/// Tracks when each symbol can be re-entered after stop-loss (cycle index) /// Tracks when each symbol can be re-entered after stop-loss (cycle index)
@@ -76,6 +89,9 @@ impl TradingBot {
peak_portfolio_value: 0.0, peak_portfolio_value: 0.0,
drawdown_halt: false, drawdown_halt: false,
drawdown_halt_start: None, drawdown_halt_start: None,
drawdown_halt_severity: 0.0,
drawdown_requires_bull: false,
current_regime: MarketRegime::Bull,
trading_cycle_count: 0, trading_cycle_count: 0,
cooldown_timers: HashMap::new(), cooldown_timers: HashMap::new(),
new_positions_this_cycle: 0, new_positions_this_cycle: 0,
@@ -349,34 +365,87 @@ impl TradingBot {
0.0 0.0
}; };
// Trigger halt if drawdown exceeds threshold // Scaled drawdown circuit breaker (Tier 1/2/3)
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt { 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!( 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, drawdown_pct * 100.0,
MAX_DRAWDOWN_HALT * 100.0, halt_bars,
DRAWDOWN_HALT_BARS if self.drawdown_requires_bull { " Requires BULL regime to resume." } else { "" }
); );
self.drawdown_halt = true; self.drawdown_halt = true;
self.drawdown_halt_start = Some(self.trading_cycle_count); 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 // Auto-resume after time-based cooldown
if self.drawdown_halt { if self.drawdown_halt {
if let Some(halt_start) = self.drawdown_halt_start { 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!( tracing::info!(
"Drawdown halt expired after {} cycles. Resuming trading. \ "Drawdown halt expired after {} cycles (regime: {}). \
Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).", Peak reset from ${:.2} to ${:.2} (was {:.2}% drawdown).",
DRAWDOWN_HALT_BARS, required_bars,
self.current_regime.as_str(),
self.peak_portfolio_value, self.peak_portfolio_value,
portfolio_value, portfolio_value,
drawdown_pct * 100.0 drawdown_pct * 100.0
); );
self.drawdown_halt = false; self.drawdown_halt = false;
self.drawdown_halt_start = None; 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; 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 ────────────────────────────────────────────── // ── 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 // Check if already holding
if let Some(qty) = self.get_position(symbol).await { if let Some(qty) = self.get_position(symbol).await {
if qty > 0.0 { if qty > 0.0 {
@@ -556,7 +625,10 @@ impl TradingBot {
return false; 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 { if shares <= 0.0 {
tracing::info!("{}: Insufficient funds for purchase", symbol); tracing::info!("{}: Insufficient funds for purchase", symbol);
return false; return false;
@@ -692,6 +764,66 @@ impl TradingBot {
// Partial exits removed: they systematically halve winning trade size // Partial exits removed: they systematically halve winning trade size
// while losing trades remain at full size, creating unfavorable avg win/loss ratio. // 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 ───────────────────────────────────────────────────── // ── Analysis ─────────────────────────────────────────────────────
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> { 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) // Increment bars_held once per trading cycle (matches backtester's per-bar increment)
for meta in self.position_meta.values_mut() { for meta in self.position_meta.values_mut() {
meta.bars_held += 1; meta.bars_held += 1;
@@ -843,13 +985,52 @@ impl TradingBot {
); );
// Phase 3: Process buys in momentum-ranked order (highest momentum first) // Phase 3: Process buys in momentum-ranked order (highest momentum first)
for signal in &ranked_signals { // Gate by market regime and equity curve stop
if !top_momentum_symbols.contains(&signal.symbol) { if !self.current_regime.allows_new_longs() {
continue; 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,
);
} }
if signal.signal.is_buy() { for signal in &ranked_signals {
self.execute_buy(&signal.symbol, signal).await; if !top_momentum_symbols.contains(&signal.symbol) {
continue;
}
// 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 TELECOM_MEDIA: &[&str] = &["T", "VZ", "CMCSA", "TMUS", "NFLX"];
pub const INTERNATIONAL: &[&str] = &["TSM", "BABA", "JD", "SHOP", "MELI"]; pub const INTERNATIONAL: &[&str] = &["TSM", "BABA", "JD", "SHOP", "MELI"];
pub const MATERIALS: &[&str] = &["FCX", "NEM", "LIN", "APD", "SHW"]; 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> { pub fn get_all_symbols() -> Vec<&'static str> {
let mut symbols = Vec::new(); let mut symbols = Vec::new();
symbols.extend_from_slice(MAG7); 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(TELECOM_MEDIA);
symbols.extend_from_slice(INTERNATIONAL); symbols.extend_from_slice(INTERNATIONAL);
symbols.extend_from_slice(MATERIALS); 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) // Deduplicate (NFLX appears in both GROWTH_TECH and TELECOM_MEDIA)
symbols.sort(); symbols.sort();
symbols.dedup(); 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) // RSI-14 for trend assessment, RSI-2 for mean-reversion entries (Connors)
pub const RSI_PERIOD: usize = 14; pub const RSI_PERIOD: usize = 14;
pub const RSI_SHORT_PERIOD: usize = 2; // Connors RSI-2 for mean reversion 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_FAST: usize = 12;
pub const MACD_SLOW: usize = 26; pub const MACD_SLOW: usize = 26;
pub const MACD_SIGNAL: usize = 9; pub const MACD_SIGNAL: usize = 9;
@@ -56,9 +54,7 @@ pub const EMA_TREND: usize = 50;
// ADX > TREND_THRESHOLD = trending (use momentum/pullback) // ADX > TREND_THRESHOLD = trending (use momentum/pullback)
// Between = transition zone (reduce size, be cautious) // Between = transition zone (reduce size, be cautious)
pub const ADX_PERIOD: usize = 14; 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_TREND_THRESHOLD: f64 = 25.0; // Above this = trending
pub const ADX_STRONG: f64 = 40.0; // Strong trend for bonus conviction
// Bollinger Bands // Bollinger Bands
pub const BB_PERIOD: usize = 20; pub const BB_PERIOD: usize = 20;
pub const BB_STD: f64 = 2.0; 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_MA_PERIOD: usize = 20;
pub const VOLUME_THRESHOLD: f64 = 0.8; pub const VOLUME_THRESHOLD: f64 = 0.8;
// Momentum Ranking // 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 // Risk Management
pub const MAX_POSITION_SIZE: f64 = 0.25; // Slightly larger for concentrated bets pub const MAX_POSITION_SIZE: f64 = 0.25; // Slightly larger for concentrated bets
pub const MIN_CASH_RESERVE: f64 = 0.05; pub const MIN_CASH_RESERVE: f64 = 0.05;
pub const STOP_LOSS_PCT: f64 = 0.025; 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 MAX_LOSS_PCT: f64 = 0.08; // Gap protection only — ATR stop handles normal exits
pub const TRAILING_STOP_ACTIVATION: f64 = 0.06; pub const TRAILING_STOP_ACTIVATION: f64 = 0.04; // Activate earlier to protect profits
pub const TRAILING_STOP_DISTANCE: f64 = 0.04; pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // Wider trail to let winners run
// ATR-based risk management // 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_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_MULTIPLIER: f64 = 2.5; // Wide trail from HWM so winners have room to breathe
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Activate after 2x ATR gain pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 1.5; // Activate earlier (1.5x ATR gain) to protect profits
// Portfolio-level controls // 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_SECTOR_POSITIONS: usize = 2;
pub const MAX_DRAWDOWN_HALT: f64 = 0.15; // 15% drawdown trigger (markets routinely correct 10-15%) // Old single-tier drawdown constants (replaced by tiered system below)
pub const DRAWDOWN_HALT_BARS: usize = 10; // Shorter cooldown: 10 bars to resume after halt // pub const MAX_DRAWDOWN_HALT: f64 = 0.15;
// pub const DRAWDOWN_HALT_BARS: usize = 10;
// Time-based exit // 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 REENTRY_COOLDOWN_BARS: usize = 5; // Shorter cooldown
pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up 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 // Backtester slippage
pub const SLIPPAGE_BPS: f64 = 10.0; pub const SLIPPAGE_BPS: f64 = 10.0;
// Trading intervals // Trading intervals

View File

@@ -1,8 +1,7 @@
//! Technical indicator calculations. //! Technical indicator calculations.
use crate::config::{ use crate::config::{
IndicatorParams, ADX_RANGE_THRESHOLD, ADX_STRONG, ADX_TREND_THRESHOLD, BB_STD, IndicatorParams, ADX_TREND_THRESHOLD, BB_STD, VOLUME_THRESHOLD,
RSI2_OVERBOUGHT, RSI2_OVERSOLD, RSI_OVERBOUGHT, RSI_OVERSOLD, VOLUME_THRESHOLD,
}; };
use crate::types::{Bar, IndicatorRow, Signal, TradeSignal}; use crate::types::{Bar, IndicatorRow, Signal, TradeSignal};
@@ -423,25 +422,78 @@ pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<I
rows rows
} }
/// Generate trading signal using regime-adaptive dual strategy. /// Determine the broad market regime from SPY indicator data.
/// ///
/// REGIME DETECTION (via ADX): /// This is the single most important risk filter in the system. During the
/// - ADX < 20: Range-bound → use Connors RSI-2 mean reversion /// 2020 COVID crash (SPY fell ~34% in 23 trading days) and the 2022 bear
/// - ADX > 25: Trending → use momentum pullback entries /// market (SPY fell ~25% over 9 months), SPY spent the majority of those
/// - 20-25: Transition → require extra confirmation /// 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): /// The three regimes map to position-sizing multipliers:
/// - Buy when RSI-2 < 10 AND price above 200 EMA (long-term uptrend filter) /// - Bull (SPY > EMA-200, EMA-50 > EMA-200): full size, normal thresholds
/// - Sell when RSI-2 > 90 (take profit at mean) /// - Caution (SPY < EMA-50, SPY > EMA-200): half size, raised thresholds
/// - Bollinger Band extremes add conviction /// - 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): /// This replaces the previous additive "indicator soup" approach. The academic
/// - Buy pullbacks in uptrends: RSI-14 dips + EMA support + MACD confirming /// evidence for momentum is robust (Jegadeesh & Titman 1993, Moskowitz et al.
/// - Sell when trend breaks: EMA crossover down + momentum loss /// 2012, Asness et al. 2013 "Value and Momentum Everywhere"). Rather than
/// - Strong trend bonus for high ADX /// 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 { pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &IndicatorRow) -> TradeSignal {
let rsi = current.rsi; let rsi = current.rsi;
let rsi2 = current.rsi_short;
let macd_hist = current.macd_histogram; let macd_hist = current.macd_histogram;
let momentum = current.momentum; let momentum = current.momentum;
let ema_short = current.ema_short; let ema_short = current.ema_short;
@@ -451,166 +503,134 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
// Safe NaN handling // Safe NaN handling
let trend_bullish = current.trend_bullish; let trend_bullish = current.trend_bullish;
let volume_ratio = if current.volume_ratio.is_nan() { 1.0 } else { current.volume_ratio }; 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_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 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 // EMA state
let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long; 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 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;
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 buy_score: f64 = 0.0;
let mut sell_score: f64 = 0.0; let mut sell_score: f64 = 0.0;
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// REGIME 1: MEAN REVERSION (ranging market, ADX < 20) // BUY LOGIC: Hierarchical filter (all gates must pass)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
if is_ranging {
// Connors RSI-2 mean reversion: buy extreme oversold in uptrend context // GATE 1: Trend must be confirmed (price > EMA-trend AND EMA alignment)
if !rsi2.is_nan() { // Without this, no buy signal at all. This is the Faber (2007) filter
// Buy: RSI-2 extremely oversold + long-term trend intact // that alone produces positive risk-adjusted returns.
if rsi2 < RSI2_OVERSOLD { if trend_bullish && ema_bullish {
buy_score += 5.0; // Strong mean reversion signal // GATE 2: Positive time-series momentum (Moskowitz et al. 2012)
if trend_bullish { if has_momentum {
buy_score += 3.0; // With-trend mean reversion = highest conviction // Base score for being in a confirmed uptrend with positive momentum
} buy_score += 4.0;
if bb_pct < 0.05 {
buy_score += 2.0; // Price at/below lower BB // TIMING: RSI-14 pullback in uptrend (the "buy the dip" pattern)
} // RSI 30-50 means price has pulled back but trend is intact.
} else if rsi2 < 20.0 { // This is the most robust single-stock entry timing signal.
buy_score += 2.5; if !rsi.is_nan() && rsi >= 30.0 && rsi <= 50.0 {
if trend_bullish { 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; buy_score += 1.5;
} }
} }
// Sell: RSI-2 overbought = take profit on mean reversion // Volume confirmation: above-average volume = institutional interest
if rsi2 > RSI2_OVERBOUGHT { if volume_ratio >= VOLUME_THRESHOLD {
sell_score += 4.0; buy_score += 0.5;
if !trend_bullish { } else {
sell_score += 2.0; // Low volume = less reliable, reduce score
} buy_score *= 0.7;
} else if rsi2 > 80.0 && !trend_bullish {
sell_score += 2.0;
} }
}
// Bollinger Band extremes in range // Strong momentum bonus (ROC > 10% = strong trend)
if bb_pct < 0.0 { if momentum > 10.0 {
buy_score += 2.0; // Below lower band buy_score += 1.0;
} 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 {
sell_score += 1.5;
} }
} }
} }
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// UNIVERSAL SIGNALS (both regimes) // SELL LOGIC: Exit when trend breaks or momentum reverses
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// RSI-14 extremes (strong conviction regardless of regime) // CRITICAL SELL: Trend break — price drops below EMA-trend
if !rsi.is_nan() { // This is the single most important exit signal. When the long-term
if rsi < RSI_OVERSOLD && trend_bullish { // trend breaks, the position has no structural support.
buy_score += 3.0; // Oversold in uptrend = strong buy if !trend_bullish {
} else if rsi > RSI_OVERBOUGHT && !trend_bullish { sell_score += 4.0;
sell_score += 3.0; // Overbought in downtrend = strong sell
}
}
// MACD crossover // If also EMA death cross, very strong sell
if macd_crossed_up { if !ema_bullish {
buy_score += 2.0; sell_score += 2.0;
if is_trending && trend_up {
buy_score += 1.0; // Trend-confirming crossover
} }
} else if macd_crossed_down {
sell_score += 2.0; // Momentum confirming the breakdown
if is_trending && !trend_up { if !momentum.is_nan() && momentum < -5.0 {
sell_score += 2.0;
} else if !momentum.is_nan() && momentum < 0.0 {
sell_score += 1.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;
}
// MACD histogram direction // Momentum has reversed significantly (still above EMA-trend though)
if !macd_hist.is_nan() { if !momentum.is_nan() && momentum < -10.0 {
if macd_hist > 0.0 { buy_score += 0.5; } sell_score += 3.0;
else if macd_hist < 0.0 { sell_score += 0.5; } } else if !momentum.is_nan() && momentum < -5.0 {
} sell_score += 1.5;
}
// Momentum // MACD crossed down = momentum decelerating
if !momentum.is_nan() { let macd_crossed_down = !previous.macd.is_nan()
if momentum > 5.0 { buy_score += 1.5; } && !previous.macd_signal.is_nan()
else if momentum > 2.0 { buy_score += 0.5; } && !current.macd.is_nan()
else if momentum < -5.0 { sell_score += 1.5; } && !current.macd_signal.is_nan()
else if momentum < -2.0 { sell_score += 0.5; } && previous.macd > previous.macd_signal
} && current.macd < current.macd_signal;
if macd_crossed_down {
sell_score += 2.0;
}
// EMA crossover events // RSI extremely overbought (>80) in deteriorating momentum
let prev_ema_bullish = !previous.ema_short.is_nan() if !rsi.is_nan() && rsi > 80.0 && !momentum.is_nan() && momentum < 5.0 {
&& !previous.ema_long.is_nan() sell_score += 1.5;
&& 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;
} }
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -630,7 +650,9 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
Signal::Hold 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 { TradeSignal {
symbol: symbol.to_string(), symbol: symbol.to_string(),

View File

@@ -64,4 +64,12 @@ lazy_static! {
path.push("trading_bot.log"); path.push("trading_bot.log");
path 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( pub fn calculate_position_size(
&self, &self,
price: f64, price: f64,
@@ -42,8 +48,9 @@ impl Strategy {
let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER; let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER;
let risk_amount = portfolio_value * RISK_PER_TRADE; let risk_amount = portfolio_value * RISK_PER_TRADE;
let vol_adjusted = risk_amount / atr_stop_pct; let vol_adjusted = risk_amount / atr_stop_pct;
// Scale by confidence // Wide confidence scaling: 0.4x for weak signals, 1.0x for strongest.
let confidence_scale = 0.7 + 0.3 * signal.confidence; // 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; let sized = vol_adjusted * confidence_scale;
sized.min(portfolio_value * MAX_POSITION_SIZE) sized.min(portfolio_value * MAX_POSITION_SIZE)
} else { } else {
@@ -51,12 +58,26 @@ impl Strategy {
}; };
let position_value = position_value.min(available_cash); 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. // Truncate to 4 decimal places to avoid floating point dust.
((position_value / price) * 10000.0).floor() / 10000.0 ((position_value / price) * 10000.0).floor() / 10000.0
} }
/// Check if stop-loss, trailing stop, or time exit should trigger. /// 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( pub fn check_stop_loss_take_profit(
&mut self, &mut self,
symbol: &str, 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 { if pnl_pct <= -MAX_LOSS_PCT {
return Some(Signal::StrongSell); return Some(Signal::StrongSell);
} }
// ATR-based stop loss // 2. ATR-based initial stop-loss (primary risk control)
if entry_atr > 0.0 { if entry_atr > 0.0 {
let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr; let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr;
if current_price <= atr_stop_price { if current_price <= atr_stop_price {
return Some(Signal::StrongSell); return Some(Signal::StrongSell);
} }
} else if pnl_pct <= -STOP_LOSS_PCT { } else if pnl_pct <= -STOP_LOSS_PCT {
// 3. Fixed percentage fallback
return Some(Signal::StrongSell); return Some(Signal::StrongSell);
} }
// Time-based exit // 4. ATR-based trailing stop (profit protection)
if bars_held >= TIME_EXIT_BARS { // Activates earlier than before (1.5x ATR gain) so profits are locked in.
let activation = if entry_atr > 0.0 { // Distance is wider (2.5x ATR from HWM) so normal retracements don't trigger it.
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else {
TRAILING_STOP_ACTIVATION
};
if pnl_pct < activation {
return Some(Signal::Sell);
}
}
// ATR-based trailing stop
let activation_gain = if entry_atr > 0.0 { let activation_gain = if entry_atr > 0.0 {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else { } 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 None
} }

View File

@@ -3,6 +3,34 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; 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. /// Trading signal types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -110,7 +138,7 @@ pub struct EquityPoint {
} }
/// OHLCV bar data. /// OHLCV bar data.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bar { pub struct Bar {
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
pub open: f64, pub open: f64,