feat: add rolling backtest and optimization improvements
- Add run_rolling_backtest tool for N consecutive weekly backtests - Add max_passes support for optimization to cap genetic passes - Kill stale MT5/agent processes before optimization launch - Auto-parse optimization results on status poll when process completes - Fix XML namespace for SpreadsheetML parsing - Change optimization model from 4 to 1 for better accuracy - Persist completion results to job metadata
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Datelike;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tokio::time::Duration;
|
||||
use crate::models::Config;
|
||||
use crate::models::report::BacktestJob;
|
||||
use crate::pipeline::backtest::{BacktestParams, BacktestPipeline};
|
||||
@@ -375,6 +377,348 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Result<Value> {
|
||||
let expert = args.get("expert")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert is required"))?;
|
||||
|
||||
let weeks_count = args.get("weeks").and_then(|v| v.as_u64()).unwrap_or(4) as i64;
|
||||
if weeks_count < 1 || weeks_count > 52 {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": "weeks must be between 1 and 52".to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Calculate weekly date ranges
|
||||
let from_arg = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let to_arg = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let weeks: Vec<(String, String, String)> = if !from_arg.is_empty() && !to_arg.is_empty() {
|
||||
let start = chrono::NaiveDate::parse_from_str(from_arg, "%Y.%m.%d")
|
||||
.map_err(|e| anyhow::anyhow!("Invalid from_date '{}': {}", from_arg, e))?;
|
||||
let end = chrono::NaiveDate::parse_from_str(to_arg, "%Y.%m.%d")
|
||||
.map_err(|e| anyhow::anyhow!("Invalid to_date '{}': {}", to_arg, e))?;
|
||||
if end <= start {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": "to_date must be after from_date".to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
let mut weeks = Vec::new();
|
||||
let mut current = start;
|
||||
let mut week_idx = 0;
|
||||
while current < end && weeks.len() < weeks_count as usize {
|
||||
week_idx += 1;
|
||||
let week_end = if current + chrono::Duration::days(7) >= end {
|
||||
end
|
||||
} else {
|
||||
let days_to_sun = 6 - current.weekday().num_days_from_monday();
|
||||
let week_end_raw = current + chrono::Duration::days(days_to_sun as i64);
|
||||
std::cmp::min(week_end_raw, end)
|
||||
};
|
||||
let label = format!("Week {}", week_idx);
|
||||
weeks.push((label, current.format("%Y.%m.%d").to_string(), week_end.format("%Y.%m.%d").to_string()));
|
||||
current = week_end + chrono::Duration::days(1);
|
||||
}
|
||||
weeks
|
||||
} else {
|
||||
let now = chrono::Utc::now().date_naive();
|
||||
let days_from_sun = now.weekday().num_days_from_sunday();
|
||||
let last_sun = now - chrono::Duration::days(days_from_sun as i64);
|
||||
let mut weeks = Vec::new();
|
||||
for i in 0..weeks_count {
|
||||
let i = weeks_count - 1 - i;
|
||||
let week_end = last_sun - chrono::Duration::days(i * 7);
|
||||
let week_start = week_end - chrono::Duration::days(6);
|
||||
let label = format!(
|
||||
"{} - {}",
|
||||
week_start.format("%b %d"),
|
||||
week_end.format("%b %d")
|
||||
);
|
||||
weeks.push((label, week_start.format("%Y.%m.%d").to_string(), week_end.format("%Y.%m.%d").to_string()));
|
||||
}
|
||||
weeks
|
||||
};
|
||||
|
||||
let symbol = args.get("symbol").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let timeframe = args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string();
|
||||
let deposit = args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32;
|
||||
let model = args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||
let leverage: u32 = args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32;
|
||||
let set_file = args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let deep_analyze = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let shutdown = args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let kill_existing = args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let timeout = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900);
|
||||
let gui = args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let startup_delay_secs = args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let skip_compile_first = args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
// Create a rolling job report dir for status tracking
|
||||
let report_id = format!(
|
||||
"ROLLING_{}_{}_{}",
|
||||
expert,
|
||||
weeks.first().map(|w| &w.1).unwrap_or(&"unknown".to_string()),
|
||||
weeks.last().map(|w| &w.2).unwrap_or(&"unknown".to_string()),
|
||||
);
|
||||
let report_dir = config.reports_dir().join(&report_id);
|
||||
fs::create_dir_all(&report_dir)?;
|
||||
|
||||
let job_path = report_dir.join("job.json");
|
||||
let job = BacktestJob {
|
||||
report_id: report_id.clone(),
|
||||
report_dir: report_dir.to_string_lossy().to_string(),
|
||||
expert: expert.to_string(),
|
||||
symbol: symbol.clone(),
|
||||
timeframe: timeframe.clone(),
|
||||
mt5_pid: None,
|
||||
expected_report_path: String::new(),
|
||||
timeout_seconds: timeout * weeks.len() as u64,
|
||||
launched_at: chrono::Utc::now().to_rfc3339(),
|
||||
status: Some("launched".to_string()),
|
||||
};
|
||||
fs::write(&job_path, serde_json::to_string_pretty(&job)?)?;
|
||||
|
||||
// Save the weekly schedule for status polling
|
||||
let weeks_path = report_dir.join("weeks.json");
|
||||
fs::write(&weeks_path, serde_json::to_string_pretty(&json!({
|
||||
"weeks": weeks.iter().map(|(l, f, t)| json!({"label": l, "from_date": f, "to_date": t})).collect::<Vec<_>>()
|
||||
}))?)?;
|
||||
|
||||
// Spawn background task to run all weeks sequentially
|
||||
let config_clone = config.clone();
|
||||
let report_dir_clone = report_dir.clone();
|
||||
let expert_clone = expert.to_string();
|
||||
let symbol_clone = symbol;
|
||||
let timeframe_clone = timeframe;
|
||||
let set_file_clone = set_file;
|
||||
let weeks_clone = weeks.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_rolling_weeks_sequential(
|
||||
config_clone,
|
||||
report_dir_clone,
|
||||
expert_clone,
|
||||
symbol_clone,
|
||||
timeframe_clone,
|
||||
deposit,
|
||||
model,
|
||||
leverage,
|
||||
set_file_clone,
|
||||
deep_analyze,
|
||||
shutdown,
|
||||
kill_existing,
|
||||
timeout,
|
||||
gui,
|
||||
startup_delay_secs,
|
||||
skip_compile_first,
|
||||
weeks_clone,
|
||||
).await {
|
||||
tracing::error!("Rolling backtest failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"message": format!("Rolling backtest launched with {} weeks. Use get_backtest_status to poll for completion.", weeks.len()),
|
||||
"report_id": report_id,
|
||||
"report_dir": report_dir.to_string_lossy(),
|
||||
"expert": expert,
|
||||
"weeks": weeks.iter().map(|(l, f, t)| json!({"label": l, "from_date": f, "to_date": t})).collect::<Vec<_>>(),
|
||||
"poll_hint": "Call get_backtest_status with report_dir to check progress"
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn run_rolling_weeks_sequential(
|
||||
config: Config,
|
||||
report_dir: PathBuf,
|
||||
expert: String,
|
||||
symbol: String,
|
||||
timeframe: String,
|
||||
deposit: u32,
|
||||
model: u8,
|
||||
leverage: u32,
|
||||
set_file: Option<String>,
|
||||
deep_analyze: bool,
|
||||
_shutdown: bool,
|
||||
_kill_existing: bool,
|
||||
timeout: u64,
|
||||
gui: bool,
|
||||
startup_delay_secs: u64,
|
||||
skip_compile_first: bool,
|
||||
weeks: Vec<(String, String, String)>,
|
||||
) -> Result<()> {
|
||||
let pipeline = if cfg!(debug_assertions) {
|
||||
BacktestPipeline::new(config.clone())
|
||||
} else {
|
||||
BacktestPipeline::new(config.clone())
|
||||
};
|
||||
let progress_log = report_dir.join("progress.log");
|
||||
|
||||
// Clear stale results
|
||||
let results_path = report_dir.join("rolling_results.json");
|
||||
let _ = fs::remove_file(&results_path);
|
||||
|
||||
let mut week_results = Vec::new();
|
||||
let mut total_net_profit: f64 = 0.0;
|
||||
let mut max_drawdown: f64 = 0.0;
|
||||
let mut total_trades: u64 = 0;
|
||||
|
||||
// Kill MT5/wineserver before starting
|
||||
for pat in &["MetaTrader 5.app", "terminal64.exe", "wineserver"] {
|
||||
let _ = Command::new("pkill").args(["-KILL", "-f", pat]).output();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
for (idx, (label, from_date, to_date)) in weeks.iter().enumerate() {
|
||||
let skip_compile = if idx == 0 { skip_compile_first } else { true };
|
||||
|
||||
fs::write(&progress_log, format!("WEEK {}: {} ({} -> {})\n", idx + 1, label, from_date, to_date))
|
||||
.unwrap_or(());
|
||||
|
||||
tracing::info!("Rolling week {}/{}: {} ({} -> {})", idx + 1, weeks.len(), label, from_date, to_date);
|
||||
|
||||
// Clean slate before each week
|
||||
for pat in &["MetaTrader 5.app", "terminal64.exe", "wineserver"] {
|
||||
let _ = Command::new("pkill").args(["-KILL", "-f", pat]).output();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Fire-and-forget launch (handles journal fallback if HTML not produced)
|
||||
let params = BacktestParams {
|
||||
expert: expert.clone(),
|
||||
symbol: symbol.clone(),
|
||||
from_date: from_date.clone(),
|
||||
to_date: to_date.clone(),
|
||||
timeframe: timeframe.clone(),
|
||||
deposit,
|
||||
model,
|
||||
leverage,
|
||||
set_file: set_file.clone(),
|
||||
skip_compile,
|
||||
skip_clean: false,
|
||||
skip_analyze: false,
|
||||
deep_analyze,
|
||||
shutdown: true,
|
||||
kill_existing: false,
|
||||
timeout,
|
||||
gui,
|
||||
startup_delay_secs,
|
||||
inactivity_kill_secs: None,
|
||||
};
|
||||
|
||||
let job = match pipeline.launch_backtest(params).await {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::error!("Rolling week {}/{} launch failed: {}", idx + 1, weeks.len(), e);
|
||||
week_results.push(json!({
|
||||
"label": label, "from_date": from_date, "to_date": to_date,
|
||||
"success": false, "error": format!("launch failed: {}", e)
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for completion (up to timeout)
|
||||
let week_report_dir = Path::new(&job.report_dir);
|
||||
let poll_start = std::time::Instant::now();
|
||||
let max_wait = Duration::from_secs(timeout);
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// Check for metrics.json (written after extraction, including journal fallback)
|
||||
let metrics_path = week_report_dir.join("metrics.json");
|
||||
if metrics_path.exists() { break; }
|
||||
|
||||
let jp = week_report_dir.join("job.json");
|
||||
if let Ok(content) = fs::read_to_string(&jp) {
|
||||
if let Ok(j) = serde_json::from_str::<BacktestJob>(&content) {
|
||||
if let Some(ref s) = j.status {
|
||||
if s == "completed" || s == "completed_no_html" { break; }
|
||||
if s == "failed" || s == "timeout" || s == "timeout_inactive" {
|
||||
tracing::warn!("Rolling week {}/{} background status: {}", idx + 1, weeks.len(), s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if poll_start.elapsed() > max_wait {
|
||||
tracing::warn!("Rolling week {}/{} timed out after {}s", idx + 1, weeks.len(), timeout);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a moment for extraction to finish
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Read results
|
||||
let metrics_path = week_report_dir.join("metrics.json");
|
||||
if let Ok(content) = fs::read_to_string(&metrics_path) {
|
||||
if let Ok(metrics) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let np = metrics.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let dd = metrics.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let trades = metrics.get("total_trades").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let pf = metrics.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
total_net_profit += np;
|
||||
if dd > max_drawdown { max_drawdown = dd; }
|
||||
total_trades += trades;
|
||||
|
||||
tracing::info!("Rolling week {}/{} done: profit={:.2}, dd={:.1}%", idx + 1, weeks.len(), np, dd);
|
||||
|
||||
week_results.push(json!({
|
||||
"label": label, "from_date": from_date, "to_date": to_date,
|
||||
"success": true, "net_profit": np, "max_dd_pct": dd,
|
||||
"total_trades": trades, "profit_factor": pf,
|
||||
"report_dir": job.report_dir
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No metrics found
|
||||
week_results.push(json!({
|
||||
"label": label, "from_date": from_date, "to_date": to_date,
|
||||
"success": false, "error": "No metrics extracted"
|
||||
}));
|
||||
}
|
||||
|
||||
// Write final results
|
||||
let summary = json!({
|
||||
"success": true,
|
||||
"weeks_run": week_results.len(),
|
||||
"summary": {
|
||||
"total_net_profit": total_net_profit,
|
||||
"max_drawdown_pct": max_drawdown,
|
||||
"total_trades": total_trades,
|
||||
},
|
||||
"weekly_results": week_results
|
||||
});
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&summary) {
|
||||
let _ = fs::write(&results_path, &json_str);
|
||||
}
|
||||
|
||||
// Update job status
|
||||
let job_path = report_dir.join("job.json");
|
||||
if let Ok(content) = fs::read_to_string(&job_path) {
|
||||
if let Ok(mut job) = serde_json::from_str::<BacktestJob>(&content) {
|
||||
job.status = Some("completed".to_string());
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&job) {
|
||||
let _ = fs::write(&job_path, json_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&progress_log, "DONE\n").unwrap_or(());
|
||||
tracing::info!("Rolling backtest completed: {} weeks, profit={:.2}, max_dd={:.1}%", week_results.len(), total_net_profit, max_drawdown);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
Reference in New Issue
Block a user