Skip to main content

fin_primitives/signals/indicators/
range_midpoint_position.rs

1//! Range Midpoint Position — close's position relative to the N-period high/low midpoint.
2
3use crate::error::FinError;
4use crate::signals::{BarInput, Signal, SignalValue};
5use rust_decimal::Decimal;
6use std::collections::VecDeque;
7
8/// Range Midpoint Position — `(close - midpoint) / half_range` normalized to `[-1, 1]`.
9///
10/// Computes the N-period high/low midpoint and measures where the close sits:
11/// - **+1.0**: close at the period high.
12/// - **0.0**: close at the midpoint of the period range.
13/// - **-1.0**: close at the period low.
14///
15/// Returns [`SignalValue::Unavailable`] until `period` bars have been accumulated,
16/// or when `high == low` (zero range).
17///
18/// # Errors
19/// Returns [`FinError::InvalidPeriod`] if `period == 0`.
20///
21/// # Example
22/// ```rust
23/// use fin_primitives::signals::indicators::RangeMidpointPosition;
24/// use fin_primitives::signals::Signal;
25/// let rmp = RangeMidpointPosition::new("rmp_20", 20).unwrap();
26/// assert_eq!(rmp.period(), 20);
27/// ```
28pub struct RangeMidpointPosition {
29    name: String,
30    period: usize,
31    highs: VecDeque<Decimal>,
32    lows: VecDeque<Decimal>,
33    closes: VecDeque<Decimal>,
34}
35
36impl RangeMidpointPosition {
37    /// Constructs a new `RangeMidpointPosition`.
38    ///
39    /// # Errors
40    /// Returns [`FinError::InvalidPeriod`] if `period == 0`.
41    pub fn new(name: impl Into<String>, period: usize) -> Result<Self, FinError> {
42        if period == 0 {
43            return Err(FinError::InvalidPeriod(period));
44        }
45        Ok(Self {
46            name: name.into(),
47            period,
48            highs: VecDeque::with_capacity(period),
49            lows: VecDeque::with_capacity(period),
50            closes: VecDeque::with_capacity(period),
51        })
52    }
53}
54
55impl Signal for RangeMidpointPosition {
56    fn name(&self) -> &str { &self.name }
57    fn period(&self) -> usize { self.period }
58    fn is_ready(&self) -> bool { self.closes.len() >= self.period }
59
60    fn update(&mut self, bar: &BarInput) -> Result<SignalValue, FinError> {
61        self.highs.push_back(bar.high);
62        self.lows.push_back(bar.low);
63        self.closes.push_back(bar.close);
64
65        if self.highs.len() > self.period {
66            self.highs.pop_front();
67            self.lows.pop_front();
68            self.closes.pop_front();
69        }
70
71        if self.closes.len() < self.period {
72            return Ok(SignalValue::Unavailable);
73        }
74
75        let period_high = self.highs.iter().copied().fold(Decimal::MIN, Decimal::max);
76        let period_low = self.lows.iter().copied().fold(Decimal::MAX, Decimal::min);
77        let close = *self.closes.back().unwrap();
78
79        let range = period_high - period_low;
80        if range.is_zero() {
81            return Ok(SignalValue::Unavailable);
82        }
83
84        let midpoint = (period_high + period_low)
85            .checked_div(Decimal::TWO)
86            .ok_or(FinError::ArithmeticOverflow)?;
87        let half_range = range
88            .checked_div(Decimal::TWO)
89            .ok_or(FinError::ArithmeticOverflow)?;
90
91        let pos = (close - midpoint)
92            .checked_div(half_range)
93            .ok_or(FinError::ArithmeticOverflow)?;
94
95        // Clamp to [-1, 1]
96        Ok(SignalValue::Scalar(pos.max(Decimal::NEGATIVE_ONE).min(Decimal::ONE)))
97    }
98
99    fn reset(&mut self) {
100        self.highs.clear();
101        self.lows.clear();
102        self.closes.clear();
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::ohlcv::OhlcvBar;
110    use crate::signals::Signal;
111    use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
112    use rust_decimal_macros::dec;
113
114    fn bar(h: &str, l: &str, c: &str) -> OhlcvBar {
115        let hp = Price::new(h.parse().unwrap()).unwrap();
116        let lp = Price::new(l.parse().unwrap()).unwrap();
117        let cp = Price::new(c.parse().unwrap()).unwrap();
118        OhlcvBar {
119            symbol: Symbol::new("X").unwrap(),
120            open: lp, high: hp, low: lp, close: cp,
121            volume: Quantity::zero(),
122            ts_open: NanoTimestamp::new(0),
123            ts_close: NanoTimestamp::new(1),
124            tick_count: 1,
125        }
126    }
127
128    #[test]
129    fn test_rmp_invalid_period() {
130        assert!(RangeMidpointPosition::new("rmp", 0).is_err());
131    }
132
133    #[test]
134    fn test_rmp_unavailable_before_period() {
135        let mut s = RangeMidpointPosition::new("rmp", 3).unwrap();
136        assert_eq!(s.update_bar(&bar("110","90","100")).unwrap(), SignalValue::Unavailable);
137        assert_eq!(s.update_bar(&bar("112","88","100")).unwrap(), SignalValue::Unavailable);
138        assert!(!s.is_ready());
139    }
140
141    #[test]
142    fn test_rmp_close_at_midpoint_gives_zero() {
143        // Period high=110, low=90 → midpoint=100. Close=100 → position=0
144        let mut s = RangeMidpointPosition::new("rmp", 2).unwrap();
145        s.update_bar(&bar("110","90","100")).unwrap();
146        if let SignalValue::Scalar(v) = s.update_bar(&bar("110","90","100")).unwrap() {
147            assert!(v.abs() < dec!(0.001), "close at midpoint should give 0: {v}");
148        } else {
149            panic!("expected Scalar");
150        }
151    }
152
153    #[test]
154    fn test_rmp_close_at_high_gives_positive() {
155        let mut s = RangeMidpointPosition::new("rmp", 2).unwrap();
156        s.update_bar(&bar("110","90","100")).unwrap();
157        if let SignalValue::Scalar(v) = s.update_bar(&bar("110","90","110")).unwrap() {
158            assert!(v > dec!(0), "close at high should give positive: {v}");
159        } else {
160            panic!("expected Scalar");
161        }
162    }
163
164    #[test]
165    fn test_rmp_in_range_negative_one_to_one() {
166        let mut s = RangeMidpointPosition::new("rmp", 3).unwrap();
167        for (h,l,c) in &[("110","90","100"),("115","85","95"),("112","88","110"),("108","92","89")] {
168            if let SignalValue::Scalar(v) = s.update_bar(&bar(h,l,c)).unwrap() {
169                assert!(v >= dec!(-1) && v <= dec!(1), "position out of [-1,1]: {v}");
170            }
171        }
172    }
173
174    #[test]
175    fn test_rmp_reset() {
176        let mut s = RangeMidpointPosition::new("rmp", 2).unwrap();
177        s.update_bar(&bar("110","90","100")).unwrap();
178        s.update_bar(&bar("110","90","100")).unwrap();
179        assert!(s.is_ready());
180        s.reset();
181        assert!(!s.is_ready());
182    }
183}