- Fix format_push_string: write!() instead of push_str(&format!()) (25 sites) - Fix str_to_string: .to_owned() instead of .to_string() on &str (6 sites) - Fix unseparated_literal_suffix: add _ separator (6 sites) - Fix multiple_inherent_impl: merge split impl blocks in TGGN, TFT, OFI (3) - Fix else_if_without_else: add exhaustive else clauses (3 sites) - Fix if_then_some_else_none: use .then().transpose() (1 site) - Fix unwrap_in_result: replace expect() with match + ? (2 sites) - Fix wildcard_enum_match_arm: enumerate Storage variants explicitly (2) - Fix decimal_literal_representation: use hex for power-of-2 constants (5) - Fix rc_buffer: Arc<Vec<T>> → Arc<[T]> for OFI features - Fix needless_range_loop: convert to iterator patterns (17 sites) - Fix used_underscore_binding: remove prefix on used vars (6 sites) - Fix doc list item indentation (7 sites) - Allow too_many_arguments on ML training functions (4) - Allow multiple_unsafe_ops_per_block on CUDA FFI functions (3) - Allow upper_case_acronyms on SLSTM/MLSTM model names (2) - Add ML-crate pedantic allows: shadow, similar_names, type_complexity, indexing_slicing, partial_pub_fields, non_ascii_literal, same_name_method (following existing ml-labeling/ml-universe pattern) Result: cargo clippy --workspace -- -D warnings passes with zero warnings. All 2758+ lib tests pass (2 pre-existing backtesting failures unchanged). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
476 lines
18 KiB
Rust
476 lines
18 KiB
Rust
//! Backtesting Report Generation Module
|
|
//!
|
|
//! Provides comprehensive markdown report generation for comparing DQN model performance
|
|
//! against baseline models. Includes deployment recommendations based on production criteria.
|
|
//!
|
|
//! # Features
|
|
//!
|
|
//! - **Markdown Report Generation**: Professional formatted reports with tables
|
|
//! - **Production Criteria Validation**: Automated APPROVE/REJECT/REVIEW recommendations
|
|
//! - **Baseline Comparison**: Side-by-side comparison with reference model (Trial #35)
|
|
//! - **Comprehensive Metrics**: Returns, Sharpe, drawdown, win rate, alpha, trade stats
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! ```rust
|
|
//! use ml_backtesting::report::{BacktestReport, PerformanceMetrics};
|
|
//!
|
|
//! let new_results = PerformanceMetrics {
|
|
//! total_return_pct: 15.2,
|
|
//! sharpe_ratio: 2.3,
|
|
//! max_drawdown_pct: 12.5,
|
|
//! win_rate: 0.58,
|
|
//! alpha: 3.2,
|
|
//! total_trades: 145,
|
|
//! avg_trade_return_pct: 0.105,
|
|
//! };
|
|
//!
|
|
//! let baseline = Some(PerformanceMetrics {
|
|
//! total_return_pct: 12.1,
|
|
//! sharpe_ratio: 1.8,
|
|
//! max_drawdown_pct: 18.3,
|
|
//! win_rate: 0.52,
|
|
//! alpha: 1.5,
|
|
//! total_trades: 138,
|
|
//! avg_trade_return_pct: 0.088,
|
|
//! });
|
|
//!
|
|
//! let report = BacktestReport {
|
|
//! model_name: "DQN-Wave3-Entropy".to_owned(),
|
|
//! baseline_name: "DQN-Trial35-Baseline".to_owned(),
|
|
//! new_results,
|
|
//! baseline_results: baseline,
|
|
//! };
|
|
//!
|
|
//! let markdown = report.generate_markdown();
|
|
//! std::fs::write("backtest_report.md", markdown).unwrap();
|
|
//! ```
|
|
|
|
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fmt::Write;
|
|
|
|
/// Performance metrics for backtesting evaluation
|
|
///
|
|
/// Simplified metrics struct focused on production criteria validation.
|
|
/// Matches the key metrics from `backtesting/src/metrics.rs` but optimized
|
|
/// for report generation and comparison purposes.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PerformanceMetrics {
|
|
/// Total return as percentage (e.g., 15.2 = 15.2%)
|
|
pub total_return_pct: f64,
|
|
/// Sharpe ratio (risk-adjusted return)
|
|
pub sharpe_ratio: f64,
|
|
/// Maximum drawdown as percentage (e.g., 12.5 = 12.5%)
|
|
pub max_drawdown_pct: f64,
|
|
/// Win rate (0.0-1.0, e.g., 0.58 = 58%)
|
|
pub win_rate: f64,
|
|
/// Alpha (excess return vs benchmark, percentage)
|
|
pub alpha: f64,
|
|
/// Total number of trades executed
|
|
pub total_trades: usize,
|
|
/// Average trade return as percentage
|
|
pub avg_trade_return_pct: f64,
|
|
}
|
|
|
|
/// Deployment recommendation structure
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Recommendation {
|
|
/// Status string (✅ APPROVE, ⚠️ REVIEW, ❌ REJECT)
|
|
pub status: String,
|
|
/// Human-readable reasoning for the recommendation
|
|
pub reasoning: String,
|
|
}
|
|
|
|
/// Complete backtesting report structure
|
|
///
|
|
/// Compares a new model against an optional baseline and generates
|
|
/// deployment recommendations based on production criteria.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BacktestReport {
|
|
/// Name of the new model being evaluated
|
|
pub model_name: String,
|
|
/// Name of the baseline model for comparison
|
|
pub baseline_name: String,
|
|
/// Performance metrics for the new model
|
|
pub new_results: PerformanceMetrics,
|
|
/// Optional baseline performance metrics (e.g., Trial #35)
|
|
pub baseline_results: Option<PerformanceMetrics>,
|
|
}
|
|
|
|
impl BacktestReport {
|
|
/// Generate comprehensive markdown report
|
|
///
|
|
/// Creates a professional formatted report with:
|
|
/// - Header with model names and timestamp
|
|
/// - Performance summary table with production criteria
|
|
/// - Baseline comparison table (if baseline provided)
|
|
/// - Deployment recommendation with reasoning
|
|
/// - Trade statistics summary
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A formatted markdown string ready to write to a file
|
|
pub fn generate_markdown(&self) -> String {
|
|
let mut report = String::new();
|
|
|
|
// Header
|
|
report.push_str("# DQN Backtesting Report\n\n");
|
|
let _w = writeln!(report, "**Model**: {}", self.model_name);
|
|
if self.baseline_results.is_some() {
|
|
let _w = writeln!(report, "**Baseline**: {}", self.baseline_name);
|
|
}
|
|
let _w = write!(report, "**Generated**: {}\n\n", Utc::now().format("%Y-%m-%d %H:%M:%S UTC"));
|
|
|
|
report.push_str("---\n\n");
|
|
|
|
// Performance Summary
|
|
report.push_str("## Performance Summary\n\n");
|
|
report.push_str("| Metric | Value | Target | Status |\n");
|
|
report.push_str("|--------|-------|--------|--------|\n");
|
|
|
|
let m = &self.new_results;
|
|
|
|
// Total Return
|
|
let _w = writeln!(
|
|
report,
|
|
"| Total Return | {:.2}% | >0% | {} |",
|
|
m.total_return_pct,
|
|
if m.total_return_pct > 0.0 { "\u{2705}" } else { "\u{274c}" }
|
|
);
|
|
|
|
// Sharpe Ratio
|
|
let _w = writeln!(
|
|
report,
|
|
"| Sharpe Ratio | {:.2} | >1.5 | {} |",
|
|
m.sharpe_ratio,
|
|
if m.sharpe_ratio > 1.5 { "\u{2705}" } else { "\u{274c}" }
|
|
);
|
|
|
|
// Max Drawdown
|
|
let _w = writeln!(
|
|
report,
|
|
"| Max Drawdown | {:.2}% | <20% | {} |",
|
|
m.max_drawdown_pct,
|
|
if m.max_drawdown_pct < 20.0 { "\u{2705}" } else { "\u{274c}" }
|
|
);
|
|
|
|
// Win Rate
|
|
let _w = writeln!(
|
|
report,
|
|
"| Win Rate | {:.1}% | >50% | {} |",
|
|
m.win_rate * 100.0,
|
|
if m.win_rate > 0.50 { "\u{2705}" } else { "\u{274c}" }
|
|
);
|
|
|
|
// Alpha
|
|
let _w = writeln!(
|
|
report,
|
|
"| Alpha vs B&H | {:.2}% | >0% | {} |",
|
|
m.alpha,
|
|
if m.alpha > 0.0 { "\u{2705}" } else { "\u{274c}" }
|
|
);
|
|
|
|
report.push('\n');
|
|
|
|
// Comparison to baseline (if provided)
|
|
if let Some(baseline) = &self.baseline_results {
|
|
report.push_str("## Comparison to Baseline\n\n");
|
|
report.push_str("| Metric | Baseline | New Model | Change | Direction |\n");
|
|
report.push_str("|--------|----------|-----------|--------|----------|\n");
|
|
|
|
// Returns comparison
|
|
let return_change = m.total_return_pct - baseline.total_return_pct;
|
|
let return_arrow = if return_change > 0.0 { "\u{2197}\u{fe0f}" } else if return_change < 0.0 { "\u{2198}\u{fe0f}" } else { "\u{2192}" };
|
|
let _w = writeln!(
|
|
report,
|
|
"| Returns | {:.2}% | {:.2}% | {:+.2}% | {} |",
|
|
baseline.total_return_pct, m.total_return_pct, return_change, return_arrow
|
|
);
|
|
|
|
// Sharpe comparison
|
|
let sharpe_change = m.sharpe_ratio - baseline.sharpe_ratio;
|
|
let sharpe_arrow = if sharpe_change > 0.0 { "\u{2197}\u{fe0f}" } else if sharpe_change < 0.0 { "\u{2198}\u{fe0f}" } else { "\u{2192}" };
|
|
let _w = writeln!(
|
|
report,
|
|
"| Sharpe | {:.2} | {:.2} | {:+.2} | {} |",
|
|
baseline.sharpe_ratio, m.sharpe_ratio, sharpe_change, sharpe_arrow
|
|
);
|
|
|
|
// Drawdown comparison (lower is better)
|
|
let dd_change = m.max_drawdown_pct - baseline.max_drawdown_pct;
|
|
let dd_arrow = if dd_change < 0.0 { "\u{2197}\u{fe0f}" } else if dd_change > 0.0 { "\u{2198}\u{fe0f}" } else { "\u{2192}" };
|
|
let _w = writeln!(
|
|
report,
|
|
"| Drawdown | {:.2}% | {:.2}% | {:+.2}% | {} |",
|
|
baseline.max_drawdown_pct, m.max_drawdown_pct, dd_change, dd_arrow
|
|
);
|
|
|
|
// Win Rate comparison
|
|
let wr_change = (m.win_rate - baseline.win_rate) * 100.0;
|
|
let wr_arrow = if wr_change > 0.0 { "\u{2197}\u{fe0f}" } else if wr_change < 0.0 { "\u{2198}\u{fe0f}" } else { "\u{2192}" };
|
|
let _w = writeln!(
|
|
report,
|
|
"| Win Rate | {:.1}% | {:.1}% | {:+.1}% | {} |",
|
|
baseline.win_rate * 100.0, m.win_rate * 100.0, wr_change, wr_arrow
|
|
);
|
|
|
|
// Alpha comparison
|
|
let alpha_change = m.alpha - baseline.alpha;
|
|
let alpha_arrow = if alpha_change > 0.0 { "\u{2197}\u{fe0f}" } else if alpha_change < 0.0 { "\u{2198}\u{fe0f}" } else { "\u{2192}" };
|
|
let _w = writeln!(
|
|
report,
|
|
"| Alpha | {:.2}% | {:.2}% | {:+.2}% | {} |",
|
|
baseline.alpha, m.alpha, alpha_change, alpha_arrow
|
|
);
|
|
|
|
report.push('\n');
|
|
}
|
|
|
|
// Trade Statistics
|
|
report.push_str("## Trade Statistics\n\n");
|
|
report.push_str("| Metric | Value |\n");
|
|
report.push_str("|--------|-------|\n");
|
|
let _w = writeln!(report, "| Total Trades | {} |", m.total_trades);
|
|
let _w = writeln!(report, "| Avg Trade Return | {:.3}% |", m.avg_trade_return_pct);
|
|
let _w = writeln!(report, "| Win Rate | {:.2}% |", m.win_rate * 100.0);
|
|
|
|
if let Some(baseline) = &self.baseline_results {
|
|
let trade_diff = m.total_trades as i64 - baseline.total_trades as i64;
|
|
let _w = writeln!(report, "| Trades vs Baseline | {:+} |", trade_diff);
|
|
}
|
|
|
|
report.push('\n');
|
|
|
|
// Deployment Recommendation
|
|
report.push_str("## Deployment Recommendation\n\n");
|
|
let recommendation = self.get_recommendation();
|
|
let _w = write!(report, "**Status**: {}\n\n", recommendation.status);
|
|
let _w = write!(report, "{}\n\n", recommendation.reasoning);
|
|
|
|
// Production Criteria Summary
|
|
report.push_str("### Production Criteria Checklist\n\n");
|
|
let criteria_passed = self.count_criteria_passed();
|
|
let _w = writeln!(report, "- **Criteria Passed**: {}/5", criteria_passed);
|
|
let _w = writeln!(report, "- **Total Return**: {} ({:.2}% > 0%)",
|
|
if m.total_return_pct > 0.0 { "\u{2705} PASS" } else { "\u{274c} FAIL" },
|
|
m.total_return_pct
|
|
);
|
|
let _w = writeln!(report, "- **Sharpe Ratio**: {} ({:.2} > 1.5)",
|
|
if m.sharpe_ratio > 1.5 { "\u{2705} PASS" } else { "\u{274c} FAIL" },
|
|
m.sharpe_ratio
|
|
);
|
|
let _w = writeln!(report, "- **Max Drawdown**: {} ({:.2}% < 20%)",
|
|
if m.max_drawdown_pct < 20.0 { "\u{2705} PASS" } else { "\u{274c} FAIL" },
|
|
m.max_drawdown_pct
|
|
);
|
|
let _w = writeln!(report, "- **Win Rate**: {} ({:.1}% > 50%)",
|
|
if m.win_rate > 0.50 { "\u{2705} PASS" } else { "\u{274c} FAIL" },
|
|
m.win_rate * 100.0
|
|
);
|
|
let _w = writeln!(report, "- **Alpha vs B&H**: {} ({:.2}% > 0%)",
|
|
if m.alpha > 0.0 { "\u{2705} PASS" } else { "\u{274c} FAIL" },
|
|
m.alpha
|
|
);
|
|
|
|
report.push('\n');
|
|
|
|
// Footer
|
|
report.push_str("---\n\n");
|
|
report.push_str("*Report generated automatically by Foxhunt ML Evaluation Framework*\n");
|
|
|
|
report
|
|
}
|
|
|
|
/// Count how many production criteria are passed
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Number of criteria passed (0-5)
|
|
fn count_criteria_passed(&self) -> usize {
|
|
let m = &self.new_results;
|
|
[
|
|
m.total_return_pct > 0.0,
|
|
m.sharpe_ratio > 1.5,
|
|
m.max_drawdown_pct < 20.0,
|
|
m.win_rate > 0.50,
|
|
m.alpha > 0.0,
|
|
]
|
|
.iter()
|
|
.filter(|&&x| x)
|
|
.count()
|
|
}
|
|
|
|
/// Generate deployment recommendation based on production criteria
|
|
///
|
|
/// # Recommendation Logic
|
|
///
|
|
/// - **APPROVE (4-5 criteria)**: Model is production-ready
|
|
/// - **REVIEW (2-3 criteria)**: Marginal performance, needs review
|
|
/// - **REJECT (0-1 criteria)**: Not production-ready
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A `Recommendation` with status and reasoning
|
|
pub fn get_recommendation(&self) -> Recommendation {
|
|
let m = &self.new_results;
|
|
let passes = self.count_criteria_passed();
|
|
|
|
if passes >= 4 {
|
|
Recommendation {
|
|
status: "\u{2705} APPROVE - Ready for Production".to_owned(),
|
|
reasoning: format!(
|
|
"Model passes {}/5 production criteria. Strong performance with {:.2}% return, {:.2} Sharpe ratio, and {:.1}% win rate. \
|
|
Risk is acceptable with {:.2}% max drawdown. Model demonstrates profitability with {:.2}% alpha vs buy-and-hold. \
|
|
\n\n**Action**: Proceed with production deployment after final validation.",
|
|
passes,
|
|
m.total_return_pct,
|
|
m.sharpe_ratio,
|
|
m.win_rate * 100.0,
|
|
m.max_drawdown_pct,
|
|
m.alpha
|
|
),
|
|
}
|
|
} else if passes >= 2 {
|
|
Recommendation {
|
|
status: "\u{26a0}\u{fe0f} REVIEW - Marginal Performance".to_owned(),
|
|
reasoning: format!(
|
|
"Model passes {}/5 production criteria. Performance is marginal and requires careful review. \
|
|
\n\n**Concerns**:\n{}
|
|
\n**Action**: Conduct detailed risk assessment and consider additional testing before deployment.",
|
|
passes,
|
|
self.generate_concerns_list()
|
|
),
|
|
}
|
|
} else {
|
|
Recommendation {
|
|
status: "\u{274c} REJECT - Not Production Ready".to_owned(),
|
|
reasoning: format!(
|
|
"Model only passes {}/5 production criteria. Performance is insufficient for production deployment. \
|
|
\n\n**Critical Issues**:\n{}
|
|
\n**Action**: Do not deploy. Retrain model with improved hyperparameters or different architecture.",
|
|
passes,
|
|
self.generate_concerns_list()
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generate list of concerns for models not passing all criteria
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Markdown-formatted list of failed criteria
|
|
fn generate_concerns_list(&self) -> String {
|
|
let m = &self.new_results;
|
|
let mut concerns = Vec::new();
|
|
|
|
if m.total_return_pct <= 0.0 {
|
|
concerns.push(format!("- \u{274c} Negative total return ({:.2}%)", m.total_return_pct));
|
|
}
|
|
if m.sharpe_ratio <= 1.5 {
|
|
concerns.push(format!("- \u{274c} Low Sharpe ratio ({:.2} < 1.5)", m.sharpe_ratio));
|
|
}
|
|
if m.max_drawdown_pct >= 20.0 {
|
|
concerns.push(format!("- \u{274c} Excessive drawdown ({:.2}% > 20%)", m.max_drawdown_pct));
|
|
}
|
|
if m.win_rate <= 0.50 {
|
|
concerns.push(format!("- \u{274c} Poor win rate ({:.1}% < 50%)", m.win_rate * 100.0));
|
|
}
|
|
if m.alpha <= 0.0 {
|
|
concerns.push(format!("- \u{274c} Negative alpha ({:.2}%)", m.alpha));
|
|
}
|
|
|
|
if concerns.is_empty() {
|
|
"- No critical issues identified".to_owned()
|
|
} else {
|
|
concerns.join("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_report_generation_approve() {
|
|
let report = BacktestReport {
|
|
model_name: "DQN-Test-Model".to_owned(),
|
|
baseline_name: "DQN-Baseline".to_owned(),
|
|
new_results: PerformanceMetrics {
|
|
total_return_pct: 15.2,
|
|
sharpe_ratio: 2.3,
|
|
max_drawdown_pct: 12.5,
|
|
win_rate: 0.58,
|
|
alpha: 3.2,
|
|
total_trades: 145,
|
|
avg_trade_return_pct: 0.105,
|
|
},
|
|
baseline_results: None,
|
|
};
|
|
|
|
let markdown = report.generate_markdown();
|
|
|
|
assert!(markdown.contains("# DQN Backtesting Report"));
|
|
assert!(markdown.contains("DQN-Test-Model"));
|
|
assert!(markdown.contains("✅ APPROVE"));
|
|
assert!(markdown.contains("5/5"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_generation_reject() {
|
|
let report = BacktestReport {
|
|
model_name: "DQN-Poor-Model".to_owned(),
|
|
baseline_name: "DQN-Baseline".to_owned(),
|
|
new_results: PerformanceMetrics {
|
|
total_return_pct: -5.2,
|
|
sharpe_ratio: 0.8,
|
|
max_drawdown_pct: 35.0,
|
|
win_rate: 0.42,
|
|
alpha: -2.1,
|
|
total_trades: 120,
|
|
avg_trade_return_pct: -0.043,
|
|
},
|
|
baseline_results: None,
|
|
};
|
|
|
|
let markdown = report.generate_markdown();
|
|
|
|
assert!(markdown.contains("❌ REJECT"));
|
|
assert!(markdown.contains("0/5"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_baseline_comparison() {
|
|
let report = BacktestReport {
|
|
model_name: "DQN-New".to_owned(),
|
|
baseline_name: "DQN-Trial35".to_owned(),
|
|
new_results: PerformanceMetrics {
|
|
total_return_pct: 18.5,
|
|
sharpe_ratio: 2.5,
|
|
max_drawdown_pct: 10.2,
|
|
win_rate: 0.62,
|
|
alpha: 4.5,
|
|
total_trades: 150,
|
|
avg_trade_return_pct: 0.123,
|
|
},
|
|
baseline_results: Some(PerformanceMetrics {
|
|
total_return_pct: 12.1,
|
|
sharpe_ratio: 1.8,
|
|
max_drawdown_pct: 18.3,
|
|
win_rate: 0.52,
|
|
alpha: 1.5,
|
|
total_trades: 138,
|
|
avg_trade_return_pct: 0.088,
|
|
}),
|
|
};
|
|
|
|
let markdown = report.generate_markdown();
|
|
|
|
assert!(markdown.contains("Comparison to Baseline"));
|
|
assert!(markdown.contains("DQN-Trial35"));
|
|
assert!(markdown.contains("+6.40%")); // Return improvement (18.5 - 12.1 = 6.4)
|
|
}
|
|
}
|