feat: configurable local agents + CPU pinning for optimization

- Add opt_max_agents config (default 75% of CPUs, capped at 20)
- Set LocalAgents in tester .ini and pin MT5 to CPUs 0..max_agents via taskset
- Resolve .set filename consistently between tester + ini config
- Move .mt5mcp_jobs to temp dir for persistence across CWD changes
- Fix optimization XML row parsing to descend into Cell/Data nodes
This commit is contained in:
masdevid
2026-07-19 15:32:39 +07:00
parent bfb45b8afc
commit 03268aee2c
5 changed files with 42 additions and 29 deletions
+8 -1
View File
@@ -94,6 +94,7 @@ pub struct Config {
pub backtest_timeout: Option<u32>,
pub opt_log_dir: Option<String>,
pub opt_min_agents: Option<u32>,
pub opt_max_agents: Option<u32>,
pub reports_dir: Option<String>,
pub backtest_login: Option<String>,
pub backtest_server: Option<String>,
@@ -121,6 +122,7 @@ impl Default for Config {
backtest_timeout: None,
opt_log_dir: None,
opt_min_agents: None,
opt_max_agents: None,
reports_dir: None,
backtest_login: None,
backtest_server: None,
@@ -241,6 +243,7 @@ impl Config {
cfg.backtest_timeout = Some(900);
cfg.opt_log_dir = Some("/tmp".into());
cfg.opt_min_agents = Some(1);
cfg.opt_max_agents = Some(20);
cfg
}
@@ -339,7 +342,8 @@ impl Config {
backtest_timeout: {to}\n\
\n\
opt_log_dir: {opt_log}\n\
opt_min_agents: {opt_agents}\n",
opt_min_agents: {opt_agents}\n\
opt_max_agents: {max_agents}\n",
wine = s(&self.wine_executable),
term = s(&self.terminal_dir),
exp = s(&self.experts_dir),
@@ -357,6 +361,7 @@ impl Config {
to = u(self.backtest_timeout),
opt_log = s(&self.opt_log_dir),
opt_agents = u(self.opt_min_agents),
max_agents = u(self.opt_max_agents),
);
fs::write(&path, content)?;
@@ -405,6 +410,7 @@ impl Config {
backtest_timeout: map.get("backtest_timeout").and_then(|s| s.parse().ok()),
opt_log_dir: map.get("opt_log_dir").cloned(),
opt_min_agents: map.get("opt_min_agents").and_then(|s| s.parse().ok()),
opt_max_agents: map.get("opt_max_agents").and_then(|s| s.parse().ok()),
reports_dir: map.get("reports_dir").cloned(),
backtest_login: map.get("backtest_login").cloned(),
backtest_server: map.get("backtest_server").cloned(),
@@ -432,6 +438,7 @@ impl Config {
"backtest_timeout" => self.backtest_timeout.unwrap_or(900).to_string(),
"opt_log_dir" => self.opt_log_dir.clone().unwrap_or_else(|| "/tmp".to_string()),
"opt_min_agents" => self.opt_min_agents.unwrap_or(1).to_string(),
"opt_max_agents" => self.opt_max_agents.unwrap_or(0).to_string(),
"reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()),
"backtest_login" => self.backtest_login.clone().unwrap_or_default(),
"backtest_server" => self.backtest_server.clone().unwrap_or_default(),
+28 -21
View File
@@ -105,6 +105,11 @@ impl OptimizationRunner {
let job_id = format!("opt_{}", timestamp);
let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp));
// Calculate agent count: 75% of available CPUs, or configured value
let cpu_count = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let default_agents = ((cpu_count as f64 * 0.75).ceil() as u32).max(1);
let max_agents = self.config.opt_max_agents.unwrap_or(default_agents).max(1);
// Count combinations
let combinations = self.count_combinations(&params.set_file)
.map_err(|e| anyhow!("count_combinations failed: {}", e))?;
@@ -115,11 +120,22 @@ impl OptimizationRunner {
let wine_exe = self.config.wine_executable.as_ref()
.ok_or_else(|| anyhow!("wine_executable not configured"))?;
// Resolve the .set filename MT5 will actually load: basename of set_file,
// else "{expert}.set". Must match the name told to MT5 below (ExpertParameters).
let set_param = if !params.set_file.is_empty() && params.set_file != format!("{}.set", params.expert) {
std::path::Path::new(&params.set_file).file_name()
.and_then(|n| n.to_str())
.unwrap_or(&format!("{}.set", params.expert))
.to_string()
} else {
format!("{}.set", params.expert)
};
// 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 tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester");
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(&set_param);
self.write_utf16le_set(&params.set_file, &dst_set_file)
.map_err(|e| anyhow!("write_utf16le_set({}) failed: {}", dst_set_file.display(), e))?;
@@ -145,24 +161,13 @@ impl OptimizationRunner {
} else {
format!("Experts\\{}.ex5", params.expert)
};
let set_param = {
let base = if !params.set_file.is_empty() && params.set_file != format!("{}.set", params.expert) {
// Extract just the filename from the set file path
std::path::Path::new(&params.set_file).file_name()
.and_then(|n| n.to_str())
.unwrap_or(&format!("{}.set", params.expert))
.to_string()
} else {
format!("{}.set", params.expert)
};
base
};
let mut tester_section = format!(
"[Tester]\n\
Expert={}\n\
ExpertParameters={}\n\
Symbol={}\n\
Period=M1\n\
LocalAgents={}\n\
Model=1\n\
FromDate={}\n\
ToDate={}\n\
@@ -177,7 +182,7 @@ impl OptimizationRunner {
Report=..\\..\\mt5mcp_opt_report.htm\n\
ReplaceReport=1\n\
ShutdownTerminal=1",
expert_path, set_param, params.symbol,
expert_path, set_param, params.symbol, max_agents,
params.from_date, params.to_date, params.deposit, params.currency, params.leverage,
);
if let Some(mp) = params.max_passes {
@@ -211,8 +216,9 @@ impl OptimizationRunner {
opt_ini.push_str(&format!("Expert={}.ex5\n", params.expert));
opt_ini.push_str(&format!("ExpertParameters={}\n", set_param));
opt_ini.push_str(&format!("Symbol={}\n", params.symbol));
opt_ini.push_str("Period=M1\n");
opt_ini.push_str("Model=1\n");
opt_ini.push_str(&format!("Period={}\n", "M1"));
opt_ini.push_str(&format!("LocalAgents={}\n", max_agents));
opt_ini.push_str(&format!("Model={}\n", "0"));
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));
@@ -249,12 +255,13 @@ impl OptimizationRunner {
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",
nohup taskset -c 0-$(( {max_agents} - 1 )) '{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,
max_agents = max_agents,
);
let script_path = std::env::temp_dir().join("mt5opt_launch.sh");
@@ -368,8 +375,8 @@ impl OptimizationRunner {
combinations: u64,
wine_prefix: &Path,
) -> Result<()> {
let jobs_dir = Path::new(".mt5mcp_jobs");
fs::create_dir_all(jobs_dir)?;
let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs");
fs::create_dir_all(&jobs_dir)?;
let meta_path = jobs_dir.join(format!("{}.json", job_id));
let started_at = Utc::now().to_rfc3339();
@@ -462,7 +469,7 @@ impl OptimizationRunner {
}
pub fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value> {
let jobs_dir = Path::new(".mt5mcp_jobs");
let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs");
let meta_path = jobs_dir.join(format!("{}.json", job_id));
if !meta_path.exists() {
@@ -538,7 +545,7 @@ impl OptimizationRunner {
}
pub fn list_jobs(&self) -> Result<Vec<serde_json::Value>> {
let jobs_dir = Path::new(".mt5mcp_jobs");
let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs");
let mut jobs = Vec::new();
if jobs_dir.exists() {
+4 -5
View File
@@ -23,7 +23,7 @@ impl OptimizationParser {
}
pub fn parse_job(&self, job_id: &str) -> Result<Vec<OptimizationPass>> {
let jobs_dir = Path::new(".mt5mcp_jobs");
let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs");
let meta_path = jobs_dir.join(format!("{}.json", job_id));
if !meta_path.exists() {
@@ -188,14 +188,13 @@ impl OptimizationParser {
for node in doc.descendants() {
if node.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Row")) ||
node.has_tag_name("Row") {
let cells: Vec<String> = node.children()
let cells: Vec<String> = node.descendants()
.filter(|n: &roxmltree::Node<'_, '_>| {
n.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Cell")) ||
n.has_tag_name("Cell") ||
n.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Data")) ||
n.has_tag_name("Data")
})
.map(|n| n.text().unwrap_or("").trim().to_string().replace(',', ""))
.filter_map(|n| n.text())
.map(|t| t.trim().to_string().replace(',', ""))
.collect();
if cells.is_empty() {
+1 -1
View File
@@ -60,7 +60,7 @@ pub async fn handle_get_optimization_status(config: &Config, args: &Value) -> Re
// Store completion results back to metadata for persistence
if status.get("status").and_then(|v| v.as_str()) == Some("completed") {
let jobs_dir = Path::new(".mt5mcp_jobs");
let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs");
let meta_path = jobs_dir.join(format!("{}.json", job_id));
if let Ok(meta_str) = fs::read_to_string(&meta_path) {
if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
+1 -1
View File
@@ -237,7 +237,7 @@ pub async fn handle_tail_log(_config: &Config, args: &Value) -> Result<Value> {
let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let log_path = if let Some(jid) = job_id {
let jobs_dir = Path::new(".mt5mcp_jobs");
let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs");
let meta_path = jobs_dir.join(format!("{}.json", jid));
let meta: Value = serde_json::from_str(&fs::read_to_string(meta_path)?)?;
meta.get("log_file").and_then(|v| v.as_str()).map(|s| s.to_string())