Skip to main content

wickra_core/indicators/
central_pivot_range.rs

1//! Central Pivot Range (CPR) — the pivot plus its two central levels.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Output of [`CentralPivotRange`]: the pivot and the two central lines that
7/// bracket it.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct CentralPivotRangeOutput {
10    /// Pivot point `(high + low + close) / 3`.
11    pub pivot: f64,
12    /// Top central line — the higher of the two central levels.
13    pub tc: f64,
14    /// Bottom central line — the lower of the two central levels.
15    pub bc: f64,
16}
17
18/// Central Pivot Range (CPR) — the classic pivot point flanked by two "central"
19/// levels whose separation gauges the day's expected character.
20///
21/// ```text
22/// pivot = (high + low + close) / 3
23/// bc'   = (high + low) / 2
24/// tc'   = 2·pivot − bc'
25/// TC    = max(tc', bc'),   BC = min(tc', bc')
26/// ```
27///
28/// The CPR is computed from the **previous** period's bar (feed it completed
29/// daily/weekly bars). The width of the range `TC − BC` is the headline read: a
30/// **narrow** CPR signals a likely trending day (price has little balance area to
31/// chew through), while a **wide** CPR signals a likely range-bound, balanced
32/// day. Price opening above the whole range is bullish, below it bearish, inside
33/// it neutral. The `tc'`/`bc'` formulas are symmetric about the pivot; this
34/// implementation labels the larger as `TC` and the smaller as `BC` so `TC >= BC`
35/// always holds.
36///
37/// There are no parameters and no warmup — each completed bar yields one CPR.
38/// Each `update` is O(1).
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Candle, Indicator, CentralPivotRange};
44///
45/// let mut indicator = CentralPivotRange::new();
46/// let prev_day = Candle::new(101.0, 110.0, 90.0, 105.0, 1_000.0, 0).unwrap();
47/// let cpr = indicator.update(prev_day).unwrap();
48/// assert!(cpr.tc >= cpr.bc);
49/// ```
50#[derive(Debug, Clone, Default)]
51pub struct CentralPivotRange {
52    ready: bool,
53}
54
55impl CentralPivotRange {
56    /// Construct a new Central Pivot Range. The indicator is parameter-free.
57    #[must_use]
58    pub const fn new() -> Self {
59        Self { ready: false }
60    }
61}
62
63impl Indicator for CentralPivotRange {
64    type Input = Candle;
65    type Output = CentralPivotRangeOutput;
66
67    fn update(&mut self, candle: Candle) -> Option<CentralPivotRangeOutput> {
68        let pivot = (candle.high + candle.low + candle.close) / 3.0;
69        let bc_raw = f64::midpoint(candle.high, candle.low);
70        let tc_raw = 2.0 * pivot - bc_raw;
71        let tc = tc_raw.max(bc_raw);
72        let bc = tc_raw.min(bc_raw);
73        self.ready = true;
74        Some(CentralPivotRangeOutput { pivot, tc, bc })
75    }
76
77    fn reset(&mut self) {
78        self.ready = false;
79    }
80
81    fn warmup_period(&self) -> usize {
82        1
83    }
84
85    fn is_ready(&self) -> bool {
86        self.ready
87    }
88
89    fn name(&self) -> &'static str {
90        "CentralPivotRange"
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::traits::BatchExt;
98
99    fn c(high: f64, low: f64, close: f64) -> Candle {
100        Candle::new_unchecked(close, high, low, close, 1_000.0, 0)
101    }
102
103    #[test]
104    fn accessors_and_metadata() {
105        let cpr = CentralPivotRange::new();
106        assert_eq!(cpr.warmup_period(), 1);
107        assert_eq!(cpr.name(), "CentralPivotRange");
108        assert!(!cpr.is_ready());
109    }
110
111    #[test]
112    fn formula_reference_values() {
113        // H=110, L=90, C=105 -> pivot = 305/3; bc' = 100; tc' = 2*pivot - 100.
114        let out = CentralPivotRange::new()
115            .update(c(110.0, 90.0, 105.0))
116            .unwrap();
117        let pivot = 305.0 / 3.0;
118        let bc_raw = 100.0;
119        let tc_raw = 2.0 * pivot - bc_raw;
120        assert!((out.pivot - pivot).abs() < 1e-12);
121        assert!((out.tc - tc_raw.max(bc_raw)).abs() < 1e-12);
122        assert!((out.bc - tc_raw.min(bc_raw)).abs() < 1e-12);
123    }
124
125    #[test]
126    fn tc_never_below_bc() {
127        let out = CentralPivotRange::new()
128            .update(c(200.0, 100.0, 150.0))
129            .unwrap();
130        assert!(out.tc >= out.bc);
131    }
132
133    #[test]
134    fn constant_bar_collapses_range() {
135        // H = L = C -> pivot = bc' = tc' = the price; range collapses.
136        let out = CentralPivotRange::new()
137            .update(c(50.0, 50.0, 50.0))
138            .unwrap();
139        assert_eq!(out.pivot, 50.0);
140        assert_eq!(out.tc, 50.0);
141        assert_eq!(out.bc, 50.0);
142    }
143
144    #[test]
145    fn ready_after_first_update() {
146        let mut cpr = CentralPivotRange::new();
147        assert!(!cpr.is_ready());
148        cpr.update(c(11.0, 9.0, 10.0));
149        assert!(cpr.is_ready());
150    }
151
152    #[test]
153    fn reset_clears_state() {
154        let mut cpr = CentralPivotRange::new();
155        cpr.update(c(11.0, 9.0, 10.0));
156        assert!(cpr.is_ready());
157        cpr.reset();
158        assert!(!cpr.is_ready());
159    }
160
161    #[test]
162    fn batch_equals_streaming() {
163        let candles: Vec<Candle> = (0..40)
164            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
165            .collect();
166        let batch = CentralPivotRange::new().batch(&candles);
167        let mut b = CentralPivotRange::new();
168        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
169        assert_eq!(batch, streamed);
170    }
171}