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:
Devid HW
2026-07-02 03:53:58 +07:00
parent d2fca8ded3
commit 552e271d08
8 changed files with 504 additions and 42 deletions
+345 -1
View File
@@ -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())