diff --git a/src/models/config.rs b/src/models/config.rs index ae4e616..524ab6e 100644 --- a/src/models/config.rs +++ b/src/models/config.rs @@ -2,13 +2,15 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub wine_executable: Option, pub terminal_dir: Option, pub experts_dir: Option, + pub indicators_dir: Option, + pub scripts_dir: Option, pub tester_profiles_dir: Option, pub tester_cache_dir: Option, pub display_mode: Option, @@ -33,6 +35,8 @@ impl Default for Config { wine_executable: None, terminal_dir: None, experts_dir: None, + indicators_dir: None, + scripts_dir: None, tester_profiles_dir: None, tester_cache_dir: None, display_mode: None, @@ -55,107 +59,361 @@ impl Default for Config { impl Config { pub fn load() -> Result { - let config_path = Self::get_config_path(); - if !config_path.exists() { - return Ok(Config::default()); + let config_path = Self::writable_config_path(); + + if config_path.exists() { + return Self::parse_file(&config_path); } - let content = fs::read_to_string(&config_path)?; - let mut config: HashMap = HashMap::new(); + // No config found — auto-discover and persist. + let discovered = Self::auto_discover(); + if let Err(e) = discovered.save() { + tracing::warn!("Could not save auto-discovered config: {}", e); + } + Ok(discovered) + } + + /// The canonical writable config location: $MT5_MCP_HOME/config/mt5-quant.yaml + /// or ~/.config/mt5-quant/config/mt5-quant.yaml. + pub fn writable_config_path() -> PathBuf { + if let Ok(home) = std::env::var("MT5_MCP_HOME") { + return Path::new(&home).join("config").join("mt5-quant.yaml"); + } + Self::installation_dir().join("config").join("mt5-quant.yaml") + } + + // ── Auto-discovery ──────────────────────────────────────────────────────── + + pub fn auto_discover() -> Self { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let mut cfg = Config::default(); + + // 1. Find Wine executable ------------------------------------------- + cfg.wine_executable = Self::find_wine(&home); + + // 2. Find MT5 terminal directory ------------------------------------ + if let Some(mt5_dir) = Self::find_mt5_dir(&home) { + cfg.experts_dir = Some( + mt5_dir.join("MQL5").join("Experts") + .to_string_lossy().to_string(), + ); + cfg.indicators_dir = Some( + mt5_dir.join("MQL5").join("Indicators") + .to_string_lossy().to_string(), + ); + cfg.scripts_dir = Some( + mt5_dir.join("MQL5").join("Scripts") + .to_string_lossy().to_string(), + ); + cfg.tester_profiles_dir = Some( + mt5_dir.join("MQL5").join("Profiles").join("Tester") + .to_string_lossy().to_string(), + ); + cfg.tester_cache_dir = Some( + mt5_dir.join("Tester") + .to_string_lossy().to_string(), + ); + cfg.terminal_dir = Some(mt5_dir.to_string_lossy().to_string()); + } + + // 3. Display mode --------------------------------------------------- + cfg.display_mode = Some(Self::detect_display_mode()); + + // 4. Sensible backtest defaults ------------------------------------ + cfg.backtest_symbol = Some("XAUUSD".into()); + cfg.backtest_deposit = Some(10000); + cfg.backtest_currency = Some("USD".into()); + cfg.backtest_leverage = Some(500); + cfg.backtest_model = Some(0); + cfg.backtest_timeframe = Some("M5".into()); + cfg.backtest_timeout = Some(900); + cfg.opt_log_dir = Some("/tmp".into()); + cfg.opt_min_agents = Some(1); + + cfg + } + + fn find_wine(home: &Path) -> Option { + let candidates: &[PathBuf] = &[ + // macOS: bundled with the official MT5 app + PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"), + // macOS: CrossOver + home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64"), + // macOS: Homebrew (Apple Silicon) + PathBuf::from("/opt/homebrew/bin/wine64"), + PathBuf::from("/opt/homebrew/bin/wine"), + // macOS: Homebrew (Intel) + PathBuf::from("/usr/local/bin/wine64"), + PathBuf::from("/usr/local/bin/wine"), + // Linux + PathBuf::from("/usr/bin/wine64"), + PathBuf::from("/usr/bin/wine"), + ]; + candidates.iter() + .find(|p| p.exists()) + .map(|p| p.to_string_lossy().to_string()) + } + + fn find_mt5_dir(home: &Path) -> Option { + let mut candidates: Vec = vec![ + // macOS: official MT5 app Wine prefix + home.join("Library/Application Support/net.metaquotes.wine.metatrader5/drive_c/Program Files/MetaTrader 5"), + // Linux / macOS Homebrew Wine + home.join(".wine/drive_c/Program Files/MetaTrader 5"), + ]; + + // macOS CrossOver bottles: scan all bottles for an MT5 install + let bottles_root = home.join("Library/Application Support/CrossOver/Bottles"); + if bottles_root.is_dir() { + if let Ok(bottles) = fs::read_dir(&bottles_root) { + for bottle in bottles.filter_map(|e| e.ok()) { + let mt5 = bottle.path() + .join("drive_c/Program Files/MetaTrader 5"); + candidates.push(mt5); + } + } + } + + candidates.into_iter().find(|p| p.is_dir()) + } + + fn detect_display_mode() -> String { + // On macOS the MT5 native app handles display via its bundled Wine — + // no Xvfb needed. + if cfg!(target_os = "macos") { + return "gui".into(); + } + // Linux: use headless (Xvfb) when no X display is available. + if std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok() { + "gui".into() + } else { + "headless".into() + } + } + + // ── Persistence ────────────────────────────────────────────────────────── + + pub fn save(&self) -> Result<()> { + let path = Self::writable_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let none = || "~".to_string(); + let s = |v: &Option| v.clone().unwrap_or_else(none); + let u = |v: Option| v.map(|n| n.to_string()).unwrap_or_else(none); + + let content = format!( + "# mt5-quant configuration — auto-generated on first run\n\ + # Edit freely; the server will not overwrite an existing file.\n\ + \n\ + wine_executable: {wine}\n\ + terminal_dir: {term}\n\ + experts_dir: {exp}\n\ + tester_profiles_dir: {prof}\n\ + tester_cache_dir: {cache}\n\ + display_mode: {disp}\n\ + \n\ + backtest_symbol: {sym}\n\ + backtest_deposit: {dep}\n\ + backtest_currency: {cur}\n\ + backtest_leverage: {lev}\n\ + backtest_model: {mdl}\n\ + backtest_timeframe: {tf}\n\ + backtest_timeout: {to}\n\ + \n\ + opt_log_dir: {opt_log}\n\ + opt_min_agents: {opt_agents}\n", + wine = s(&self.wine_executable), + term = s(&self.terminal_dir), + exp = s(&self.experts_dir), + prof = s(&self.tester_profiles_dir), + cache = s(&self.tester_cache_dir), + disp = s(&self.display_mode), + sym = s(&self.backtest_symbol), + dep = u(self.backtest_deposit), + cur = s(&self.backtest_currency), + lev = u(self.backtest_leverage), + mdl = u(self.backtest_model), + tf = s(&self.backtest_timeframe), + to = u(self.backtest_timeout), + opt_log = s(&self.opt_log_dir), + opt_agents = u(self.opt_min_agents), + ); + + fs::write(&path, content)?; + tracing::info!("Config written to {}", path.display()); + Ok(()) + } + + // ── Parsing ─────────────────────────────────────────────────────────────── + + fn parse_file(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let mut map: HashMap = HashMap::new(); for line in content.lines() { let line = line.trim(); if line.starts_with('#') || !line.contains(':') { continue; } - if let Some((key, value)) = line.split_once(':') { let key = key.trim().to_string(); let value = value.trim() .trim_matches('"') .trim_matches('\'') .to_string(); - if !value.is_empty() && value != "null" && value != "~" { - config.insert(key, value); + map.insert(key, value); } } } Ok(Config { - wine_executable: config.get("wine_executable").cloned(), - terminal_dir: config.get("terminal_dir").cloned(), - experts_dir: config.get("experts_dir").cloned(), - tester_profiles_dir: config.get("tester_profiles_dir").cloned(), - tester_cache_dir: config.get("tester_cache_dir").cloned(), - display_mode: config.get("display_mode").cloned(), - backtest_symbol: config.get("backtest_symbol").cloned(), - backtest_deposit: config.get("backtest_deposit").and_then(|s| s.parse().ok()), - backtest_currency: config.get("backtest_currency").cloned(), - backtest_leverage: config.get("backtest_leverage").and_then(|s| s.parse().ok()), - backtest_model: config.get("backtest_model").and_then(|s| s.parse().ok()), - backtest_timeframe: config.get("backtest_timeframe").cloned(), - backtest_timeout: config.get("backtest_timeout").and_then(|s| s.parse().ok()), - opt_log_dir: config.get("opt_log_dir").cloned(), - opt_min_agents: config.get("opt_min_agents").and_then(|s| s.parse().ok()), - reports_dir: config.get("reports_dir").cloned(), - backtest_login: config.get("backtest_login").cloned(), - backtest_server: config.get("backtest_server").cloned(), - project_dir: config.get("project_dir").cloned(), + wine_executable: map.get("wine_executable").cloned(), + terminal_dir: map.get("terminal_dir").cloned(), + experts_dir: map.get("experts_dir").cloned(), + indicators_dir: map.get("indicators_dir").cloned(), + scripts_dir: map.get("scripts_dir").cloned(), + tester_profiles_dir: map.get("tester_profiles_dir").cloned(), + tester_cache_dir: map.get("tester_cache_dir").cloned(), + display_mode: map.get("display_mode").cloned(), + backtest_symbol: map.get("backtest_symbol").cloned(), + backtest_deposit: map.get("backtest_deposit").and_then(|s| s.parse().ok()), + backtest_currency: map.get("backtest_currency").cloned(), + backtest_leverage: map.get("backtest_leverage").and_then(|s| s.parse().ok()), + backtest_model: map.get("backtest_model").and_then(|s| s.parse().ok()), + backtest_timeframe: map.get("backtest_timeframe").cloned(), + 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()), + reports_dir: map.get("reports_dir").cloned(), + backtest_login: map.get("backtest_login").cloned(), + backtest_server: map.get("backtest_server").cloned(), + project_dir: map.get("project_dir").cloned(), }) } - pub fn get_config_path() -> std::path::PathBuf { - if let Ok(home) = std::env::var("MT5_MCP_HOME") { - Path::new(&home).join("config").join("mt5-quant.yaml") - } else { - let base_path = dirs::home_dir() - .unwrap_or_else(|| Path::new(".").to_path_buf()) - .join(".config") - .join("mt5-quant"); - - if base_path.join("config").join("mt5-quant.yaml").exists() { - base_path.join("config").join("mt5-quant.yaml") - } else { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap_or(Path::new(".")) - .join("config") - .join("mt5-quant.yaml") - } - } - } + // ── Accessors ──────────────────────────────────────────────────────────── pub fn get(&self, key: &str) -> String { match key { - "wine_executable" => self.wine_executable.clone().unwrap_or_default(), - "terminal_dir" => self.terminal_dir.clone().unwrap_or_default(), - "experts_dir" => self.experts_dir.clone().unwrap_or_default(), - "tester_profiles_dir" => self.tester_profiles_dir.clone().unwrap_or_default(), - "tester_cache_dir" => self.tester_cache_dir.clone().unwrap_or_default(), - "display_mode" => self.display_mode.clone().unwrap_or_else(|| "auto".to_string()), - "backtest_symbol" => self.backtest_symbol.clone().unwrap_or_default(), - "backtest_deposit" => self.backtest_deposit.unwrap_or(10000).to_string(), - "backtest_currency" => self.backtest_currency.clone().unwrap_or_else(|| "USD".to_string()), - "backtest_leverage" => self.backtest_leverage.unwrap_or(500).to_string(), - "backtest_model" => self.backtest_model.unwrap_or(0).to_string(), + "wine_executable" => self.wine_executable.clone().unwrap_or_default(), + "terminal_dir" => self.terminal_dir.clone().unwrap_or_default(), + "experts_dir" => self.experts_dir.clone().unwrap_or_default(), + "tester_profiles_dir"=> self.tester_profiles_dir.clone().unwrap_or_default(), + "tester_cache_dir" => self.tester_cache_dir.clone().unwrap_or_default(), + "display_mode" => self.display_mode.clone().unwrap_or_else(|| "auto".to_string()), + "backtest_symbol" => self.backtest_symbol.clone().unwrap_or_default(), + "backtest_deposit" => self.backtest_deposit.unwrap_or(10000).to_string(), + "backtest_currency" => self.backtest_currency.clone().unwrap_or_else(|| "USD".to_string()), + "backtest_leverage" => self.backtest_leverage.unwrap_or(500).to_string(), + "backtest_model" => self.backtest_model.unwrap_or(0).to_string(), "backtest_timeframe" => self.backtest_timeframe.clone().unwrap_or_else(|| "M5".to_string()), - "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(), - "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(), - "project_dir" => self.project_dir.clone().unwrap_or_default(), - _ => String::new(), + "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(), + "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(), + "project_dir" => self.project_dir.clone().unwrap_or_default(), + _ => String::new(), } } - pub fn reports_dir(&self) -> std::path::PathBuf { - Path::new(&self.get("reports_dir")).to_path_buf() + /// Root of the MCP installation: $MT5_MCP_HOME or ~/.config/mt5-quant + pub fn installation_dir() -> PathBuf { + if let Ok(home) = std::env::var("MT5_MCP_HOME") { + return Path::new(&home).to_path_buf(); + } + dirs::home_dir() + .unwrap_or_else(|| Path::new(".").to_path_buf()) + .join(".config") + .join("mt5-quant") } - pub fn mt5_dir(&self) -> Option { + /// Centralized report data directory (metadata + deals, no HTML). + /// Always inside the MCP installation dir, never in the project. + pub fn reports_dir(&self) -> PathBuf { + if let Some(dir) = &self.reports_dir { + let p = Path::new(dir); + if p.is_absolute() { + return p.to_path_buf(); + } + } + Self::installation_dir().join("reports") + } + + /// Path to the SQLite report registry. + pub fn db_path() -> PathBuf { + Self::installation_dir().join("reports.db") + } + + /// Temp directory for equity chart images, scoped per report. + pub fn charts_temp_dir(report_id: &str) -> PathBuf { + std::env::temp_dir() + .join("mt5-quant") + .join("charts") + .join(report_id) + } + + pub fn mt5_dir(&self) -> Option { self.terminal_dir.as_ref().map(|d| Path::new(d).to_path_buf()) } + + /// Scan Bases/*/history/ for symbol directories that contain at least one .hcc file. + /// Returns deduplicated, sorted list of symbol names available for backtesting. + pub fn discover_symbols(&self) -> Vec { + let mt5_dir = match self.mt5_dir() { + Some(d) => d, + None => return Vec::new(), + }; + + let bases_dir = mt5_dir.join("Bases"); + if !bases_dir.is_dir() { + return Vec::new(); + } + + let mut symbols = std::collections::HashSet::new(); + + // Bases/{server}/history/{symbol}/{year}.hcc + if let Ok(servers) = fs::read_dir(&bases_dir) { + for server in servers.filter_map(|e| e.ok()) { + let history_dir = server.path().join("history"); + if !history_dir.is_dir() { + continue; + } + if let Ok(sym_entries) = fs::read_dir(&history_dir) { + for sym_entry in sym_entries.filter_map(|e| e.ok()) { + let sym_path = sym_entry.path(); + if !sym_path.is_dir() { + continue; + } + // Only include if at least one .hcc file exists (has downloaded data) + let has_data = fs::read_dir(&sym_path) + .ok() + .map(|entries| { + entries.filter_map(|e| e.ok()).any(|e| { + e.path().extension() + .and_then(|x| x.to_str()) + .map(|x| x == "hcc") + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + if has_data { + if let Some(name) = sym_path.file_name().and_then(|n| n.to_str()) { + symbols.insert(name.to_string()); + } + } + } + } + } + } + + let mut sorted: Vec = symbols.into_iter().collect(); + sorted.sort(); + sorted + } } diff --git a/src/tools/definitions.rs b/src/tools/definitions.rs index 384e5ac..997d870 100644 --- a/src/tools/definitions.rs +++ b/src/tools/definitions.rs @@ -11,6 +11,8 @@ pub fn get_tools_list() -> Value { tool_verify_setup(), tool_list_symbols(), tool_list_experts(), + tool_list_indicators(), + tool_list_scripts(), tool_get_backtest_status(), tool_get_optimization_status(), tool_prune_reports(), @@ -187,6 +189,33 @@ fn tool_list_experts() -> Value { }) } +fn tool_list_indicators() -> Value { + json!({ + "name": "list_indicators", + "description": "List all custom indicators in MQL5/Indicators", + "inputSchema": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Optional name filter pattern" }, + "include_builtin": { "type": "boolean", "description": "Include built-in MT5 indicators", "default": false } + } + } + }) +} + +fn tool_list_scripts() -> Value { + json!({ + "name": "list_scripts", + "description": "List all scripts in MQL5/Scripts", + "inputSchema": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Optional name filter pattern" } + } + } + }) +} + fn tool_get_backtest_status() -> Value { json!({ "name": "get_backtest_status", diff --git a/src/tools/handlers.rs b/src/tools/handlers.rs deleted file mode 100644 index 2d214ff..0000000 --- a/src/tools/handlers.rs +++ /dev/null @@ -1,1468 +0,0 @@ -use anyhow::Result; -use chrono::Datelike; -use serde_json::{json, Value}; -use std::fs; -use std::path::Path; -use crate::analytics::DealAnalyzer; -use crate::compile::MqlCompiler; -use crate::models::Config; -use crate::models::deals::Deal; -use crate::models::metrics::Metrics; -use crate::optimization::{OptimizationParams, OptimizationParser, OptimizationRunner}; -use crate::pipeline::backtest::{BacktestParams, BacktestPipeline}; -use crate::storage::{ReportDb, ReportFilters}; - -#[derive(Debug)] -pub struct ToolHandler { - config: Config, -} - -impl ToolHandler { - pub fn new(config: Config) -> Self { - Self { config } - } - - pub async fn handle(&self, name: &str, args: &Value) -> Result { - match name { - "verify_setup" => self.handle_verify_setup().await, - "list_symbols" => self.handle_list_symbols().await, - "list_experts" => self.handle_list_experts(args).await, - "run_backtest" => self.handle_run_backtest(args).await, - "compile_ea" => self.handle_compile_ea(args).await, - "get_backtest_status" => self.handle_get_backtest_status(args).await, - "cache_status" => self.handle_cache_status().await, - "clean_cache" => self.handle_clean_cache(args).await, - "list_reports" => self.handle_list_reports(args).await, - "search_reports" => self.handle_search_reports(args).await, - "prune_reports" => self.handle_prune_reports(args).await, - "list_set_files" => self.handle_list_set_files().await, - "describe_sweep" => self.handle_describe_sweep(args).await, - // Optimization tools - "run_optimization" => self.handle_run_optimization(args).await, - "get_optimization_status" => self.handle_get_optimization_status(args).await, - "get_optimization_results" => self.handle_get_optimization_results(args).await, - "list_jobs" => self.handle_list_jobs().await, - // Analysis tools - "analyze_report" => self.handle_analyze_report(args).await, - "compare_baseline" => self.handle_compare_baseline(args).await, - // Set file tools - "read_set_file" => self.handle_read_set_file(args).await, - "write_set_file" => self.handle_write_set_file(args).await, - "patch_set_file" => self.handle_patch_set_file(args).await, - "clone_set_file" => self.handle_clone_set_file(args).await, - "diff_set_files" => self.handle_diff_set_files(args).await, - "set_from_optimization" => self.handle_set_from_optimization(args).await, - // Utility tools - "tail_log" => self.handle_tail_log(args).await, - "archive_report" => self.handle_archive_report(args).await, - "archive_all_reports" => self.handle_archive_all_reports(args).await, - "promote_to_baseline" => self.handle_promote_to_baseline(args).await, - "get_history" => self.handle_get_history(args).await, - "annotate_history" => self.handle_annotate_history(args).await, - "healthcheck" => self.handle_healthcheck(args).await, - _ => Ok(json!({ - "content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }], - "isError": true - })), - } - } - - async fn handle_verify_setup(&self) -> Result { - let mut checks = serde_json::Map::new(); - let mut all_ok = true; - - let config_path = Config::writable_config_path(); - checks.insert("config_file".into(), json!({ - "ok": config_path.exists(), - "path": config_path.to_string_lossy() - })); - - let check = |v: &Option, is_dir: bool| -> Value { - match v { - None => json!({ "ok": false, "detail": "not set" }), - Some(p) => { - let ok = if is_dir { Path::new(p).is_dir() } else { Path::new(p).exists() }; - json!({ "ok": ok, "detail": p }) - } - } - }; - - let wine_ok = self.config.wine_executable.as_ref() - .map(|p| Path::new(p).exists()).unwrap_or(false); - let term_ok = self.config.terminal_dir.as_ref() - .map(|p| Path::new(p).is_dir()).unwrap_or(false); - let _exp_ok = self.config.experts_dir.as_ref() - .map(|p| Path::new(p).is_dir()).unwrap_or(false); - let _prof_ok = self.config.tester_profiles_dir.as_ref() - .map(|p| Path::new(p).is_dir()).unwrap_or(false); - - if !wine_ok || !term_ok { all_ok = false; } - - checks.insert("wine_executable".into(), check(&self.config.wine_executable, false)); - checks.insert("terminal_dir".into(), check(&self.config.terminal_dir, true)); - checks.insert("experts_dir".into(), check(&self.config.experts_dir, true)); - checks.insert("tester_profiles_dir".into(),check(&self.config.tester_profiles_dir, true)); - checks.insert("display_mode".into(), json!(self.config.display_mode)); - checks.insert("reports_dir".into(), json!(self.config.reports_dir().to_string_lossy().to_string())); - checks.insert("db_path".into(), json!(Config::db_path().to_string_lossy().to_string())); - - let hint = if all_ok { - "Environment fully configured and ready".into() - } else if !config_path.exists() { - format!("Auto-discovery will run on next request. Config will be written to {}", config_path.display()) - } else { - format!("Fix missing paths in {}", config_path.display()) - }; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "all_ok": all_ok, - "config_path": config_path.to_string_lossy(), - "checks": checks, - "hint": hint, - }).to_string() }], - "isError": false - })) - } - - async fn handle_list_symbols(&self) -> Result { - let symbols = self.config.discover_symbols(); - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "count": symbols.len(), - "symbols": symbols, - "hint": if symbols.is_empty() { - "No history data found. Open MT5 and download tick data for the symbols you want to backtest." - } else { - "These symbols have local tick history and can be used for backtesting." - } - }).to_string() }], - "isError": false - })) - } - - async fn handle_list_experts(&self, args: &Value) -> Result { - let filter = args.get("filter").and_then(|v| v.as_str()); - - let mut experts = Vec::new(); - - if let Some(experts_dir) = &self.config.experts_dir { - if let Ok(entries) = fs::read_dir(experts_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "ex5").unwrap_or(false) { - let name = path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - - if let Some(f) = filter { - if !name.to_lowercase().contains(&f.to_lowercase()) { - continue; - } - } - - experts.push(json!({ - "name": name, - "path": path.to_string_lossy() - })); - } - } - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ "experts": experts }).to_string() }], - "isError": false - })) - } - - async fn handle_run_backtest(&self, args: &Value) -> Result { - let expert = args.get("expert") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("expert is required"))?; - - // ── Symbol pre-flight ──────────────────────────────────────────────── - let requested_symbol = args.get("symbol") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let available = self.config.discover_symbols(); - - let symbol = if requested_symbol.is_empty() { - // Use config default, fallback to first available - let default = self.config.backtest_symbol.clone() - .unwrap_or_else(|| "XAUUSD".to_string()); - if available.contains(&default) { - default - } else if let Some(first) = available.first() { - tracing::warn!("Default symbol {} not found; using {}", default, first); - first.clone() - } else { - default - } - } else { - // Validate requested symbol - if !available.is_empty() && !available.contains(&requested_symbol.to_string()) { - return Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "error": format!("Symbol '{}' has no local history data.", requested_symbol), - "available_symbols": available, - "hint": "Use list_symbols to see all available symbols." - }).to_string() }], - "isError": true - })); - } - requested_symbol.to_string() - }; - - // ── Date defaulting: past complete calendar month ──────────────────── - let (from_date, to_date) = { - let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or(""); - let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or(""); - if f.is_empty() || t.is_empty() { - past_complete_month() - } else { - (f.to_string(), t.to_string()) - } - }; - - let params = BacktestParams { - expert: expert.to_string(), - symbol: symbol.to_string(), - from_date: from_date.to_string(), - to_date: to_date.to_string(), - timeframe: args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(), - deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32, - model: args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8, - leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32, - set_file: args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()), - skip_compile: args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false), - skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false), - skip_analyze: args.get("skip_analyze").and_then(|v| v.as_bool()).unwrap_or(false), - deep_analyze: args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false), - shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(false), - kill_existing: args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(false), - timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900), - gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false), - }; - - let pipeline = BacktestPipeline::new(self.config.clone()); - let result = pipeline.run(params).await?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": result.success, - "report_dir": result.report_dir.to_string_lossy(), - "duration_seconds": result.duration_seconds, - "message": result.message - }).to_string() }], - "isError": !result.success - })) - } - - async fn handle_compile_ea(&self, args: &Value) -> Result { - let expert_path = args.get("expert_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("expert_path is required"))?; - - let compiler = MqlCompiler::new(self.config.clone()); - let result = compiler.compile(expert_path)?; - - if result.success { - let path_str = result.ex5_path.as_ref().map(|p| p.to_string_lossy().to_string()); - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "ex5_path": path_str, - "binary_size": result.binary_size, - "warnings": result.warnings.len() - }).to_string() }], - "isError": false - })) - } else { - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": false, - "errors": result.errors - }).to_string() }], - "isError": true - })) - } - } - - async fn handle_get_backtest_status(&self, args: &Value) -> Result { - let report_dir = args.get("report_dir") - .and_then(|v| v.as_str()) - .unwrap_or("latest"); - - let progress_file = Path::new(report_dir).join("progress.log"); - - let status = if progress_file.exists() { - if let Ok(content) = fs::read_to_string(&progress_file) { - let last_line = content.lines().last().unwrap_or(""); - if last_line.contains("DONE") { - "completed" - } else { - "running" - } - } else { - "unknown" - } - } else { - "not_started" - }; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "report_dir": report_dir, - "status": status - }).to_string() }], - "isError": false - })) - } - - async fn handle_cache_status(&self) -> Result { - let cache_dir = self.config.tester_cache_dir.as_ref() - .map(|s| Path::new(s)) - .filter(|p| p.exists()); - - let mut total_size: u64 = 0; - let mut symbols = Vec::new(); - - if let Some(dir) = cache_dir { - for entry in walkdir::WalkDir::new(dir).max_depth(2) { - if let Ok(entry) = entry { - if entry.file_type().is_dir() { - if let Some(name) = entry.file_name().to_str() { - symbols.push(name.to_string()); - } - } else { - if let Ok(meta) = entry.metadata() { - total_size += meta.len(); - } - } - } - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "cache_dir": cache_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(), - "total_bytes": total_size, - "symbols": symbols - }).to_string() }], - "isError": false - })) - } - - async fn handle_clean_cache(&self, args: &Value) -> Result { - let _symbol = args.get("symbol").and_then(|v| v.as_str()); - let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false); - - let cache_dir = self.config.tester_cache_dir.as_ref() - .map(|s| Path::new(s)) - .filter(|p| p.exists()); - - let mut bytes_freed: u64 = 0; - - if let Some(dir) = cache_dir { - for entry in walkdir::WalkDir::new(dir) { - if let Ok(entry) = entry { - let path = entry.path(); - if path.extension().map(|e| e == "tst").unwrap_or(false) { - if let Ok(meta) = entry.metadata() { - bytes_freed += meta.len(); - if !dry_run { - let _ = fs::remove_file(path); - } - } - } - } - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "bytes_freed": bytes_freed, - "dry_run": dry_run - }).to_string() }], - "isError": false - })) - } - - async fn handle_list_reports(&self, args: &Value) -> Result { - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(30) as usize; - - let db = ReportDb::new(&Config::db_path()); - if let Err(e) = db.init() { - return Ok(json!({ - "content": [{ "type": "text", "text": format!("DB error: {}", e) }], - "isError": true - })); - } - - let filters = ReportFilters::default(); - let entries = db.list(limit, &filters)?; - let total = db.count().unwrap_or(0); - - let reports: Vec = entries - .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "charts_dir": e.charts_dir, - "report_dir": e.report_dir, - "verdict": e.verdict, - "tags": e.tags, - "notes": e.notes, - })) - .collect(); - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "total": total, - "returned": reports.len(), - "reports": reports, - }).to_string() }], - "isError": false - })) - } - - async fn handle_search_reports(&self, args: &Value) -> Result { - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; - - let db = ReportDb::new(&Config::db_path()); - db.init()?; - - let filters = ReportFilters { - expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()), - symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()), - timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()), - created_after: args.get("after").and_then(|v| v.as_str()).map(|s| s.to_string()), - min_profit: args.get("min_profit").and_then(|v| v.as_f64()), - max_dd: args.get("max_dd").and_then(|v| v.as_f64()), - verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), - }; - - let entries = db.list(limit, &filters)?; - - let reports: Vec = entries - .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "set_snapshot": e.set_snapshot_path, - "charts_dir": e.charts_dir, - "report_dir": e.report_dir, - "verdict": e.verdict, - "tags": e.tags, - "notes": e.notes, - })) - .collect(); - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "matched": reports.len(), - "reports": reports, - }).to_string() }], - "isError": false - })) - } - - async fn handle_prune_reports(&self, args: &Value) -> Result { - let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(20) as usize; - let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false); - - let db = ReportDb::new(&Config::db_path()); - db.init()?; - - let purgeable = db.list_purgeable(keep_last)?; - let mut pruned = 0; - let mut freed_bytes: u64 = 0; - - for (id, report_dir, charts_dir) in &purgeable { - if !dry_run { - // Delete report data directory - freed_bytes += dir_size(std::path::Path::new(report_dir)); - let _ = fs::remove_dir_all(report_dir); - - // Charts are in OS temp — remove if present - if let Some(cd) = charts_dir { - freed_bytes += dir_size(std::path::Path::new(cd)); - let _ = fs::remove_dir_all(cd); - } - - let _ = db.delete_entry(id); - pruned += 1; - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "pruned": pruned, - "would_prune": purgeable.len(), - "kept": keep_last, - "freed_bytes": freed_bytes, - "dry_run": dry_run, - }).to_string() }], - "isError": false - })) - } - - async fn handle_list_set_files(&self) -> Result { - let mut set_files = Vec::new(); - - if let Some(tester_dir) = &self.config.tester_profiles_dir { - if let Ok(entries) = fs::read_dir(tester_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "set").unwrap_or(false) { - let name = path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - - let content = fs::read_to_string(&path).unwrap_or_default(); - let param_count = content.lines().filter(|l| l.contains(':')).count(); - let sweep_count = content.lines().filter(|l| l.contains("||Y")).count(); - - set_files.push(json!({ - "name": name, - "path": path.to_string_lossy(), - "param_count": param_count, - "sweep_count": sweep_count - })); - } - } - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ "set_files": set_files }).to_string() }], - "isError": false - })) - } - - async fn handle_describe_sweep(&self, args: &Value) -> Result { - 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 sweep_params = serde_json::Map::new(); - - for line in content.lines() { - if let Some((key, value)) = line.split_once(':') { - let key = key.trim(); - let value = value.trim(); - - if value.contains("||Y") { - if let Some((from_val, to_val)) = value.split_once("..") { - sweep_params.insert(key.to_string(), json!({ - "from": from_val.trim(), - "to": to_val.trim().replace("||Y", ""), - "step": 1.0 - })); - } - } - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "path": path, - "sweep_params": sweep_params - }).to_string() }], - "isError": false - })) - } - - // Optimization handlers - async fn handle_run_optimization(&self, args: &Value) -> Result { - let expert = args.get("expert") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("expert is required"))?; - - let set_file = args.get("set_file") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("set_file 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"))?; - - let params = OptimizationParams { - expert: expert.to_string(), - set_file: set_file.to_string(), - symbol: args.get("symbol").and_then(|v| v.as_str()).unwrap_or("XAUUSD").to_string(), - from_date: from_date.to_string(), - to_date: to_date.to_string(), - deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32, - model: 0, // Always 0 for optimization - 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(), - }; - - let runner = OptimizationRunner::new(self.config.clone()); - let result = runner.run(params).await?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": result.success, - "job_id": result.job_id, - "pid": result.pid, - "log_file": result.log_file.to_string_lossy(), - "combinations": result.combinations, - "message": result.message, - }).to_string() }], - "isError": false - })) - } - - async fn handle_get_optimization_status(&self, args: &Value) -> Result { - let job_id = args.get("job_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("job_id is required"))?; - - let runner = OptimizationRunner::new(self.config.clone()); - let status = runner.get_job_status(job_id)?; - - Ok(json!({ - "content": [{ "type": "text", "text": status.to_string() }], - "isError": false - })) - } - - async fn handle_get_optimization_results(&self, args: &Value) -> Result { - let job_id = args.get("job_id") - .and_then(|v| v.as_str()); - - let file = args.get("file") - .and_then(|v| v.as_str()); - - let parser = OptimizationParser::new(); - - let passes = if let Some(jid) = job_id { - parser.parse_job(jid)? - } else if let Some(f) = file { - parser.parse_file(std::path::Path::new(f))? - } else { - return Err(anyhow::anyhow!("Either job_id or file is required")); - }; - - let sort_by = args.get("sort").and_then(|v| v.as_str()).unwrap_or("profit"); - let top_n = args.get("top").and_then(|v| v.as_u64()).unwrap_or(30) as usize; - - // Find best pass - let best = parser.find_best_pass(&passes, sort_by); - - let mut sorted_passes = passes.clone(); - sorted_passes.sort_by(|a, b| b.profit.partial_cmp(&a.profit).unwrap()); - sorted_passes.truncate(top_n); - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "total_passes": passes.len(), - "top_passes": sorted_passes, - "best": best, - "sort_by": sort_by, - }).to_string() }], - "isError": false - })) - } - - async fn handle_list_jobs(&self) -> Result { - let runner = OptimizationRunner::new(self.config.clone()); - let jobs = runner.list_jobs()?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ "jobs": jobs }).to_string() }], - "isError": false - })) - } - - // Analysis handlers - async fn handle_analyze_report(&self, args: &Value) -> Result { - let report_dir = args.get("report_dir") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; - - let deals_csv = std::path::Path::new(report_dir).join("deals.csv"); - let metrics_json = std::path::Path::new(report_dir).join("metrics.json"); - - if !deals_csv.exists() { - return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir)); - } - - // Read deals - let deals = self.read_deals_from_csv(&deals_csv)?; - - // Read metrics - let metrics = if metrics_json.exists() { - let content = fs::read_to_string(&metrics_json)?; - serde_json::from_str(&content)? - } else { - Metrics::default() - }; - - let _strategy = args.get("strategy").and_then(|v| v.as_str()).unwrap_or("grid"); - let _deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false); - - let analyzer = DealAnalyzer::new(); - let result = analyzer.analyze(&deals, &metrics); - - // Write analysis.json - let analysis_path = std::path::Path::new(report_dir).join("analysis.json"); - fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "analysis_file": analysis_path.to_string_lossy(), - "summary": result, - }).to_string() }], - "isError": false - })) - } - - fn read_deals_from_csv(&self, path: &std::path::Path) -> Result> { - let content = fs::read_to_string(path)?; - let mut deals = Vec::new(); - - let mut lines = content.lines(); - let _header = lines.next(); // Skip header - - for line in lines { - let parts: Vec<&str> = line.split(',').collect(); - if parts.len() >= 12 { - deals.push(Deal { - time: parts[0].to_string(), - deal: parts[1].to_string(), - symbol: parts[2].to_string(), - deal_type: parts[3].to_string(), - entry: parts[4].to_string(), - volume: parts[5].parse().unwrap_or(0.0), - price: parts[6].parse().unwrap_or(0.0), - order: parts[7].to_string(), - commission: parts[8].parse().unwrap_or(0.0), - swap: parts[9].parse().unwrap_or(0.0), - profit: parts[10].parse().unwrap_or(0.0), - balance: parts[11].parse().unwrap_or(0.0), - comment: parts.get(12).unwrap_or(&"").to_string(), - magic: parts.get(13).map(|s| s.to_string()), - }); - } - } - - Ok(deals) - } - - async fn handle_compare_baseline(&self, args: &Value) -> Result { - let report_dir = args.get("report_dir") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; - - let baseline_path = std::path::Path::new("config/baseline.json"); - let metrics_path = std::path::Path::new(report_dir).join("metrics.json"); - - if !baseline_path.exists() { - return Ok(json!({ - "content": [{ "type": "text", "text": "No baseline.json found in config/" }], - "isError": false - })); - } - - let baseline: Value = serde_json::from_str(&fs::read_to_string(baseline_path)?)?; - let current: Value = serde_json::from_str(&fs::read_to_string(metrics_path)?)?; - - let comparison = json!({ - "baseline": baseline, - "current": current, - "improvements": { - "profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0) - - baseline.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0), - "drawdown": current.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0) - - baseline.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0), - } - }); - - Ok(json!({ - "content": [{ "type": "text", "text": comparison.to_string() }], - "isError": false - })) - } - - // Set file handlers - async fn handle_read_set_file(&self, args: &Value) -> Result { - 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 params = serde_json::Map::new(); - - for line in content.lines() { - if let Some((key, value)) = line.split_once(':') { - let key = key.trim(); - let value = value.trim(); - - if value.contains("||Y") { - let parts: Vec<&str> = value.split("||").collect(); - if parts.len() >= 5 { - params.insert(key.to_string(), json!({ - "value": parts[0], - "from": parts[1], - "step": parts[2], - "to": parts[3], - "optimize": true, - })); - } - } else { - params.insert(key.to_string(), json!({ "value": value, "optimize": false })); - } - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "path": path, - "parameters": params, - }).to_string() }], - "isError": false - })) - } - - async fn handle_write_set_file(&self, args: &Value) -> Result { - let path = args.get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("path is required"))?; - - let params = args.get("parameters") - .and_then(|v| v.as_object()) - .ok_or_else(|| anyhow::anyhow!("parameters object is required"))?; - - let mut lines = Vec::new(); - for (key, value) in params { - if let Some(obj) = value.as_object() { - if obj.get("optimize").and_then(|v| v.as_bool()).unwrap_or(false) { - let from_val = obj.get("from").and_then(|v| v.as_str()).unwrap_or("0"); - let step = obj.get("step").and_then(|v| v.as_str()).unwrap_or("1"); - let to_val = obj.get("to").and_then(|v| v.as_str()).unwrap_or("0"); - lines.push(format!("{}={}||{}||{}||{}||Y", key, obj.get("value").and_then(|v| v.as_str()).unwrap_or("0"), from_val, step, to_val)); - } else { - lines.push(format!("{}={}", key, obj.get("value").and_then(|v| v.as_str()).unwrap_or("0"))); - } - } - } - - fs::write(path, lines.join("\n"))?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "path": path, - "parameters_written": lines.len(), - }).to_string() }], - "isError": false - })) - } - - async fn handle_patch_set_file(&self, args: &Value) -> Result { - let path = args.get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("path is required"))?; - - let patches = args.get("patches") - .and_then(|v| v.as_object()) - .ok_or_else(|| anyhow::anyhow!("patches object is required"))?; - - // Read existing file - let content = fs::read_to_string(path)?; - let mut lines: Vec = content.lines().map(|s| s.to_string()).collect(); - let mut patched_count = 0; - - for (key, value) in patches { - let new_value = if let Some(s) = value.as_str() { - s.to_string() - } else if let Some(n) = value.as_f64() { - n.to_string() - } else if let Some(b) = value.as_bool() { - if b { "true".to_string() } else { "false".to_string() } - } else { - value.to_string() - }; - - // Find and patch the parameter - let mut found = false; - for line in &mut lines { - if line.starts_with(&format!("{}:", key)) { - *line = format!("{}: {}", key, new_value); - found = true; - patched_count += 1; - break; - } else if line.starts_with(&format!("{}=", key)) { - *line = format!("{}={}", key, new_value); - found = true; - patched_count += 1; - break; - } - } - - // If not found, add it - if !found { - lines.push(format!("{}: {}", key, new_value)); - patched_count += 1; - } - } - - fs::write(path, lines.join("\n"))?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "path": path, - "parameters_patched": patched_count, - }).to_string() }], - "isError": false - })) - } - - async fn handle_clone_set_file(&self, args: &Value) -> Result { - let source = args.get("source") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("source is required"))?; - - let destination = args.get("destination") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("destination is required"))?; - - fs::copy(source, destination)?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "source": source, - "destination": destination, - }).to_string() }], - "isError": false - })) - } - - async fn handle_diff_set_files(&self, args: &Value) -> Result { - let file_a = args.get("file_a") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("file_a is required"))?; - - let file_b = args.get("file_b") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("file_b is required"))?; - - let content_a = fs::read_to_string(file_a)?; - let content_b = fs::read_to_string(file_b)?; - - let mut differences = Vec::new(); - - for (i, (line_a, line_b)) in content_a.lines().zip(content_b.lines()).enumerate() { - if line_a != line_b { - differences.push(json!({ - "line": i + 1, - "file_a": line_a, - "file_b": line_b, - })); - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "file_a": file_a, - "file_b": file_b, - "differences": differences, - "total_differences": differences.len(), - }).to_string() }], - "isError": false - })) - } - - async fn handle_set_from_optimization(&self, args: &Value) -> Result { - let path = args.get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("path is required"))?; - - let params = args.get("params") - .and_then(|v| v.as_object()) - .ok_or_else(|| anyhow::anyhow!("params is required"))?; - - let mut lines = Vec::new(); - for (key, value) in params { - if let Some(val_str) = value.as_str() { - lines.push(format!("{}={}", key, val_str)); - } - } - - fs::write(path, lines.join("\n"))?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "path": path, - "parameters_written": lines.len(), - }).to_string() }], - "isError": false - })) - } - - // Utility handlers - async fn handle_tail_log(&self, args: &Value) -> Result { - let job_id = args.get("job_id") - .and_then(|v| v.as_str()); - - 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 = std::path::Path::new(".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()) - } else { - args.get("file").and_then(|v| v.as_str()).map(|s| s.to_string()) - }; - - let log_path = log_path.ok_or_else(|| anyhow::anyhow!("Could not determine log file"))?; - - let content = fs::read_to_string(&log_path)?; - let all_lines: Vec<&str> = content.lines().collect(); - let start = all_lines.len().saturating_sub(lines); - let last_lines = &all_lines[start..]; - - Ok(json!({ - "content": [{ "type": "text", "text": last_lines.join("\n") }], - "isError": false - })) - } - - async fn handle_archive_report(&self, args: &Value) -> Result { - let report_dir = args.get("report_dir") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; - - let delete_after = args.get("delete_after").and_then(|v| v.as_bool()).unwrap_or(false); - - let history_dir = std::path::Path::new(".mt5mcp_history"); - fs::create_dir_all(history_dir)?; - - let report_name = std::path::Path::new(report_dir).file_name() - .and_then(|s| s.to_str()) - .unwrap_or("unknown"); - - let archive_path = history_dir.join(format!("{}.tar.gz", report_name)); - - // Create tarball - let status = std::process::Command::new("tar") - .args(["-czf", &archive_path.to_string_lossy(), "-C", - std::path::Path::new(report_dir).parent().unwrap().to_str().unwrap(), - report_name]) - .status()?; - - if delete_after && status.success() { - fs::remove_dir_all(report_dir)?; - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": status.success(), - "archive_path": archive_path.to_string_lossy(), - "deleted": delete_after && status.success(), - }).to_string() }], - "isError": false - })) - } - - async fn handle_archive_all_reports(&self, args: &Value) -> Result { - let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(10) as usize; - - let reports_dir = self.config.reports_dir(); - let history_dir = std::path::Path::new(".mt5mcp_history"); - fs::create_dir_all(history_dir)?; - - let mut archived = 0; - - if let Ok(entries) = fs::read_dir(&reports_dir) { - let mut entries: Vec<_> = entries.flatten().collect(); - entries.sort_by(|a, b| { - b.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH) - .cmp(&a.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH)) - }); - - for entry in entries.into_iter().skip(keep_last) { - let path = entry.path(); - if path.is_dir() && !path.to_string_lossy().ends_with("_opt") { - let report_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("unknown"); - let archive_path = history_dir.join(format!("{}.tar.gz", report_name)); - - let _ = std::process::Command::new("tar") - .args(["-czf", &archive_path.to_string_lossy(), "-C", - path.parent().unwrap().to_str().unwrap(), report_name]) - .status(); - - let _ = fs::remove_dir_all(&path); - archived += 1; - } - } - } - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "archived": archived, - "kept": keep_last, - }).to_string() }], - "isError": false - })) - } - - async fn handle_promote_to_baseline(&self, args: &Value) -> Result { - let report_dir = args.get("report_dir") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; - - let metrics_path = std::path::Path::new(report_dir).join("metrics.json"); - let baseline_path = std::path::Path::new("config/baseline.json"); - - fs::copy(&metrics_path, &baseline_path)?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": true, - "baseline_file": baseline_path.to_string_lossy(), - "source": metrics_path.to_string_lossy(), - }).to_string() }], - "isError": false - })) - } - - async fn handle_get_history(&self, args: &Value) -> Result { - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; - - let db = ReportDb::new(&Config::db_path()); - db.init()?; - - let filters = ReportFilters { - expert: args.get("ea").and_then(|v| v.as_str()).map(|s| s.to_string()), - symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()), - verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), - ..Default::default() - }; - - let entries = db.list(limit, &filters)?; - let total = db.count().unwrap_or(0); - - let history: Vec = entries - .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "set_file": e.set_file_original, - "set_snapshot": e.set_snapshot_path, - "charts_dir": e.charts_dir, - "verdict": e.verdict, - "tags": e.tags, - "notes": e.notes, - })) - .collect(); - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "total": total, - "returned": history.len(), - "history": history, - }).to_string() }], - "isError": false - })) - } - - async fn handle_annotate_history(&self, args: &Value) -> Result { - let report_id = args - .get("history_id") - .or_else(|| args.get("report_name")) - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("history_id is required"))?; - - let notes = args.get("notes").and_then(|v| v.as_str()); - let verdict = args.get("verdict").and_then(|v| v.as_str()); - let tags: Option> = args - .get("tags") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()); - - let db = ReportDb::new(&Config::db_path()); - db.init()?; - - let updated = db.annotate(report_id, notes, tags, verdict)?; - - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": updated, - "id": report_id, - "notes": notes, - "verdict": verdict, - }).to_string() }], - "isError": false - })) - } - - async fn handle_healthcheck(&self, args: &Value) -> Result { - let detailed = args.get("detailed").and_then(|v| v.as_bool()).unwrap_or(false); - - // OS Detection - let os_info = Self::detect_os(); - - // Configuration validation - let config_status = self.validate_configuration().await; - - // Build health status - let mut healthy = true; - let mut issues = Vec::new(); - - if !config_status.config_exists { - healthy = false; - issues.push("Configuration file not found - run setup to configure"); - } - if !config_status.wine_found { - healthy = false; - issues.push("Wine/CrossOver not found - required for MT5 execution"); - } - if !config_status.mt5_dir_found { - healthy = false; - issues.push("MT5 directory not found - check installation"); - } - - let mut response = json!({ - "success": true, - "healthy": healthy, - "timestamp": chrono::Utc::now().to_rfc3339(), - "os": { - "platform": os_info.platform, - "arch": os_info.arch, - "name": os_info.name, - "is_macos": os_info.is_macos, - "is_linux": os_info.is_linux, - }, - "configuration": { - "config_exists": config_status.config_exists, - "config_path": config_status.config_path, - "wine_found": config_status.wine_found, - "wine_path": config_status.wine_path, - "mt5_dir_found": config_status.mt5_dir_found, - "mt5_dir": config_status.mt5_dir, - "experts_dir_found": config_status.experts_dir_found, - "tester_profiles_found": config_status.tester_profiles_found, - }, - "issues": issues, - }); - - // Add detailed info if requested - if detailed { - response["detailed"] = json!({ - "rust_version": Self::get_rust_version(), - "exe_path": std::env::current_exe() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()), - "working_dir": std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()), - "env_vars": { - "DISPLAY": std::env::var("DISPLAY").ok(), - "WINEPREFIX": std::env::var("WINEPREFIX").ok(), - "HOME": std::env::var("HOME").ok(), - }, - }); - } - - Ok(json!({ - "content": [{ - "type": "text", - "text": response.to_string() - }], - "isError": false - })) - } -} - -#[derive(Debug)] -struct OsInfo { - platform: String, - arch: String, - name: String, - is_macos: bool, - is_linux: bool, -} - -#[derive(Debug)] -struct ConfigStatus { - config_exists: bool, - config_path: String, - wine_found: bool, - wine_path: Option, - mt5_dir_found: bool, - mt5_dir: Option, - experts_dir_found: bool, - tester_profiles_found: bool, -} - -impl ToolHandler { - fn detect_os() -> OsInfo { - let platform = std::env::consts::OS.to_string(); - let arch = std::env::consts::ARCH.to_string(); - - let is_macos = platform == "macos"; - let is_linux = platform == "linux"; - - let name = if is_macos { - Self::get_macos_version().unwrap_or_else(|| "macOS".to_string()) - } else if is_linux { - Self::get_linux_distro().unwrap_or_else(|| "Linux".to_string()) - } else { - platform.clone() - }; - - OsInfo { - platform, - arch, - name, - is_macos, - is_linux, - } - } - - fn get_macos_version() -> Option { - std::process::Command::new("sw_vers") - .arg("-productVersion") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| format!("macOS {}", s.trim())) - } - - fn get_linux_distro() -> Option { - std::fs::read_to_string("/etc/os-release") - .ok() - .and_then(|content| { - content.lines() - .find(|l| l.starts_with("PRETTY_NAME=")) - .map(|l| l.replace("PRETTY_NAME=", "").trim_matches('"').to_string()) - }) - } - - fn get_rust_version() -> Option { - std::process::Command::new("rustc") - .arg("--version") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - } - - async fn validate_configuration(&self) -> ConfigStatus { - let config_path = Config::writable_config_path(); - let config_exists = config_path.exists(); - - let wine_found = self.config.wine_executable.as_ref() - .map(|p| Path::new(p).exists()) - .unwrap_or(false); - let wine_path = self.config.wine_executable.clone(); - - let mt5_dir_found = self.config.terminal_dir.as_ref() - .map(|p| Path::new(p).is_dir()) - .unwrap_or(false); - let mt5_dir = self.config.terminal_dir.clone(); - - let experts_dir_found = self.config.experts_dir.as_ref() - .map(|p| Path::new(p).is_dir()) - .unwrap_or(false); - - let tester_profiles_found = self.config.tester_profiles_dir.as_ref() - .map(|p| Path::new(p).is_dir()) - .unwrap_or(false); - - ConfigStatus { - config_exists, - config_path: config_path.to_string_lossy().to_string(), - wine_found, - wine_path, - mt5_dir_found, - mt5_dir, - experts_dir_found, - tester_profiles_found, - } - } -} - -/// Returns (from_date, to_date) for the last fully-elapsed calendar month in MT5 format (YYYY.MM.DD). -fn past_complete_month() -> (String, String) { - let now = chrono::Utc::now(); - let first_of_this_month = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1) - .unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); - let last_of_prev = first_of_this_month.pred_opt() - .unwrap_or(first_of_this_month); - let first_of_prev = chrono::NaiveDate::from_ymd_opt(last_of_prev.year(), last_of_prev.month(), 1) - .unwrap_or(last_of_prev); - ( - first_of_prev.format("%Y.%m.%d").to_string(), - last_of_prev.format("%Y.%m.%d").to_string(), - ) -} - -fn dir_size(path: &std::path::Path) -> u64 { - if !path.exists() { - return 0; - } - walkdir::WalkDir::new(path) - .into_iter() - .filter_map(|e| e.ok()) - .filter_map(|e| e.metadata().ok()) - .filter(|m| m.is_file()) - .map(|m| m.len()) - .sum() -} diff --git a/src/tools/handlers/analysis.rs b/src/tools/handlers/analysis.rs new file mode 100644 index 0000000..cd93041 --- /dev/null +++ b/src/tools/handlers/analysis.rs @@ -0,0 +1,117 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use crate::analytics::DealAnalyzer; +use crate::models::deals::Deal; +use crate::models::metrics::Metrics; + +pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let deals_csv = Path::new(report_dir).join("deals.csv"); + let metrics_json = Path::new(report_dir).join("metrics.json"); + + if !deals_csv.exists() { + return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir)); + } + + let deals = read_deals_from_csv(&deals_csv)?; + + let metrics = if metrics_json.exists() { + let content = fs::read_to_string(&metrics_json)?; + serde_json::from_str(&content)? + } else { + Metrics::default() + }; + + let _strategy = args.get("strategy").and_then(|v| v.as_str()).unwrap_or("grid"); + let _deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false); + + let analyzer = DealAnalyzer::new(); + let result = analyzer.analyze(&deals, &metrics); + + let analysis_path = Path::new(report_dir).join("analysis.json"); + fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "analysis_file": analysis_path.to_string_lossy(), + "summary": result, + }).to_string() }], + "isError": false + })) +} + +fn read_deals_from_csv(path: &Path) -> Result> { + let content = fs::read_to_string(path)?; + let mut deals = Vec::new(); + + let mut lines = content.lines(); + let _header = lines.next(); + + for line in lines { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() >= 12 { + deals.push(Deal { + time: parts[0].to_string(), + deal: parts[1].to_string(), + symbol: parts[2].to_string(), + deal_type: parts[3].to_string(), + entry: parts[4].to_string(), + volume: parts[5].parse().unwrap_or(0.0), + price: parts[6].parse().unwrap_or(0.0), + order: parts[7].to_string(), + commission: parts[8].parse().unwrap_or(0.0), + swap: parts[9].parse().unwrap_or(0.0), + profit: parts[10].parse().unwrap_or(0.0), + balance: parts[11].parse().unwrap_or(0.0), + comment: parts.get(12).unwrap_or(&"").to_string(), + magic: parts.get(13).map(|s| s.to_string()), + }); + } + } + + Ok(deals) +} + +pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let baseline_path = Path::new("config/baseline.json"); + let metrics_path = Path::new(report_dir).join("metrics.json"); + + if !baseline_path.exists() { + return Ok(json!({ + "content": [{ "type": "text", "text": "No baseline.json found in config/" }], + "isError": false + })); + } + + let baseline: Value = serde_json::from_str(&fs::read_to_string(baseline_path)?)?; + let current: Value = serde_json::from_str(&fs::read_to_string(metrics_path)?)?; + + let comparison = json!({ + "baseline": baseline, + "current": current, + "improvements": { + "profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0) + - baseline.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0), + "drawdown": current.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0) + - baseline.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0), + } + }); + + Ok(json!({ + "content": [{ "type": "text", "text": comparison.to_string() }], + "isError": false + })) +} + +// Import Config for analysis module +use crate::models::Config; diff --git a/src/tools/handlers/backtest.rs b/src/tools/handlers/backtest.rs new file mode 100644 index 0000000..ec99434 --- /dev/null +++ b/src/tools/handlers/backtest.rs @@ -0,0 +1,191 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use crate::models::Config; +use crate::pipeline::backtest::{BacktestParams, BacktestPipeline}; + +pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result { + let expert = args.get("expert") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("expert is required"))?; + + // Symbol pre-flight + let requested_symbol = args.get("symbol") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let available = config.discover_symbols(); + + let symbol = if requested_symbol.is_empty() { + let default = config.backtest_symbol.clone() + .unwrap_or_else(|| "XAUUSD".to_string()); + if available.contains(&default) { + default + } else if let Some(first) = available.first() { + tracing::warn!("Default symbol {} not found; using {}", default, first); + first.clone() + } else { + default + } + } else { + if !available.is_empty() && !available.contains(&requested_symbol.to_string()) { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "error": format!("Symbol '{}' has no local history data.", requested_symbol), + "available_symbols": available, + "hint": "Use list_symbols to see all available symbols." + }).to_string() }], + "isError": true + })); + } + requested_symbol.to_string() + }; + + // Date defaulting: past complete calendar month + let (from_date, to_date) = { + let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or(""); + let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or(""); + if f.is_empty() || t.is_empty() { + super::past_complete_month() + } else { + (f.to_string(), t.to_string()) + } + }; + + let params = BacktestParams { + expert: expert.to_string(), + symbol: symbol.to_string(), + from_date: from_date.to_string(), + to_date: to_date.to_string(), + timeframe: args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(), + deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32, + model: args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8, + leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32, + set_file: args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()), + skip_compile: args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false), + skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false), + skip_analyze: args.get("skip_analyze").and_then(|v| v.as_bool()).unwrap_or(false), + deep_analyze: args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false), + shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(false), + kill_existing: args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(false), + timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900), + gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false), + }; + + let pipeline = BacktestPipeline::new(config.clone()); + let result = pipeline.run(params).await?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": result.success, + "report_dir": result.report_dir.to_string_lossy(), + "duration_seconds": result.duration_seconds, + "message": result.message + }).to_string() }], + "isError": !result.success + })) +} + +pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .unwrap_or("latest"); + + let progress_file = Path::new(report_dir).join("progress.log"); + + let status = if progress_file.exists() { + if let Ok(content) = fs::read_to_string(&progress_file) { + let last_line = content.lines().last().unwrap_or(""); + if last_line.contains("DONE") { + "completed" + } else { + "running" + } + } else { + "unknown" + } + } else { + "not_started" + }; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "report_dir": report_dir, + "status": status + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_cache_status(config: &Config) -> Result { + let cache_dir = config.tester_cache_dir.as_ref() + .map(|s| Path::new(s)) + .filter(|p| p.exists()); + + let mut total_size: u64 = 0; + let mut symbols = Vec::new(); + + if let Some(dir) = cache_dir { + for entry in walkdir::WalkDir::new(dir).max_depth(2) { + if let Ok(entry) = entry { + if entry.file_type().is_dir() { + if let Some(name) = entry.file_name().to_str() { + symbols.push(name.to_string()); + } + } else { + if let Ok(meta) = entry.metadata() { + total_size += meta.len(); + } + } + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "cache_dir": cache_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(), + "total_bytes": total_size, + "symbols": symbols + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_clean_cache(config: &Config, args: &Value) -> Result { + let _symbol = args.get("symbol").and_then(|v| v.as_str()); + let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false); + + let cache_dir = config.tester_cache_dir.as_ref() + .map(|s| Path::new(s)) + .filter(|p| p.exists()); + + let mut bytes_freed: u64 = 0; + + if let Some(dir) = cache_dir { + for entry in walkdir::WalkDir::new(dir) { + if let Ok(entry) = entry { + let path = entry.path(); + if path.extension().map(|e| e == "tst").unwrap_or(false) { + if let Ok(meta) = entry.metadata() { + bytes_freed += meta.len(); + if !dry_run { + let _ = fs::remove_file(path); + } + } + } + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "bytes_freed": bytes_freed, + "dry_run": dry_run + }).to_string() }], + "isError": false + })) +} diff --git a/src/tools/handlers/experts.rs b/src/tools/handlers/experts.rs new file mode 100644 index 0000000..d15d5d0 --- /dev/null +++ b/src/tools/handlers/experts.rs @@ -0,0 +1,193 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use crate::compile::MqlCompiler; +use crate::models::Config; + +pub async fn handle_list_experts(config: &Config, args: &Value) -> Result { + let filter = args.get("filter").and_then(|v| v.as_str()); + + let mut experts = Vec::new(); + + if let Some(experts_dir) = &config.experts_dir { + if let Ok(entries) = fs::read_dir(experts_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if let Some(name) = path.file_stem() { + let name_str = name.to_string_lossy().to_string(); + let is_compiled = path.extension() + .map(|e| e == "ex5") + .unwrap_or(false); + + if let Some(filter_str) = filter { + if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) { + continue; + } + } + + experts.push(json!({ + "name": name_str, + "compiled": is_compiled, + "path": path.to_string_lossy().to_string(), + })); + } + } + } + } + + experts.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "count": experts.len(), + "experts": experts, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_list_indicators(config: &Config, args: &Value) -> Result { + let filter = args.get("filter").and_then(|v| v.as_str()); + let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false); + + let mut indicators = Vec::new(); + + // List custom indicators + if let Some(indicators_dir) = &config.indicators_dir { + if let Ok(entries) = fs::read_dir(indicators_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if let Some(name) = path.file_stem() { + let name_str = name.to_string_lossy().to_string(); + let is_compiled = path.extension() + .map(|e| e == "ex5") + .unwrap_or(false); + + if let Some(filter_str) = filter { + if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) { + continue; + } + } + + indicators.push(json!({ + "name": name_str, + "compiled": is_compiled, + "type": "custom", + "path": path.to_string_lossy().to_string(), + })); + } + } + } + } + + // Add built-in indicators if requested + if include_builtin { + let builtin = vec![ + "Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR", + "Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force", + "Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum", + "OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR", + ]; + for name in builtin { + if filter.map(|f| name.to_lowercase().contains(&f.to_lowercase())).unwrap_or(true) { + indicators.push(json!({ + "name": name, + "compiled": true, + "type": "builtin", + "path": null, + })); + } + } + } + + indicators.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "count": indicators.len(), + "indicators": indicators, + "custom_dir": config.indicators_dir.clone(), + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_list_scripts(config: &Config, args: &Value) -> Result { + let filter = args.get("filter").and_then(|v| v.as_str()); + + let mut scripts = Vec::new(); + + if let Some(scripts_dir) = &config.scripts_dir { + if let Ok(entries) = fs::read_dir(scripts_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if let Some(name) = path.file_stem() { + let name_str = name.to_string_lossy().to_string(); + let is_compiled = path.extension() + .map(|e| e == "ex5") + .unwrap_or(false); + + if let Some(filter_str) = filter { + if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) { + continue; + } + } + + scripts.push(json!({ + "name": name_str, + "compiled": is_compiled, + "path": path.to_string_lossy().to_string(), + })); + } + } + } + } + + scripts.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "count": scripts.len(), + "scripts": scripts, + "scripts_dir": config.scripts_dir.clone(), + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result { + let expert_path = args.get("expert_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("expert_path is required"))?; + + let compiler = MqlCompiler::new(config.clone()); + + match compiler.compile(expert_path) { + Ok(result) => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": result.success, + "binary_path": result.ex5_path.map(|p| p.to_string_lossy().to_string()), + "binary_size_bytes": result.binary_size, + "warnings": result.warnings.len(), + "errors": result.errors.len(), + "error_list": result.errors, + }).to_string() }], + "isError": !result.success + })) + } + Err(e) => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Compilation failed: {}", e), + }).to_string() }], + "isError": true + })) + } + } +} diff --git a/src/tools/handlers/mod.rs b/src/tools/handlers/mod.rs new file mode 100644 index 0000000..6fe1f9e --- /dev/null +++ b/src/tools/handlers/mod.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use chrono::Datelike; +use serde_json::{json, Value}; +use std::path::Path; +use crate::models::Config; + +mod system; +mod experts; +mod backtest; +mod optimization; +mod analysis; +mod setfiles; +mod reports; + +#[derive(Debug)] +pub struct ToolHandler { + pub config: Config, +} + +impl ToolHandler { + pub fn new(config: Config) -> Self { + Self { config } + } + + pub async fn handle(&self, name: &str, args: &Value) -> Result { + match name { + // System handlers + "verify_setup" => system::handle_verify_setup(&self.config).await, + "list_symbols" => system::handle_list_symbols(&self.config).await, + "healthcheck" => system::handle_healthcheck(&self.config, args).await, + + // Expert/Indicator/Script handlers + "list_experts" => experts::handle_list_experts(&self.config, args).await, + "list_indicators" => experts::handle_list_indicators(&self.config, args).await, + "list_scripts" => experts::handle_list_scripts(&self.config, args).await, + "compile_ea" => experts::handle_compile_ea(&self.config, args).await, + + // Backtest handlers + "run_backtest" => backtest::handle_run_backtest(&self.config, args).await, + "get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await, + "cache_status" => backtest::handle_cache_status(&self.config).await, + "clean_cache" => backtest::handle_clean_cache(&self.config, args).await, + + // Optimization handlers + "run_optimization" => optimization::handle_run_optimization(&self.config, args).await, + "get_optimization_status" => optimization::handle_get_optimization_status(&self.config, args).await, + "get_optimization_results" => optimization::handle_get_optimization_results(&self.config, args).await, + "list_jobs" => optimization::handle_list_jobs(&self.config).await, + + // Analysis handlers + "analyze_report" => analysis::handle_analyze_report(&self.config, args).await, + "compare_baseline" => analysis::handle_compare_baseline(&self.config, args).await, + + // Set file handlers + "read_set_file" => setfiles::handle_read_set_file(args).await, + "write_set_file" => setfiles::handle_write_set_file(args).await, + "patch_set_file" => setfiles::handle_patch_set_file(args).await, + "clone_set_file" => setfiles::handle_clone_set_file(args).await, + "diff_set_files" => setfiles::handle_diff_set_files(args).await, + "set_from_optimization" => setfiles::handle_set_from_optimization(args).await, + "describe_sweep" => setfiles::handle_describe_sweep(args).await, + "list_set_files" => setfiles::handle_list_set_files(&self.config).await, + + // Report handlers + "list_reports" => reports::handle_list_reports(args).await, + "search_reports" => reports::handle_search_reports(args).await, + "prune_reports" => reports::handle_prune_reports(&self.config, args).await, + "tail_log" => reports::handle_tail_log(&self.config, args).await, + "archive_report" => reports::handle_archive_report(&self.config, args).await, + "archive_all_reports" => reports::handle_archive_all_reports(&self.config, args).await, + "promote_to_baseline" => reports::handle_promote_to_baseline(&self.config, args).await, + "get_history" => reports::handle_get_history(args).await, + "annotate_history" => reports::handle_annotate_history(args).await, + + _ => Ok(json!({ + "content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }], + "isError": true + })), + } + } +} + +// Helper functions used across modules +pub(crate) fn dir_size(path: &Path) -> u64 { + if !path.exists() { + return 0; + } + walkdir::WalkDir::new(path) + .into_iter() + .filter_map(|e| e.ok()) + .filter_map(|e| e.metadata().ok()) + .filter(|m| m.is_file()) + .map(|m| m.len()) + .sum() +} + +pub(crate) fn past_complete_month() -> (String, String) { + let now = chrono::Utc::now(); + let today = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1) + .unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); + let last_of_prev = today.pred_opt() + .unwrap_or(today); + let first_of_prev = chrono::NaiveDate::from_ymd_opt(last_of_prev.year(), last_of_prev.month(), 1) + .unwrap_or(last_of_prev); + ( + first_of_prev.format("%Y.%m.%d").to_string(), + last_of_prev.format("%Y.%m.%d").to_string(), + ) +} diff --git a/src/tools/handlers/optimization.rs b/src/tools/handlers/optimization.rs new file mode 100644 index 0000000..f7d5499 --- /dev/null +++ b/src/tools/handlers/optimization.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use crate::models::Config; +use crate::optimization::{OptimizationParams, OptimizationParser, OptimizationRunner}; + +pub async fn handle_run_optimization(config: &Config, args: &Value) -> Result { + let expert = args.get("expert") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("expert is required"))?; + + let set_file = args.get("set_file") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("set_file 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"))?; + + let params = OptimizationParams { + expert: expert.to_string(), + set_file: set_file.to_string(), + symbol: args.get("symbol").and_then(|v| v.as_str()).unwrap_or("XAUUSD").to_string(), + from_date: from_date.to_string(), + to_date: to_date.to_string(), + 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, + currency: args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD").to_string(), + }; + + let runner = OptimizationRunner::new(config.clone()); + let result = runner.run(params).await?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": result.success, + "job_id": result.job_id, + "pid": result.pid, + "log_file": result.log_file.to_string_lossy(), + "combinations": result.combinations, + "message": result.message, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_get_optimization_status(config: &Config, args: &Value) -> Result { + let job_id = args.get("job_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("job_id is required"))?; + + let runner = OptimizationRunner::new(config.clone()); + let status = runner.get_job_status(job_id)?; + + Ok(json!({ + "content": [{ "type": "text", "text": status.to_string() }], + "isError": false + })) +} + +pub async fn handle_get_optimization_results(_config: &Config, args: &Value) -> Result { + let job_id = args.get("job_id") + .and_then(|v| v.as_str()); + + let file = args.get("file") + .and_then(|v| v.as_str()); + + let parser = OptimizationParser::new(); + + let passes = if let Some(jid) = job_id { + parser.parse_job(jid)? + } else if let Some(f) = file { + parser.parse_file(std::path::Path::new(f))? + } else { + return Err(anyhow::anyhow!("Either job_id or file is required")); + }; + + let sort_by = args.get("sort").and_then(|v| v.as_str()).unwrap_or("profit"); + let top_n = args.get("top").and_then(|v| v.as_u64()).unwrap_or(30) as usize; + + let best = parser.find_best_pass(&passes, sort_by); + + let mut sorted_passes = passes.clone(); + sorted_passes.sort_by(|a, b| b.profit.partial_cmp(&a.profit).unwrap()); + sorted_passes.truncate(top_n); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "total_passes": passes.len(), + "top_passes": sorted_passes, + "best": best, + "sort_by": sort_by, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_list_jobs(config: &Config) -> Result { + let runner = OptimizationRunner::new(config.clone()); + let jobs = runner.list_jobs()?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ "jobs": jobs }).to_string() }], + "isError": false + })) +} diff --git a/src/tools/handlers/reports.rs b/src/tools/handlers/reports.rs new file mode 100644 index 0000000..8be942c --- /dev/null +++ b/src/tools/handlers/reports.rs @@ -0,0 +1,350 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use crate::models::Config; +use crate::storage::{ReportDb, ReportFilters}; + +pub async fn handle_list_reports(args: &Value) -> Result { + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(30) as usize; + + let db = ReportDb::new(&Config::db_path()); + if let Err(e) = db.init() { + return Ok(json!({ + "content": [{ "type": "text", "text": format!("DB error: {}", e) }], + "isError": true + })); + } + + let filters = ReportFilters::default(); + let entries = db.list(limit, &filters)?; + let total = db.count().unwrap_or(0); + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "charts_dir": e.charts_dir, + "report_dir": e.report_dir, + "verdict": e.verdict, + "tags": e.tags, + "notes": e.notes, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "total": total, + "returned": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_search_reports(args: &Value) -> Result { + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let db = ReportDb::new(&Config::db_path()); + db.init()?; + + let filters = ReportFilters { + expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()), + symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()), + timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()), + created_after: args.get("after").and_then(|v| v.as_str()).map(|s| s.to_string()), + min_profit: args.get("min_profit").and_then(|v| v.as_f64()), + max_dd: args.get("max_dd").and_then(|v| v.as_f64()), + verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), + }; + + let entries = db.list(limit, &filters)?; + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "set_snapshot": e.set_snapshot_path, + "charts_dir": e.charts_dir, + "report_dir": e.report_dir, + "verdict": e.verdict, + "tags": e.tags, + "notes": e.notes, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "matched": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_prune_reports(_config: &Config, args: &Value) -> Result { + let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false); + + let db = ReportDb::new(&Config::db_path()); + db.init()?; + + let purgeable = db.list_purgeable(keep_last)?; + let mut pruned = 0; + let mut freed_bytes: u64 = 0; + + for (id, report_dir, charts_dir) in &purgeable { + if !dry_run { + freed_bytes += super::dir_size(Path::new(report_dir)); + let _ = fs::remove_dir_all(report_dir); + + if let Some(cd) = charts_dir { + freed_bytes += super::dir_size(Path::new(cd)); + let _ = fs::remove_dir_all(cd); + } + + let _ = db.delete_entry(id); + pruned += 1; + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "pruned": pruned, + "would_prune": purgeable.len(), + "kept": keep_last, + "freed_bytes": freed_bytes, + "dry_run": dry_run, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_tail_log(_config: &Config, args: &Value) -> Result { + let job_id = args.get("job_id").and_then(|v| v.as_str()); + + 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 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()) + } else { + args.get("file").and_then(|v| v.as_str()).map(|s| s.to_string()) + }; + + let log_path = log_path.ok_or_else(|| anyhow::anyhow!("Could not determine log file"))?; + + let content = fs::read_to_string(&log_path)?; + let all_lines: Vec<&str> = content.lines().collect(); + let start = all_lines.len().saturating_sub(lines); + let last_lines = &all_lines[start..]; + + Ok(json!({ + "content": [{ "type": "text", "text": last_lines.join("\n") }], + "isError": false + })) +} + +pub async fn handle_archive_report(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let delete_after = args.get("delete_after").and_then(|v| v.as_bool()).unwrap_or(false); + + let history_dir = Path::new(".mt5mcp_history"); + fs::create_dir_all(history_dir)?; + + let report_name = Path::new(report_dir).file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let archive_path = history_dir.join(format!("{}.tar.gz", report_name)); + + let status = std::process::Command::new("tar") + .args(["-czf", &archive_path.to_string_lossy(), "-C", + Path::new(report_dir).parent().unwrap().to_str().unwrap(), report_name]) + .status()?; + + if delete_after && status.success() { + fs::remove_dir_all(report_dir)?; + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": status.success(), + "archive_path": archive_path.to_string_lossy(), + "deleted": delete_after && status.success(), + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_archive_all_reports(config: &Config, args: &Value) -> Result { + let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + + let reports_dir = config.reports_dir(); + let history_dir = Path::new(".mt5mcp_history"); + fs::create_dir_all(history_dir)?; + + let mut archived = 0; + + if let Ok(entries) = fs::read_dir(&reports_dir) { + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by(|a, b| { + b.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH) + .cmp(&a.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH)) + }); + + for entry in entries.into_iter().skip(keep_last) { + let path = entry.path(); + if path.is_dir() && !path.to_string_lossy().ends_with("_opt") { + let report_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("unknown"); + let archive_path = history_dir.join(format!("{}.tar.gz", report_name)); + + let _ = std::process::Command::new("tar") + .args(["-czf", &archive_path.to_string_lossy(), "-C", + path.parent().unwrap().to_str().unwrap(), report_name]) + .status(); + + let _ = fs::remove_dir_all(&path); + archived += 1; + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "archived": archived, + "kept": keep_last, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_promote_to_baseline(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let metrics_path = Path::new(report_dir).join("metrics.json"); + let baseline_path = Path::new("config/baseline.json"); + + fs::copy(&metrics_path, &baseline_path)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "baseline_file": baseline_path.to_string_lossy(), + "source": metrics_path.to_string_lossy(), + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_get_history(args: &Value) -> Result { + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let db = ReportDb::new(&Config::db_path()); + db.init()?; + + let filters = ReportFilters { + expert: args.get("ea").and_then(|v| v.as_str()).map(|s| s.to_string()), + symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()), + verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), + ..Default::default() + }; + + let entries = db.list(limit, &filters)?; + let total = db.count().unwrap_or(0); + + let history: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "set_file": e.set_file_original, + "set_snapshot": e.set_snapshot_path, + "charts_dir": e.charts_dir, + "verdict": e.verdict, + "tags": e.tags, + "notes": e.notes, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "total": total, + "returned": history.len(), + "history": history, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_annotate_history(args: &Value) -> Result { + let report_id = args + .get("history_id") + .or_else(|| args.get("report_name")) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("history_id is required"))?; + + let notes = args.get("notes").and_then(|v| v.as_str()); + let verdict = args.get("verdict").and_then(|v| v.as_str()); + let tags: Option> = args + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()); + + let db = ReportDb::new(&Config::db_path()); + db.init()?; + + let updated = db.annotate(report_id, notes, tags, verdict)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": updated, + "id": report_id, + "notes": notes, + "verdict": verdict, + }).to_string() }], + "isError": false + })) +} diff --git a/src/tools/handlers/setfiles.rs b/src/tools/handlers/setfiles.rs new file mode 100644 index 0000000..9d33574 --- /dev/null +++ b/src/tools/handlers/setfiles.rs @@ -0,0 +1,289 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use crate::models::Config; + +pub async fn handle_read_set_file(args: &Value) -> Result { + 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 params = serde_json::Map::new(); + + for line in content.lines() { + if let Some((key, value)) = line.split_once(':') { + let key = key.trim(); + let value = value.trim(); + + if value.contains("||Y") { + let parts: Vec<&str> = value.split("||").collect(); + if parts.len() >= 5 { + params.insert(key.to_string(), json!({ + "value": parts[0], + "from": parts[1], + "step": parts[2], + "to": parts[3], + "optimize": true, + })); + } + } else { + params.insert(key.to_string(), json!({ "value": value, "optimize": false })); + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "path": path, + "parameters": params, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_write_set_file(args: &Value) -> Result { + let path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("path is required"))?; + + let params = args.get("parameters") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow::anyhow!("parameters object is required"))?; + + let mut lines = Vec::new(); + for (key, value) in params { + if let Some(obj) = value.as_object() { + if obj.get("optimize").and_then(|v| v.as_bool()).unwrap_or(false) { + let from_val = obj.get("from").and_then(|v| v.as_str()).unwrap_or("0"); + let step = obj.get("step").and_then(|v| v.as_str()).unwrap_or("1"); + let to_val = obj.get("to").and_then(|v| v.as_str()).unwrap_or("0"); + lines.push(format!("{}={}||{}||{}||{}||Y", key, obj.get("value").and_then(|v| v.as_str()).unwrap_or("0"), from_val, step, to_val)); + } else { + lines.push(format!("{}={}", key, obj.get("value").and_then(|v| v.as_str()).unwrap_or("0"))); + } + } + } + + fs::write(path, lines.join("\n"))?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "path": path, + "parameters_written": lines.len(), + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_patch_set_file(args: &Value) -> Result { + let path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("path is required"))?; + + let patches = args.get("patches") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow::anyhow!("patches object is required"))?; + + let content = fs::read_to_string(path)?; + let mut lines: Vec = content.lines().map(|s| s.to_string()).collect(); + let mut patched_count = 0; + + for (key, value) in patches { + let new_value = if let Some(s) = value.as_str() { + s.to_string() + } else if let Some(n) = value.as_f64() { + n.to_string() + } else if let Some(b) = value.as_bool() { + if b { "true".to_string() } else { "false".to_string() } + } else { + value.to_string() + }; + + let mut found = false; + for line in &mut lines { + if line.starts_with(&format!("{}:", key)) { + *line = format!("{}: {}", key, new_value); + found = true; + patched_count += 1; + break; + } else if line.starts_with(&format!("{}=", key)) { + *line = format!("{}={}", key, new_value); + found = true; + patched_count += 1; + break; + } + } + + if !found { + lines.push(format!("{}: {}", key, new_value)); + patched_count += 1; + } + } + + fs::write(path, lines.join("\n"))?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "path": path, + "parameters_patched": patched_count, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_clone_set_file(args: &Value) -> Result { + let source = args.get("source") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("source is required"))?; + + let destination = args.get("destination") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("destination is required"))?; + + fs::copy(source, destination)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "source": source, + "destination": destination, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_diff_set_files(args: &Value) -> Result { + let file_a = args.get("file_a") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("file_a is required"))?; + + let file_b = args.get("file_b") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("file_b is required"))?; + + let content_a = fs::read_to_string(file_a)?; + let content_b = fs::read_to_string(file_b)?; + + let mut differences = Vec::new(); + + for (i, (line_a, line_b)) in content_a.lines().zip(content_b.lines()).enumerate() { + if line_a != line_b { + differences.push(json!({ + "line": i + 1, + "file_a": line_a, + "file_b": line_b, + })); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "file_a": file_a, + "file_b": file_b, + "differences": differences, + "total_differences": differences.len(), + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_set_from_optimization(args: &Value) -> Result { + let path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("path is required"))?; + + let params = args.get("params") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow::anyhow!("params is required"))?; + + let mut lines = Vec::new(); + for (key, value) in params { + if let Some(val_str) = value.as_str() { + lines.push(format!("{}={}", key, val_str)); + } + } + + fs::write(path, lines.join("\n"))?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "path": path, + "parameters_written": lines.len(), + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_describe_sweep(args: &Value) -> Result { + 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 sweep_params = serde_json::Map::new(); + + for line in content.lines() { + if let Some((key, value)) = line.split_once(':') { + let key = key.trim(); + let value = value.trim(); + + if value.contains("||Y") { + if let Some((from_val, to_val)) = value.split_once("..") { + sweep_params.insert(key.to_string(), json!({ + "from": from_val.trim(), + "to": to_val.trim().replace("||Y", ""), + "step": 1.0 + })); + } + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "path": path, + "sweep_params": sweep_params + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_list_set_files(config: &Config) -> Result { + let mut set_files = Vec::new(); + + if let Some(tester_dir) = &config.tester_profiles_dir { + if let Ok(entries) = fs::read_dir(tester_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "set").unwrap_or(false) { + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let content = fs::read_to_string(&path).unwrap_or_default(); + let param_count = content.lines().filter(|l| l.contains(':')).count(); + let sweep_count = content.lines().filter(|l| l.contains("||Y")).count(); + + set_files.push(json!({ + "name": name, + "path": path.to_string_lossy(), + "param_count": param_count, + "sweep_count": sweep_count + })); + } + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ "set_files": set_files }).to_string() }], + "isError": false + })) +} diff --git a/src/tools/handlers/system.rs b/src/tools/handlers/system.rs new file mode 100644 index 0000000..2a8cbe9 --- /dev/null +++ b/src/tools/handlers/system.rs @@ -0,0 +1,268 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use std::path::Path; +use crate::models::Config; + +pub async fn handle_verify_setup(config: &Config) -> Result { + let mut checks = serde_json::Map::new(); + let mut all_ok = true; + + let config_path = Config::writable_config_path(); + checks.insert("config_file".into(), json!({ + "ok": config_path.exists(), + "path": config_path.to_string_lossy() + })); + + let check = |v: &Option, is_dir: bool| -> Value { + match v { + None => json!({ "ok": false, "detail": "not set" }), + Some(p) => { + let ok = if is_dir { Path::new(p).is_dir() } else { Path::new(p).exists() }; + json!({ "ok": ok, "detail": p }) + } + } + }; + + let wine_ok = config.wine_executable.as_ref() + .map(|p| Path::new(p).exists()).unwrap_or(false); + let term_ok = config.terminal_dir.as_ref() + .map(|p| Path::new(p).is_dir()).unwrap_or(false); + + if !wine_ok || !term_ok { all_ok = false; } + + checks.insert("wine_executable".into(), check(&config.wine_executable, false)); + checks.insert("terminal_dir".into(), check(&config.terminal_dir, true)); + checks.insert("experts_dir".into(), check(&config.experts_dir, true)); + checks.insert("indicators_dir".into(), check(&config.indicators_dir, true)); + checks.insert("scripts_dir".into(), check(&config.scripts_dir, true)); + checks.insert("tester_profiles_dir".into(), check(&config.tester_profiles_dir, true)); + checks.insert("display_mode".into(), json!(config.display_mode)); + checks.insert("reports_dir".into(), json!(config.reports_dir().to_string_lossy().to_string())); + checks.insert("db_path".into(), json!(Config::db_path().to_string_lossy().to_string())); + + let hint = if all_ok { + "Environment fully configured and ready".into() + } else if !config_path.exists() { + format!("Auto-discovery will run on next request. Config will be written to {}", config_path.display()) + } else { + format!("Fix missing paths in {}", config_path.display()) + }; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "all_ok": all_ok, + "config_path": config_path.to_string_lossy(), + "checks": checks, + "hint": hint, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_list_symbols(config: &Config) -> Result { + let symbols = config.discover_symbols(); + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "count": symbols.len(), + "symbols": symbols, + "hint": if symbols.is_empty() { + "No history data found. Open MT5 and download tick data for the symbols you want to backtest." + } else { + "These symbols have local tick history and can be used for backtesting." + } + }).to_string() }], + "isError": false + })) +} + +// OS Detection structs and healthcheck +#[derive(Debug)] +struct OsInfo { + platform: String, + arch: String, + name: String, + is_macos: bool, + is_linux: bool, +} + +#[derive(Debug)] +struct ConfigStatus { + config_exists: bool, + config_path: String, + wine_found: bool, + wine_path: Option, + mt5_dir_found: bool, + mt5_dir: Option, + experts_dir_found: bool, + indicators_dir_found: bool, + scripts_dir_found: bool, + tester_profiles_found: bool, +} + +pub async fn handle_healthcheck(config: &Config, args: &Value) -> Result { + let detailed = args.get("detailed").and_then(|v| v.as_bool()).unwrap_or(false); + + let os_info = detect_os(); + let config_status = validate_configuration(config).await; + + let mut healthy = true; + let mut issues = Vec::new(); + + if !config_status.config_exists { + healthy = false; + issues.push("Configuration file not found - run setup to configure"); + } + if !config_status.wine_found { + healthy = false; + issues.push("Wine/CrossOver not found - required for MT5 execution"); + } + if !config_status.mt5_dir_found { + healthy = false; + issues.push("MT5 directory not found - check installation"); + } + + let mut response = json!({ + "success": true, + "healthy": healthy, + "timestamp": chrono::Utc::now().to_rfc3339(), + "os": { + "platform": os_info.platform, + "arch": os_info.arch, + "name": os_info.name, + "is_macos": os_info.is_macos, + "is_linux": os_info.is_linux, + }, + "configuration": { + "config_exists": config_status.config_exists, + "config_path": config_status.config_path, + "wine_found": config_status.wine_found, + "wine_path": config_status.wine_path, + "mt5_dir_found": config_status.mt5_dir_found, + "mt5_dir": config_status.mt5_dir, + "experts_dir_found": config_status.experts_dir_found, + "indicators_dir_found": config_status.indicators_dir_found, + "scripts_dir_found": config_status.scripts_dir_found, + "tester_profiles_found": config_status.tester_profiles_found, + }, + "issues": issues, + }); + + if detailed { + response["detailed"] = json!({ + "rust_version": get_rust_version(), + "exe_path": std::env::current_exe() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()), + "working_dir": std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()), + "env_vars": { + "DISPLAY": std::env::var("DISPLAY").ok(), + "WINEPREFIX": std::env::var("WINEPREFIX").ok(), + "HOME": std::env::var("HOME").ok(), + }, + }); + } + + Ok(json!({ + "content": [{ "type": "text", "text": response.to_string() }], + "isError": false + })) +} + +fn detect_os() -> OsInfo { + let platform = std::env::consts::OS.to_string(); + let arch = std::env::consts::ARCH.to_string(); + + let is_macos = platform == "macos"; + let is_linux = platform == "linux"; + + let name = if is_macos { + get_macos_version().unwrap_or_else(|| "macOS".to_string()) + } else if is_linux { + get_linux_distro().unwrap_or_else(|| "Linux".to_string()) + } else { + platform.clone() + }; + + OsInfo { + platform, + arch, + name, + is_macos, + is_linux, + } +} + +fn get_macos_version() -> Option { + std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| format!("macOS {}", s.trim())) +} + +fn get_linux_distro() -> Option { + std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|content| { + content.lines() + .find(|l| l.starts_with("PRETTY_NAME=")) + .map(|l| l.replace("PRETTY_NAME=", "").trim_matches('"').to_string()) + }) +} + +fn get_rust_version() -> Option { + std::process::Command::new("rustc") + .arg("--version") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} + +async fn validate_configuration(config: &Config) -> ConfigStatus { + let config_path = Config::writable_config_path(); + let config_exists = config_path.exists(); + + let wine_found = config.wine_executable.as_ref() + .map(|p| Path::new(p).exists()) + .unwrap_or(false); + let wine_path = config.wine_executable.clone(); + + let mt5_dir_found = config.terminal_dir.as_ref() + .map(|p| Path::new(p).is_dir()) + .unwrap_or(false); + let mt5_dir = config.terminal_dir.clone(); + + let experts_dir_found = config.experts_dir.as_ref() + .map(|p| Path::new(p).is_dir()) + .unwrap_or(false); + + let indicators_dir_found = config.indicators_dir.as_ref() + .map(|p| Path::new(p).is_dir()) + .unwrap_or(false); + + let scripts_dir_found = config.scripts_dir.as_ref() + .map(|p| Path::new(p).is_dir()) + .unwrap_or(false); + + let tester_profiles_found = config.tester_profiles_dir.as_ref() + .map(|p| Path::new(p).is_dir()) + .unwrap_or(false); + + ConfigStatus { + config_exists, + config_path: config_path.to_string_lossy().to_string(), + wine_found, + wine_path, + mt5_dir_found, + mt5_dir, + experts_dir_found, + indicators_dir_found, + scripts_dir_found, + tester_profiles_found, + } +}