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    fn update(&mut self, section: CrossSection) -> Option<f64> {
59        let net = section.advancing_volume() - section.declining_volume();
60        self.line += net;
61        self.has_emitted = true;
62        Some(self.line)
63    }
64
65    fn reset(&mut self) {
66        self.line = 0.0;
67        self.has_emitted = false;
68    }
69
70    fn warmup_period(&self) -> usize {
71        1
72    }
73
74    fn is_ready(&self) -> bool {
75        self.has_emitted
76    }
77
78    fn name(&self) -> &'static str {
79        "AdVolumeLine"
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::cross_section::Member;
87    use crate::traits::BatchExt;
88
89    fn tick(items: &[(f64, f64)]) -> CrossSection {
90        CrossSection::new(
91            items
92                .iter()
93                .map(|&(change, volume)| Member::new(change, volume, false, false))
94                .collect(),
95            0,
96        )
97        .unwrap()
98    }
99
100    #[test]
101    fn accessors_and_metadata() {
102        let adv = AdVolumeLine::new();
103        assert_eq!(adv.name(), "AdVolumeLine");
104        assert_eq!(adv.warmup_period(), 1);
105        assert!(!adv.is_ready());
106    }
107
108    #[test]
109    fn first_tick_emits_net_volume() {
110        let mut adv = AdVolumeLine::new();
111        assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
112        assert!(adv.is_ready());
113    }
114
115    #[test]
116    fn line_accumulates_across_ticks() {
117        let mut adv = AdVolumeLine::new();
118        assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
119        assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
120        assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
121    }
122
123    #[test]
124    fn unchanged_volume_is_ignored() {
125        let mut adv = AdVolumeLine::new();
126        // Unchanged symbols (zero change) contribute to neither bucket.
127        assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
128    }
129
130    #[test]
131    fn reset_clears_state() {
132        let mut adv = AdVolumeLine::new();
133        adv.update(tick(&[(1.0, 100.0)]));
134        assert!(adv.is_ready());
135        adv.reset();
136        assert!(!adv.is_ready());
137        assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
138    }
139
140    #[test]
141    fn batch_equals_streaming() {
142        let sections = vec![
143            tick(&[(1.0, 150.0), (-1.0, 50.0)]),
144            tick(&[(1.0, 60.0), (-1.0, 60.0)]),
145            tick(&[(1.0, 30.0)]),
146        ];
147        let mut a = AdVolumeLine::new();
148        let mut b = AdVolumeLine::new();
149        assert_eq!(
150            a.batch(&sections),
151            sections
152                .iter()
153                .map(|s| b.update(s.clone()))
154                .collect::<Vec<_>>()
155        );
156    }
157}