//! Technical indicator calculations. use crate::config::{ IndicatorParams, ADX_TREND_THRESHOLD, BB_STD, VOLUME_THRESHOLD, }; use crate::types::{Bar, IndicatorRow, Signal, TradeSignal}; /// Calculate Exponential Moving Average (EMA). pub fn calculate_ema(data: &[f64], period: usize) -> Vec { if data.is_empty() || period == 0 { return vec![]; } let mut ema = vec![f64::NAN; data.len()]; let multiplier = 2.0 / (period as f64 + 1.0); // Start with SMA for the first period values if data.len() >= period { let sma: f64 = data[..period].iter().sum::() / period as f64; ema[period - 1] = sma; for i in period..data.len() { ema[i] = (data[i] - ema[i - 1]) * multiplier + ema[i - 1]; } } ema } /// Calculate Simple Moving Average (SMA). pub fn calculate_sma(data: &[f64], period: usize) -> Vec { if data.is_empty() || period == 0 { return vec![]; } let mut sma = vec![f64::NAN; data.len()]; for i in (period - 1)..data.len() { let sum: f64 = data[(i + 1 - period)..=i].iter().sum(); sma[i] = sum / period as f64; } sma } /// Calculate standard deviation over a rolling window. pub fn calculate_rolling_std(data: &[f64], period: usize) -> Vec { if data.is_empty() || period == 0 { return vec![]; } let mut std = vec![f64::NAN; data.len()]; for i in (period - 1)..data.len() { let window = &data[(i + 1 - period)..=i]; let mean: f64 = window.iter().sum::() / period as f64; let variance: f64 = window.iter().map(|x| (x - mean).powi(2)).sum::() / period as f64; std[i] = variance.sqrt(); } std } /// Calculate Relative Strength Index (RSI). pub fn calculate_rsi(closes: &[f64], period: usize) -> Vec { if closes.len() < 2 || period == 0 { return vec![f64::NAN; closes.len()]; } let mut rsi = vec![f64::NAN; closes.len()]; // Calculate price changes let mut gains = vec![0.0; closes.len()]; let mut losses = vec![0.0; closes.len()]; for i in 1..closes.len() { let change = closes[i] - closes[i - 1]; if change > 0.0 { gains[i] = change; } else { losses[i] = -change; } } // Calculate initial average gain/loss if closes.len() > period { let mut avg_gain: f64 = gains[1..=period].iter().sum::() / period as f64; let mut avg_loss: f64 = losses[1..=period].iter().sum::() / period as f64; if avg_loss == 0.0 { rsi[period] = 100.0; } else { let rs = avg_gain / avg_loss; rsi[period] = 100.0 - (100.0 / (1.0 + rs)); } // Smoothed RSI calculation for i in (period + 1)..closes.len() { avg_gain = (avg_gain * (period - 1) as f64 + gains[i]) / period as f64; avg_loss = (avg_loss * (period - 1) as f64 + losses[i]) / period as f64; if avg_loss == 0.0 { rsi[i] = 100.0; } else { let rs = avg_gain / avg_loss; rsi[i] = 100.0 - (100.0 / (1.0 + rs)); } } } rsi } /// Calculate MACD (Moving Average Convergence Divergence). pub fn calculate_macd( closes: &[f64], fast_period: usize, slow_period: usize, signal_period: usize, ) -> (Vec, Vec, Vec) { let fast_ema = calculate_ema(closes, fast_period); let slow_ema = calculate_ema(closes, slow_period); let mut macd_line = vec![f64::NAN; closes.len()]; for i in 0..closes.len() { if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() { macd_line[i] = fast_ema[i] - slow_ema[i]; } } // Calculate signal line from MACD line (excluding NaN values) let valid_macd: Vec = macd_line.iter().copied().filter(|x| !x.is_nan()).collect(); let signal_ema = calculate_ema(&valid_macd, signal_period); // Map signal EMA back to original indices let mut signal_line = vec![f64::NAN; closes.len()]; let mut valid_idx = 0; for i in 0..closes.len() { if !macd_line[i].is_nan() { if valid_idx < signal_ema.len() { signal_line[i] = signal_ema[valid_idx]; } valid_idx += 1; } } // Calculate histogram let mut histogram = vec![f64::NAN; closes.len()]; for i in 0..closes.len() { if !macd_line[i].is_nan() && !signal_line[i].is_nan() { histogram[i] = macd_line[i] - signal_line[i]; } } (macd_line, signal_line, histogram) } /// Calculate Rate of Change (Momentum). pub fn calculate_roc(closes: &[f64], period: usize) -> Vec { if closes.is_empty() || period == 0 { return vec![]; } let mut roc = vec![f64::NAN; closes.len()]; for i in period..closes.len() { if closes[i - period] != 0.0 { roc[i] = ((closes[i] - closes[i - period]) / closes[i - period]) * 100.0; } } roc } /// Calculate True Range. fn calculate_true_range(highs: &[f64], lows: &[f64], closes: &[f64]) -> Vec { let mut tr = vec![f64::NAN; highs.len()]; if !highs.is_empty() { tr[0] = highs[0] - lows[0]; } for i in 1..highs.len() { let hl = highs[i] - lows[i]; let hc = (highs[i] - closes[i - 1]).abs(); let lc = (lows[i] - closes[i - 1]).abs(); tr[i] = hl.max(hc).max(lc); } tr } /// Calculate Average True Range (ATR). pub fn calculate_atr(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec { let tr = calculate_true_range(highs, lows, closes); // Use Wilder's smoothing (similar to EMA but with different multiplier) let mut atr = vec![f64::NAN; tr.len()]; if tr.len() >= period { // First ATR is simple average let first_atr: f64 = tr[..period].iter().filter(|x| !x.is_nan()).sum::() / period as f64; atr[period - 1] = first_atr; // Subsequent ATR values use smoothing for i in period..tr.len() { atr[i] = (atr[i - 1] * (period - 1) as f64 + tr[i]) / period as f64; } } atr } /// Calculate ADX (Average Directional Index) along with DI+ and DI-. pub fn calculate_adx( highs: &[f64], lows: &[f64], closes: &[f64], period: usize, ) -> (Vec, Vec, Vec) { let len = highs.len(); if len < 2 { return (vec![f64::NAN; len], vec![f64::NAN; len], vec![f64::NAN; len]); } let mut plus_dm = vec![0.0; len]; let mut minus_dm = vec![0.0; len]; // Calculate +DM and -DM for i in 1..len { let up_move = highs[i] - highs[i - 1]; let down_move = lows[i - 1] - lows[i]; if up_move > down_move && up_move > 0.0 { plus_dm[i] = up_move; } if down_move > up_move && down_move > 0.0 { minus_dm[i] = down_move; } } let tr = calculate_true_range(highs, lows, closes); // Smooth the values using Wilder's smoothing let mut smoothed_plus_dm = vec![f64::NAN; len]; let mut smoothed_minus_dm = vec![f64::NAN; len]; let mut smoothed_tr = vec![f64::NAN; len]; if len >= period { smoothed_plus_dm[period - 1] = plus_dm[..period].iter().sum(); smoothed_minus_dm[period - 1] = minus_dm[..period].iter().sum(); smoothed_tr[period - 1] = tr[..period].iter().filter(|x| !x.is_nan()).sum(); for i in period..len { smoothed_plus_dm[i] = smoothed_plus_dm[i - 1] - (smoothed_plus_dm[i - 1] / period as f64) + plus_dm[i]; smoothed_minus_dm[i] = smoothed_minus_dm[i - 1] - (smoothed_minus_dm[i - 1] / period as f64) + minus_dm[i]; smoothed_tr[i] = smoothed_tr[i - 1] - (smoothed_tr[i - 1] / period as f64) + tr[i]; } } // Calculate DI+ and DI- let mut di_plus = vec![f64::NAN; len]; let mut di_minus = vec![f64::NAN; len]; for i in 0..len { if !smoothed_tr[i].is_nan() && smoothed_tr[i] != 0.0 { di_plus[i] = (smoothed_plus_dm[i] / smoothed_tr[i]) * 100.0; di_minus[i] = (smoothed_minus_dm[i] / smoothed_tr[i]) * 100.0; } } // Calculate DX let mut dx = vec![f64::NAN; len]; for i in 0..len { if !di_plus[i].is_nan() && !di_minus[i].is_nan() { let di_sum = di_plus[i] + di_minus[i]; if di_sum != 0.0 { dx[i] = ((di_plus[i] - di_minus[i]).abs() / di_sum) * 100.0; } } } // Calculate ADX (smoothed DX) let mut adx = vec![f64::NAN; len]; let adx_start = period * 2 - 1; if len > adx_start { // First ADX is simple average of DX let first_adx: f64 = dx[(period - 1)..adx_start] .iter() .filter(|x| !x.is_nan()) .sum::() / period as f64; adx[adx_start - 1] = first_adx; for i in adx_start..len { if !dx[i].is_nan() { adx[i] = (adx[i - 1] * (period - 1) as f64 + dx[i]) / period as f64; } } } (adx, di_plus, di_minus) } /// Calculate Bollinger Bands. pub fn calculate_bollinger_bands( closes: &[f64], period: usize, std_dev: f64, ) -> (Vec, Vec, Vec, Vec) { let middle = calculate_sma(closes, period); let std = calculate_rolling_std(closes, period); let mut upper = vec![f64::NAN; closes.len()]; let mut lower = vec![f64::NAN; closes.len()]; let mut pct_b = vec![f64::NAN; closes.len()]; for i in 0..closes.len() { if !middle[i].is_nan() && !std[i].is_nan() { upper[i] = middle[i] + std_dev * std[i]; lower[i] = middle[i] - std_dev * std[i]; let band_width = upper[i] - lower[i]; if band_width != 0.0 { pct_b[i] = (closes[i] - lower[i]) / band_width; } } } (upper, middle, lower, pct_b) } /// Calculate all technical indicators for a series of bars. pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec { if bars.is_empty() { return vec![]; } let closes: Vec = bars.iter().map(|b| b.close).collect(); let highs: Vec = bars.iter().map(|b| b.high).collect(); let lows: Vec = bars.iter().map(|b| b.low).collect(); let volumes: Vec = bars.iter().map(|b| b.volume).collect(); // Calculate all indicators let rsi = calculate_rsi(&closes, params.rsi_period); let rsi_short = calculate_rsi(&closes, params.rsi_short_period); let (macd, macd_signal, macd_histogram) = calculate_macd(&closes, params.macd_fast, params.macd_slow, params.macd_signal); let momentum = calculate_roc(&closes, params.momentum_period); let ema_short = calculate_ema(&closes, params.ema_short); let ema_long = calculate_ema(&closes, params.ema_long); let ema_trend = calculate_ema(&closes, params.ema_trend); let atr = calculate_atr(&highs, &lows, &closes, params.atr_period); let (adx, di_plus, di_minus) = calculate_adx(&highs, &lows, &closes, params.adx_period); let (bb_upper, bb_middle, bb_lower, bb_pct) = calculate_bollinger_bands(&closes, params.bb_period, BB_STD); let volume_ma = calculate_sma(&volumes, params.volume_ma_period); // Build indicator rows let mut rows = Vec::with_capacity(bars.len()); for i in 0..bars.len() { let bar = &bars[i]; let vol_ratio = if !volume_ma[i].is_nan() && volume_ma[i] != 0.0 { bar.volume / volume_ma[i] } else { f64::NAN }; let ema_dist = if !ema_trend[i].is_nan() && ema_trend[i] != 0.0 { (bar.close - ema_trend[i]) / ema_trend[i] } else { f64::NAN }; let atr_pct_val = if !atr[i].is_nan() && bar.close != 0.0 { atr[i] / bar.close } else { f64::NAN }; rows.push(IndicatorRow { timestamp: bar.timestamp, open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume, rsi: rsi[i], rsi_short: rsi_short[i], macd: macd[i], macd_signal: macd_signal[i], macd_histogram: macd_histogram[i], momentum: momentum[i], ema_short: ema_short[i], ema_long: ema_long[i], ema_trend: ema_trend[i], ema_bullish: !ema_short[i].is_nan() && !ema_long[i].is_nan() && ema_short[i] > ema_long[i], trend_bullish: !ema_trend[i].is_nan() && bar.close > ema_trend[i], atr: atr[i], atr_pct: atr_pct_val, adx: adx[i], di_plus: di_plus[i], di_minus: di_minus[i], bb_upper: bb_upper[i], bb_middle: bb_middle[i], bb_lower: bb_lower[i], bb_pct: bb_pct[i], volume_ma: volume_ma[i], volume_ratio: vol_ratio, ema_distance: ema_dist, }); } rows } /// Determine the broad market regime from SPY indicator data. /// /// 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. /// /// 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. /// /// 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 macd_hist = current.macd_histogram; let momentum = current.momentum; let ema_short = current.ema_short; let ema_long = current.ema_long; let current_price = current.close; // 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() { 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 }; // 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; let has_momentum = !momentum.is_nan() && momentum > 0.0; let mut buy_score: f64 = 0.0; let mut sell_score: f64 = 0.0; // ═══════════════════════════════════════════════════════════════ // BUY LOGIC: Hierarchical filter (all gates must pass) // ═══════════════════════════════════════════════════════════════ // GATE 1: Trend must be confirmed (price > EMA-trend AND EMA alignment) // Without this, no buy signal at all. This is the Faber (2007) filter // that alone produces positive risk-adjusted returns. if trend_bullish && ema_bullish { // GATE 2: Positive time-series momentum (Moskowitz et al. 2012) if has_momentum { // Base score for being in a confirmed uptrend with positive momentum buy_score += 4.0; // TIMING: RSI-14 pullback in uptrend (the "buy the dip" pattern) // Widened to 25-55: in strong uptrends RSI often stays 40-65, // so the old 30-50 window missed many good pullback entries. if !rsi.is_nan() && rsi >= 25.0 && rsi <= 55.0 { buy_score += 3.0; } // Moderate pullback (RSI 55-65) still gets some credit else if !rsi.is_nan() && rsi > 55.0 && rsi <= 65.0 { buy_score += 1.0; } // RSI > 70 = overbought, do not add to buy score (chasing) // CONVICTION BOOSTERS (each adds incremental edge) // Strong directional trend (ADX > 25, DI+ dominant) if adx > ADX_TREND_THRESHOLD && di_plus > di_minus { buy_score += 1.5; } // MACD histogram positive and increasing = accelerating momentum if !macd_hist.is_nan() && macd_hist > 0.0 { buy_score += 1.0; // MACD just crossed up = fresh momentum impulse let macd_crossed_up = !previous.macd.is_nan() && !previous.macd_signal.is_nan() && !current.macd.is_nan() && !current.macd_signal.is_nan() && previous.macd < previous.macd_signal && current.macd > current.macd_signal; if macd_crossed_up { buy_score += 1.5; } } // Volume confirmation: above-average volume = institutional interest if volume_ratio >= VOLUME_THRESHOLD { buy_score += 0.5; } else { // Low volume = less reliable, reduce score buy_score *= 0.7; } // Strong momentum bonus (ROC > 10% = strong trend) if momentum > 10.0 { buy_score += 1.0; } } } // ═══════════════════════════════════════════════════════════════ // SELL LOGIC: Exit when trend breaks or momentum reverses // ═══════════════════════════════════════════════════════════════ // CRITICAL SELL: Trend break — price drops below EMA-trend // This is the single most important exit signal. When the long-term // trend breaks, the position has no structural support. if !trend_bullish { sell_score += 4.0; // If also EMA death cross, very strong sell if !ema_bullish { sell_score += 2.0; } // Momentum confirming the breakdown if !momentum.is_nan() && momentum < -5.0 { sell_score += 2.0; } else if !momentum.is_nan() && momentum < 0.0 { sell_score += 1.0; } } // Trend still intact but showing weakness else { // EMA death cross while still above trend EMA = early warning if !ema_bullish && prev_ema_bullish { sell_score += 3.0; } // Momentum has reversed significantly (still above EMA-trend though) if !momentum.is_nan() && momentum < -10.0 { sell_score += 3.0; } else if !momentum.is_nan() && momentum < -5.0 { sell_score += 1.5; } // MACD crossed down = momentum decelerating let macd_crossed_down = !previous.macd.is_nan() && !previous.macd_signal.is_nan() && !current.macd.is_nan() && !current.macd_signal.is_nan() && previous.macd > previous.macd_signal && current.macd < current.macd_signal; if macd_crossed_down { sell_score += 2.0; } // RSI extremely overbought (>80) in deteriorating momentum if !rsi.is_nan() && rsi > 80.0 && !momentum.is_nan() && momentum < 5.0 { sell_score += 1.5; } } // ═══════════════════════════════════════════════════════════════ // SIGNAL DETERMINATION // ═══════════════════════════════════════════════════════════════ let total_score = buy_score - sell_score; let signal = if total_score >= 7.0 { Signal::StrongBuy } else if total_score >= 4.0 { Signal::Buy } else if total_score <= -7.0 { Signal::StrongSell } else if total_score <= -4.0 { Signal::Sell } else { Signal::Hold }; // 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(), signal, rsi: if rsi.is_nan() { 0.0 } else { rsi }, macd: if current.macd.is_nan() { 0.0 } else { current.macd }, macd_signal: if current.macd_signal.is_nan() { 0.0 } else { current.macd_signal }, macd_histogram: if macd_hist.is_nan() { 0.0 } else { macd_hist }, momentum: if momentum.is_nan() { 0.0 } else { momentum }, ema_short: if ema_short.is_nan() { 0.0 } else { ema_short }, ema_long: if ema_long.is_nan() { 0.0 } else { ema_long }, current_price, confidence, atr: if current.atr.is_nan() { 0.0 } else { current.atr }, atr_pct: if current.atr_pct.is_nan() { 0.0 } else { current.atr_pct }, } }