1#[cfg(feature = "serde")]
21use serde::{Deserialize, Serialize};
22
23use crate::model::{
24 ContinuityKind, LiquidityTier, PriceAdjustment, Provenance, SeriesCapabilities, VolumeKind,
25};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
32pub struct DataRequirements {
33 pub needs_real_volume: bool,
35 pub needs_trade_direction: bool,
37 pub session_sensitive: bool,
39 pub roll_sensitive: bool,
42 pub adjustment_sensitive: bool,
45 pub needs_liquidity_depth: bool,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub enum ApplicabilityReason {
53 VolumeIsTickNotTurnover,
56 NoVolumeAvailable,
58 NoTradeDirection,
60 BackAdjustedLevelsNotTraded,
63 UnadjustedRollJumps,
65 AdjustedLevelsShifted,
68 ThinLiquidity,
70 ProviderSpecificExtreme,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(Serialize))]
95pub struct ApplicabilityNote {
96 pub reason: ApplicabilityReason,
97 pub explanation: &'static str,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
103pub enum ApplicabilityTier {
104 Degraded,
105 Unsuitable,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(Serialize))]
115pub struct RuleDescription {
116 pub reason: ApplicabilityReason,
117 pub tier: ApplicabilityTier,
118 pub explanation: &'static str,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
126#[cfg_attr(feature = "serde", derive(Serialize))]
127pub enum Applicability {
128 Applicable,
129 Degraded {
132 reasons: Vec<ApplicabilityNote>,
133 },
134 Unsuitable {
136 reasons: Vec<ApplicabilityNote>,
137 },
138}
139
140const RULE_VOLUME_IS_TICK: RuleDescription = RuleDescription {
147 reason: ApplicabilityReason::VolumeIsTickNotTurnover,
148 tier: ApplicabilityTier::Unsuitable,
149 explanation: "zeigt Update-Häufigkeit, nicht Umsatz",
150};
151
152const RULE_NO_VOLUME: RuleDescription = RuleDescription {
154 reason: ApplicabilityReason::NoVolumeAvailable,
155 tier: ApplicabilityTier::Unsuitable,
156 explanation: "die Serie hat kein Volumen; ein Index ist ein berechneter Wert — \
157 Volumen-Referenz wäre Future oder ETF",
158};
159
160const RULE_NO_TRADE_DIRECTION: RuleDescription = RuleDescription {
173 reason: ApplicabilityReason::NoTradeDirection,
174 tier: ApplicabilityTier::Unsuitable,
175 explanation: "die Serie enthält keine klassifizierten Einzeltrades, nur aggregierte Bars",
176};
177
178const RULE_BACK_ADJUSTED: RuleDescription = RuleDescription {
180 reason: ApplicabilityReason::BackAdjustedLevelsNotTraded,
181 tier: ApplicabilityTier::Degraded,
182 explanation: "historische Niveaus sind nicht die gehandelten",
183};
184
185const RULE_UNADJUSTED_ROLL: RuleDescription = RuleDescription {
187 reason: ApplicabilityReason::UnadjustedRollJumps,
188 tier: ApplicabilityTier::Degraded,
189 explanation: "Roll-Sprünge in der Reihe",
190};
191
192const RULE_ADJUSTED_SHIFTED: RuleDescription = RuleDescription {
194 reason: ApplicabilityReason::AdjustedLevelsShifted,
195 tier: ApplicabilityTier::Degraded,
196 explanation: "historische Marken liegen in der bereinigten Reihe an anderen Zahlen",
197};
198
199const RULE_THIN_LIQUIDITY: RuleDescription = RuleDescription {
201 reason: ApplicabilityReason::ThinLiquidity,
202 tier: ApplicabilityTier::Degraded,
203 explanation: "geringe Liquidität; Markttiefe für diese Kennzahl nicht ausreichend abgebildet",
204};
205
206const RULE_PROVIDER_SPECIFIC_EXTREME: RuleDescription = RuleDescription {
210 reason: ApplicabilityReason::ProviderSpecificExtreme,
211 tier: ApplicabilityTier::Degraded,
212 explanation: "Extreme sind anbieterspezifisch, eine Auszeichnung ist nicht übertragbar",
213};
214
215pub fn rule_catalog() -> [RuleDescription; 8] {
218 [
219 RULE_VOLUME_IS_TICK,
220 RULE_NO_VOLUME,
221 RULE_NO_TRADE_DIRECTION,
222 RULE_BACK_ADJUSTED,
223 RULE_UNADJUSTED_ROLL,
224 RULE_ADJUSTED_SHIFTED,
225 RULE_THIN_LIQUIDITY,
226 RULE_PROVIDER_SPECIFIC_EXTREME,
227 ]
228}
229
230fn note_from(rule: RuleDescription) -> ApplicabilityNote {
231 ApplicabilityNote {
232 reason: rule.reason,
233 explanation: rule.explanation,
234 }
235}
236
237pub fn check_applicability(
245 requirements: &DataRequirements,
246 capabilities: &SeriesCapabilities,
247) -> Applicability {
248 let mut unsuitable_notes = Vec::new();
249 let mut degraded_notes = Vec::new();
250
251 if requirements.needs_real_volume && capabilities.volume == VolumeKind::Tick {
252 unsuitable_notes.push(note_from(RULE_VOLUME_IS_TICK));
253 }
254
255 if requirements.needs_real_volume && capabilities.volume == VolumeKind::None {
256 unsuitable_notes.push(note_from(RULE_NO_VOLUME));
257 }
258
259 if requirements.needs_trade_direction && !capabilities.trade_direction {
260 unsuitable_notes.push(note_from(RULE_NO_TRADE_DIRECTION));
261 }
262
263 if requirements.roll_sensitive
264 && capabilities.continuity == ContinuityKind::StitchedBackAdjusted
265 {
266 degraded_notes.push(note_from(RULE_BACK_ADJUSTED));
267 }
268
269 if requirements.roll_sensitive && capabilities.continuity == ContinuityKind::StitchedUnadjusted
270 {
271 degraded_notes.push(note_from(RULE_UNADJUSTED_ROLL));
272 }
273
274 if requirements.adjustment_sensitive && capabilities.price_adjustment != PriceAdjustment::Raw {
275 degraded_notes.push(note_from(RULE_ADJUSTED_SHIFTED));
276 }
277
278 if requirements.needs_liquidity_depth && capabilities.liquidity_tier == LiquidityTier::Thin {
279 degraded_notes.push(note_from(RULE_THIN_LIQUIDITY));
280 }
281
282 if (requirements.session_sensitive || requirements.roll_sensitive)
283 && capabilities.provenance == Provenance::Broker
284 {
285 degraded_notes.push(note_from(RULE_PROVIDER_SPECIFIC_EXTREME));
286 }
287
288 if !unsuitable_notes.is_empty() {
289 unsuitable_notes.extend(degraded_notes);
290 Applicability::Unsuitable {
291 reasons: unsuitable_notes,
292 }
293 } else if !degraded_notes.is_empty() {
294 Applicability::Degraded {
295 reasons: degraded_notes,
296 }
297 } else {
298 Applicability::Applicable
299 }
300}
301
302pub fn data_requirements(name: &str) -> DataRequirements {
320 match name.to_lowercase().as_str() {
321 "volume_profile"
322 | "extended_volume_profile"
323 | "persistent_volume_profile"
324 | "money_flow_profile" => DataRequirements {
325 needs_real_volume: true,
326 roll_sensitive: true,
327 ..DataRequirements::default()
328 },
329 "vwap" | "anchored_vwap" | "rvat" => DataRequirements {
330 needs_real_volume: true,
331 session_sensitive: true,
332 ..DataRequirements::default()
333 },
334 "cvd" | "buy_sell_pressure" | "efi" | "mfi" | "eom" | "nvi" | "pvi" | "pvt" | "klinger"
335 | "chaikin_oscillator" | "elder_ray" | "midas" => DataRequirements {
336 needs_real_volume: true,
337 ..DataRequirements::default()
338 },
339 "pivot_sets" | "pivots_structure" | "zigzag" | "zigzag_advanced" | "elliott"
340 | "swing_structure" => DataRequirements {
341 session_sensitive: true,
342 roll_sensitive: true,
343 adjustment_sensitive: true,
344 ..DataRequirements::default()
345 },
346 _ => DataRequirements::default(),
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::model::SessionKind;
354
355 fn baseline_capabilities() -> SeriesCapabilities {
359 SeriesCapabilities {
360 volume: VolumeKind::RealTurnover,
361 trade_direction: false,
362 session: SessionKind::Regular,
363 continuity: ContinuityKind::SingleContract,
364 price_adjustment: PriceAdjustment::Raw,
365 provenance: Provenance::Exchange,
366 liquidity_tier: LiquidityTier::Deep,
367 }
368 }
369
370 fn only_unsuitable(applicability: &Applicability) -> &[ApplicabilityNote] {
371 match applicability {
372 Applicability::Unsuitable { reasons } => reasons,
373 other => panic!("expected Unsuitable, got {other:?}"),
374 }
375 }
376
377 fn only_degraded(applicability: &Applicability) -> &[ApplicabilityNote] {
378 match applicability {
379 Applicability::Degraded { reasons } => reasons,
380 other => panic!("expected Degraded, got {other:?}"),
381 }
382 }
383
384 #[test]
385 fn rule1_real_volume_on_tick_is_unsuitable() {
386 let requirements = DataRequirements {
387 needs_real_volume: true,
388 ..DataRequirements::default()
389 };
390 let capabilities = SeriesCapabilities {
391 volume: VolumeKind::Tick,
392 ..baseline_capabilities()
393 };
394 let result = check_applicability(&requirements, &capabilities);
395 let reasons = only_unsuitable(&result);
396 assert!(reasons
397 .iter()
398 .any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
399 }
400
401 #[test]
402 fn rule2_real_volume_on_none_is_unsuitable() {
403 let requirements = DataRequirements {
404 needs_real_volume: true,
405 ..DataRequirements::default()
406 };
407 let capabilities = SeriesCapabilities {
408 volume: VolumeKind::None,
409 ..baseline_capabilities()
410 };
411 let result = check_applicability(&requirements, &capabilities);
412 let reasons = only_unsuitable(&result);
413 assert!(reasons
414 .iter()
415 .any(|n| n.reason == ApplicabilityReason::NoVolumeAvailable));
416 }
417
418 #[test]
419 fn rule3_trade_direction_missing_is_unsuitable() {
420 let requirements = DataRequirements {
421 needs_trade_direction: true,
422 ..DataRequirements::default()
423 };
424 let capabilities = SeriesCapabilities {
425 trade_direction: false,
426 ..baseline_capabilities()
427 };
428 let result = check_applicability(&requirements, &capabilities);
429 let reasons = only_unsuitable(&result);
430 assert!(reasons
431 .iter()
432 .any(|n| n.reason == ApplicabilityReason::NoTradeDirection));
433 }
434
435 #[test]
436 fn rule4_roll_sensitive_on_back_adjusted_is_degraded() {
437 let requirements = DataRequirements {
438 roll_sensitive: true,
439 ..DataRequirements::default()
440 };
441 let capabilities = SeriesCapabilities {
442 continuity: ContinuityKind::StitchedBackAdjusted,
443 ..baseline_capabilities()
444 };
445 let result = check_applicability(&requirements, &capabilities);
446 let reasons = only_degraded(&result);
447 assert!(reasons
448 .iter()
449 .any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
450 }
451
452 #[test]
453 fn rule5_roll_sensitive_on_unadjusted_stitched_is_degraded() {
454 let requirements = DataRequirements {
455 roll_sensitive: true,
456 ..DataRequirements::default()
457 };
458 let capabilities = SeriesCapabilities {
459 continuity: ContinuityKind::StitchedUnadjusted,
460 ..baseline_capabilities()
461 };
462 let result = check_applicability(&requirements, &capabilities);
463 let reasons = only_degraded(&result);
464 assert!(reasons
465 .iter()
466 .any(|n| n.reason == ApplicabilityReason::UnadjustedRollJumps));
467 }
468
469 #[test]
470 fn rule6_adjustment_sensitive_on_adjusted_series_is_degraded() {
471 let requirements = DataRequirements {
472 adjustment_sensitive: true,
473 ..DataRequirements::default()
474 };
475 let capabilities = SeriesCapabilities {
476 price_adjustment: PriceAdjustment::SplitAndDividend,
477 ..baseline_capabilities()
478 };
479 let result = check_applicability(&requirements, &capabilities);
480 let reasons = only_degraded(&result);
481 assert!(reasons
482 .iter()
483 .any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
484 }
485
486 #[test]
487 fn rule7_liquidity_depth_on_thin_series_is_degraded() {
488 let requirements = DataRequirements {
489 needs_liquidity_depth: true,
490 ..DataRequirements::default()
491 };
492 let capabilities = SeriesCapabilities {
493 liquidity_tier: LiquidityTier::Thin,
494 ..baseline_capabilities()
495 };
496 let result = check_applicability(&requirements, &capabilities);
497 let reasons = only_degraded(&result);
498 assert!(reasons
499 .iter()
500 .any(|n| n.reason == ApplicabilityReason::ThinLiquidity));
501 }
502
503 #[test]
504 fn rule8_extreme_checking_on_broker_data_is_degraded() {
505 let requirements = DataRequirements {
506 session_sensitive: true,
507 ..DataRequirements::default()
508 };
509 let capabilities = SeriesCapabilities {
510 provenance: Provenance::Broker,
511 ..baseline_capabilities()
512 };
513 let result = check_applicability(&requirements, &capabilities);
514 let reasons = only_degraded(&result);
515 assert!(reasons
516 .iter()
517 .any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));
518
519 let requirements2 = DataRequirements {
521 roll_sensitive: true,
522 ..DataRequirements::default()
523 };
524 let result2 = check_applicability(&requirements2, &capabilities);
525 let reasons2 = only_degraded(&result2);
526 assert!(reasons2
527 .iter()
528 .any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));
529 }
530
531 #[test]
532 fn no_requirements_is_always_applicable() {
533 let requirements = DataRequirements::default();
534 let capabilities_variants = [
535 baseline_capabilities(),
536 SeriesCapabilities {
537 volume: VolumeKind::None,
538 continuity: ContinuityKind::StitchedBackAdjusted,
539 price_adjustment: PriceAdjustment::SplitAndDividend,
540 provenance: Provenance::Broker,
541 liquidity_tier: LiquidityTier::Thin,
542 ..baseline_capabilities()
543 },
544 ];
545 for capabilities in capabilities_variants {
546 assert_eq!(
547 check_applicability(&requirements, &capabilities),
548 Applicability::Applicable
549 );
550 }
551 }
552
553 #[test]
554 fn multiple_violations_collect_into_one_result_with_all_notes() {
555 let requirements = DataRequirements {
558 needs_real_volume: true,
559 roll_sensitive: true,
560 adjustment_sensitive: true,
561 ..DataRequirements::default()
562 };
563 let capabilities = SeriesCapabilities {
564 volume: VolumeKind::Tick,
565 continuity: ContinuityKind::StitchedBackAdjusted,
566 price_adjustment: PriceAdjustment::Split,
567 ..baseline_capabilities()
568 };
569 let result = check_applicability(&requirements, &capabilities);
570 let reasons = only_unsuitable(&result);
571 assert_eq!(reasons.len(), 3);
572 assert!(reasons
573 .iter()
574 .any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
575 assert!(reasons
576 .iter()
577 .any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
578 assert!(reasons
579 .iter()
580 .any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
581 }
582
583 #[test]
584 fn data_requirements_maps_known_names() {
585 assert_eq!(
586 data_requirements("volume_profile"),
587 DataRequirements {
588 needs_real_volume: true,
589 roll_sensitive: true,
590 ..DataRequirements::default()
591 }
592 );
593 assert_eq!(
594 data_requirements("VWAP"),
595 DataRequirements {
596 needs_real_volume: true,
597 session_sensitive: true,
598 ..DataRequirements::default()
599 }
600 );
601 assert_eq!(
602 data_requirements("elliott"),
603 DataRequirements {
604 session_sensitive: true,
605 roll_sensitive: true,
606 adjustment_sensitive: true,
607 ..DataRequirements::default()
608 }
609 );
610 }
611
612 #[test]
613 fn data_requirements_unmapped_name_is_default() {
614 assert_eq!(data_requirements("rsi"), DataRequirements::default());
615 }
616
617 #[test]
618 fn rule_catalog_has_one_entry_per_reason_with_matching_tier() {
619 let rules = rule_catalog();
620 assert_eq!(rules.len(), 8);
621
622 let unsuitable_reasons = [
625 ApplicabilityReason::VolumeIsTickNotTurnover,
626 ApplicabilityReason::NoVolumeAvailable,
627 ApplicabilityReason::NoTradeDirection,
628 ];
629 for reason in unsuitable_reasons {
630 let rule = rules.iter().find(|r| r.reason == reason).unwrap();
631 assert_eq!(rule.tier, ApplicabilityTier::Unsuitable);
632 }
633 let degraded_reasons = [
634 ApplicabilityReason::BackAdjustedLevelsNotTraded,
635 ApplicabilityReason::UnadjustedRollJumps,
636 ApplicabilityReason::AdjustedLevelsShifted,
637 ApplicabilityReason::ThinLiquidity,
638 ApplicabilityReason::ProviderSpecificExtreme,
639 ];
640 for reason in degraded_reasons {
641 let rule = rules.iter().find(|r| r.reason == reason).unwrap();
642 assert_eq!(rule.tier, ApplicabilityTier::Degraded);
643 }
644 }
645}