just a checkpoint
This commit is contained in:
@@ -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
|
||||||
|
|||||||
152
src/alpaca.rs
152
src/alpaca.rs
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¤t_prices);
|
let portfolio_value = self.get_portfolio_value(¤t_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",
|
||||||
|
|||||||
219
src/bot.rs
219
src/bot.rs
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
src/types.rs
30
src/types.rs
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user