Skip to main content

wickra_core/indicators/
close_vs_open.rs

1//! Close vs Open — the signed relative body of a bar.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Close vs Open — the bar's body as a signed fraction of its open price.
7///
8/// ```text
9/// CloseVsOpen = (close − open) / open
10/// ```
11///
12/// A scale-free, signed measure of how far price travelled from open to close:
13/// `+0.02` is a bar that closed 2% above its open (a green bar), `−0.02` the
14/// mirror. Unlike [`BalanceOfPower`](crate::BalanceOfPower) — which normalises
15/// the body by the bar *range* — this normalises by the *open price*, so it is
16/// directly comparable to a return and stays meaningful across instruments of
17/// different nominal price. A zero open carries no scale and yields `0`.
18///
19/// This is a stateless per-bar transform: every candle produces one value.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Candle, Indicator, CloseVsOpen};
25///
26/// let mut indicator = CloseVsOpen::new();
27/// // open 100, close 102 -> +0.02.
28/// let c = Candle::new(100.0, 103.0, 99.0, 102.0, 10.0, 0).unwrap();
29/// assert!((indicator.update(c).unwrap() - 0.02).abs() < 1e-12);
30/// ```
31#[derive(Debug, Clone, Default)]
32pub struct CloseVsOpen {
33    has_emitted: bool,
34}
35
36impl CloseVsOpen {
37    /// Construct a new Close vs Open transform.
38    pub const fn new() -> Self {
39        Self { has_emitted: false }
40    }
41}
42
43impl Indicator for CloseVsOpen {
44    type Input = Candle;
45    type Output = f64;
46
47    fn update(&mut self, candle: Candle) -> Option<f64> {
48        self.has_emitted = true;
49        let out = if candle.open == 0.0 {
50            // A zero open price carries no scale to normalise against.
51            0.0
52        } else {
53            (candle.close - candle.open) / candle.open
54        };
55        Some(out)
56    }
57
58    fn reset(&mut self) {
59        self.has_emitted = false;
60    }
61
62    fn warmup_period(&self) -> usize {
63        1
64    }
65
66    fn is_ready(&self) -> bool {
67        self.has_emitted
68    }
69
70    fn name(&self) -> &'static str {
71        "CloseVsOpen"
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::traits::BatchExt;
79    use approx::assert_relative_eq;
80
81    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
82        Candle::new(open, high, low, close, 1.0, ts).unwrap()
83    }
84
85    #[test]
86    fn reference_value() {
87        // (102 - 100) / 100 = 0.02.
88        let mut cvo = CloseVsOpen::new();
89        assert_relative_eq!(
90            cvo.update(candle(100.0, 103.0, 99.0, 102.0, 0)).unwrap(),
91            0.02,
92            epsilon = 1e-12
93        );
94    }
95
96    #[test]
97    fn negative_body_is_negative() {
98        let mut cvo = CloseVsOpen::new();
99        // close below open -> negative.
100        assert_relative_eq!(
101            cvo.update(candle(100.0, 101.0, 97.0, 98.0, 0)).unwrap(),
102            -0.02,
103            epsilon = 1e-12
104        );
105    }
106
107    #[test]
108    fn zero_open_yields_zero() {
109        // Candle permits a zero open (only finiteness + OHLC ordering checked).
110        let mut cvo = CloseVsOpen::new();
111        assert_relative_eq!(
112            cvo.update(candle(0.0, 1.0, 0.0, 0.5, 0)).unwrap(),
113            0.0,
114            epsilon = 1e-12
115        );
116    }
117
118    #[test]
119    fn name_metadata() {
120        let cvo = CloseVsOpen::new();
121        assert_eq!(cvo.name(), "CloseVsOpen");
122    }
123
124    #[test]
125    fn emits_from_first_candle() {
126        let mut cvo = CloseVsOpen::new();
127        assert_eq!(cvo.warmup_period(), 1);
128        assert!(!cvo.is_ready());
129        assert!(cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
130        assert!(cvo.is_ready());
131    }
132
133    #[test]
134    fn reset_clears_state() {
135        let mut cvo = CloseVsOpen::new();
136        cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0));
137        assert!(cvo.is_ready());
138        cvo.reset();
139        assert!(!cvo.is_ready());
140    }
141
142    #[test]
143    fn batch_equals_streaming() {
144        let candles: Vec<Candle> = (0..40)
145            .map(|i| {
146                let base = 100.0 + f64::from(i);
147                candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
148            })
149            .collect();
150        let mut a = CloseVsOpen::new();
151        let mut b = CloseVsOpen::new();
152        assert_eq!(
153            a.batch(&candles),
154            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
155        );
156    }
157}