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