673 lines
24 KiB
Rust
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 },
|
|
}
|
|
}
|