Skip to main content

egui_charts/studies/builtin/
obv.rs

1//! On Balance Volume (OBV) Indicator
2//!
3//! OBV is a cumulative volume-based indicator that relates volume to price change.
4//! It adds volume on up days and subtracts volume on down days.
5//!
6//! # Interpretation
7//! - Rising OBV: Accumulation (buying pressure)
8//! - Falling OBV: Distribution (selling pressure)
9//! - OBV divergence from price can signal trend changes
10//!
11//! # Calculation
12//! OBV is 0 on the first bar. On each later bar:
13//! If close > prev_close: OBV = prev_OBV + volume
14//! If close < prev_close: OBV = prev_OBV - volume
15//! If close == prev_close: OBV = prev_OBV
16//!
17//! # Example
18//! ```
19//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
20//! use egui_charts::studies::{OnBalanceVolume, Indicator};
21//!
22//! let mut obv = OnBalanceVolume::new();
23//! obv.calculate(&bars);
24//! ```
25
26use crate::model::Bar;
27use crate::studies::palette;
28use crate::studies::stepper::Stepper;
29use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
30use egui::Color32;
31
32/// On Balance Volume indicator
33#[derive(Clone)]
34pub struct OnBalanceVolume {
35    values: IndicatorValues,
36    stepper: Stepper<State>,
37    color: Color32,
38    visible: bool,
39}
40
41#[derive(Clone, Default)]
42struct State {
43    previous: Option<f64>,
44    total: f64,
45}
46
47impl OnBalanceVolume {
48    /// Create a new OBV indicator
49    pub fn new() -> Self {
50        Self {
51            values: IndicatorValues::new(),
52            stepper: Stepper::default(),
53            color: palette::BLUE,
54            visible: true,
55        }
56    }
57
58    /// Set the indicator color
59    pub fn with_color(mut self, color: Color32) -> Self {
60        self.color = color;
61        self
62    }
63}
64
65impl Default for OnBalanceVolume {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl Indicator for OnBalanceVolume {
72    fn source(&self) -> Option<crate::studies::StudySource> {
73        Some(crate::studies::StudySource::Price(
74            crate::model::PriceSource::Close,
75        ))
76    }
77
78    fn value_format(&self) -> crate::scales::StudyFormat {
79        crate::scales::StudyFormat::Volume
80    }
81
82    fn name(&self) -> &str {
83        "OBV"
84    }
85
86    fn desc(&self) -> &str {
87        "On Balance Volume - Cumulative volume indicator"
88    }
89
90    fn calculate(&mut self, data: &[Bar]) {
91        self.update(data, 0);
92    }
93
94    fn update(&mut self, data: &[Bar], from: usize) {
95        self.stepper
96            .run(data, from, &mut self.values, |state, bar| {
97                if !bar.close.is_finite() || !bar.volume.is_finite() {
98                    return IndicatorValue::None;
99                }
100                if let Some(previous) = state.previous {
101                    if bar.close > previous {
102                        state.total += bar.volume;
103                    } else if bar.close < previous {
104                        state.total -= bar.volume;
105                    }
106                }
107                state.previous = Some(bar.close);
108                IndicatorValue::Single(state.total)
109            });
110    }
111
112    fn is_incremental(&self) -> bool {
113        true
114    }
115
116    fn values(&self) -> &IndicatorValues {
117        &self.values
118    }
119
120    fn colors(&self) -> Vec<Color32> {
121        vec![self.color]
122    }
123
124    fn set_colors(&mut self, colors: Vec<Color32>) {
125        if !colors.is_empty() {
126            self.color = colors[0];
127        }
128    }
129
130    fn is_overlay(&self) -> bool {
131        false // OBV is drawn in separate pane
132    }
133
134    fn is_visible(&self) -> bool {
135        self.visible
136    }
137
138    fn set_visible(&mut self, visible: bool) {
139        self.visible = visible;
140    }
141
142    fn clone_box(&self) -> Box<dyn Indicator> {
143        Box::new(self.clone())
144    }
145
146    fn line_names(&self) -> Vec<String> {
147        vec!["OBV".to_string()]
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use chrono::Utc;
155
156    fn bars(closes: &[f64], volumes: &[f64]) -> Vec<Bar> {
157        closes
158            .iter()
159            .zip(volumes)
160            .map(|(&c, &v)| Bar::new(Utc::now(), c, c + 1.0, c - 1.0, c, v))
161            .collect()
162    }
163
164    /// Expected values are the running total worked by hand: the first bar has
165    /// no previous close, so it adds nothing and OBV is 0 there.
166    #[test]
167    fn test_obv_matches_reference() {
168        // (closes, volumes, expected OBV)
169        type Case = (&'static [f64], &'static [f64], &'static [Option<f64>]);
170        let cases: &[Case] = &[
171            // Up, down, unchanged, a zero-volume up bar, then down.
172            (
173                &[10.0, 11.0, 10.5, 10.5, 12.0, 11.0],
174                &[1000.0, 1500.0, 700.0, 900.0, 0.0, 400.0],
175                &[
176                    Some(0.0),
177                    Some(1500.0),
178                    Some(800.0),
179                    Some(800.0),
180                    Some(800.0),
181                    Some(400.0),
182                ],
183            ),
184            // Flat series.
185            (
186                &[10.0, 10.0, 10.0],
187                &[1000.0, 2000.0, 3000.0],
188                &[Some(0.0), Some(0.0), Some(0.0)],
189            ),
190            // Single bar.
191            (&[10.0], &[5000.0], &[Some(0.0)]),
192            // A NaN volume has no value; the next bar compares with the close before it.
193            (
194                &[10.0, 11.0, 12.0, 13.0, 14.0],
195                &[100.0, 200.0, f64::NAN, 400.0, 500.0],
196                &[Some(0.0), Some(200.0), None, Some(600.0), Some(1100.0)],
197            ),
198        ];
199        for (closes, volumes, expected) in cases {
200            let mut obv = OnBalanceVolume::new();
201            obv.calculate(&bars(closes, volumes));
202            let got: Vec<Option<f64>> = obv
203                .values()
204                .to_rows()
205                .iter()
206                .map(|v| match v {
207                    IndicatorValue::Single(x) => Some(*x),
208                    IndicatorValue::None => None,
209                    other => panic!("unexpected value {other:?}"),
210                })
211                .collect();
212            assert_eq!(got, *expected, "closes {closes:?}");
213        }
214    }
215}