//! Comprehensive DBN Data Quality Validation Tool //! //! This tool performs systematic validation across all DBN market data files: //! - Multi-file validation with quality scoring //! - Cross-symbol validation (price correlations, time alignment) //! - Anomaly detection (automated spike detection) //! - Summary statistics and quality reports //! - Exit codes for CI/CD integration //! //! ## Usage //! //! ```bash //! # Validate single symbol //! cargo run --bin validate_dbn_data -- --symbol ES.FUT //! //! # Validate all symbols //! cargo run --bin validate_dbn_data -- --all //! //! # Generate HTML report //! cargo run --bin validate_dbn_data -- --all --output report.html //! //! # CI/CD mode (exit 1 if quality fails) //! cargo run --bin validate_dbn_data -- --all --fail-on-poor-quality //! ``` use anyhow::{Context, Result}; use backtesting_service::dbn_data_source::DbnDataSource; use backtesting_service::strategy_engine::MarketData; use chrono::Utc; use clap::Parser; use rust_decimal::prelude::*; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Instant; #[derive(Parser, Debug)] #[clap(name = "validate_dbn_data")] #[clap(about = "Comprehensive DBN data quality validation tool")] struct Args { /// Validate specific symbol #[clap(long, short)] symbol: Option, /// Validate all symbols in test_data #[clap(long, short)] all: bool, /// Output format: text, json, html #[clap(long, short, default_value = "text")] format: String, /// Output file path (stdout if not specified) #[clap(long, short)] output: Option, /// Exit with error code if quality is poor #[clap(long)] fail_on_poor_quality: bool, /// Minimum quality score (0-100) to pass #[clap(long, default_value = "70")] min_quality_score: u8, /// Enable verbose output #[clap(long, short)] verbose: bool, /// Test data directory #[clap(long, default_value = "test_data/real/databento")] data_dir: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ValidationReport { timestamp: String, total_symbols: usize, total_files: usize, total_bars: usize, duration_ms: u64, symbols: Vec, cross_symbol_analysis: Option, overall_quality: QualityScore, } #[derive(Debug, Clone, Serialize, Deserialize)] struct SymbolReport { symbol: String, files: Vec, total_bars: usize, statistics: Statistics, quality_checks: QualityChecks, quality_score: QualityScore, anomalies: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] struct Statistics { // Price statistics min_close: String, max_close: String, median_close: String, avg_close: String, price_range: String, price_std_dev: String, // Volume statistics total_volume: String, avg_volume: String, min_volume: String, max_volume: String, volume_std_dev: String, // Time coverage first_timestamp: String, last_timestamp: String, duration_hours: i64, expected_bars: usize, actual_bars: usize, completeness_pct: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] struct QualityChecks { ohlcv_violations: usize, zero_volumes: usize, timestamp_gaps: usize, price_spikes: usize, duplicate_timestamps: usize, out_of_order_timestamps: usize, negative_prices: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] struct QualityScore { score: u8, rating: String, issues: Vec, recommendations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] struct Anomaly { anomaly_type: String, bar_index: usize, timestamp: String, description: String, severity: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CrossSymbolAnalysis { symbols_analyzed: Vec, correlation_matrix: HashMap>, time_alignment_issues: Vec, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); // Initialize tracing if args.verbose { tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .init(); } else { tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); } let start = Instant::now(); // Discover DBN files let data_dir = Path::new(&args.data_dir); let symbols_files = if args.all { discover_dbn_files(data_dir)? } else if let Some(ref symbol) = args.symbol { let files = find_symbol_files(data_dir, symbol)?; if files.is_empty() { anyhow::bail!("No DBN files found for symbol: {}", symbol); } let mut map = HashMap::new(); map.insert(symbol.clone(), files); map } else { anyhow::bail!("Either --symbol or --all must be specified"); }; if symbols_files.is_empty() { anyhow::bail!("No DBN files found in: {}", data_dir.display()); } println!("Validating {} symbols...\n", symbols_files.len()); // Load and validate each symbol let mut symbol_reports = Vec::new(); let mut total_bars = 0; for (symbol, files) in symbols_files.iter() { println!("Validating {}...", symbol); let report = validate_symbol(symbol, files).await?; total_bars += report.total_bars; if args.verbose { print_symbol_summary(&report); } symbol_reports.push(report); } // Cross-symbol analysis let cross_symbol = if symbol_reports.len() > 1 { Some(analyze_cross_symbol(&symbol_reports).await?) } else { None }; // Calculate overall quality let overall_quality = calculate_overall_quality(&symbol_reports); // Create validation report let report = ValidationReport { timestamp: Utc::now().to_rfc3339(), total_symbols: symbols_files.len(), total_files: symbols_files.values().map(|f| f.len()).sum(), total_bars, duration_ms: start.elapsed().as_millis() as u64, symbols: symbol_reports, cross_symbol_analysis: cross_symbol, overall_quality: overall_quality.clone(), }; // Output report match args.format.as_str() { "json" => output_json(&report, args.output.as_ref())?, "html" => output_html(&report, args.output.as_ref())?, _ => output_text(&report), } // Exit with error if quality check fails if args.fail_on_poor_quality && overall_quality.score < args.min_quality_score { eprintln!( "\n❌ VALIDATION FAILED: Quality score {} < minimum {}", overall_quality.score, args.min_quality_score ); std::process::exit(1); } if overall_quality.score >= args.min_quality_score { println!( "\n✅ VALIDATION PASSED: Quality score {}/100", overall_quality.score ); } Ok(()) } /// Discover all DBN files in directory fn discover_dbn_files(data_dir: &Path) -> Result>> { let mut symbols_files: HashMap> = HashMap::new(); if !data_dir.exists() { anyhow::bail!("Data directory not found: {}", data_dir.display()); } for entry in std::fs::read_dir(data_dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("dbn") { if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) { // Extract symbol from filename (e.g., "ES.FUT_ohlcv-1m_2024-01-02.dbn" -> "ES.FUT") if let Some(symbol) = file_name.split('_').next() { symbols_files .entry(symbol.to_string()) .or_default() .push(path.to_string_lossy().to_string()); } } } } // Sort files for each symbol for files in symbols_files.values_mut() { files.sort(); } Ok(symbols_files) } /// Find all DBN files for a specific symbol fn find_symbol_files(data_dir: &Path, symbol: &str) -> Result> { let mut files = Vec::new(); if !data_dir.exists() { return Ok(files); } for entry in std::fs::read_dir(data_dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("dbn") { if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) { if file_name.starts_with(symbol) { files.push(path.to_string_lossy().to_string()); } } } } files.sort(); Ok(files) } /// Validate single symbol across all files async fn validate_symbol(symbol: &str, files: &[String]) -> Result { // Create data source with all files for symbol let mut file_mapping = HashMap::new(); file_mapping.insert(symbol.to_string(), files.to_vec()); let data_source = DbnDataSource::new_multi_file(file_mapping).await?; // Load all bars let bars = data_source.load_ohlcv_bars_all(symbol).await?; if bars.is_empty() { anyhow::bail!("No bars loaded for symbol: {}", symbol); } // Calculate statistics let statistics = calculate_statistics(&bars); // Perform quality checks let (quality_checks, anomalies) = perform_quality_checks(&bars); // Calculate quality score let quality_score = calculate_quality_score(&bars, &quality_checks, &statistics); Ok(SymbolReport { symbol: symbol.to_string(), files: files.to_vec(), total_bars: bars.len(), statistics, quality_checks, quality_score, anomalies, }) } /// Calculate comprehensive statistics fn calculate_statistics(bars: &[MarketData]) -> Statistics { let mut prices: Vec = bars.iter().map(|b| b.close).collect(); prices.sort(); let min_close = *prices.first().expect("INVARIANT: Collection should be non-empty"); let max_close = *prices.last().expect("INVARIANT: Collection should be non-empty"); let median_close = prices[prices.len() / 2]; let sum_prices: Decimal = prices.iter().sum(); let avg_close = sum_prices / Decimal::from(prices.len()); // Calculate standard deviation let variance: Decimal = prices .iter() .map(|p| (*p - avg_close).powi(2)) .sum::() / Decimal::from(prices.len()); let std_dev = variance.sqrt().unwrap_or(Decimal::ZERO); // Volume statistics let total_volume: Decimal = bars.iter().map(|b| b.volume).sum(); let avg_volume = total_volume / Decimal::from(bars.len()); let max_volume = bars.iter().map(|b| b.volume).max().unwrap_or(Decimal::ZERO); let min_volume = bars.iter().map(|b| b.volume).min().unwrap_or(Decimal::ZERO); let volumes: Vec = bars.iter().map(|b| b.volume).collect(); let volume_variance: Decimal = volumes .iter() .map(|v| (*v - avg_volume).powi(2)) .sum::() / Decimal::from(volumes.len()); let volume_std_dev = volume_variance.sqrt().unwrap_or(Decimal::ZERO); // Time coverage let first_ts = bars.first().expect("INVARIANT: Collection should be non-empty").timestamp; let last_ts = bars.last().expect("INVARIANT: Collection should be non-empty").timestamp; let duration_seconds = (last_ts - first_ts).num_seconds(); let duration_hours = duration_seconds / 3600; // Expected bars (1-minute bars, assuming ~6.5 hour trading day) let expected_bars = (duration_hours as f64 / 6.5 * 390.0) as usize; let completeness_pct = (bars.len() as f64 / expected_bars.max(1) as f64) * 100.0; Statistics { min_close: format!("{:.2}", min_close), max_close: format!("{:.2}", max_close), median_close: format!("{:.2}", median_close), avg_close: format!("{:.2}", avg_close), price_range: format!("{:.2}", max_close - min_close), price_std_dev: format!("{:.2}", std_dev), total_volume: format!("{:.0}", total_volume), avg_volume: format!("{:.0}", avg_volume), min_volume: format!("{:.0}", min_volume), max_volume: format!("{:.0}", max_volume), volume_std_dev: format!("{:.0}", volume_std_dev), first_timestamp: first_ts.to_rfc3339(), last_timestamp: last_ts.to_rfc3339(), duration_hours, expected_bars, actual_bars: bars.len(), completeness_pct, } } /// Perform comprehensive quality checks fn perform_quality_checks(bars: &[MarketData]) -> (QualityChecks, Vec) { let mut checks = QualityChecks { ohlcv_violations: 0, zero_volumes: 0, timestamp_gaps: 0, price_spikes: 0, duplicate_timestamps: 0, out_of_order_timestamps: 0, negative_prices: 0, }; let mut anomalies = Vec::new(); let mut timestamps_seen = std::collections::HashSet::new(); for i in 0..bars.len() { let bar = &bars[i]; // OHLCV relationship check if !(bar.high >= bar.low && bar.high >= bar.open && bar.high >= bar.close && bar.low <= bar.open && bar.low <= bar.close) { checks.ohlcv_violations += 1; anomalies.push(Anomaly { anomaly_type: "OHLCV Violation".to_string(), bar_index: i, timestamp: bar.timestamp.to_rfc3339(), description: format!( "Invalid OHLCV relationship: O={} H={} L={} C={}", bar.open, bar.high, bar.low, bar.close ), severity: "HIGH".to_string(), }); } // Zero volume check if bar.volume == Decimal::ZERO { checks.zero_volumes += 1; } // Negative price check if bar.open < Decimal::ZERO || bar.high < Decimal::ZERO || bar.low < Decimal::ZERO || bar.close < Decimal::ZERO { checks.negative_prices += 1; anomalies.push(Anomaly { anomaly_type: "Negative Price".to_string(), bar_index: i, timestamp: bar.timestamp.to_rfc3339(), description: format!( "Negative price detected: O={} H={} L={} C={}", bar.open, bar.high, bar.low, bar.close ), severity: "CRITICAL".to_string(), }); } // Duplicate timestamp check if !timestamps_seen.insert(bar.timestamp) { checks.duplicate_timestamps += 1; anomalies.push(Anomaly { anomaly_type: "Duplicate Timestamp".to_string(), bar_index: i, timestamp: bar.timestamp.to_rfc3339(), description: "Duplicate timestamp found".to_string(), severity: "MEDIUM".to_string(), }); } if i > 0 { let prev_bar = &bars[i - 1]; // Out of order timestamp check if bar.timestamp < prev_bar.timestamp { checks.out_of_order_timestamps += 1; anomalies.push(Anomaly { anomaly_type: "Out of Order".to_string(), bar_index: i, timestamp: bar.timestamp.to_rfc3339(), description: format!( "Timestamp out of order: current={} < previous={}", bar.timestamp, prev_bar.timestamp ), severity: "HIGH".to_string(), }); } // Timestamp gap check (>2 minutes for 1-minute bars) let gap_seconds = (bar.timestamp - prev_bar.timestamp).num_seconds(); if gap_seconds > 120 { checks.timestamp_gaps += 1; if gap_seconds > 3600 { // Gaps > 1 hour are anomalies anomalies.push(Anomaly { anomaly_type: "Large Gap".to_string(), bar_index: i, timestamp: bar.timestamp.to_rfc3339(), description: format!( "Gap of {} seconds ({} minutes)", gap_seconds, gap_seconds / 60 ), severity: "LOW".to_string(), }); } } // Price spike check (>10% move) let price_change_pct = ((bar.close - prev_bar.close) / prev_bar.close).abs() * Decimal::from(100); if price_change_pct > Decimal::from(10) { checks.price_spikes += 1; anomalies.push(Anomaly { anomaly_type: "Price Spike".to_string(), bar_index: i, timestamp: bar.timestamp.to_rfc3339(), description: format!( "{:.2}% price change (${:.2} -> ${:.2})", price_change_pct, prev_bar.close, bar.close ), severity: "MEDIUM".to_string(), }); } } } // Sort anomalies by severity anomalies.sort_by(|a, b| { let severity_order = |s: &str| match s { "CRITICAL" => 0, "HIGH" => 1, "MEDIUM" => 2, "LOW" => 3, _ => 4, }; severity_order(&a.severity).cmp(&severity_order(&b.severity)) }); (checks, anomalies) } /// Calculate quality score (0-100) fn calculate_quality_score( bars: &[MarketData], checks: &QualityChecks, stats: &Statistics, ) -> QualityScore { let mut score = 100u8; let mut issues = Vec::new(); let mut recommendations = Vec::new(); // Critical issues (-20 points each) if checks.ohlcv_violations > 0 { score = score.saturating_sub(20); issues.push(format!("{} OHLCV violations", checks.ohlcv_violations)); recommendations .push("Fix OHLCV relationship violations - data corruption likely".to_string()); } if checks.negative_prices > 0 { score = score.saturating_sub(20); issues.push(format!("{} negative prices", checks.negative_prices)); recommendations.push("Remove bars with negative prices".to_string()); } if checks.out_of_order_timestamps > 0 { score = score.saturating_sub(15); issues.push(format!( "{} out-of-order timestamps", checks.out_of_order_timestamps )); recommendations.push("Sort bars chronologically".to_string()); } // High severity issues (-10 points each) if checks.duplicate_timestamps > 0 { score = score.saturating_sub(10); issues.push(format!( "{} duplicate timestamps", checks.duplicate_timestamps )); recommendations.push("Remove duplicate bars".to_string()); } // Medium severity issues (-5 points) if checks.price_spikes > bars.len() / 100 { score = score.saturating_sub(5); issues.push(format!("{} price spikes (>10%)", checks.price_spikes)); recommendations.push("Investigate price spikes for data quality".to_string()); } // Low severity issues (-2 points) if checks.zero_volumes > bars.len() / 10 { score = score.saturating_sub(2); issues.push(format!("{} zero volume bars", checks.zero_volumes)); recommendations.push("Zero volumes may indicate illiquid periods".to_string()); } if checks.timestamp_gaps > bars.len() / 20 { score = score.saturating_sub(2); issues.push(format!("{} timestamp gaps", checks.timestamp_gaps)); recommendations.push("Timestamp gaps expected during market close/open".to_string()); } // Data completeness penalty if stats.completeness_pct < 80.0 { score = score.saturating_sub(10); issues.push(format!("Low completeness: {:.1}%", stats.completeness_pct)); recommendations.push("Consider acquiring more complete data".to_string()); } // Determine rating let rating = if score >= 90 { "EXCELLENT" } else if score >= 75 { "GOOD" } else if score >= 60 { "ACCEPTABLE" } else if score >= 40 { "POOR" } else { "CRITICAL" }; QualityScore { score, rating: rating.to_string(), issues, recommendations, } } /// Calculate overall quality across all symbols fn calculate_overall_quality(symbol_reports: &[SymbolReport]) -> QualityScore { if symbol_reports.is_empty() { return QualityScore { score: 0, rating: "NO DATA".to_string(), issues: vec!["No symbols validated".to_string()], recommendations: vec![], }; } // Average score across symbols let avg_score = symbol_reports .iter() .map(|r| r.quality_score.score as u32) .sum::() / symbol_reports.len() as u32; // Aggregate issues let mut all_issues = Vec::new(); let mut all_recommendations = Vec::new(); for report in symbol_reports { if report.quality_score.score < 70 { all_issues.push(format!( "{}: {} (score: {})", report.symbol, report.quality_score.rating, report.quality_score.score )); } all_recommendations.extend(report.quality_score.recommendations.clone()); } // Deduplicate recommendations all_recommendations.sort(); all_recommendations.dedup(); let rating = if avg_score >= 90 { "EXCELLENT" } else if avg_score >= 75 { "GOOD" } else if avg_score >= 60 { "ACCEPTABLE" } else if avg_score >= 40 { "POOR" } else { "CRITICAL" }; QualityScore { score: avg_score as u8, rating: rating.to_string(), issues: all_issues, recommendations: all_recommendations, } } /// Cross-symbol analysis (correlations, time alignment) /// /// Pearson correlation of close prices requires raw bar data (`Vec`) /// which is not available from `SymbolReport` (only string-formatted statistics). /// To add real correlation, pass `HashMap>` alongside /// `symbol_reports` and compute `pearson_correlation(&closes_a, &closes_b)` on /// overlapping timestamps. /// /// Time alignment is implemented below using the first/last timestamps from /// each symbol's statistics. async fn analyze_cross_symbol(symbol_reports: &[SymbolReport]) -> Result { let symbols: Vec = symbol_reports.iter().map(|r| r.symbol.clone()).collect(); // Correlation requires raw close-price vectors which SymbolReport does not carry. // Placeholder: callers should extend this once raw bars are passed through. let correlation_matrix = HashMap::new(); // Time alignment: check whether symbol pairs share the same time window. let mut time_alignment_issues = Vec::new(); for i in 0..symbol_reports.len() { for j in (i + 1)..symbol_reports.len() { let a = &symbol_reports[i]; let b = &symbol_reports[j]; // Compare first-timestamp alignment (>1 hour difference = issue) let a_first = chrono::DateTime::parse_from_rfc3339(&a.statistics.first_timestamp) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)); let b_first = chrono::DateTime::parse_from_rfc3339(&b.statistics.first_timestamp) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)); let a_last = chrono::DateTime::parse_from_rfc3339(&a.statistics.last_timestamp) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)); let b_last = chrono::DateTime::parse_from_rfc3339(&b.statistics.last_timestamp) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)); if let (Some(af), Some(bf)) = (a_first, b_first) { let gap_secs = (af - bf).num_seconds().unsigned_abs(); if gap_secs > 3600 { time_alignment_issues.push(format!( "{} vs {}: start-time gap of {} seconds ({} hours)", a.symbol, b.symbol, gap_secs, gap_secs / 3600 )); } } if let (Some(al), Some(bl)) = (a_last, b_last) { let gap_secs = (al - bl).num_seconds().unsigned_abs(); if gap_secs > 3600 { time_alignment_issues.push(format!( "{} vs {}: end-time gap of {} seconds ({} hours)", a.symbol, b.symbol, gap_secs, gap_secs / 3600 )); } } // Check overlapping coverage if let (Some(af), Some(al), Some(bf), Some(bl)) = (a_first, a_last, b_first, b_last) { let overlap_start = af.max(bf); let overlap_end = al.min(bl); if overlap_start > overlap_end { time_alignment_issues.push(format!( "{} vs {}: NO overlapping time window", a.symbol, b.symbol )); } } } } Ok(CrossSymbolAnalysis { symbols_analyzed: symbols, correlation_matrix, time_alignment_issues, }) } /// Print symbol summary to console fn print_symbol_summary(report: &SymbolReport) { println!(" Symbol: {}", report.symbol); println!(" Files: {}", report.files.len()); println!(" Bars: {}", report.total_bars); println!( " Quality: {} ({})", report.quality_score.score, report.quality_score.rating ); if !report.anomalies.is_empty() { println!(" Anomalies: {}", report.anomalies.len()); } println!(); } /// Output report as text fn output_text(report: &ValidationReport) { println!("\n═══════════════════════════════════════════════════════"); println!("DBN DATA QUALITY VALIDATION REPORT"); println!("═══════════════════════════════════════════════════════"); println!("Timestamp: {}", report.timestamp); println!("Duration: {}ms", report.duration_ms); println!(); println!("📊 SUMMARY"); println!(" Total Symbols: {}", report.total_symbols); println!(" Total Files: {}", report.total_files); println!(" Total Bars: {}", report.total_bars); println!( " Overall Quality: {} ({})", report.overall_quality.score, report.overall_quality.rating ); println!(); for symbol_report in &report.symbols { println!("─────────────────────────────────────────────────────"); println!("SYMBOL: {}", symbol_report.symbol); println!("─────────────────────────────────────────────────────"); println!(); println!("📈 Statistics:"); println!(" Bars: {}", symbol_report.total_bars); println!( " Price Range: ${} - ${}", symbol_report.statistics.min_close, symbol_report.statistics.max_close ); println!(" Avg Close: ${}", symbol_report.statistics.avg_close); println!(" Std Dev: ${}", symbol_report.statistics.price_std_dev); println!(" Total Volume: {}", symbol_report.statistics.total_volume); println!(" Avg Volume: {}", symbol_report.statistics.avg_volume); println!( " Duration: {} hours", symbol_report.statistics.duration_hours ); println!( " Completeness: {:.1}%", symbol_report.statistics.completeness_pct ); println!(); println!("✅ Quality Checks:"); println!( " OHLCV Violations: {}", symbol_report.quality_checks.ohlcv_violations ); println!( " Zero Volumes: {}", symbol_report.quality_checks.zero_volumes ); println!( " Timestamp Gaps: {}", symbol_report.quality_checks.timestamp_gaps ); println!( " Price Spikes: {}", symbol_report.quality_checks.price_spikes ); println!( " Duplicate Timestamps: {}", symbol_report.quality_checks.duplicate_timestamps ); println!( " Out of Order: {}", symbol_report.quality_checks.out_of_order_timestamps ); println!( " Negative Prices: {}", symbol_report.quality_checks.negative_prices ); println!(); println!( "🎯 Quality Score: {} ({})", symbol_report.quality_score.score, symbol_report.quality_score.rating ); if !symbol_report.quality_score.issues.is_empty() { println!(" Issues:"); for issue in &symbol_report.quality_score.issues { println!(" • {}", issue); } } if !symbol_report.anomalies.is_empty() { println!(); println!("⚠️ Anomalies (showing first 5):"); for anomaly in symbol_report.anomalies.iter().take(5) { println!( " [{:8}] {} at bar {}: {}", anomaly.severity, anomaly.anomaly_type, anomaly.bar_index, anomaly.description ); } if symbol_report.anomalies.len() > 5 { println!(" ... and {} more", symbol_report.anomalies.len() - 5); } } println!(); } println!("═══════════════════════════════════════════════════════"); println!( "OVERALL QUALITY: {} ({})", report.overall_quality.score, report.overall_quality.rating ); if !report.overall_quality.recommendations.is_empty() { println!(); println!("💡 Recommendations:"); for rec in &report.overall_quality.recommendations { println!(" • {}", rec); } } println!("═══════════════════════════════════════════════════════"); } /// Output report as JSON fn output_json(report: &ValidationReport, output_path: Option<&PathBuf>) -> Result<()> { let json = serde_json::to_string_pretty(report).context("Failed to serialize report to JSON")?; if let Some(path) = output_path { std::fs::write(path, json).context("Failed to write JSON report")?; println!("JSON report written to: {}", path.display()); } else { println!("{}", json); } Ok(()) } /// Output report as HTML fn output_html(report: &ValidationReport, output_path: Option<&PathBuf>) -> Result<()> { let html = generate_html_report(report); if let Some(path) = output_path { std::fs::write(path, html).context("Failed to write HTML report")?; println!("HTML report written to: {}", path.display()); } else { println!("{}", html); } Ok(()) } /// Generate HTML report fn generate_html_report(report: &ValidationReport) -> String { let mut html = String::new(); html.push_str("\n\n\n"); html.push_str("\n"); html.push_str("DBN Data Quality Validation Report\n"); html.push_str("\n"); html.push_str("\n\n"); html.push_str("

DBN Data Quality Validation Report

\n"); html.push_str(&format!( "

Generated: {}

\n", report.timestamp )); html.push_str(&format!( "

Duration: {}ms

\n", report.duration_ms )); html.push_str("
\n"); html.push_str("

Summary

\n"); html.push_str("\n"); html.push_str(&format!( "\n", report.total_symbols )); html.push_str(&format!( "\n", report.total_files )); html.push_str(&format!( "\n", report.total_bars )); let quality_class = match report.overall_quality.rating.as_str() { "EXCELLENT" => "quality-excellent", "GOOD" => "quality-good", "ACCEPTABLE" => "quality-acceptable", "POOR" => "quality-poor", _ => "quality-critical", }; html.push_str(&format!( "\n", quality_class, report.overall_quality.score, report.overall_quality.rating )); html.push_str("
Total Symbols{}
Total Files{}
Total Bars{}
Overall Quality{} ({})
\n"); html.push_str("
\n"); for symbol_report in &report.symbols { html.push_str("
\n"); html.push_str(&format!("

Symbol: {}

\n", symbol_report.symbol)); html.push_str("

Statistics

\n"); html.push_str("\n"); html.push_str(&format!( "\n", symbol_report.total_bars )); html.push_str(&format!( "\n", symbol_report.statistics.min_close, symbol_report.statistics.max_close )); html.push_str(&format!( "\n", symbol_report.statistics.avg_close )); html.push_str(&format!( "\n", symbol_report.statistics.completeness_pct )); html.push_str("
Total Bars{}
Price Range${} - ${}
Average Close${}
Completeness{:.1}%
\n"); html.push_str("

Quality Checks

\n"); html.push_str("\n"); html.push_str(&format!( "\n", symbol_report.quality_checks.ohlcv_violations )); html.push_str(&format!( "\n", symbol_report.quality_checks.zero_volumes )); html.push_str(&format!( "\n", symbol_report.quality_checks.price_spikes )); html.push_str(&format!( "\n", symbol_report.quality_checks.timestamp_gaps )); html.push_str("
OHLCV Violations{}
Zero Volumes{}
Price Spikes{}
Timestamp Gaps{}
\n"); let quality_class = match symbol_report.quality_score.rating.as_str() { "EXCELLENT" => "quality-excellent", "GOOD" => "quality-good", "ACCEPTABLE" => "quality-acceptable", "POOR" => "quality-poor", _ => "quality-critical", }; html.push_str(&format!( "

Quality Score: {} ({})

\n", quality_class, symbol_report.quality_score.score, symbol_report.quality_score.rating )); if !symbol_report.anomalies.is_empty() { html.push_str("

Anomalies

\n"); for anomaly in symbol_report.anomalies.iter().take(10) { let anomaly_class = match anomaly.severity.as_str() { "CRITICAL" => "anomaly-critical", "HIGH" => "anomaly-high", "MEDIUM" => "anomaly-medium", _ => "anomaly-low", }; html.push_str(&format!("
\n", anomaly_class)); html.push_str(&format!( "[{}] {} at bar {}: {}\n", anomaly.severity, anomaly.anomaly_type, anomaly.bar_index, anomaly.description )); html.push_str("
\n"); } if symbol_report.anomalies.len() > 10 { html.push_str(&format!( "

... and {} more anomalies

\n", symbol_report.anomalies.len() - 10 )); } } html.push_str("
\n"); } html.push_str("\n"); html }