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    #[inline]
48    fn update(&mut self, candle: Candle) -> Option<f64> {
49        self.has_emitted = true;
50        let out = if candle.open == 0.0 {
51            // A zero open price carries no scale to normalise against.
52            0.0
53        } else {
54            (candle.close - candle.open) / candle.open
55        };
56        Some(out)
57    }
58
59    fn reset(&mut self) {
60        self.has_emitted = false;
61    }
62
63    #[inline]
64    fn warmup_period(&self) -> usize {
65        1
66    }
67
68    #[inline]
69    fn is_ready(&self) -> bool {
70        self.has_emitted
71    }
72
73    #[inline]
74    fn name(&self) -> &'static str {
75        "CloseVsOpen"
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::traits::BatchExt;
83    use approx::assert_relative_eq;
84
85    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
86        Candle::new(open, high, low, close, 1.0, ts).unwrap()
87    }
88
89    #[test]
90    fn reference_value() {
91        // (102 - 100) / 100 = 0.02.
92        let mut cvo = CloseVsOpen::new();
93        assert_relative_eq!(
94            cvo.update(candle(100.0, 103.0, 99.0, 102.0, 0)).unwrap(),
95            0.02,
96            epsilon = 1e-12
97        );
98    }
99
100    #[test]
101    fn negative_body_is_negative() {
102        let mut cvo = CloseVsOpen::new();
103        // close below open -> negative.
104        assert_relative_eq!(
105            cvo.update(candle(100.0, 101.0, 97.0, 98.0, 0)).unwrap(),
106            -0.02,
107            epsilon = 1e-12
108        );
109    }
110
111    #[test]
112    fn zero_open_yields_zero() {
113        // Candle permits a zero open (only finiteness + OHLC ordering checked).
114        let mut cvo = CloseVsOpen::new();
115        assert_relative_eq!(
116            cvo.update(candle(0.0, 1.0, 0.0, 0.5, 0)).unwrap(),
117            0.0,
118            epsilon = 1e-12
119        );
120    }
121
122    #[test]
123    fn name_metadata() {
124        let cvo = CloseVsOpen::new();
125        assert_eq!(cvo.name(), "CloseVsOpen");
126    }
127
128    #[test]
129    fn emits_from_first_candle() {
130        let mut cvo = CloseVsOpen::new();
131        assert_eq!(cvo.warmup_period(), 1);
132        assert!(!cvo.is_ready());
133        assert!(cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
134        assert!(cvo.is_ready());
135    }
136
137    #[test]
138    fn reset_clears_state() {
139        let mut cvo = CloseVsOpen::new();
140        cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0));
141        assert!(cvo.is_ready());
142        cvo.reset();
143        assert!(!cvo.is_ready());
144    }
145
146    #[test]
147    fn batch_equals_streaming() {
148        let candles: Vec<Candle> = (0..40)
149            .map(|i| {
150                let base = 100.0 + f64::from(i);
151                candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
152            })
153            .collect();
154        let mut a = CloseVsOpen::new();
155        let mut b = CloseVsOpen::new();
156        assert_eq!(
157            a.batch(&candles),
158            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
159        );
160    }
161}