Skip to main content

wickra_core/
cross_section.rs

1//! Cross-section value type: a market-breadth snapshot across a whole universe.
2//!
3//! A [`CrossSection`] is a single tick that carries the per-symbol state of
4//! *every* symbol in a universe at one point in time. It is the non-OHLCV input
5//! consumed by the market-breadth indicator family (advance/decline, `McClellan`,
6//! the TRIN / Arms index, the high-low index, ...), each of which aggregates the
7//! whole cross-section into a single breadth reading. This is the same
8//! one-rich-type-per-family pattern as [`DerivativesTick`] and [`OrderBook`].
9//!
10//! Each [`Member`] precomputes the per-symbol signals the breadth indicators
11//! need — a signed price `change` (whose sign classifies the symbol as
12//! advancing, declining or unchanged), the period `volume`, the
13//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal`
14//! state flags — so the indicators stay stateless per tick and never have to
15//! track per-symbol history.
16//!
17//! [`DerivativesTick`]: crate::DerivativesTick
18//! [`OrderBook`]: crate::OrderBook
19
20use crate::error::{Error, Result};
21
22/// One symbol's contribution to a [`CrossSection`] tick.
23///
24/// Field invariants enforced by [`CrossSection::new`] when the member is placed
25/// into a tick:
26///
27/// - `change` is finite (its sign classifies the symbol — positive is
28///   advancing, negative is declining, zero is unchanged).
29/// - `volume` is finite and non-negative.
30///
31/// `new_high` / `new_low` are caller-supplied flags marking whether the symbol
32/// printed a new period extreme; `above_ma` / `on_buy_signal` are caller-supplied
33/// per-symbol state signals (whether the symbol trades above its reference moving
34/// average, and whether it is on a point-and-figure buy signal). None of the four
35/// flags carries a numeric invariant.
36#[non_exhaustive]
37#[derive(Debug, Clone, Copy, PartialEq)]
38#[allow(
39    clippy::struct_excessive_bools,
40    reason = "the four flags are independent per-symbol breadth signals, not a state machine"
41)]
42pub struct Member {
43    /// Price change versus the previous close. Sign classifies the symbol:
44    /// positive is advancing, negative is declining, zero is unchanged.
45    pub change: f64,
46    /// Period volume for the symbol (finite, non-negative).
47    pub volume: f64,
48    /// Whether the symbol printed a new period high.
49    pub new_high: bool,
50    /// Whether the symbol printed a new period low.
51    pub new_low: bool,
52    /// Whether the symbol is trading above its reference moving average
53    /// (consumed by the `% Above Moving Average` breadth indicator).
54    pub above_ma: bool,
55    /// Whether the symbol is on a point-and-figure buy signal
56    /// (consumed by the `Bullish Percent Index` breadth indicator).
57    pub on_buy_signal: bool,
58}
59
60impl Member {
61    /// Assemble a cross-section member from its core signals, leaving the
62    /// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared.
63    ///
64    /// The field invariants documented on [`Member`] are validated centrally by
65    /// [`CrossSection::new`] when the member is placed into a tick; this
66    /// constructor only assembles the value so the `#[non_exhaustive]` struct can
67    /// be built from outside the crate.
68    #[must_use]
69    pub const fn new(change: f64, volume: f64, new_high: bool, new_low: bool) -> Self {
70        Self {
71            change,
72            volume,
73            new_high,
74            new_low,
75            above_ma: false,
76            on_buy_signal: false,
77        }
78    }
79
80    /// Assemble a cross-section member including the extended per-symbol state
81    /// signals `above_ma` and `on_buy_signal`.
82    ///
83    /// Use this constructor for the breadth indicators that read per-symbol
84    /// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new)
85    /// is the shorthand that leaves both flags `false`.
86    #[must_use]
87    #[allow(
88        clippy::fn_params_excessive_bools,
89        reason = "mirrors the four independent per-symbol flag fields of Member"
90    )]
91    pub const fn with_signals(
92        change: f64,
93        volume: f64,
94        new_high: bool,
95        new_low: bool,
96        above_ma: bool,
97        on_buy_signal: bool,
98    ) -> Self {
99        Self {
100            change,
101            volume,
102            new_high,
103            new_low,
104            above_ma,
105            on_buy_signal,
106        }
107    }
108}
109
110/// A market-breadth cross-section: the per-symbol state of an entire universe at
111/// a single point in time.
112///
113/// Invariants enforced by [`new`](CrossSection::new):
114///
115/// - `members` is non-empty (a breadth reading needs at least one symbol).
116/// - every member's `change` is finite, and `volume` is finite and non-negative.
117///
118/// `timestamp` is a caller-defined epoch / resolution and is not validated.
119#[non_exhaustive]
120#[derive(Debug, Clone, PartialEq)]
121pub struct CrossSection {
122    /// Per-symbol members of the universe for this tick.
123    pub members: Vec<Member>,
124    /// Tick timestamp (caller-defined epoch / resolution).
125    pub timestamp: i64,
126}
127
128impl CrossSection {
129    /// Construct a cross-section, validating every member invariant.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`Error::InvalidCrossSection`] if `members` is empty, if any
134    /// member has a non-finite `change`, or if any member has a `volume` that is
135    /// not a finite non-negative number.
136    pub fn new(members: Vec<Member>, timestamp: i64) -> Result<Self> {
137        if members.is_empty() {
138            return Err(Error::InvalidCrossSection {
139                message: "cross-section must contain at least one member",
140            });
141        }
142        for member in &members {
143            if !member.change.is_finite() {
144                return Err(Error::InvalidCrossSection {
145                    message: "member change must be finite",
146                });
147            }
148            if !member.volume.is_finite() || member.volume < 0.0 {
149                return Err(Error::InvalidCrossSection {
150                    message: "member volume must be finite and non-negative",
151                });
152            }
153        }
154        Ok(Self { members, timestamp })
155    }
156
157    /// Construct a cross-section without validation. The caller asserts that
158    /// every invariant documented on [`CrossSection`] holds.
159    #[must_use]
160    pub const fn new_unchecked(members: Vec<Member>, timestamp: i64) -> Self {
161        Self { members, timestamp }
162    }
163
164    /// Number of advancing symbols (those with a strictly positive `change`).
165    #[must_use]
166    pub fn advancers(&self) -> usize {
167        self.members.iter().filter(|m| m.change > 0.0).count()
168    }
169
170    /// Number of declining symbols (those with a strictly negative `change`).
171    #[must_use]
172    pub fn decliners(&self) -> usize {
173        self.members.iter().filter(|m| m.change < 0.0).count()
174    }
175
176    /// Total volume traded by advancing symbols (those with positive `change`).
177    #[must_use]
178    pub fn advancing_volume(&self) -> f64 {
179        self.members
180            .iter()
181            .filter(|m| m.change > 0.0)
182            .map(|m| m.volume)
183            .sum()
184    }
185
186    /// Total volume traded by declining symbols (those with negative `change`).
187    #[must_use]
188    pub fn declining_volume(&self) -> f64 {
189        self.members
190            .iter()
191            .filter(|m| m.change < 0.0)
192            .map(|m| m.volume)
193            .sum()
194    }
195
196    /// Total volume traded across the whole universe.
197    #[must_use]
198    pub fn total_volume(&self) -> f64 {
199        self.members.iter().map(|m| m.volume).sum()
200    }
201
202    /// Number of symbols that printed a new period high.
203    #[must_use]
204    pub fn new_highs(&self) -> usize {
205        self.members.iter().filter(|m| m.new_high).count()
206    }
207
208    /// Number of symbols that printed a new period low.
209    #[must_use]
210    pub fn new_lows(&self) -> usize {
211        self.members.iter().filter(|m| m.new_low).count()
212    }
213
214    /// Number of symbols trading above their reference moving average.
215    #[must_use]
216    pub fn above_ma_count(&self) -> usize {
217        self.members.iter().filter(|m| m.above_ma).count()
218    }
219
220    /// Number of symbols on a point-and-figure buy signal.
221    #[must_use]
222    pub fn on_buy_signal_count(&self) -> usize {
223        self.members.iter().filter(|m| m.on_buy_signal).count()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn members() -> Vec<Member> {
232        vec![
233            Member::new(1.5, 100.0, true, false),
234            Member::new(-0.5, 50.0, false, true),
235            Member::new(0.0, 0.0, false, false),
236        ]
237    }
238
239    #[test]
240    fn new_accepts_valid() {
241        let cs = CrossSection::new(members(), 42).unwrap();
242        assert_eq!(cs.members.len(), 3);
243        assert_eq!(cs.timestamp, 42);
244        assert_eq!(cs.members[0].change, 1.5);
245        assert_eq!(cs.members[0].volume, 100.0);
246        assert!(cs.members[0].new_high);
247        assert!(cs.members[1].new_low);
248    }
249
250    #[test]
251    fn member_new_assembles_fields() {
252        let m = Member::new(2.0, 10.0, true, false);
253        assert_eq!(m.change, 2.0);
254        assert_eq!(m.volume, 10.0);
255        assert!(m.new_high);
256        assert!(!m.new_low);
257    }
258
259    #[test]
260    fn new_rejects_empty() {
261        assert!(matches!(
262            CrossSection::new(Vec::new(), 0),
263            Err(Error::InvalidCrossSection { .. })
264        ));
265    }
266
267    #[test]
268    fn new_rejects_non_finite_change() {
269        assert!(matches!(
270            CrossSection::new(vec![Member::new(f64::NAN, 10.0, false, false)], 0),
271            Err(Error::InvalidCrossSection { .. })
272        ));
273        assert!(matches!(
274            CrossSection::new(vec![Member::new(f64::INFINITY, 10.0, false, false)], 0),
275            Err(Error::InvalidCrossSection { .. })
276        ));
277    }
278
279    #[test]
280    fn new_rejects_negative_volume() {
281        assert!(matches!(
282            CrossSection::new(vec![Member::new(1.0, -1.0, false, false)], 0),
283            Err(Error::InvalidCrossSection { .. })
284        ));
285    }
286
287    #[test]
288    fn new_rejects_non_finite_volume() {
289        assert!(matches!(
290            CrossSection::new(vec![Member::new(1.0, f64::NAN, false, false)], 0),
291            Err(Error::InvalidCrossSection { .. })
292        ));
293    }
294
295    #[test]
296    fn new_unchecked_skips_validation() {
297        let cs = CrossSection::new_unchecked(vec![Member::new(f64::NAN, -1.0, false, false)], 7);
298        assert_eq!(cs.members.len(), 1);
299        assert_eq!(cs.timestamp, 7);
300    }
301
302    #[test]
303    fn advancers_and_decliners_count_by_sign() {
304        let cs = CrossSection::new(members(), 0).unwrap();
305        assert_eq!(cs.advancers(), 1);
306        assert_eq!(cs.decliners(), 1);
307    }
308
309    #[test]
310    fn unchanged_members_count_as_neither() {
311        let cs = CrossSection::new(
312            vec![
313                Member::new(0.0, 1.0, false, false),
314                Member::new(0.0, 1.0, false, false),
315            ],
316            0,
317        )
318        .unwrap();
319        assert_eq!(cs.advancers(), 0);
320        assert_eq!(cs.decliners(), 0);
321    }
322
323    #[test]
324    fn new_leaves_extended_flags_cleared() {
325        let m = Member::new(1.0, 10.0, true, false);
326        assert!(!m.above_ma);
327        assert!(!m.on_buy_signal);
328    }
329
330    #[test]
331    fn with_signals_assembles_all_fields() {
332        let m = Member::with_signals(2.0, 10.0, true, false, true, true);
333        assert_eq!(m.change, 2.0);
334        assert_eq!(m.volume, 10.0);
335        assert!(m.new_high);
336        assert!(!m.new_low);
337        assert!(m.above_ma);
338        assert!(m.on_buy_signal);
339    }
340
341    #[test]
342    fn volume_helpers_bucket_by_change_sign() {
343        let cs = CrossSection::new(
344            vec![
345                Member::new(1.5, 100.0, false, false), // advancing
346                Member::new(2.0, 40.0, false, false),  // advancing
347                Member::new(-0.5, 50.0, false, false), // declining
348                Member::new(0.0, 7.0, false, false),   // unchanged
349            ],
350            0,
351        )
352        .unwrap();
353        assert_eq!(cs.advancing_volume(), 140.0);
354        assert_eq!(cs.declining_volume(), 50.0);
355        assert_eq!(cs.total_volume(), 197.0);
356    }
357
358    #[test]
359    fn high_low_helpers_count_flags() {
360        let cs = CrossSection::new(
361            vec![
362                Member::new(1.0, 1.0, true, false),
363                Member::new(1.0, 1.0, true, false),
364                Member::new(-1.0, 1.0, false, true),
365            ],
366            0,
367        )
368        .unwrap();
369        assert_eq!(cs.new_highs(), 2);
370        assert_eq!(cs.new_lows(), 1);
371    }
372
373    #[test]
374    fn state_helpers_count_extended_flags() {
375        let cs = CrossSection::new(
376            vec![
377                Member::with_signals(1.0, 1.0, false, false, true, true),
378                Member::with_signals(1.0, 1.0, false, false, true, false),
379                Member::with_signals(-1.0, 1.0, false, false, false, true),
380            ],
381            0,
382        )
383        .unwrap();
384        assert_eq!(cs.above_ma_count(), 2);
385        assert_eq!(cs.on_buy_signal_count(), 2);
386    }
387}