Files
foxhunt/services/ml_training_service/src/technical_indicators.rs
jgrusewski 5634909f06 refactor: wire up underscore-prefixed constructor parameters
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>
2026-02-24 14:45:43 +01:00

1044 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}