Skip to main content

finance_solution/stocks/ta/
obv.rs

1//! # On-Balance Volume (OBV)
2//!
3//! Cumulative volume flow signed by close direction:
4//!
5//! ```text
6//! if close[i] > close[i−1]:  OBV += volume[i]
7//! if close[i] < close[i−1]:  OBV −= volume[i]
8//! if close[i] == close[i−1]: OBV unchanged
9//! ```
10//!
11//! First bar: **OBV = volume[0]** (common convention; signed flow starts on bar 1).
12//! Absolute level is arbitrary — desks watch **slope and divergence**, not a magic OBV number.
13//!
14//! ---
15//!
16//! ## Trading perspective
17//!
18//! | Story | Habit (classic) |
19//! |-------|-----------------|
20//! | Price up, OBV up | Volume confirms trend |
21//! | Price up, OBV flat/down | Bearish divergence screen |
22//! | Price down, OBV up | Bullish accumulation screen |
23//!
24//! ## vs other volume tools
25//!
26//! | | OBV | MFI | RVOL | VWAP |
27//! |--|-----|-----|------|------|
28//! | Idea | Cumulative signed volume | Bounded oscillator 0–100 | Volume vs its mean | Volume-weighted price |
29//! | Best for | Divergence / confirmation | Overbought/oversold w/ volume | Spike detection | Intraday fair value |
30//!
31//! Prefer **MFI** when you want RSI-like bounds; **OBV** when you want unbounded cumulative flow;
32//! **RVOL** for “is this bar loud?”; **VWAP** for session price, not volume trend.
33//!
34//! ## Pairs well with
35//!
36//! - **Price structure / Donchian or Supertrend** — breakout + rising OBV.
37//! - **ADX** — strong trend + confirming OBV slope.
38//! - **RSI/WillR** — oscillator extreme + OBV not confirming → divergence narrative.
39//!
40//! ---
41//!
42//! ## Engineering
43//!
44//! [`ObvParams`] (unit pack) → [`obv`] / [`ObvState`] → [`obv_solution`].  
45//! Batch uses [`ObvState`] end-to-end. Each push is **O(1)**.
46//!
47//! ## Word problem
48//!
49//! > Closes 10 → 11 → 10 with volumes 100, 200, 50. OBV path?
50//!
51//! Expect: `100`, then `100+200=300`, then `300−50=250`.
52//!
53//! ```
54//! use finance_solution::stocks::ta::{obv, ObvParams};
55//! let c = [10.0, 11.0, 10.0];
56//! let v = [100.0, 200.0, 50.0];
57//! let s = obv(&c, &v, ObvParams::default_pack()).unwrap();
58//! assert!((s.obv[0].unwrap() - 100.0).abs() < 1e-12);
59//! assert!((s.obv[1].unwrap() - 300.0).abs() < 1e-12);
60//! assert!((s.obv[2].unwrap() - 250.0).abs() < 1e-12);
61//! ```
62
63use crate::stocks::ta::common::{
64    opt_cell, require_same_len, validate_positive_volume, validate_series,
65};
66use crate::util::error::{require_finite, FinanceError, FinanceResult};
67use crate::{columns_with_strings, print_table_locale_opt};
68
69/// OBV has no lookback; pack is a unit for API consistency.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
71pub struct ObvParams;
72
73impl ObvParams {
74    pub const fn default_pack() -> Self {
75        Self
76    }
77}
78
79/// Validated pack (always succeeds).
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
81pub struct ValidatedObv {
82    params: ObvParams,
83}
84
85impl ValidatedObv {
86    pub fn new(params: ObvParams) -> FinanceResult<Self> {
87        Ok(Self { params })
88    }
89
90    pub fn params(self) -> ObvParams {
91        self.params
92    }
93
94    pub fn compute(self, closes: &[f64], volume: &[f64]) -> FinanceResult<ObvSeries> {
95        obv_validated(closes, volume, self)
96    }
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct ObvSeries {
101    pub obv: Vec<Option<f64>>,
102    pub params: ObvParams,
103}
104
105impl ObvSeries {
106    pub fn last(&self) -> Option<f64> {
107        self.obv.iter().rev().find_map(|x| *x)
108    }
109}
110
111/// Incremental OBV. Each [`push`](Self::push) is O(1).
112#[derive(Clone, Debug)]
113pub struct ObvState {
114    params: ObvParams,
115    prev_close: Option<f64>,
116    obv: Option<f64>,
117    last: Option<f64>,
118}
119
120impl ObvState {
121    pub fn new(params: ObvParams) -> FinanceResult<Self> {
122        let _ = ValidatedObv::new(params)?;
123        Ok(Self {
124            params,
125            prev_close: None,
126            obv: None,
127            last: None,
128        })
129    }
130
131    pub fn from_history(params: ObvParams, closes: &[f64], volume: &[f64]) -> FinanceResult<Self> {
132        let mut s = Self::new(params)?;
133        let _ = s.push_bars(closes, volume)?;
134        Ok(s)
135    }
136
137    pub fn params(&self) -> ObvParams {
138        self.params
139    }
140
141    pub fn reset(&mut self) {
142        self.prev_close = None;
143        self.obv = None;
144        self.last = None;
145    }
146
147    pub fn push(&mut self, close: f64, volume: f64) -> FinanceResult<Option<f64>> {
148        require_finite("close", close)?;
149        require_finite("volume", volume)?;
150        if volume < 0.0 {
151            return Err(FinanceError::InvalidCashflow {
152                message: "volume must be non-negative",
153            });
154        }
155        let val = match self.prev_close {
156            None => volume,
157            Some(pc) => {
158                let mut o = self.obv.unwrap_or(0.0);
159                if close > pc {
160                    o += volume;
161                } else if close < pc {
162                    o -= volume;
163                }
164                o
165            }
166        };
167        self.prev_close = Some(close);
168        self.obv = Some(val);
169        self.last = Some(val);
170        Ok(Some(val))
171    }
172
173    pub fn push_bars(&mut self, closes: &[f64], volume: &[f64]) -> FinanceResult<Vec<Option<f64>>> {
174        validate_series("close", closes)?;
175        validate_positive_volume(volume)?;
176        require_same_len(closes, volume, "close/volume")?;
177        let mut out = Vec::with_capacity(closes.len());
178        for i in 0..closes.len() {
179            out.push(self.push(closes[i], volume[i])?);
180        }
181        Ok(out)
182    }
183
184    pub fn last(&self) -> Option<f64> {
185        self.last
186    }
187}
188
189pub fn obv(closes: &[f64], volume: &[f64], params: ObvParams) -> FinanceResult<ObvSeries> {
190    ValidatedObv::new(params)?.compute(closes, volume)
191}
192
193fn obv_validated(closes: &[f64], volume: &[f64], eng: ValidatedObv) -> FinanceResult<ObvSeries> {
194    let mut st = ObvState::new(eng.params)?;
195    let obv = st.push_bars(closes, volume)?;
196    Ok(ObvSeries {
197        obv,
198        params: eng.params,
199    })
200}
201
202#[derive(Clone, Debug)]
203pub struct ObvSolution {
204    series: ObvSeries,
205    close: Vec<f64>,
206    volume: Vec<f64>,
207    formula: String,
208}
209
210impl ObvSolution {
211    pub fn series(&self) -> &ObvSeries {
212        &self.series
213    }
214    pub fn formula(&self) -> &str {
215        &self.formula
216    }
217
218    pub fn print_table(&self) {
219        self.print_table_locale_opt(None, None);
220    }
221
222    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
223        self.print_table_locale_opt(Some(locale), Some(precision));
224    }
225
226    fn print_table_locale_opt(
227        &self,
228        locale: Option<&num_format::Locale>,
229        precision: Option<usize>,
230    ) {
231        let columns = columns_with_strings(&[
232            ("period", "i", true),
233            ("close", "f", true),
234            ("volume", "f", true),
235            ("obv", "f", true),
236        ]);
237        let data = self
238            .close
239            .iter()
240            .enumerate()
241            .map(|(i, c)| {
242                vec![
243                    i.to_string(),
244                    c.to_string(),
245                    self.volume[i].to_string(),
246                    opt_cell(self.series.obv[i]),
247                ]
248            })
249            .collect();
250        print_table_locale_opt(&columns, data, locale, precision);
251    }
252}
253
254/// # Examples
255/// ```
256/// use finance_solution::stocks::ta::{obv_solution, ObvParams};
257/// let c = [10.0, 11.0, 10.5];
258/// let v = [100.0, 50.0, 25.0];
259/// let sol = obv_solution(&c, &v, ObvParams::default_pack()).unwrap();
260/// assert!(sol.series().last().is_some());
261/// ```
262pub fn obv_solution(
263    closes: &[f64],
264    volume: &[f64],
265    params: ObvParams,
266) -> FinanceResult<ObvSolution> {
267    let series = obv(closes, volume, params)?;
268    Ok(ObvSolution {
269        series,
270        close: closes.to_vec(),
271        volume: volume.to_vec(),
272        formula: "OBV: +vol on up close, −vol on down close, flat otherwise; seed = vol[0]"
273            .to_string(),
274    })
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn word_problem() {
283        let c = [10.0, 11.0, 10.0];
284        let v = [100.0, 200.0, 50.0];
285        let s = obv(&c, &v, ObvParams::default_pack()).unwrap();
286        assert!((s.obv[0].unwrap() - 100.0).abs() < 1e-12);
287        assert!((s.obv[1].unwrap() - 300.0).abs() < 1e-12);
288        assert!((s.obv[2].unwrap() - 250.0).abs() < 1e-12);
289    }
290
291    #[test]
292    fn flat_close_no_volume_change() {
293        let c = [10.0, 10.0];
294        let v = [100.0, 999.0];
295        let s = obv(&c, &v, ObvParams::default_pack()).unwrap();
296        assert!((s.obv[1].unwrap() - 100.0).abs() < 1e-12);
297    }
298
299    #[test]
300    fn state_parity() {
301        let c: Vec<_> = (0..40).map(|i| 100.0 + (i as f64).sin()).collect();
302        let v: Vec<_> = (0..40).map(|i| 1000.0 + i as f64).collect();
303        let p = ObvParams::default_pack();
304        let batch = obv(&c, &v, p).unwrap();
305        let mut st = ObvState::new(p).unwrap();
306        for i in 0..c.len() {
307            let o = st.push(c[i], v[i]).unwrap();
308            assert!((o.unwrap() - batch.obv[i].unwrap()).abs() < 1e-12);
309        }
310    }
311
312    #[test]
313    fn negative_volume_err() {
314        assert!(obv(&[1.0], &[-1.0], ObvParams::default_pack()).is_err());
315    }
316}