Files
foxhunt/crates/ml-data-validation/src/rules.rs
jgrusewski 7ef92983f9 fix(clippy): apply cargo clippy --fix across workspace
Mechanical auto-fixes: redundant borrows, clone on Copy, or_insert_with,
single-char push_str, get(0) → first(), needless borrow, let_and_return.
150 files, no behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:17:51 +01:00

523 lines
15 KiB
Rust

//! # Validation Rules
//!
//! Individual validation rules for different aspects of data quality.
//! Each rule implements the `ValidationRule` trait and can be composed
//! into a comprehensive validation pipeline.
use crate::Indicators;
use crate::OHLCVBar;
use anyhow::Result;
/// Validation error information
#[derive(Debug, Clone)]
pub struct ValidationError {
/// Error category
pub category: String,
/// Error message
pub message: String,
/// Bar index where error occurred
pub bar_index: Option<usize>,
/// Severity (Error or Warning)
pub severity: Severity,
}
/// Error severity level
#[derive(Debug, Clone, PartialEq)]
pub enum Severity {
/// Critical error that indicates data corruption
Error,
/// Warning about potential data quality issues
Warning,
}
impl ValidationError {
/// Create new error
pub fn error<C: Into<String>, M: Into<String>>(category: C, message: M) -> Self {
Self {
category: category.into(),
message: message.into(),
bar_index: None,
severity: Severity::Error,
}
}
/// Create new warning
pub fn warning<C: Into<String>, M: Into<String>>(category: C, message: M) -> Self {
Self {
category: category.into(),
message: message.into(),
bar_index: None,
severity: Severity::Warning,
}
}
/// Set bar index
pub const fn at_index(mut self, index: usize) -> Self {
self.bar_index = Some(index);
self
}
}
/// Validation rule trait
///
/// Each rule implements specific validation logic for data quality checks.
pub trait ValidationRule: Send + Sync {
/// Validate OHLCV bars
fn validate_bars(&self, bars: &[OHLCVBar]) -> Result<Vec<ValidationError>>;
/// Validate technical indicators (optional)
fn validate_indicators(&self, _indicators: &Indicators) -> Result<Vec<ValidationError>> {
Ok(Vec::new()) // Default: no indicator validation
}
/// Rule name for reporting
fn name(&self) -> &str;
}
// ============================================================================
// Rule 1: OHLCV Integrity Rule
// ============================================================================
/// OHLCV integrity validation
///
/// Checks:
/// - high >= low
/// - high >= open, close
/// - low <= open, close
/// - volume >= 0
#[derive(Debug)]
pub struct IntegrityRule;
impl Default for IntegrityRule {
fn default() -> Self {
Self::new()
}
}
impl IntegrityRule {
pub const fn new() -> Self {
Self
}
}
impl ValidationRule for IntegrityRule {
fn name(&self) -> &str {
"OHLCV Integrity"
}
fn validate_bars(&self, bars: &[OHLCVBar]) -> Result<Vec<ValidationError>> {
let mut errors = Vec::new();
for (i, bar) in bars.iter().enumerate() {
// Check: high >= low
if bar.high < bar.low {
errors.push(
ValidationError::error(
"integrity",
format!("Bar {}: high < low ({:.2} < {:.2})", i, bar.high, bar.low),
)
.at_index(i),
);
}
// Check: high >= open, close
if bar.high < bar.open {
errors.push(
ValidationError::error(
"integrity",
format!("Bar {}: high < open ({:.2} < {:.2})", i, bar.high, bar.open),
)
.at_index(i),
);
}
if bar.high < bar.close {
errors.push(
ValidationError::error(
"integrity",
format!(
"Bar {}: high < close ({:.2} < {:.2})",
i, bar.high, bar.close
),
)
.at_index(i),
);
}
// Check: low <= open, close
if bar.low > bar.open {
errors.push(
ValidationError::error(
"integrity",
format!("Bar {}: low > open ({:.2} > {:.2})", i, bar.low, bar.open),
)
.at_index(i),
);
}
if bar.low > bar.close {
errors.push(
ValidationError::error(
"integrity",
format!("Bar {}: low > close ({:.2} > {:.2})", i, bar.low, bar.close),
)
.at_index(i),
);
}
// Check: volume >= 0
if bar.volume < 0.0 {
errors.push(
ValidationError::error(
"integrity",
format!("Bar {}: negative volume ({:.2})", i, bar.volume),
)
.at_index(i),
);
}
}
Ok(errors)
}
}
// ============================================================================
// Rule 2: Price Continuity Rule
// ============================================================================
/// Price continuity validation
///
/// Detects price spikes (large percentage changes between consecutive bars).
/// Default threshold: 20% change
#[derive(Debug)]
pub struct ContinuityRule {
/// Maximum allowed percentage change (0.20 = 20%)
threshold: f64,
}
impl ContinuityRule {
pub const fn new(threshold: f64) -> Self {
Self { threshold }
}
}
impl ValidationRule for ContinuityRule {
fn name(&self) -> &str {
"Price Continuity"
}
fn validate_bars(&self, bars: &[OHLCVBar]) -> Result<Vec<ValidationError>> {
let mut errors = Vec::new();
for i in 1..bars.len() {
let prev_close = bars[i - 1].close;
let curr_open = bars[i].open;
// Calculate percentage change
let pct_change = ((curr_open - prev_close) / prev_close).abs();
if pct_change > self.threshold {
errors.push(
ValidationError::error(
"continuity",
format!(
"Bar {}: price spike of {:.1}% (threshold: {:.1}%)",
i,
pct_change * 100.0,
self.threshold * 100.0
),
)
.at_index(i),
);
}
}
Ok(errors)
}
}
// ============================================================================
// Rule 3: Indicator Validation Rule
// ============================================================================
/// Technical indicator validation
///
/// Checks:
/// - RSI in range [0, 100]
/// - No NaN or Infinite values
/// - Bollinger bands properly ordered (upper > middle > lower)
#[derive(Debug)]
pub struct IndicatorRule;
impl Default for IndicatorRule {
fn default() -> Self {
Self::new()
}
}
impl IndicatorRule {
pub const fn new() -> Self {
Self
}
}
impl ValidationRule for IndicatorRule {
fn name(&self) -> &str {
"Technical Indicators"
}
fn validate_bars(&self, _bars: &[OHLCVBar]) -> Result<Vec<ValidationError>> {
// This rule validates indicators, not bars
Ok(Vec::new())
}
fn validate_indicators(&self, indicators: &Indicators) -> Result<Vec<ValidationError>> {
let mut errors = Vec::new();
// Validate RSI range (0-100)
for (i, &rsi) in indicators.rsi.iter().enumerate() {
if rsi.is_nan() {
errors.push(
ValidationError::error("indicator", format!("RSI at index {}: NaN value", i))
.at_index(i),
);
} else if rsi.is_infinite() {
errors.push(
ValidationError::error(
"indicator",
format!("RSI at index {}: infinite value", i),
)
.at_index(i),
);
} else if !(0.0..=100.0).contains(&rsi) {
errors.push(
ValidationError::error(
"indicator",
format!("RSI at index {}: out of range [{:.2}]", i, rsi),
)
.at_index(i),
);
} else {
// RSI value is valid
}
}
// Validate MACD for NaN/Inf
for (i, &macd) in indicators.macd.iter().enumerate() {
if macd.is_nan() || macd.is_infinite() {
errors.push(
ValidationError::error(
"indicator",
format!("MACD at index {}: invalid value", i),
)
.at_index(i),
);
}
}
// Validate Bollinger Bands ordering
for i in 0..indicators.bb_upper.len() {
let upper = indicators.bb_upper[i];
let middle = indicators.bb_middle[i];
let lower = indicators.bb_lower[i];
if upper.is_nan() || middle.is_nan() || lower.is_nan() {
errors.push(
ValidationError::error(
"indicator",
format!("Bollinger Bands at index {}: NaN value", i),
)
.at_index(i),
);
} else if !(upper >= middle && middle >= lower) {
errors.push(
ValidationError::warning(
"indicator",
format!(
"Bollinger Bands at index {}: improper ordering (upper: {:.2}, middle: {:.2}, lower: {:.2})",
i, upper, middle, lower
),
)
.at_index(i),
);
} else {
// Bollinger Bands properly ordered
}
}
// Validate ATR for negative values
for (i, &atr) in indicators.atr.iter().enumerate() {
if atr.is_nan() || atr.is_infinite() {
errors.push(
ValidationError::error(
"indicator",
format!("ATR at index {}: invalid value", i),
)
.at_index(i),
);
} else if atr < 0.0 {
errors.push(
ValidationError::error(
"indicator",
format!("ATR at index {}: negative value ({:.2})", i, atr),
)
.at_index(i),
);
} else {
// ATR value is valid
}
}
Ok(errors)
}
}
// ============================================================================
// Rule 4: Timestamp Validation Rule
// ============================================================================
/// Timestamp alignment validation
///
/// Checks:
/// - Timestamps are properly ordered
/// - No large gaps in time series
#[derive(Debug)]
pub struct TimestampRule {
/// Expected interval between bars in seconds
expected_interval_secs: i64,
}
impl TimestampRule {
pub const fn new(expected_interval_secs: i64) -> Self {
Self {
expected_interval_secs,
}
}
}
impl ValidationRule for TimestampRule {
fn name(&self) -> &str {
"Timestamp Alignment"
}
fn validate_bars(&self, bars: &[OHLCVBar]) -> Result<Vec<ValidationError>> {
let mut errors = Vec::new();
for i in 1..bars.len() {
let prev_ts = bars[i - 1].timestamp;
let curr_ts = bars[i].timestamp;
// Check: timestamps are ordered
if curr_ts <= prev_ts {
errors.push(
ValidationError::error(
"timestamp",
format!("Bar {}: timestamp not ordered (current <= previous)", i),
)
.at_index(i),
);
}
// Check: no large gaps
let gap_secs = (curr_ts - prev_ts).num_seconds();
let max_gap = self.expected_interval_secs * 3; // Allow up to 3x expected interval
if gap_secs > max_gap {
errors.push(
ValidationError::error(
"timestamp",
format!(
"Bar {}: large gap of {}s (expected: {}s)",
i, gap_secs, self.expected_interval_secs
),
)
.at_index(i),
);
}
}
Ok(errors)
}
}
// ============================================================================
// Rule 5: Data Completeness Rule
// ============================================================================
/// Data completeness validation
///
/// Checks for missing bars in the time series based on expected interval.
#[derive(Debug)]
pub struct CompletenessRule {
/// Expected interval between bars in seconds
expected_interval_secs: i64,
/// Minimum completeness ratio (0.0 - 1.0)
min_completeness_ratio: f64,
}
impl CompletenessRule {
pub const fn new(expected_interval_secs: i64, min_completeness_ratio: f64) -> Self {
Self {
expected_interval_secs,
min_completeness_ratio,
}
}
}
impl ValidationRule for CompletenessRule {
fn name(&self) -> &str {
"Data Completeness"
}
fn validate_bars(&self, bars: &[OHLCVBar]) -> Result<Vec<ValidationError>> {
let mut errors = Vec::new();
if bars.len() < 2 {
return Ok(errors);
}
// Calculate expected number of bars (safe: checked bars.len() >= 2)
let start_ts = match bars.first() {
Some(b) => b.timestamp,
None => return Ok(errors),
};
let end_ts = match bars.last() {
Some(b) => b.timestamp,
None => return Ok(errors),
};
let total_duration_secs = (end_ts - start_ts).num_seconds();
let expected_bars = (total_duration_secs / self.expected_interval_secs) as usize + 1;
let actual_bars = bars.len();
// Calculate completeness ratio
let completeness_ratio = actual_bars as f64 / expected_bars as f64;
if completeness_ratio < self.min_completeness_ratio {
errors.push(ValidationError::error(
"completeness",
format!(
"Data completeness: {:.1}% (expected: \u{2265}{:.1}%) - {}/{} bars present",
completeness_ratio * 100.0,
self.min_completeness_ratio * 100.0,
actual_bars,
expected_bars
),
));
} else if completeness_ratio < 1.0 {
errors.push(ValidationError::warning(
"completeness",
format!(
"Data completeness: {:.1}% - {}/{} bars present",
completeness_ratio * 100.0,
actual_bars,
expected_bars
),
));
} else {
// Full completeness, no issues
}
Ok(errors)
}
}