Skip to main content

indicators/volatility/
market_cycle.rs

1//! Market Cycle Indicator.
2//!
3//! Python source: `indicators/other/market_cycle.py :: class MarketCycleIndicator`
4//!
5//! Detects market cycle phases from price momentum:
6//! - `Markup`       — momentum > 0
7//! - `Markdown`     — momentum < 0
8//! - `Plateau`      — momentum == 0
9//! - `Accumulation` — previous phase was Markdown, current changed
10//! - `Distribution` — previous phase was Markup, current changed
11//!
12//! Output column: `"MarketCycle"` — encoded as `f64`:
13//! - 1.0 = Markup, -1.0 = Markdown, 0.0 = Plateau,
14//!   0.5 = Accumulation, -0.5 = Distribution.
15
16use std::collections::HashMap;
17
18use crate::error::IndicatorError;
19use crate::indicator::{Indicator, IndicatorOutput};
20use crate::registry::param_usize;
21use crate::types::Candle;
22
23/// Numeric encoding for cycle phases (avoids `String` in `IndicatorOutput`).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CyclePhase {
26    Markup = 1,
27    Markdown = -1,
28    Plateau = 0,
29    Accumulation = 2, // using 2/-2 to distinguish from Markup/Markdown
30    Distribution = -2,
31}
32
33impl CyclePhase {
34    pub fn as_f64(self) -> f64 {
35        self as i32 as f64
36    }
37}
38
39#[derive(Debug, Clone)]
40pub struct MarketCycleParams {
41    /// Momentum diff period.  Python default: 1.
42    pub momentum_period: usize,
43}
44impl Default for MarketCycleParams {
45    fn default() -> Self {
46        Self { momentum_period: 1 }
47    }
48}
49
50#[derive(Debug, Clone)]
51pub struct MarketCycle {
52    pub params: MarketCycleParams,
53}
54
55impl MarketCycle {
56    pub fn new(params: MarketCycleParams) -> Self {
57        Self { params }
58    }
59}
60
61impl Default for MarketCycle {
62    fn default() -> Self {
63        Self::new(MarketCycleParams::default())
64    }
65}
66
67impl Indicator for MarketCycle {
68    fn name(&self) -> &'static str {
69        "MarketCycle"
70    }
71    fn required_len(&self) -> usize {
72        self.params.momentum_period + 1
73    }
74    fn required_columns(&self) -> &[&'static str] {
75        &["close"]
76    }
77
78    /// Detects market cycle phase for each bar using price momentum and
79    /// transition rules that mirror Python's pandas vectorised assignments.
80    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
81        self.check_len(candles)?;
82
83        let close: Vec<f64> = candles.iter().map(|c| c.close).collect();
84        let mp = self.params.momentum_period;
85        let n = close.len();
86
87        // Step 1: assign base phases from momentum.
88        let mut phases = vec![CyclePhase::Plateau; n];
89        for i in mp..n {
90            let momentum = close[i] - close[i - mp];
91            phases[i] = if momentum > 0.0 {
92                CyclePhase::Markup
93            } else if momentum < 0.0 {
94                CyclePhase::Markdown
95            } else {
96                CyclePhase::Plateau
97            };
98        }
99
100        // Step 2: apply transition rules, matching Python's pandas semantics.
101        //
102        // Python does:
103        //   cycle.loc[(cycle.shift(1) == "Markdown") & (cycle != "Markdown")] = "Accumulation"
104        //   cycle.loc[(cycle.shift(1) == "Markup")   & (cycle != "Markup")]   = "Distribution"
105        //
106        // `cycle.shift(1)` in the second rule sees Accumulation values already
107        // written by the first rule.  We replicate this by reading from `result`
108        // (the output being built) rather than from the original `phases` slice.
109        let mut result = phases.clone();
110        for i in 1..n {
111            match (result[i - 1], phases[i]) {
112                (CyclePhase::Markdown, p) if p != CyclePhase::Markdown => {
113                    result[i] = CyclePhase::Accumulation;
114                }
115                (CyclePhase::Markup, p) if p != CyclePhase::Markup => {
116                    result[i] = CyclePhase::Distribution;
117                }
118                _ => {}
119            }
120        }
121
122        let values: Vec<f64> = result.iter().map(|p| p.as_f64()).collect();
123
124        Ok(IndicatorOutput::from_pairs([(
125            "MarketCycle".to_string(),
126            values,
127        )]))
128    }
129}
130
131pub fn factory<S: ::std::hash::BuildHasher>(
132    params: &HashMap<String, String, S>,
133) -> Result<Box<dyn Indicator>, IndicatorError> {
134    Ok(Box::new(MarketCycle::new(MarketCycleParams {
135        momentum_period: param_usize(params, "momentum_period", 1)?,
136    })))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn candles(closes: &[f64]) -> Vec<Candle> {
144        closes
145            .iter()
146            .enumerate()
147            .map(|(i, &c)| Candle {
148                time: i64::try_from(i).expect("time index fits i64"),
149                open: c,
150                high: c,
151                low: c,
152                close: c,
153                volume: 1.0,
154            })
155            .collect()
156    }
157
158    #[test]
159    fn market_cycle_output_column() {
160        let out = MarketCycle::default()
161            .calculate(&candles(&[1.0, 2.0, 3.0]))
162            .unwrap();
163        assert!(out.get("MarketCycle").is_some());
164    }
165
166    #[test]
167    fn rising_prices_give_markup() {
168        let closes = vec![1.0, 2.0, 3.0, 4.0, 5.0];
169        let out = MarketCycle::default().calculate(&candles(&closes)).unwrap();
170        let vals = out.get("MarketCycle").unwrap();
171        // Index 1+ should reflect Markup (1.0) except where transition rules fire.
172        assert_eq!(vals[1], CyclePhase::Markup.as_f64());
173    }
174
175    #[test]
176    fn falling_after_rising_gives_distribution() {
177        // Rise then fall → distribution transition.
178        let closes = vec![1.0, 2.0, 3.0, 2.0];
179        let out = MarketCycle::default().calculate(&candles(&closes)).unwrap();
180        let vals = out.get("MarketCycle").unwrap();
181        assert_eq!(vals[3], CyclePhase::Distribution.as_f64());
182    }
183
184    #[test]
185    fn factory_creates_market_cycle() {
186        assert_eq!(factory(&HashMap::new()).unwrap().name(), "MarketCycle");
187    }
188}