feat: overhaul optimizer launch, rewrite set file parsing, add OS detection and utils

This commit is contained in:
Devid HW
2026-06-25 16:03:59 +07:00
parent 52c3d91639
commit 17bb7545ba
6 changed files with 501 additions and 159 deletions
+9 -7
View File
@@ -247,19 +247,21 @@ impl Config {
fn find_wine(home: &Path) -> Option<String> { fn find_wine(home: &Path) -> Option<String> {
let candidates: &[PathBuf] = &[ let candidates: &[PathBuf] = &[
// macOS: bundled with the official MT5 app // macOS: bundled with the official MT5 app (binary is just named 'wine' on recent builds)
PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine"),
PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"), PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"),
// macOS: CrossOver // macOS: CrossOver (new versions may use 'wine', older ones 'wine64')
home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine"),
home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64"), home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64"),
// macOS: Homebrew (Apple Silicon) // macOS: Homebrew Apple Silicon (prefer 'wine', fall back to 'wine64')
PathBuf::from("/opt/homebrew/bin/wine64"),
PathBuf::from("/opt/homebrew/bin/wine"), PathBuf::from("/opt/homebrew/bin/wine"),
// macOS: Homebrew (Intel) PathBuf::from("/opt/homebrew/bin/wine64"),
PathBuf::from("/usr/local/bin/wine64"), // macOS: Homebrew Intel
PathBuf::from("/usr/local/bin/wine"), PathBuf::from("/usr/local/bin/wine"),
PathBuf::from("/usr/local/bin/wine64"),
// Linux // Linux
PathBuf::from("/usr/bin/wine64"),
PathBuf::from("/usr/bin/wine"), PathBuf::from("/usr/bin/wine"),
PathBuf::from("/usr/bin/wine64"),
]; ];
candidates.iter() candidates.iter()
.find(|p| p.exists()) .find(|p| p.exists())
+257 -75
View File
@@ -6,6 +6,27 @@ use std::process::{Command, Stdio};
use crate::models::Config; use crate::models::Config;
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE).
fn read_file_as_utf8(path: &Path) -> Result<String> {
let bytes = fs::read(path)?;
// Check for UTF-16LE BOM (0xFF 0xFE)
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
// UTF-16LE with BOM - skip the 2-byte BOM and decode
let utf16_data: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&utf16_data)
.map_err(|e| anyhow!("Failed to decode UTF-16LE: {}", e))
} else {
// Try UTF-8
String::from_utf8(bytes)
.map_err(|e| anyhow!("Failed to decode as UTF-8: {}", e))
}
}
pub struct OptimizationParams { pub struct OptimizationParams {
pub expert: String, pub expert: String,
pub set_file: String, pub set_file: String,
@@ -13,7 +34,6 @@ pub struct OptimizationParams {
pub from_date: String, pub from_date: String,
pub to_date: String, pub to_date: String,
pub deposit: u32, pub deposit: u32,
pub model: u8,
pub leverage: u32, pub leverage: u32,
pub currency: String, pub currency: String,
} }
@@ -27,7 +47,6 @@ impl Default for OptimizationParams {
from_date: String::new(), from_date: String::new(),
to_date: String::new(), to_date: String::new(),
deposit: 10000, deposit: 10000,
model: 0,
leverage: 500, leverage: 500,
currency: "USD".to_string(), currency: "USD".to_string(),
} }
@@ -78,7 +97,8 @@ impl OptimizationRunner {
let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp)); let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp));
// Count combinations // Count combinations
let combinations = self.count_combinations(&params.set_file)?; let combinations = self.count_combinations(&params.set_file)
.map_err(|e| anyhow!("count_combinations failed: {}", e))?;
// Get paths // Get paths
let mt5_dir = self.config.terminal_dir.as_ref() let mt5_dir = self.config.terminal_dir.as_ref()
@@ -89,50 +109,167 @@ impl OptimizationRunner {
// Write .set file as UTF-16LE with BOM directly to MT5 tester directory // Write .set file as UTF-16LE with BOM directly to MT5 tester directory
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?; let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?;
let tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester"); let tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester");
fs::create_dir_all(&tester_dir)?; fs::create_dir_all(&tester_dir).map_err(|e| anyhow!("create_dir_all({}) failed: {}", tester_dir.display(), e))?;
let dst_set_file = tester_dir.join(format!("{}.set", params.expert)); let dst_set_file = tester_dir.join(format!("{}.set", params.expert));
self.write_utf16le_set(&params.set_file, &dst_set_file)?; self.write_utf16le_set(&params.set_file, &dst_set_file)
.map_err(|e| anyhow!("write_utf16le_set({}) failed: {}", dst_set_file.display(), e))?;
// Reset OptMode in terminal.ini // Reset OptMode in terminal.ini
self.reset_optmode(mt5_dir)?; // Patch terminal.ini [Tester] section with optimization params (primary mechanism)
let terminal_ini = if Path::new(mt5_dir).join("config").exists() {
Path::new(mt5_dir).join("config").join("terminal.ini")
} else {
Path::new(mt5_dir).join("terminal.ini")
};
let mt5_ini_text = if terminal_ini.exists() {
read_file_as_utf8(&terminal_ini).unwrap_or_default()
} else {
String::new()
};
let expert_path = if let Some(experts_dir) = &self.config.experts_dir {
let nested = Path::new(experts_dir).join(&params.expert).join(format!("{}.mq5", params.expert));
if nested.exists() {
format!("Experts\\{}\\{}.ex5", params.expert, params.expert)
} else {
format!("Experts\\{}.ex5", params.expert)
}
} else {
format!("Experts\\{}.ex5", params.expert)
};
let tester_section = format!(
"[Tester]\n\
Expert={}\n\
ExpertParameters={}.set\n\
Symbol={}\n\
Period=M1\n\
Model=4\n\
FromDate={}\n\
ToDate={}\n\
ForwardMode=0\n\
Deposit={}\n\
Currency={}\n\
ProfitInPips=0\n\
Leverage={}\n\
Execution=10\n\
Optimization=2\n\
Agents=10\n\
Visual=0\n\
Report=reports\\opt_report.htm\n\
ReplaceReport=1\n\
ShutdownTerminal=1",
expert_path, params.expert, params.symbol,
params.from_date, params.to_date, params.deposit, params.currency, params.leverage,
);
let agents_section = "\
[Agents]\n\
Agent0000=11111111-1111-1111-1111-111111111111\n\
AgentStatus0000=3\n\
AgentState0000=0\n\
Enabled0000=1\n\
IP0000=127.0.0.1\n\
Port0000=3000\n\
Agent0001=22222222-2222-2222-2222-222222222222\n\
AgentStatus0001=3\n\
AgentState0001=0\n\
Enabled0001=1\n\
IP0001=127.0.0.1\n\
Port0001=3001\n\
Agent0002=33333333-3333-3333-3333-333333333333\n\
AgentStatus0002=3\n\
AgentState0002=0\n\
Enabled0002=1\n\
IP0002=127.0.0.1\n\
Port0002=3002\n\
Agent0003=44444444-4444-4444-4444-444444444444\n\
AgentStatus0003=3\n\
AgentState0003=0\n\
Enabled0003=1\n\
IP0003=127.0.0.1\n\
Port0003=3003";
let updated_ini = Self::patch_ini_section(&mt5_ini_text, "Tester", &tester_section);
// Strip any stale [Agents] sections from previous runs, then append fresh one
let cleaned = Self::strip_ini_section(&updated_ini, "Agents");
let final_ini = format!("{}\n{}", cleaned.trim_end(), agents_section);
let mut utf16_out: Vec<u8> = vec![0xFF, 0xFE];
utf16_out.extend(final_ini.encode_utf16().flat_map(|c| c.to_le_bytes()));
fs::write(&terminal_ini, utf16_out)?;
// Get Wine prefix directory // Write /config: INI to trigger tester/optimizer mode
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?; // For /config: format, Expert path is relative to MQL5/Experts/ (no Experts\ prefix)
let opt_config_win = r"C:\mt5opt_config.ini";
let opt_config_host = wine_prefix_dir.join("drive_c").join("mt5opt_config.ini");
let mut opt_ini = String::new();
if let Some(login) = &self.config.backtest_login {
if let Some(server) = &self.config.backtest_server {
opt_ini.push_str("[Common]\n");
opt_ini.push_str(&format!("Login={}\n", login));
opt_ini.push_str(&format!("Server={}\n", server));
if let Some(password) = &self.config.backtest_password {
opt_ini.push_str(&format!("Password={}\n", password));
}
opt_ini.push_str("\n");
}
}
opt_ini.push_str("[Tester]\n");
opt_ini.push_str(&format!("Expert={}.ex5\n", params.expert));
opt_ini.push_str(&format!("ExpertParameters={}.set\n", params.expert));
opt_ini.push_str(&format!("Symbol={}\n", params.symbol));
opt_ini.push_str("Period=M1\n");
opt_ini.push_str("Optimization=2\n");
opt_ini.push_str(&format!("FromDate={}\n", params.from_date));
opt_ini.push_str(&format!("ToDate={}\n", params.to_date));
opt_ini.push_str("ForwardMode=0\n");
opt_ini.push_str(&format!("Deposit={}\n", params.deposit));
opt_ini.push_str(&format!("Currency={}\n", params.currency));
opt_ini.push_str("ProfitInPips=0\n");
opt_ini.push_str(&format!("Leverage={}\n", params.leverage));
opt_ini.push_str("Execution=10\n");
opt_ini.push_str("Visual=0\n");
opt_ini.push_str("Report=reports\\opt_report.htm\n");
opt_ini.push_str("ReplaceReport=1\n");
opt_ini.push_str("ShutdownTerminal=1\n");
fs::write(&opt_config_host, opt_ini.as_bytes())?;
// Build optimization INI // Build launch script (macOS-compatible with /config: to trigger tester mode)
let ini_path = wine_prefix_dir.join("drive_c/mt5mcp_backtest.ini"); let wine_bin = Path::new(wine_exe);
let ini_content = format!(r#"[Tester] let wine_root = wine_bin
Expert={} .parent()
Symbol={} .and_then(|p| p.parent())
Period=M5 .ok_or_else(|| anyhow!("Cannot derive Wine root from wine_exe"))?;
Deposit={} let ext_libs = wine_root.join("lib").join("external");
Currency={} let wine_libs = wine_root.join("lib");
Leverage={} let dyld = format!("{}:{}:/usr/lib:/usr/local/lib",
Model={} ext_libs.display(), wine_libs.display());
FromDate={} let terminal_host = wine_prefix_dir.join("drive_c")
ToDate={} .join("Program Files").join("MetaTrader 5").join("terminal64.exe");
Report=C:\mt5mcp_opt_report
Optimization=2
ExpertParameters={}.set
ShutdownTerminal=1
"#, params.expert, params.symbol, params.deposit, params.currency,
params.leverage, params.model, params.from_date, params.to_date, params.expert);
fs::write(&ini_path, ini_content)?;
// Build batch file let script = format!(
let batch_path = wine_prefix_dir.join("drive_c/mt5mcp_run.bat"); "#!/bin/sh\n\
let batch_content = format!(r#"@echo off export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\
"C:\Program Files\MetaTrader 5\terminal64.exe" /config:C:\mt5mcp_backtest.ini export WINEPREFIX='{prefix}'\n\
"#); export WINEDEBUG='-all'\n\
fs::write(&batch_path, batch_content)?; nohup '{wine}' '{terminal}' '/config:{config}' >/dev/null 2>&1 &\n",
dyld = dyld,
prefix = wine_prefix_dir.display(),
wine = wine_exe,
terminal = terminal_host.display(),
config = opt_config_win,
);
// Launch detached process let script_path = std::env::temp_dir().join("mt5opt_launch.sh");
let cmd = format!("cmd.exe /c 'C:\\mt5mcp_run.bat'"); fs::write(&script_path, &script)?;
let child = Command::new(wine_exe) #[cfg(unix)]
.arg(&cmd) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
}
let child = Command::new("/bin/sh")
.arg(&script_path)
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.spawn()?; .spawn()
.map_err(|e| anyhow!("spawn /bin/sh {} failed: {}", script_path.display(), e))?;
let pid = child.id(); let pid = child.id();
@@ -150,7 +287,7 @@ ShutdownTerminal=1
} }
fn count_combinations(&self, set_file: &str) -> Result<u64> { fn count_combinations(&self, set_file: &str) -> Result<u64> {
let content = fs::read_to_string(set_file)?; let content = read_file_as_utf8(Path::new(set_file))?;
let mut total: u64 = 1; let mut total: u64 = 1;
for line in content.lines() { for line in content.lines() {
@@ -179,13 +316,23 @@ ShutdownTerminal=1
} }
fn write_utf16le_set(&self, src: &str, dst: &Path) -> Result<()> { fn write_utf16le_set(&self, src: &str, dst: &Path) -> Result<()> {
let content = fs::read_to_string(src)?; let content = read_file_as_utf8(Path::new(src))?;
// Create parent directory if needed // Create parent directory if needed
if let Some(parent) = dst.parent() { if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
// Remove existing file if read-only from previous run
if dst.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dst, fs::Permissions::from_mode(0o644));
}
let _ = fs::remove_file(dst);
}
// Write UTF-16LE with BOM // Write UTF-16LE with BOM
let mut utf16_content: Vec<u16> = vec![0xFEFF]; // BOM let mut utf16_content: Vec<u16> = vec![0xFEFF]; // BOM
utf16_content.extend(content.encode_utf16()); utf16_content.extend(content.encode_utf16());
@@ -195,50 +342,18 @@ ShutdownTerminal=1
.collect(); .collect();
fs::write(dst, bytes)?; fs::write(dst, bytes)?;
// Make read-only
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dst, fs::Permissions::from_mode(0o444))?;
}
Ok(()) Ok(())
} }
fn reset_optmode(&self, mt5_dir: &str) -> Result<()> {
let terminal_ini = Path::new(mt5_dir).join("terminal.ini");
if !terminal_ini.exists() {
return Ok(());
}
let content = fs::read_to_string(&terminal_ini)?;
let updated = content
.lines()
.map(|line| {
if line.starts_with("OptMode=") {
"OptMode=0".to_string()
} else if line.starts_with("LastOptimization=") {
String::new()
} else {
line.to_string()
}
})
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
fs::write(&terminal_ini, updated)?;
Ok(())
}
fn get_wine_prefix_dir(&self, mt5_dir: &str) -> Result<PathBuf> { fn get_wine_prefix_dir(&self, mt5_dir: &str) -> Result<PathBuf> {
let path = Path::new(mt5_dir); let path = Path::new(mt5_dir);
// Go up two levels: .../drive_c/Program Files/MetaTrader 5 -> .../drive_c // Go up three levels: .../drive_c/Program Files/MetaTrader 5 -> .../net.metaquotes.wine.metatrader5
// (same as backtest pipeline)
let prefix_dir = path let prefix_dir = path
.parent() .parent()
.and_then(|p| p.parent()) .and_then(|p| p.parent())
.and_then(|p| p.parent())
.ok_or_else(|| anyhow!("Cannot determine Wine prefix from terminal_dir"))?; .ok_or_else(|| anyhow!("Cannot determine Wine prefix from terminal_dir"))?;
Ok(prefix_dir.to_path_buf()) Ok(prefix_dir.to_path_buf())
} }
@@ -276,6 +391,73 @@ ShutdownTerminal=1
Ok(()) Ok(())
} }
/// Replace a [section] in an INI string — removes old content and inserts new.
fn patch_ini_section(text: &str, section: &str, new_content: &str) -> String {
let section_header = format!("[{}]", section);
let mut result = String::new();
let mut in_section = false;
let mut section_found = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed == section_header {
in_section = true;
section_found = true;
continue;
}
if in_section {
if trimmed.starts_with('[') {
in_section = false;
result.push_str(new_content);
if !new_content.ends_with('\n') {
result.push('\n');
}
result.push_str(line);
result.push('\n');
continue;
}
continue;
}
result.push_str(line);
result.push('\n');
}
if !section_found {
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push_str(new_content);
result.push('\n');
} else if in_section {
result.push_str(new_content);
result.push('\n');
}
result
}
/// Remove all lines belonging to a [section] from the INI text.
fn strip_ini_section(text: &str, section: &str) -> String {
let header = format!("[{}]", section);
let mut result = String::new();
let mut skipping = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed == header {
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') {
skipping = false;
}
if !skipping {
result.push_str(line);
result.push('\n');
}
}
result
}
pub fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value> { pub fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value> {
let jobs_dir = Path::new(".mt5mcp_jobs"); let jobs_dir = Path::new(".mt5mcp_jobs");
let meta_path = jobs_dir.join(format!("{}.json", job_id)); let meta_path = jobs_dir.join(format!("{}.json", job_id));
-1
View File
@@ -27,7 +27,6 @@ pub async fn handle_run_optimization(config: &Config, args: &Value) -> Result<Va
from_date: from_date.to_string(), from_date: from_date.to_string(),
to_date: to_date.to_string(), to_date: to_date.to_string(),
deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32, deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32,
model: 0,
leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32, leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32,
currency: args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD").to_string(), currency: args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD").to_string(),
}; };
+29 -12
View File
@@ -3,16 +3,32 @@ use serde_json::{json, Value};
use std::fs; use std::fs;
use crate::models::Config; use crate::models::Config;
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
fn read_file_as_utf8(path: &str) -> Result<String> {
let bytes = fs::read(path)?;
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
let utf16_data: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&utf16_data)
.map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e))
} else {
String::from_utf8(bytes)
.map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e))
}
}
pub async fn handle_read_set_file(args: &Value) -> Result<Value> { pub async fn handle_read_set_file(args: &Value) -> Result<Value> {
let path = args.get("path") let path = args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("path is required"))?; .ok_or_else(|| anyhow::anyhow!("path is required"))?;
let content = fs::read_to_string(path)?; let content = read_file_as_utf8(path)?;
let mut params = serde_json::Map::new(); let mut params = serde_json::Map::new();
for line in content.lines() { for line in content.lines() {
if let Some((key, value)) = line.split_once(':') { if let Some((key, value)) = line.split_once('=') {
let key = key.trim(); let key = key.trim();
let value = value.trim(); let value = value.trim();
@@ -86,7 +102,7 @@ pub async fn handle_patch_set_file(args: &Value) -> Result<Value> {
.and_then(|v| v.as_object()) .and_then(|v| v.as_object())
.ok_or_else(|| anyhow::anyhow!("patches object is required"))?; .ok_or_else(|| anyhow::anyhow!("patches object is required"))?;
let content = fs::read_to_string(path)?; let content = read_file_as_utf8(path)?;
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect(); let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let mut patched_count = 0; let mut patched_count = 0;
@@ -164,8 +180,8 @@ pub async fn handle_diff_set_files(args: &Value) -> Result<Value> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("file_b is required"))?; .ok_or_else(|| anyhow::anyhow!("file_b is required"))?;
let content_a = fs::read_to_string(file_a)?; let content_a = read_file_as_utf8(file_a)?;
let content_b = fs::read_to_string(file_b)?; let content_b = read_file_as_utf8(file_b)?;
let mut differences = Vec::new(); let mut differences = Vec::new();
@@ -223,20 +239,21 @@ pub async fn handle_describe_sweep(args: &Value) -> Result<Value> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("path is required"))?; .ok_or_else(|| anyhow::anyhow!("path is required"))?;
let content = fs::read_to_string(path)?; let content = read_file_as_utf8(path)?;
let mut sweep_params = serde_json::Map::new(); let mut sweep_params = serde_json::Map::new();
for line in content.lines() { for line in content.lines() {
if let Some((key, value)) = line.split_once(':') { if let Some((key, value)) = line.split_once('=') {
let key = key.trim(); let key = key.trim();
let value = value.trim(); let value = value.trim();
if value.contains("||Y") { if value.contains("||Y") {
if let Some((from_val, to_val)) = value.split_once("..") { let parts: Vec<&str> = value.split("||").collect();
if parts.len() >= 5 && parts[4].trim().to_uppercase() == "Y" {
sweep_params.insert(key.to_string(), json!({ sweep_params.insert(key.to_string(), json!({
"from": from_val.trim(), "from": parts[1].trim(),
"to": to_val.trim().replace("||Y", ""), "to": parts[3].trim(),
"step": 1.0 "step": parts[2].trim(),
})); }));
} }
} }
@@ -266,7 +283,7 @@ pub async fn handle_list_set_files(config: &Config) -> Result<Value> {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
let content = fs::read_to_string(&path).unwrap_or_default(); let content = read_file_as_utf8(&path.to_string_lossy()).unwrap_or_default();
let param_count = content.lines().filter(|l| l.contains(':')).count(); let param_count = content.lines().filter(|l| l.contains(':')).count();
let sweep_count = content.lines().filter(|l| l.contains("||Y")).count(); let sweep_count = content.lines().filter(|l| l.contains("||Y")).count();
+186 -64
View File
@@ -2,9 +2,61 @@ use anyhow::{Context, Result};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use crate::models::Config; use crate::models::Config;
use crate::storage::ReportDb; use crate::storage::ReportDb;
/// OS detection and information
#[derive(Debug, Clone)]
#[allow(dead_code)]
enum OsType {
Windows,
MacOS,
Linux,
Unknown,
}
impl OsType {
fn detect() -> Self {
#[cfg(target_os = "windows")]
return OsType::Windows;
#[cfg(target_os = "macos")]
return OsType::MacOS;
#[cfg(target_os = "linux")]
return OsType::Linux;
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
return OsType::Unknown;
}
fn as_str(&self) -> &'static str {
match self {
OsType::Windows => "windows",
OsType::MacOS => "macos",
OsType::Linux => "linux",
OsType::Unknown => "unknown",
}
}
fn uses_wine(&self) -> bool {
matches!(self, OsType::MacOS | OsType::Linux)
}
}
/// Detect if using CrossOver instead of Wine on macOS
fn detect_crossover(wine_exe: &str) -> bool {
wine_exe.contains("crossover") || wine_exe.contains("CrossOver")
}
/// Get OS context for diagnostic outputs
fn get_os_context() -> serde_json::Value {
let os_type = OsType::detect();
json!({
"os": os_type.as_str(),
"uses_wine": os_type.uses_wine(),
"architecture": std::env::consts::ARCH,
})
}
/// Validate that `user_path` resolves to a location within `allowed_base`. /// Validate that `user_path` resolves to a location within `allowed_base`.
/// Returns the canonicalized absolute path on success. /// Returns the canonicalized absolute path on success.
fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result<PathBuf> { fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result<PathBuf> {
@@ -846,8 +898,11 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
/// Diagnose Wine installation and prefix health /// Diagnose Wine installation and prefix health
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> { pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
let os_type = OsType::detect();
let mut diagnostics = json!({ let mut diagnostics = json!({
"os_context": get_os_context(),
"wine_executable": null, "wine_executable": null,
"wine_type": null,
"wine_version": null, "wine_version": null,
"wine_prefix": null, "wine_prefix": null,
"prefix_health": null, "prefix_health": null,
@@ -857,12 +912,16 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Valu
"warnings": Vec::<String>::new(), "warnings": Vec::<String>::new(),
}); });
// Check wine executable // Check wine executable (macOS/Linux)
if let Some(wine_exe) = config.wine_executable.as_ref() { if let Some(wine_exe) = config.wine_executable.as_ref() {
diagnostics["wine_executable"] = json!(wine_exe); diagnostics["wine_executable"] = json!(wine_exe);
// Get Wine version // Detect CrossOver vs Wine
let version_output = std::process::Command::new(wine_exe) let is_crossover = detect_crossover(wine_exe);
diagnostics["wine_type"] = json!(if is_crossover { "crossover" } else { "wine" });
// Get Wine/CrossOver version
let version_output = Command::new(wine_exe)
.arg("--version") .arg("--version")
.output(); .output();
@@ -872,14 +931,26 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Valu
diagnostics["wine_version"] = json!(version); diagnostics["wine_version"] = json!(version);
} }
_ => { _ => {
let wine_type_str = if is_crossover { "CrossOver" } else { "Wine" };
diagnostics["errors"].as_array_mut().unwrap().push( diagnostics["errors"].as_array_mut().unwrap().push(
json!("Failed to get Wine version - Wine may not be properly installed") json!(format!("Failed to get {} version - may not be properly installed", wine_type_str))
);
}
}
// macOS-specific CrossOver checks
if matches!(os_type, OsType::MacOS) && is_crossover {
// Check if CrossOver app exists
let crossover_app = Path::new("/Applications/CrossOver.app");
if !crossover_app.exists() {
diagnostics["warnings"].as_array_mut().unwrap().push(
json!("CrossOver.app not found in /Applications - may be custom installation")
); );
} }
} }
} else { } else {
diagnostics["errors"].as_array_mut().unwrap().push( diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine executable not configured") json!("Wine/CrossOver executable not configured")
); );
} }
@@ -991,6 +1062,7 @@ pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result<Value>
}; };
let mut result = json!({ let mut result = json!({
"os_context": get_os_context(),
"log_type": log_type, "log_type": log_type,
"log_path": log_path.to_string_lossy().to_string(), "log_path": log_path.to_string_lossy().to_string(),
"found": false, "found": false,
@@ -1109,6 +1181,7 @@ pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<V
} }
let result = json!({ let result = json!({
"os_context": get_os_context(),
"hours_searched": hours_back, "hours_searched": hours_back,
"errors_found": errors_found.len(), "errors_found": errors_found.len(),
"max_errors": max_errors, "max_errors": max_errors,
@@ -1128,16 +1201,17 @@ pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<V
/// Check MT5 process status /// Check MT5 process status
pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> { pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command; let _os_type = OsType::detect();
let mut result = json!({ let mut result = json!({
"os_context": get_os_context(),
"is_running": false, "is_running": false,
"processes": Vec::<serde_json::Value>::new(), "processes": Vec::<serde_json::Value>::new(),
"wine_server_running": false, "wine_server_running": false,
"total_instances": 0, "total_instances": 0,
}); });
#[cfg(target_os = "macos")] #[cfg(any(target_os = "macos", target_os = "linux"))]
{ {
// Check for MT5 processes // Check for MT5 processes
let ps_output = Command::new("ps") let ps_output = Command::new("ps")
@@ -1149,6 +1223,7 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
let mut processes = Vec::new(); let mut processes = Vec::new();
let mut mt5_count = 0; let mut mt5_count = 0;
let mut wine_server = false; let mut wine_server = false;
let mut crossover_server = false;
for line in content.lines() { for line in content.lines() {
let line_lower = line.to_lowercase(); let line_lower = line.to_lowercase();
@@ -1169,45 +1244,11 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
if line_lower.contains("wineserver") { if line_lower.contains("wineserver") {
wine_server = true; wine_server = true;
} }
}
result["processes"] = json!(processes);
result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server);
}
}
#[cfg(target_os = "linux")]
{
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
let mut processes = Vec::new();
let mut mt5_count = 0;
let mut wine_server = false;
for line in content.lines() {
let line_lower = line.to_lowercase();
if line_lower.contains("terminal64") || line_lower.contains("metatrader") { // Detect CrossOver server on macOS
let parts: Vec<&str> = line.split_whitespace().collect(); #[cfg(target_os = "macos")]
if parts.len() >= 11 { if line_lower.contains("cxstart") || line_lower.contains("crossover") {
processes.push(json!({ crossover_server = true;
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
wine_server = true;
} }
} }
@@ -1215,6 +1256,11 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
result["is_running"] = json!(mt5_count > 0); result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count); result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server); result["wine_server_running"] = json!(wine_server);
#[cfg(target_os = "macos")]
{
result["crossover_server_running"] = json!(crossover_server);
}
} }
} }
@@ -1226,7 +1272,7 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
/// Kill stuck MT5 process /// Kill stuck MT5 process
pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> { pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> {
use std::process::Command; let _os_type = OsType::detect();
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
let pid = args.get("pid").and_then(|v| v.as_str()); let pid = args.get("pid").and_then(|v| v.as_str());
@@ -1271,6 +1317,12 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<V
// Also kill wineserver if force=true // Also kill wineserver if force=true
if force { if force {
let _ = Command::new("killall").arg("wineserver").output(); let _ = Command::new("killall").arg("wineserver").output();
// On macOS, also kill CrossOver processes
#[cfg(target_os = "macos")]
{
let _ = Command::new("killall").arg("cxstart").output();
}
} }
} }
@@ -1281,6 +1333,7 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<V
}; };
let result = json!({ let result = json!({
"os_context": get_os_context(),
"killed": killed, "killed": killed,
"failed": failed, "failed": failed,
"force": force, "force": force,
@@ -1295,9 +1348,10 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<V
/// Check system resources for MT5 /// Check system resources for MT5
pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> { pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command; let _os_type = OsType::detect();
let mut result = json!({ let mut result = json!({
"os_context": get_os_context(),
"disk_space": null, "disk_space": null,
"memory": null, "memory": null,
"cpu_cores": 0, "cpu_cores": 0,
@@ -1345,34 +1399,66 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R
let vm_output = Command::new("vm_stat").output(); let vm_output = Command::new("vm_stat").output();
if let Ok(output) = vm_output { if let Ok(output) = vm_output {
let content = String::from_utf8_lossy(&output.stdout); let content = String::from_utf8_lossy(&output.stdout);
// Parse vm_stat output // Parse vm_stat output with improved robustness
let mut free_pages = 0u64; let mut free_pages = 0u64;
let mut active_pages = 0u64; let mut active_pages = 0u64;
let mut inactive_pages = 0u64; let mut inactive_pages = 0u64;
let mut wired_pages = 0u64;
let mut page_size = 4096u64;
// Try to get page size from vm_stat output
for line in content.lines() { for line in content.lines() {
if line.contains("Pages free:") { if line.contains("Mach Virtual Memory Statistics:") {
free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0); // Page size is typically 4096 on macOS
} else if line.contains("Pages active:") { page_size = 4096;
active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages inactive:") {
inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} }
} }
let page_size = 4096u64; for line in content.lines() {
let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024; // More robust parsing using regex-like pattern matching
if line.contains("Pages free:") {
if let Some(val) = line.split(':').nth(1) {
free_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
} else if line.contains("Pages active:") {
if let Some(val) = line.split(':').nth(1) {
active_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
} else if line.contains("Pages inactive:") {
if let Some(val) = line.split(':').nth(1) {
inactive_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
} else if line.contains("Pages wired:") {
if let Some(val) = line.split(':').nth(1) {
wired_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
}
}
// Calculate memory more accurately
let total_pages = free_pages + active_pages + inactive_pages + wired_pages;
let total_mb = (total_pages * page_size) / 1024 / 1024;
let free_mb = (free_pages * page_size) / 1024 / 1024; let free_mb = (free_pages * page_size) / 1024 / 1024;
let available_mb = ((free_pages + inactive_pages) * page_size) / 1024 / 1024;
result["memory"] = json!({ result["memory"] = json!({
"total_mb": total_mb, "total_mb": total_mb,
"free_mb": free_mb, "free_mb": free_mb,
"available_mb": available_mb,
"active_mb": (active_pages * page_size) / 1024 / 1024,
"wired_mb": (wired_pages * page_size) / 1024 / 1024,
"page_size": page_size,
"unit": "MB", "unit": "MB",
}); });
if free_mb < 2048 { // Adjust memory threshold for macOS (compressed memory makes free appear lower)
if available_mb < 1024 {
result["recommendations"].as_array_mut().unwrap().push( result["recommendations"].as_array_mut().unwrap().push(
json!("Low memory available. MT5 may crash during large optimizations.") json!(format!("Low memory available ({} MB free). MT5 may crash during large optimizations. Close other apps.", available_mb))
);
} else if available_mb < 2048 {
result["recommendations"].as_array_mut().unwrap().push(
json!(format!("Memory getting low ({} MB available). Consider closing other applications.", available_mb))
); );
} }
} }
@@ -1393,6 +1479,13 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R
"free_mb": parts[3].parse::<u64>().unwrap_or(0), "free_mb": parts[3].parse::<u64>().unwrap_or(0),
"unit": "MB", "unit": "MB",
}); });
let free_mb = parts[3].parse::<u64>().unwrap_or(0);
if free_mb < 1024 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Low memory available. MT5 may crash during large optimizations.")
);
}
} }
} }
} }
@@ -1400,13 +1493,38 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R
} }
// Get CPU cores // Get CPU cores
let nproc_output = Command::new("sysctl") #[cfg(target_os = "macos")]
.args(["-n", "hw.ncpu"]) {
.output(); let nproc_output = Command::new("sysctl")
.args(["-n", "hw.ncpu"])
.output();
if let Ok(output) = nproc_output {
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
result["cpu_cores"] = json!(cores);
if cores < 4 {
result["recommendations"].as_array_mut().unwrap().push(
json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores))
);
}
}
}
if let Ok(output) = nproc_output { #[cfg(target_os = "linux")]
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0); {
result["cpu_cores"] = json!(cores); let nproc_output = Command::new("nproc").output();
if let Ok(output) = nproc_output {
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
result["cpu_cores"] = json!(cores);
if cores < 4 {
result["recommendations"].as_array_mut().unwrap().push(
json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores))
);
}
}
} }
} }
@@ -1422,6 +1540,7 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut result = json!({ let mut result = json!({
"os_context": get_os_context(),
"terminal_ini": null, "terminal_ini": null,
"tester_ini": null, "tester_ini": null,
"config_files_found": Vec::<String>::new(), "config_files_found": Vec::<String>::new(),
@@ -1505,6 +1624,7 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul
/// Get Wine prefix detailed information /// Get Wine prefix detailed information
pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> { pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> {
let _os_type = OsType::detect();
let mt5_dir = config.mt5_dir() let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
@@ -1514,6 +1634,7 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu
.and_then(|p| p.parent()); .and_then(|p| p.parent());
let mut result = json!({ let mut result = json!({
"os_context": get_os_context(),
"prefix_path": null, "prefix_path": null,
"exists": false, "exists": false,
"windows_version": null, "windows_version": null,
@@ -1609,6 +1730,7 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6); let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6);
let mut result = json!({ let mut result = json!({
"os_context": get_os_context(),
"crashes_found": Vec::<serde_json::Value>::new(), "crashes_found": Vec::<serde_json::Value>::new(),
"recent_failures": 0, "recent_failures": 0,
"common_patterns": Vec::<String>::new(), "common_patterns": Vec::<String>::new(),
+20
View File
@@ -0,0 +1,20 @@
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE).
pub fn read_file_as_utf8(path: &std::path::Path) -> anyhow::Result<String> {
let bytes = std::fs::read(path)?;
// Check for UTF-16LE BOM (0xFF 0xFE)
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
// UTF-16LE with BOM - skip the 2-byte BOM and decode
let utf16_data: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&utf16_data)
.map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e))
} else {
// Try UTF-8
String::from_utf8(bytes)
.map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e))
}
}