1use crate::error::{Error, Result};
21
22#[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 pub change: f64,
46 pub volume: f64,
48 pub new_high: bool,
50 pub new_low: bool,
52 pub above_ma: bool,
55 pub on_buy_signal: bool,
58}
59
60impl Member {
61 #[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 #[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#[non_exhaustive]
120#[derive(Debug, Clone, PartialEq)]
121pub struct CrossSection {
122 pub members: Vec<Member>,
124 pub timestamp: i64,
126}
127
128impl CrossSection {
129 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 #[must_use]
160 pub const fn new_unchecked(members: Vec<Member>, timestamp: i64) -> Self {
161 Self { members, timestamp }
162 }
163
164 #[must_use]
166 pub fn advancers(&self) -> usize {
167 self.members.iter().filter(|m| m.change > 0.0).count()
168 }
169
170 #[must_use]
172 pub fn decliners(&self) -> usize {
173 self.members.iter().filter(|m| m.change < 0.0).count()
174 }
175
176 #[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 #[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 #[must_use]
198 pub fn total_volume(&self) -> f64 {
199 self.members.iter().map(|m| m.volume).sum()
200 }
201
202 #[must_use]
204 pub fn new_highs(&self) -> usize {
205 self.members.iter().filter(|m| m.new_high).count()
206 }
207
208 #[must_use]
210 pub fn new_lows(&self) -> usize {
211 self.members.iter().filter(|m| m.new_low).count()
212 }
213
214 #[must_use]
216 pub fn above_ma_count(&self) -> usize {
217 self.members.iter().filter(|m| m.above_ma).count()
218 }
219
220 #[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), Member::new(2.0, 40.0, false, false), Member::new(-0.5, 50.0, false, false), Member::new(0.0, 7.0, false, false), ],
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}