use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptimizationPass {
pub pass: u32,
pub profit: f64,
pub total_trades: u32,
pub profit_factor: f64,
pub expected_payoff: f64,
pub drawdown_pct: f64,
pub params: HashMap<String, String>,
}
pub struct OptimizationParser;
impl OptimizationParser {
pub fn new() -> Self {
Self
}
pub fn parse_job(&self, job_id: &str) -> Result<Vec<OptimizationPass>> {
let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs");
let meta_path = jobs_dir.join(format!("{}.json", job_id));
if !meta_path.exists() {
return Err(anyhow!("Job not found: {}. Check .mt5mcp_jobs/", job_id));
}
let meta: serde_json::Value = serde_json::from_str(&fs::read_to_string(&meta_path)?)?;
if let Some(report_base) = meta.get("report_path").and_then(|v| v.as_str()) {
let base_path = Path::new(report_base);
for ext in &[".htm", ".htm.xml", ".html"] {
let candidate = base_path.with_extension(ext.trim_start_matches('.'));
if candidate.exists() {
return self.parse_file(&candidate);
}
}
return Err(anyhow!(
"Optimization report not found at {}.*. Is MT5 optimization still running?",
base_path.display()
));
}
let wine_prefix = meta
.get("wine_prefix")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("wine_prefix not in job metadata"))?;
let base_path = Path::new(wine_prefix).join("drive_c/mt5mcp_opt_report");
for ext in &[".htm", ".htm.xml", ".html"] {
let candidate = base_path.with_extension(ext.trim_start_matches('.'));
if candidate.exists() {
return self.parse_file(&candidate);
}
}
Err(anyhow!(
"Optimization report not found. Expected: {}.htm or {}.htm.xml\nIs MT5 optimization still running?",
base_path.display(),
base_path.display()
))
}
pub fn parse_file(&self, path: &Path) -> Result<Vec<OptimizationPass>> {
let format = self.detect_format(path);
let text = self.read_text(path)?;
match format {
"xml" => self.parse_xml(&text),
_ => self.parse_html(&text),
}
}
fn detect_format(&self, path: &Path) -> &str {
let path_str = path.to_string_lossy();
if path_str.ends_with(".xml") || path_str.ends_with(".htm.xml") {
return "xml";
}
if let Ok(header) = fs::read(path) {
let header = &header[..header.len().min(512)];
if header.windows(5).any(|w| w == b"<?xml")
|| header.windows(8).any(|w| w == b"Workbook")
{
return "xml";
}
}
"html"
}
fn read_text(&self, path: &Path) -> Result<String> {
let raw = fs::read(path)?;
if raw.len() >= 2 {
if raw[0] == 0xFF && raw[1] == 0xFE {
let u16_vec: Vec<u16> = raw[2..]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
return Ok(String::from_utf16_lossy(&u16_vec));
} else if raw[0] == 0xFE && raw[1] == 0xFF {
let u16_vec: Vec<u16> = raw[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
return Ok(String::from_utf16_lossy(&u16_vec));
}
}
if let Ok(text) = String::from_utf8(raw.clone()) {
return Ok(text);
}
if raw.len() % 2 == 0 {
let u16_vec: Vec<u16> = raw
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
let text = String::from_utf16_lossy(&u16_vec);
if text.chars().any(|c| c.is_ascii_alphanumeric()) {
return Ok(text);
}
}
Ok(String::from_utf8_lossy(&raw).to_string())
}
fn parse_html(&self, text: &str) -> Result<Vec<OptimizationPass>> {
let mut results = Vec::new();
let mut headers: Vec<String> = Vec::new();
let row_regex = regex::Regex::new(r"<tr[^>]*>(.*?)</tr>")?;
let cell_regex = regex::Regex::new(r"<t[dh][^>]*>(.*?)</t[dh]>")?;
let tag_regex = regex::Regex::new(r"<[^>]+>")?;
for row_caps in row_regex.captures_iter(text) {
let row = &row_caps[1];
let cells: Vec<String> = cell_regex
.captures_iter(row)
.map(|c| {
let cell = &c[1];
tag_regex
.replace_all(cell, "")
.trim()
.to_string()
.replace(',', "")
})
.collect();
if cells.is_empty() {
continue;
}
if headers.is_empty() && cells[0].to_lowercase().contains("pass") {
headers = cells;
continue;
}
if !headers.is_empty() && cells[0].parse::<u32>().is_ok() {
let row_map: HashMap<String, String> = headers
.iter()
.zip(cells.iter())
.map(|(h, c)| (h.to_lowercase().replace(' ', "_"), c.clone()))
.collect();
if let Some(pass) = self.row_to_pass(&row_map) {
results.push(pass);
}
}
}
Ok(results)
}
fn parse_xml(&self, text: &str) -> Result<Vec<OptimizationPass>> {
let mut results = Vec::new();
let doc = roxmltree::Document::parse(text)?;
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
.descendants()
.filter(|n: &roxmltree::Node<'_, '_>| {
n.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Data"))
|| n.has_tag_name("Data")
})
.filter_map(|n| n.text())
.map(|t| t.trim().to_string().replace(',', ""))
.collect();
if cells.is_empty() {
continue;
}
if let Ok(pass_num) = cells[0].parse::<u32>() {
if pass_num > 0 {
let mut row_map = HashMap::new();
let headers = vec![
"pass",
"result",
"profit",
"total_trades",
"profit_factor",
"expected_payoff",
"drawdown_pct",
"recovery_factor",
"sharpe_ratio",
"custom",
"consecutive_wins",
"consecutive_losses",
];
for (i, cell) in cells.iter().enumerate() {
if let Some(header) = headers.get(i) {
row_map.insert(header.to_string(), cell.clone());
}
}
if let Some(pass) = self.row_to_pass(&row_map) {
results.push(pass);
}
}
}
}
}
Ok(results)
}
fn row_to_pass(&self, row: &HashMap<String, String>) -> Option<OptimizationPass> {
let pass = row
.get("pass")
.or_else(|| row.get("#"))
.and_then(|v| v.parse().ok())?;
let profit = row
.get("profit")
.or_else(|| row.get("total_net_profit"))
.and_then(|v| v.replace(' ', "").parse().ok())?;
let total_trades = row
.get("total_trades")
.or_else(|| row.get("trades"))
.and_then(|v| v.parse().ok())?;
let profit_factor = row.get("profit_factor").and_then(|v| v.parse().ok())?;
let expected_payoff = row.get("expected_payoff").and_then(|v| v.parse().ok())?;
let drawdown_pct = row
.get("drawdown_pct")
.or_else(|| row.get("max_drawdown"))
.and_then(|v| v.trim_end_matches('%').trim().parse().ok())?;
let params: HashMap<String, String> = row
.iter()
.filter(|(k, _)| {
![
"pass",
"result",
"profit",
"total_trades",
"profit_factor",
"expected_payoff",
"drawdown_pct",
"max_drawdown",
"recovery_factor",
"sharpe_ratio",
"custom",
"consecutive_wins",
"consecutive_losses",
]
.contains(&k.as_str())
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Some(OptimizationPass {
pass,
profit,
total_trades,
profit_factor,
expected_payoff,
drawdown_pct,
params,
})
}
pub fn find_best_pass<'a>(
&self,
passes: &'a [OptimizationPass],
criteria: &str,
) -> Option<&'a OptimizationPass> {
match criteria {
"profit" => passes
.iter()
.max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()),
"profit_factor" => passes
.iter()
.max_by(|a, b| a.profit_factor.partial_cmp(&b.profit_factor).unwrap()),
"sharpe" => passes.iter().max_by(|a, b| {
let a_sharpe = a
.params
.get("sharpe_ratio")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0);
let b_sharpe = b
.params
.get("sharpe_ratio")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0);
a_sharpe.partial_cmp(&b_sharpe).unwrap()
}),
"drawdown" => passes
.iter()
.min_by(|a, b| a.drawdown_pct.partial_cmp(&b.drawdown_pct).unwrap()),
_ => passes
.iter()
.max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()),
}
}
}