Files
mt5-quant/src/tools/handlers/utility.rs
T
Devid HW 6ce8808948 v1.30.0: Add 10 Wine/MT5 debugging tools
New debugging/diagnostics tools for crash investigation:
- diagnose_wine: Check Wine installation and prefix health
- get_mt5_logs: Get terminal/tester/metaeditor logs
- search_mt5_errors: Search logs for error patterns
- check_mt5_process: Check MT5 process status
- kill_mt5_process: Kill stuck MT5 processes
- check_system_resources: Check disk/memory/CPU
- validate_mt5_config: Validate MT5 configuration
- get_wine_prefix_info: Wine prefix details
- get_backtest_crash_info: Investigate backtest failures

Total tools: 85
Documentation updated in README.md and MCP_TOOLS.md
2026-04-20 02:25:07 +07:00

1719 lines
65 KiB
Rust

use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use crate::models::Config;
/// Validate that `user_path` resolves to a location within `allowed_base`.
/// Returns the canonicalized absolute path on success.
fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result<PathBuf> {
// Resolve the base first (must already exist).
let base = allowed_base.canonicalize()
.with_context(|| format!("allowed base directory does not exist: {}", allowed_base.display()))?;
// Build the candidate path. If the user supplied an absolute path we use it
// as-is; relative paths are joined onto the base so they stay inside it.
let candidate = {
let p = Path::new(user_path);
if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
};
// Canonicalize the parent directory (the file itself need not exist yet).
let parent = candidate.parent()
.ok_or_else(|| anyhow::anyhow!("output_path has no parent directory"))?;
let canonical_parent = parent.canonicalize()
.with_context(|| format!("output_path parent directory does not exist: {}", parent.display()))?;
let canonical = canonical_parent.join(
candidate.file_name()
.ok_or_else(|| anyhow::anyhow!("output_path must include a filename"))?,
);
if !canonical.starts_with(&base) {
return Err(anyhow::anyhow!(
"output_path '{}' is outside the allowed directory '{}'",
user_path, base.display()
));
}
Ok(canonical)
}
/// Check if symbol has sufficient data for date range
pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> Result<Value> {
let symbol = args.get("symbol")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("symbol is required"))?;
let from_date = args.get("from_date")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("from_date is required"))?;
let to_date = args.get("to_date")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("to_date is required"))?;
// Get active account to determine which server to check
let current_account = config.current_account();
let server = current_account.as_ref().map(|a| a.server.as_str()).unwrap_or("");
// Check if symbol exists in available symbols
let available_symbols = config.discover_symbols_for_active_account();
let symbol_available = available_symbols.contains(&symbol.to_string());
if !symbol_available {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"symbol": symbol,
"has_sufficient_data": false,
"error": format!("Symbol '{}' not available for server '{}'", symbol, server),
"available_symbols": available_symbols,
"suggestion": "Use get_active_account to see available symbols for this account"
}).to_string() }],
"isError": false
}));
}
// Try to find hcc files to determine actual data range
let mt5_dir = config.mt5_dir();
let mut data_range_start = None;
let mut data_range_end = None;
let mut bars_count = 0;
if let Some(mt5_path) = mt5_dir {
let bases_dir = mt5_path.join("Bases");
if bases_dir.exists() {
// Look through servers for this symbol
for server_entry in fs::read_dir(&bases_dir)?.flatten() {
let server_name = server_entry.file_name().to_string_lossy().to_string();
if server.is_empty() || server_name == server {
let symbol_dir = server_entry.path().join("history").join(symbol);
if symbol_dir.exists() {
// Count hcc files and get date range
for entry in fs::read_dir(&symbol_dir)?.flatten() {
if let Some(ext) = entry.path().extension() {
if ext == "hcc" {
bars_count += 1;
if let Some(fname) = entry.file_name().to_str() {
// Parse year from filename (e.g., "2024.hcc")
if let Ok(year) = fname.trim_end_matches(".hcc").parse::<i32>() {
if data_range_start.is_none() || year < data_range_start.unwrap() {
data_range_start = Some(year);
}
if data_range_end.is_none() || year > data_range_end.unwrap() {
data_range_end = Some(year);
}
}
}
}
}
}
}
}
}
}
}
// Parse requested date range
let parse_date = |date_str: &str| -> Option<(i32, i32, i32)> {
let parts: Vec<&str> = date_str.split('.').collect();
if parts.len() == 3 {
if let (Ok(y), Ok(m), Ok(d)) = (parts[0].parse(), parts[1].parse(), parts[2].parse()) {
return Some((y, m, d));
}
}
None
};
let req_from = parse_date(from_date);
let req_to = parse_date(to_date);
let mut has_sufficient = true;
let mut warnings = Vec::new();
if let (Some(req_year), Some(start_year), Some(end_year)) = (req_from.map(|d| d.0), data_range_start, data_range_end) {
if req_year < start_year {
has_sufficient = false;
warnings.push(format!("Requested start year {} is before available data start {}", req_year, start_year));
}
if let Some(req_to_year) = req_to.map(|d| d.0) {
if req_to_year > end_year {
has_sufficient = false;
warnings.push(format!("Requested end year {} is after available data end {}", req_to_year, end_year));
}
}
}
Ok(json!({
"content": [{ "type": "text", "text": json!({
"symbol": symbol,
"server": server,
"has_sufficient_data": has_sufficient && bars_count > 0,
"requested_range": {
"from": from_date,
"to": to_date
},
"data_range": match (data_range_start, data_range_end) {
(Some(s), Some(e)) => format!("{}.01.01 - {}.12.31", s, e),
_ => "unknown".to_string()
},
"years_available": data_range_end.map(|e| e - data_range_start.unwrap_or(e) + 1).unwrap_or(0),
"hcc_files_count": bars_count,
"warnings": if warnings.is_empty() { None } else { Some(warnings) },
"suggestion": if !has_sufficient { "Consider downloading more history in MT5 or adjusting date range" } else { "Data range is sufficient for backtest" }
}).to_string() }],
"isError": false
}))
}
/// Get backtest history for EA/symbol
pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Result<Value> {
let expert_filter = args.get("expert").and_then(|v| v.as_str());
let symbol_filter = args.get("symbol").and_then(|v| v.as_str());
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let reports_dir = config.reports_dir();
let mut history = Vec::new();
if reports_dir.exists() {
for entry in fs::read_dir(&reports_dir)?.flatten() {
let path = entry.path();
if path.is_dir() {
// Try to read metrics.json
let metrics_path = path.join("metrics.json");
if let Ok(content) = fs::read_to_string(&metrics_path) {
if let Ok(metrics) = serde_json::from_str::<Value>(&content) {
let report_expert = metrics.get("expert").and_then(|v| v.as_str());
let report_symbol = metrics.get("symbol").and_then(|v| v.as_str());
// Apply filters
if let (Some(e), Some(filter)) = (report_expert, expert_filter) {
if !e.contains(filter) {
continue;
}
}
if let (Some(s), Some(filter)) = (report_symbol, symbol_filter) {
if s != filter {
continue;
}
}
// Extract key metrics
let summary = json!({
"report_dir": path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
"date": metrics.get("backtest_date").and_then(|v| v.as_str()),
"expert": report_expert,
"symbol": report_symbol,
"period": metrics.get("period").and_then(|v| v.as_str()),
"profit": metrics.get("net_profit").and_then(|v| v.as_f64()),
"profit_factor": metrics.get("profit_factor").and_then(|v| v.as_f64()),
"expected_payoff": metrics.get("expected_payoff").and_then(|v| v.as_f64()),
"drawdown_pct": metrics.get("drawdown_pct").and_then(|v| v.as_f64()),
"total_trades": metrics.get("total_trades").and_then(|v| v.as_u64()),
"win_rate": metrics.get("win_rate").and_then(|v| v.as_f64()),
});
history.push(summary);
}
}
}
}
}
// Sort by date (newest first)
history.sort_by(|a, b| {
let date_a = a.get("date").and_then(|v| v.as_str()).unwrap_or("");
let date_b = b.get("date").and_then(|v| v.as_str()).unwrap_or("");
date_b.cmp(date_a)
});
// Apply limit
let total = history.len();
let limited: Vec<Value> = history.into_iter().take(limit).collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"count": limited.len(),
"total": total,
"filters": {
"expert": expert_filter,
"symbol": symbol_filter
},
"history": limited,
"hint": if limited.is_empty() { "No backtest history found. Run a backtest first with run_backtest." } else { "Use compare_backtests to analyze differences between results." }
}).to_string() }],
"isError": false
}))
}
/// Compare multiple backtests
pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result<Value> {
let report_dirs = args.get("report_dirs")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("report_dirs array is required"))?;
if report_dirs.len() < 2 {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"error": "At least 2 report directories required for comparison"
}).to_string() }],
"isError": true
}));
}
let mut comparisons = Vec::new();
let mut base_metrics: Option<Value> = None;
for dir_value in report_dirs {
let dir = dir_value.as_str().unwrap_or("");
let path = Path::new(dir);
let metrics_path = path.join("metrics.json");
if let Ok(content) = fs::read_to_string(&metrics_path) {
if let Ok(metrics) = serde_json::from_str::<Value>(&content) {
if base_metrics.is_none() {
base_metrics = Some(metrics.clone());
}
let summary = json!({
"report_dir": dir,
"expert": metrics.get("expert").and_then(|v| v.as_str()),
"symbol": metrics.get("symbol").and_then(|v| v.as_str()),
"net_profit": metrics.get("net_profit").and_then(|v| v.as_f64()),
"profit_factor": metrics.get("profit_factor").and_then(|v| v.as_f64()),
"drawdown_pct": metrics.get("drawdown_pct").and_then(|v| v.as_f64()),
"total_trades": metrics.get("total_trades").and_then(|v| v.as_u64()),
"win_rate": metrics.get("win_rate").and_then(|v| v.as_f64()),
"expected_payoff": metrics.get("expected_payoff").and_then(|v| v.as_f64()),
"recovery_factor": metrics.get("recovery_factor").and_then(|v| v.as_f64()),
"sharpe_ratio": metrics.get("sharpe_ratio").and_then(|v| v.as_f64()),
});
comparisons.push(summary);
}
}
}
// Calculate differences if we have base
let mut analysis = Vec::new();
if let Some(base) = &base_metrics {
let base_profit = base.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0);
let base_dd = base.get("drawdown_pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
let base_pf = base.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0);
for (_i, comp) in comparisons.iter().enumerate().skip(1) {
let profit = comp.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0);
let dd = comp.get("drawdown_pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
let pf = comp.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0);
analysis.push(json!({
"compare_to": comparisons[0].get("report_dir"),
"report": comp.get("report_dir"),
"profit_diff": profit - base_profit,
"profit_pct_change": if base_profit != 0.0 { ((profit - base_profit) / base_profit.abs()) * 100.0 } else { 0.0 },
"drawdown_diff": dd - base_dd,
"profit_factor_diff": pf - base_pf,
"verdict": if profit > base_profit && dd <= base_dd { "better" }
else if profit < base_profit { "worse" }
else { "mixed" }
}));
}
}
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"count": comparisons.len(),
"comparisons": comparisons,
"analysis": if analysis.is_empty() { None } else { Some(analysis) },
"verdict": if comparisons.len() >= 2 {
let profits: Vec<f64> = comparisons.iter()
.filter_map(|c| c.get("net_profit").and_then(|v| v.as_f64()))
.collect();
let best_idx = profits.iter().enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i);
best_idx.map(|i| format!("Best: {}",
comparisons.get(i).and_then(|c| c.get("report_dir").and_then(|v| v.as_str())).unwrap_or("unknown")))
} else { None }
}).to_string() }],
"isError": false
}))
}
/// Initialize new MQL5 project
pub async fn handle_init_project(config: &Config, args: &Value) -> Result<Value> {
let name = args.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("name is required"))?;
let template = args.get("template").and_then(|v| v.as_str()).unwrap_or("basic");
// Determine project directory
let project_dir = config.project_dir.as_ref()
.map(|p| Path::new(p).to_path_buf())
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?;
let ea_path = project_dir.join(format!("{}.mq5", name));
// Basic EA template
let ea_template = match template {
"scalper" => format!(r#"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Scalper EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double Lots = 0.1;
input int StopLoss = 20; // points
input int TakeProfit = 10; // points
input int Slippage = 3;
int OnInit() {{
Print("{} Scalper EA initialized");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} EA stopped, reason: ", reason);
}}
void OnTick() {{
static datetime last_bar = 0;
datetime current_bar = iTime(_Symbol, PERIOD_CURRENT, 0);
if(current_bar == last_bar) return; // New bar only
last_bar = current_bar;
// Fast MAs crossover logic here
double ma_fast = iMA(_Symbol, PERIOD_CURRENT, 5, 0, MODE_EMA, PRICE_CLOSE, 0);
double ma_slow = iMA(_Symbol, PERIOD_CURRENT, 15, 0, MODE_EMA, PRICE_CLOSE, 0);
if(PositionsTotal() == 0) {{
if(ma_fast > ma_slow) {{
// Buy signal
// OrderSend(_Symbol, ORDER_TYPE_BUY, Lots, Ask, Slippage, Bid-StopLoss*_Point, Bid+TakeProfit*_Point);
}}
else if(ma_fast < ma_slow) {{
// Sell signal
// OrderSend(_Symbol, ORDER_TYPE_SELL, Lots, Bid, Slippage, Ask+StopLoss*_Point, Ask-TakeProfit*_Point);
}}
}}
}}
"#, name, name, name),
"swing" => format!(r#"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Swing EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double Lots = 0.1;
input int StopLoss = 100; // points
input int TakeProfit = 200; // points
input int TrailingStop = 50; // points
int OnInit() {{
Print("{} Swing EA initialized");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} EA stopped");
}}
void OnTick() {{
// Daily/H4 trend following logic
double trend_ma = iMA(_Symbol, PERIOD_D1, 50, 0, MODE_SMA, PRICE_CLOSE, 0);
double price = iClose(_Symbol, PERIOD_CURRENT, 0);
// Implementation here
}}
"#, name, name, name),
"grid" => format!(r#"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Grid EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double StartLots = 0.01;
input double LotMultiplier = 1.5;
input int GridPips = 20;
input int MaxLevels = 10;
int OnInit() {{
Print("{} Grid EA initialized - Use with extreme caution!");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} Grid EA stopped");
}}
void OnTick() {{
// Grid trading logic
// WARNING: Grid strategies can lead to significant losses
}}
"#, name, name, name),
_ => format!(r#"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Basic EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double Lots = 0.1;
input int StopLoss = 50;
input int TakeProfit = 50;
int OnInit() {{
Print("{} EA initialized");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} EA stopped");
}}
void OnTick() {{
// Your trading logic here
// Check for open positions, signals, etc.
}}
"#, name, name, name)
};
// Write EA file
fs::write(&ea_path, ea_template)?;
// Create README
let readme_path = project_dir.join("README.md");
let readme_content = format!(r#"# {}
MQL5 Expert Advisor generated by MT5-Quant
## Files
- `{}`.mq5 - Main EA source code
## Parameters
Edit the `input` variables in the source code to customize:
- `Lots` - Trading lot size
- `StopLoss` - Stop loss in points
- `TakeProfit` - Take profit in points
## Build & Test
```bash
# Compile EA
mt5-quant compile_ea expert={}
# Run backtest
mt5-quant run_backtest expert={} symbol=XAUUSDc
```
"#, name, name, name, name);
fs::write(&readme_path, readme_content)?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"project_name": name,
"template": template,
"created_files": [
ea_path.to_string_lossy().to_string(),
readme_path.to_string_lossy().to_string()
],
"hint": format!("Edit {}.mq5 to implement your strategy, then compile with compile_ea", name)
}).to_string() }],
"isError": false
}))
}
/// Validate EA syntax (basic check without full compile)
pub async fn handle_validate_ea_syntax(_config: &Config, args: &Value) -> Result<Value> {
let path = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
let content = fs::read_to_string(path)?;
let mut errors = Vec::new();
let mut warnings = Vec::new();
// Basic syntax checks
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
let line_num = i + 1;
let trimmed = line.trim();
// Check for basic issues
if trimmed.ends_with('{') && !trimmed.contains('}') {
// This is just a heuristic, not a real syntax error
}
if trimmed.contains("OrderSend") && !trimmed.starts_with("//") {
warnings.push(json!({
"line": line_num,
"message": "OrderSend found - ensure proper error handling",
"severity": "warning"
}));
}
if trimmed.contains("input") && trimmed.contains("double") && trimmed.contains("Lots") {
if !trimmed.contains("=") {
warnings.push(json!({
"line": line_num,
"message": "Input parameter 'Lots' has no default value",
"severity": "warning"
}));
}
}
}
// Check for required sections
let has_on_init = content.contains("int OnInit()");
let has_on_tick = content.contains("void OnTick()");
let has_on_deinit = content.contains("void OnDeinit");
if !has_on_init {
errors.push(json!({
"line": 0,
"message": "Missing OnInit() function - required for EA",
"severity": "error"
}));
}
if !has_on_tick && !content.contains("void OnTimer()") {
warnings.push(json!({
"line": 0,
"message": "No OnTick() or OnTimer() found - EA won't respond to events",
"severity": "warning"
}));
}
let valid = errors.is_empty();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": valid,
"valid": valid,
"path": path,
"checks": {
"has_on_init": has_on_init,
"has_on_tick": has_on_tick,
"has_on_deinit": has_on_deinit,
"lines": lines.len()
},
"errors": if errors.is_empty() { None } else { Some(errors) },
"warnings": if warnings.is_empty() { None } else { Some(warnings) },
"hint": if valid { "Syntax looks good. Run compile_ea for full compilation check." } else { "Fix errors before compiling." }
}).to_string() }],
"isError": !valid
}))
}
/// Check MT5 terminal status
pub async fn handle_check_mt5_status(config: &Config) -> Result<Value> {
let mt5_dir = config.mt5_dir();
let wine_exe = config.wine_executable.as_ref();
// Check if MT5 files exist
let terminal_exists = mt5_dir.as_ref().map(|d| d.join("terminal64.exe").exists()).unwrap_or(false);
let metaeditor_exists = mt5_dir.as_ref().map(|d| d.join("metaeditor64.exe").exists()).unwrap_or(false);
let tester_exists = mt5_dir.as_ref().map(|d| d.join("metatester64.exe").exists()).unwrap_or(false);
// Check Wine
let wine_ok = wine_exe.map(|w| Path::new(w).exists()).unwrap_or(false);
// Try to get MT5 version (would need to actually run it, skip for now)
let mut mt5_version = None;
if wine_ok && terminal_exists {
// Could run: wine terminal64.exe /version but it's complex
mt5_version = Some("detected".to_string());
}
let all_ok = terminal_exists && metaeditor_exists && tester_exists && wine_ok;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": all_ok,
"terminal_ready": all_ok,
"checks": {
"mt5_dir_exists": mt5_dir.as_ref().map(|d| d.exists()).unwrap_or(false),
"terminal64_exe": terminal_exists,
"metaeditor64_exe": metaeditor_exists,
"metatester64_exe": tester_exists,
"wine_executable": wine_ok,
"wine_path": wine_exe
},
"mt5_version": mt5_version,
"current_account": config.current_account().map(|a| json!({
"login": a.login,
"server": a.server
})),
"hint": if all_ok {
"MT5 is ready for backtesting and optimization."
} else {
"Some components missing. Run verify_setup for detailed diagnostics."
}
}).to_string() }],
"isError": false
}))
}
/// Create .set file template from EA
pub async fn handle_create_set_template(config: &Config, args: &Value) -> Result<Value> {
let ea = args.get("ea")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("ea is required"))?;
let output_path = args.get("output_path").and_then(|v| v.as_str());
// Find EA source file
let ea_path = if Path::new(ea).exists() {
Path::new(ea).to_path_buf()
} else if let Some(experts_dir) = &config.experts_dir {
Path::new(experts_dir).join(format!("{}.mq5", ea))
} else {
return Err(anyhow::anyhow!("Cannot find EA: {}", ea));
};
if !ea_path.exists() {
return Err(anyhow::anyhow!("EA file not found: {}", ea_path.display()));
}
let content = fs::read_to_string(&ea_path)?;
let mut inputs = Vec::new();
// Parse input declarations
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("input ") {
// Parse: input type name = default; // comment
let without_input = &trimmed[6..]; // Remove "input "
// Extract comment if any
let parts: Vec<&str> = without_input.split("//").collect();
let decl = parts[0].trim();
let comment = parts.get(1).map(|c| c.trim());
// Parse type and name
let tokens: Vec<&str> = decl.split_whitespace().collect();
if tokens.len() >= 2 {
let type_name = tokens[0];
let rest = tokens[1..].to_vec().join(" ");
// Parse name = value
let name_value: Vec<&str> = rest.split('=').collect();
let name = name_value[0].trim();
let default_val = name_value.get(1).map(|v| v.trim().trim_end_matches(';')).unwrap_or("0");
inputs.push(json!({
"name": name,
"type": type_name,
"default": default_val,
"description": comment
}));
}
}
}
// Generate .set content
let mut set_content = format!("; {} parameters generated by MT5-Quant\n", ea);
set_content.push_str("; Format: name=value\n\n");
for input in &inputs {
let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("");
let default = input.get("default").and_then(|v| v.as_str()).unwrap_or("0");
let desc = input.get("description").and_then(|v| v.as_str()).unwrap_or("");
if !desc.is_empty() {
set_content.push_str(&format!("; {}\n", desc));
}
set_content.push_str(&format!("{}={}\n\n", name, default));
}
// Determine output path
let set_path = if let Some(path) = output_path {
// Restrict writes to the tester profiles dir (or the EA's own dir as fallback).
let allowed_base = if let Some(profiles_dir) = &config.tester_profiles_dir {
Path::new(profiles_dir).to_path_buf()
} else {
ea_path.parent().unwrap_or(Path::new(".")).to_path_buf()
};
safe_output_path(path, &allowed_base)?
} else if let Some(profiles_dir) = &config.tester_profiles_dir {
Path::new(profiles_dir).join(format!("{}.set", ea))
} else {
ea_path.with_extension("set")
};
fs::write(&set_path, set_content)?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"ea": ea,
"inputs_found": inputs.len(),
"inputs": inputs,
"set_file": set_path.to_string_lossy().to_string(),
"hint": "Edit .set file to modify parameter values, then use with run_backtest set_file=..."
}).to_string() }],
"isError": false
}))
}
/// Export backtest report to various formats
pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let format = args.get("format").and_then(|v| v.as_str()).unwrap_or("csv");
let output_path = args.get("output_path").and_then(|v| v.as_str());
let path = Path::new(report_dir);
let metrics_path = path.join("metrics.json");
let _deals_path = path.join("deals.csv");
// Read metrics
let metrics: Value = if metrics_path.exists() {
fs::read_to_string(&metrics_path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or(json!({}))
} else {
json!({})
};
// Determine output file; restrict user-supplied path to the report directory.
let output = match output_path {
Some(p) => safe_output_path(p, path)?,
None => path.join(format!("report.{}", format)),
};
let content = match format {
"csv" => {
// Simple CSV export of metrics
let mut csv = "Metric,Value\n".to_string();
if let Some(obj) = metrics.as_object() {
for (key, value) in obj {
csv.push_str(&format!("{},{}\n", key, value));
}
}
csv
}
"md" => {
// Markdown format
let mut md = format!("# Backtest Report: {}\n\n", metrics.get("expert").and_then(|v| v.as_str()).unwrap_or("Unknown"));
md.push_str("## Summary\n\n");
if let Some(obj) = metrics.as_object() {
for (key, value) in obj {
md.push_str(&format!("- **{}**: {}\n", key, value));
}
}
md
}
_ => metrics.to_string() // JSON fallback
};
fs::write(&output, content)?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"format": format,
"output_file": output.to_string_lossy().to_string(),
"source": report_dir,
"hint": "Exported report ready for analysis or sharing."
}).to_string() }],
"isError": false
}))
}
// === Wine & MT5 Debugging Tools ===
/// Diagnose Wine installation and prefix health
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
let mut diagnostics = json!({
"wine_executable": null,
"wine_version": null,
"wine_prefix": null,
"prefix_health": null,
"prefix_exists": false,
"prefix_size_mb": 0,
"errors": Vec::<String>::new(),
"warnings": Vec::<String>::new(),
});
// Check wine executable
if let Some(wine_exe) = config.wine_executable.as_ref() {
diagnostics["wine_executable"] = json!(wine_exe);
// Get Wine version
let version_output = std::process::Command::new(wine_exe)
.arg("--version")
.output();
match version_output {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
diagnostics["wine_version"] = json!(version);
}
_ => {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Failed to get Wine version - Wine may not be properly installed")
);
}
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine executable not configured")
);
}
// Check Wine prefix
if let Some(mt5_dir) = config.mt5_dir() {
let wine_prefix = mt5_dir
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
if let Some(prefix) = wine_prefix {
diagnostics["wine_prefix"] = json!(prefix.to_string_lossy().to_string());
// Check if prefix exists
let prefix_exists = prefix.exists();
diagnostics["prefix_exists"] = json!(prefix_exists);
if prefix_exists {
// Calculate prefix size
let mut total_size = 0u64;
fn calculate_size(dir: &Path, total: &mut u64) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(meta) = entry.metadata() {
*total += meta.len();
}
} else if path.is_dir() {
calculate_size(&path, total);
}
}
}
}
calculate_size(prefix, &mut total_size);
diagnostics["prefix_size_mb"] = json!((total_size / 1024 / 1024) as i64);
// Check critical directories
let system32 = prefix.join("drive_c/windows/system32");
let program_files = prefix.join("drive_c/Program Files");
if !system32.exists() {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine prefix missing system32 directory - prefix may be corrupted")
);
diagnostics["prefix_health"] = json!("corrupted");
} else if !program_files.exists() {
diagnostics["warnings"].as_array_mut().unwrap().push(
json!("Program Files directory not found")
);
diagnostics["prefix_health"] = json!("incomplete");
} else {
diagnostics["prefix_health"] = json!("healthy");
}
// Check for recent Wine errors
let wine_log = prefix.join("wine.log");
if wine_log.exists() {
if let Ok(content) = fs::read_to_string(&wine_log) {
let recent_errors: Vec<&str> = content.lines()
.filter(|l| l.contains("err:") || l.contains("fixme:"))
.rev()
.take(10)
.collect();
if !recent_errors.is_empty() {
diagnostics["recent_wine_errors"] = json!(recent_errors);
}
}
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine prefix directory does not exist")
);
diagnostics["prefix_health"] = json!("missing");
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Could not determine Wine prefix from MT5 directory")
);
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("MT5 directory not configured")
);
}
let has_errors = !diagnostics["errors"].as_array().unwrap().is_empty();
Ok(json!({
"content": [{ "type": "text", "text": diagnostics.to_string() }],
"isError": has_errors
}))
}
/// Get MT5 terminal logs
pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result<Value> {
let log_type = args.get("log_type").and_then(|v| v.as_str()).unwrap_or("terminal");
let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let search = args.get("search").and_then(|v| v.as_str());
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let log_path = match log_type {
"terminal" => mt5_dir.join("logs").join(format!("{}", chrono::Local::now().format("%Y%m%d"))),
"tester" => mt5_dir.join("Tester").join("logs"),
"metaeditor" => mt5_dir.join("MetaEditor").join("logs"),
_ => mt5_dir.join("logs"),
};
let mut result = json!({
"log_type": log_type,
"log_path": log_path.to_string_lossy().to_string(),
"found": false,
"lines_total": 0,
"lines_returned": 0,
"content": Vec::<String>::new(),
});
// Find log files
let mut log_files: Vec<_> = Vec::new();
if log_path.exists() {
if let Ok(entries) = fs::read_dir(&log_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "log" {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
log_files.push((path, modified));
}
}
}
}
}
}
}
}
// Sort by modification time (newest first)
log_files.sort_by(|a, b| b.1.cmp(&a.1));
if let Some((latest_log, _)) = log_files.first() {
result["found"] = json!(true);
if let Ok(content) = fs::read_to_string(latest_log) {
let all_lines: Vec<&str> = content.lines().collect();
result["lines_total"] = json!(all_lines.len());
// Filter and limit lines
let mut filtered: Vec<&str> = all_lines.clone();
// Apply search filter
if let Some(search_term) = search {
let search_lower = search_term.to_lowercase();
filtered.retain(|line| line.to_lowercase().contains(&search_lower));
}
// Get last N lines
let start = filtered.len().saturating_sub(lines);
let final_lines: Vec<String> = filtered[start..].iter().map(|s| s.to_string()).collect();
result["lines_returned"] = json!(final_lines.len());
result["content"] = json!(final_lines);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Search MT5 logs for error patterns
pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<Value> {
let error_patterns = vec![
"error", "failed", "crash", "exception", "access violation",
"out of memory", "cannot", "unable to", "terminated"
];
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(24);
let max_errors = args.get("max_errors").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut errors_found = Vec::new();
let logs_dir = mt5_dir.join("logs");
let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
// Search recent log files
if logs_dir.exists() {
if let Ok(entries) = fs::read_dir(&logs_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "log").unwrap_or(false) {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified >= cutoff_time {
if let Ok(content) = fs::read_to_string(&path) {
for (i, line) in content.lines().enumerate() {
let line_lower = line.to_lowercase();
for pattern in &error_patterns {
if line_lower.contains(pattern) {
errors_found.push(json!({
"file": path.file_name().unwrap_or_default().to_string_lossy().to_string(),
"line": i + 1,
"content": line.trim().to_string(),
"pattern": pattern,
}));
if errors_found.len() >= max_errors {
break;
}
}
}
}
}
}
}
}
}
if errors_found.len() >= max_errors {
break;
}
}
}
}
let result = json!({
"hours_searched": hours_back,
"errors_found": errors_found.len(),
"max_errors": max_errors,
"errors": errors_found,
"suggestion": if errors_found.is_empty() {
"No errors found in recent logs. Check get_mt5_logs for full log content."
} else {
"Found potential errors. Review the 'content' field for details."
},
});
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Check MT5 process status
pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let mut result = json!({
"is_running": false,
"processes": Vec::<serde_json::Value>::new(),
"wine_server_running": false,
"total_instances": 0,
});
#[cfg(target_os = "macos")]
{
// Check for MT5 processes
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") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
processes.push(json!({
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
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") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
processes.push(json!({
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
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);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Kill stuck MT5 process
pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> {
use std::process::Command;
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 mut killed = Vec::new();
let mut failed = Vec::new();
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
// Get list of MT5 processes
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines() {
let line_lower = line.to_lowercase();
let should_kill = if let Some(target_pid) = pid {
line.contains(target_pid) && (line_lower.contains("terminal64") || line_lower.contains("metatrader"))
} else {
line_lower.contains("terminal64") || line_lower.contains("metatrader")
};
if should_kill {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let process_pid = parts[1];
let signal = if force { "-9" } else { "-15" };
match Command::new("kill").args([signal, process_pid]).output() {
Ok(_) => killed.push(process_pid.to_string()),
Err(e) => failed.push(format!("{}: {}", process_pid, e)),
}
}
}
}
}
// Also kill wineserver if force=true
if force {
let _ = Command::new("killall").arg("wineserver").output();
}
}
let message = if killed.is_empty() {
"No MT5 processes found to kill".to_string()
} else {
format!("Killed {} MT5 process(es)", killed.len())
};
let result = json!({
"killed": killed,
"failed": failed,
"force": force,
"message": message,
});
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": !failed.is_empty()
}))
}
/// Check system resources for MT5
pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let mut result = json!({
"disk_space": null,
"memory": null,
"cpu_cores": 0,
"recommendations": Vec::<String>::new(),
});
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
// Check disk space
let df_output = Command::new("df")
.args(["-h", "/"])
.output();
if let Ok(output) = df_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
result["disk_space"] = json!({
"filesystem": parts[0],
"size": parts[1],
"used": parts[2],
"available": parts[3],
"use_percent": parts[4],
});
// Check if low on space
let use_pct = parts[4].trim_end_matches('%').parse::<u32>().unwrap_or(0);
if use_pct > 90 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Disk space critically low. Clean MT5 cache with clean_cache.")
);
} else if use_pct > 80 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Disk space getting low. Consider cleaning cache.")
);
}
}
}
}
// Check memory
#[cfg(target_os = "macos")]
{
let vm_output = Command::new("vm_stat").output();
if let Ok(output) = vm_output {
let content = String::from_utf8_lossy(&output.stdout);
// Parse vm_stat output
let mut free_pages = 0u64;
let mut active_pages = 0u64;
let mut inactive_pages = 0u64;
for line in content.lines() {
if line.contains("Pages free:") {
free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages active:") {
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;
let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024;
let free_mb = (free_pages * page_size) / 1024 / 1024;
result["memory"] = json!({
"total_mb": total_mb,
"free_mb": free_mb,
"unit": "MB",
});
if free_mb < 2048 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Low memory available. MT5 may crash during large optimizations.")
);
}
}
}
#[cfg(target_os = "linux")]
{
let mem_output = Command::new("free").args(["-m"]).output();
if let Ok(output) = mem_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines() {
if line.starts_with("Mem:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
result["memory"] = json!({
"total_mb": parts[1].parse::<u64>().unwrap_or(0),
"used_mb": parts[2].parse::<u64>().unwrap_or(0),
"free_mb": parts[3].parse::<u64>().unwrap_or(0),
"unit": "MB",
});
}
}
}
}
}
// Get CPU cores
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);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Validate MT5 configuration files
pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Result<Value> {
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut result = json!({
"terminal_ini": null,
"tester_ini": null,
"config_files_found": Vec::<String>::new(),
"errors": Vec::<String>::new(),
"warnings": Vec::<String>::new(),
});
// Check terminal.ini
let terminal_ini = mt5_dir.join("terminal.ini");
if terminal_ini.exists() {
result["config_files_found"].as_array_mut().unwrap().push(json!("terminal.ini"));
if let Ok(content) = fs::read_to_string(&terminal_ini) {
// Check for common issues
if !content.contains("[Common]") {
result["errors"].as_array_mut().unwrap().push(
json!("terminal.ini missing [Common] section")
);
}
// Extract key settings
let mut settings = serde_json::Map::new();
for line in content.lines() {
if line.starts_with("Login=") {
settings.insert("login".to_string(), json!(line.trim_start_matches("Login=")));
} else if line.starts_with("Server=") {
settings.insert("server".to_string(), json!(line.trim_start_matches("Server=")));
} else if line.starts_with("Expert=") {
settings.insert("expert".to_string(), json!(line.trim_start_matches("Expert=")));
}
}
result["terminal_ini"] = json!(settings);
} else {
result["errors"].as_array_mut().unwrap().push(
json!("Could not read terminal.ini")
);
}
} else {
result["warnings"].as_array_mut().unwrap().push(
json!("terminal.ini not found")
);
}
// Check for tester config
let tester_dir = mt5_dir.join("Tester");
if tester_dir.exists() {
if let Ok(entries) = fs::read_dir(&tester_dir) {
let ini_files: Vec<String> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension()?.to_str()? == "ini" {
Some(p.file_name()?.to_string_lossy().to_string())
} else {
None
}
})
.collect();
if !ini_files.is_empty() {
result["tester_ini"] = json!(ini_files);
}
}
}
// Check for common problems
let experts_dir = mt5_dir.join("MQL5").join("Experts");
if !experts_dir.exists() {
result["errors"].as_array_mut().unwrap().push(
json!("MQL5/Experts directory not found - MT5 installation may be incomplete")
);
}
let has_errors = !result["errors"].as_array().unwrap().is_empty();
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": has_errors
}))
}
/// Get Wine prefix detailed information
pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> {
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let wine_prefix = mt5_dir
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
let mut result = json!({
"prefix_path": null,
"exists": false,
"windows_version": null,
"dll_overrides": Vec::<String>::new(),
"installed_programs": Vec::<String>::new(),
"registry_files": Vec::<String>::new(),
"drive_c_size_mb": 0,
});
if let Some(prefix) = wine_prefix {
result["prefix_path"] = json!(prefix.to_string_lossy().to_string());
result["exists"] = json!(prefix.exists());
if prefix.exists() {
// Check Windows version
let system_reg = prefix.join("system.reg");
if system_reg.exists() {
if let Ok(content) = fs::read_to_string(&system_reg) {
for line in content.lines().take(50) {
if line.contains("\"ProductName\"") {
let parts: Vec<&str> = line.split('"').collect();
if parts.len() >= 4 {
result["windows_version"] = json!(parts[3]);
}
}
}
}
}
// Calculate drive_c size
let drive_c = prefix.join("drive_c");
if drive_c.exists() {
let mut size = 0u64;
fn calc_size(dir: &Path, total: &mut u64) {
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.is_file() {
if let Ok(m) = e.metadata() {
*total += m.len();
}
} else if p.is_dir() {
calc_size(&p, total);
}
}
}
}
calc_size(&drive_c, &mut size);
result["drive_c_size_mb"] = json!((size / 1024 / 1024) as i64);
}
// Check for installed programs
let prog_files = prefix.join("drive_c").join("Program Files");
if prog_files.exists() {
if let Ok(entries) = fs::read_dir(&prog_files) {
let programs: Vec<String> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.is_dir() {
Some(p.file_name()?.to_string_lossy().to_string())
} else {
None
}
})
.collect();
result["installed_programs"] = json!(programs);
}
}
// List registry files
let reg_files: Vec<String> = vec![
"system.reg", "user.reg", "userdef.reg"
]
.into_iter()
.filter(|f| prefix.join(f).exists())
.map(|f| f.to_string())
.collect();
result["registry_files"] = json!(reg_files);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Get backtest crash/failure information
pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir").and_then(|v| v.as_str());
let check_recent = args.get("check_recent").and_then(|v| v.as_bool()).unwrap_or(true);
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6);
let mut result = json!({
"crashes_found": Vec::<serde_json::Value>::new(),
"recent_failures": 0,
"common_patterns": Vec::<String>::new(),
});
// Check specific report directory if provided
if let Some(dir) = report_dir {
let path = Path::new(dir);
if path.exists() {
// Check for incomplete markers
let incomplete_marker = path.join(".incomplete");
let error_log = path.join("error.log");
if incomplete_marker.exists() {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "incomplete",
"reason": "Backtest was interrupted or timed out",
}));
}
if error_log.exists() {
if let Ok(content) = fs::read_to_string(&error_log) {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "error_log",
"content": content.lines().take(20).collect::<Vec<_>>().join("\n"),
}));
}
}
// Check if deals.csv is missing or empty
let deals_csv = path.join("deals.csv");
if !deals_csv.exists() {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "missing_deals",
"reason": "deals.csv not found - backtest likely failed",
}));
} else if let Ok(meta) = deals_csv.metadata() {
if meta.len() < 100 {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "empty_deals",
"reason": "deals.csv is nearly empty - no trades were made",
}));
}
}
}
}
// Check recent reports if requested
if check_recent {
let reports_dir_str = config.get("reports_dir");
let reports_dir = Path::new(&reports_dir_str);
if reports_dir.exists() {
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
if let Ok(entries) = fs::read_dir(&reports_dir) {
let mut failures = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified >= cutoff {
// Check for failure indicators
let has_deals = path.join("deals.csv").exists();
let has_incomplete = path.join(".incomplete").exists();
if !has_deals || has_incomplete {
failures += 1;
}
}
}
}
}
}
result["recent_failures"] = json!(failures);
}
}
}
// Analyze common patterns
let crashes = result["crashes_found"].as_array().unwrap();
if !crashes.is_empty() {
let types: Vec<String> = crashes.iter()
.filter_map(|c| c.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()))
.collect();
if types.contains(&"missing_deals".to_string()) {
result["common_patterns"].as_array_mut().unwrap().push(
json!("Missing deals.csv suggests MT5 crashed during backtest")
);
}
if types.contains(&"incomplete".to_string()) {
result["common_patterns"].as_array_mut().unwrap().push(
json!("Incomplete markers indicate interruptions - check system resources")
);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}