Files
vibe-invest/src/indicators.rs
2026-02-13 17:53:11 +00:00

673 lines
24 KiB
Rust

//! 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<f64> {
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::<f64>() / 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<f64> {
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<f64> {
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::<f64>() / period as f64;
let variance: f64 = window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
std[i] = variance.sqrt();
}
std
}
/// Calculate Relative Strength Index (RSI).
pub fn calculate_rsi(closes: &[f64], period: usize) -> Vec<f64> {
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::<f64>() / period as f64;
let mut avg_loss: f64 = losses[1..=period].iter().sum::<f64>() / 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<f64>, Vec<f64>, Vec<f64>) {
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<f64> = 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<f64> {
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<f64> {
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<f64> {
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::<f64>() / 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<f64>, Vec<f64>, Vec<f64>) {
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::<f64>()
/ 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<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
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<IndicatorRow> {
if bars.is_empty() {
return vec![];
}
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
let highs: Vec<f64> = bars.iter().map(|b| b.high).collect();
let lows: Vec<f64> = bars.iter().map(|b| b.low).collect();
let volumes: Vec<f64> = 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 },
}
}