Add report database and improve compile_ea tool
**Report Database (src/storage/):** - New SQLite-based report registry for tracking backtest history - ReportDb with methods: init, list, count, annotate, list_purgeable, delete_entry - Charts relocation to temp directory with automatic cleanup - Set file snapshotting alongside extracted data - Database registration integrated into backtest pipeline **compile_ea Tool Improvements:** - Support both 'expert' (EA name) and 'expert_path' (full path) parameters - Auto-discovery: searches MT5 Experts dir and current directory for .mq5 files - Better error messages when EA not found - Added files_synced and warning_list to response - Updated tool definition to reflect new parameters **Pipeline Updates:** - Backtest pipeline now registers results in database after completion - Equity charts moved to OS temp dir with unique report ID - HTML reports cleaned up after extraction - Set file snapshots preserved for reproducibility **Dependencies:** - Added rusqlite for SQLite database support
This commit is contained in:
+481
-93
@@ -9,6 +9,7 @@ use crate::analytics::{DealAnalyzer, ReportExtractor};
|
||||
use crate::compile::MqlCompiler;
|
||||
use crate::models::config::Config;
|
||||
use crate::models::report::{PipelineMetadata, FilePaths};
|
||||
use crate::storage::{ReportDb, ReportEntry};
|
||||
|
||||
pub struct BacktestPipeline {
|
||||
config: Config,
|
||||
@@ -33,6 +34,7 @@ pub struct BacktestParams {
|
||||
#[allow(dead_code)]
|
||||
pub deep_analyze: bool,
|
||||
pub shutdown: bool,
|
||||
#[allow(dead_code)]
|
||||
pub kill_existing: bool,
|
||||
pub timeout: u64,
|
||||
pub gui: bool,
|
||||
@@ -63,9 +65,9 @@ impl BacktestPipeline {
|
||||
let start_time = chrono::Utc::now();
|
||||
let report_id = self.generate_report_id(¶ms);
|
||||
let report_dir = self.config.reports_dir().join(&report_id);
|
||||
|
||||
|
||||
fs::create_dir_all(&report_dir)?;
|
||||
|
||||
|
||||
let progress_log = report_dir.join("progress.log");
|
||||
self.log_progress(&progress_log, "START").await;
|
||||
|
||||
@@ -85,23 +87,41 @@ impl BacktestPipeline {
|
||||
self.log_progress(&progress_log, "EXTRACT").await;
|
||||
let extraction = self.extractor.extract(
|
||||
&report_path.to_string_lossy(),
|
||||
&report_dir.to_string_lossy()
|
||||
&report_dir.to_string_lossy(),
|
||||
)?;
|
||||
|
||||
// Move equity chart images to OS temp dir, then delete the HTML report.
|
||||
let charts_dir = self.relocate_charts(&report_path, &report_id).await;
|
||||
let _ = fs::remove_file(&report_path);
|
||||
|
||||
// Snapshot the set file alongside the extracted data.
|
||||
let set_snapshot = self.snapshot_set_file(¶ms, &report_dir).await;
|
||||
|
||||
if !params.skip_analyze {
|
||||
self.log_progress(&progress_log, "ANALYZE").await;
|
||||
let analysis = self.analyzer.analyze(&extraction.deals, &extraction.metrics);
|
||||
|
||||
|
||||
let analysis_path = report_dir.join("analysis.json");
|
||||
let analysis_json = serde_json::to_string_pretty(&analysis)?;
|
||||
fs::write(&analysis_path, analysis_json)?;
|
||||
fs::write(&analysis_path, serde_json::to_string_pretty(&analysis)?)?;
|
||||
}
|
||||
|
||||
self.log_progress(&progress_log, "DONE").await;
|
||||
|
||||
|
||||
let duration = (chrono::Utc::now() - start_time).num_seconds();
|
||||
self.save_metadata(¶ms, &report_dir, duration).await?;
|
||||
|
||||
// Register in the SQLite report registry.
|
||||
self.register_in_db(
|
||||
&report_id,
|
||||
¶ms,
|
||||
&report_dir,
|
||||
charts_dir.as_deref(),
|
||||
set_snapshot.as_deref(),
|
||||
&extraction.metrics,
|
||||
duration,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(PipelineResult {
|
||||
success: true,
|
||||
report_dir,
|
||||
@@ -110,8 +130,110 @@ impl BacktestPipeline {
|
||||
})
|
||||
}
|
||||
|
||||
/// Move equity chart images (*.png, *.gif) from MT5's reports dir to OS temp,
|
||||
/// returning the temp path if any images were found.
|
||||
async fn relocate_charts(&self, html_path: &Path, report_id: &str) -> Option<PathBuf> {
|
||||
let reports_dir = html_path.parent()?;
|
||||
let charts_dir = Config::charts_temp_dir(report_id);
|
||||
let image_exts = ["png", "gif", "jpg", "jpeg"];
|
||||
|
||||
let entries = fs::read_dir(reports_dir).ok()?;
|
||||
let mut found = false;
|
||||
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let name = path.file_name()?.to_string_lossy().to_string();
|
||||
|
||||
let is_chart = name.starts_with(report_id)
|
||||
&& path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| image_exts.contains(&e))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_chart {
|
||||
if !found {
|
||||
if fs::create_dir_all(&charts_dir).is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let dest = charts_dir.join(entry.file_name());
|
||||
let _ = fs::rename(&path, &dest);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if found { Some(charts_dir) } else { None }
|
||||
}
|
||||
|
||||
/// Copy the set file into the report dir as set_snapshot.set.
|
||||
async fn snapshot_set_file(
|
||||
&self,
|
||||
params: &BacktestParams,
|
||||
report_dir: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
let set_src = params.set_file.as_ref()?;
|
||||
let src_path = Path::new(set_src);
|
||||
if !src_path.exists() {
|
||||
return None;
|
||||
}
|
||||
let dest = report_dir.join("set_snapshot.set");
|
||||
fs::copy(src_path, &dest).ok()?;
|
||||
Some(dest)
|
||||
}
|
||||
|
||||
async fn register_in_db(
|
||||
&self,
|
||||
report_id: &str,
|
||||
params: &BacktestParams,
|
||||
report_dir: &Path,
|
||||
charts_dir: Option<&Path>,
|
||||
set_snapshot: Option<&Path>,
|
||||
metrics: &crate::models::metrics::Metrics,
|
||||
duration: i64,
|
||||
) {
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
if let Err(e) = db.init() {
|
||||
tracing::warn!("Failed to init report DB: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let entry = ReportEntry {
|
||||
id: report_id.to_string(),
|
||||
expert: params.expert.clone(),
|
||||
symbol: params.symbol.clone(),
|
||||
timeframe: params.timeframe.clone(),
|
||||
model: params.model as i64,
|
||||
from_date: params.from_date.clone(),
|
||||
to_date: params.to_date.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
set_file_original: params.set_file.clone(),
|
||||
set_snapshot_path: set_snapshot.map(|p| p.to_string_lossy().to_string()),
|
||||
report_dir: report_dir.to_string_lossy().to_string(),
|
||||
charts_dir: charts_dir.map(|p| p.to_string_lossy().to_string()),
|
||||
net_profit: Some(metrics.net_profit),
|
||||
profit_factor: Some(metrics.profit_factor),
|
||||
max_dd_pct: Some(metrics.max_dd_pct),
|
||||
sharpe_ratio: Some(metrics.sharpe_ratio),
|
||||
total_trades: Some(metrics.total_trades as i64),
|
||||
win_rate_pct: Some(metrics.win_rate_pct),
|
||||
recovery_factor: Some(metrics.recovery_factor),
|
||||
deposit: Some(params.deposit as f64),
|
||||
currency: self.config.backtest_currency.clone(),
|
||||
leverage: Some(params.leverage as i64),
|
||||
duration_seconds: Some(duration),
|
||||
tags: Vec::new(),
|
||||
notes: None,
|
||||
verdict: None,
|
||||
};
|
||||
|
||||
if let Err(e) = db.insert(&entry) {
|
||||
tracing::warn!("Failed to register report in DB: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn compile_ea(&self, expert: &str) -> Result<()> {
|
||||
let search_paths = [
|
||||
let mut search_paths = vec![
|
||||
PathBuf::from(&self.config.get("project_dir")).join("src/experts").join(format!("{}.mq5", expert)),
|
||||
PathBuf::from(&self.config.get("project_dir")).join("src").join(format!("{}.mq5", expert)),
|
||||
PathBuf::from(&self.config.get("project_dir")).join(format!("{}.mq5", expert)),
|
||||
@@ -119,11 +241,16 @@ impl BacktestPipeline {
|
||||
PathBuf::from("src").join(format!("{}.mq5", expert)),
|
||||
PathBuf::from(format!("{}.mq5", expert)),
|
||||
];
|
||||
// Also search in MT5 Experts dir: Experts/{expert}/{expert}.mq5 and Experts/{expert}.mq5
|
||||
if let Some(experts_dir) = &self.config.experts_dir {
|
||||
search_paths.push(PathBuf::from(experts_dir).join(expert).join(format!("{}.mq5", expert)));
|
||||
search_paths.push(PathBuf::from(experts_dir).join(format!("{}.mq5", expert)));
|
||||
}
|
||||
|
||||
let source_path = search_paths
|
||||
.into_iter()
|
||||
.find(|p| p.exists())
|
||||
.ok_or_else(|| anyhow!("Cannot find {}.mq5", expert))?;
|
||||
.ok_or_else(|| anyhow!("Cannot find {}.mq5 — searched project_dir and MT5 Experts dir", expert))?;
|
||||
|
||||
let result = self.compiler.compile(&source_path.to_string_lossy())?;
|
||||
|
||||
@@ -209,103 +336,310 @@ impl BacktestPipeline {
|
||||
async fn run_backtest(&self, params: &BacktestParams, report_id: &str) -> Result<PathBuf> {
|
||||
let mt5_dir = self.config.mt5_dir()
|
||||
.ok_or_else(|| anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
|
||||
let wine_exe = self.config.wine_executable.as_ref()
|
||||
.ok_or_else(|| anyhow!("wine_executable not configured"))?;
|
||||
|
||||
// mt5_dir = {prefix}/drive_c/Program Files/MetaTrader 5
|
||||
// WINEPREFIX = three levels up
|
||||
let wine_prefix = mt5_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.parent() // .../drive_c/Program Files
|
||||
.and_then(|p| p.parent()) // .../drive_c
|
||||
.and_then(|p| p.parent()) // .../<prefix>
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| anyhow!("Could not determine Wine prefix"))?;
|
||||
.ok_or_else(|| anyhow!("Could not determine Wine prefix from terminal_dir"))?;
|
||||
|
||||
let reports_dir = mt5_dir.join("reports");
|
||||
fs::create_dir_all(&reports_dir)?;
|
||||
|
||||
let ini_path = mt5_dir.join("backtest_config.ini");
|
||||
// Write backtest_config.ini (used by wine64 shell-script launch via /config:)
|
||||
// and also patch terminal.ini so the Strategy Tester panel shows the right
|
||||
// settings if the user opens MT5 manually.
|
||||
let ini_content = self.build_backtest_ini(params, report_id)?;
|
||||
|
||||
let ini_utf16: Vec<u8> = std::iter::once(0xFFu8)
|
||||
.chain(std::iter::once(0xFEu8))
|
||||
.chain(ini_content.encode_utf16().flat_map(|c| c.to_le_bytes()))
|
||||
.collect();
|
||||
|
||||
fs::write(&ini_path, ini_utf16)?;
|
||||
// Write to drive_c root (C:\backtest_config.ini) — no spaces in path avoids
|
||||
// Wine argument-quoting issues when MT5 parses the /config: value.
|
||||
let config_host = wine_prefix.join("drive_c").join("backtest_config.ini");
|
||||
fs::write(&config_host, ini_content.as_bytes())?;
|
||||
self.update_terminal_ini(params, report_id)?;
|
||||
|
||||
if params.kill_existing {
|
||||
self.kill_mt5().await?;
|
||||
// Always kill any running MT5 — Wine allows only one instance per prefix.
|
||||
self.kill_mt5().await?;
|
||||
|
||||
// Record launch time before sleeping so find_newest_report doesn't miss
|
||||
// reports written during the startup wait.
|
||||
let poll_start = std::time::SystemTime::now();
|
||||
let launch_instant = tokio::time::Instant::now();
|
||||
|
||||
// Build the launch command, adapting for the Wine runtime in use.
|
||||
let mut cmd = self.build_wine_launch(wine_exe, &wine_prefix)?;
|
||||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
// Give MT5 time to fully initialize before polling.
|
||||
// MT5 app startup (Wine init + network auth + tester) typically takes 15–20 s.
|
||||
sleep(Duration::from_secs(20)).await;
|
||||
|
||||
// Poll for the report file (MT5 writes it when the backtest completes).
|
||||
// Grace period: don't check process liveness for the first 30 s after launch —
|
||||
// MT5 may still be appearing in the process list while wineserver re-initializes.
|
||||
let grace_period = Duration::from_secs(30);
|
||||
let deadline = launch_instant + Duration::from_secs(params.timeout);
|
||||
|
||||
loop {
|
||||
let elapsed = launch_instant.elapsed().as_secs();
|
||||
|
||||
// 1. Check for the exact expected report filename.
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = reports_dir.join(format!("{}{}", report_id, ext));
|
||||
tracing::debug!("poll t+{}s: checking {}", elapsed, candidate.display());
|
||||
if candidate.exists() {
|
||||
tracing::info!("poll t+{}s: found exact report {}", elapsed, candidate.display());
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Only check process liveness after the grace period — this prevents
|
||||
// a false "not running" when the new instance is still starting up.
|
||||
let in_grace = launch_instant.elapsed() <= grace_period;
|
||||
let mt5_alive = Self::is_mt5_running();
|
||||
tracing::info!("poll t+{}s: in_grace={} mt5_alive={}", elapsed, in_grace, mt5_alive);
|
||||
|
||||
if !in_grace && !mt5_alive {
|
||||
if let Some(path) = Self::find_newest_report(&reports_dir, poll_start) {
|
||||
tracing::info!("poll: MT5 exited, found fallback report {}", path.display());
|
||||
return Ok(path);
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"MT5 exited without producing a report. \
|
||||
The backtest may have been stopped mid-way or failed to start."
|
||||
));
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Err(anyhow!("Timeout: no report after {} seconds", params.timeout));
|
||||
}
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let bat_content = if params.shutdown {
|
||||
format!(r#"@echo off
|
||||
cd /d "C:\Program Files\MetaTrader 5"
|
||||
start /wait terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
"#)
|
||||
/// Write backtest parameters into terminal.ini [Tester] section so MT5 reads
|
||||
/// them on startup without needing a /config: command-line argument.
|
||||
fn update_terminal_ini(&self, params: &BacktestParams, report_id: &str) -> Result<()> {
|
||||
let mt5_dir = self.config.mt5_dir()
|
||||
.ok_or_else(|| anyhow!("MT5 directory not configured"))?;
|
||||
let terminal_ini = mt5_dir.join("config").join("terminal.ini");
|
||||
|
||||
let raw = fs::read(&terminal_ini)
|
||||
.unwrap_or_default();
|
||||
let text = if raw.starts_with(&[0xFF, 0xFE]) {
|
||||
raw[2..].chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect::<Vec<_>>()
|
||||
.iter()
|
||||
.map(|&c| char::from_u32(c as u32).unwrap_or('?'))
|
||||
.collect::<String>()
|
||||
} else {
|
||||
format!(r#"@echo off
|
||||
cd /d "C:\Program Files\MetaTrader 5"
|
||||
start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
"#)
|
||||
String::from_utf8_lossy(&raw).into_owned()
|
||||
};
|
||||
|
||||
let bat_path = wine_prefix.join("drive_c").join("_mt5mcp_run.bat");
|
||||
fs::write(&bat_path, bat_content)?;
|
||||
let period = match params.timeframe.as_str() {
|
||||
"M1" => 1u32, "M5" => 5, "M15" => 15, "M30" => 30,
|
||||
"H1" => 60, "H4" => 240, "D1" => 1440,
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
let _cmd = format!("cmd.exe /c 'C:\\_mt5mcp_run.bat'");
|
||||
let from_ts = Self::date_str_to_unix(¶ms.from_date)?;
|
||||
let to_ts = Self::date_str_to_unix(¶ms.to_date)?;
|
||||
|
||||
if params.shutdown {
|
||||
let output = Command::new("timeout")
|
||||
.arg(¶ms.timeout.to_string())
|
||||
.arg(wine_exe)
|
||||
.arg("cmd.exe")
|
||||
.arg("/c")
|
||||
.arg("C:\\_mt5mcp_run.bat")
|
||||
.env("WINEPREFIX", &wine_prefix)
|
||||
.env("WINEDEBUG", "-all")
|
||||
.output()?;
|
||||
let currency = self.config.backtest_currency.as_deref().unwrap_or("USD");
|
||||
let set_file_line = params.set_file.as_ref()
|
||||
.map(|p| format!("ExpertParameters={}\n", p))
|
||||
.unwrap_or_default();
|
||||
|
||||
if !output.status.success() {
|
||||
tracing::warn!("MT5 exited with code: {:?}", output.status.code());
|
||||
let updates: &[(&str, String)] = &[
|
||||
("Expert", self.resolve_expert_path(¶ms.expert)),
|
||||
("Symbol", params.symbol.clone()),
|
||||
("Period", period.to_string()),
|
||||
("DateRange", "3".into()),
|
||||
("DateFrom", from_ts.to_string()),
|
||||
("DateTo", to_ts.to_string()),
|
||||
("Visualization", "0".into()),
|
||||
("Execution", "10".into()),
|
||||
("Currency", currency.into()),
|
||||
("Leverage", params.leverage.to_string()),
|
||||
("Deposit", format!("{:.2}", params.deposit)),
|
||||
("TicksMode", params.model.to_string()),
|
||||
("PipsCalculation", "1".into()),
|
||||
("OptMode", "0".into()),
|
||||
("Report", format!("reports\\{}.htm", report_id)),
|
||||
("ReplaceReport", "1".into()),
|
||||
("ShutdownTerminal", if params.shutdown { "1" } else { "0" }.into()),
|
||||
];
|
||||
|
||||
let updated = Self::patch_ini_section(&text, "Tester", updates)
|
||||
+ &set_file_line;
|
||||
|
||||
let bom_utf16: Vec<u8> = [0xFF, 0xFE].iter().copied()
|
||||
.chain(updated.encode_utf16().flat_map(|c| c.to_le_bytes()))
|
||||
.collect();
|
||||
fs::write(&terminal_ini, bom_utf16)?;
|
||||
tracing::info!("terminal.ini [Tester] updated for backtest {}", report_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse "YYYY.MM.DD" and return a Unix timestamp (seconds since 1970-01-01 UTC).
|
||||
fn date_str_to_unix(date: &str) -> Result<i64> {
|
||||
let parts: Vec<u32> = date.split('.').filter_map(|p| p.parse().ok()).collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!("Invalid date format: {}", date));
|
||||
}
|
||||
let dt = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1], parts[2])
|
||||
.ok_or_else(|| anyhow!("Invalid date: {}", date))?
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| anyhow!("Date conversion failed"))?;
|
||||
Ok(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc).timestamp())
|
||||
}
|
||||
|
||||
/// Replace or add key=value pairs in a named INI section.
|
||||
fn patch_ini_section(text: &str, section: &str, updates: &[(&str, String)]) -> String {
|
||||
let section_header = format!("[{}]", section);
|
||||
let mut result = String::with_capacity(text.len() + 256);
|
||||
let mut in_section = false;
|
||||
let mut pending: std::collections::HashMap<&str, &String> =
|
||||
updates.iter().map(|(k, v)| (*k, v)).collect();
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == section_header {
|
||||
in_section = true;
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
Command::new("nohup")
|
||||
.arg(wine_exe)
|
||||
.arg("cmd.exe")
|
||||
.arg("/c")
|
||||
.arg("C:\\_mt5mcp_run.bat")
|
||||
.env("WINEPREFIX", &wine_prefix)
|
||||
.env("WINEDEBUG", "-all")
|
||||
.spawn()?;
|
||||
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(params.timeout);
|
||||
loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Err(anyhow!("Timeout waiting for backtest report"));
|
||||
if trimmed.starts_with('[') && in_section {
|
||||
// End of our section — flush any keys not yet written
|
||||
for (k, v) in &pending {
|
||||
result.push_str(&format!("{}={}\n", k, v));
|
||||
}
|
||||
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = reports_dir.join(format!("{}{}", report_id, ext));
|
||||
if candidate.exists() {
|
||||
let _ = fs::remove_file(&bat_path);
|
||||
return Ok(candidate);
|
||||
pending.clear();
|
||||
in_section = false;
|
||||
}
|
||||
if in_section {
|
||||
if let Some((key, _)) = trimmed.split_once('=') {
|
||||
let key = key.trim();
|
||||
if let Some(val) = pending.remove(key) {
|
||||
result.push_str(&format!("{}={}\n", key, val));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
// Flush remaining keys if section was at end of file
|
||||
if in_section {
|
||||
for (k, v) in &pending {
|
||||
result.push_str(&format!("{}={}\n", k, v));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = reports_dir.join(format!("{}{}", report_id, ext));
|
||||
if candidate.exists() {
|
||||
let _ = fs::remove_file(&bat_path);
|
||||
return Ok(candidate);
|
||||
/// Build the OS-appropriate command to launch MT5 with the backtest config.
|
||||
///
|
||||
/// - macOS MT5.app bundle: use `open -a "MetaTrader 5" --args /config:...`
|
||||
/// The native launcher handles Wine env setup (DYLD vars are stripped by SIP
|
||||
/// when set on child processes spawned from a Rust binary).
|
||||
/// - macOS CrossOver / Linux Wine: standard WINEPREFIX + wine64 direct invocation.
|
||||
fn build_wine_launch(&self, wine_exe: &str, wine_prefix: &Path) -> Result<Command> {
|
||||
if wine_exe.contains("MetaTrader 5.app") {
|
||||
// macOS MT5.app — the Swift launcher ignores --args so we can't pass
|
||||
// /config: via `open`. Instead, write a temp shell script that sets
|
||||
// DYLD_FALLBACK_LIBRARY_PATH and invokes wine64 with /config: directly.
|
||||
// Shell scripts bypass the SIP restriction that strips DYLD_* vars
|
||||
// when Rust spawns a codesigned binary as a direct child process.
|
||||
let wine_bin = Path::new(wine_exe);
|
||||
let wine_root = wine_bin
|
||||
.parent() // bin/
|
||||
.and_then(|p| p.parent()) // wine/
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| anyhow!("Cannot derive Wine root from wine_exe"))?;
|
||||
|
||||
let ext_libs = wine_root.join("lib").join("external");
|
||||
let wine_libs = wine_root.join("lib");
|
||||
let dyld = format!("{}:{}:/usr/lib:/usr/local/lib",
|
||||
ext_libs.display(), wine_libs.display());
|
||||
|
||||
// Use host path for the exe; config at drive root to avoid spaces in path.
|
||||
let terminal_host = wine_prefix.join("drive_c")
|
||||
.join("Program Files").join("MetaTrader 5").join("terminal64.exe");
|
||||
let config_win = r"C:\backtest_config.ini";
|
||||
|
||||
let script = format!(
|
||||
"#!/bin/sh\n\
|
||||
export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\
|
||||
export WINEPREFIX='{prefix}'\n\
|
||||
export WINEDEBUG='-all'\n\
|
||||
nohup '{wine}' '{terminal}' '/config:{config}' \
|
||||
>/dev/null 2>&1 &\n",
|
||||
dyld = dyld,
|
||||
prefix = wine_prefix.display(),
|
||||
wine = wine_exe,
|
||||
terminal = terminal_host.display(),
|
||||
config = config_win,
|
||||
);
|
||||
|
||||
let script_path = std::env::temp_dir().join("mt5_backtest_launch.sh");
|
||||
fs::write(&script_path, &script)?;
|
||||
// chmod +x
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&script_path,
|
||||
fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
tracing::info!("Launching MT5 via shell script: {}", script_path.display());
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg(&script_path);
|
||||
return Ok(cmd);
|
||||
}
|
||||
|
||||
Err(anyhow!("No report file generated"))
|
||||
// CrossOver / Linux: invoke wine64 directly with WINEPREFIX set.
|
||||
// Params are already written to terminal.ini so no /config: arg needed.
|
||||
let terminal_win_path = r"C:\Program Files\MetaTrader 5\terminal64.exe";
|
||||
let mut cmd = Command::new(wine_exe);
|
||||
cmd.arg(terminal_win_path)
|
||||
.env("WINEPREFIX", wine_prefix)
|
||||
.env("WINEDEBUG", "-all");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// For terminal.ini: path relative to MQL5/ (e.g. `Experts\DPS21\DPS21.ex5`).
|
||||
fn resolve_expert_path(&self, expert: &str) -> String {
|
||||
if let Some(experts_dir) = &self.config.experts_dir {
|
||||
let nested_ex5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.ex5", expert));
|
||||
let nested_mq5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.mq5", expert));
|
||||
if nested_ex5.exists() || nested_mq5.exists() {
|
||||
return format!("Experts\\{}\\{}.ex5", expert, expert);
|
||||
}
|
||||
}
|
||||
format!("Experts\\{}.ex5", expert)
|
||||
}
|
||||
|
||||
/// For /config: INI: path relative to MQL5/Experts/ (e.g. `DPS21\DPS21.ex5`).
|
||||
/// The /config: format does NOT include the "Experts\" prefix.
|
||||
fn resolve_backtest_ini_expert_path(&self, expert: &str) -> String {
|
||||
if let Some(experts_dir) = &self.config.experts_dir {
|
||||
let nested_ex5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.ex5", expert));
|
||||
let nested_mq5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.mq5", expert));
|
||||
if nested_ex5.exists() || nested_mq5.exists() {
|
||||
return format!("{}\\{}.ex5", expert, expert);
|
||||
}
|
||||
}
|
||||
format!("{}.ex5", expert)
|
||||
}
|
||||
|
||||
fn build_backtest_ini(&self, params: &BacktestParams, report_id: &str) -> Result<String> {
|
||||
@@ -320,7 +654,8 @@ start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
}
|
||||
|
||||
ini.push_str("[Tester]\n");
|
||||
ini.push_str(&format!("Expert={}.ex5\n", params.expert));
|
||||
// Expert path is relative to MQL5/Experts/ in the /config: format (no "Experts\" prefix).
|
||||
ini.push_str(&format!("Expert={}\n", self.resolve_backtest_ini_expert_path(¶ms.expert)));
|
||||
ini.push_str(&format!("Symbol={}\n", params.symbol));
|
||||
ini.push_str(&format!("Period={}\n", params.timeframe));
|
||||
ini.push_str("Optimization=0\n");
|
||||
@@ -332,8 +667,7 @@ start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
ini.push_str(&format!("Currency={}\n", self.config.backtest_currency.as_ref().unwrap_or(&"USD".to_string())));
|
||||
ini.push_str("ProfitInPips=1\n");
|
||||
ini.push_str(&format!("Leverage={}\n", params.leverage));
|
||||
ini.push_str("ExecutionMode=10\n");
|
||||
ini.push_str("OptimizationCriterion=0\n");
|
||||
ini.push_str("Execution=10\n");
|
||||
ini.push_str(&format!("Visual={}\n", if params.gui { "1" } else { "0" }));
|
||||
ini.push_str(&format!("Report=reports\\{}.htm\n", report_id));
|
||||
ini.push_str("ReplaceReport=1\n");
|
||||
@@ -347,26 +681,80 @@ start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
}
|
||||
|
||||
async fn kill_mt5(&self) -> Result<()> {
|
||||
let _output = Command::new("pkill")
|
||||
.args(&["-TERM", "-f", "terminal64\\.exe"])
|
||||
.output()?;
|
||||
let patterns = Self::mt5_process_patterns();
|
||||
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
let running = patterns.iter().any(|pat| {
|
||||
Command::new("pgrep")
|
||||
.args(["-f", pat.as_str()])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let check = Command::new("pgrep")
|
||||
.args(&["-f", "terminal64\\.exe"])
|
||||
.output()?;
|
||||
|
||||
if check.status.success() {
|
||||
let _ = Command::new("pkill")
|
||||
.args(&["-KILL", "-f", "terminal64\\.exe"])
|
||||
.output();
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
if !running {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!("Stopping existing MT5 instance...");
|
||||
// SIGKILL immediately — MT5 holds no state we care about preserving.
|
||||
for pat in &patterns {
|
||||
let _ = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output();
|
||||
}
|
||||
let _ = Command::new("pkill").args(["-KILL", "-f", "wineserver"]).output();
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_mt5_running() -> bool {
|
||||
Self::mt5_process_patterns().iter().any(|pat| {
|
||||
Command::new("pgrep")
|
||||
.args(["-f", pat.as_str()])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Scan `dir` for the newest .htm/.htm.xml/.html file written after `since`.
|
||||
fn find_newest_report(dir: &Path, since: std::time::SystemTime) -> Option<PathBuf> {
|
||||
let entries = fs::read_dir(dir).ok()?;
|
||||
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let ext = e.path().extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
matches!(ext.as_str(), "htm" | "xml" | "html")
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let mtime = e.metadata().ok()?.modified().ok()?;
|
||||
if mtime >= since { Some((mtime, e.path())) } else { None }
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by_key(|(t, _)| *t);
|
||||
candidates.into_iter().last().map(|(_, p)| p)
|
||||
}
|
||||
|
||||
fn mt5_process_patterns() -> Vec<String> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// macOS: official MT5.app bundle (contains its own Wine runtime)
|
||||
// Also match Wine-hosted terminal64.exe for CrossOver installs
|
||||
vec![
|
||||
"MetaTrader 5\\.app".to_string(),
|
||||
"terminal64\\.exe".to_string(),
|
||||
]
|
||||
} else {
|
||||
// Linux: MT5 always runs as a Wine process
|
||||
vec![
|
||||
"terminal64\\.exe".to_string(),
|
||||
"metatrader".to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_progress(&self, log_path: &Path, stage: &str) {
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
let line = format!("{} {}\n", stage, timestamp);
|
||||
|
||||
Reference in New Issue
Block a user