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>
197 lines
5.9 KiB
Rust
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)
|
|
}
|
|
}
|