just a checkpoint

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

View File

@@ -1,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(),