#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::model::{
ContinuityKind, LiquidityTier, PriceAdjustment, Provenance, SeriesCapabilities, VolumeKind,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DataRequirements {
pub needs_real_volume: bool,
pub needs_trade_direction: bool,
pub session_sensitive: bool,
pub roll_sensitive: bool,
pub adjustment_sensitive: bool,
pub needs_liquidity_depth: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ApplicabilityReason {
VolumeIsTickNotTurnover,
NoVolumeAvailable,
NoTradeDirection,
BackAdjustedLevelsNotTraded,
UnadjustedRollJumps,
AdjustedLevelsShifted,
ThinLiquidity,
ProviderSpecificExtreme,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct ApplicabilityNote {
pub reason: ApplicabilityReason,
pub explanation: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ApplicabilityTier {
Degraded,
Unsuitable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct RuleDescription {
pub reason: ApplicabilityReason,
pub tier: ApplicabilityTier,
pub explanation: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub enum Applicability {
Applicable,
Degraded {
reasons: Vec<ApplicabilityNote>,
},
Unsuitable {
reasons: Vec<ApplicabilityNote>,
},
}
const RULE_VOLUME_IS_TICK: RuleDescription = RuleDescription {
reason: ApplicabilityReason::VolumeIsTickNotTurnover,
tier: ApplicabilityTier::Unsuitable,
explanation: "zeigt Update-Häufigkeit, nicht Umsatz",
};
const RULE_NO_VOLUME: RuleDescription = RuleDescription {
reason: ApplicabilityReason::NoVolumeAvailable,
tier: ApplicabilityTier::Unsuitable,
explanation: "die Serie hat kein Volumen; ein Index ist ein berechneter Wert — \
Volumen-Referenz wäre Future oder ETF",
};
const RULE_NO_TRADE_DIRECTION: RuleDescription = RuleDescription {
reason: ApplicabilityReason::NoTradeDirection,
tier: ApplicabilityTier::Unsuitable,
explanation: "die Serie enthält keine klassifizierten Einzeltrades, nur aggregierte Bars",
};
const RULE_BACK_ADJUSTED: RuleDescription = RuleDescription {
reason: ApplicabilityReason::BackAdjustedLevelsNotTraded,
tier: ApplicabilityTier::Degraded,
explanation: "historische Niveaus sind nicht die gehandelten",
};
const RULE_UNADJUSTED_ROLL: RuleDescription = RuleDescription {
reason: ApplicabilityReason::UnadjustedRollJumps,
tier: ApplicabilityTier::Degraded,
explanation: "Roll-Sprünge in der Reihe",
};
const RULE_ADJUSTED_SHIFTED: RuleDescription = RuleDescription {
reason: ApplicabilityReason::AdjustedLevelsShifted,
tier: ApplicabilityTier::Degraded,
explanation: "historische Marken liegen in der bereinigten Reihe an anderen Zahlen",
};
const RULE_THIN_LIQUIDITY: RuleDescription = RuleDescription {
reason: ApplicabilityReason::ThinLiquidity,
tier: ApplicabilityTier::Degraded,
explanation: "geringe Liquidität; Markttiefe für diese Kennzahl nicht ausreichend abgebildet",
};
const RULE_PROVIDER_SPECIFIC_EXTREME: RuleDescription = RuleDescription {
reason: ApplicabilityReason::ProviderSpecificExtreme,
tier: ApplicabilityTier::Degraded,
explanation: "Extreme sind anbieterspezifisch, eine Auszeichnung ist nicht übertragbar",
};
pub fn rule_catalog() -> [RuleDescription; 8] {
[
RULE_VOLUME_IS_TICK,
RULE_NO_VOLUME,
RULE_NO_TRADE_DIRECTION,
RULE_BACK_ADJUSTED,
RULE_UNADJUSTED_ROLL,
RULE_ADJUSTED_SHIFTED,
RULE_THIN_LIQUIDITY,
RULE_PROVIDER_SPECIFIC_EXTREME,
]
}
fn note_from(rule: RuleDescription) -> ApplicabilityNote {
ApplicabilityNote {
reason: rule.reason,
explanation: rule.explanation,
}
}
pub fn check_applicability(
requirements: &DataRequirements,
capabilities: &SeriesCapabilities,
) -> Applicability {
let mut unsuitable_notes = Vec::new();
let mut degraded_notes = Vec::new();
if requirements.needs_real_volume && capabilities.volume == VolumeKind::Tick {
unsuitable_notes.push(note_from(RULE_VOLUME_IS_TICK));
}
if requirements.needs_real_volume && capabilities.volume == VolumeKind::None {
unsuitable_notes.push(note_from(RULE_NO_VOLUME));
}
if requirements.needs_trade_direction && !capabilities.trade_direction {
unsuitable_notes.push(note_from(RULE_NO_TRADE_DIRECTION));
}
if requirements.roll_sensitive
&& capabilities.continuity == ContinuityKind::StitchedBackAdjusted
{
degraded_notes.push(note_from(RULE_BACK_ADJUSTED));
}
if requirements.roll_sensitive && capabilities.continuity == ContinuityKind::StitchedUnadjusted
{
degraded_notes.push(note_from(RULE_UNADJUSTED_ROLL));
}
if requirements.adjustment_sensitive && capabilities.price_adjustment != PriceAdjustment::Raw {
degraded_notes.push(note_from(RULE_ADJUSTED_SHIFTED));
}
if requirements.needs_liquidity_depth && capabilities.liquidity_tier == LiquidityTier::Thin {
degraded_notes.push(note_from(RULE_THIN_LIQUIDITY));
}
if (requirements.session_sensitive || requirements.roll_sensitive)
&& capabilities.provenance == Provenance::Broker
{
degraded_notes.push(note_from(RULE_PROVIDER_SPECIFIC_EXTREME));
}
if !unsuitable_notes.is_empty() {
unsuitable_notes.extend(degraded_notes);
Applicability::Unsuitable {
reasons: unsuitable_notes,
}
} else if !degraded_notes.is_empty() {
Applicability::Degraded {
reasons: degraded_notes,
}
} else {
Applicability::Applicable
}
}
pub fn data_requirements(name: &str) -> DataRequirements {
match name.to_lowercase().as_str() {
"volume_profile"
| "extended_volume_profile"
| "persistent_volume_profile"
| "money_flow_profile" => DataRequirements {
needs_real_volume: true,
roll_sensitive: true,
..DataRequirements::default()
},
"vwap" | "anchored_vwap" | "rvat" => DataRequirements {
needs_real_volume: true,
session_sensitive: true,
..DataRequirements::default()
},
"cvd" | "buy_sell_pressure" | "efi" | "mfi" | "eom" | "nvi" | "pvi" | "pvt" | "klinger"
| "chaikin_oscillator" | "elder_ray" | "midas" => DataRequirements {
needs_real_volume: true,
..DataRequirements::default()
},
"pivot_sets" | "pivots_structure" | "zigzag" | "zigzag_advanced" | "elliott"
| "swing_structure" => DataRequirements {
session_sensitive: true,
roll_sensitive: true,
adjustment_sensitive: true,
..DataRequirements::default()
},
_ => DataRequirements::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::SessionKind;
fn baseline_capabilities() -> SeriesCapabilities {
SeriesCapabilities {
volume: VolumeKind::RealTurnover,
trade_direction: false,
session: SessionKind::Regular,
continuity: ContinuityKind::SingleContract,
price_adjustment: PriceAdjustment::Raw,
provenance: Provenance::Exchange,
liquidity_tier: LiquidityTier::Deep,
}
}
fn only_unsuitable(applicability: &Applicability) -> &[ApplicabilityNote] {
match applicability {
Applicability::Unsuitable { reasons } => reasons,
other => panic!("expected Unsuitable, got {other:?}"),
}
}
fn only_degraded(applicability: &Applicability) -> &[ApplicabilityNote] {
match applicability {
Applicability::Degraded { reasons } => reasons,
other => panic!("expected Degraded, got {other:?}"),
}
}
#[test]
fn rule1_real_volume_on_tick_is_unsuitable() {
let requirements = DataRequirements {
needs_real_volume: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
volume: VolumeKind::Tick,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_unsuitable(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
}
#[test]
fn rule2_real_volume_on_none_is_unsuitable() {
let requirements = DataRequirements {
needs_real_volume: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
volume: VolumeKind::None,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_unsuitable(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::NoVolumeAvailable));
}
#[test]
fn rule3_trade_direction_missing_is_unsuitable() {
let requirements = DataRequirements {
needs_trade_direction: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
trade_direction: false,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_unsuitable(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::NoTradeDirection));
}
#[test]
fn rule4_roll_sensitive_on_back_adjusted_is_degraded() {
let requirements = DataRequirements {
roll_sensitive: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
continuity: ContinuityKind::StitchedBackAdjusted,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_degraded(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
}
#[test]
fn rule5_roll_sensitive_on_unadjusted_stitched_is_degraded() {
let requirements = DataRequirements {
roll_sensitive: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
continuity: ContinuityKind::StitchedUnadjusted,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_degraded(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::UnadjustedRollJumps));
}
#[test]
fn rule6_adjustment_sensitive_on_adjusted_series_is_degraded() {
let requirements = DataRequirements {
adjustment_sensitive: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
price_adjustment: PriceAdjustment::SplitAndDividend,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_degraded(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
}
#[test]
fn rule7_liquidity_depth_on_thin_series_is_degraded() {
let requirements = DataRequirements {
needs_liquidity_depth: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
liquidity_tier: LiquidityTier::Thin,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_degraded(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::ThinLiquidity));
}
#[test]
fn rule8_extreme_checking_on_broker_data_is_degraded() {
let requirements = DataRequirements {
session_sensitive: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
provenance: Provenance::Broker,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_degraded(&result);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));
let requirements2 = DataRequirements {
roll_sensitive: true,
..DataRequirements::default()
};
let result2 = check_applicability(&requirements2, &capabilities);
let reasons2 = only_degraded(&result2);
assert!(reasons2
.iter()
.any(|n| n.reason == ApplicabilityReason::ProviderSpecificExtreme));
}
#[test]
fn no_requirements_is_always_applicable() {
let requirements = DataRequirements::default();
let capabilities_variants = [
baseline_capabilities(),
SeriesCapabilities {
volume: VolumeKind::None,
continuity: ContinuityKind::StitchedBackAdjusted,
price_adjustment: PriceAdjustment::SplitAndDividend,
provenance: Provenance::Broker,
liquidity_tier: LiquidityTier::Thin,
..baseline_capabilities()
},
];
for capabilities in capabilities_variants {
assert_eq!(
check_applicability(&requirements, &capabilities),
Applicability::Applicable
);
}
}
#[test]
fn multiple_violations_collect_into_one_result_with_all_notes() {
let requirements = DataRequirements {
needs_real_volume: true,
roll_sensitive: true,
adjustment_sensitive: true,
..DataRequirements::default()
};
let capabilities = SeriesCapabilities {
volume: VolumeKind::Tick,
continuity: ContinuityKind::StitchedBackAdjusted,
price_adjustment: PriceAdjustment::Split,
..baseline_capabilities()
};
let result = check_applicability(&requirements, &capabilities);
let reasons = only_unsuitable(&result);
assert_eq!(reasons.len(), 3);
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::VolumeIsTickNotTurnover));
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::BackAdjustedLevelsNotTraded));
assert!(reasons
.iter()
.any(|n| n.reason == ApplicabilityReason::AdjustedLevelsShifted));
}
#[test]
fn data_requirements_maps_known_names() {
assert_eq!(
data_requirements("volume_profile"),
DataRequirements {
needs_real_volume: true,
roll_sensitive: true,
..DataRequirements::default()
}
);
assert_eq!(
data_requirements("VWAP"),
DataRequirements {
needs_real_volume: true,
session_sensitive: true,
..DataRequirements::default()
}
);
assert_eq!(
data_requirements("elliott"),
DataRequirements {
session_sensitive: true,
roll_sensitive: true,
adjustment_sensitive: true,
..DataRequirements::default()
}
);
}
#[test]
fn data_requirements_unmapped_name_is_default() {
assert_eq!(data_requirements("rsi"), DataRequirements::default());
}
#[test]
fn rule_catalog_has_one_entry_per_reason_with_matching_tier() {
let rules = rule_catalog();
assert_eq!(rules.len(), 8);
let unsuitable_reasons = [
ApplicabilityReason::VolumeIsTickNotTurnover,
ApplicabilityReason::NoVolumeAvailable,
ApplicabilityReason::NoTradeDirection,
];
for reason in unsuitable_reasons {
let rule = rules.iter().find(|r| r.reason == reason).unwrap();
assert_eq!(rule.tier, ApplicabilityTier::Unsuitable);
}
let degraded_reasons = [
ApplicabilityReason::BackAdjustedLevelsNotTraded,
ApplicabilityReason::UnadjustedRollJumps,
ApplicabilityReason::AdjustedLevelsShifted,
ApplicabilityReason::ThinLiquidity,
ApplicabilityReason::ProviderSpecificExtreme,
];
for reason in degraded_reasons {
let rule = rules.iter().find(|r| r.reason == reason).unwrap();
assert_eq!(rule.tier, ApplicabilityTier::Degraded);
}
}
}