1use std::fmt;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6#[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
32pub type ChopResult<T> = Result<T, ChopError>;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
38pub enum MarketCondition {
39 StrongTrend,
41 Trend,
43 Transitional,
45 Choppy,
47 VeryChoppy,
49}
50
51impl MarketCondition {
52 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 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#[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#[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
107pub fn choppiness_index(
118 high: &[f64],
119 low: &[f64],
120 close: &[f64],
121 period: usize,
122) -> ChopResult<Vec<f64>> {
123 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 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 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 let chop = 100.0 * (atr_sum / range).log10() / log_period;
169 results.push(chop);
170 }
171
172 Ok(results)
173}
174
175pub 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
187pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
209pub enum OptionsSignal {
210 NoTrade,
212 SellPremium,
214 Neutral,
216 Directional,
218 StrongDirectional,
220}
221
222pub 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
237pub fn quick_1min_analysis(
239 high: &[f64],
240 low: &[f64],
241 close: &[f64],
242) -> ChopResult<(f64, OptionsSignal, MarketCondition)> {
243 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 for &chop in &chop_values {
272 assert!(chop >= 0.0 && chop <= 100.0);
273 }
274 }
275
276 #[test]
277 fn test_trending_market() {
278 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