chop_indicator/
lib.rs

1use std::fmt;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// Errors that can occur during CHOP calculation
7#[derive(Debug, Clone, PartialEq)]
8pub enum ChopError {
9    InsufficientData { required: usize, got: usize },
10    InvalidPeriod,
11    MismatchedLengths,
12    InvalidPrice,
13    ZeroRange,
14}
15
16impl fmt::Display for ChopError {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::InsufficientData { required, got } => {
20                write!(f, "Insufficient data: need {} points, got {}", required, got)
21            }
22            Self::InvalidPeriod => write!(f, "Period must be greater than 0"),
23            Self::MismatchedLengths => write!(f, "High, low, and close arrays must have same length"),
24            Self::InvalidPrice => write!(f, "Prices must be positive and finite"),
25            Self::ZeroRange => write!(f, "Price range is zero, cannot calculate CHOP"),
26        }
27    }
28}
29
30impl std::error::Error for ChopError {}
31
32/// Result type for CHOP calculations
33pub type ChopResult<T> = Result<T, ChopError>;
34
35/// Market condition based on CHOP value
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
38pub enum MarketCondition {
39    /// Strong trending market (CHOP < 38.2)
40    StrongTrend,
41    /// Trending market (38.2 <= CHOP < 45)
42    Trend,
43    /// Transitional market (45 <= CHOP < 55)
44    Transitional,
45    /// Choppy market (55 <= CHOP < 61.8)
46    Choppy,
47    /// Very choppy/sideways market (CHOP >= 61.8)
48    VeryChoppy,
49}
50
51impl MarketCondition {
52    /// Determine market condition from CHOP value
53    pub fn from_chop(chop: f64) -> Self {
54        if chop < 38.2 {
55            Self::StrongTrend
56        } else if chop < 45.0 {
57            Self::Trend
58        } else if chop < 55.0 {
59            Self::Transitional
60        } else if chop < 61.8 {
61            Self::Choppy
62        } else {
63            Self::VeryChoppy
64        }
65    }
66
67    /// Get recommended trading strategy for options
68    pub fn options_strategy(&self) -> &str {
69        match self {
70            Self::StrongTrend => "Directional strategies: Calls/Puts, Spreads in trend direction",
71            Self::Trend => "Moderate directional: Vertical spreads, Covered calls/puts",
72            Self::Transitional => "Caution: Reduce position size, consider iron condors",
73            Self::Choppy => "Range-bound: Iron condors, Iron butterflies, Short strangles",
74            Self::VeryChoppy => "Premium selling: Credit spreads, Short strangles, Calendar spreads",
75        }
76    }
77}
78
79/// CHOP analysis result
80#[derive(Debug, Clone)]
81#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
82pub struct ChopAnalysis {
83    pub value: f64,
84    pub condition: MarketCondition,
85    pub is_trending: bool,
86    pub is_choppy: bool,
87    pub strategy_recommendation: String,
88}
89
90/// Calculate True Range
91#[inline]
92fn true_range(high: f64, low: f64, prev_close: f64) -> ChopResult<f64> {
93    if !high.is_finite() || !low.is_finite() || !prev_close.is_finite() {
94        return Err(ChopError::InvalidPrice);
95    }
96    if high < low {
97        return Err(ChopError::InvalidPrice);
98    }
99
100    let hl = high - low;
101    let hc = (high - prev_close).abs();
102    let lc = (low - prev_close).abs();
103    
104    Ok(hl.max(hc).max(lc))
105}
106
107/// Calculate Choppiness Index (CHOP)
108/// 
109/// # Arguments
110/// * `high` - High prices
111/// * `low` - Low prices
112/// * `close` - Close prices
113/// * `period` - Lookback period (default: 14)
114/// 
115/// # Returns
116/// Vector of CHOP values
117pub fn choppiness_index(
118    high: &[f64],
119    low: &[f64],
120    close: &[f64],
121    period: usize,
122) -> ChopResult<Vec<f64>> {
123    // Validation
124    if period == 0 {
125        return Err(ChopError::InvalidPeriod);
126    }
127
128    let len = high.len();
129    if len != low.len() || len != close.len() {
130        return Err(ChopError::MismatchedLengths);
131    }
132
133    if len < period + 1 {
134        return Err(ChopError::InsufficientData {
135            required: period + 1,
136            got: len,
137        });
138    }
139
140    let mut results = Vec::with_capacity(len - period);
141    let log_period = (period as f64).log10();
142
143    for i in period..len {
144        // Calculate sum of True Range
145        let mut atr_sum = 0.0;
146        for j in (i - period + 1)..=i {
147            let tr = true_range(high[j], low[j], close[j - 1])?;
148            atr_sum += tr;
149        }
150
151        // Find max high and min low in period
152        let window_high = &high[i - period + 1..=i];
153        let window_low = &low[i - period + 1..=i];
154
155        let max_high = window_high.iter()
156            .copied()
157            .fold(f64::NEG_INFINITY, f64::max);
158        let min_low = window_low.iter()
159            .copied()
160            .fold(f64::INFINITY, f64::min);
161
162        let range = max_high - min_low;
163        if range <= 0.0 {
164            return Err(ChopError::ZeroRange);
165        }
166
167        // Calculate CHOP
168        let chop = 100.0 * (atr_sum / range).log10() / log_period;
169        results.push(chop);
170    }
171
172    Ok(results)
173}
174
175/// Calculate single CHOP value from recent data
176pub fn choppiness_index_last(
177    high: &[f64],
178    low: &[f64],
179    close: &[f64],
180    period: usize,
181) -> ChopResult<f64> {
182    let results = choppiness_index(high, low, close, period)?;
183    results.last().copied()
184        .ok_or(ChopError::InsufficientData { required: period + 1, got: 0 })
185}
186
187/// Analyze market condition using CHOP
188pub fn analyze_chop(
189    high: &[f64],
190    low: &[f64],
191    close: &[f64],
192    period: usize,
193) -> ChopResult<ChopAnalysis> {
194    let chop_value = choppiness_index_last(high, low, close, period)?;
195    let condition = MarketCondition::from_chop(chop_value);
196    
197    Ok(ChopAnalysis {
198        value: chop_value,
199        condition,
200        is_trending: chop_value < 45.0,
201        is_choppy: chop_value >= 55.0,
202        strategy_recommendation: condition.options_strategy().to_string(),
203    })
204}
205
206/// CHOP Signal for 1-minute options trading
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
209pub enum OptionsSignal {
210    /// Avoid trading - very choppy
211    NoTrade,
212    /// Sell premium strategies
213    SellPremium,
214    /// Neutral strategies
215    Neutral,
216    /// Directional strategies
217    Directional,
218    /// Strong directional strategies
219    StrongDirectional,
220}
221
222/// Get options trading signal for 1-minute timeframe
223pub fn get_options_signal(chop: f64) -> OptionsSignal {
224    if chop >= 65.0 {
225        OptionsSignal::NoTrade
226    } else if chop >= 55.0 {
227        OptionsSignal::SellPremium
228    } else if chop >= 45.0 {
229        OptionsSignal::Neutral
230    } else if chop >= 35.0 {
231        OptionsSignal::Directional
232    } else {
233        OptionsSignal::StrongDirectional
234    }
235}
236
237/// Quick analysis for rapid 1-minute decision making
238pub fn quick_1min_analysis(
239    high: &[f64],
240    low: &[f64],
241    close: &[f64],
242) -> ChopResult<(f64, OptionsSignal, MarketCondition)> {
243    // For 1-minute trading, use shorter period (10-14)
244    let period = 14;
245    let chop = choppiness_index_last(high, low, close, period)?;
246    let signal = get_options_signal(chop);
247    let condition = MarketCondition::from_chop(chop);
248    
249    Ok((chop, signal, condition))
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_chop_calculation() {
258        let high = vec![100.0, 102.0, 104.0, 103.0, 105.0, 107.0, 106.0, 108.0, 110.0, 109.0,
259                       111.0, 113.0, 112.0, 114.0, 116.0, 115.0];
260        let low = vec![98.0, 100.0, 102.0, 101.0, 103.0, 105.0, 104.0, 106.0, 108.0, 107.0,
261                      109.0, 111.0, 110.0, 112.0, 114.0, 113.0];
262        let close = vec![99.0, 101.0, 103.0, 102.0, 104.0, 106.0, 105.0, 107.0, 109.0, 108.0,
263                        110.0, 112.0, 111.0, 113.0, 115.0, 114.0];
264
265        let result = choppiness_index(&high, &low, &close, 14);
266        assert!(result.is_ok());
267        let chop_values = result.unwrap();
268        assert!(!chop_values.is_empty());
269        
270        // CHOP should be between 0 and 100
271        for &chop in &chop_values {
272            assert!(chop >= 0.0 && chop <= 100.0);
273        }
274    }
275
276    #[test]
277    fn test_trending_market() {
278        // Strong uptrend - should have low CHOP
279        let high: Vec<f64> = (0..20).map(|i| 100.0 + i as f64 * 2.0).collect();
280        let low: Vec<f64> = (0..20).map(|i| 98.0 + i as f64 * 2.0).collect();
281        let close: Vec<f64> = (0..20).map(|i| 99.0 + i as f64 * 2.0).collect();
282
283        let chop = choppiness_index_last(&high, &low, &close, 14).unwrap();
284        assert!(chop < 50.0, "Trending market should have CHOP < 50, got {}", chop);
285    }
286
287    #[test]
288    fn test_market_condition() {
289        assert_eq!(MarketCondition::from_chop(30.0), MarketCondition::StrongTrend);
290        assert_eq!(MarketCondition::from_chop(40.0), MarketCondition::Trend);
291        assert_eq!(MarketCondition::from_chop(50.0), MarketCondition::Transitional);
292        assert_eq!(MarketCondition::from_chop(60.0), MarketCondition::Choppy);
293        assert_eq!(MarketCondition::from_chop(70.0), MarketCondition::VeryChoppy);
294    }
295
296    #[test]
297    fn test_options_signal() {
298        assert_eq!(get_options_signal(30.0), OptionsSignal::StrongDirectional);
299        assert_eq!(get_options_signal(40.0), OptionsSignal::Directional);
300        assert_eq!(get_options_signal(50.0), OptionsSignal::Neutral);
301        assert_eq!(get_options_signal(60.0), OptionsSignal::SellPremium);
302        assert_eq!(get_options_signal(70.0), OptionsSignal::NoTrade);
303    }
304
305    #[test]
306    fn test_insufficient_data() {
307        let high = vec![100.0, 102.0];
308        let low = vec![98.0, 100.0];
309        let close = vec![99.0, 101.0];
310
311        let result = choppiness_index(&high, &low, &close, 14);
312        assert!(matches!(result, Err(ChopError::InsufficientData { .. })));
313    }
314
315    #[test]
316    fn test_analysis() {
317        let high: Vec<f64> = (0..20).map(|i| 100.0 + i as f64 * 2.0).collect();
318        let low: Vec<f64> = (0..20).map(|i| 98.0 + i as f64 * 2.0).collect();
319        let close: Vec<f64> = (0..20).map(|i| 99.0 + i as f64 * 2.0).collect();
320
321        let analysis = analyze_chop(&high, &low, &close, 14).unwrap();
322        assert!(analysis.is_trending);
323        assert!(!analysis.is_choppy);
324        assert!(analysis.value < 50.0);
325    }
326}
327