Skip to main content

wickra_core/indicators/
murrey_math_lines.rs

1//! Murrey Math Lines — the eighths grid over the recent trading range.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Output of [`MurreyMathLines`]: the nine Murrey Math levels from the bottom
10/// (`mm0_8`, ultimate support) to the top (`mm8_8`, ultimate resistance).
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct MurreyMathLinesOutput {
13    /// 8/8 — ultimate resistance (top of the frame).
14    pub mm8_8: f64,
15    /// 7/8 — "weak, stall and reverse" (overbought).
16    pub mm7_8: f64,
17    /// 6/8 — upper pivot / reversal line.
18    pub mm6_8: f64,
19    /// 5/8 — top of the normal trading range.
20    pub mm5_8: f64,
21    /// 4/8 — the major pivot (mean) line.
22    pub mm4_8: f64,
23    /// 3/8 — bottom of the normal trading range.
24    pub mm3_8: f64,
25    /// 2/8 — lower pivot / reversal line.
26    pub mm2_8: f64,
27    /// 1/8 — "weak, stall and reverse" (oversold).
28    pub mm1_8: f64,
29    /// 0/8 — ultimate support (bottom of the frame).
30    pub mm0_8: f64,
31}
32
33/// Murrey Math Lines — T. H. Murrey's grid that divides the recent trading range
34/// into eighths, each acting as support/resistance.
35///
36/// ```text
37/// HH = highest high over `period`,  LL = lowest low over `period`
38/// step = (HH − LL) / 8
39/// mm{i}_8 = LL + i · step       for i = 0..8
40/// ```
41///
42/// Murrey Math (a Gann-derived framework) holds that price gravitates to and
43/// reverses at the eighth divisions of its range. The **4/8** line is the major
44/// pivot (mean); **0/8** and **8/8** are the strongest support and resistance;
45/// **3/8** and **5/8** bound the "normal" trading range, while **1/8**/**7/8** are
46/// the weak "stall and reverse" lines. This implementation uses the price-derived
47/// eighths over a rolling high-low frame (the practical core of the method) rather
48/// than Murrey's full octave-quantised frame sizing, so the levels track the
49/// instrument's actual recent range.
50///
51/// The first value lands after `period` inputs; each `update` rescans the frame in
52/// O(`period`). A degenerate flat frame (`HH == LL`) collapses every line onto the
53/// price.
54///
55/// # Example
56///
57/// ```
58/// use wickra_core::{Candle, Indicator, MurreyMathLines};
59///
60/// let mut indicator = MurreyMathLines::new(64).unwrap();
61/// let mut last = None;
62/// for i in 0..120 {
63///     let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
64///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
65///     last = indicator.update(c);
66/// }
67/// assert!(last.is_some());
68/// ```
69#[derive(Debug, Clone)]
70pub struct MurreyMathLines {
71    period: usize,
72    highs: VecDeque<f64>,
73    lows: VecDeque<f64>,
74    last: Option<MurreyMathLinesOutput>,
75}
76
77impl MurreyMathLines {
78    /// Construct Murrey Math Lines over a `period`-bar high-low frame.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`Error::PeriodZero`] if `period == 0`.
83    pub fn new(period: usize) -> Result<Self> {
84        if period == 0 {
85            return Err(Error::PeriodZero);
86        }
87        if period > crate::error::MAX_PERIOD {
88            return Err(Error::InvalidPeriod {
89                message: crate::error::PERIOD_ABOVE_MAX,
90            });
91        }
92        Ok(Self {
93            period,
94            highs: VecDeque::with_capacity(period),
95            lows: VecDeque::with_capacity(period),
96            last: None,
97        })
98    }
99
100    /// Configured frame period.
101    pub const fn period(&self) -> usize {
102        self.period
103    }
104
105    /// Current value if available.
106    pub const fn value(&self) -> Option<MurreyMathLinesOutput> {
107        self.last
108    }
109}
110
111impl Indicator for MurreyMathLines {
112    type Input = Candle;
113    type Output = MurreyMathLinesOutput;
114
115    #[inline]
116    fn update(&mut self, candle: Candle) -> Option<MurreyMathLinesOutput> {
117        if self.highs.len() == self.period {
118            self.highs.pop_front();
119            self.lows.pop_front();
120        }
121        self.highs.push_back(candle.high);
122        self.lows.push_back(candle.low);
123        if self.highs.len() < self.period {
124            return None;
125        }
126        let hh = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
127        let ll = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
128        let step = (hh - ll) / 8.0;
129        let level = |i: f64| ll + i * step;
130        let out = MurreyMathLinesOutput {
131            mm0_8: level(0.0),
132            mm1_8: level(1.0),
133            mm2_8: level(2.0),
134            mm3_8: level(3.0),
135            mm4_8: level(4.0),
136            mm5_8: level(5.0),
137            mm6_8: level(6.0),
138            mm7_8: level(7.0),
139            mm8_8: level(8.0),
140        };
141        self.last = Some(out);
142        Some(out)
143    }
144
145    fn reset(&mut self) {
146        self.highs.clear();
147        self.lows.clear();
148        self.last = None;
149    }
150
151    #[inline]
152    fn warmup_period(&self) -> usize {
153        self.period
154    }
155
156    #[inline]
157    fn is_ready(&self) -> bool {
158        self.last.is_some()
159    }
160
161    #[inline]
162    fn name(&self) -> &'static str {
163        "MurreyMathLines"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171    use approx::assert_relative_eq;
172
173    fn c(high: f64, low: f64) -> Candle {
174        Candle::new_unchecked(low, high, low, f64::midpoint(high, low), 1_000.0, 0)
175    }
176
177    #[test]
178    fn rejects_zero_period() {
179        assert!(matches!(MurreyMathLines::new(0), Err(Error::PeriodZero)));
180    }
181
182    #[test]
183    fn accessors_and_metadata() {
184        let m = MurreyMathLines::new(64).unwrap();
185        assert_eq!(m.period(), 64);
186        assert_eq!(m.warmup_period(), 64);
187        assert_eq!(m.name(), "MurreyMathLines");
188        assert!(!m.is_ready());
189        assert_eq!(m.value(), None);
190    }
191
192    #[test]
193    fn first_emission_at_warmup_period() {
194        let mut m = MurreyMathLines::new(4).unwrap();
195        let candles: Vec<Candle> = (0..6)
196            .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
197            .collect();
198        let out = m.batch(&candles);
199        for v in out.iter().take(3) {
200            assert!(v.is_none());
201        }
202        assert!(out[3].is_some());
203    }
204
205    #[test]
206    fn eighths_are_evenly_spaced() {
207        // Frame [100, 180] over the window -> step = 10.
208        let mut m = MurreyMathLines::new(2).unwrap();
209        let out = m
210            .batch(&[c(180.0, 100.0), c(180.0, 100.0)])
211            .into_iter()
212            .flatten()
213            .last()
214            .unwrap();
215        assert_relative_eq!(out.mm0_8, 100.0, epsilon = 1e-9);
216        assert_relative_eq!(out.mm4_8, 140.0, epsilon = 1e-9);
217        assert_relative_eq!(out.mm8_8, 180.0, epsilon = 1e-9);
218        assert_relative_eq!(out.mm1_8 - out.mm0_8, 10.0, epsilon = 1e-9);
219    }
220
221    #[test]
222    fn levels_are_ordered() {
223        let mut m = MurreyMathLines::new(10).unwrap();
224        let candles: Vec<Candle> = (0..30)
225            .map(|i| {
226                c(
227                    110.0 + (f64::from(i) * 0.3).sin() * 8.0,
228                    90.0 + (f64::from(i) * 0.3).cos() * 8.0,
229                )
230            })
231            .collect();
232        for o in m.batch(&candles).into_iter().flatten() {
233            assert!(o.mm0_8 <= o.mm4_8 && o.mm4_8 <= o.mm8_8);
234            assert!(o.mm3_8 <= o.mm5_8);
235        }
236    }
237
238    #[test]
239    fn flat_frame_collapses() {
240        let mut m = MurreyMathLines::new(3).unwrap();
241        let out = m
242            .batch(&[c(50.0, 50.0), c(50.0, 50.0), c(50.0, 50.0)])
243            .into_iter()
244            .flatten()
245            .last()
246            .unwrap();
247        assert_relative_eq!(out.mm0_8, 50.0, epsilon = 1e-12);
248        assert_relative_eq!(out.mm8_8, 50.0, epsilon = 1e-12);
249    }
250
251    #[test]
252    fn reset_clears_state() {
253        let mut m = MurreyMathLines::new(4).unwrap();
254        m.batch(
255            &(0..6)
256                .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
257                .collect::<Vec<_>>(),
258        );
259        assert!(m.is_ready());
260        m.reset();
261        assert!(!m.is_ready());
262        assert_eq!(m.value(), None);
263        assert_eq!(m.update(c(101.0, 99.0)), None);
264    }
265
266    #[test]
267    fn batch_equals_streaming() {
268        let candles: Vec<Candle> = (0..120)
269            .map(|i| {
270                c(
271                    110.0 + (f64::from(i) * 0.25).sin() * 9.0,
272                    90.0 + (f64::from(i) * 0.25).cos() * 9.0,
273                )
274            })
275            .collect();
276        let batch = MurreyMathLines::new(64).unwrap().batch(&candles);
277        let mut b = MurreyMathLines::new(64).unwrap();
278        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
279        assert_eq!(batch, streamed);
280    }
281}