Skip to main content

wickra_core/indicators/
tsv.rs

1//! Time Segmented Volume (Worden).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Time Segmented Volume (Don Worden) — a rolling sum of *signed* volume
10/// weighted by the bar's close-to-close move.
11///
12/// Each bar's contribution is the close change times the bar volume. Summed
13/// over a fixed window, the result quantifies the net accumulation (positive)
14/// or distribution (negative) over that span:
15///
16/// ```text
17/// flow_t = (close_t − close_{t−1}) · volume_t          (signed money flow)
18/// TSV_t  = Σ_{i = t−period+1}^{t} flow_i               (rolling window sum)
19/// ```
20///
21/// The first candle only seeds `close_{t−1}`; the first flow lands at bar 2,
22/// and the first TSV emission lands once the window has accumulated `period`
23/// flows — i.e. at bar `period + 1`. Worden's original TC2000 implementation
24/// often charts an additional EMA smoothing of TSV as a signal line; that is
25/// left to the caller via [`crate::Ema`] composition.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, Tsv};
31///
32/// let mut indicator = Tsv::new(18).unwrap();
33/// let mut last = None;
34/// for i in 0..80 {
35///     let base = 100.0 + f64::from(i);
36///     let candle =
37///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
38///     last = indicator.update(candle);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct Tsv {
44    period: usize,
45    prev_close: Option<f64>,
46    window: VecDeque<f64>,
47    sum: f64,
48}
49
50impl Tsv {
51    /// Construct a new TSV with the given rolling window length.
52    ///
53    /// # Errors
54    /// Returns [`Error::PeriodZero`] if `period == 0`.
55    pub fn new(period: usize) -> Result<Self> {
56        if period == 0 {
57            return Err(Error::PeriodZero);
58        }
59        if period > crate::error::MAX_PERIOD {
60            return Err(Error::InvalidPeriod {
61                message: crate::error::PERIOD_ABOVE_MAX,
62            });
63        }
64        Ok(Self {
65            period,
66            prev_close: None,
67            window: VecDeque::with_capacity(period),
68            sum: 0.0,
69        })
70    }
71
72    /// Configured window length.
73    pub const fn period(&self) -> usize {
74        self.period
75    }
76}
77
78impl Indicator for Tsv {
79    type Input = Candle;
80    type Output = f64;
81
82    #[inline]
83    fn update(&mut self, candle: Candle) -> Option<f64> {
84        let Some(prev) = self.prev_close else {
85            self.prev_close = Some(candle.close);
86            return None;
87        };
88        let flow = (candle.close - prev) * candle.volume;
89        self.prev_close = Some(candle.close);
90
91        if self.window.len() == self.period {
92            self.sum -= self.window.pop_front().expect("non-empty");
93        }
94        self.window.push_back(flow);
95        self.sum += flow;
96        if self.window.len() < self.period {
97            return None;
98        }
99        Some(self.sum)
100    }
101
102    fn reset(&mut self) {
103        self.prev_close = None;
104        self.window.clear();
105        self.sum = 0.0;
106    }
107
108    #[inline]
109    fn warmup_period(&self) -> usize {
110        // One seed bar for `prev_close`, then `period` flows to fill the window.
111        self.period + 1
112    }
113
114    #[inline]
115    fn is_ready(&self) -> bool {
116        self.window.len() == self.period
117    }
118
119    #[inline]
120    fn name(&self) -> &'static str {
121        "TSV"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::traits::BatchExt;
129    use approx::assert_relative_eq;
130
131    fn c(close: f64, volume: f64, ts: i64) -> Candle {
132        Candle::new(close, close, close, close, volume, ts).unwrap()
133    }
134
135    #[test]
136    fn rejects_zero_period() {
137        assert!(matches!(Tsv::new(0), Err(Error::PeriodZero)));
138    }
139
140    #[test]
141    fn accessors_and_metadata() {
142        let t = Tsv::new(18).unwrap();
143        assert_eq!(t.period(), 18);
144        assert_eq!(t.name(), "TSV");
145        assert_eq!(t.warmup_period(), 19);
146    }
147
148    #[test]
149    fn constant_close_yields_zero() {
150        // Flat close -> every flow is zero -> rolling sum stays at zero.
151        let candles: Vec<Candle> = (0..30).map(|i| c(10.0, 100.0, i)).collect();
152        let mut t = Tsv::new(5).unwrap();
153        for v in t.batch(&candles).into_iter().flatten() {
154            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
155        }
156    }
157
158    #[test]
159    fn reference_window_sum() {
160        // closes  = [10, 11, 13, 12, 14, 15]
161        // volumes = [.., 100, 200, 150, 50, 200]
162        // flows   = [None, (1)*100=100, (2)*200=400, (-1)*150=-150, (2)*50=100, (1)*200=200]
163        // period = 3: first emission at bar index 3 (the 4th flow, since one bar seeds).
164        // Wait: bar 0 seeds, bars 1..5 produce 5 flows. Window of 3 fills at the
165        // 3rd flow, i.e. bar index 3.
166        //   bar 3 -> window = [100, 400, -150] -> sum = 350.
167        //   bar 4 -> window = [400, -150, 100] -> sum = 350.
168        //   bar 5 -> window = [-150, 100, 200] -> sum = 150.
169        let mut t = Tsv::new(3).unwrap();
170        let out = t.batch(&[
171            c(10.0, 50.0, 0),
172            c(11.0, 100.0, 1),
173            c(13.0, 200.0, 2),
174            c(12.0, 150.0, 3),
175            c(14.0, 50.0, 4),
176            c(15.0, 200.0, 5),
177        ]);
178        assert!(out[0].is_none() && out[1].is_none() && out[2].is_none());
179        assert_relative_eq!(out[3].unwrap(), 350.0, epsilon = 1e-9);
180        assert_relative_eq!(out[4].unwrap(), 350.0, epsilon = 1e-9);
181        assert_relative_eq!(out[5].unwrap(), 150.0, epsilon = 1e-9);
182    }
183
184    #[test]
185    fn batch_equals_streaming() {
186        let candles: Vec<Candle> = (0..80i64)
187            .map(|i| {
188                let f = i as f64;
189                c(
190                    100.0 + (f * 0.3).sin() * 5.0,
191                    50.0 + (i % 7) as f64 * 10.0,
192                    i,
193                )
194            })
195            .collect();
196        let mut a = Tsv::new(18).unwrap();
197        let mut b = Tsv::new(18).unwrap();
198        assert_eq!(
199            a.batch(&candles),
200            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
201        );
202    }
203
204    #[test]
205    fn reset_clears_state() {
206        let candles: Vec<Candle> = (0..40).map(|i| c(10.0 + i as f64, 100.0, i)).collect();
207        let mut t = Tsv::new(10).unwrap();
208        t.batch(&candles);
209        assert!(t.is_ready());
210        t.reset();
211        assert!(!t.is_ready());
212        assert_eq!(t.update(candles[0]), None);
213    }
214}