Files
foxhunt/services/backtesting_service/src/bin/validate_dbn_data.rs
2026-02-23 00:50:07 +01:00

1120 lines
38 KiB
Rust

//! 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<String>,
/// 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<PathBuf>,
/// 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<SymbolReport>,
cross_symbol_analysis: Option<CrossSymbolAnalysis>,
overall_quality: QualityScore,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SymbolReport {
symbol: String,
files: Vec<String>,
total_bars: usize,
statistics: Statistics,
quality_checks: QualityChecks,
quality_score: QualityScore,
anomalies: Vec<Anomaly>,
}
#[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<String>,
recommendations: Vec<String>,
}
#[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<String>,
correlation_matrix: HashMap<String, HashMap<String, f64>>,
time_alignment_issues: Vec<String>,
}
#[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<HashMap<String, Vec<String>>> {
let mut symbols_files: HashMap<String, Vec<String>> = 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<Vec<String>> {
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<SymbolReport> {
// 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<Decimal> = 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>()
/ 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<Decimal> = bars.iter().map(|b| b.volume).collect();
let volume_variance: Decimal = volumes
.iter()
.map(|v| (*v - avg_volume).powi(2))
.sum::<Decimal>()
/ 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<Anomaly>) {
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::<u32>()
/ 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<MarketData>`)
/// which is not available from `SymbolReport` (only string-formatted statistics).
/// To add real correlation, pass `HashMap<String, Vec<MarketData>>` 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<CrossSymbolAnalysis> {
let symbols: Vec<String> = 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("<!DOCTYPE html>\n<html>\n<head>\n");
html.push_str("<meta charset='UTF-8'>\n");
html.push_str("<title>DBN Data Quality Validation Report</title>\n");
html.push_str("<style>\n");
html.push_str("body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }\n");
html.push_str("h1 { color: #333; }\n");
html.push_str("h2 { color: #666; border-bottom: 2px solid #ddd; padding-bottom: 5px; }\n");
html.push_str(".summary { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }\n");
html.push_str(".symbol-report { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }\n");
html.push_str(".quality-excellent { color: #28a745; font-weight: bold; }\n");
html.push_str(".quality-good { color: #5cb85c; font-weight: bold; }\n");
html.push_str(".quality-acceptable { color: #f0ad4e; font-weight: bold; }\n");
html.push_str(".quality-poor { color: #d9534f; font-weight: bold; }\n");
html.push_str(".quality-critical { color: #c82333; font-weight: bold; }\n");
html.push_str(".stats-table { width: 100%; border-collapse: collapse; margin: 10px 0; }\n");
html.push_str(".stats-table th, .stats-table td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }\n");
html.push_str(".stats-table th { background: #f8f9fa; font-weight: bold; }\n");
html.push_str(".anomaly { padding: 10px; margin: 5px 0; border-left: 4px solid; }\n");
html.push_str(".anomaly-critical { border-color: #c82333; background: #f8d7da; }\n");
html.push_str(".anomaly-high { border-color: #d9534f; background: #f8d7da; }\n");
html.push_str(".anomaly-medium { border-color: #f0ad4e; background: #fcf8e3; }\n");
html.push_str(".anomaly-low { border-color: #5bc0de; background: #d9edf7; }\n");
html.push_str("</style>\n");
html.push_str("</head>\n<body>\n");
html.push_str("<h1>DBN Data Quality Validation Report</h1>\n");
html.push_str(&format!(
"<p><strong>Generated:</strong> {}</p>\n",
report.timestamp
));
html.push_str(&format!(
"<p><strong>Duration:</strong> {}ms</p>\n",
report.duration_ms
));
html.push_str("<div class='summary'>\n");
html.push_str("<h2>Summary</h2>\n");
html.push_str("<table class='stats-table'>\n");
html.push_str(&format!(
"<tr><th>Total Symbols</th><td>{}</td></tr>\n",
report.total_symbols
));
html.push_str(&format!(
"<tr><th>Total Files</th><td>{}</td></tr>\n",
report.total_files
));
html.push_str(&format!(
"<tr><th>Total Bars</th><td>{}</td></tr>\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!(
"<tr><th>Overall Quality</th><td><span class='{}'>{} ({})</span></td></tr>\n",
quality_class, report.overall_quality.score, report.overall_quality.rating
));
html.push_str("</table>\n");
html.push_str("</div>\n");
for symbol_report in &report.symbols {
html.push_str("<div class='symbol-report'>\n");
html.push_str(&format!("<h2>Symbol: {}</h2>\n", symbol_report.symbol));
html.push_str("<h3>Statistics</h3>\n");
html.push_str("<table class='stats-table'>\n");
html.push_str(&format!(
"<tr><th>Total Bars</th><td>{}</td></tr>\n",
symbol_report.total_bars
));
html.push_str(&format!(
"<tr><th>Price Range</th><td>${} - ${}</td></tr>\n",
symbol_report.statistics.min_close, symbol_report.statistics.max_close
));
html.push_str(&format!(
"<tr><th>Average Close</th><td>${}</td></tr>\n",
symbol_report.statistics.avg_close
));
html.push_str(&format!(
"<tr><th>Completeness</th><td>{:.1}%</td></tr>\n",
symbol_report.statistics.completeness_pct
));
html.push_str("</table>\n");
html.push_str("<h3>Quality Checks</h3>\n");
html.push_str("<table class='stats-table'>\n");
html.push_str(&format!(
"<tr><th>OHLCV Violations</th><td>{}</td></tr>\n",
symbol_report.quality_checks.ohlcv_violations
));
html.push_str(&format!(
"<tr><th>Zero Volumes</th><td>{}</td></tr>\n",
symbol_report.quality_checks.zero_volumes
));
html.push_str(&format!(
"<tr><th>Price Spikes</th><td>{}</td></tr>\n",
symbol_report.quality_checks.price_spikes
));
html.push_str(&format!(
"<tr><th>Timestamp Gaps</th><td>{}</td></tr>\n",
symbol_report.quality_checks.timestamp_gaps
));
html.push_str("</table>\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!(
"<h3>Quality Score: <span class='{}'>{} ({})</span></h3>\n",
quality_class, symbol_report.quality_score.score, symbol_report.quality_score.rating
));
if !symbol_report.anomalies.is_empty() {
html.push_str("<h3>Anomalies</h3>\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!("<div class='anomaly {}'>\n", anomaly_class));
html.push_str(&format!(
"<strong>[{}]</strong> {} at bar {}: {}\n",
anomaly.severity, anomaly.anomaly_type, anomaly.bar_index, anomaly.description
));
html.push_str("</div>\n");
}
if symbol_report.anomalies.len() > 10 {
html.push_str(&format!(
"<p><em>... and {} more anomalies</em></p>\n",
symbol_report.anomalies.len() - 10
));
}
}
html.push_str("</div>\n");
}
html.push_str("</body>\n</html>");
html
}