Replace _param suppression pattern with actual usage across 21 files: - adaptive-strategy: wire EpistemicConfig/AleatoricConfig into UncertaintyQuantifier, KellyConfig into DrawdownTracker, TLOBConfig into TLOBTransformer - trading_engine/compliance: store config in 26 compliance structs (audit_trails, best_execution, sox, iso27001, transaction_reporting, compliance_reporting, automated_reporting) with public accessors - fxt: store Channel in LoginClient, ConnectionConfig in ConnectionManager - ml: remove unused path param from ReplayBuffer::new(), wire Mamba2Config.target_latency_us into HardwareOptimizer - services: store TrainingConfig in GpuConfigManager, symbol in TechnicalIndicatorCalculator - database: change let _result to let _ (intentional discard) - trading_engine/brokers: store BrokerConnectorConfig in BrokerConnector Result: 0 warnings across all 37+ workspace crates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1044 lines
35 KiB
Rust
1044 lines
35 KiB
Rust
//! Technical Indicator Calculations for ML Feature Extraction
|
||
//!
|
||
//! This module provides stateful, high-performance technical indicator calculations
|
||
//! for ML training data pipelines. All indicators support incremental updates with
|
||
//! O(1) amortized complexity for HFT requirements.
|
||
//!
|
||
//! ## Supported Indicators (36 Total)
|
||
//!
|
||
//! ### Original Indicators (16)
|
||
//! - **RSI (Relative Strength Index)**: 14-period momentum oscillator (0-100 range)
|
||
//! - **EMA (Exponential Moving Average)**: Fast (12) and slow (26) EMAs
|
||
//! - **MACD (Moving Average Convergence Divergence)**: Trend following indicator
|
||
//! - **Bollinger Bands**: Volatility indicator with SMA ± 2σ (middle, upper, lower, width)
|
||
//! - **ATR (Average True Range)**: Volatility measurement
|
||
//!
|
||
//! ### NEW Momentum Indicators (3)
|
||
//! - **MFI (Money Flow Index)**: Volume-weighted RSI (0-100, overbought >80, oversold <20)
|
||
//! - **CMF (Chaikin Money Flow)**: Accumulation/distribution indicator (-1 to +1, buying pressure)
|
||
//! - **Chaikin Oscillator**: Fast/slow EMA of A/D Line (trend strength)
|
||
//!
|
||
//! ### NEW Volatility Indicators (8)
|
||
//! - **Keltner Channels**: EMA ± ATR multiplier (middle, upper, lower, width)
|
||
//! - **Donchian Channels**: Highest high/lowest low (middle, upper, lower, width)
|
||
//!
|
||
//! ### NEW Volume Indicators (5)
|
||
//! - **OBV (On-Balance Volume)**: Cumulative volume direction indicator
|
||
//! - **VWAP (Volume-Weighted Average Price)**: Intraday benchmark price
|
||
//! - **VWAP Deviation**: Price distance from VWAP (%)
|
||
//! - **Volume Oscillator**: Fast/slow EMA of volume (%)
|
||
//!
|
||
//! ### Feature Count Breakdown
|
||
//! - OHLCV: 5 features (open, high, low, close, volume)
|
||
//! - Original indicators: 11 features (RSI, EMA×2, MACD×3, Bollinger×4, ATR)
|
||
//! - New momentum: 3 features (MFI, CMF, Chaikin)
|
||
//! - New volatility: 8 features (Keltner×4, Donchian×4)
|
||
//! - New volume: 4 features (OBV, VWAP, VWAP deviation, Vol Oscillator)
|
||
//! - Time-based: 5 features (hour, day, month, is_market_hours, time_since_open)
|
||
//! **Total: 36 features** (16 → 36, +125% increase)
|
||
//!
|
||
//! ## Architecture
|
||
//!
|
||
//! ```rust
|
||
//! use ml_training_service::technical_indicators::TechnicalIndicatorCalculator;
|
||
//! use ml_training_service::technical_indicators::IndicatorConfig;
|
||
//!
|
||
//! let config = IndicatorConfig::default();
|
||
//! let mut calculator = TechnicalIndicatorCalculator::new("AAPL".to_string(), config);
|
||
//!
|
||
//! // Feed price data
|
||
//! calculator.update(100.0, 50000.0); // price, volume
|
||
//!
|
||
//! // Extract indicators
|
||
//! let indicators = calculator.current_indicators();
|
||
//! ```
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
/// Configuration for technical indicator calculations
|
||
#[derive(Debug, Clone)]
|
||
pub struct IndicatorConfig {
|
||
/// `RSI` period (default: 14)
|
||
pub rsi_period: usize,
|
||
/// Fast `EMA` period (default: 12)
|
||
pub ema_fast_period: usize,
|
||
/// Slow `EMA` period (default: 26)
|
||
pub ema_slow_period: usize,
|
||
/// `MACD` signal line period (default: 9)
|
||
pub macd_signal_period: usize,
|
||
/// Bollinger Bands period (default: 20)
|
||
pub bollinger_period: usize,
|
||
/// Bollinger Bands standard deviations (default: 2.0)
|
||
pub bollinger_std_dev: f64,
|
||
/// `ATR` period (default: 14)
|
||
pub atr_period: usize,
|
||
/// Minimum data points before calculating indicators
|
||
pub warmup_period: usize,
|
||
|
||
// === NEW MOMENTUM INDICATORS ===
|
||
/// Money Flow Index period (default: 14)
|
||
pub mfi_period: usize,
|
||
/// Chaikin Money Flow period (default: 20)
|
||
pub cmf_period: usize,
|
||
/// Chaikin Oscillator fast/slow periods (default: 3, 10)
|
||
pub chaikin_fast_period: usize,
|
||
pub chaikin_slow_period: usize,
|
||
|
||
// === NEW VOLATILITY INDICATORS ===
|
||
/// Keltner Channel period (default: 20)
|
||
pub keltner_period: usize,
|
||
/// Keltner Channel multiplier (default: 2.0)
|
||
pub keltner_multiplier: f64,
|
||
/// Donchian Channel period (default: 20)
|
||
pub donchian_period: usize,
|
||
|
||
// === NEW VOLUME INDICATORS ===
|
||
/// VWAP reset period in bars (default: 390 for daily)
|
||
pub vwap_reset_period: usize,
|
||
/// Volume Oscillator fast/slow periods (default: 5, 10)
|
||
pub vol_osc_fast_period: usize,
|
||
pub vol_osc_slow_period: usize,
|
||
}
|
||
|
||
impl Default for IndicatorConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
rsi_period: 14,
|
||
ema_fast_period: 12,
|
||
ema_slow_period: 26,
|
||
macd_signal_period: 9,
|
||
bollinger_period: 20,
|
||
bollinger_std_dev: 2.0,
|
||
atr_period: 14,
|
||
warmup_period: 26, // Max of all periods for full indicator calculation
|
||
|
||
// New momentum indicators
|
||
mfi_period: 14,
|
||
cmf_period: 20,
|
||
chaikin_fast_period: 3,
|
||
chaikin_slow_period: 10,
|
||
|
||
// New volatility indicators
|
||
keltner_period: 20,
|
||
keltner_multiplier: 2.0,
|
||
donchian_period: 20,
|
||
|
||
// New volume indicators
|
||
vwap_reset_period: 390, // ~1 trading day (6.5 hours)
|
||
vol_osc_fast_period: 5,
|
||
vol_osc_slow_period: 10,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Stateful technical indicator calculator
|
||
///
|
||
/// Maintains rolling windows and state for efficient indicator calculation.
|
||
///
|
||
/// Designed for O(1) amortized updates with minimal allocations.
|
||
pub struct TechnicalIndicatorCalculator {
|
||
/// Trading symbol this calculator tracks
|
||
symbol: String,
|
||
|
||
/// Configuration
|
||
config: IndicatorConfig,
|
||
|
||
/// Price history for windowed calculations
|
||
price_history: VecDeque<f64>,
|
||
|
||
/// Volume history
|
||
volume_history: VecDeque<f64>,
|
||
|
||
/// High prices for ATR
|
||
high_history: VecDeque<f64>,
|
||
|
||
/// Low prices for ATR
|
||
low_history: VecDeque<f64>,
|
||
|
||
/// `RSI` internal state
|
||
rsi_state: RsiState,
|
||
|
||
/// `EMA` states
|
||
ema_fast_state: Option<f64>,
|
||
ema_slow_state: Option<f64>,
|
||
|
||
/// `MACD` signal line state
|
||
macd_signal_state: Option<f64>,
|
||
|
||
/// `ATR` state
|
||
atr_state: Option<f64>,
|
||
|
||
/// Total updates received
|
||
update_count: usize,
|
||
|
||
// === NEW INDICATOR STATES ===
|
||
/// Money Flow Index state
|
||
mfi_state: MFIState,
|
||
|
||
/// Chaikin Money Flow accumulator
|
||
cmf_accumulator: f64,
|
||
cmf_volume_sum: f64,
|
||
|
||
/// Accumulation/Distribution Line for Chaikin Oscillator
|
||
ad_line: f64,
|
||
chaikin_fast_ema: Option<f64>,
|
||
chaikin_slow_ema: Option<f64>,
|
||
|
||
/// Donchian Channel state (highest high, lowest low)
|
||
donchian_highs: VecDeque<f64>,
|
||
donchian_lows: VecDeque<f64>,
|
||
|
||
/// On-Balance Volume state
|
||
obv: f64,
|
||
|
||
/// VWAP state (cumulative price * volume, cumulative volume)
|
||
vwap_cumulative_pv: f64,
|
||
vwap_cumulative_volume: f64,
|
||
vwap_bar_count: usize,
|
||
|
||
/// Volume EMA states for Volume Oscillator
|
||
vol_ema_fast: Option<f64>,
|
||
vol_ema_slow: Option<f64>,
|
||
}
|
||
|
||
/// `RSI` calculator state using Wilder's smoothing
|
||
#[derive(Debug, Clone)]
|
||
struct RsiState {
|
||
/// Average gain (Wilder's smoothed)
|
||
avg_gain: f64,
|
||
/// Average loss (Wilder's smoothed)
|
||
avg_loss: f64,
|
||
/// Previous price for change calculation
|
||
prev_price: Option<f64>,
|
||
/// Initialization complete flag
|
||
initialized: bool,
|
||
}
|
||
|
||
impl Default for RsiState {
|
||
fn default() -> Self {
|
||
Self {
|
||
avg_gain: 0.0,
|
||
avg_loss: 0.0,
|
||
prev_price: None,
|
||
initialized: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Money Flow Index state
|
||
#[derive(Debug, Clone)]
|
||
struct MFIState {
|
||
/// Positive money flow sum
|
||
positive_mf: f64,
|
||
/// Negative money flow sum
|
||
negative_mf: f64,
|
||
/// Previous typical price
|
||
prev_typical_price: Option<f64>,
|
||
/// Initialization complete
|
||
initialized: bool,
|
||
}
|
||
|
||
impl Default for MFIState {
|
||
fn default() -> Self {
|
||
Self {
|
||
positive_mf: 0.0,
|
||
negative_mf: 0.0,
|
||
prev_typical_price: None,
|
||
initialized: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl TechnicalIndicatorCalculator {
|
||
/// Get the trading symbol this calculator is tracking
|
||
pub fn symbol(&self) -> &str {
|
||
&self.symbol
|
||
}
|
||
|
||
/// Create a new technical indicator calculator
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `symbol` - Trading symbol
|
||
/// * `config` - Indicator configuration
|
||
pub fn new(symbol: String, config: IndicatorConfig) -> Self {
|
||
let max_window = config
|
||
.warmup_period
|
||
.max(config.bollinger_period)
|
||
.max(config.keltner_period)
|
||
.max(config.donchian_period)
|
||
.max(config.cmf_period);
|
||
|
||
Self {
|
||
symbol,
|
||
config,
|
||
price_history: VecDeque::with_capacity(max_window),
|
||
volume_history: VecDeque::with_capacity(max_window),
|
||
high_history: VecDeque::with_capacity(max_window),
|
||
low_history: VecDeque::with_capacity(max_window),
|
||
rsi_state: RsiState::default(),
|
||
ema_fast_state: None,
|
||
ema_slow_state: None,
|
||
macd_signal_state: None,
|
||
atr_state: None,
|
||
update_count: 0,
|
||
|
||
// Initialize new indicator states
|
||
mfi_state: MFIState::default(),
|
||
cmf_accumulator: 0.0,
|
||
cmf_volume_sum: 0.0,
|
||
ad_line: 0.0,
|
||
chaikin_fast_ema: None,
|
||
chaikin_slow_ema: None,
|
||
donchian_highs: VecDeque::with_capacity(max_window),
|
||
donchian_lows: VecDeque::with_capacity(max_window),
|
||
obv: 0.0,
|
||
vwap_cumulative_pv: 0.0,
|
||
vwap_cumulative_volume: 0.0,
|
||
vwap_bar_count: 0,
|
||
vol_ema_fast: None,
|
||
vol_ema_slow: None,
|
||
}
|
||
}
|
||
|
||
/// Update calculator with new `OHLC` data
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `price` - Current price (close)
|
||
/// * `volume` - Current volume
|
||
///
|
||
/// * `high` - High price (optional, defaults to price)
|
||
/// * `low` - Low price (optional, defaults to price)
|
||
pub fn update(&mut self, price: f64, volume: f64, high: Option<f64>, low: Option<f64>) {
|
||
// Add to history with window management
|
||
self.price_history.push_back(price);
|
||
self.volume_history.push_back(volume);
|
||
self.high_history.push_back(high.unwrap_or(price));
|
||
self.low_history.push_back(low.unwrap_or(price));
|
||
|
||
// Maintain maximum window size
|
||
let max_window = self.config.warmup_period.max(self.config.bollinger_period);
|
||
if self.price_history.len() > max_window {
|
||
self.price_history.pop_front();
|
||
self.volume_history.pop_front();
|
||
self.high_history.pop_front();
|
||
self.low_history.pop_front();
|
||
}
|
||
|
||
self.update_count += 1;
|
||
|
||
// Update stateful indicators incrementally
|
||
self.update_rsi(price);
|
||
self.update_ema(price);
|
||
self.update_atr();
|
||
|
||
// Update new indicators
|
||
self.update_mfi(high.unwrap_or(price), low.unwrap_or(price), price, volume);
|
||
self.update_cmf(high.unwrap_or(price), low.unwrap_or(price), price, volume);
|
||
self.update_chaikin(high.unwrap_or(price), low.unwrap_or(price), price, volume);
|
||
self.update_donchian(high.unwrap_or(price), low.unwrap_or(price));
|
||
self.update_obv(price, volume);
|
||
self.update_vwap(price, volume);
|
||
self.update_volume_oscillator(volume);
|
||
}
|
||
|
||
/// Update `RSI` using Wilder's smoothing method
|
||
fn update_rsi(&mut self, price: f64) {
|
||
if let Some(prev) = self.rsi_state.prev_price {
|
||
let change = price - prev;
|
||
let gain = if change > 0.0 { change } else { 0.0 };
|
||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||
|
||
if !self.rsi_state.initialized && self.update_count >= self.config.rsi_period {
|
||
// Initial average using SMA
|
||
let gains: Vec<f64> = self
|
||
.price_history
|
||
.iter()
|
||
.zip(self.price_history.iter().skip(1))
|
||
.map(|(p1, p2)| {
|
||
let change = p2 - p1;
|
||
if change > 0.0 {
|
||
change
|
||
} else {
|
||
0.0
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let losses: Vec<f64> = self
|
||
.price_history
|
||
.iter()
|
||
.zip(self.price_history.iter().skip(1))
|
||
.map(|(p1, p2)| {
|
||
let change = p2 - p1;
|
||
if change < 0.0 {
|
||
-change
|
||
} else {
|
||
0.0
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
self.rsi_state.avg_gain = gains.iter().sum::<f64>() / self.config.rsi_period as f64;
|
||
self.rsi_state.avg_loss =
|
||
losses.iter().sum::<f64>() / self.config.rsi_period as f64;
|
||
self.rsi_state.initialized = true;
|
||
} else if self.rsi_state.initialized {
|
||
// Wilder's smoothing: avg = (prev_avg * (n-1) + current) / n
|
||
let period = self.config.rsi_period as f64;
|
||
self.rsi_state.avg_gain =
|
||
(self.rsi_state.avg_gain * (period - 1.0) + gain) / period;
|
||
self.rsi_state.avg_loss =
|
||
(self.rsi_state.avg_loss * (period - 1.0) + loss) / period;
|
||
}
|
||
}
|
||
|
||
self.rsi_state.prev_price = Some(price);
|
||
}
|
||
|
||
/// Update `EMA` states incrementally
|
||
fn update_ema(&mut self, price: f64) {
|
||
// Fast EMA (12-period)
|
||
if let Some(ema) = self.ema_fast_state {
|
||
let k = 2.0 / (self.config.ema_fast_period as f64 + 1.0);
|
||
self.ema_fast_state = Some(price * k + ema * (1.0 - k));
|
||
} else if self.update_count >= self.config.ema_fast_period {
|
||
// Initialize with SMA
|
||
let sum: f64 = self
|
||
.price_history
|
||
.iter()
|
||
.rev()
|
||
.take(self.config.ema_fast_period)
|
||
.sum();
|
||
self.ema_fast_state = Some(sum / self.config.ema_fast_period as f64);
|
||
}
|
||
|
||
// Slow EMA (26-period)
|
||
if let Some(ema) = self.ema_slow_state {
|
||
let k = 2.0 / (self.config.ema_slow_period as f64 + 1.0);
|
||
self.ema_slow_state = Some(price * k + ema * (1.0 - k));
|
||
} else if self.update_count >= self.config.ema_slow_period {
|
||
// Initialize with SMA
|
||
let sum: f64 = self
|
||
.price_history
|
||
.iter()
|
||
.rev()
|
||
.take(self.config.ema_slow_period)
|
||
.sum();
|
||
self.ema_slow_state = Some(sum / self.config.ema_slow_period as f64);
|
||
}
|
||
|
||
// MACD signal line (9-period EMA of MACD)
|
||
if let Some(macd) = self.calculate_macd() {
|
||
if let Some(signal) = self.macd_signal_state {
|
||
let k = 2.0 / (self.config.macd_signal_period as f64 + 1.0);
|
||
self.macd_signal_state = Some(macd * k + signal * (1.0 - k));
|
||
} else if self.update_count
|
||
>= self.config.ema_slow_period + self.config.macd_signal_period
|
||
{
|
||
self.macd_signal_state = Some(macd);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Update `ATR` (Average True Range)
|
||
fn update_atr(&mut self) {
|
||
if self.update_count < 2 {
|
||
return;
|
||
}
|
||
|
||
// Calculate True Range
|
||
let Some(&high) = self.high_history.back() else {
|
||
return;
|
||
};
|
||
let Some(&low) = self.low_history.back() else {
|
||
return;
|
||
};
|
||
let len = self.price_history.len();
|
||
let Some(prev_close) = self.price_history.get(len.saturating_sub(2)) else {
|
||
return;
|
||
};
|
||
|
||
let tr = (high - low)
|
||
.max((high - prev_close).abs())
|
||
.max((low - prev_close).abs());
|
||
|
||
// Update ATR using Wilder's smoothing
|
||
if let Some(atr) = self.atr_state {
|
||
let period = self.config.atr_period as f64;
|
||
self.atr_state = Some((atr * (period - 1.0) + tr) / period);
|
||
} else if self.update_count >= self.config.atr_period {
|
||
// Initialize with SMA of TR
|
||
self.atr_state = Some(tr);
|
||
}
|
||
}
|
||
|
||
// ===== NEW INDICATOR UPDATE METHODS =====
|
||
|
||
/// Update Money Flow Index (MFI) - momentum indicator with volume
|
||
fn update_mfi(&mut self, high: f64, low: f64, close: f64, volume: f64) {
|
||
let typical_price = (high + low + close) / 3.0;
|
||
let money_flow = typical_price * volume;
|
||
|
||
if let Some(prev_tp) = self.mfi_state.prev_typical_price {
|
||
if typical_price > prev_tp {
|
||
// Positive money flow
|
||
self.mfi_state.positive_mf = self.mfi_state.positive_mf
|
||
* ((self.config.mfi_period - 1) as f64 / self.config.mfi_period as f64)
|
||
+ money_flow;
|
||
} else if typical_price < prev_tp {
|
||
// Negative money flow
|
||
self.mfi_state.negative_mf = self.mfi_state.negative_mf
|
||
* ((self.config.mfi_period - 1) as f64 / self.config.mfi_period as f64)
|
||
+ money_flow;
|
||
}
|
||
|
||
if self.update_count >= self.config.mfi_period {
|
||
self.mfi_state.initialized = true;
|
||
}
|
||
}
|
||
|
||
self.mfi_state.prev_typical_price = Some(typical_price);
|
||
}
|
||
|
||
/// Update Chaikin Money Flow (CMF) - accumulates money flow over period
|
||
fn update_cmf(&mut self, high: f64, low: f64, close: f64, volume: f64) {
|
||
let range = high - low;
|
||
if range > 0.0 {
|
||
let multiplier = ((close - low) - (high - close)) / range;
|
||
let mf_volume = multiplier * volume;
|
||
|
||
self.cmf_accumulator = self.cmf_accumulator
|
||
* ((self.config.cmf_period - 1) as f64 / self.config.cmf_period as f64)
|
||
+ mf_volume;
|
||
self.cmf_volume_sum = self.cmf_volume_sum
|
||
* ((self.config.cmf_period - 1) as f64 / self.config.cmf_period as f64)
|
||
+ volume;
|
||
}
|
||
}
|
||
|
||
/// Update Chaikin Oscillator (A/D Line with fast/slow EMAs)
|
||
fn update_chaikin(&mut self, high: f64, low: f64, close: f64, volume: f64) {
|
||
// Update Accumulation/Distribution Line
|
||
let range = high - low;
|
||
if range > 0.0 {
|
||
let multiplier = ((close - low) - (high - close)) / range;
|
||
self.ad_line += multiplier * volume;
|
||
}
|
||
|
||
// Fast EMA of A/D Line
|
||
if let Some(ema) = self.chaikin_fast_ema {
|
||
let k = 2.0 / (self.config.chaikin_fast_period as f64 + 1.0);
|
||
self.chaikin_fast_ema = Some(self.ad_line * k + ema * (1.0 - k));
|
||
} else if self.update_count >= self.config.chaikin_fast_period {
|
||
self.chaikin_fast_ema = Some(self.ad_line);
|
||
}
|
||
|
||
// Slow EMA of A/D Line
|
||
if let Some(ema) = self.chaikin_slow_ema {
|
||
let k = 2.0 / (self.config.chaikin_slow_period as f64 + 1.0);
|
||
self.chaikin_slow_ema = Some(self.ad_line * k + ema * (1.0 - k));
|
||
} else if self.update_count >= self.config.chaikin_slow_period {
|
||
self.chaikin_slow_ema = Some(self.ad_line);
|
||
}
|
||
}
|
||
|
||
/// Update Donchian Channels (highest high, lowest low over period)
|
||
fn update_donchian(&mut self, high: f64, low: f64) {
|
||
self.donchian_highs.push_back(high);
|
||
self.donchian_lows.push_back(low);
|
||
|
||
if self.donchian_highs.len() > self.config.donchian_period {
|
||
self.donchian_highs.pop_front();
|
||
self.donchian_lows.pop_front();
|
||
}
|
||
}
|
||
|
||
/// Update On-Balance Volume (OBV)
|
||
fn update_obv(&mut self, price: f64, volume: f64) {
|
||
if let Some(prev_close) = self
|
||
.price_history
|
||
.get(self.price_history.len().saturating_sub(2))
|
||
{
|
||
if price > *prev_close {
|
||
self.obv += volume;
|
||
} else if price < *prev_close {
|
||
self.obv -= volume;
|
||
}
|
||
// If price unchanged, OBV unchanged
|
||
}
|
||
}
|
||
|
||
/// Update VWAP (Volume-Weighted Average Price)
|
||
fn update_vwap(&mut self, price: f64, volume: f64) {
|
||
self.vwap_cumulative_pv += price * volume;
|
||
self.vwap_cumulative_volume += volume;
|
||
self.vwap_bar_count += 1;
|
||
|
||
// Reset VWAP periodically (e.g., daily)
|
||
if self.vwap_bar_count >= self.config.vwap_reset_period {
|
||
self.vwap_cumulative_pv = price * volume;
|
||
self.vwap_cumulative_volume = volume;
|
||
self.vwap_bar_count = 1;
|
||
}
|
||
}
|
||
|
||
/// Update Volume Oscillator (fast/slow EMAs of volume)
|
||
fn update_volume_oscillator(&mut self, volume: f64) {
|
||
// Fast volume EMA
|
||
if let Some(ema) = self.vol_ema_fast {
|
||
let k = 2.0 / (self.config.vol_osc_fast_period as f64 + 1.0);
|
||
self.vol_ema_fast = Some(volume * k + ema * (1.0 - k));
|
||
} else if self.update_count >= self.config.vol_osc_fast_period {
|
||
self.vol_ema_fast = Some(volume);
|
||
}
|
||
|
||
// Slow volume EMA
|
||
if let Some(ema) = self.vol_ema_slow {
|
||
let k = 2.0 / (self.config.vol_osc_slow_period as f64 + 1.0);
|
||
self.vol_ema_slow = Some(volume * k + ema * (1.0 - k));
|
||
} else if self.update_count >= self.config.vol_osc_slow_period {
|
||
self.vol_ema_slow = Some(volume);
|
||
}
|
||
}
|
||
|
||
// ===== NEW INDICATOR CALCULATION METHODS =====
|
||
|
||
/// Calculate Money Flow Index (0-100, overbought >80, oversold <20)
|
||
pub fn calculate_mfi(&self) -> Option<f64> {
|
||
if !self.mfi_state.initialized {
|
||
return None;
|
||
}
|
||
|
||
if self.mfi_state.negative_mf == 0.0 {
|
||
return Some(100.0);
|
||
}
|
||
|
||
let money_ratio = self.mfi_state.positive_mf / self.mfi_state.negative_mf;
|
||
Some(100.0 - (100.0 / (1.0 + money_ratio)))
|
||
}
|
||
|
||
/// Calculate Chaikin Money Flow (-1.0 to 1.0, >0 = buying pressure)
|
||
pub fn calculate_cmf(&self) -> Option<f64> {
|
||
if self.cmf_volume_sum == 0.0 || self.update_count < self.config.cmf_period {
|
||
return None;
|
||
}
|
||
|
||
Some(self.cmf_accumulator / self.cmf_volume_sum)
|
||
}
|
||
|
||
/// Calculate Chaikin Oscillator (fast EMA - slow EMA of A/D Line)
|
||
pub fn calculate_chaikin_oscillator(&self) -> Option<f64> {
|
||
match (self.chaikin_fast_ema, self.chaikin_slow_ema) {
|
||
(Some(fast), Some(slow)) => Some(fast - slow),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Calculate Keltner Channels (EMA ± multiplier * ATR)
|
||
pub fn calculate_keltner_channels(&self) -> Option<(f64, f64, f64)> {
|
||
let ema = self.ema_fast_state?;
|
||
let atr = self.atr_state?;
|
||
|
||
if self.update_count < self.config.keltner_period {
|
||
return None;
|
||
}
|
||
|
||
let upper = ema + self.config.keltner_multiplier * atr;
|
||
let lower = ema - self.config.keltner_multiplier * atr;
|
||
|
||
Some((ema, upper, lower))
|
||
}
|
||
|
||
/// Calculate Donchian Channels (highest high, lowest low, middle)
|
||
pub fn calculate_donchian_channels(&self) -> Option<(f64, f64, f64)> {
|
||
if self.donchian_highs.len() < self.config.donchian_period {
|
||
return None;
|
||
}
|
||
|
||
let highest = self
|
||
.donchian_highs
|
||
.iter()
|
||
.copied()
|
||
.fold(f64::NEG_INFINITY, f64::max);
|
||
let lowest = self
|
||
.donchian_lows
|
||
.iter()
|
||
.copied()
|
||
.fold(f64::INFINITY, f64::min);
|
||
let middle = (highest + lowest) / 2.0;
|
||
|
||
Some((middle, highest, lowest))
|
||
}
|
||
|
||
/// Get On-Balance Volume
|
||
pub fn calculate_obv(&self) -> f64 {
|
||
self.obv
|
||
}
|
||
|
||
/// Calculate VWAP
|
||
pub fn calculate_vwap(&self) -> Option<f64> {
|
||
if self.vwap_cumulative_volume == 0.0 {
|
||
return None;
|
||
}
|
||
|
||
Some(self.vwap_cumulative_pv / self.vwap_cumulative_volume)
|
||
}
|
||
|
||
/// Calculate Volume Oscillator (%)
|
||
pub fn calculate_volume_oscillator(&self) -> Option<f64> {
|
||
match (self.vol_ema_fast, self.vol_ema_slow) {
|
||
(Some(fast), Some(slow)) if slow != 0.0 => Some(((fast - slow) / slow) * 100.0),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Calculate current `RSI` (0-100 range)
|
||
pub fn calculate_rsi(&self) -> Option<f64> {
|
||
if !self.rsi_state.initialized {
|
||
return None;
|
||
}
|
||
|
||
if self.rsi_state.avg_loss == 0.0 {
|
||
return Some(100.0);
|
||
}
|
||
|
||
let rs = self.rsi_state.avg_gain / self.rsi_state.avg_loss;
|
||
Some(100.0 - (100.0 / (1.0 + rs)))
|
||
}
|
||
|
||
/// Calculate current `MACD` (difference between fast and slow `EMA`)
|
||
pub fn calculate_macd(&self) -> Option<f64> {
|
||
match (self.ema_fast_state, self.ema_slow_state) {
|
||
(Some(fast), Some(slow)) => Some(fast - slow),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Calculate `MACD` signal line
|
||
pub fn calculate_macd_signal(&self) -> Option<f64> {
|
||
self.macd_signal_state
|
||
}
|
||
|
||
/// Calculate `MACD` histogram
|
||
pub fn calculate_macd_histogram(&self) -> Option<f64> {
|
||
match (self.calculate_macd(), self.macd_signal_state) {
|
||
(Some(macd), Some(signal)) => Some(macd - signal),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Calculate Bollinger Bands (middle, upper, lower)
|
||
pub fn calculate_bollinger_bands(&self) -> Option<(f64, f64, f64)> {
|
||
if self.price_history.len() < self.config.bollinger_period {
|
||
return None;
|
||
}
|
||
|
||
let prices: Vec<f64> = self
|
||
.price_history
|
||
.iter()
|
||
.rev()
|
||
.take(self.config.bollinger_period)
|
||
.copied()
|
||
.collect();
|
||
|
||
// Calculate SMA (middle band)
|
||
let sma = prices.iter().sum::<f64>() / prices.len() as f64;
|
||
|
||
// Calculate standard deviation
|
||
let variance = prices.iter().map(|p| (p - sma).powi(2)).sum::<f64>() / prices.len() as f64;
|
||
let std_dev = variance.sqrt();
|
||
|
||
let upper = sma + self.config.bollinger_std_dev * std_dev;
|
||
let lower = sma - self.config.bollinger_std_dev * std_dev;
|
||
|
||
Some((sma, upper, lower))
|
||
}
|
||
|
||
/// Get current `ATR` value
|
||
pub fn calculate_atr(&self) -> Option<f64> {
|
||
self.atr_state
|
||
}
|
||
|
||
/// Get fast EMA
|
||
pub fn calculate_ema_fast(&self) -> Option<f64> {
|
||
self.ema_fast_state
|
||
}
|
||
|
||
/// Get slow EMA
|
||
pub fn calculate_ema_slow(&self) -> Option<f64> {
|
||
self.ema_slow_state
|
||
}
|
||
|
||
/// Get current price
|
||
pub fn current_price(&self) -> Option<f64> {
|
||
self.price_history.back().copied()
|
||
}
|
||
|
||
/// Check if warmup period is complete
|
||
pub fn is_warmed_up(&self) -> bool {
|
||
self.update_count >= self.config.warmup_period
|
||
}
|
||
|
||
/// Get all current indicators as HashMap (36 indicators total)
|
||
pub fn current_indicators(&self) -> std::collections::HashMap<String, f64> {
|
||
let mut indicators = std::collections::HashMap::new();
|
||
|
||
// Current price (for fallback when indicators not ready)
|
||
if let Some(price) = self.current_price() {
|
||
indicators.insert("price".to_string(), price);
|
||
}
|
||
|
||
// RSI
|
||
if let Some(rsi) = self.calculate_rsi() {
|
||
indicators.insert("rsi".to_string(), rsi);
|
||
}
|
||
|
||
// EMA
|
||
if let Some(ema_fast) = self.ema_fast_state {
|
||
indicators.insert("ema_fast".to_string(), ema_fast);
|
||
}
|
||
if let Some(ema_slow) = self.ema_slow_state {
|
||
indicators.insert("ema_slow".to_string(), ema_slow);
|
||
}
|
||
|
||
// MACD
|
||
if let Some(macd) = self.calculate_macd() {
|
||
indicators.insert("macd".to_string(), macd);
|
||
}
|
||
if let Some(signal) = self.macd_signal_state {
|
||
indicators.insert("macd_signal".to_string(), signal);
|
||
}
|
||
if let Some(histogram) = self.calculate_macd_histogram() {
|
||
indicators.insert("macd_histogram".to_string(), histogram);
|
||
}
|
||
|
||
// Bollinger Bands
|
||
if let Some((middle, upper, lower)) = self.calculate_bollinger_bands() {
|
||
indicators.insert("bollinger_middle".to_string(), middle);
|
||
indicators.insert("bollinger_upper".to_string(), upper);
|
||
indicators.insert("bollinger_lower".to_string(), lower);
|
||
|
||
// Bollinger Band width (volatility proxy)
|
||
let width = (upper - lower) / middle;
|
||
indicators.insert("bollinger_width".to_string(), width);
|
||
}
|
||
|
||
// ATR
|
||
if let Some(atr) = self.atr_state {
|
||
indicators.insert("atr".to_string(), atr);
|
||
}
|
||
|
||
// === NEW MOMENTUM INDICATORS ===
|
||
// Money Flow Index (MFI)
|
||
if let Some(mfi) = self.calculate_mfi() {
|
||
indicators.insert("mfi".to_string(), mfi);
|
||
}
|
||
|
||
// Chaikin Money Flow (CMF)
|
||
if let Some(cmf) = self.calculate_cmf() {
|
||
indicators.insert("cmf".to_string(), cmf);
|
||
}
|
||
|
||
// Chaikin Oscillator
|
||
if let Some(chaikin) = self.calculate_chaikin_oscillator() {
|
||
indicators.insert("chaikin_oscillator".to_string(), chaikin);
|
||
}
|
||
|
||
// === NEW VOLATILITY INDICATORS ===
|
||
// Keltner Channels
|
||
if let Some((middle, upper, lower)) = self.calculate_keltner_channels() {
|
||
indicators.insert("keltner_middle".to_string(), middle);
|
||
indicators.insert("keltner_upper".to_string(), upper);
|
||
indicators.insert("keltner_lower".to_string(), lower);
|
||
|
||
// Keltner width
|
||
let width = (upper - lower) / middle;
|
||
indicators.insert("keltner_width".to_string(), width);
|
||
}
|
||
|
||
// Donchian Channels
|
||
if let Some((middle, upper, lower)) = self.calculate_donchian_channels() {
|
||
indicators.insert("donchian_middle".to_string(), middle);
|
||
indicators.insert("donchian_upper".to_string(), upper);
|
||
indicators.insert("donchian_lower".to_string(), lower);
|
||
|
||
// Donchian width
|
||
let width = (upper - lower) / middle;
|
||
indicators.insert("donchian_width".to_string(), width);
|
||
}
|
||
|
||
// === NEW VOLUME INDICATORS ===
|
||
// On-Balance Volume (OBV)
|
||
indicators.insert("obv".to_string(), self.calculate_obv());
|
||
|
||
// VWAP
|
||
if let Some(vwap) = self.calculate_vwap() {
|
||
indicators.insert("vwap".to_string(), vwap);
|
||
|
||
// VWAP deviation (price distance from VWAP)
|
||
if let Some(price) = self.current_price() {
|
||
let vwap_deviation = (price - vwap) / vwap;
|
||
indicators.insert("vwap_deviation".to_string(), vwap_deviation);
|
||
}
|
||
}
|
||
|
||
// Volume Oscillator
|
||
if let Some(vol_osc) = self.calculate_volume_oscillator() {
|
||
indicators.insert("volume_oscillator".to_string(), vol_osc);
|
||
}
|
||
|
||
indicators
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_rsi_calculation() {
|
||
let config = IndicatorConfig {
|
||
rsi_period: 14,
|
||
..Default::default()
|
||
};
|
||
let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config);
|
||
|
||
// Feed price data with clear uptrend
|
||
let prices = vec![
|
||
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0, 111.0,
|
||
112.0, 113.0, 114.0,
|
||
];
|
||
|
||
for price in prices {
|
||
calc.update(price, 1000.0, None, None);
|
||
}
|
||
|
||
let rsi = calc.calculate_rsi().expect("RSI should be calculated");
|
||
assert!(rsi > 50.0, "Uptrend should have RSI > 50, got {}", rsi);
|
||
assert!(rsi <= 100.0, "RSI should be <= 100, got {}", rsi);
|
||
}
|
||
|
||
#[test]
|
||
fn test_ema_calculation() {
|
||
let config = IndicatorConfig {
|
||
ema_fast_period: 5,
|
||
ema_slow_period: 10,
|
||
..Default::default()
|
||
};
|
||
let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config);
|
||
|
||
// Feed constant price - EMA should converge to price
|
||
for _ in 0..20 {
|
||
calc.update(100.0, 1000.0, None, None);
|
||
}
|
||
|
||
let ema_fast = calc.calculate_ema_fast().expect("Fast EMA should exist");
|
||
let ema_slow = calc.calculate_ema_slow().expect("Slow EMA should exist");
|
||
|
||
assert!(
|
||
(ema_fast - 100.0).abs() < 0.1,
|
||
"Fast EMA should converge to 100"
|
||
);
|
||
assert!(
|
||
(ema_slow - 100.0).abs() < 0.1,
|
||
"Slow EMA should converge to 100"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_macd_calculation() {
|
||
let config = IndicatorConfig {
|
||
ema_fast_period: 12,
|
||
ema_slow_period: 26,
|
||
macd_signal_period: 9,
|
||
..Default::default()
|
||
};
|
||
let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config);
|
||
|
||
// Feed trending price data
|
||
for i in 0..50 {
|
||
calc.update(100.0 + i as f64, 1000.0, None, None);
|
||
}
|
||
|
||
let macd = calc.calculate_macd().expect("MACD should exist");
|
||
let signal = calc.calculate_macd_signal().expect("Signal should exist");
|
||
let histogram = calc
|
||
.calculate_macd_histogram()
|
||
.expect("Histogram should exist");
|
||
|
||
assert!(macd > 0.0, "Uptrend should have positive MACD");
|
||
assert!(
|
||
(histogram - (macd - signal)).abs() < 0.001,
|
||
"Histogram should equal MACD - Signal"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_bollinger_bands() {
|
||
let config = IndicatorConfig {
|
||
bollinger_period: 20,
|
||
bollinger_std_dev: 2.0,
|
||
..Default::default()
|
||
};
|
||
let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config);
|
||
|
||
// Feed price data with volatility
|
||
for i in 0..30 {
|
||
let price = 100.0 + (i as f64 * 0.5).sin() * 5.0;
|
||
calc.update(price, 1000.0, None, None);
|
||
}
|
||
|
||
let (middle, upper, lower) = calc
|
||
.calculate_bollinger_bands()
|
||
.expect("Bollinger bands should be calculated");
|
||
|
||
assert!(upper > middle, "Upper band should be > middle");
|
||
assert!(middle > lower, "Middle should be > lower band");
|
||
assert!(
|
||
(upper - middle) - (middle - lower) < 0.001,
|
||
"Bands should be symmetric"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_atr_calculation() {
|
||
let config = IndicatorConfig {
|
||
atr_period: 14,
|
||
..Default::default()
|
||
};
|
||
let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config);
|
||
|
||
// Feed OHLC data with varying ranges
|
||
for i in 0..20 {
|
||
let price = 100.0 + i as f64;
|
||
let high = price + 1.0;
|
||
let low = price - 1.0;
|
||
calc.update(price, 1000.0, Some(high), Some(low));
|
||
}
|
||
|
||
let atr = calc.calculate_atr().expect("ATR should be calculated");
|
||
assert!(atr > 0.0, "ATR should be positive");
|
||
assert!(atr < 10.0, "ATR should be reasonable for this data");
|
||
}
|
||
|
||
#[test]
|
||
fn test_warmup_period() {
|
||
let config = IndicatorConfig {
|
||
warmup_period: 26,
|
||
..Default::default()
|
||
};
|
||
let mut calc = TechnicalIndicatorCalculator::new("TEST".to_string(), config);
|
||
|
||
assert!(!calc.is_warmed_up(), "Should not be warmed up initially");
|
||
|
||
for i in 0..30 {
|
||
calc.update(100.0 + i as f64, 1000.0, None, None);
|
||
}
|
||
|
||
assert!(calc.is_warmed_up(), "Should be warmed up after 30 updates");
|
||
}
|
||
}
|