Skip to main content

wickra_core/indicators/
ad_volume_line.rs

1//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume.
2
3use crate::cross_section::CrossSection;
4use crate::traits::Indicator;
5
6/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of
7/// net advancing volume across a universe.
8///
9/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`,
10/// where advancing volume is the total volume of symbols with a positive change
11/// and declining volume the total volume of symbols with a negative change. The
12/// line accumulates this net over time, so a rising line means volume is flowing
13/// into advancing issues (healthy participation) while a falling line warns that
14/// declining issues are carrying the volume — the volume-weighted analogue of the
15/// plain Advance/Decline Line.
16///
17/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the
18/// first tick).
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member};
24///
25/// let mut adv = AdVolumeLine::new();
26/// // advancing volume 150, declining volume 50 -> net +100.
27/// let tick = CrossSection::new(
28///     vec![
29///         Member::new(1.0, 150.0, false, false),
30///         Member::new(-1.0, 50.0, false, false),
31///     ],
32///     0,
33/// )
34/// .unwrap();
35/// assert_eq!(adv.update(tick), Some(100.0));
36/// ```
37#[derive(Debug, Clone, Default)]
38pub struct AdVolumeLine {
39    line: f64,
40    has_emitted: bool,
41}
42
43impl AdVolumeLine {
44    /// Construct a new Advance/Decline Volume Line indicator.
45    #[must_use]
46    pub const fn new() -> Self {
47        Self {
48            line: 0.0,
49            has_emitted: false,
50        }
51    }
52}
53
54impl Indicator for AdVolumeLine {
55    type Input = CrossSection;
56    type Output = f64;
57
58    #[inline]
59    fn update(&mut self, section: CrossSection) -> Option<f64> {
60        let net = section.advancing_volume() - section.declining_volume();
61        self.line += net;
62        self.has_emitted = true;
63        Some(self.line)
64    }
65
66    fn reset(&mut self) {
67        self.line = 0.0;
68        self.has_emitted = false;
69    }
70
71    #[inline]
72    fn warmup_period(&self) -> usize {
73        1
74    }
75
76    #[inline]
77    fn is_ready(&self) -> bool {
78        self.has_emitted
79    }
80
81    #[inline]
82    fn name(&self) -> &'static str {
83        "AdVolumeLine"
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::cross_section::Member;
91    use crate::traits::BatchExt;
92
93    fn tick(items: &[(f64, f64)]) -> CrossSection {
94        CrossSection::new(
95            items
96                .iter()
97                .map(|&(change, volume)| Member::new(change, volume, false, false))
98                .collect(),
99            0,
100        )
101        .unwrap()
102    }
103
104    #[test]
105    fn accessors_and_metadata() {
106        let adv = AdVolumeLine::new();
107        assert_eq!(adv.name(), "AdVolumeLine");
108        assert_eq!(adv.warmup_period(), 1);
109        assert!(!adv.is_ready());
110    }
111
112    #[test]
113    fn first_tick_emits_net_volume() {
114        let mut adv = AdVolumeLine::new();
115        assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
116        assert!(adv.is_ready());
117    }
118
119    #[test]
120    fn line_accumulates_across_ticks() {
121        let mut adv = AdVolumeLine::new();
122        assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
123        assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
124        assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
125    }
126
127    #[test]
128    fn unchanged_volume_is_ignored() {
129        let mut adv = AdVolumeLine::new();
130        // Unchanged symbols (zero change) contribute to neither bucket.
131        assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
132    }
133
134    #[test]
135    fn reset_clears_state() {
136        let mut adv = AdVolumeLine::new();
137        adv.update(tick(&[(1.0, 100.0)]));
138        assert!(adv.is_ready());
139        adv.reset();
140        assert!(!adv.is_ready());
141        assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
142    }
143
144    #[test]
145    fn batch_equals_streaming() {
146        let sections = vec![
147            tick(&[(1.0, 150.0), (-1.0, 50.0)]),
148            tick(&[(1.0, 60.0), (-1.0, 60.0)]),
149            tick(&[(1.0, 30.0)]),
150        ];
151        let mut a = AdVolumeLine::new();
152        let mut b = AdVolumeLine::new();
153        assert_eq!(
154            a.batch(&sections),
155            sections
156                .iter()
157                .map(|s| b.update(s.clone()))
158                .collect::<Vec<_>>()
159        );
160    }
161}