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>
523 lines
15 KiB
Rust
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)
|
|
}
|
|
}
|