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    #[inline]
68    fn update(&mut self, candle: Candle) -> Option<CentralPivotRangeOutput> {
69        let pivot = (candle.high + candle.low + candle.close) / 3.0;
70        let bc_raw = f64::midpoint(candle.high, candle.low);
71        let tc_raw = 2.0 * pivot - bc_raw;
72        let tc = tc_raw.max(bc_raw);
73        let bc = tc_raw.min(bc_raw);
74        self.ready = true;
75        Some(CentralPivotRangeOutput { pivot, tc, bc })
76    }
77
78    fn reset(&mut self) {
79        self.ready = false;
80    }
81
82    #[inline]
83    fn warmup_period(&self) -> usize {
84        1
85    }
86
87    #[inline]
88    fn is_ready(&self) -> bool {
89        self.ready
90    }
91
92    #[inline]
93    fn name(&self) -> &'static str {
94        "CentralPivotRange"
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::traits::BatchExt;
102
103    fn c(high: f64, low: f64, close: f64) -> Candle {
104        Candle::new_unchecked(close, high, low, close, 1_000.0, 0)
105    }
106
107    #[test]
108    fn accessors_and_metadata() {
109        let cpr = CentralPivotRange::new();
110        assert_eq!(cpr.warmup_period(), 1);
111        assert_eq!(cpr.name(), "CentralPivotRange");
112        assert!(!cpr.is_ready());
113    }
114
115    #[test]
116    fn formula_reference_values() {
117        // H=110, L=90, C=105 -> pivot = 305/3; bc' = 100; tc' = 2*pivot - 100.
118        let out = CentralPivotRange::new()
119            .update(c(110.0, 90.0, 105.0))
120            .unwrap();
121        let pivot = 305.0 / 3.0;
122        let bc_raw = 100.0;
123        let tc_raw = 2.0 * pivot - bc_raw;
124        assert!((out.pivot - pivot).abs() < 1e-12);
125        assert!((out.tc - tc_raw.max(bc_raw)).abs() < 1e-12);
126        assert!((out.bc - tc_raw.min(bc_raw)).abs() < 1e-12);
127    }
128
129    #[test]
130    fn tc_never_below_bc() {
131        let out = CentralPivotRange::new()
132            .update(c(200.0, 100.0, 150.0))
133            .unwrap();
134        assert!(out.tc >= out.bc);
135    }
136
137    #[test]
138    fn constant_bar_collapses_range() {
139        // H = L = C -> pivot = bc' = tc' = the price; range collapses.
140        let out = CentralPivotRange::new()
141            .update(c(50.0, 50.0, 50.0))
142            .unwrap();
143        assert_eq!(out.pivot, 50.0);
144        assert_eq!(out.tc, 50.0);
145        assert_eq!(out.bc, 50.0);
146    }
147
148    #[test]
149    fn ready_after_first_update() {
150        let mut cpr = CentralPivotRange::new();
151        assert!(!cpr.is_ready());
152        cpr.update(c(11.0, 9.0, 10.0));
153        assert!(cpr.is_ready());
154    }
155
156    #[test]
157    fn reset_clears_state() {
158        let mut cpr = CentralPivotRange::new();
159        cpr.update(c(11.0, 9.0, 10.0));
160        assert!(cpr.is_ready());
161        cpr.reset();
162        assert!(!cpr.is_ready());
163    }
164
165    #[test]
166    fn batch_equals_streaming() {
167        let candles: Vec<Candle> = (0..40)
168            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
169            .collect();
170        let batch = CentralPivotRange::new().batch(&candles);
171        let mut b = CentralPivotRange::new();
172        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
173        assert_eq!(batch, streamed);
174    }
175}