Files
foxhunt/services/data_acquisition_service/src/validator.rs
jgrusewski 2fa459cf08 fix(ml): replace unwrap() with ok_or/? in DQN IQN paths
Replace 6 unwrap() calls with safe error handling in DQN IQN code:
- Production: 3 unwrap() on iqn_network/iqn_target_network replaced with
  ok_or_else returning MLError::ModelError for clear diagnostics
- Tests: 2 result.unwrap() replaced with ?, 2 DQN::new().unwrap() replaced
  with ? after changing test signatures to return anyhow::Result<()>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:12:16 +01:00

197 lines
5.9 KiB
Rust

//! Data validation module
//!
//! Validates downloaded data for quality issues:
//! - Missing values
//! - Price anomalies
//! - Volume outliers
//! - Timestamp gaps
//! - Schema conformance
use crate::error::{AcquisitionError, AcquisitionResult};
use std::path::Path;
/// Validation configuration
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub max_price_change_percent: f64,
pub max_volume_std_dev: f64,
pub max_timestamp_gap_seconds: i64,
pub min_bars_required: usize,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
max_price_change_percent: 10.0,
max_volume_std_dev: 5.0,
max_timestamp_gap_seconds: 3600,
min_bars_required: 100,
}
}
}
/// Validation result
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub records_count: u64,
pub issues_found: Vec<ValidationIssue>,
pub quality_score: f64,
}
/// Validation issue
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub severity: IssueSeverity,
pub category: String,
pub description: String,
pub record_index: Option<usize>,
}
/// Issue severity
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueSeverity {
Critical,
High,
Medium,
Low,
}
/// Recognized file extensions for market data files.
const RECOGNIZED_EXTENSIONS: &[&str] = &["csv", "parquet", "dbn", "json", "zst"];
/// Data validator
pub struct DataValidator {
config: ValidationConfig,
}
impl DataValidator {
/// Create new validator
pub fn new(config: ValidationConfig) -> Self {
Self { config }
}
/// Validate a downloaded file
///
/// Performs full validation including file existence, size, extension,
/// estimated record count, and quality scoring.
pub async fn validate(&self, file_path: &Path) -> AcquisitionResult<ValidationResult> {
let mut issues: Vec<ValidationIssue> = Vec::new();
// Check file exists and is readable
let metadata = match tokio::fs::metadata(file_path).await {
Ok(m) => m,
Err(e) => {
return Err(AcquisitionError::Validation {
message: format!("Cannot access file '{}': {}", file_path.display(), e),
});
}
};
// Check file is not empty
let file_size = metadata.len();
if file_size == 0 {
issues.push(ValidationIssue {
severity: IssueSeverity::Critical,
category: "file_size".to_string(),
description: "File is empty (0 bytes)".to_string(),
record_index: None,
});
return Ok(ValidationResult {
is_valid: false,
records_count: 0,
issues_found: issues,
quality_score: 0.0,
});
}
// Check file has recognized extension
let has_recognized_ext = file_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| RECOGNIZED_EXTENSIONS.contains(&ext))
.unwrap_or(false);
if !has_recognized_ext {
issues.push(ValidationIssue {
severity: IssueSeverity::Medium,
category: "file_extension".to_string(),
description: format!(
"Unrecognized file extension for '{}'",
file_path.display()
),
record_index: None,
});
}
// Estimate record count from file size (rough: ~100 bytes per record)
let records_count = file_size / 100;
// Check minimum bars requirement
if (records_count as usize) < self.config.min_bars_required {
issues.push(ValidationIssue {
severity: IssueSeverity::High,
category: "record_count".to_string(),
description: format!(
"Estimated record count ({}) is below minimum required ({})",
records_count, self.config.min_bars_required
),
record_index: None,
});
}
// Calculate quality score: 1.0 minus 0.1 per issue, floored at 0.0
let quality_score = 1.0 - (issues.len() as f64 * 0.1).min(1.0);
// is_valid = no Critical issues
let is_valid = !issues
.iter()
.any(|issue| issue.severity == IssueSeverity::Critical);
Ok(ValidationResult {
is_valid,
records_count,
issues_found: issues,
quality_score,
})
}
/// Quick validation (basic checks only)
///
/// Checks file existence, non-empty size, and recognized extension.
/// Returns `Ok(false)` for any check failure, `Ok(true)` when all pass.
/// Only returns `Err` on unexpected I/O errors.
pub async fn quick_validate(&self, file_path: &Path) -> AcquisitionResult<bool> {
// Check file exists and is readable
let metadata = match tokio::fs::metadata(file_path).await {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Ok(false),
Err(e) => {
return Err(AcquisitionError::Validation {
message: format!("I/O error reading '{}': {}", file_path.display(), e),
});
}
};
// Check file is not empty
if metadata.len() == 0 {
return Ok(false);
}
// Check file has recognized extension
let has_recognized_ext = file_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| RECOGNIZED_EXTENSIONS.contains(&ext))
.unwrap_or(false);
if !has_recognized_ext {
return Ok(false);
}
Ok(true)
}
}